diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 4efa4fcf..58532845 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -165,6 +165,12 @@ jobs: BUSYMARK_TYPST_PATH: ${{ github.workspace }}/build/linux/x64/release/bundle/libexec/busymark/typst run: flutter test + - name: Exercise demand-driven native visualization lifecycle + run: | + xvfb-run -a -s '-screen 0 1280x1024x24' \ + env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 NO_AT_BRIDGE=1 \ + bash tools/test_web_render_host.sh + - name: Run real WebKit, PlantUML, Mermaid, OpenAPI, and D2 smoke tests run: | xvfb-run -a -s '-screen 0 1280x1024x24' \ diff --git a/.gitignore b/.gitignore index 9099c2b2..413b1288 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ migrate_working_dir/ /delivery/ /test-results/ /evidence/ +/docs/development/evidence/ +/docs/development/element-properties*-acceptance.md +/docs/development/nextcloud-notes-milestone-*.md .widget_preview/ **/node_modules/ linux/flutter/ephemeral/ diff --git a/README.md b/README.md index 7b710201..28f8ad60 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,13 @@ Markdown, Writerside, and Nextcloud Notes editor for Linux. [![busymark](https://snapcraft.io/busymark/badge.svg)](https://snapcraft.io/busymark) -[![Get it from the Snap Store](https://snapcraft.io/en/dark/install.svg)](https://snapcraft.io/busymark) - + + Get it from the Snap Store +

BusyMark split source and reading view

@@ -15,6 +20,7 @@ Markdown, Writerside, and Nextcloud Notes editor for Linux. * **Markdown editing** — Source, Editor, Reading, and Split views with formatting tools, syntax highlighting, code folding, tables, images, links, code blocks, callouts, collapsible content, and document diagnostics. * **Writerside projects** — Open and create Writerside-compatible projects; edit Markdown and XML topics; manage instances, tables of contents, reusable TOC libraries, and project structure. * **Nextcloud Notes** — Connect to a Nextcloud server, create and edit notes offline, synchronize changes, search and filter notes, manage categories and favorites, and work with attachments. +* **References and links** — Incoming/outgoing references, document/topic/heading target chooser, reviewed local Markdown rename/move, and portable Nextcloud note URLs. See [reference workflows](docs/references.md). * **Navigation and search** — Files, Topics, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed replacement across local workspaces, and Markdown TOC generation. * **Technical documentation** — Local rendering of Mermaid, PlantUML, D2, fenced OpenAPI specifications, and MathJax mathematical expressions. * **PDF and HTML publishing** — Export Markdown documents and Writerside instances to configurable PDF or portable offline HTML, with controls for layout, typography, tables of contents, heading numbering, and HTML styling. @@ -97,6 +103,7 @@ the current `snap/snapcraft.yaml` in a clean Ubuntu 24.04 amd64 Snapcraft build environment. The authoritative build sequence is: ```bash +export SNAPCRAFT_BUILD_INFO=1 snapcraft --version snapcraft expand-extensions snapcraft clean @@ -108,6 +115,7 @@ Inspect and test the exact file emitted by the final `snapcraft` command. A failed build must not fall back to an older `.snap`. The `snap` job in [the Linux workflow](.github/workflows/flutter-linux.yml) follows this route, installs that selected artifact, and runs the strict-confinement smoke checks. +Build information embeds the original recipe for the workflow's identity check. `tools/build_install_snap_local.sh` is intentionally different: it replaces the Flutter payload in an installed Snap scaffold for quick development and @@ -203,11 +211,15 @@ Once connected, select **Nextcloud Notes** on the welcome screen to reopen the w ### Work with notes * **Create and edit:** Create notes from the Notes sidebar and edit them using the normal Markdown editor. Use each note’s menu to change its title, category, or favorite status, save a separate local copy, or delete the note. -* **Find and organize:** Search notes by title, content, or category; filter by favorites or category; and see synchronization status in the note list and editor. -* **Work offline:** Edits and new notes are saved to local storage before they are synchronized. Pending changes can survive an application restart. Synchronization runs when a connection is available; the sidebar’s **Refresh** control requests another pass. +* **Find and organize:** Use All notes, Favorites, Uncategorized, Recovery, or a category and its descendants. Sort by activity or title. Ctrl-click toggles selection, Shift-click selects a range, and Ctrl+A selects the current list; the selected-notes action reviews batch favorite/category changes and reports partial outcomes. +* **Quick Open and search:** Ctrl+P finds titles/categories, including unopened cached notes; Ctrl+Shift+P opens the command palette. Notes search combines terms with AND and supports quoted phrases, `title:` / `category:` restrictions, whole words, snippets, and navigation to the match. Unsaved open text is included without forcing a save. +* **Work offline:** Edits and new notes are saved locally before synchronization and survive restart. **Make available offline** retains a note or category/subtree's supported managed media. Its status lists available/missing bytes and offers deliberate retry/cancellation. Offline availability, freshness, and acknowledged synchronization are separate states; external dependencies remain identified. * **Review conflicts:** If a note changes on the server while you have local edits, BusyMark preserves the competing versions for review instead of silently overwriting one. The comparison workflow supports choosing a version, merging changes, or recovering content as a separate note. Ambiguous note-creation results also require explicit review. * **Use attachments:** Add files to notes, view supported images and media, and include managed resources when exporting Markdown or HTML. PDF export includes supported images but does not embed arbitrary file attachments. New uploads are limited to **100 MiB per file**. -* **Export or recover:** Export individual notes to PDF or offline HTML, use **Save As** to create an independent local Markdown copy, and restore earlier content through Local History. +* **Export and import:** Keep per-note **Save local copy**, PDF, and offline HTML exports, or export a category/subtree or account as ordinary Markdown with companion media and a versioned metadata manifest. Dirty buffers must save first; missing media is listed explicitly. Import Markdown files/folders or these snapshots through a review of categories, media, and collisions. Imports create distinct notes and resume successful local items after interruption. +* **Recover:** Recovery lists retained deleted notes and available Local History revisions, previews their content/media availability, and recovers as a new note. It uses local retention settings; Nextcloud server trash/history is unavailable. + +See the [Notes workspace guide](docs/development/nextcloud-notes.md#everyday-notes-workspace) for search syntax, offline requirements, recovery, and the portable snapshot format. ### Requirements and limitations @@ -263,6 +275,10 @@ Issues and focused pull requests are welcome. Changes should remain consistent with BusyMark's scope as a Linux desktop editor for Markdown, Writerside documentation, and Nextcloud Notes. +Keep temporary execution reports, test-run screenshots, agent session notes, and +acceptance diaries outside the repository; keep reusable tests, fixtures, tools, +and maintained documentation in it. + ## License BusyMark is licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/docs/README.md b/docs/README.md index af072f65..27fd8def 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ are intentionally independent so they can be referenced directly. - [Supported content and limitations](writerside-support.md) - [Instances and TOC libraries](writerside-instances.md) +- [Element Properties](element-properties.md): contextual Writerside properties, defaults, drafts, Source integration and navigation. - [Admonitions](admonitions.md) - [Collapsible elements](collapsible-elements.md) - [Videos](videos.md) diff --git a/docs/collapsible-elements.md b/docs/collapsible-elements.md index 29a4859c..a8f4e78b 100644 --- a/docs/collapsible-elements.md +++ b/docs/collapsible-elements.md @@ -61,8 +61,10 @@ independent disclosure item: ``` -Collapsible content is closed initially unless its element has -`default-state="expanded"`. BusyMark preserves the original source and passes -the same documented syntax to Writerside's official build pipeline. +Published state follows authored values, module defaults and the pinned builder's +element-specific rules. Inspect **Element Properties** to see authored overrides +and effective defaults; local editor expansion is separate and source-neutral. +See [Element Properties](element-properties.md) for state-only forms, explicit +false and definition-item inheritance verified against builder `2026.08.0328`. The syntax follows JetBrains' [Writerside collapsible-elements documentation](https://www.jetbrains.com/help/writerside/collapsible-elements.html). diff --git a/docs/development/nextcloud-notes-api-coverage.md b/docs/development/nextcloud-notes-api-coverage.md new file mode 100644 index 00000000..8280cb54 --- /dev/null +++ b/docs/development/nextcloud-notes-api-coverage.md @@ -0,0 +1,40 @@ +# Notes API coverage + +Contract: Notes API **1.4**, verified Notes **6.0.2** and **6.1.0**. Direct Notes +API integration, one account, schema **6**, existing outbox/coordinator retained. + +| Operation | Coverage | State boundary | +| --- | --- | --- | +| GET full `/api/v1/notes` | Chunk cursors, pruneBefore, collection If-None-Match, final ID-only entries | Only a completed authoritative collection advances checkpoints or infers deletion | +| GET `/api/v1/notes/{id}` | Complete 200 and conditional 304; quoted ETag; unconditional retry path when cache is absent | 304 reuses the captured server base, never acknowledges local edits/conflicts | +| GET filtered/reduced collection | Optional exact category and exclude parameters retained on every chunk; omitted fields represented by presence | Query-scoped projections, no full-account checkpoint/validator/deletion inference | +| GET reduced single note | Server-supported exclude projection; identity checked when included | Projection object, no fabricated NoteState defaults | +| POST/PUT/DELETE note | Existing durable outbox, optimistic conflict handling, uncertainty/retry evidence | Existing accepted synchronization strategy | +| GET/PUT settings | Existing conditional/concurrent settings handling and suffix normalization | Existing settings boundary | +| GET/POST attachment | Existing authenticated API 1.4 retrieval/upload and safe managed-reference publication | Existing ownership, generation and uncertainty protections | +| DELETE attachment | Notes 6.1.0 application capability gate retained | Existing unsupported-version explanation | +| Browser `/note/{id}` | Ordinary HTTPS web navigation, installation prefix, real server ID; public routing observation selects the configured pretty/front-controller form | Existing connected-account opening; cached offline or authenticated repository retrieval | + +A category filter is exact on the supported servers; it is not a descendant +category synchronization strategy. Exclude can omit ID; ID-only entries repeated +by the final server chunk are continuation evidence, not complete projected +notes. Optional reads do not feed the full-account synchronization checkpoint. +The tagged helper always adds ETag and some internal server fields; projections +retain present fields rather than promising that every excluded server field +will disappear. + +The public installation login page's explicit `modRewriteWorking` configuration +selects the web URL form. This anonymous, bounded request never sends account +credentials or follows redirects. A successful HTTP response from the other +form does not prove navigation: on a pretty-URL installation the Notes SPA can +open its most recent note instead. Browser acceptance verifies the intended +note ID and unique content. The optional cached route observation is stored in +the existing account JSON; schema 6 and synchronization checkpoints are unchanged. +BusyMark recognizes both pasted forms internally. Portable heading insertion +is disabled because the verified browser view does not expose BusyMark's heading IDs. + +Deterministic retrieval tests are in +`test/src/nextcloud_notes/notes_retrieval_test.dart`; authenticated fixture checks +are in `tools/nextcloud_notes_live_acceptance.dart`. See the +[verification instructions](nextcloud-notes.md#verification) for running them +against isolated HTTPS fixtures. diff --git a/docs/development/nextcloud-notes.md b/docs/development/nextcloud-notes.md index 63a8f337..051c285b 100644 --- a/docs/development/nextcloud-notes.md +++ b/docs/development/nextcloud-notes.md @@ -63,6 +63,24 @@ Unknown/malformed/prerelease app versions conservatively disable DELETE. An unsupported cleanup attempt explains the limitation before queuing any operation; notes and attachment upload/download remain available. +## Retrieval coverage + +[API coverage](nextcloud-notes-api-coverage.md) records the supported request +scopes. Single-note conditional retrieval returns a distinct not-modified result +with its complete cached server representation. Linked-note opening benefits +from this path; it does not add a synchronization poller. A 304 does not +acknowledge pending writes, clear conflicts, or replace dirty content. Cached +notes open offline and an uncached linked ID uses the existing authenticated +repository boundary. Account and base generations fence asynchronous replies. + +Optional category/projection queries retain their immutable request scope and +all pagination parameters. Missing fields remain absent, including an explicitly +excluded ID; reduced payloads never become complete NoteState objects. These +reads use no collection validator/checkpoint and never infer account deletions. +The authoritative full-account list strategy and local category/search workflows +remain unchanged. Portable note URLs and reference workflows are described in +[the user guide](../references.md). + ## Authentication and credential boundary Anonymous Login Flow v2 opens the returned URL with `url_launcher` in the default @@ -101,12 +119,16 @@ Parser labels are logical identifiers only. Remote workspaces install no disk watchers, discover no Git repository and expose no path/file-manager actions. `NotesStore` uses direct `sqlite3` 3.7.0 in a dedicated isolate under application -support storage. Schema version 2 includes accounts, notes, a coalesced per-note +support storage. Schema version 3 includes accounts, notes, a coalesced per-note outbox and attachment metadata/bytes. The v1 → v2 migration retains existing records and adds optional creation-attempt metadata in note/outbox JSON; the version fence prevents older clients from discarding it. Existing uncertain creations without that metadata remain unresolved. Migrations use transactions and reject -newer unsupported schemas. SQLite uses WAL, `synchronous=FULL`, foreign keys and +newer unsupported schemas. The v2 → v3 transaction fences new failure states, +retry deadlines/budgets, settings attempts, media validation and local activity. +Missing historical edit times remain missing; server modified time is the ordering +fallback. Ambiguous legacy creation histories require deliberate recovery. +SQLite uses WAL, `synchronous=FULL`, foreign keys and a busy timeout. Notes content, revision, base state, outbox and synchronization checkpoint commit atomically. The Notes directory is mode 0700 and database 0600 on Linux. Managed attachment paths reject traversal and symbolic links. @@ -125,11 +147,23 @@ copy in session/recovery JSON. A clean shutdown may retain a durable outbox. `NotesApiClient` takes an injectable HTTP client. `NotesRepository` serializes local mutations and runs at most one synchronization pass per account. The -controller requests a subsequent pass when another local save commits during -an upload. Transient network/server/lock failures receive six delayed retry -attempts (5, 10, 20, 40, 80, 160 seconds), then explicit Refresh remains available. -Conflicts, uncertain creation, authentication and forbidden errors require -deliberate resolution. +`NotesSyncCoordinator` routes opening, committed edits, manual Refresh and desktop +lifecycle events through that same mechanism. It coalesces one follow-up when +local work arrives during a pass; manual Refresh is awaitable. Visible active +workspaces check every 60 seconds, including authenticated read-side connectivity +recovery. Inactive-but-visible windows keep polling; hidden/minimized windows and +other screens suspend healthy polling. Focus/resume coalesces checks completed +within ten seconds. Timers/listeners are disposed and account generations fenced. +Transient writes receive six delayed retries (5, 10, 20, 40, 80, 160 seconds). +Healthy reads/focus never reset their durable exhausted budgets. Successful read-side +connectivity recovery releases only network failures. Valid Retry-After delta/date +deadlines are durable and never shortened. Conflicts, uncertain outcomes, +authentication, permissions, quota and permanent rejection require correction; +a relevant edit or deliberate Retry can release a rejected operation. Separate +last-successful-server-check time means a complete GET/304, not all writes/media +having synchronized. Local activity uses an injectable microsecond clock and +writable API modified uses Unix seconds; internal attachment publication retains +the human edit time. Read-only favorite writes still contain only favorite. List synchronization sends `If-None-Match`, the **previous server Last-Modified** as `pruneBefore`, `chunkSize` and the opaque chunk cursor. Header @@ -216,16 +250,22 @@ are rejected and repeated confirmation reuses that replacement. The original uncertain draft remains recoverable and blocked. Cancel and dismissal leave it untouched. No suspected server duplicate is automatically deleted. Legacy uncertain records without creation evidence cannot be adopted by guessing; -the existing database migrations and startup recovery remain unchanged. +schema migration and startup recovery preserve their uncertainty and work. Read-only notes block content/title/category editing while favorite remains writable under the documented contract. A note becoming read-only does not discard edits already captured locally. `error: true` responses carry unavailable state; their exception-text content never replaces known-good cached content. -Errors are classified as reconnect required (401), forbidden/read-only (403), -missing/deleted (404), conflict (412), locked (423), insufficient storage (507), -transport/offline, server errors and safely reported unknown responses. A locked +Errors carry operation scope as well as rejection/transient/uncertain classification. +Only individual-note absence or a completed authoritative ID set establishes note +deletion. Collection/settings/capability and attachment 404s preserve notes, +bases, revisions, outbox and bytes. Legacy poisoned server edits reconcile against +remote ETags, using the existing conflict workflow for divergence. Positive durable +never-sent evidence permits draft recovery; ambiguous old creation histories retain +work and require deliberate separate-note recovery. A 429 preserves a not-before +deadline; rejected 4xx operations do not loop. Lock/network/5xx/invalid write +responses remain conservative uncertain create/upload outcomes. A locked note retains its content and outbox and does not prevent unrelated notes from synchronizing. Issue 1940 describes locks retained by Notes/Text editing sessions; the client cannot remove those server locks or claim live collaboration. @@ -263,6 +303,10 @@ Only actual Markdown/HTML destinations are rewritten; namespace text in prose or code remains literal. History restoration can restage deleted attachment bytes, and recovery as a new note clones managed bytes under the new logical identity. Neither operation reuses another note's attachment ownership. +API paths/filenames are raw strings. Markdown destinations are decoded exactly +once and raw segments encoded exactly once; literal percent sequences remain +filename characters. Upload response paths commit durably before reference +replacement, so later local failures cannot cause a second upload. Attachment downloads have a dedicated streaming path and share the existing local asset ingestion limit, `maximumManagedAssetBytes` (**100 MiB**). Advertised @@ -301,6 +345,34 @@ WYSIWYG, HTML and PDF share this resource boundary. Filesystem/Writerside projec workspace-wide search/replace are explicitly unavailable for Notes; per-note search/replace uses the normal editor and save pipeline. +## Settings and maintained capabilities + +The account settings screen loads the server Notes application's notesPath and +fileSuffix, with explicit Save/Cancel, partial updates, custom suffixes and returned +normalization. These preferences affect other clients; path changes do not migrate +files and suffix changes do not rename existing notes. Dirty buffers save through +the normal boundary before transitions. Pending notes/conflicts/attachment work +block collection-affecting settings changes; synchronization is serialized and +mutations fenced. A durable exact settings attempt survives a lost PUT response +and is reconciled by GET before success/retry. Relevant changes reset the old +checkpoint and perform a full authoritative read. + +Capabilities refresh on first connected use per process, explicit Refresh, +reconnect and hourly active use. OCS and case-insensitive X-Notes-API-Versions +headers share version parsing. API advertisement is separate from application +version evidence; a header never independently enables attachment deletion. +Verified unsupported advertisements block remote operations while retaining local +editing/recovery. Transient refresh failure retains verified metadata. Updates +merge onto current account/checkpoint state and pre-reconnect responses are fenced. + +Managed media validates after observed remote note changes, on explicit Refresh +for cached attachments used by the active note (including 304), and becomes stale +after five minutes while displayed. Both repository bytes and document contexts +refresh. Failed refresh retains usable old bytes without advancing validation. +Requests coalesce; late downloads are fenced by account/ownership/deletion/media +generation. Materializations use content identity and remove superseded files. +No whole-library uncached media prefetch is performed. + ## Verification Focused tests cover authentication/URI/capabilities/keyring failures, mocked @@ -346,6 +418,61 @@ 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. +For repeatable performance measurements, build the existing real-SQLite corpus +harness with the pinned Dart SDK and dependency lock. Use an empty corpus +directory outside the checkout: + +```bash +performance_dir="$(mktemp -d)" +dart build cli -t tools/nextcloud_notes_performance.dart \ + -o "$performance_dir/build" +"$performance_dir/build/bundle/bin/nextcloud_notes_performance" \ + "$performance_dir/corpus" +``` + +The harness measures index rebuilding, warm queries, concurrent saves/imports +and reference indexing with durable notes and cached attachments. Compare its +`report.json` under equivalent machine/load conditions; it is a benchmark, +not a timing assertion in the regression suite. + +### Native desktop integration + +Build the reusable native target with: + +```sh +flutter build linux --release -t tools/nextcloud_notes_desktop_acceptance.dart +GDK_BACKEND=x11 build/linux/x64/release/bundle/busymark \ + /private/app-credentials.json /private/test-ca.pem /private/desktop-output \ + disposable-nextcloud-container --full +``` + +Use a disposable XDG profile/display and an isolated HTTPS account. The target +runs production UI/controllers with connectivity loss injected around real +requests. `--full` additionally replaces an owned image inside the named Docker +container to verify refresh without a changed note ETag. + +| Scenario | Coverage | +| --- | --- | +| `--full` (default) | Metadata races, authentication/retry, settings, idle/focus polling, media refresh, and workspace workflows. | +| `--workspace` | Category creation, Quick Open/search, batch review, offline media, recovery, and export/import. | +| `--offline-start`, then `--offline-resume` | Pending text/media, category retention, history and search across a real process restart, native shortcuts, and switching to local Markdown/Writerside. | +| `--references`, then `--references-resume` | Portable note links, reviewed rename/category changes, missing targets, and cached ID navigation after restart. | +| `--local-references`, then `--local-references-resume` | Local Markdown link creation, reviewed moves, and media/link navigation after restart. | +| `--writerside-references` | Native Writerside link creation, reference discovery, and reviewed refactoring. | +| `--recovery-start`, then `--recovery-resume` | Search pagination, reviewed/repaired imports, interrupted-import resume, deleted-media isolation, and recovery across restart. | +| `--performance` | Quick Open during reference indexing and durable edits during search in an existing profile with at least 10,000 notes. | + +Use a fresh output directory for each scenario or start/resume pair. Resume with +the same credentials, CA and output directory; start modes retain only the +resources needed for their second process. The performance mode requires a +prepopulated `notes.sqlite3` with an account matching the private credentials; +use the standalone corpus benchmark above for reproducible SQLite measurements. +Each run exits nonzero on failure and writes `report-SCENARIO.json`, plus +captures and journey state, outside the repository. Unknown scenarios fail +before reading credentials or creating output. Rebuild the default application +entry point after running a custom target, and remove the isolated account, +container, profile and output after inspection. + ## Explicit non-goals No Nextcloud Files, WebDAV, automatic protocol fallback, file browsing, embedded @@ -355,3 +482,116 @@ The current [Text source](https://github.com/nextcloud/text) and describe web-editor ownership and internal collaborative machinery, not a documented supported external native collaboration API. A future official native API would require a separate product decision. + +## Everyday Notes workspace + +The existing sidebar offers All notes, Favorites, Uncategorized, Recovery and a +collapsible category tree. Category counts and selections include descendants; +New note creates directly in the selected category. Categories come from notes, +so there are no persistent empty server folders. Sort by local editing activity +(newest or oldest), or by title. Click selects and opens; Ctrl-click toggles, +Shift-click extends a range, and Ctrl+A selects the current list. The checkbox +marks selection, the edge marks focus, and the document icon marks the open note. +The Selected action and a row's context menu review the exact target titles +before setting/removing favorites or moving notes. A move can introduce a category. +Read-only notes support favorite changes. Partial results retain successes and +identify skipped, conflicting or failed notes; resolve conflicts through the +existing review workflow. + +Ctrl+P opens Quick Open for the active workspace. Cached unopened Notes titles +and categories participate alongside local Markdown/Writerside files in their +own workspaces. Exact and prefix title matches rank first. Arrow keys choose, +Enter opens, and Escape dismisses. Ctrl+Shift+P still opens the command palette. + +The sidebar Search control and workspace search use one indexed Notes search +workflow. Terms combine with AND; quote a phrase, use `title:meeting` or +`category:"Work/Team"`, and select Whole word when needed. Ordinary punctuation, +SQL/FTS operators and wildcard characters are literal. Partial terms, short +queries, Unicode and decomposed accents are supported. Results include local +unsynchronized text and overlay dirty open buffers without saving. Results show +context, individual occurrences, loading/indexing/errors and Show more when +truncated. Opening a changed result rechecks its revision and content digest and +relocates the match. The Source location button explicitly selects source for +syntax that the rendered view cannot represent. Remote workspace replacement +remains disabled. A missing derived index rebuilds without changing notes/outbox. + +Make available offline retains an individual note or a category and descendants. +Individual and category requirements are independent and survive restart. Moving +notes into a retained category or adding supported managed references schedules +those bytes through the existing download machinery. Status shows text, required +media counts, missing items and failures. Cancel stops that requirement (a note +cancellation also stops category-derived work for that note); Retry deliberately +resumes. Completed downloads remain retained. Removing a requirement removes no +notes, remote attachments or pending sole copies. External URLs are identified +but never downloaded automatically. Available offline describes local bytes; +last server check describes freshness; Saved locally / sync pending describes +unacknowledged changes. These states can coexist. + +Recovery lists fully deleted local records for the current account. Deletion +conflicts with pending work stay in the normal list and conflict workflow. +Recovery previews retained text and Local History revisions, checks actual media +availability and recovers as a new identity. It never overwrites an existing +note. History settings/retention still apply; unavailable history is explained. +This is local recovery, not Nextcloud server trash or server history. + +## Portable Notes snapshots and import + +The sidebar destination menu exports the current category/subtree or whole +account. The existing per-note Save local copy remains available. Dirty buffers +in scope must save successfully through the normal save workflow before export. +A cancelled/failed save produces no completed export. Documents represent the +captured local working version, including saved unsynchronized changes; exporting +a conflict does not resolve it. Choose a parent directory: BusyMark stages then +publishes a new unique `BusyMark-notes-…` directory, without overwriting existing +files. Cancelling or a filesystem failure removes unpublished staging. Missing +managed bytes and unsupported/external dependencies are listed as omissions. +If attachment bytes refresh or the account is removed during capture/copying, +publication stops and asks for a fresh snapshot; a mixed snapshot is not published. + +Each snapshot contains ordinary `.md` documents in category directories and +companion media. Only recognized attachment destinations are rewritten; prose, +code, external URLs and unrelated document links remain unchanged. Sanitized, +UTF-8-bounded, collision-safe names are mapped to exact original metadata in +`busymark-notes.json`. The manifest has `format: "busymark-notes"`, `version: 1`, +`capturedAt`, `complete` and a `documents` array. Each document records `file` +(relative Markdown path), exact `title`, `category`, `favorite`, `activityMicros` +(local editing time), `serverModified` (server seconds), `workingVersion`, +`unresolvedConflict` and `omissions`. It contains no credentials, local/server +identities, outbox or full history. No proprietary front matter is inserted. +Markdown and media remain usable without BusyMark. + +Import accepts a Markdown file, a folder, or a version-1 snapshot directory. The +review shows selected documents, editable target categories, recognized media, +missing/unsafe references and collisions. Uncheck an item to skip it; a collision +imports a distinct new note. Existing notes are never overwritten. Source paths, +symlinks, versions, size limits and reviewed file digests are validated. Companion +files must remain inside the selected source directory (the document's containing +directory for a single-file import); external URLs are not fetched. Unsafe or +missing media remains an explicit incomplete item for review. + +Imports commit each new local identity, attachment bytes, outbox and source-item +association together. Resuming a specific interrupted operation after +cancellation/restart does not duplicate successful items. A new reviewed import +creates distinct notes even when the source content is unchanged. +Imported locally means durable local content; waiting to synchronize means +publication has not been acknowledged. The existing uncertain-create/upload +workflow applies to interrupted publication. One failed item retains the others. + +### Import operations and Recovery search + +Each confirmed import is a separate operation. Selecting the same source again +creates distinct notes and uses the newly reviewed categories and media. When +an interrupted operation exists for that source, choose **Start a new import** +or **Resume interrupted import** explicitly. Resume reloads the persisted review +and identifies completed items as **Already imported in this operation**; it +does not repeat their creation or publication. Successful items keep their +original identities even when another item fails. Safe relative images such as +`./images/photo.png` and `../images/photo.png` are imported when their resolved +files stay inside the selected source folder; symlinks and outside references +remain excluded. + +Search in **Recovery** searches retained deleted records for the current account +and opens their recovery preview. Ordinary Notes search excludes these records. +The preview continues to offer retained Local History revisions and recovery +as a new note; it does not access server history or trash. Deleted attachments +retained for recovery do not count as live offline availability. diff --git a/docs/development/visualizations.md b/docs/development/visualizations.md index bd821752..9856797f 100644 --- a/docs/development/visualizations.md +++ b/docs/development/visualizations.md @@ -36,6 +36,58 @@ workspace. The interactive Scalar view receives bundled content with agent, telemetry, authentication persistence, proxying, API requests, remote fonts, and custom fetches disabled. +## Linux host lifecycle + +Startup registers the channel and a lightweight host. A launch without a +visualization request creates no visualization WebKit context, hidden view, or +harness. There is no idle preloading or first-frame gate; the release smoke can +still render before `runApp()`. + +The first accepted render operation queues initialization on the GTK main +context. Requests share one ephemeral context, view, and harness load. The +renderer moves through dormant, scheduled, loading, ready, failed, and stopped +states. Loading includes the harness's asynchronous ready event and its existing +15-second deadline. Dart's existing request deadlines include initialization. +Malformed, oversized, unsupported, cancellation, and PNG clipboard operations +do not start WebKit. Cancelling all scheduled requests avoids construction. + +The harness registers its dispatcher without evaluating an engine. The first +operation imports a fixed, offline bundle for Mermaid, PlantUML, MathJax, or +OpenAPI. SVG rasterization needs only the harness. PlantUML alone loads the +bundled Graphviz script. Module evaluation is reused within the view, and +concurrent imports share the browser's module registry. Engine load failures +reject their request with `visualization.hostUnavailable` and retire the failed +view and its module registry. Queued work and retries use a fresh restricted +view in the same ephemeral context. Process recovery also creates a fresh +registry. Each engine retains its existing validation, +configuration, and dependencies. Math includes all offline NewCM font tables. +First use still pays for the WebKit process, harness, and requested engine; +subsequent requests reuse them. Those costs must be measured alongside plain +startup when assessing performance, including preparation that an eager +baseline completed before receiving its first request. + +Opening an interactive reference first creates the shared context and its +visible view, without loading the offscreen harness. A later render uses that +context. Reference windows retain their normal acceleration policy. + +Initialization failure fails the queue and releases the failed view; later +requests can retry. Renderer-process termination recreates the hidden view with +the same restrictions and offscreen acceleration policy. Cancellation marks +active work as responded before cancelling its callback, preventing a late +success. Shutdown removes scheduled work, cancels initialization and rendering, +fails queued calls, closes reference windows, and disconnects view callbacks. +Late channel calls return `visualization.hostUnavailable` until host disposal, +which detaches the channel handler. Async +callbacks retain the host until completion, check its state and view identity, +and cannot recreate resources after shutdown. Private resource callbacks own +their resource path independently of the host. + +Set `BUSYMARK_VISUALIZATION_TRACE=1` for opt-in monotonic timings on stderr: +context construction, view construction, offscreen realization/show, harness +dispatch, and request acceptance, start, JavaScript delivery, and completion. It logs no document source. Moving +construction to first use removes unused startup work; it does not establish +the cause or elimination of a rare WebKit construction stall. + ## Dependencies and packaging Do not duplicate pinned versions or hashes in documentation. The authoritative @@ -66,6 +118,10 @@ BUSYMARK_TYPST_PATH=build/linux/x64/debug/bundle/libexec/busymark/typst \ Run the real WebKit and D2 corpus under X11: ```bash +xvfb-run -a -s '-screen 0 1280x1024x24' \ + env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ + bash tools/test_web_render_host.sh + xvfb-run -a -s '-screen 0 1280x1024x24' \ env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ /usr/bin/python3 -u tools/visualization_smoke.py \ diff --git a/docs/development/writerside-verification.md b/docs/development/writerside-verification.md index 8617d236..97c3c1b6 100644 --- a/docs/development/writerside-verification.md +++ b/docs/development/writerside-verification.md @@ -93,6 +93,136 @@ 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. +## Linux Element Properties harness + +`test/support/writerside_authoring_native_smoke.dart` opens the production +`BusyMarkApp` workspace with isolated settings, history, recovery and template +storage. It copies `test/fixtures/writerside/conformance_project` and +`test/fixtures/writerside/property_defaults_conformance`, then drives GTK menus +and native keyboard shortcuts through `tools/linux_native_menu_probe.py`. + +Use the pinned Flutter SDK and the Linux build dependencies listed in the README, +plus Python GTK/AT-SPI introspection, XTest, xdotool, Xvfb and a D-Bus session. +Run from the repository root with an isolated display and software rendering: + +The harness keeps an AT-SPI probe connection alive until its application exits. +This avoids GTK's [key-listener lifetime bug](https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/10391) +when the last short-lived automation client disconnects during key dispatch. +Accessibility remains enabled; every command still queries real native controls, +and premature probe termination fails the workflow. + +Finish the debug build and retain its complete bundle before starting a release +build. Flutter shares generated asset outputs between configurations; concurrent +builds can remove the debug harness's `data/flutter_assets/kernel_blob.bin`. +Check that file before launch. Keep retained bundles outside the checkout. + +On GLVND hosts where the NVIDIA vendor blocks an isolated software display, +select the installed Mesa vendor for both Xvfb and the application. For a system +with `/usr/share/glvnd/egl_vendor.d/50_mesa.json`, use +`__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json` and +`__GLX_VENDOR_LIBRARY_NAME=mesa` alongside `LIBGL_ALWAYS_SOFTWARE=1`. +For an isolated CPU-rendered WebKitGTK 2.52 fixture that cannot initialize its +GBM EGL display, the verified environment also uses +`WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_SKIA_ENABLE_CPU_RENDERING=1`. +These are fixture rendering settings; retain WebKit's sandbox protections. + +```bash +unset BUSYMARK_PROPERTY_CONTRACT_ONLY BUSYMARK_INLINE_REFERENCES_ONLY \ + BUSYMARK_VARIABLE_MEDIA_ONLY BUSYMARK_NAVIGATION_ONLY BUSYMARK_PROPERTIES_ONLY +inspector_output="$(mktemp -d)" +flutter build linux --debug --no-pub \ + --target=test/support/writerside_authoring_native_smoke.dart +BUSYMARK_NATIVE_PROBE=1 GDK_BACKEND=x11 NO_AT_BRIDGE=0 \ + LIBGL_ALWAYS_SOFTWARE=1 \ + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x1000x24' \ + build/linux/x64/debug/bundle/busymark \ + test/fixtures/writerside/conformance_project "$inspector_output" +flutter build linux --debug --no-pub --target lib/main.dart +``` + +The full invocation clears focused-mode flags so it reaches the retained +switcher, saved-code, draft-safety, list/admonition and numbering workflows. +Confirm those scenario names in `result.json`'s `checks`, rather than treating a +focused run's check count as evidence that the full workflows executed. + +Both invocations also exercise contextual margin access before their later +workflows: native pointer movement from a block to its Properties icon, +off-caret inspection, nested list ancestry, native list attributes, immediate +list/capitalization Undo/Redo and save/close/reopen. The copied +`${fixture}-contextual` project uses `Conformance/conformance`; validate its +saved bytes with the pinned builder, then run the existing property verifier +with mode `list-caps-native`. Review the contextual light/dark captures as well +as the original inspector captures. A narrow-window journey closes the pane +through its guarded Close control before hovering exposed content to reopen it. + +The shared journeys also open `nested-references.md` in the contextual project +and target repeated parent, child, quoted and embedded-XML references through +Editor and Source, using the explicit Element Properties command after selecting +an occurrence. The shared explicit-inspection journey also checks pinned owners +across Editor/Source/Split, ordinary editing and caret motion, deletion/Undo, +invalid-draft rejection, multiline XML attributes, and semantic-text wrapping and +insertion through the consolidated Writerside menu. Their saved output is checked +by `list-caps-native`. Review +the `form-*` and `*-form-topic-*` top/bottom captures: these retain the complete +forms at normal/minimum pane widths, light/dark themes, text scaling and RTL, +including expanded optional fields. Opening disclosures and changing display +settings must leave the source and inspected owner unchanged. + +Use `test/fixtures/writerside/property_contract_conformance` as the fixture argument +and set `BUSYMARK_PROPERTY_CONTRACT_ONLY=1` for the reusable Topic, Chapter and +Paragraph journey. It exercises the production forms, source ownership, Custom +navigation from all presets, invalid-draft protection, compound undo/redo, +conditions, defaults, Source/Editor/Split/Reading, save/close/reopen, keyboard +focus and narrow light/dark layouts. The two `contract-*` documents come from +`test/fixtures/writerside/property_contract_conformance`. Validate their saved +bytes with the pinned builder and the property verifier's `contract-native` +mode. The `contract` mode checks the checked-in fixture. Both assert published +default titles, XML instance titles, chapter overrides and anchors, navigation +targets, conditional visibility, literal variables and switcher labels; they +explicitly check the topic-H1 attribute and Markdown instance-title limitations +instead of mistaking accepted markup for supported output. + +Treat a nonzero exit or a non-null `failure` in `result.json` as failure. The checks +cover target identity and ancestry independently of the caret, Source without a +hidden Editor, native Save and one-step undo/redo, invalid-draft transition +blocking and Escape, XML/Markdown save/close/reopen, defaults/override/reset, +source-neutral folding, Split/Reading, narrow layouts, RTL and text scaling. +Ownership cases retain literal code/image labels and selected inline attributes; +ordered-item conversions retain outer and nested list starts. Screenshots alone +do not establish persistence or correct mutation. Keep output and copied projects +outside the repository and remove them after inspection. + +`WRITERSIDE_CONFORMANCE_OUTPUT` retains builder artifacts before report assertions, +including failed reports. The strict fixture checks still require zero errors and +warnings. The native semantic-text journey intentionally inserts a literal +shortcut; when a keymap is configured, the pinned builder reports `SCT001` for +that literal. Inspect and report that advisory instead of replacing the literal +with an invented action ID. The HTML verifier checks its literal keystroke output +separately from action-reference output. + +The checked-in `list_capitalization_conformance` fixture and its config variants +assert marker styles, checkbox eligibility, columns, sorted publication, native +start/default precedence and contrasting capitalization output. The ordinary +conformance invocation runs these cases without changing the pinned builder. +It explicitly records ignored module `` declarations and the native +Markdown checked-state limitation instead of equating accepted markup with +published behavior. + +To check the saved source with the pinned builder, pass the copied project named +by `result.json`'s `fixture` field to `tools/validate_writerside_conformance.sh`. +Validate its sibling `${fixture}-defaults` project separately. Both use +`Conformance/conformance`. Retain each build outside the checkout via +`WRITERSIDE_CONFORMANCE_OUTPUT`, then run +`tools/verify_writerside_property_fixture.py` on its `webHelpCONFORMANCE2-all.zip` +with mode `native` or `native-defaults`, respectively. This checks the actual +saved bytes and module defaults as well as the checked-in conformance inputs. + +The corresponding executable regressions are +`test/src/writerside_properties_round_trip_test.dart`, +`test/src/writerside_inspector_session_test.dart`, +`test/src/writerside_authoring_ui_test.dart` and +`test/src/workspace_controller_test.dart`. + ## Official-builder conformance `test/fixtures/writerside/conformance_semantics.json` is a normalized semantic @@ -107,9 +237,17 @@ Run the builder comparison with: bash tools/validate_writerside_conformance.sh ``` -The script uses temporary source copies and validates both the builder report -and normalized semantic snapshot. Set `WRITERSIDE_CONFORMANCE_OUTPUT` only when -generated artifacts must be retained for diagnosis. +The script requires Docker and uses the pinned builder **2026.08.0328** with +temporary source copies. It validates the builder report and normalized semantic +snapshot, then checks `reference_conformance`, `property_conformance`, +`property_defaults_conformance` and `property_contract_conformance` under +`test/fixtures/writerside/`. +`tools/verify_writerside_property_fixture.py` checks generated HTML for collapse, +state/default inheritance, extraction/emphasis, reference ownership and nested +ordered-list starts. Builder errors, warnings, semantic mismatches or failed HTML +assertions fail validation; process success alone does not establish the output +semantics. Set `WRITERSIDE_CONFORMANCE_OUTPUT` to a directory outside the +repository only when generated artifacts must be retained for diagnosis. Regenerate the authoring schema from an intentionally selected official XSD: diff --git a/docs/element-properties.md b/docs/element-properties.md new file mode 100644 index 00000000..3ff545ef --- /dev/null +++ b/docs/element-properties.md @@ -0,0 +1,284 @@ +# Element Properties + +In a Writerside workspace, **Element Properties** stays on the element you +explicitly inspect. Hover a block in Editor to reveal one **Element Properties** +icon in its leading margin, then click it to inspect that block. Hovering alone +does not open the pane, move the caret, change source, or add history. The +formatting toolbar has no duplicate Properties button. The existing command +palette command **Element Properties** opens the pane or inspects the current +selection again when it is already open, including inline links, variables and +shortcuts. In Source and Split, the command uses the Source selection. Use the +pane’s Close control to hide it. + +Ordinary clicks, caret movement, text selection, focus changes and scrolling do +not switch the inspected owner. Switching between Editor, Source and Split for +the same document preserves the pane and its target. Changes to that owner, +including Undo/Redo, refresh its values when its authored identity can be +verified. A deleted or ambiguous target becomes unavailable; later edits cannot +reactivate it by inserting a replacement. Explicitly inspect the replacement to +select it. Undo/Redo can restore the original owner when its captured editing +history establishes that restoration. The inspector never selects a neighbor. +Opening another document leaves the pane open without an +editable target until you explicitly inspect an element there. + +Reading shows validated values without allowing changes. Ordinary Markdown and +Nextcloud Notes retain their own authoring behavior. + +The **Element** selector shows the inspected type; ancestor choices retain their +titles or identifiers. Editable chapters show their heading/title and anchor in +the corresponding controls, without a repeated identity strip. Paragraphs and +references keep a concise excerpt. The margin icon captures its hovered block +independently of the caret. Its target remains revision-bound while pending +inspector edits are processed. Choose **Topic Properties** in the same selector +for the topic title, document identity and navigation. The first top-level Markdown H1 +owns the logical topic title; later H1 headings keep their own element identity. +A missing H1 is reported rather than selecting a paragraph as the title. +Selections spanning several elements show a selection message instead of +editing an arbitrary element. + +Text edits commit on Enter, on leaving the field, or before Save. Escape restores +the last committed value. Save, closing the inspector, and changing the document +or view process pending edits first. Invalid drafts stay with their original +element and explain the problem; correct them or use Escape before continuing. +A changed or removed element produces a conflict instead of applying a delayed +edit to a replacement. Property edits use the document's normal undo, recovery +and save history. + +Drag the separator to resize the panel. A focused separator also accepts Left +and Right arrows. The inspector starts closed on each launch, including with an +older saved visible preference. BusyMark remembers its preferred width separately +from the navigation sidebar. Once opened, it retains the explicitly inspected +element. +In a narrow window the panel becomes a dismissible +right-edge overlay. Closing it returns focus to the active editing surface. + +## Common controls + +Frequent properties appear first; **Advanced** starts closed and contains other +applicable scalar attributes from the supported schema. An authored-settings +indication makes existing overrides discoverable while the section is closed. +The compact disclosure header retains its explicit expansion while editing. +Collapsing a section processes its drafts first. Empty optional text +fields remove their local attribute when committed. Enum controls offer +**Use default**, which removes the local override. Required attributes must keep +a valid value. Suggestions and pickers do not replace legal variable expressions. + +| Element | Properties and source mapping | +| --- | --- | +| Topic | Default XML `title` or Markdown H1 text first, followed by read-only **Topic ID** derived from the filename. **Rename topic file** uses the established reviewed refactoring. Topic navigation and Advanced start collapsed. XML Advanced offers root `help-id`, `switcher-label`, `ignore-vars`, `is-library`, and `last-modified`. Markdown document settings offer the confirmed front-matter `switcher-label` mapping separately from heading attributes. | +| Chapter | Default XML `title` or Markdown heading text, an optional element anchor, published collapsibility and applicable initial state. Advanced retains capitalization, switcher, conditions, context-help, literal-variable settings and the Markdown `title` override. | +| Paragraph | Optional element anchor first. Advanced retains `switcher-key`, `filter`, `instance`, `help-id` and `ignore-vars`. These fields can be left absent while writing content. | +| Procedures | Title, element ID, published collapse and initial state. Existing formatted or conditional XML `` children are preserved. | +| Code | Language maps to XML `lang` or a native fence's language. An explicit Markdown `lang` override takes precedence; changing it preserves the original fence language, and resetting it restores that language. **Source file (src)** is one editable field with file selection and removal. Extraction, emphasis, collapse and collapsed title remain separate. The code-language command opens this block’s Language (`lang`) control in the workspace inspector. `include-lines` and `include-symbol` conflict; emphasis counts the displayed snippet after extraction. | +| Definition lists/items | Schema-supported list layouts, list collapse/state, item title and item state override. Literal `inherited` is an authored item override distinct from an absent attribute. | +| Tabs | Container `group` synchronizes tab sets; child `group-key` and title identify individual tabs. Topic switchers are separate. Existing code-block tab groups are preserved. | +| Lists and items | Every parsed list is inspectable before it has attributes. Hover an item, then select its enclosing list in **Element**; nested ancestors identify their content and level. List controls offer `type`, `start`, `columns`, `sorted`, and the whole-list anchor, with applicable conditions in Advanced. A Markdown attribute line after a blank line belongs to the whole list. An individual item's ID or condition uses semantic `<list>`/`<li>` markup; conversion preserves the other items, checked task states and ordered starts at every nested level and is one undoable edit. An authored list `start` overrides its first Markdown marker. | +| Images | Source and alternative text use the image model; applicable dimensions, style, thumbnail and **Preview image (preview-src)** are available. The preview can be an image thumbnail or an animated GIF’s initial preview; it is separate from the main `src`. Link wrappers and reference syntax survive scalar property edits. Writerside documents a limitation on sizing external images; authored dimensions do not guarantee external-image builder sizing. | +| Includes/references | **Topic (from)**, **Included element (element-id)** and **Help module (origin)** identify the reference. **Element ID (id)** identifies the include itself. Topic, element and module selection use the current project index. Include selection publishes the reference tuple together. `use-filter` belongs to the include; `filter` belongs to source content. Unavailable resolution remains a diagnostic. | +| Links and shortcuts | **Link target (href)** names the destination; **Help module (origin)** selects its originating module. **Action ID (key)** references a keymap action without replacing literal shortcut content. **Keymap help instance (from-keymap-of)** names a help instance, not a file. | +| Topic | XML root properties or the appropriate native heading/front matter; navigation uses `<show-structure>`. XML-only fields do not acquire invented Markdown equivalents. | + +The inspector pairs descriptive localized labels with exact Writerside attribute +names. Less-used schema attributes remain under **Advanced**. Empty inactive +controls are hidden; existing authored values remain inspectable and removable. + +Advanced uses separate **Document settings** and **Heading attributes** groups +for Markdown topics. **Authored settings present** counts only explicit settings +shown for that owner, including relevant unsupported markup; generated anchors, +effective defaults and another element’s settings do not count. Read-only Topic +ID keeps its rename action in the same row. Notices and navigation actions are +separate from editable property groups. **Show in Source** is a compact secondary +button that processes pending edits, validates the inspected owner and navigates +to its exact authored occurrence without retargeting the pane. +At narrow widths or increased text sizes, enum rows place the selected value +below the label. They keep the same native menu and keyboard operation. + +Topic identity is independent of titles and anchors. Renaming a topic updates its +filename, XML root ID and references through the existing review. Editing a +heading or explicit anchor does not rename a file. Unsaved documents have no +confirmed file identity. A root ID that differs from its filename is diagnosed +and preserved. + +A generated chapter anchor appears as **Generated: …** within the anchor row, +with an empty override editor. It remains selectable and wraps when necessary. +Entering an explicit `id` replaces the generated value; clearing it restores the +heading-derived anchor. Editing the heading updates its generated anchor; +an explicit override stays unchanged. Focus, inspection and Save do not author +that value. +Attribute explanations and full provenance are available in contextual help: +focus the control and press **F1**, or hover over its property row. + +Default XML titles edit only the scalar `title` attribute; formatted and conditional +`<title>` children remain intact. Markdown heading text edits its inline content, +while **Title override (title)** under Advanced edits the separate heading +attribute. The override changes the published chapter title without changing the +heading text. **Instance and TOC titles…** reuses the established instance/TOC title workflow +for a uniquely identified entry in the selected instance. When a topic has +multiple entries, use the desired entry's existing TOC action rather than +choosing an arbitrary owner. + +The pinned builder does not publish attributes on the topic H1. Inline H1 +attribute syntax becomes literal title text; the separate-line form is ignored. +Existing authored H1 attributes and the independently editable heading anchor +remain distinct from Topic ID. The affected fields show **Not published by this +builder**; F1 explains the limitation and source ownership. BusyMark +does not offer other unverified H1 or YAML properties as XML-root settings. +The same builder ignores instance-specific `<title>` children in Markdown +topics. The existing Edit Title workflow keeps TOC overrides available and +explains that limitation. Existing instance markup is retained and accessible +in Source, with no new unsupported instance override offered. XML instance +titles are supported. Chapter heading attributes, including explicit `title`, +are supported and tested against the published output. + +**Filter (filter)** selects reusable content by criteria. **Help instances +(instance)** selects help outputs and accepts legal names, groups, lists, +exclusions and expressions. **Treat variable text literally (ignore-vars)** +enables literal variable-looking text rather than substituting variables. +Unsupported authored attributes remain preserved, are identified in Advanced, +and remain accessible through **Show in Source**. They are not offered as new +supported properties. + +For code, the native file chooser updates the same **Source file (src)** field +as direct typing. It stores a reference from the designated snippets directory +when that reference resolves to the chosen file, or a path relative to the topic +within its help module. Unsupported or out-of-module choices explain that the +reference cannot be resolved. Cancelling changes nothing and selecting a file +never changes that file. BusyMark rejects adding a source reference to a block +with authored code; move or explicitly clear that code first. Removing `src` +preserves authored content and other attributes, without copying referenced +file contents into the topic. **Show in Source** processes pending edits and +navigates to the inspected element’s markup. It does not open the code file. + +Image source and alt edits can use local Writerside attribute overrides while +retaining a Markdown reference definition and its link wrapper. Explicit empty +alt text stays authored; the pinned builder uses a filename fallback in the +verified image fixtures. Changing a reference link's destination converts only +that occurrence to a direct Markdown link, retaining formatted link text and +leaving the reference definition and other occurrences untouched. The pinned +builder ignores a native link's `href` attribute override, so BusyMark does not +use that override to implement destination changes. + +The inspector targets a complete link even when only part of its caption is +formatted. **Show in Source** navigates to the inspected occurrence's markup, +including repeated variable and shortcut references in the same paragraph. +Source selections identify those same occurrences independently. + +Backtick-delimited code and escaped literal braces stay inline content, even +when they look like attributes. A link or image's attached attributes belong to +that inline element, including links containing code and image alt text that looks like an attribute block. Ordinary text edits preserve their formatting and attributes. Blockquote-form admonition IDs and conditions are written +after a blank line so the pinned builder assigns them to the admonition alone. + +## Published defaults and editor folding + +The selector shows **Use default** when no local override is authored. Its row +subtitle shows **Default: Disabled** for a built-in value, or, for example, +**Default: Expanded · Module** for a module setting. An explicit literal value +does not repeat the same effective value. Inherited values and resolved variable +expressions show a concise **Resolved: …** subtitle when the displayed result +differs from the authored selection. Full provenance remains in keyboard and +accessible contextual help. A module `<default-property>` can change +an omitted property's meaning. Unresolved variable expressions remain visible +without being converted to false or to an arbitrary enum. Opening the panel +does not write effective defaults to source. + +Definition-list layout uses the same defaults: a module setting of `medium` +appears beneath **Use default** as **Default: Medium (1:2) · Module**, +including after resetting a local override. Origins distinguish an element +override, parent element, module default, and built-in default. + +The supported builder is `2026.08.0328`. Its generated output requires effective +`collapsible="true"` for chapters, procedures, code and definition lists, including +state-only chapter forms. Explicit false disables collapse even when a module +supplies true. This pinned behavior differs from the current chapter reference's +state-only description. A source file or XML self-closing syntax does not itself +enable collapse. See [Writerside verification](development/writerside-verification.md#official-builder-conformance) +for the maintained fixtures and generated-HTML checks. + +Definition items get collapse from their list. With no module item default, +absent and literal `inherited` item states both follow the list. With a module +item-state default, absence uses that module default while literal `inherited` +uses the list's state. + +Opening content locally to edit it does not dirty the document or change its +published initial state. Publishing properties updates the semantic result +without rewriting unrelated local folding preferences. + +## Whole lists and capitalization + +A list and its items own different properties. **List type (type)** offers +bullets, decimal numbers, lowercase letters, checkboxes and no markers. +**Starting number (start)** applies to decimal and lettered styles; +**Columns (columns)** accepts 1–5, and **Sorting (sorted)** selects build-time +ascending or descending order. Sorting preserves the authored item order. +Inspecting or editing the whole list retains native Markdown markers, loose +paragraphs, nested lists, inline formatting and references. **Show in Source** +goes to the selected container's boundary. Item overrides remain a separate +operation through the existing semantic conversion. XML requires one marker style +per list: item-property conversion is protected for a native list mixing tasks +and ordinary markers. Its whole-list properties remain editable; use Source for +item markup rather than losing task state or turning ordinary items into tasks. +Converting the last item to +ordinary content is blocked while its list still has authored settings; reset +those settings first so they are not discarded. + +The empty starting-number editor is an absent override. Its summary distinguishes +the native marker from a module default. An explicit `start` takes precedence. +In builder `2026.08.0328`, a non-one ordered Markdown marker also takes precedence +over a module start default; a marker of one allows that default. Ordered +Markdown markers establish decimal style, while untyped bullet lists can inherit +a module list style. Reset removes only the authored override. + +Capitalization remains in Chapter **Advanced** and changes `caps`, leaving the +heading text intact. With no relevant supported default, the effective style is +**As written**. A local attribute overrides a chapter `default-property`, including +variable-valued defaults resolved in the existing lexical scope. The pinned +builder uses the first capitalization `default-property` when several apply. +It accepts module `<caps>` rules for chapters, topics and TOC entries but does +not apply their documented capitalization effect; the inspector shows +**Module capitalization is not published** beside the resolved value, with the +full explanation in F1 help. Use a local `caps` or a chapter +`default-property` for a published transformation. This distinction is checked +against [the configuration reference](https://www.jetbrains.com/help/writerside/writerside-cfg.html) +and the maintained builder fixtures. + +The pinned builder publishes checkbox lists through its checkbox list class. +Native Markdown task states remain in the authored source, but its generated HTML +does not initialize checked state from `[x]`; XML `li@checked` is supported and +verified independently. The existing item conversion preserves that state. + +## Topic navigation + +**Use default** removes the unscoped navigation declaration. **All elements with +title** creates an empty `<show-structure/>`. **No navigation** authors +`for="none"`. These have different Writerside meanings. Selecting **Custom +navigation** exposes `for` and `depth` together without inventing source values. +Supported element types are `chapter`, `procedure`, +`tab` and `def`; a depth-only configuration is also supported. The pending +configuration is validated and published as one undoable change. Invalid drafts +block closing or hiding the section. Resetting removes the declaration instead +of serializing an effective default. Multiple or conditional/scoped declarations +remain protected from this single-declaration editor; use Source to edit them. + +## Writerside commands + +The Editor toolbar has one **Writerside** menu with three groups: + +- **Insert elements**: Procedure, Tabs, Definition List, Include Reusable Content, + Variable Reference, Video, TLDR and Convert List to Procedure. +- **Admonitions and quotes**: Tip, Note, Warning and Quote. These retain their + existing block transformations. +- **Semantic text**: UI Control, File / Path, UI Path and Keyboard Shortcuts. With + selected text, the command wraps it in the corresponding Writerside element. + At a caret, it opens text entry and inserts a nonempty element at that captured + location. Cancel leaves the source unchanged; insertion uses ordinary Undo/Redo. + +Block transformations retain editing focus in their content through +Undo/Redo. Caret restoration uses the exact saved source representation when +editing-model IDs change during reparsing; it does not retarget Element Properties. + +Unavailable commands are disabled for the current document and structure. A +literal shortcut inserts its entered combination; an Action ID in Element +Properties remains a separate keymap reference. Semantic content is escaped for +XML, including XML embedded in Markdown. Multiline reference-property changes +in quoted and nested lists preserve their Markdown prefixes and neighboring +occurrences; ambiguous mappings are rejected before changing source. diff --git a/docs/references.md b/docs/references.md new file mode 100644 index 00000000..85ae9c96 --- /dev/null +++ b/docs/references.md @@ -0,0 +1,81 @@ +# References and links + +Open **References** from the sidebar menu or Command Palette. The panel follows +the current document and shows its workspace/account scope. Switch between +Incoming and Outgoing, or choose a parsed heading, topic element, or reusable +fragment. Rows show the authored context, destination, and resolution status. +Writerside rows retain their module and origin; **Unavailable in this context** +can mean an instance condition rather than a missing topic. **Show more results** +continues through the complete result set. + +Select a row to follow its reference. The row menu also provides **Source +location** and **Target location**. These actions check the current source +revision; stale positions refresh instead of selecting unrelated text. The +normal navigation keeps your view where it can show the target. Use Source +location for syntax that Editor or Reading cannot represent. + +The index includes unopened workspace documents and unsaved buffer content. +It is derived data: rebuilding it does not save files or alter Notes history or +synchronization. Unreadable files, unsafe paths, and documents larger than 2 MiB +are unavailable for inspection. Code examples and escaped link syntax do not +create references. External destinations without a supported internal target +are marked Unsupported; normal external-link navigation remains available. + +## Insert or edit a link + +Select text in Source or Editor and use the existing Link action (Ctrl+K). +Search the target chooser for a document/topic or an actual parsed heading ID. +Unicode heading and explicit IDs use ordinary percent-encoded URL fragments; +References resolves them to the actual parsed ID without changing authored bytes. +Manual URL entry remains available. XML topics receive semantic Writerside +links through the existing adapter, including namespace and module origin. +Reading follows links; it does not insert them. Cancelling restores focus and +makes no edit. A changed document, selection, workspace, or edit permission +invalidates the pending selection. + +## Rename and move local Markdown + +Use the ordinary Files sidebar **Rename**, or **Cut** and **Paste** to move a +file or folder. Resolve unsaved affected buffers through the save review first. +BusyMark then shows the files and exact destination edits before Apply. +Incoming document links and outgoing relative links/media are reviewed together. +Reference definitions are edited once; link labels, text, titles, queries, +fragments, formatting, encoding and line endings are preserved. Code and prose +are not globally replaced. Writerside topic operations retain their established +Rename Preview workflow. + +If files, folder membership, filesystem identity or the destination change +between review and Apply, prepare and review the operation again. Unsafe paths, +uninspectable Markdown, mixed line endings and unavailable Local History +protection block the operation with an explanation. Cancellation before Apply +makes no refactoring changes. After an interrupted publication, References +exposes Recovery. Review the pending recovery before applying it; newer external +content prevents automatic restoration and the recovery evidence remains. +Recovery also verifies the moved file/folder identity, including entries that had +no reference edits. Recreated original paths or replaced destinations require +manual review and retain the journal. Older journals without identity evidence +are retained for manual review rather than inferred from path existence. + +## Portable Nextcloud note links + +Choose a synchronized note to insert its ordinary HTTPS Notes web URL. +BusyMark first observes the installation's public routing configuration and uses +`https://server/installation/index.php/apps/notes/note/123` or its configured +pretty form, `https://server/installation/apps/notes/note/123`. This observation +is cached for offline authoring. If the route cannot be confirmed, the chooser +explains the failure and manual URL entry remains available. The real server ID +survives title/category changes. Both forms are recognized when pasted from a +browser. BusyMark intercepts only the exact connected +installation; other URLs retain external navigation. Account removal disables +that account's internal link resolution. + +Cached targets open offline, including after a process restart. An uncached +recognized target uses authenticated individual-note retrieval. Deleted, +forbidden, unavailable and offline targets produce explicit errors. Recovery or +import creates a new identity and does not redirect an old note ID to it. + +A never-synchronized note has no confirmed server ID and cannot supply a +portable target URL. The chooser explains this rather than creating another +note. Note heading targets are disabled: the verified Notes browser view does +not expose BusyMark's parsed Markdown heading IDs. BusyMark does not +invent a persistent link syntax for this limitation. diff --git a/docs/search-and-replace.md b/docs/search-and-replace.md index 8fdd98d2..75119d04 100644 --- a/docs/search-and-replace.md +++ b/docs/search-and-replace.md @@ -1,8 +1,30 @@ # Search and replace Press **Ctrl+F**, choose **Search**, or use the command palette to search the -open workspace. The search controls use the same query and options for the -active document and workspace results. +open workspace. For local Markdown and Writerside workspaces, the search controls +use the same query and options for the active document and workspace results. + +## Quick Open and Notes search + +**Ctrl+P** opens Quick Open for titles and local paths or Notes categories. +Exact/prefix title matches appear first. Use the arrow keys and Enter to open a +result, or Escape to dismiss; **Ctrl+Shift+P** remains the command palette. + +In a Nextcloud Notes workspace, the sidebar search and workspace Search action +open the same indexed search dialog, including unopened cached notes, saved +pending edits, and unsaved open buffers. Terms use AND; quotes match phrases, +`title:` restricts a term to the title, and `category:` restricts the category. +For example, `"alpha beta" café category:"Work/子"` combines all three restrictions. +Whole word is optional. Ordinary punctuation and technical identifiers are +literal; Notes search does not expose regular-expression or FTS operators. + +Results show contextual highlights. Select a result to navigate in the current +view where supported, or choose its source-location action for Markdown syntax +that has no rendered counterpart. Changed documents are checked before applying +stored offsets. Loading/indexing, errors, empty results, and **Show more** are +explicit. Workspace-wide replacement remains unavailable for Notes. + +The remaining sections describe local workspace search and replacement. ## Search options diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 8489ba65..21cc4151 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "لا يُنشر تنسيق أحرف الوحدة.", + "wsTitleOverridesAction": "عناوين المثيل وجدول المحتويات…", + "wsHeadingNotPublished": "لا ينشرها هذا المنشئ.", + "wsMarkdownInstanceTitleLimitation": "لا ينشر الباني المثبّت عناوين النسخ في مواضيع Markdown. تُحفظ العلامات الحالية؛ افحصها في المصدر.", + "wsTopicIdentity": "معرّف الموضوع", + "wsElementAnchor": "مرساة العنصر", + "wsHeadingText": "نص العنوان", + "wsDefaultTitle": "العنوان الافتراضي", + "wsTopicNavigation": "تنقّل الموضوع", + "wsHeadingOptions": "سمات العنوان", + "wsDocumentOptions": "إعدادات المستند", + "wsSettingsPresent": "توجد إعدادات مكتوبة", + "wsTitleOverrideExplanation": "يتجاوز العنوان المنشور؛ يبقى نص العنوان كما هو.", + "wsIgnoreVariables": "معاملة المتغيرات كنص حرفي", + "wsIgnoreVariablesExplanation": "عند التفعيل، لا يُستبدل النص الذي يشبه المتغيرات.", + "wsInstanceSelection": "نُسخ المساعدة", + "wsInstanceExplanation": "اختر المخرجات بالأسماء أو المجموعات أو القوائم أو الاستثناءات أو التعبيرات.", + "wsFilterExplanation": "يرشّح المحتوى المعاد استخدامه حسب المعايير؛ لا يختار نسخة مساعدة.", + "wsContextHelp": "معرّف المساعدة السياقية", + "wsCapitalization": "حالة الأحرف", + "wsLibraryTopic": "موضوع مكتبة", + "wsLastModified": "آخر تعديل", + "wsMissingTopicHeading": "لا يوجد H1 للموضوع. أضفه في المصدر.", + "wsMultipleTopicHeadings": "يحدّد أول H1 عنوان الموضوع. عناوين H1 اللاحقة مستقلة.", + "wsTopicIdentityMismatch": "يختلف id الجذر عن معرّف الموضوع المشتق من اسم الملف. استخدم إعادة تسمية ملف الموضوع أو افحص المصدر.", + "wsUnsupportedProperties": "تُحفظ السمات غير المدعومة. افحصها في المصدر.", + "wsGeneratedAnchor": "مرساة مولّدة؛ إدخال قيمة ينشئ تجاوزًا.", + "wsHeadingBuilderLimitation": "لا ينشر الباني المثبّت سمات H1. تخص العنوان وليس جذر XML.", "closeOtherTabs": "إغلاق علامات التبويب الأخرى", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "موضوع من قالب...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "تطابق محتمل بعد تنقية الطلب؛ الهوية غير مؤكدة.", "nextcloudSelectCandidate": "اختر ملاحظة على الخادم لمراجعتها", "nextcloudCreateSeparate": "إنشاء ملاحظة منفصلة", - "nextcloudCreateSeparateWarning": "قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة." + "nextcloudCreateSeparateWarning": "قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة.", + "wsSemanticFormats": "تنسيق دلالي", + "wsUiControl": "عنصر تحكم في الواجهة", + "wsFilePath": "ملف / مسار", + "wsUiPath": "مسار الواجهة", + "wsDefinitionList": "قائمة تعريفات", + "wsIncludeContent": "تضمين محتوى قابل لإعادة الاستخدام", + "wsVariableReference": "مرجع متغير", + "wsConvertList": "تحويل القائمة إلى إجراء", + "wsElementProperties": "خصائص العنصر", + "wsTopicProperties": "خصائص الموضوع", + "wsCollapsible": "قابل للطي", + "wsPublishedState": "الحالة الأولية عند النشر", + "wsExpanded": "موسع", + "wsCollapsed": "مطوي", + "wsCollapsedTitle": "العنوان عند الطي", + "wsTerm": "المصطلح", + "wsListLayout": "تخطيط القائمة", + "wsSyncGroup": "مجموعة المزامنة", + "wsTabKey": "مفتاح علامة التبويب", + "wsSwitcherLabel": "تسمية المبدل", + "wsNavigationElements": "عناصر التنقل", + "wsNavigationDepth": "عمق التنقل", + "wsAddItem": "إضافة عنصر", + "wsStep": "خطوة", + "wsPropertyInvalid": "قيمة خاصية غير صالحة", + "wsFilter": "عامل التصفية", + "wsIdentifier": "معرّف العنصر", + "wsTldr": "ملخّص (TLDR)", + "wsFullLayout": "عرض كامل", + "wsMediumLayout": "متوسط (1:2)", + "wsNarrowLayout": "مصطلح ضيق", + "wsTitle": "العنوان", + "wsSwitcherKey": "مفتاح المبدّل", + "nextcloudServerSettings": "إعدادات Notes على الخادم", + "nextcloudNotesPath": "مسار مجلد الملاحظات", + "nextcloudFileSuffix": "لاحقة ملفات الملاحظات الجديدة", + "nextcloudServerSettingsExplanation": "تؤثر إعدادات Notes هذه على العملاء الآخرين. تغيير المسار لا ينقل الملفات؛ وتغيير اللاحقة لا يعيد تسمية الملاحظات الحالية.", + "nextcloudSettingsSaved": "حُفظت إعدادات الخادم", + "nextcloudSettingsNormalized": "حُفظت القيم بعد توحيدها على الخادم", + "nextcloudPendingUpload": "رفع المرفق قيد الانتظار", + "nextcloudServerCheck": "آخر فحص للخادم: {time}", + "nextcloudSettingsLoading": "جارٍ تحميل إعدادات الخادم", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "جارٍ حفظ إعدادات الخادم", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "التعديل المحلي السابق", + "nextcloudUsePreviousEdit": "استخدام التعديل السابق", + "quickOpen": "فتح سريع", + "notesAll": "كل الملاحظات", + "notesUncategorized": "بلا تصنيف", + "notesRecovery": "استعادة", + "notesRecentlyEdited": "المعدلة مؤخراً", + "notesOldestEdited": "الأقدم تعديلاً", + "notesTitleAscending": "العنوان تصاعدياً", + "notesTitleDescending": "العنوان تنازلياً", + "notesMove": "نقل إلى تصنيف", + "notesRemoveFavorite": "إزالة من المفضلة", + "notesOffline": "إتاحة دون اتصال", + "notesOfflineRemove": "إزالة الاحتفاظ دون اتصال", + "notesIndexing": "جارٍ فهرسة الملاحظات…", + "notesSearchHelp": "تُجمع الكلمات باستخدام AND. استخدم \"عبارات مقتبسة\" و title: و category:.", + "notesSourceLocation": "الموضع في المصدر", + "notesRecoveryHelp": "الملاحظات المحلية المحتفظ بها والسجل المحلي فقط. سلة المحذوفات وسجل الخادم غير متاحين.", + "notesImport": "استيراد ملاحظات", + "notesExport": "تصدير ملاحظات", + "notesImportDistinct": "استيراد كملاحظة مستقلة", + "notesAvailableOffline": "متاح دون اتصال", + "notesIncomplete": "غير مكتمل", + "notesSelected": "الملاحظات المحددة", + "notesNoHistory": "لا توجد مراجعات محفوظة. قد يكون السجل معطلاً أو منتهياً أو ممسوحاً.", + "notesTextAvailable": "النص متاح محليًا", + "notesLocalVersion": "نسخة العمل المحلية", + "notesAttachments": "المرفقات", + "notesSkipped": "تم التخطي", + "notesFailed": "فشل", + "notesCompleted": "مكتمل", + "notesImportNew": "بدء استيراد جديد", + "notesImportResume": "استئناف الاستيراد المنقطع", + "notesImportAlreadyImported": "تم الاستيراد بالفعل في هذه العملية", + "references": "المراجع", + "referenceIncoming": "الواردة", + "referenceOutgoing": "الصادرة", + "referenceChooseTarget": "اختر هدف الرابط", + "referenceWholeDocument": "المستند بالكامل", + "referenceSourceLocation": "موقع المصدر", + "referenceTargetLocation": "موقع الهدف", + "referenceResolved": "تم الحل", + "referenceMissing": "مفقود", + "referenceAmbiguous": "ملتبس", + "referenceUnavailable": "غير متاح في هذا السياق", + "referenceUnsupported": "غير مدعوم", + "referenceNoPortableId": "ليس لهذا الهدف عنوان URL محمول ومُتحقَّق منه للملاحظة.", + "wsIncludeLines": "تضمين الأسطر", + "wsIncludeSymbol": "تضمين الرمز", + "wsEmphasizeLines": "تمييز الأسطر المعروضة", + "wsPropertiesReadOnly": "الخصائص للقراءة فقط في هذا العرض.", + "wsPropertiesProtected": "هذا العنصر محمي. حرر مصدره.", + "wsPropertiesConflict": "تغير المستند أو العنصر. تراجع عن المسودة وحدد العنصر مجدداً.", + "wsPropertiesUnresolved": "لا يمكن حل المرجع حالياً.", + "wsUseDefault": "استخدام الافتراضي", + "wsPropertiesSelection": "حدد عنصراً واحداً.", + "wsImageDisplay": "عرض الصورة", + "wsImageThumbnail": "صورة مصغرة قابلة للنقر", + "wsEnabled": "مفعّل", + "wsDisabled": "معطّل", + "wsWidth": "العرض", + "wsHeight": "الارتفاع", + "wsPropertiesInactive": "لا تؤثر هذه الخاصية إلا عند تفعيل الطي المنشور.", + "wsNavigationAll": "كل العناصر ذات العناوين", + "wsNavigationNone": "بلا تنقل", + "wsNavigationCustom": "تنقل مخصص", + "wsTitleOverride": "تجاوز العنوان", + "wsCompactLayout": "مضغوط", + "wsWideLayout": "عريض", + "wsCodeSourceConflict": "انقل أو أزل الشفرة المكتوبة في هذه الكتلة قبل تعيين src. يرفض Writerside كتلة شفرة غير فارغة لها مرجع مصدر.", + "wsInherited": "وراثة من القائمة", + "wsExternalImageSize": "لا يحدد Writerside أبعاد الصور الخارجية بشكل موثوق. يمكنك فحص الأبعاد الحالية وإزالتها.", + "wsPropertiesNotSerialized": "لم يُطبّق هذا التغيير على المصدر. تم الاحتفاظ بالمسودة.", + "wsListItem": "عنصر قائمة", + "wsSourceFile": "ملف المصدر", + "wsLinkTarget": "وجهة الرابط", + "wsPreviewImage": "صورة المعاينة", + "wsIncludedElement": "العنصر المُضمَّن", + "wsHelpModule": "وحدة المساعدة", + "wsActionId": "معرّف الإجراء", + "wsKeymapHelpInstance": "نسخة مساعدة خريطة المفاتيح", + "wsShowInSource": "إظهار في المصدر", + "wsOriginElement": "تجاوز العنصر", + "wsOriginAncestor": "العنصر الأب", + "wsOriginModule": "القيمة الافتراضية للوحدة", + "wsOriginBuiltIn": "القيمة الافتراضية المضمّنة", + "wsOriginUnresolved": "غير محلول", + "wsEffectiveProperty": "القيمة الفعلية: {value} — {origin}", + "wsElementSelector": "العنصر", + "wsGeneratedAnchorValue": "مُولَّد: {anchor}", + "wsDefaultProperty": "الافتراضي: {value}", + "wsDefaultPropertyOrigin": "الافتراضي: {value} · {origin}", + "wsResolvedProperty": "القيمة المحلولة: {value}", + "wsPropertyUnresolved": "غير محلول", + "wsModule": "الوحدة", + "wsParent": "العنصر الأب", + "wsListType": "نوع القائمة", + "wsStartingNumber": "رقم البداية", + "wsListSorting": "الفرز", + "wsLetteredList": "قائمة حرفية", + "wsCheckboxList": "قائمة مربعات اختيار", + "wsMarkerlessList": "قائمة دون علامات", + "wsAscending": "تصاعدي", + "wsDescending": "تنازلي", + "wsNoSorting": "دون فرز", + "wsAsWritten": "كما كُتب", + "wsSentenceCase": "حالة أحرف الجملة", + "wsTitleCase": "حالة أحرف العنوان", + "wsLowerCase": "أحرف صغيرة", + "wsUpperCase": "أحرف كبيرة", + "wsNativeMarkers": "علامات Markdown", + "wsListMarkerStart": "علامة البداية: {number}", + "wsListLevel": "المستوى {level}", + "wsModuleCapsIgnored": "يتجاهل الباني المثبّت قواعد <caps> للوحدة. استخدم سمة caps محلية أو default-property.", + "wsInsertElements": "إدراج عناصر", + "wsAdmonitionsAndQuotes": "تنبيهات واقتباسات", + "wsSemanticText": "نص دلالي", + "wsPropertiesUnavailable": "العنصر الذي يتم فحصه غير متاح. افحص عنصرًا للمتابعة." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f2644d0..643e1188 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Modul-Großschreibung wird nicht veröffentlicht.", + "wsTitleOverridesAction": "Instanz- und Inhaltsverzeichnistitel…", + "wsHeadingNotPublished": "Wird von diesem Builder nicht veröffentlicht.", + "wsMarkdownInstanceTitleLimitation": "Der festgelegte Builder veröffentlicht keine instanzspezifischen Titel in Markdown-Themen. Bestehendes Markup bleibt erhalten; prüfen Sie es im Quelltext.", + "wsTopicIdentity": "Themen-ID", + "wsElementAnchor": "Elementanker", + "wsHeadingText": "Überschriftentext", + "wsDefaultTitle": "Standardtitel", + "wsTopicNavigation": "Themennavigation", + "wsHeadingOptions": "Überschriftenattribute", + "wsDocumentOptions": "Dokumenteinstellungen", + "wsSettingsPresent": "Explizite Einstellungen vorhanden", + "wsTitleOverrideExplanation": "Überschreibt den veröffentlichten Titel; der Überschriftentext bleibt erhalten.", + "wsIgnoreVariables": "Variablentext wörtlich behandeln", + "wsIgnoreVariablesExplanation": "Bei Aktivierung wird variablenähnlicher Text nicht ersetzt.", + "wsInstanceSelection": "Hilfeinstanzen", + "wsInstanceExplanation": "Hilfeausgaben über Namen, Gruppen, Listen, Ausschlüsse oder Ausdrücke auswählen.", + "wsFilterExplanation": "Wiederverwendbare Inhalte nach Kriterien filtern; dies wählt keine Hilfeinstanz aus.", + "wsContextHelp": "Kontexthilfe-ID", + "wsCapitalization": "Groß- und Kleinschreibung", + "wsLibraryTopic": "Bibliotheksthema", + "wsLastModified": "Zuletzt geändert", + "wsMissingTopicHeading": "Keine Themenüberschrift H1 vorhanden. Fügen Sie sie im Quelltext hinzu.", + "wsMultipleTopicHeadings": "Die erste H1 bestimmt den Thementitel. Weitere H1 sind eigene Überschriften.", + "wsTopicIdentityMismatch": "Die Root-ID weicht von der dateibasierten Themen-ID ab. Verwenden Sie Thema umbenennen oder den Quelltext.", + "wsUnsupportedProperties": "Nicht unterstützte Attribute bleiben erhalten. Prüfen Sie sie im Quelltext.", + "wsGeneratedAnchor": "Generierter Anker; ein eingegebener Wert erstellt eine Überschreibung.", + "wsHeadingBuilderLimitation": "Der festgelegte Builder veröffentlicht keine H1-Attribute. Sie gehören zur Überschrift, nicht zur XML-Root.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Mögliche Übereinstimmung nach Bereinigung der Anfrage; Identität unbestätigt.", "nextcloudSelectCandidate": "Servernotiz zur Prüfung auswählen", "nextcloudCreateSeparate": "Separate Notiz erstellen", - "nextcloudCreateSeparateWarning": "Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht." + "nextcloudCreateSeparateWarning": "Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht.", + "wsSemanticFormats": "Semantische Formatierung", + "wsUiControl": "UI-Steuerelement", + "wsFilePath": "Datei / Pfad", + "wsUiPath": "UI-Pfad", + "wsDefinitionList": "Definitionsliste", + "wsIncludeContent": "Wiederverwendbaren Inhalt einbinden", + "wsVariableReference": "Variablenreferenz", + "wsConvertList": "Liste in Prozedur umwandeln", + "wsElementProperties": "Elementeigenschaften", + "wsTopicProperties": "Themeneigenschaften", + "wsCollapsible": "Einklappbar", + "wsPublishedState": "Anfangszustand bei Veröffentlichung", + "wsExpanded": "Ausgeklappt", + "wsCollapsed": "Eingeklappt", + "wsCollapsedTitle": "Titel im eingeklappten Zustand", + "wsTerm": "Begriff", + "wsListLayout": "Listenlayout", + "wsSyncGroup": "Synchronisierungsgruppe", + "wsTabKey": "Registerschlüssel", + "wsSwitcherLabel": "Beschriftung des Umschalters", + "wsNavigationElements": "Navigationselemente", + "wsNavigationDepth": "Navigationstiefe", + "wsAddItem": "Element hinzufügen", + "wsStep": "Schritt", + "wsPropertyInvalid": "Ungültiger Eigenschaftswert", + "wsFilter": "Inhaltsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Kurzfassung (TLDR)", + "wsFullLayout": "Volle Breite", + "wsMediumLayout": "Mittel (1:2)", + "wsNarrowLayout": "Schmale Begriffsspalte", + "wsTitle": "Titel", + "wsSwitcherKey": "Umschaltschlüssel", + "nextcloudServerSettings": "Notes-Einstellungen auf dem Server", + "nextcloudNotesPath": "Pfad des Notizordners", + "nextcloudFileSuffix": "Dateiendung neuer Notizen", + "nextcloudServerSettingsExplanation": "Diese Notes-Einstellungen gelten auch für andere Clients. Ein Pfadwechsel verschiebt keine Dateien; eine neue Endung benennt bestehende Notizen nicht um.", + "nextcloudSettingsSaved": "Servereinstellungen gespeichert", + "nextcloudSettingsNormalized": "Mit vom Server normalisierten Werten gespeichert", + "nextcloudPendingUpload": "Anhang wartet auf Upload", + "nextcloudServerCheck": "Letzte Serverprüfung: {time}", + "nextcloudSettingsLoading": "Servereinstellungen werden geladen", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Servereinstellungen werden gespeichert", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Vorherige lokale Änderung", + "nextcloudUsePreviousEdit": "Vorherige Änderung verwenden", + "quickOpen": "Schnell öffnen", + "notesAll": "Alle Notizen", + "notesUncategorized": "Ohne Kategorie", + "notesRecovery": "Wiederherstellung", + "notesRecentlyEdited": "Zuletzt bearbeitet", + "notesOldestEdited": "Älteste Bearbeitung", + "notesTitleAscending": "Titel A–Z", + "notesTitleDescending": "Titel Z–A", + "notesMove": "In Kategorie verschieben", + "notesRemoveFavorite": "Favorit entfernen", + "notesOffline": "Offline verfügbar machen", + "notesOfflineRemove": "Offline-Aufbewahrung entfernen", + "notesIndexing": "Notizen werden indiziert…", + "notesSearchHelp": "Begriffe verwenden UND. Verwenden Sie \"Wortgruppen\", title: und category:.", + "notesSourceLocation": "Quellposition", + "notesRecoveryHelp": "Nur lokal aufbewahrte Notizen und lokaler Verlauf. Server-Papierkorb und Server-Verlauf sind nicht verfügbar.", + "notesImport": "Notizen importieren", + "notesExport": "Notizen exportieren", + "notesImportDistinct": "Als eigene Notiz importieren", + "notesAvailableOffline": "Offline verfügbar", + "notesIncomplete": "Unvollständig", + "notesSelected": "Ausgewählte Notizen", + "notesNoHistory": "Keine aufbewahrten Versionen. Der Verlauf kann deaktiviert, abgelaufen oder gelöscht sein.", + "notesTextAvailable": "Text lokal verfügbar", + "notesLocalVersion": "Lokale Arbeitsversion", + "notesAttachments": "Anhänge", + "notesSkipped": "Übersprungen", + "notesFailed": "Fehlgeschlagen", + "notesCompleted": "Abgeschlossen", + "notesImportNew": "Neuen Import starten", + "notesImportResume": "Unterbrochenen Import fortsetzen", + "notesImportAlreadyImported": "In diesem Vorgang bereits importiert", + "references": "Verweise", + "referenceIncoming": "Eingehend", + "referenceOutgoing": "Ausgehend", + "referenceChooseTarget": "Linkziel auswählen", + "referenceWholeDocument": "Gesamtes Dokument", + "referenceSourceLocation": "Quellposition", + "referenceTargetLocation": "Zielposition", + "referenceResolved": "Aufgelöst", + "referenceMissing": "Fehlt", + "referenceAmbiguous": "Mehrdeutig", + "referenceUnavailable": "In diesem Kontext nicht verfügbar", + "referenceUnsupported": "Nicht unterstützt", + "referenceNoPortableId": "Dieses Ziel hat keine bestätigte portable Notiz-URL.", + "wsIncludeLines": "Zeilen einschließen", + "wsIncludeSymbol": "Symbol einschließen", + "wsEmphasizeLines": "Angezeigte Zeilen hervorheben", + "wsPropertiesReadOnly": "Eigenschaften sind in dieser Ansicht schreibgeschützt.", + "wsPropertiesProtected": "Dieses Element ist geschützt. Bearbeiten Sie seine Quelle.", + "wsPropertiesConflict": "Dokument oder Element geändert. Entwurf zurücksetzen und erneut auswählen.", + "wsPropertiesUnresolved": "Der Verweis kann derzeit nicht aufgelöst werden.", + "wsUseDefault": "Standard verwenden", + "wsPropertiesSelection": "Ein einzelnes Element auswählen.", + "wsImageDisplay": "Bilddarstellung", + "wsImageThumbnail": "Anklickbare Miniaturansicht", + "wsEnabled": "Aktiviert", + "wsDisabled": "Deaktiviert", + "wsWidth": "Breite", + "wsHeight": "Höhe", + "wsPropertiesInactive": "Ohne veröffentlichtes Einklappen hat diese Eigenschaft keine Wirkung.", + "wsNavigationAll": "Alle Elemente mit Titel", + "wsNavigationNone": "Keine Navigation", + "wsNavigationCustom": "Benutzerdefinierte Navigation", + "wsTitleOverride": "Titel überschreiben", + "wsCompactLayout": "Kompakt", + "wsWideLayout": "Breit", + "wsCodeSourceConflict": "Verschieben oder entfernen Sie den Code in diesem Block, bevor Sie src setzen. Writerside lehnt nicht leere Codeblöcke mit einer Quellreferenz ab.", + "wsInherited": "Von Liste erben", + "wsExternalImageSize": "Writerside skaliert extern gehostete Bilder nicht zuverlässig. Vorhandene Maße können geprüft und entfernt werden.", + "wsPropertiesNotSerialized": "Diese Änderung wurde nicht in den Quelltext übernommen. Der Entwurf wurde beibehalten.", + "wsListItem": "Listeneintrag", + "wsSourceFile": "Quelldatei", + "wsLinkTarget": "Linkziel", + "wsPreviewImage": "Vorschaubild", + "wsIncludedElement": "Eingebundenes Element", + "wsHelpModule": "Hilfemodul", + "wsActionId": "Aktions-ID", + "wsKeymapHelpInstance": "Hilfeinstanz der Tastaturbelegung", + "wsShowInSource": "Im Quelltext anzeigen", + "wsOriginElement": "Elementüberschreibung", + "wsOriginAncestor": "Übergeordnetes Element", + "wsOriginModule": "Modulstandard", + "wsOriginBuiltIn": "Integrierter Standard", + "wsOriginUnresolved": "Nicht aufgelöst", + "wsEffectiveProperty": "Effektiv: {value} — {origin}", + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Generiert: {anchor}", + "wsDefaultProperty": "Standard: {value}", + "wsDefaultPropertyOrigin": "Standard: {value} · {origin}", + "wsResolvedProperty": "Aufgelöst: {value}", + "wsPropertyUnresolved": "Nicht aufgelöst", + "wsModule": "Modul", + "wsParent": "Übergeordnetes Element", + "wsListType": "Listentyp", + "wsStartingNumber": "Startnummer", + "wsListSorting": "Sortierung", + "wsLetteredList": "Buchstabenliste", + "wsCheckboxList": "Liste mit Kontrollkästchen", + "wsMarkerlessList": "Liste ohne Aufzählungszeichen", + "wsAscending": "Aufsteigend", + "wsDescending": "Absteigend", + "wsNoSorting": "Keine Sortierung", + "wsAsWritten": "Wie geschrieben", + "wsSentenceCase": "Satzschreibung", + "wsTitleCase": "Titelschreibung", + "wsLowerCase": "Kleinbuchstaben", + "wsUpperCase": "Großbuchstaben", + "wsNativeMarkers": "Markdown-Listenzeichen", + "wsListMarkerStart": "Listenbeginn: {number}", + "wsListLevel": "Ebene {level}", + "wsModuleCapsIgnored": "Der festgelegte Builder ignoriert <caps>-Regeln des Moduls. Verwenden Sie ein lokales caps-Attribut oder default-property.", + "wsInsertElements": "Elemente einfügen", + "wsAdmonitionsAndQuotes": "Hinweise und Zitate", + "wsSemanticText": "Semantischer Text", + "wsPropertiesUnavailable": "Das betrachtete Element ist nicht verfügbar. Wählen Sie ein Element zum Fortfahren." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30936f30..fa1d3be4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,59 @@ { + "wsModuleCapsNotPublished": "Module capitalization is not published.", + "wsTitleOverridesAction": "Instance and TOC titles…", + "wsHeadingNotPublished": "Not published by this builder.", + "@wsTitleOverridesAction": {"description": "Opens the existing instance-specific and TOC title workflow, separate from the default title."}, + "@wsHeadingNotPublished": {"description": "Compact property-specific publishing limitation; full explanation remains in contextual help."}, + "wsMarkdownInstanceTitleLimitation": "The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.", + "@wsMarkdownInstanceTitleLimitation": {"description": "Pinned-builder limitation shown in the existing instance/TOC title workflow."}, + "wsTopicIdentity": "Topic ID", + "@wsTopicIdentity": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsElementAnchor": "Element anchor", + "@wsElementAnchor": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsHeadingText": "Heading text", + "@wsHeadingText": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsDefaultTitle": "Default title", + "@wsDefaultTitle": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsTopicNavigation": "Topic navigation", + "@wsTopicNavigation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsHeadingOptions": "Heading attributes", + "@wsHeadingOptions": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsDocumentOptions": "Document settings", + "@wsDocumentOptions": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsSettingsPresent": "Authored settings present", + "@wsSettingsPresent": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsTitleOverrideExplanation": "Overrides the published heading title; the heading text remains unchanged.", + "@wsTitleOverrideExplanation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsIgnoreVariables": "Treat variable text literally", + "@wsIgnoreVariables": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsIgnoreVariablesExplanation": "When enabled, variable-looking text is not substituted.", + "@wsIgnoreVariablesExplanation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsInstanceSelection": "Help instances", + "@wsInstanceSelection": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsInstanceExplanation": "Select help outputs with names, groups, lists, exclusions, or expressions.", + "@wsInstanceExplanation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsFilterExplanation": "Filter reusable content by criteria; this does not select a help instance.", + "@wsFilterExplanation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsContextHelp": "Context help ID", + "@wsContextHelp": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsCapitalization": "Capitalization", + "@wsCapitalization": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsLibraryTopic": "Library topic", + "@wsLibraryTopic": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsLastModified": "Last modified", + "@wsLastModified": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsMissingTopicHeading": "No topic H1 is authored. Add the topic heading in Source.", + "@wsMissingTopicHeading": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsMultipleTopicHeadings": "The first H1 owns the topic title. Later H1s are separate headings.", + "@wsMultipleTopicHeadings": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsTopicIdentityMismatch": "The authored root id differs from the filename-derived Topic ID. Use Rename topic file or inspect Source.", + "@wsTopicIdentityMismatch": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsUnsupportedProperties": "Unsupported authored attributes are preserved. Inspect them in Source.", + "@wsUnsupportedProperties": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsGeneratedAnchor": "Generated anchor; setting a value authors an override.", + "@wsGeneratedAnchor": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, + "wsHeadingBuilderLimitation": "The pinned builder does not publish topic H1 attributes: inline attributes render literally and separate-line attributes are ignored. These belong to the heading, not the XML root.", + "@wsHeadingBuilderLimitation": {"description": "Writerside inspector source ownership, applicability, or field explanation."}, "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...", @@ -2856,5 +2911,251 @@ "nextcloudPossibleCandidate": "Possible sanitized request match; identity is unconfirmed.", "nextcloudSelectCandidate": "Select a server note to review", "nextcloudCreateSeparate": "Create a separate note", - "nextcloudCreateSeparateWarning": "The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted." + "nextcloudCreateSeparateWarning": "The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.", + "wsSemanticFormats": "Semantic formatting", + "wsUiControl": "UI Control", + "wsFilePath": "File / Path", + "wsUiPath": "UI Path", + "wsDefinitionList": "Definition List", + "wsIncludeContent": "Include Reusable Content", + "wsVariableReference": "Variable Reference", + "wsConvertList": "Convert List to Procedure", + "wsElementProperties": "Element Properties", + "wsTopicProperties": "Topic Properties", + "wsCollapsible": "Collapsible", + "wsPublishedState": "Published initial state", + "wsExpanded": "Expanded", + "wsCollapsed": "Collapsed", + "wsCollapsedTitle": "Collapsed title", + "wsTerm": "Term", + "wsListLayout": "List layout", + "wsSyncGroup": "Synchronization group", + "wsTabKey": "Tab key", + "wsSwitcherLabel": "Switcher label", + "wsNavigationElements": "Navigation elements", + "wsNavigationDepth": "Navigation depth", + "wsAddItem": "Add item", + "wsStep": "Step", + "wsPropertyInvalid": "Invalid property value", + "wsFilter": "Filter", + "wsIdentifier": "Element ID", + "wsTldr": "TLDR", + "wsFullLayout": "Full width", + "wsMediumLayout": "Medium (1:2)", + "wsNarrowLayout": "Narrow term", + "wsTitle": "Title", + "wsSwitcherKey": "Switcher key", + "nextcloudServerSettings": "Server Notes settings", + "nextcloudNotesPath": "Notes folder path", + "nextcloudFileSuffix": "New-note file suffix", + "nextcloudServerSettingsExplanation": "These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.", + "nextcloudSettingsSaved": "Server settings saved", + "nextcloudSettingsNormalized": "Saved with server-normalized values", + "nextcloudPendingUpload": "Attachment upload pending", + "nextcloudServerCheck": "Last server check: {time}", + "@nextcloudServerCheck": {"placeholders": {"time": {"type": "String"}}}, + "nextcloudSettingsLoading": "Loading server settings", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Saving server settings", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Previous local edit", + "nextcloudUsePreviousEdit": "Use previous edit", + "@nextcloudPreviousLocalEdit": { + "description": "Label for the competing accepted local metadata edit, distinct from server state." + }, + "@nextcloudUsePreviousEdit": { + "description": "Conflict action that keeps the exact previously reviewed local metadata values." + }, + "quickOpen": "Quick Open", + "notesAll": "All notes", + "notesUncategorized": "Uncategorized", + "notesRecovery": "Recovery", + "notesRecentlyEdited": "Recently edited", + "notesOldestEdited": "Oldest edited", + "notesTitleAscending": "Title A–Z", + "notesTitleDescending": "Title Z–A", + "notesMove": "Move to category", + "notesRemoveFavorite": "Remove favorite", + "notesOffline": "Make available offline", + "notesOfflineRemove": "Remove offline requirement", + "notesIndexing": "Indexing notes…", + "notesSearchHelp": "Terms use AND. Use \"quoted phrases\", title: and category:.", + "notesSourceLocation": "Source location", + "notesRecoveryHelp": "Retained local notes and Local History only. Server trash and history are unavailable.", + "notesImport": "Import notes", + "notesExport": "Export notes", + "notesImportDistinct": "Import as a distinct note", + "notesAvailableOffline": "Available offline", + "notesIncomplete": "Incomplete", + "notesSelected": "Selected notes", + "notesNoHistory": "No retained revisions. History may be disabled, expired or cleared.", + "notesTextAvailable": "Text available locally", + "notesLocalVersion": "Local working version", + "notesAttachments": "Attachments", + "notesSkipped": "Skipped", + "notesFailed": "Failed", + "notesCompleted": "Completed", + "notesImportNew": "Start a new import", + "notesImportResume": "Resume interrupted import", + "notesImportAlreadyImported": "Already imported in this operation", + "references": "References", + "referenceIncoming": "Incoming", + "referenceOutgoing": "Outgoing", + "referenceChooseTarget": "Choose link target", + "referenceWholeDocument": "Whole document", + "referenceSourceLocation": "Source location", + "referenceTargetLocation": "Target location", + "referenceResolved": "Resolved", + "referenceMissing": "Missing", + "referenceAmbiguous": "Ambiguous", + "referenceUnavailable": "Unavailable in this context", + "referenceUnsupported": "Unsupported", + "referenceNoPortableId": "This target has no verified portable note URL.", + "wsIncludeLines": "Include lines", + "wsIncludeSymbol": "Include symbol", + "wsEmphasizeLines": "Emphasize displayed lines", + "wsPropertiesReadOnly": "Properties are read-only in this view.", + "wsPropertiesProtected": "This element is protected. Edit its source to change properties.", + "wsPropertiesConflict": "The document or element changed. Revert this draft and inspect the current element.", + "wsPropertiesUnresolved": "The reference cannot currently be resolved.", + "wsUseDefault": "Use default", + "wsPropertiesSelection": "Select a single element to inspect its properties.", + "wsImageDisplay": "Image display", + "wsImageThumbnail": "Clickable thumbnail", + "wsEnabled": "Enabled", + "wsDisabled": "Disabled", + "wsWidth": "Width", + "wsHeight": "Height", + "wsPropertiesInactive": "This property has no effect while published collapse is disabled.", + "wsNavigationAll": "All titled elements", + "wsNavigationNone": "No navigation", + "wsNavigationCustom": "Custom navigation", + "wsTitleOverride": "Title override", + "wsCompactLayout": "Compact", + "wsWideLayout": "Wide", + "wsCodeSourceConflict": "Move or remove the code authored in this block before setting src. Writerside rejects a nonempty code block with a source reference.", + "wsInherited": "Inherit from list", + "wsExternalImageSize": "Writerside does not reliably size externally hosted images. Existing dimensions can be inspected and removed.", + "wsPropertiesNotSerialized": "This change did not reach the source. The draft has been retained.", + "wsListItem": "List item", + "wsSourceFile": "Source file", + "wsLinkTarget": "Link target", + "wsPreviewImage": "Preview image", + "wsIncludedElement": "Included element", + "wsHelpModule": "Help module", + "wsActionId": "Action ID", + "wsKeymapHelpInstance": "Keymap help instance", + "wsShowInSource": "Show in Source", + "wsOriginElement": "element override", + "wsOriginAncestor": "parent element", + "wsOriginModule": "module default", + "wsOriginBuiltIn": "built-in default", + "wsOriginUnresolved": "unresolved", + "wsEffectiveProperty": "Effective: {value} — {origin}", + "@wsEffectiveProperty": { + "description": "Effective Writerside property value and its localized origin. Attribute names are displayed separately without translation.", + "placeholders": { + "value": { + "type": "String" + }, + "origin": { + "type": "String" + } + } + }, + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Generated: {anchor}", + "wsDefaultProperty": "Default: {value}", + "wsDefaultPropertyOrigin": "Default: {value} · {origin}", + "wsResolvedProperty": "Resolved: {value}", + "wsPropertyUnresolved": "Unresolved", + "wsModule": "Module", + "wsParent": "Parent", + "@wsElementSelector": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged." + }, + "@wsGeneratedAnchorValue": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged.", + "placeholders": { + "anchor": { + "type": "String" + } + } + }, + "@wsDefaultProperty": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "@wsDefaultPropertyOrigin": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged.", + "placeholders": { + "value": { + "type": "String" + }, + "origin": { + "type": "String" + } + } + }, + "@wsResolvedProperty": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged.", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "@wsPropertyUnresolved": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged." + }, + "@wsModule": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged." + }, + "@wsParent": { + "description": "Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged." + }, + "wsListType": "List type", + "@wsListType": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsStartingNumber": "Starting number", + "@wsStartingNumber": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsListSorting": "Sorting", + "@wsListSorting": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsLetteredList": "Lettered list", + "@wsLetteredList": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsCheckboxList": "Checkbox list", + "@wsCheckboxList": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsMarkerlessList": "List without markers", + "@wsMarkerlessList": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsAscending": "Ascending", + "@wsAscending": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsDescending": "Descending", + "@wsDescending": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsNoSorting": "No sorting", + "@wsNoSorting": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsAsWritten": "As written", + "@wsAsWritten": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsSentenceCase": "Sentence case", + "@wsSentenceCase": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsTitleCase": "Title case", + "@wsTitleCase": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsLowerCase": "Lowercase", + "@wsLowerCase": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsUpperCase": "Uppercase", + "@wsUpperCase": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsNativeMarkers": "Markdown markers", + "@wsNativeMarkers": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsListMarkerStart": "Marker start: {number}", + "@wsListMarkerStart": {"description": "Writerside list ownership or verified capitalization presentation.", "placeholders": {"number": {"type": "String"}}}, + "wsListLevel": "Level {level}", + "@wsListLevel": {"description": "Writerside list ownership or verified capitalization presentation.", "placeholders": {"level": {"type": "String"}}}, + "wsModuleCapsIgnored": "The pinned builder ignores module <caps> rules. Use a local caps override or default-property.", + "@wsModuleCapsIgnored": {"description": "Writerside list ownership or verified capitalization presentation."}, + "wsInsertElements": "Insert elements", + "wsAdmonitionsAndQuotes": "Admonitions and quotes", + "wsSemanticText": "Semantic text", + "wsPropertiesUnavailable": "The inspected element is unavailable. Inspect an element to continue." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 919eb98b..38458714 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "No se publica la capitalización del módulo.", + "wsTitleOverridesAction": "Títulos de instancia e índice…", + "wsHeadingNotPublished": "Este generador no lo publica.", + "wsMarkdownInstanceTitleLimitation": "El constructor fijado no publica títulos por instancia en temas Markdown. Se conserva el marcado existente; examínelo en el código fuente.", + "wsTopicIdentity": "ID del tema", + "wsElementAnchor": "Ancla del elemento", + "wsHeadingText": "Texto del encabezado", + "wsDefaultTitle": "Título predeterminado", + "wsTopicNavigation": "Navegación del tema", + "wsHeadingOptions": "Atributos del encabezado", + "wsDocumentOptions": "Ajustes del documento", + "wsSettingsPresent": "Hay ajustes explícitos", + "wsTitleOverrideExplanation": "Reemplaza el título publicado; el texto del encabezado no cambia.", + "wsIgnoreVariables": "Tratar las variables como texto literal", + "wsIgnoreVariablesExplanation": "Al activarlo, el texto con aspecto de variable no se sustituye.", + "wsInstanceSelection": "Instancias de ayuda", + "wsInstanceExplanation": "Seleccione salidas de ayuda mediante nombres, grupos, listas, exclusiones o expresiones.", + "wsFilterExplanation": "Filtra contenido reutilizable por criterios; no selecciona una instancia de ayuda.", + "wsContextHelp": "ID de ayuda contextual", + "wsCapitalization": "Mayúsculas y minúsculas", + "wsLibraryTopic": "Tema de biblioteca", + "wsLastModified": "Última modificación", + "wsMissingTopicHeading": "No hay un H1 de tema. Añádalo en el código fuente.", + "wsMultipleTopicHeadings": "El primer H1 define el título del tema. Los demás H1 son encabezados independientes.", + "wsTopicIdentityMismatch": "El id raíz difiere del ID derivado del archivo. Use Renombrar archivo de tema o examine el código fuente.", + "wsUnsupportedProperties": "Se conservan los atributos no compatibles. Examínelos en el código fuente.", + "wsGeneratedAnchor": "Ancla generada; introducir un valor crea una anulación.", + "wsHeadingBuilderLimitation": "El constructor fijado no publica los atributos del H1. Pertenecen al encabezado, no a la raíz XML.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Posible coincidencia con la solicitud saneada; identidad sin confirmar.", "nextcloudSelectCandidate": "Seleccionar una nota del servidor para revisarla", "nextcloudCreateSeparate": "Crear una nota aparte", - "nextcloudCreateSeparateWarning": "La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado." + "nextcloudCreateSeparateWarning": "La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado.", + "wsSemanticFormats": "Formato semántico", + "wsUiControl": "Control de interfaz", + "wsFilePath": "Archivo / Ruta", + "wsUiPath": "Ruta de interfaz", + "wsDefinitionList": "Lista de definiciones", + "wsIncludeContent": "Incluir contenido reutilizable", + "wsVariableReference": "Referencia de variable", + "wsConvertList": "Convertir lista en procedimiento", + "wsElementProperties": "Propiedades del elemento", + "wsTopicProperties": "Propiedades del tema", + "wsCollapsible": "Contraíble", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Contraído", + "wsCollapsedTitle": "Título contraído", + "wsTerm": "Término", + "wsListLayout": "Diseño de la lista", + "wsSyncGroup": "Grupo de sincronización", + "wsTabKey": "Clave de pestaña", + "wsSwitcherLabel": "Etiqueta del selector", + "wsNavigationElements": "Elementos de navegación", + "wsNavigationDepth": "Profundidad de navegación", + "wsAddItem": "Añadir elemento", + "wsStep": "Paso", + "wsPropertyInvalid": "Valor de propiedad no válido", + "wsFilter": "Filtro", + "wsIdentifier": "ID del elemento", + "wsTldr": "Resumen (TLDR)", + "wsFullLayout": "Ancho completo", + "wsMediumLayout": "Medio (1:2)", + "wsNarrowLayout": "Término estrecho", + "wsTitle": "Título", + "wsSwitcherKey": "Clave del selector", + "nextcloudServerSettings": "Ajustes de Notes del servidor", + "nextcloudNotesPath": "Ruta de la carpeta de notas", + "nextcloudFileSuffix": "Extensión de las notas nuevas", + "nextcloudServerSettingsExplanation": "Estos ajustes de Notes afectan a otros clientes. Cambiar la ruta no mueve archivos; cambiar la extensión no renombra las notas existentes.", + "nextcloudSettingsSaved": "Ajustes del servidor guardados", + "nextcloudSettingsNormalized": "Guardado con valores normalizados por el servidor", + "nextcloudPendingUpload": "Carga de adjunto pendiente", + "nextcloudServerCheck": "Última comprobación del servidor: {time}", + "nextcloudSettingsLoading": "Cargando ajustes del servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Guardando ajustes del servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edición local anterior", + "nextcloudUsePreviousEdit": "Usar edición anterior", + "quickOpen": "Apertura rápida", + "notesAll": "Todas las notas", + "notesUncategorized": "Sin categoría", + "notesRecovery": "Recuperación", + "notesRecentlyEdited": "Editadas recientemente", + "notesOldestEdited": "Ediciones más antiguas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover a categoría", + "notesRemoveFavorite": "Quitar favorito", + "notesOffline": "Hacer disponible sin conexión", + "notesOfflineRemove": "Quitar conservación sin conexión", + "notesIndexing": "Indexando notas…", + "notesSearchHelp": "Los términos usan Y. Use \"frases entre comillas\", title: y category:.", + "notesSourceLocation": "Ubicación en el código fuente", + "notesRecoveryHelp": "Solo notas locales conservadas e historial local. La papelera y el historial del servidor no están disponibles.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como una nota distinta", + "notesAvailableOffline": "Disponible sin conexión", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas seleccionadas", + "notesNoHistory": "No hay revisiones conservadas. El historial puede estar desactivado, caducado o borrado.", + "notesTextAvailable": "Texto disponible localmente", + "notesLocalVersion": "Versión de trabajo local", + "notesAttachments": "Adjuntos", + "notesSkipped": "Omitido", + "notesFailed": "Fallido", + "notesCompleted": "Completado", + "notesImportNew": "Iniciar una nueva importación", + "notesImportResume": "Reanudar importación interrumpida", + "notesImportAlreadyImported": "Ya importado en esta operación", + "references": "Referencias", + "referenceIncoming": "Entrantes", + "referenceOutgoing": "Salientes", + "referenceChooseTarget": "Elegir destino del enlace", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Ubicación de origen", + "referenceTargetLocation": "Ubicación de destino", + "referenceResolved": "Resuelta", + "referenceMissing": "No encontrada", + "referenceAmbiguous": "Ambigua", + "referenceUnavailable": "No disponible en este contexto", + "referenceUnsupported": "No compatible", + "referenceNoPortableId": "Este destino no tiene una URL portátil de nota verificada.", + "wsIncludeLines": "Incluir líneas", + "wsIncludeSymbol": "Incluir símbolo", + "wsEmphasizeLines": "Resaltar líneas mostradas", + "wsPropertiesReadOnly": "Propiedades de solo lectura en esta vista.", + "wsPropertiesProtected": "Este elemento está protegido. Edite su fuente.", + "wsPropertiesConflict": "El documento o elemento cambió. Revierta el borrador y seleccione de nuevo.", + "wsPropertiesUnresolved": "No se puede resolver la referencia actualmente.", + "wsUseDefault": "Usar valor predeterminado", + "wsPropertiesSelection": "Seleccione un solo elemento.", + "wsImageDisplay": "Visualización de imagen", + "wsImageThumbnail": "Miniatura pulsable", + "wsEnabled": "Activado", + "wsDisabled": "Desactivado", + "wsWidth": "Ancho", + "wsHeight": "Alto", + "wsPropertiesInactive": "Esta propiedad no tiene efecto sin contraer en la publicación.", + "wsNavigationAll": "Todos los elementos con título", + "wsNavigationNone": "Sin navegación", + "wsNavigationCustom": "Navegación personalizada", + "wsTitleOverride": "Sustituir título", + "wsCompactLayout": "Compacto", + "wsWideLayout": "Ancho", + "wsCodeSourceConflict": "Mueve o elimina el código escrito en este bloque antes de establecer src. Writerside rechaza bloques de código no vacíos con una referencia de origen.", + "wsInherited": "Heredar de la lista", + "wsExternalImageSize": "Writerside no dimensiona con fiabilidad las imágenes externas. Puede revisar y quitar dimensiones existentes.", + "wsPropertiesNotSerialized": "Este cambio no se aplicó al código fuente. Se ha conservado el borrador.", + "wsListItem": "Elemento de lista", + "wsSourceFile": "Archivo de origen", + "wsLinkTarget": "Destino del enlace", + "wsPreviewImage": "Imagen de vista previa", + "wsIncludedElement": "Elemento incluido", + "wsHelpModule": "Módulo de ayuda", + "wsActionId": "ID de acción", + "wsKeymapHelpInstance": "Instancia de ayuda del mapa de teclas", + "wsShowInSource": "Mostrar en Fuente", + "wsOriginElement": "Valor del elemento", + "wsOriginAncestor": "Elemento padre", + "wsOriginModule": "Predeterminado del módulo", + "wsOriginBuiltIn": "Predeterminado integrado", + "wsOriginUnresolved": "Sin resolver", + "wsEffectiveProperty": "Efectivo: {value} — {origin}", + "wsElementSelector": "Elemento", + "wsGeneratedAnchorValue": "Generado: {anchor}", + "wsDefaultProperty": "Predeterminado: {value}", + "wsDefaultPropertyOrigin": "Predeterminado: {value} · {origin}", + "wsResolvedProperty": "Resuelto: {value}", + "wsPropertyUnresolved": "Sin resolver", + "wsModule": "Módulo", + "wsParent": "Elemento padre", + "wsListType": "Tipo de lista", + "wsStartingNumber": "Número inicial", + "wsListSorting": "Ordenación", + "wsLetteredList": "Lista alfabética", + "wsCheckboxList": "Lista de casillas", + "wsMarkerlessList": "Lista sin marcadores", + "wsAscending": "Ascendente", + "wsDescending": "Descendente", + "wsNoSorting": "Sin ordenar", + "wsAsWritten": "Como está escrito", + "wsSentenceCase": "Mayúscula inicial", + "wsTitleCase": "Mayúsculas de título", + "wsLowerCase": "Minúsculas", + "wsUpperCase": "Mayúsculas", + "wsNativeMarkers": "Marcadores Markdown", + "wsListMarkerStart": "Inicio del marcador: {number}", + "wsListLevel": "Nivel {level}", + "wsModuleCapsIgnored": "El compilador fijado ignora las reglas <caps> del módulo. Use un atributo caps local o default-property.", + "wsInsertElements": "Insertar elementos", + "wsAdmonitionsAndQuotes": "Avisos y citas", + "wsSemanticText": "Texto semántico", + "wsPropertiesUnavailable": "El elemento inspeccionado no está disponible. Inspeccione otro elemento para continuar." } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 22fbad6f..0a9e9b77 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Mooduli suurtähereeglit ei avaldata.", + "wsTitleOverridesAction": "Eksemplari ja sisukorra pealkirjad…", + "wsHeadingNotPublished": "See koostaja ei avalda seda.", + "wsMarkdownInstanceTitleLimitation": "Kinnitatud koostaja ei avalda Markdowni teemade instantsipealkirju. Olemasolev märgistus säilitatakse; vaata lähteteksti.", + "wsTopicIdentity": "Teema ID", + "wsElementAnchor": "Elemendi ankur", + "wsHeadingText": "Pealkirja tekst", + "wsDefaultTitle": "Vaikepealkiri", + "wsTopicNavigation": "Teema navigeerimine", + "wsHeadingOptions": "Pealkirja atribuudid", + "wsDocumentOptions": "Dokumendi sätted", + "wsSettingsPresent": "Koostatud sätted olemas", + "wsTitleOverrideExplanation": "Asendab avaldatud pealkirja; pealkirja tekst ei muutu.", + "wsIgnoreVariables": "Käsitle muutujaid sõnasõnalise tekstina", + "wsIgnoreVariablesExplanation": "Lubamisel ei asendata muutujalaadset teksti.", + "wsInstanceSelection": "Spikri instantsid", + "wsInstanceExplanation": "Vali väljundid nimede, rühmade, loendite, välistuste või avaldistega.", + "wsFilterExplanation": "Filtreerib korduvkasutatavat sisu tingimuste järgi; ei vali spikri instantsi.", + "wsContextHelp": "Kontekstispikri ID", + "wsCapitalization": "Suur- ja väiketähed", + "wsLibraryTopic": "Teegi teema", + "wsLastModified": "Viimati muudetud", + "wsMissingTopicHeading": "Teema H1 puudub. Lisa see lähtetekstis.", + "wsMultipleTopicHeadings": "Esimene H1 määrab teema pealkirja. Hilisemad H1-d on eraldi pealkirjad.", + "wsTopicIdentityMismatch": "Juure id erineb failinimest tulenevast teema ID-st. Nimeta teemafail ümber või vaata lähteteksti.", + "wsUnsupportedProperties": "Toetamata atribuudid säilitatakse. Vaata neid lähtetekstis.", + "wsGeneratedAnchor": "Loodud ankur; väärtus loob ülekirjutuse.", + "wsHeadingBuilderLimitation": "Kinnitatud koostaja ei avalda H1 atribuute. Need kuuluvad pealkirjale, mitte XML-juurele.", "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...", @@ -3879,5 +3907,168 @@ "nextcloudPossibleCandidate": "Võimalik puhastatud päringu vaste; identiteet on kinnitamata.", "nextcloudSelectCandidate": "Vali ülevaatamiseks serverimärkus", "nextcloudCreateSeparate": "Loo eraldi märkus", - "nextcloudCreateSeparateWarning": "Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata." + "nextcloudCreateSeparateWarning": "Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata.", + "wsSemanticFormats": "Semantiline vormindus", + "wsUiControl": "Kasutajaliidese juhtelement", + "wsFilePath": "Fail / Tee", + "wsUiPath": "Kasutajaliidese tee", + "wsDefinitionList": "Definitsiooniloend", + "wsIncludeContent": "Lisa korduskasutatav sisu", + "wsVariableReference": "Muutuja viide", + "wsConvertList": "Teisenda loend protseduuriks", + "wsElementProperties": "Elemendi omadused", + "wsTopicProperties": "Teema omadused", + "wsCollapsible": "Ahendatav", + "wsPublishedState": "Avaldatud algolek", + "wsExpanded": "Laiendatud", + "wsCollapsed": "Ahendatud", + "wsCollapsedTitle": "Ahendatud pealkiri", + "wsTerm": "Termin", + "wsListLayout": "Loendi paigutus", + "wsSyncGroup": "Sünkroonimisrühm", + "wsTabKey": "Vahelehe võti", + "wsSwitcherLabel": "Lüliti silt", + "wsNavigationElements": "Navigeerimise elemendid", + "wsNavigationDepth": "Navigeerimise sügavus", + "wsAddItem": "Lisa element", + "wsStep": "Samm", + "wsPropertyInvalid": "Vigane omaduse väärtus", + "wsFilter": "Sisufilter", + "wsIdentifier": "Elemendi ID", + "wsTldr": "Kokkuvõte (TLDR)", + "wsFullLayout": "Täislaius", + "wsMediumLayout": "Keskmine (1:2)", + "wsNarrowLayout": "Kitsas terminiveerg", + "wsTitle": "Pealkiri", + "wsSwitcherKey": "Valikulüliti võti", + "nextcloudServerSettings": "Serveri Notesi seaded", + "nextcloudNotesPath": "Märkmete kausta tee", + "nextcloudFileSuffix": "Uute märkmete faililaiend", + "nextcloudServerSettingsExplanation": "Need Notesi seaded mõjutavad teisi kliente. Tee muutmine ei teisalda faile; laiendi muutmine ei nimeta olemasolevaid märkmeid ümber.", + "nextcloudSettingsSaved": "Serveri seaded salvestatud", + "nextcloudSettingsNormalized": "Salvestatud serveri normaliseeritud väärtustega", + "nextcloudPendingUpload": "Manuse üleslaadimine ootel", + "nextcloudServerCheck": "Viimane serverikontroll: {time}", + "nextcloudSettingsLoading": "Serveri seadete laadimine", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Serveri seadete salvestamine", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Eelmine kohalik muudatus", + "nextcloudUsePreviousEdit": "Kasuta eelmist muudatust", + "quickOpen": "Kiiravamine", + "notesAll": "Kõik märkmed", + "notesUncategorized": "Kategooriata", + "notesRecovery": "Taastamine", + "notesRecentlyEdited": "Hiljuti muudetud", + "notesOldestEdited": "Vanim muutmine", + "notesTitleAscending": "Pealkiri A–Z", + "notesTitleDescending": "Pealkiri Z–A", + "notesMove": "Teisalda kategooriasse", + "notesRemoveFavorite": "Eemalda lemmik", + "notesOffline": "Tee võrguta kättesaadavaks", + "notesOfflineRemove": "Eemalda võrguta säilitamine", + "notesIndexing": "Märkmete indekseerimine…", + "notesSearchHelp": "Mõisted ühendatakse JA abil. Kasuta \"tsiteeritud fraase\", title: ja category:.", + "notesSourceLocation": "Lähtekoht", + "notesRecoveryHelp": "Ainult säilitatud kohalikud märkmed ja kohalik ajalugu. Serveri prügikast ja ajalugu pole saadaval.", + "notesImport": "Impordi märkmed", + "notesExport": "Ekspordi märkmed", + "notesImportDistinct": "Impordi eraldi märkmena", + "notesAvailableOffline": "Võrguta saadaval", + "notesIncomplete": "Mittetäielik", + "notesSelected": "Valitud märkmed", + "notesNoHistory": "Säilitatud versioone pole. Ajalugu võib olla keelatud, aegunud või kustutatud.", + "notesTextAvailable": "Tekst on kohalikult saadaval", + "notesLocalVersion": "Kohalik tööversioon", + "notesAttachments": "Manused", + "notesSkipped": "Vahele jäetud", + "notesFailed": "Ebaõnnestus", + "notesCompleted": "Lõpetatud", + "notesImportNew": "Alusta uut importimist", + "notesImportResume": "Jätka katkestatud importimist", + "notesImportAlreadyImported": "Selles toimingus juba imporditud", + "references": "Viited", + "referenceIncoming": "Sissetulevad", + "referenceOutgoing": "Väljaminevad", + "referenceChooseTarget": "Vali lingi siht", + "referenceWholeDocument": "Kogu dokument", + "referenceSourceLocation": "Allika asukoht", + "referenceTargetLocation": "Sihtkoha asukoht", + "referenceResolved": "Lahendatud", + "referenceMissing": "Puudub", + "referenceAmbiguous": "Mitmetähenduslik", + "referenceUnavailable": "Selles kontekstis pole saadaval", + "referenceUnsupported": "Pole toetatud", + "referenceNoPortableId": "Sellel sihtkohal pole kinnitatud teisaldatavat märkme URL-i.", + "wsIncludeLines": "Kaasa read", + "wsIncludeSymbol": "Kaasa sümbol", + "wsEmphasizeLines": "Tõsta kuvatud read esile", + "wsPropertiesReadOnly": "Selles vaates on omadused kirjutuskaitstud.", + "wsPropertiesProtected": "Element on kaitstud. Muuda lähtekoodi.", + "wsPropertiesConflict": "Dokument või element muutus. Taasta mustand ja vali uuesti.", + "wsPropertiesUnresolved": "Viidet ei saa praegu lahendada.", + "wsUseDefault": "Kasuta vaikeväärtust", + "wsPropertiesSelection": "Vali üks element.", + "wsImageDisplay": "Pildi kuvamine", + "wsImageThumbnail": "Klõpsatav pisipilt", + "wsEnabled": "Lubatud", + "wsDisabled": "Keelatud", + "wsWidth": "Laius", + "wsHeight": "Kõrgus", + "wsPropertiesInactive": "Omadus toimib ainult avaldatud ahendamise korral.", + "wsNavigationAll": "Kõik pealkirjaga elemendid", + "wsNavigationNone": "Navigeerimine puudub", + "wsNavigationCustom": "Kohandatud navigeerimine", + "wsTitleOverride": "Pealkirja ülekirjutus", + "wsCompactLayout": "Kompaktne", + "wsWideLayout": "Lai", + "wsCodeSourceConflict": "Enne src määramist teisalda või eemalda sellesse plokki kirjutatud kood. Writerside ei luba lähteviitega mittetühja koodiplokki.", + "wsInherited": "Päri loendilt", + "wsExternalImageSize": "Writerside ei määra väliste piltide mõõtmeid usaldusväärselt. Olemasolevaid mõõtmeid saab vaadata ja eemaldada.", + "wsPropertiesNotSerialized": "Muudatust ei rakendatud lähtetekstile. Mustand on alles.", + "wsListItem": "Loendiüksus", + "wsSourceFile": "Lähtefail", + "wsLinkTarget": "Lingi siht", + "wsPreviewImage": "Eelvaatepilt", + "wsIncludedElement": "Kaasatud element", + "wsHelpModule": "Abimoodul", + "wsActionId": "Toimingu ID", + "wsKeymapHelpInstance": "Klahvikaardi abieksemplar", + "wsShowInSource": "Näita lähtekoodis", + "wsOriginElement": "Elemendi ülekirjutus", + "wsOriginAncestor": "Ülemelement", + "wsOriginModule": "Mooduli vaikeväärtus", + "wsOriginBuiltIn": "Sisseehitatud vaikeväärtus", + "wsOriginUnresolved": "Lahendamata", + "wsEffectiveProperty": "Tegelik: {value} — {origin}", + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Genereeritud: {anchor}", + "wsDefaultProperty": "Vaikimisi: {value}", + "wsDefaultPropertyOrigin": "Vaikimisi: {value} · {origin}", + "wsResolvedProperty": "Lahendatud: {value}", + "wsPropertyUnresolved": "Lahendamata", + "wsModule": "Moodul", + "wsParent": "Ülemelement", + "wsListType": "Loendi tüüp", + "wsStartingNumber": "Algusnumber", + "wsListSorting": "Sortimine", + "wsLetteredList": "Tähtedega loend", + "wsCheckboxList": "Märkeruutudega loend", + "wsMarkerlessList": "Markeriteta loend", + "wsAscending": "Kasvav", + "wsDescending": "Kahanev", + "wsNoSorting": "Sortimata", + "wsAsWritten": "Nagu kirjutatud", + "wsSentenceCase": "Lause tähesuurus", + "wsTitleCase": "Pealkirja tähesuurus", + "wsLowerCase": "Väiketähed", + "wsUpperCase": "Suurtähed", + "wsNativeMarkers": "Markdowni markerid", + "wsListMarkerStart": "Algusmarker: {number}", + "wsListLevel": "Tase {level}", + "wsModuleCapsIgnored": "Fikseeritud koostaja eirab mooduli <caps>-reegleid. Kasuta kohalikku caps-atribuuti või default-property sätet.", + "wsInsertElements": "Lisa elemendid", + "wsAdmonitionsAndQuotes": "Märkused ja tsitaadid", + "wsSemanticText": "Semantiline tekst", + "wsPropertiesUnavailable": "Vaadeldav element pole saadaval. Jätkamiseks vali element." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b880d7cc..a009276a 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "سبک حروف ماژول منتشر نمی‌شود.", + "wsTitleOverridesAction": "عنوان‌های نمونه و فهرست…", + "wsHeadingNotPublished": "این سازنده آن را منتشر نمی‌کند.", + "wsMarkdownInstanceTitleLimitation": "سازنده تثبیت‌شده عنوان نمونه را در موضوع‌های Markdown منتشر نمی‌کند. نشانه‌گذاری موجود حفظ می‌شود؛ منبع را بررسی کنید.", + "wsTopicIdentity": "شناسه موضوع", + "wsElementAnchor": "لنگر عنصر", + "wsHeadingText": "متن عنوان", + "wsDefaultTitle": "عنوان پیش‌فرض", + "wsTopicNavigation": "پیمایش موضوع", + "wsHeadingOptions": "ویژگی‌های عنوان", + "wsDocumentOptions": "تنظیمات سند", + "wsSettingsPresent": "تنظیمات صریح موجود است", + "wsTitleOverrideExplanation": "عنوان منتشرشده را بازنویسی می‌کند؛ متن عنوان تغییر نمی‌کند.", + "wsIgnoreVariables": "متغیرها را متن لفظی در نظر بگیر", + "wsIgnoreVariablesExplanation": "وقتی فعال باشد، متن شبیه متغیر جایگزین نمی‌شود.", + "wsInstanceSelection": "نمونه‌های راهنما", + "wsInstanceExplanation": "خروجی را با نام، گروه، فهرست، استثنا یا عبارت انتخاب کنید.", + "wsFilterExplanation": "محتوای قابل استفاده مجدد را با معیارها فیلتر می‌کند؛ نمونه راهنما را انتخاب نمی‌کند.", + "wsContextHelp": "شناسه راهنمای زمینه‌ای", + "wsCapitalization": "بزرگی حروف", + "wsLibraryTopic": "موضوع کتابخانه", + "wsLastModified": "آخرین تغییر", + "wsMissingTopicHeading": "H1 موضوع وجود ندارد. آن را در منبع اضافه کنید.", + "wsMultipleTopicHeadings": "اولین H1 عنوان موضوع را تعیین می‌کند. H1های بعدی مستقل‌اند.", + "wsTopicIdentityMismatch": "id ریشه با شناسه مشتق از نام فایل متفاوت است. فایل موضوع را تغییر نام دهید یا منبع را بررسی کنید.", + "wsUnsupportedProperties": "ویژگی‌های پشتیبانی‌نشده حفظ می‌شوند. در منبع بررسی کنید.", + "wsGeneratedAnchor": "لنگر تولیدشده؛ وارد کردن مقدار آن را بازنویسی می‌کند.", + "wsHeadingBuilderLimitation": "سازنده تثبیت‌شده ویژگی‌های H1 را منتشر نمی‌کند. متعلق به عنوان هستند، نه ریشه XML.", "closeOtherTabs": "بستن زبانه‌های دیگر", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "موضوع از الگو...", @@ -3461,5 +3489,168 @@ "nextcloudPossibleCandidate": "تطابق احتمالی درخواست پاک‌سازی‌شده؛ هویت تأیید نشده است.", "nextcloudSelectCandidate": "یک یادداشت سرور را برای بررسی انتخاب کنید", "nextcloudCreateSeparate": "ایجاد یک یادداشت جداگانه", - "nextcloudCreateSeparateWarning": "ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود." + "nextcloudCreateSeparateWarning": "ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود.", + "wsSemanticFormats": "قالب‌بندی معنایی", + "wsUiControl": "کنترل رابط کاربری", + "wsFilePath": "فایل / مسیر", + "wsUiPath": "مسیر رابط کاربری", + "wsDefinitionList": "فهرست تعریف‌ها", + "wsIncludeContent": "درج محتوای قابل استفاده مجدد", + "wsVariableReference": "ارجاع به متغیر", + "wsConvertList": "تبدیل فهرست به رویه", + "wsElementProperties": "ویژگی‌های عنصر", + "wsTopicProperties": "ویژگی‌های موضوع", + "wsCollapsible": "قابل جمع شدن", + "wsPublishedState": "حالت اولیه هنگام انتشار", + "wsExpanded": "باز", + "wsCollapsed": "جمع شده", + "wsCollapsedTitle": "عنوان در حالت جمع شده", + "wsTerm": "اصطلاح", + "wsListLayout": "چیدمان فهرست", + "wsSyncGroup": "گروه همگام‌سازی", + "wsTabKey": "کلید زبانه", + "wsSwitcherLabel": "برچسب تغییر‌دهنده", + "wsNavigationElements": "عناصر پیمایش", + "wsNavigationDepth": "عمق پیمایش", + "wsAddItem": "افزودن مورد", + "wsStep": "گام", + "wsPropertyInvalid": "مقدار ویژگی نامعتبر", + "wsFilter": "فیلتر", + "wsIdentifier": "شناسه عنصر", + "wsTldr": "خلاصه (TLDR)", + "wsFullLayout": "عرض کامل", + "wsMediumLayout": "متوسط (1:2)", + "wsNarrowLayout": "ستون باریک اصطلاح", + "wsTitle": "عنوان", + "wsSwitcherKey": "کلید انتخابگر", + "nextcloudServerSettings": "تنظیمات Notes سرور", + "nextcloudNotesPath": "مسیر پوشهٔ یادداشت‌ها", + "nextcloudFileSuffix": "پسوند فایل یادداشت‌های جدید", + "nextcloudServerSettingsExplanation": "این تنظیمات Notes روی کارخواه‌های دیگر هم اثر می‌گذارد. تغییر مسیر فایل‌ها را جابه‌جا نمی‌کند؛ تغییر پسوند نام یادداشت‌های موجود را تغییر نمی‌دهد.", + "nextcloudSettingsSaved": "تنظیمات سرور ذخیره شد", + "nextcloudSettingsNormalized": "با مقادیر نرمال‌شدهٔ سرور ذخیره شد", + "nextcloudPendingUpload": "بارگذاری پیوست در انتظار است", + "nextcloudServerCheck": "آخرین بررسی سرور: {time}", + "nextcloudSettingsLoading": "در حال بارگیری تنظیمات سرور", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "در حال ذخیره تنظیمات سرور", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "ویرایش محلی قبلی", + "nextcloudUsePreviousEdit": "استفاده از ویرایش قبلی", + "quickOpen": "باز کردن سریع", + "notesAll": "همه یادداشت‌ها", + "notesUncategorized": "بدون دسته", + "notesRecovery": "بازیابی", + "notesRecentlyEdited": "تازه ویرایش‌شده", + "notesOldestEdited": "قدیمی‌ترین ویرایش", + "notesTitleAscending": "عنوان صعودی", + "notesTitleDescending": "عنوان نزولی", + "notesMove": "انتقال به دسته", + "notesRemoveFavorite": "حذف از علاقه‌مندی‌ها", + "notesOffline": "در دسترس کردن آفلاین", + "notesOfflineRemove": "حذف نگهداری آفلاین", + "notesIndexing": "در حال نمایه‌سازی یادداشت‌ها…", + "notesSearchHelp": "واژه‌ها با AND ترکیب می‌شوند. از \"عبارت نقل‌شده\" و title: و category: استفاده کنید.", + "notesSourceLocation": "موقعیت در منبع", + "notesRecoveryHelp": "فقط یادداشت‌های محلی نگهداری‌شده و تاریخچه محلی. زباله‌دان و تاریخچه سرور در دسترس نیست.", + "notesImport": "وارد کردن یادداشت‌ها", + "notesExport": "صادر کردن یادداشت‌ها", + "notesImportDistinct": "وارد کردن به عنوان یادداشت جدا", + "notesAvailableOffline": "در دسترس آفلاین", + "notesIncomplete": "ناقص", + "notesSelected": "یادداشت‌های انتخاب‌شده", + "notesNoHistory": "نسخه نگهداری‌شده‌ای نیست. تاریخچه ممکن است غیرفعال، منقضی یا پاک شده باشد.", + "notesTextAvailable": "متن به صورت محلی در دسترس است", + "notesLocalVersion": "نسخهٔ کاری محلی", + "notesAttachments": "پیوست‌ها", + "notesSkipped": "رد شد", + "notesFailed": "ناموفق", + "notesCompleted": "تکمیل شد", + "notesImportNew": "شروع وارد کردن جدید", + "notesImportResume": "ادامه وارد کردن متوقف‌شده", + "notesImportAlreadyImported": "قبلاً در این عملیات وارد شده است", + "references": "ارجاع‌ها", + "referenceIncoming": "ورودی", + "referenceOutgoing": "خروجی", + "referenceChooseTarget": "انتخاب مقصد پیوند", + "referenceWholeDocument": "کل سند", + "referenceSourceLocation": "مکان منبع", + "referenceTargetLocation": "مکان مقصد", + "referenceResolved": "حل‌شده", + "referenceMissing": "یافت نشد", + "referenceAmbiguous": "مبهم", + "referenceUnavailable": "در این زمینه در دسترس نیست", + "referenceUnsupported": "پشتیبانی نمی‌شود", + "referenceNoPortableId": "این مقصد نشانی قابل‌انتقال و تأییدشده‌ای برای یادداشت ندارد.", + "wsIncludeLines": "شامل کردن خط‌ها", + "wsIncludeSymbol": "شامل کردن نماد", + "wsEmphasizeLines": "برجسته کردن خط‌های نمایش داده‌شده", + "wsPropertiesReadOnly": "ویژگی‌ها در این نما فقط خواندنی هستند.", + "wsPropertiesProtected": "این عنصر محافظت شده است. منبع آن را ویرایش کنید.", + "wsPropertiesConflict": "سند یا عنصر تغییر کرد. پیش‌نویس را برگردانید و دوباره انتخاب کنید.", + "wsPropertiesUnresolved": "مرجع اکنون قابل حل نیست.", + "wsUseDefault": "استفاده از پیش‌فرض", + "wsPropertiesSelection": "یک عنصر را انتخاب کنید.", + "wsImageDisplay": "نمایش تصویر", + "wsImageThumbnail": "تصویر بندانگشتی قابل کلیک", + "wsEnabled": "فعال", + "wsDisabled": "غیرفعال", + "wsWidth": "عرض", + "wsHeight": "ارتفاع", + "wsPropertiesInactive": "این ویژگی تنها با فعال بودن جمع‌شدن در انتشار اثر دارد.", + "wsNavigationAll": "همه عناصر دارای عنوان", + "wsNavigationNone": "بدون پیمایش", + "wsNavigationCustom": "پیمایش سفارشی", + "wsTitleOverride": "بازنویسی عنوان", + "wsCompactLayout": "فشرده", + "wsWideLayout": "عریض", + "wsCodeSourceConflict": "پیش از تنظیم src، کد نوشته‌شده در این بلوک را منتقل یا حذف کنید. Writerside بلوک کد غیرخالی با ارجاع منبع را نمی‌پذیرد.", + "wsInherited": "ارث‌بری از فهرست", + "wsExternalImageSize": "Writerside اندازه تصاویر خارجی را به‌طور قابل اتکا تنظیم نمی‌کند. اندازه‌های موجود قابل بررسی و حذف هستند.", + "wsPropertiesNotSerialized": "این تغییر در متن منبع اعمال نشد. پیش‌نویس حفظ شده است.", + "wsListItem": "مورد فهرست", + "wsSourceFile": "فایل منبع", + "wsLinkTarget": "مقصد پیوند", + "wsPreviewImage": "تصویر پیش‌نمایش", + "wsIncludedElement": "عنصر گنجانده‌شده", + "wsHelpModule": "ماژول راهنما", + "wsActionId": "شناسهٔ عمل", + "wsKeymapHelpInstance": "نمونهٔ راهنمای نگاشت کلید", + "wsShowInSource": "نمایش در منبع", + "wsOriginElement": "بازنویسی عنصر", + "wsOriginAncestor": "عنصر والد", + "wsOriginModule": "پیش‌فرض ماژول", + "wsOriginBuiltIn": "پیش‌فرض داخلی", + "wsOriginUnresolved": "حل‌نشده", + "wsEffectiveProperty": "مقدار مؤثر: {value} — {origin}", + "wsElementSelector": "عنصر", + "wsGeneratedAnchorValue": "تولیدشده: {anchor}", + "wsDefaultProperty": "پیش‌فرض: {value}", + "wsDefaultPropertyOrigin": "پیش‌فرض: {value} · {origin}", + "wsResolvedProperty": "مقدار حل‌شده: {value}", + "wsPropertyUnresolved": "حل‌نشده", + "wsModule": "ماژول", + "wsParent": "عنصر والد", + "wsListType": "نوع فهرست", + "wsStartingNumber": "شمارهٔ آغاز", + "wsListSorting": "مرتب‌سازی", + "wsLetteredList": "فهرست حروفی", + "wsCheckboxList": "فهرست کادرهای انتخاب", + "wsMarkerlessList": "فهرست بدون نشانگر", + "wsAscending": "صعودی", + "wsDescending": "نزولی", + "wsNoSorting": "بدون مرتب‌سازی", + "wsAsWritten": "همان‌طور که نوشته شده", + "wsSentenceCase": "حالت حروف جمله", + "wsTitleCase": "حالت حروف عنوان", + "wsLowerCase": "حروف کوچک", + "wsUpperCase": "حروف بزرگ", + "wsNativeMarkers": "نشانگرهای Markdown", + "wsListMarkerStart": "نشانگر آغاز: {number}", + "wsListLevel": "سطح {level}", + "wsModuleCapsIgnored": "سازندهٔ ثابت قواعد <caps> ماژول را نادیده می‌گیرد. از ویژگی caps محلی یا default-property استفاده کنید.", + "wsInsertElements": "درج عناصر", + "wsAdmonitionsAndQuotes": "یادآوری‌ها و نقل‌قول‌ها", + "wsSemanticText": "متن معنایی", + "wsPropertiesUnavailable": "عنصر بررسی‌شده در دسترس نیست. برای ادامه عنصری را بررسی کنید." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 608257f8..230fd23b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "La casse du module n’est pas publiée.", + "wsTitleOverridesAction": "Titres de l’instance et du sommaire…", + "wsHeadingNotPublished": "Non publié par ce générateur.", + "wsMarkdownInstanceTitleLimitation": "Le constructeur fixé ne publie pas les titres par instance des sujets Markdown. Le balisage existant est conservé ; consultez la source.", + "wsTopicIdentity": "ID du sujet", + "wsElementAnchor": "Ancre de l’élément", + "wsHeadingText": "Texte du titre", + "wsDefaultTitle": "Titre par défaut", + "wsTopicNavigation": "Navigation du sujet", + "wsHeadingOptions": "Attributs du titre", + "wsDocumentOptions": "Paramètres du document", + "wsSettingsPresent": "Paramètres explicites présents", + "wsTitleOverrideExplanation": "Remplace le titre publié ; le texte du titre reste inchangé.", + "wsIgnoreVariables": "Traiter les variables littéralement", + "wsIgnoreVariablesExplanation": "Si activé, le texte ressemblant à une variable n’est pas substitué.", + "wsInstanceSelection": "Instances d’aide", + "wsInstanceExplanation": "Sélectionnez les sorties d’aide par noms, groupes, listes, exclusions ou expressions.", + "wsFilterExplanation": "Filtre le contenu réutilisable selon des critères ; ne sélectionne pas une instance d’aide.", + "wsContextHelp": "ID d’aide contextuelle", + "wsCapitalization": "Casse", + "wsLibraryTopic": "Sujet de bibliothèque", + "wsLastModified": "Dernière modification", + "wsMissingTopicHeading": "Aucun H1 de sujet. Ajoutez-le dans la source.", + "wsMultipleTopicHeadings": "Le premier H1 définit le titre du sujet. Les autres H1 sont des titres distincts.", + "wsTopicIdentityMismatch": "L’id racine diffère de l’ID issu du nom du fichier. Utilisez Renommer le fichier du sujet ou consultez la source.", + "wsUnsupportedProperties": "Les attributs non pris en charge sont conservés. Consultez-les dans la source.", + "wsGeneratedAnchor": "Ancre générée ; saisir une valeur crée un remplacement.", + "wsHeadingBuilderLimitation": "Le constructeur fixé ne publie pas les attributs H1. Ils appartiennent au titre, pas à la racine XML.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Correspondance possible après normalisation ; identité non confirmée.", "nextcloudSelectCandidate": "Sélectionner une note du serveur à examiner", "nextcloudCreateSeparate": "Créer une note distincte", - "nextcloudCreateSeparateWarning": "La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé." + "nextcloudCreateSeparateWarning": "La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé.", + "wsSemanticFormats": "Mise en forme sémantique", + "wsUiControl": "Contrôle d’interface", + "wsFilePath": "Fichier / Chemin", + "wsUiPath": "Chemin d’interface", + "wsDefinitionList": "Liste de définitions", + "wsIncludeContent": "Inclure du contenu réutilisable", + "wsVariableReference": "Référence de variable", + "wsConvertList": "Convertir la liste en procédure", + "wsElementProperties": "Propriétés de l’élément", + "wsTopicProperties": "Propriétés du sujet", + "wsCollapsible": "Repliable", + "wsPublishedState": "État initial publié", + "wsExpanded": "Développé", + "wsCollapsed": "Replié", + "wsCollapsedTitle": "Titre replié", + "wsTerm": "Terme", + "wsListLayout": "Disposition de la liste", + "wsSyncGroup": "Groupe de synchronisation", + "wsTabKey": "Clé de l’onglet", + "wsSwitcherLabel": "Libellé du sélecteur", + "wsNavigationElements": "Éléments de navigation", + "wsNavigationDepth": "Profondeur de navigation", + "wsAddItem": "Ajouter un élément", + "wsStep": "Étape", + "wsPropertyInvalid": "Valeur de propriété non valide", + "wsFilter": "Filtre", + "wsIdentifier": "ID de l’élément", + "wsTldr": "Résumé (TLDR)", + "wsFullLayout": "Pleine largeur", + "wsMediumLayout": "Moyen (1:2)", + "wsNarrowLayout": "Terme étroit", + "wsTitle": "Titre", + "wsSwitcherKey": "Clé du sélecteur", + "nextcloudServerSettings": "Paramètres Notes du serveur", + "nextcloudNotesPath": "Chemin du dossier de notes", + "nextcloudFileSuffix": "Extension des nouvelles notes", + "nextcloudServerSettingsExplanation": "Ces paramètres Notes du serveur affectent les autres clients. Changer le chemin ne déplace pas les fichiers ; changer l’extension ne renomme pas les notes existantes.", + "nextcloudSettingsSaved": "Paramètres serveur enregistrés", + "nextcloudSettingsNormalized": "Enregistré avec les valeurs normalisées par le serveur", + "nextcloudPendingUpload": "Envoi de pièce jointe en attente", + "nextcloudServerCheck": "Dernière vérification du serveur : {time}", + "nextcloudSettingsLoading": "Chargement des paramètres du serveur", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Enregistrement des paramètres du serveur", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Modification locale précédente", + "nextcloudUsePreviousEdit": "Utiliser la modification précédente", + "quickOpen": "Ouverture rapide", + "notesAll": "Toutes les notes", + "notesUncategorized": "Sans catégorie", + "notesRecovery": "Récupération", + "notesRecentlyEdited": "Modifiées récemment", + "notesOldestEdited": "Modifiées les plus anciennes", + "notesTitleAscending": "Titre A–Z", + "notesTitleDescending": "Titre Z–A", + "notesMove": "Déplacer vers une catégorie", + "notesRemoveFavorite": "Retirer des favoris", + "notesOffline": "Rendre disponible hors ligne", + "notesOfflineRemove": "Retirer la conservation hors ligne", + "notesIndexing": "Indexation des notes…", + "notesSearchHelp": "Les termes utilisent ET. Utilisez \"des expressions\", title: et category:.", + "notesSourceLocation": "Emplacement source", + "notesRecoveryHelp": "Notes locales conservées et historique local uniquement. La corbeille et l’historique du serveur sont indisponibles.", + "notesImport": "Importer des notes", + "notesExport": "Exporter des notes", + "notesImportDistinct": "Importer comme une note distincte", + "notesAvailableOffline": "Disponible hors ligne", + "notesIncomplete": "Incomplet", + "notesSelected": "Notes sélectionnées", + "notesNoHistory": "Aucune révision conservée. L’historique peut être désactivé, expiré ou effacé.", + "notesTextAvailable": "Texte disponible localement", + "notesLocalVersion": "Version de travail locale", + "notesAttachments": "Pièces jointes", + "notesSkipped": "Ignoré", + "notesFailed": "Échec", + "notesCompleted": "Terminé", + "notesImportNew": "Démarrer une nouvelle importation", + "notesImportResume": "Reprendre l’importation interrompue", + "notesImportAlreadyImported": "Déjà importé dans cette opération", + "references": "Références", + "referenceIncoming": "Entrantes", + "referenceOutgoing": "Sortantes", + "referenceChooseTarget": "Choisir la cible du lien", + "referenceWholeDocument": "Document entier", + "referenceSourceLocation": "Emplacement source", + "referenceTargetLocation": "Emplacement cible", + "referenceResolved": "Résolue", + "referenceMissing": "Manquante", + "referenceAmbiguous": "Ambiguë", + "referenceUnavailable": "Indisponible dans ce contexte", + "referenceUnsupported": "Non prise en charge", + "referenceNoPortableId": "Cette cible ne possède pas d’URL de note portable vérifiée.", + "wsIncludeLines": "Inclure les lignes", + "wsIncludeSymbol": "Inclure le symbole", + "wsEmphasizeLines": "Surligner les lignes affichées", + "wsPropertiesReadOnly": "Propriétés en lecture seule dans cette vue.", + "wsPropertiesProtected": "Cet élément est protégé. Modifiez sa source.", + "wsPropertiesConflict": "Le document ou élément a changé. Annulez le brouillon et sélectionnez de nouveau.", + "wsPropertiesUnresolved": "La référence ne peut pas être résolue actuellement.", + "wsUseDefault": "Utiliser la valeur par défaut", + "wsPropertiesSelection": "Sélectionnez un seul élément.", + "wsImageDisplay": "Affichage de l’image", + "wsImageThumbnail": "Miniature cliquable", + "wsEnabled": "Activé", + "wsDisabled": "Désactivé", + "wsWidth": "Largeur", + "wsHeight": "Hauteur", + "wsPropertiesInactive": "Sans repli publié, cette propriété est sans effet.", + "wsNavigationAll": "Tous les éléments avec titre", + "wsNavigationNone": "Aucune navigation", + "wsNavigationCustom": "Navigation personnalisée", + "wsTitleOverride": "Remplacer le titre", + "wsCompactLayout": "Disposition compacte", + "wsWideLayout": "Large", + "wsCodeSourceConflict": "Déplacez ou supprimez le code écrit dans ce bloc avant de définir src. Writerside refuse un bloc de code non vide avec une référence source.", + "wsInherited": "Hériter de la liste", + "wsExternalImageSize": "Writerside ne dimensionne pas de manière fiable les images externes. Les dimensions existantes restent consultables et supprimables.", + "wsPropertiesNotSerialized": "Cette modification n’a pas été appliquée à la source. Le brouillon a été conservé.", + "wsListItem": "Élément de liste", + "wsSourceFile": "Fichier source", + "wsLinkTarget": "Cible du lien", + "wsPreviewImage": "Image d’aperçu", + "wsIncludedElement": "Élément inclus", + "wsHelpModule": "Module d’aide", + "wsActionId": "ID d’action", + "wsKeymapHelpInstance": "Instance d’aide du clavier", + "wsShowInSource": "Afficher dans Source", + "wsOriginElement": "Valeur de l’élément", + "wsOriginAncestor": "Élément parent", + "wsOriginModule": "Valeur par défaut du module", + "wsOriginBuiltIn": "Valeur intégrée par défaut", + "wsOriginUnresolved": "Non résolu", + "wsEffectiveProperty": "Effectif : {value} — {origin}", + "wsElementSelector": "Élément", + "wsGeneratedAnchorValue": "Généré : {anchor}", + "wsDefaultProperty": "Par défaut : {value}", + "wsDefaultPropertyOrigin": "Par défaut : {value} · {origin}", + "wsResolvedProperty": "Résolu : {value}", + "wsPropertyUnresolved": "Non résolu", + "wsModule": "Module", + "wsParent": "Élément parent", + "wsListType": "Type de liste", + "wsStartingNumber": "Numéro de départ", + "wsListSorting": "Tri", + "wsLetteredList": "Liste alphabétique", + "wsCheckboxList": "Liste à cases à cocher", + "wsMarkerlessList": "Liste sans marqueurs", + "wsAscending": "Croissant", + "wsDescending": "Décroissant", + "wsNoSorting": "Sans tri", + "wsAsWritten": "Tel qu’écrit", + "wsSentenceCase": "Casse de phrase", + "wsTitleCase": "Casse de titre", + "wsLowerCase": "Minuscules", + "wsUpperCase": "Majuscules", + "wsNativeMarkers": "Marqueurs Markdown", + "wsListMarkerStart": "Premier marqueur : {number}", + "wsListLevel": "Niveau {level}", + "wsModuleCapsIgnored": "Le compilateur fixé ignore les règles <caps> du module. Utilisez un attribut caps local ou default-property.", + "wsInsertElements": "Insérer des éléments", + "wsAdmonitionsAndQuotes": "Avertissements et citations", + "wsSemanticText": "Texte sémantique", + "wsPropertiesUnavailable": "L’élément inspecté est indisponible. Inspectez un élément pour continuer." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b99755a9..4c9b5781 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "मॉड्यूल का अक्षर-शैली नियम प्रकाशित नहीं होता।", + "wsTitleOverridesAction": "इंस्टेंस और विषय-सूची के शीर्षक…", + "wsHeadingNotPublished": "यह बिल्डर इसे प्रकाशित नहीं करता।", + "wsMarkdownInstanceTitleLimitation": "निश्चित बिल्डर Markdown विषयों में इंस्टेंस शीर्षक प्रकाशित नहीं करता। मौजूदा मार्कअप सुरक्षित है; स्रोत में देखें।", + "wsTopicIdentity": "विषय ID", + "wsElementAnchor": "तत्व एंकर", + "wsHeadingText": "शीर्षक पाठ", + "wsDefaultTitle": "डिफ़ॉल्ट शीर्षक", + "wsTopicNavigation": "विषय नेविगेशन", + "wsHeadingOptions": "शीर्षक विशेषताएँ", + "wsDocumentOptions": "दस्तावेज़ सेटिंग", + "wsSettingsPresent": "लिखी हुई सेटिंग मौजूद हैं", + "wsTitleOverrideExplanation": "प्रकाशित शीर्षक को बदलता है; शीर्षक पाठ अपरिवर्तित रहता है।", + "wsIgnoreVariables": "चरों को शाब्दिक पाठ मानें", + "wsIgnoreVariablesExplanation": "सक्षम होने पर चर जैसा पाठ प्रतिस्थापित नहीं होता।", + "wsInstanceSelection": "सहायता इंस्टेंस", + "wsInstanceExplanation": "नाम, समूह, सूची, बहिष्करण या अभिव्यक्ति से सहायता आउटपुट चुनें।", + "wsFilterExplanation": "पुनः उपयोग की सामग्री को मानदंड से फ़िल्टर करता है; सहायता इंस्टेंस नहीं चुनता।", + "wsContextHelp": "प्रासंगिक सहायता ID", + "wsCapitalization": "अक्षर केस", + "wsLibraryTopic": "लाइब्रेरी विषय", + "wsLastModified": "अंतिम संशोधन", + "wsMissingTopicHeading": "विषय H1 मौजूद नहीं है। स्रोत में जोड़ें।", + "wsMultipleTopicHeadings": "पहला H1 विषय शीर्षक निर्धारित करता है। बाद के H1 अलग शीर्षक हैं।", + "wsTopicIdentityMismatch": "मूल id फ़ाइलनाम से बने विषय ID से अलग है। विषय फ़ाइल का नाम बदलें या स्रोत देखें।", + "wsUnsupportedProperties": "असमर्थित विशेषताएँ सुरक्षित रहती हैं। स्रोत में देखें।", + "wsGeneratedAnchor": "बना हुआ एंकर; मान दर्ज करने से ओवरराइड बनता है।", + "wsHeadingBuilderLimitation": "निश्चित बिल्डर H1 विशेषताएँ प्रकाशित नहीं करता। वे शीर्षक की हैं, XML मूल की नहीं।", "closeOtherTabs": "अन्य टैब बंद करें", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "टेम्पलेट से विषय...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "संशोधित अनुरोध से संभावित मेल; पहचान की पुष्टि नहीं हुई है।", "nextcloudSelectCandidate": "समीक्षा के लिए सर्वर का नोट चुनें", "nextcloudCreateSeparate": "अलग नोट बनाएँ", - "nextcloudCreateSeparateWarning": "पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।" + "nextcloudCreateSeparateWarning": "पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।", + "wsSemanticFormats": "अर्थपूर्ण स्वरूपण", + "wsUiControl": "यूआई नियंत्रण", + "wsFilePath": "फ़ाइल / पथ", + "wsUiPath": "यूआई पथ", + "wsDefinitionList": "परिभाषा सूची", + "wsIncludeContent": "पुनः उपयोग योग्य सामग्री शामिल करें", + "wsVariableReference": "चर संदर्भ", + "wsConvertList": "सूची को प्रक्रिया में बदलें", + "wsElementProperties": "तत्व के गुण", + "wsTopicProperties": "विषय के गुण", + "wsCollapsible": "समेटने योग्य", + "wsPublishedState": "प्रकाशित आरंभिक स्थिति", + "wsExpanded": "विस्तृत", + "wsCollapsed": "संक्षिप्त", + "wsCollapsedTitle": "संक्षिप्त शीर्षक", + "wsTerm": "शब्द", + "wsListLayout": "सूची लेआउट", + "wsSyncGroup": "सिंक समूह", + "wsTabKey": "टैब कुंजी", + "wsSwitcherLabel": "स्विचर लेबल", + "wsNavigationElements": "नेविगेशन तत्व", + "wsNavigationDepth": "नेविगेशन गहराई", + "wsAddItem": "आइटम जोड़ें", + "wsStep": "चरण", + "wsPropertyInvalid": "अमान्य गुण मान", + "wsFilter": "फ़िल्टर", + "wsIdentifier": "तत्व आईडी", + "wsTldr": "सारांश (TLDR)", + "wsFullLayout": "पूरी चौड़ाई", + "wsMediumLayout": "मध्यम (1:2)", + "wsNarrowLayout": "संकीर्ण शब्द स्तंभ", + "wsTitle": "शीर्षक", + "wsSwitcherKey": "स्विचर कुंजी", + "nextcloudServerSettings": "सर्वर Notes सेटिंग्स", + "nextcloudNotesPath": "नोट फ़ोल्डर का पथ", + "nextcloudFileSuffix": "नए नोट की फ़ाइल का प्रत्यय", + "nextcloudServerSettingsExplanation": "ये सर्वर Notes सेटिंग्स दूसरे क्लाइंट को भी प्रभावित करती हैं। पथ बदलने से फ़ाइलें स्थानांतरित नहीं होतीं; प्रत्यय बदलने से मौजूदा नोट का नाम नहीं बदलता।", + "nextcloudSettingsSaved": "सर्वर सेटिंग्स सहेजी गईं", + "nextcloudSettingsNormalized": "सर्वर द्वारा सामान्यीकृत मानों के साथ सहेजा गया", + "nextcloudPendingUpload": "अटैचमेंट अपलोड लंबित है", + "nextcloudServerCheck": "सर्वर की अंतिम जाँच: {time}", + "nextcloudSettingsLoading": "सर्वर सेटिंग लोड हो रही हैं", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "सर्वर सेटिंग सहेजी जा रही हैं", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "पिछला स्थानीय संपादन", + "nextcloudUsePreviousEdit": "पिछला संपादन इस्तेमाल करें", + "quickOpen": "त्वरित खोलें", + "notesAll": "सभी नोट्स", + "notesUncategorized": "बिना श्रेणी", + "notesRecovery": "पुनर्प्राप्ति", + "notesRecentlyEdited": "हाल में संपादित", + "notesOldestEdited": "सबसे पुराना संपादन", + "notesTitleAscending": "शीर्षक आरोही", + "notesTitleDescending": "शीर्षक अवरोही", + "notesMove": "श्रेणी में ले जाएँ", + "notesRemoveFavorite": "पसंदीदा हटाएँ", + "notesOffline": "ऑफ़लाइन उपलब्ध करें", + "notesOfflineRemove": "ऑफ़लाइन संरक्षण हटाएँ", + "notesIndexing": "नोट्स की अनुक्रमणिका बन रही है…", + "notesSearchHelp": "शब्द AND से जुड़ते हैं। \"उद्धृत वाक्यांश\", title: और category: का उपयोग करें।", + "notesSourceLocation": "स्रोत स्थान", + "notesRecoveryHelp": "केवल संरक्षित स्थानीय नोट्स और स्थानीय इतिहास। सर्वर का कचरा और इतिहास उपलब्ध नहीं है।", + "notesImport": "नोट्स आयात करें", + "notesExport": "नोट्स निर्यात करें", + "notesImportDistinct": "अलग नोट के रूप में आयात करें", + "notesAvailableOffline": "ऑफ़लाइन उपलब्ध", + "notesIncomplete": "अपूर्ण", + "notesSelected": "चुने गए नोट्स", + "notesNoHistory": "कोई संरक्षित संशोधन नहीं। इतिहास बंद, समाप्त या साफ़ किया गया हो सकता है।", + "notesTextAvailable": "पाठ स्थानीय रूप से उपलब्ध है", + "notesLocalVersion": "स्थानीय कार्य संस्करण", + "notesAttachments": "अटैचमेंट", + "notesSkipped": "छोड़ा गया", + "notesFailed": "विफल", + "notesCompleted": "पूर्ण", + "notesImportNew": "नया आयात शुरू करें", + "notesImportResume": "बाधित आयात फिर से शुरू करें", + "notesImportAlreadyImported": "इस प्रक्रिया में पहले ही आयात किया गया", + "references": "संदर्भ", + "referenceIncoming": "आने वाले", + "referenceOutgoing": "जाने वाले", + "referenceChooseTarget": "लिंक लक्ष्य चुनें", + "referenceWholeDocument": "पूरा दस्तावेज़", + "referenceSourceLocation": "स्रोत स्थान", + "referenceTargetLocation": "लक्ष्य स्थान", + "referenceResolved": "हल हुआ", + "referenceMissing": "गुम", + "referenceAmbiguous": "अस्पष्ट", + "referenceUnavailable": "इस संदर्भ में अनुपलब्ध", + "referenceUnsupported": "असमर्थित", + "referenceNoPortableId": "इस लक्ष्य का सत्यापित पोर्टेबल नोट URL नहीं है.", + "wsIncludeLines": "पंक्तियाँ शामिल करें", + "wsIncludeSymbol": "प्रतीक शामिल करें", + "wsEmphasizeLines": "दिखाई गई पंक्तियाँ हाइलाइट करें", + "wsPropertiesReadOnly": "इस दृश्य में गुण केवल पढ़ने के लिए हैं।", + "wsPropertiesProtected": "यह तत्व सुरक्षित है। इसका स्रोत संपादित करें।", + "wsPropertiesConflict": "दस्तावेज़ या तत्व बदल गया। मसौदा वापस करें और फिर चुनें।", + "wsPropertiesUnresolved": "संदर्भ अभी हल नहीं हो सकता।", + "wsUseDefault": "डिफ़ॉल्ट उपयोग करें", + "wsPropertiesSelection": "एक तत्व चुनें।", + "wsImageDisplay": "छवि प्रदर्शन", + "wsImageThumbnail": "क्लिक करने योग्य थंबनेल", + "wsEnabled": "सक्षम", + "wsDisabled": "अक्षम", + "wsWidth": "चौड़ाई", + "wsHeight": "ऊँचाई", + "wsPropertiesInactive": "प्रकाशित संकुचन बंद होने पर इस गुण का प्रभाव नहीं होता।", + "wsNavigationAll": "सभी शीर्षक वाले तत्व", + "wsNavigationNone": "नेविगेशन नहीं", + "wsNavigationCustom": "कस्टम नेविगेशन", + "wsTitleOverride": "शीर्षक बदलें", + "wsCompactLayout": "संक्षिप्त", + "wsWideLayout": "चौड़ा", + "wsCodeSourceConflict": "src सेट करने से पहले इस ब्लॉक में लिखे कोड को हटाएँ या स्थानांतरित करें। Writerside स्रोत संदर्भ वाले गैर-खाली कोड ब्लॉक को अस्वीकार करता है।", + "wsInherited": "सूची से प्राप्त करें", + "wsExternalImageSize": "Writerside बाहरी छवियों का आकार विश्वसनीय ढंग से नहीं बदलता। मौजूदा आयाम देखे और हटाए जा सकते हैं।", + "wsPropertiesNotSerialized": "यह बदलाव स्रोत में लागू नहीं हुआ। मसौदा सुरक्षित रखा गया है।", + "wsListItem": "सूची आइटम", + "wsSourceFile": "स्रोत फ़ाइल", + "wsLinkTarget": "लिंक लक्ष्य", + "wsPreviewImage": "पूर्वावलोकन छवि", + "wsIncludedElement": "शामिल तत्व", + "wsHelpModule": "सहायता मॉड्यूल", + "wsActionId": "कार्रवाई ID", + "wsKeymapHelpInstance": "कीमैप सहायता इंस्टेंस", + "wsShowInSource": "स्रोत में दिखाएँ", + "wsOriginElement": "तत्व का ओवरराइड", + "wsOriginAncestor": "मूल तत्व", + "wsOriginModule": "मॉड्यूल का डिफ़ॉल्ट", + "wsOriginBuiltIn": "अंतर्निहित डिफ़ॉल्ट", + "wsOriginUnresolved": "अनसुलझा", + "wsEffectiveProperty": "प्रभावी: {value} — {origin}", + "wsElementSelector": "तत्व", + "wsGeneratedAnchorValue": "जनित: {anchor}", + "wsDefaultProperty": "डिफ़ॉल्ट: {value}", + "wsDefaultPropertyOrigin": "डिफ़ॉल्ट: {value} · {origin}", + "wsResolvedProperty": "समाधित: {value}", + "wsPropertyUnresolved": "असमाधित", + "wsModule": "मॉड्यूल", + "wsParent": "मूल तत्व", + "wsListType": "सूची प्रकार", + "wsStartingNumber": "आरंभ संख्या", + "wsListSorting": "क्रमबद्ध करना", + "wsLetteredList": "अक्षर सूची", + "wsCheckboxList": "चेकबॉक्स सूची", + "wsMarkerlessList": "बिना चिह्नों की सूची", + "wsAscending": "आरोही", + "wsDescending": "अवरोही", + "wsNoSorting": "क्रमबद्ध नहीं", + "wsAsWritten": "जैसा लिखा है", + "wsSentenceCase": "वाक्य अक्षर शैली", + "wsTitleCase": "शीर्षक अक्षर शैली", + "wsLowerCase": "छोटे अक्षर", + "wsUpperCase": "बड़े अक्षर", + "wsNativeMarkers": "Markdown चिह्न", + "wsListMarkerStart": "आरंभ चिह्न: {number}", + "wsListLevel": "स्तर {level}", + "wsModuleCapsIgnored": "निर्धारित बिल्डर मॉड्यूल के <caps> नियमों को अनदेखा करता है। स्थानीय caps विशेषता या default-property का उपयोग करें।", + "wsInsertElements": "तत्व जोड़ें", + "wsAdmonitionsAndQuotes": "सूचनाएँ और उद्धरण", + "wsSemanticText": "अर्थपूर्ण पाठ", + "wsPropertiesUnavailable": "जाँचा गया तत्व उपलब्ध नहीं है। जारी रखने के लिए कोई तत्व जाँचें।" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4b25ffd3..3441e636 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Kapitalisasi modul tidak diterbitkan.", + "wsTitleOverridesAction": "Judul instans dan daftar isi…", + "wsHeadingNotPublished": "Tidak diterbitkan oleh pembuat ini.", + "wsMarkdownInstanceTitleLimitation": "Builder yang dipatok tidak menerbitkan judul instans pada topik Markdown. Markup yang ada tetap disimpan; periksa di Sumber.", + "wsTopicIdentity": "ID topik", + "wsElementAnchor": "Jangkar elemen", + "wsHeadingText": "Teks judul", + "wsDefaultTitle": "Judul bawaan", + "wsTopicNavigation": "Navigasi topik", + "wsHeadingOptions": "Atribut judul", + "wsDocumentOptions": "Pengaturan dokumen", + "wsSettingsPresent": "Ada pengaturan tertulis", + "wsTitleOverrideExplanation": "Mengganti judul yang diterbitkan; teks judul tidak berubah.", + "wsIgnoreVariables": "Perlakukan variabel sebagai teks literal", + "wsIgnoreVariablesExplanation": "Jika diaktifkan, teks yang menyerupai variabel tidak diganti.", + "wsInstanceSelection": "Instans bantuan", + "wsInstanceExplanation": "Pilih keluaran bantuan dengan nama, grup, daftar, pengecualian, atau ekspresi.", + "wsFilterExplanation": "Menyaring konten pakai ulang menurut kriteria; tidak memilih instans bantuan.", + "wsContextHelp": "ID bantuan kontekstual", + "wsCapitalization": "Kapitalisasi", + "wsLibraryTopic": "Topik pustaka", + "wsLastModified": "Terakhir diubah", + "wsMissingTopicHeading": "Tidak ada H1 topik. Tambahkan di Sumber.", + "wsMultipleTopicHeadings": "H1 pertama menentukan judul topik. H1 berikutnya adalah judul terpisah.", + "wsTopicIdentityMismatch": "id akar berbeda dari ID berdasarkan nama berkas. Gunakan Ubah nama berkas topik atau periksa Sumber.", + "wsUnsupportedProperties": "Atribut yang tidak didukung tetap disimpan. Periksa di Sumber.", + "wsGeneratedAnchor": "Jangkar yang dihasilkan; memasukkan nilai membuat penimpaan.", + "wsHeadingBuilderLimitation": "Builder yang dipatok tidak menerbitkan atribut H1. Atribut milik judul, bukan akar XML.", "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...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "Kemungkinan cocok setelah permintaan disanitasi; identitas belum dikonfirmasi.", "nextcloudSelectCandidate": "Pilih catatan server untuk ditinjau", "nextcloudCreateSeparate": "Buat catatan terpisah", - "nextcloudCreateSeparateWarning": "Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus." + "nextcloudCreateSeparateWarning": "Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus.", + "wsSemanticFormats": "Pemformatan semantik", + "wsUiControl": "Kontrol antarmuka", + "wsFilePath": "Berkas / Jalur", + "wsUiPath": "Jalur antarmuka", + "wsDefinitionList": "Daftar definisi", + "wsIncludeContent": "Sertakan konten yang dapat digunakan ulang", + "wsVariableReference": "Referensi variabel", + "wsConvertList": "Ubah daftar menjadi prosedur", + "wsElementProperties": "Properti elemen", + "wsTopicProperties": "Properti topik", + "wsCollapsible": "Dapat diciutkan", + "wsPublishedState": "Status awal publikasi", + "wsExpanded": "Diperluas", + "wsCollapsed": "Diciutkan", + "wsCollapsedTitle": "Judul saat diciutkan", + "wsTerm": "Istilah", + "wsListLayout": "Tata letak daftar", + "wsSyncGroup": "Grup sinkronisasi", + "wsTabKey": "Kunci tab", + "wsSwitcherLabel": "Label pengalih", + "wsNavigationElements": "Elemen navigasi", + "wsNavigationDepth": "Kedalaman navigasi", + "wsAddItem": "Tambah item", + "wsStep": "Langkah", + "wsPropertyInvalid": "Nilai properti tidak valid", + "wsFilter": "Filter konten", + "wsIdentifier": "ID elemen", + "wsTldr": "Ringkasan (TLDR)", + "wsFullLayout": "Lebar penuh", + "wsMediumLayout": "Sedang (1:2)", + "wsNarrowLayout": "Kolom istilah sempit", + "wsTitle": "Judul", + "wsSwitcherKey": "Kunci pemilih", + "nextcloudServerSettings": "Pengaturan Notes server", + "nextcloudNotesPath": "Jalur folder catatan", + "nextcloudFileSuffix": "Ekstensi berkas catatan baru", + "nextcloudServerSettingsExplanation": "Pengaturan Notes server ini memengaruhi klien lain. Mengubah jalur tidak memindahkan berkas; mengubah ekstensi tidak mengganti nama catatan yang ada.", + "nextcloudSettingsSaved": "Pengaturan server disimpan", + "nextcloudSettingsNormalized": "Disimpan dengan nilai yang dinormalisasi server", + "nextcloudPendingUpload": "Unggahan lampiran tertunda", + "nextcloudServerCheck": "Pemeriksaan server terakhir: {time}", + "nextcloudSettingsLoading": "Memuat pengaturan server", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Menyimpan pengaturan server", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edit lokal sebelumnya", + "nextcloudUsePreviousEdit": "Gunakan edit sebelumnya", + "quickOpen": "Buka cepat", + "notesAll": "Semua catatan", + "notesUncategorized": "Tanpa kategori", + "notesRecovery": "Pemulihan", + "notesRecentlyEdited": "Baru diedit", + "notesOldestEdited": "Edit terlama", + "notesTitleAscending": "Judul A–Z", + "notesTitleDescending": "Judul Z–A", + "notesMove": "Pindah ke kategori", + "notesRemoveFavorite": "Hapus favorit", + "notesOffline": "Sediakan secara luring", + "notesOfflineRemove": "Hapus penyimpanan luring", + "notesIndexing": "Mengindeks catatan…", + "notesSearchHelp": "Istilah digabung dengan DAN. Gunakan \"frasa kutipan\", title: dan category:.", + "notesSourceLocation": "Lokasi sumber", + "notesRecoveryHelp": "Hanya catatan lokal tersimpan dan riwayat lokal. Sampah dan riwayat server tidak tersedia.", + "notesImport": "Impor catatan", + "notesExport": "Ekspor catatan", + "notesImportDistinct": "Impor sebagai catatan terpisah", + "notesAvailableOffline": "Tersedia secara luring", + "notesIncomplete": "Tidak lengkap", + "notesSelected": "Catatan terpilih", + "notesNoHistory": "Tidak ada revisi tersimpan. Riwayat mungkin dinonaktifkan, kedaluwarsa atau dihapus.", + "notesTextAvailable": "Teks tersedia secara lokal", + "notesLocalVersion": "Versi kerja lokal", + "notesAttachments": "Lampiran", + "notesSkipped": "Dilewati", + "notesFailed": "Gagal", + "notesCompleted": "Selesai", + "notesImportNew": "Mulai impor baru", + "notesImportResume": "Lanjutkan impor yang terputus", + "notesImportAlreadyImported": "Sudah diimpor dalam operasi ini", + "references": "Referensi", + "referenceIncoming": "Masuk", + "referenceOutgoing": "Keluar", + "referenceChooseTarget": "Pilih tujuan tautan", + "referenceWholeDocument": "Seluruh dokumen", + "referenceSourceLocation": "Lokasi sumber", + "referenceTargetLocation": "Lokasi tujuan", + "referenceResolved": "Terselesaikan", + "referenceMissing": "Tidak ditemukan", + "referenceAmbiguous": "Ambigu", + "referenceUnavailable": "Tidak tersedia dalam konteks ini", + "referenceUnsupported": "Tidak didukung", + "referenceNoPortableId": "Tujuan ini tidak memiliki URL catatan portabel yang terverifikasi.", + "wsIncludeLines": "Sertakan baris", + "wsIncludeSymbol": "Sertakan simbol", + "wsEmphasizeLines": "Sorot baris yang ditampilkan", + "wsPropertiesReadOnly": "Properti hanya baca pada tampilan ini.", + "wsPropertiesProtected": "Elemen dilindungi. Edit sumbernya.", + "wsPropertiesConflict": "Dokumen atau elemen berubah. Kembalikan draf dan pilih lagi.", + "wsPropertiesUnresolved": "Referensi belum dapat diselesaikan.", + "wsUseDefault": "Gunakan bawaan", + "wsPropertiesSelection": "Pilih satu elemen.", + "wsImageDisplay": "Tampilan gambar", + "wsImageThumbnail": "Thumbnail yang dapat diklik", + "wsEnabled": "Aktif", + "wsDisabled": "Nonaktif", + "wsWidth": "Lebar", + "wsHeight": "Tinggi", + "wsPropertiesInactive": "Properti hanya berlaku saat penciutan publikasi diaktifkan.", + "wsNavigationAll": "Semua elemen berjudul", + "wsNavigationNone": "Tanpa navigasi", + "wsNavigationCustom": "Navigasi khusus", + "wsTitleOverride": "Timpa judul", + "wsCompactLayout": "Ringkas", + "wsWideLayout": "Lebar", + "wsCodeSourceConflict": "Pindahkan atau hapus kode yang ditulis dalam blok ini sebelum menetapkan src. Writerside menolak blok kode yang tidak kosong dengan referensi sumber.", + "wsInherited": "Warisi dari daftar", + "wsExternalImageSize": "Writerside tidak mengatur ukuran gambar eksternal dengan andal. Ukuran yang ada dapat dilihat dan dihapus.", + "wsPropertiesNotSerialized": "Perubahan ini tidak diterapkan ke sumber. Draf tetap disimpan.", + "wsListItem": "Item daftar", + "wsSourceFile": "Berkas sumber", + "wsLinkTarget": "Tujuan tautan", + "wsPreviewImage": "Gambar pratinjau", + "wsIncludedElement": "Elemen yang disertakan", + "wsHelpModule": "Modul bantuan", + "wsActionId": "ID tindakan", + "wsKeymapHelpInstance": "Instans bantuan peta tombol", + "wsShowInSource": "Tampilkan di Sumber", + "wsOriginElement": "Penggantian elemen", + "wsOriginAncestor": "Elemen induk", + "wsOriginModule": "Bawaan modul", + "wsOriginBuiltIn": "Bawaan internal", + "wsOriginUnresolved": "Belum teratasi", + "wsEffectiveProperty": "Efektif: {value} — {origin}", + "wsElementSelector": "Elemen", + "wsGeneratedAnchorValue": "Dihasilkan: {anchor}", + "wsDefaultProperty": "Default: {value}", + "wsDefaultPropertyOrigin": "Default: {value} · {origin}", + "wsResolvedProperty": "Diselesaikan: {value}", + "wsPropertyUnresolved": "Belum diselesaikan", + "wsModule": "Modul", + "wsParent": "Induk", + "wsListType": "Jenis daftar", + "wsStartingNumber": "Nomor awal", + "wsListSorting": "Pengurutan", + "wsLetteredList": "Daftar huruf", + "wsCheckboxList": "Daftar kotak centang", + "wsMarkerlessList": "Daftar tanpa penanda", + "wsAscending": "Menaik", + "wsDescending": "Menurun", + "wsNoSorting": "Tanpa pengurutan", + "wsAsWritten": "Sesuai tulisan", + "wsSentenceCase": "Kapitalisasi kalimat", + "wsTitleCase": "Kapitalisasi judul", + "wsLowerCase": "Huruf kecil", + "wsUpperCase": "Huruf besar", + "wsNativeMarkers": "Penanda Markdown", + "wsListMarkerStart": "Penanda awal: {number}", + "wsListLevel": "Tingkat {level}", + "wsModuleCapsIgnored": "Builder yang dipatok mengabaikan aturan <caps> modul. Gunakan atribut caps lokal atau default-property.", + "wsInsertElements": "Sisipkan elemen", + "wsAdmonitionsAndQuotes": "Pemberitahuan dan kutipan", + "wsSemanticText": "Teks semantik", + "wsPropertiesUnavailable": "Elemen yang diperiksa tidak tersedia. Periksa elemen untuk melanjutkan." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 74a442bb..d88067e9 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "La capitalizzazione del modulo non viene pubblicata.", + "wsTitleOverridesAction": "Titoli dell’istanza e dell’indice…", + "wsHeadingNotPublished": "Non pubblicato da questo generatore.", + "wsMarkdownInstanceTitleLimitation": "Il builder fissato non pubblica titoli per istanza negli argomenti Markdown. Il markup esistente è conservato; consultalo nel sorgente.", + "wsTopicIdentity": "ID argomento", + "wsElementAnchor": "Ancora elemento", + "wsHeadingText": "Testo intestazione", + "wsDefaultTitle": "Titolo predefinito", + "wsTopicNavigation": "Navigazione argomento", + "wsHeadingOptions": "Attributi intestazione", + "wsDocumentOptions": "Impostazioni documento", + "wsSettingsPresent": "Impostazioni esplicite presenti", + "wsTitleOverrideExplanation": "Sostituisce il titolo pubblicato; il testo dell’intestazione non cambia.", + "wsIgnoreVariables": "Tratta le variabili come testo letterale", + "wsIgnoreVariablesExplanation": "Se attivo, il testo simile a una variabile non viene sostituito.", + "wsInstanceSelection": "Istanze della guida", + "wsInstanceExplanation": "Seleziona le uscite tramite nomi, gruppi, elenchi, esclusioni o espressioni.", + "wsFilterExplanation": "Filtra contenuti riutilizzabili per criteri; non seleziona un’istanza della guida.", + "wsContextHelp": "ID guida contestuale", + "wsCapitalization": "Maiuscole e minuscole", + "wsLibraryTopic": "Argomento libreria", + "wsLastModified": "Ultima modifica", + "wsMissingTopicHeading": "Nessun H1 dell’argomento. Aggiungilo nel sorgente.", + "wsMultipleTopicHeadings": "Il primo H1 determina il titolo dell’argomento. Gli altri H1 sono intestazioni separate.", + "wsTopicIdentityMismatch": "L’id radice differisce dall’ID del nome file. Usa Rinomina file argomento o consulta il sorgente.", + "wsUnsupportedProperties": "Gli attributi non supportati sono conservati. Consultali nel sorgente.", + "wsGeneratedAnchor": "Ancora generata; un valore crea una sostituzione.", + "wsHeadingBuilderLimitation": "Il builder fissato non pubblica gli attributi H1. Appartengono all’intestazione, non alla radice XML.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Possibile corrispondenza dopo la normalizzazione; identità non confermata.", "nextcloudSelectCandidate": "Seleziona una nota del server da esaminare", "nextcloudCreateSeparate": "Crea una nota separata", - "nextcloudCreateSeparateWarning": "La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato." + "nextcloudCreateSeparateWarning": "La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato.", + "wsSemanticFormats": "Formattazione semantica", + "wsUiControl": "Controllo dell’interfaccia", + "wsFilePath": "File / Percorso", + "wsUiPath": "Percorso dell’interfaccia", + "wsDefinitionList": "Elenco di definizioni", + "wsIncludeContent": "Includi contenuto riutilizzabile", + "wsVariableReference": "Riferimento a variabile", + "wsConvertList": "Converti elenco in procedura", + "wsElementProperties": "Proprietà dell’elemento", + "wsTopicProperties": "Proprietà dell’argomento", + "wsCollapsible": "Comprimibile", + "wsPublishedState": "Stato iniziale pubblicato", + "wsExpanded": "Espanso", + "wsCollapsed": "Compresso", + "wsCollapsedTitle": "Titolo compresso", + "wsTerm": "Termine", + "wsListLayout": "Disposizione dell’elenco", + "wsSyncGroup": "Gruppo di sincronizzazione", + "wsTabKey": "Chiave della scheda", + "wsSwitcherLabel": "Etichetta del selettore", + "wsNavigationElements": "Elementi di navigazione", + "wsNavigationDepth": "Profondità di navigazione", + "wsAddItem": "Aggiungi elemento", + "wsStep": "Passaggio", + "wsPropertyInvalid": "Valore della proprietà non valido", + "wsFilter": "Filtro", + "wsIdentifier": "ID elemento", + "wsTldr": "Riepilogo (TLDR)", + "wsFullLayout": "Larghezza piena", + "wsMediumLayout": "Medio (1:2)", + "wsNarrowLayout": "Termine stretto", + "wsTitle": "Titolo", + "wsSwitcherKey": "Chiave del selettore", + "nextcloudServerSettings": "Impostazioni Notes del server", + "nextcloudNotesPath": "Percorso della cartella delle note", + "nextcloudFileSuffix": "Estensione delle nuove note", + "nextcloudServerSettingsExplanation": "Queste impostazioni Notes influiscono sugli altri client. Cambiare il percorso non sposta i file; cambiare l’estensione non rinomina le note esistenti.", + "nextcloudSettingsSaved": "Impostazioni del server salvate", + "nextcloudSettingsNormalized": "Salvato con i valori normalizzati dal server", + "nextcloudPendingUpload": "Caricamento allegato in attesa", + "nextcloudServerCheck": "Ultimo controllo del server: {time}", + "nextcloudSettingsLoading": "Caricamento delle impostazioni del server", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Salvataggio delle impostazioni del server", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Modifica locale precedente", + "nextcloudUsePreviousEdit": "Usa la modifica precedente", + "quickOpen": "Apertura rapida", + "notesAll": "Tutte le note", + "notesUncategorized": "Senza categoria", + "notesRecovery": "Recupero", + "notesRecentlyEdited": "Modificate di recente", + "notesOldestEdited": "Modifiche più vecchie", + "notesTitleAscending": "Titolo A–Z", + "notesTitleDescending": "Titolo Z–A", + "notesMove": "Sposta nella categoria", + "notesRemoveFavorite": "Rimuovi preferito", + "notesOffline": "Rendi disponibile offline", + "notesOfflineRemove": "Rimuovi conservazione offline", + "notesIndexing": "Indicizzazione delle note…", + "notesSearchHelp": "I termini usano E. Usa \"frasi tra virgolette\", title: e category:.", + "notesSourceLocation": "Posizione nel sorgente", + "notesRecoveryHelp": "Solo note locali conservate e cronologia locale. Cestino e cronologia del server non disponibili.", + "notesImport": "Importa note", + "notesExport": "Esporta note", + "notesImportDistinct": "Importa come nota distinta", + "notesAvailableOffline": "Disponibile offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Note selezionate", + "notesNoHistory": "Nessuna revisione conservata. La cronologia può essere disabilitata, scaduta o cancellata.", + "notesTextAvailable": "Testo disponibile localmente", + "notesLocalVersion": "Versione di lavoro locale", + "notesAttachments": "Allegati", + "notesSkipped": "Saltato", + "notesFailed": "Non riuscito", + "notesCompleted": "Completato", + "notesImportNew": "Avvia una nuova importazione", + "notesImportResume": "Riprendi importazione interrotta", + "notesImportAlreadyImported": "Già importato in questa operazione", + "references": "Riferimenti", + "referenceIncoming": "In entrata", + "referenceOutgoing": "In uscita", + "referenceChooseTarget": "Scegli destinazione del collegamento", + "referenceWholeDocument": "Intero documento", + "referenceSourceLocation": "Posizione di origine", + "referenceTargetLocation": "Posizione di destinazione", + "referenceResolved": "Risolto", + "referenceMissing": "Mancante", + "referenceAmbiguous": "Ambiguo", + "referenceUnavailable": "Non disponibile in questo contesto", + "referenceUnsupported": "Non supportato", + "referenceNoPortableId": "Questa destinazione non ha un URL portabile della nota verificato.", + "wsIncludeLines": "Includi righe", + "wsIncludeSymbol": "Includi simbolo", + "wsEmphasizeLines": "Evidenzia righe visualizzate", + "wsPropertiesReadOnly": "Proprietà di sola lettura in questa vista.", + "wsPropertiesProtected": "Elemento protetto. Modifica la sorgente.", + "wsPropertiesConflict": "Documento o elemento modificato. Annulla la bozza e seleziona di nuovo.", + "wsPropertiesUnresolved": "Impossibile risolvere il riferimento al momento.", + "wsUseDefault": "Usa predefinito", + "wsPropertiesSelection": "Seleziona un solo elemento.", + "wsImageDisplay": "Visualizzazione immagine", + "wsImageThumbnail": "Miniatura cliccabile", + "wsEnabled": "Abilitato", + "wsDisabled": "Disabilitato", + "wsWidth": "Larghezza", + "wsHeight": "Altezza", + "wsPropertiesInactive": "Questa proprietà non ha effetto senza compressione pubblicata.", + "wsNavigationAll": "Tutti gli elementi con titolo", + "wsNavigationNone": "Nessuna navigazione", + "wsNavigationCustom": "Navigazione personalizzata", + "wsTitleOverride": "Sostituisci titolo", + "wsCompactLayout": "Compatto", + "wsWideLayout": "Largo", + "wsCodeSourceConflict": "Sposta o rimuovi il codice scritto in questo blocco prima di impostare src. Writerside rifiuta un blocco di codice non vuoto con un riferimento sorgente.", + "wsInherited": "Eredita dall’elenco", + "wsExternalImageSize": "Writerside non dimensiona in modo affidabile le immagini esterne. Le dimensioni esistenti possono essere consultate e rimosse.", + "wsPropertiesNotSerialized": "Questa modifica non è stata applicata al sorgente. La bozza è stata conservata.", + "wsListItem": "Elemento elenco", + "wsSourceFile": "File sorgente", + "wsLinkTarget": "Destinazione del collegamento", + "wsPreviewImage": "Immagine di anteprima", + "wsIncludedElement": "Elemento incluso", + "wsHelpModule": "Modulo di aiuto", + "wsActionId": "ID azione", + "wsKeymapHelpInstance": "Istanza di aiuto della mappa tasti", + "wsShowInSource": "Mostra nel sorgente", + "wsOriginElement": "Valore dell’elemento", + "wsOriginAncestor": "Elemento padre", + "wsOriginModule": "Predefinito del modulo", + "wsOriginBuiltIn": "Predefinito integrato", + "wsOriginUnresolved": "Non risolto", + "wsEffectiveProperty": "Effettivo: {value} — {origin}", + "wsElementSelector": "Elemento", + "wsGeneratedAnchorValue": "Generato: {anchor}", + "wsDefaultProperty": "Predefinito: {value}", + "wsDefaultPropertyOrigin": "Predefinito: {value} · {origin}", + "wsResolvedProperty": "Risolto: {value}", + "wsPropertyUnresolved": "Non risolto", + "wsModule": "Modulo", + "wsParent": "Elemento padre", + "wsListType": "Tipo di elenco", + "wsStartingNumber": "Numero iniziale", + "wsListSorting": "Ordinamento", + "wsLetteredList": "Elenco alfabetico", + "wsCheckboxList": "Elenco con caselle", + "wsMarkerlessList": "Elenco senza indicatori", + "wsAscending": "Crescente", + "wsDescending": "Decrescente", + "wsNoSorting": "Nessun ordinamento", + "wsAsWritten": "Come scritto", + "wsSentenceCase": "Maiuscola iniziale", + "wsTitleCase": "Maiuscole del titolo", + "wsLowerCase": "Minuscolo", + "wsUpperCase": "Maiuscolo", + "wsNativeMarkers": "Indicatori Markdown", + "wsListMarkerStart": "Indicatore iniziale: {number}", + "wsListLevel": "Livello {level}", + "wsModuleCapsIgnored": "Il builder fissato ignora le regole <caps> del modulo. Usare un attributo caps locale o default-property.", + "wsInsertElements": "Inserisci elementi", + "wsAdmonitionsAndQuotes": "Avvisi e citazioni", + "wsSemanticText": "Testo semantico", + "wsPropertiesUnavailable": "L’elemento esaminato non è disponibile. Esamina un elemento per continuare." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b923ea74..1937b115 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "モジュールの大文字・小文字設定は公開されません。", + "wsTitleOverridesAction": "インスタンスと目次のタイトル…", + "wsHeadingNotPublished": "このビルダーでは公開されません。", + "wsMarkdownInstanceTitleLimitation": "固定バージョンのビルダーは Markdown トピックのインスタンス別タイトルを公開しません。既存のマークアップは保持されます。ソースで確認してください。", + "wsTopicIdentity": "トピック ID", + "wsElementAnchor": "要素アンカー", + "wsHeadingText": "見出しテキスト", + "wsDefaultTitle": "既定のタイトル", + "wsTopicNavigation": "トピックナビゲーション", + "wsHeadingOptions": "見出しの属性", + "wsDocumentOptions": "文書設定", + "wsSettingsPresent": "明示的な設定あり", + "wsTitleOverrideExplanation": "公開タイトルを上書きします。見出しテキストは変わりません。", + "wsIgnoreVariables": "変数をリテラルテキストとして扱う", + "wsIgnoreVariablesExplanation": "有効にすると、変数のようなテキストは置換されません。", + "wsInstanceSelection": "ヘルプインスタンス", + "wsInstanceExplanation": "名前、グループ、リスト、除外、式でヘルプ出力を選択します。", + "wsFilterExplanation": "再利用する内容を条件で絞り込みます。ヘルプインスタンスの選択ではありません。", + "wsContextHelp": "コンテキストヘルプ ID", + "wsCapitalization": "大文字・小文字", + "wsLibraryTopic": "ライブラリトピック", + "wsLastModified": "最終更新", + "wsMissingTopicHeading": "トピックの H1 がありません。ソースで追加してください。", + "wsMultipleTopicHeadings": "最初の H1 がトピックタイトルです。後続の H1 は別の見出しです。", + "wsTopicIdentityMismatch": "ルート id がファイル名によるトピック ID と異なります。トピックファイルの名前変更かソースを使用してください。", + "wsUnsupportedProperties": "未対応の属性は保持されます。ソースで確認してください。", + "wsGeneratedAnchor": "自動生成アンカー。値の入力で上書きします。", + "wsHeadingBuilderLimitation": "固定バージョンのビルダーは H1 属性を公開しません。属性の所有者は見出しであり、XML ルートではありません。", "closeOtherTabs": "他のタブを閉じる", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "テンプレートからトピック...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "正規化されたリクエストと一致する可能性あり。識別情報は未確認です。", "nextcloudSelectCandidate": "確認するサーバーノートを選択", "nextcloudCreateSeparate": "別のノートを作成", - "nextcloudCreateSeparateWarning": "最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。" + "nextcloudCreateSeparateWarning": "最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。", + "wsSemanticFormats": "意味に基づく書式", + "wsUiControl": "UI コントロール", + "wsFilePath": "ファイル / パス", + "wsUiPath": "UI パス", + "wsDefinitionList": "定義リスト", + "wsIncludeContent": "再利用可能なコンテンツを含める", + "wsVariableReference": "変数参照", + "wsConvertList": "リストを手順に変換", + "wsElementProperties": "要素のプロパティ", + "wsTopicProperties": "トピックのプロパティ", + "wsCollapsible": "折りたたみ可能", + "wsPublishedState": "公開時の初期状態", + "wsExpanded": "展開", + "wsCollapsed": "折りたたみ", + "wsCollapsedTitle": "折りたたみ時のタイトル", + "wsTerm": "用語", + "wsListLayout": "リストのレイアウト", + "wsSyncGroup": "同期グループ", + "wsTabKey": "タブのキー", + "wsSwitcherLabel": "切り替えラベル", + "wsNavigationElements": "ナビゲーション要素", + "wsNavigationDepth": "ナビゲーションの深さ", + "wsAddItem": "項目を追加", + "wsStep": "ステップ", + "wsPropertyInvalid": "無効なプロパティ値", + "wsFilter": "フィルター", + "wsIdentifier": "要素 ID", + "wsTldr": "要約 (TLDR)", + "wsFullLayout": "全幅", + "wsMediumLayout": "中幅 (1:2)", + "wsNarrowLayout": "狭い用語列", + "wsTitle": "タイトル", + "wsSwitcherKey": "切り替えキー", + "nextcloudServerSettings": "サーバーのNotes設定", + "nextcloudNotesPath": "ノートフォルダーのパス", + "nextcloudFileSuffix": "新しいノートの拡張子", + "nextcloudServerSettingsExplanation": "このサーバーのNotes設定は他のクライアントにも影響します。パスを変更してもファイルは移動せず、拡張子を変更しても既存のノートの名前は変わりません。", + "nextcloudSettingsSaved": "サーバー設定を保存しました", + "nextcloudSettingsNormalized": "サーバーが正規化した値で保存しました", + "nextcloudPendingUpload": "添付ファイルのアップロード待ち", + "nextcloudServerCheck": "前回のサーバー確認: {time}", + "nextcloudSettingsLoading": "サーバー設定を読み込み中", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "サーバー設定を保存中", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "以前のローカル編集", + "nextcloudUsePreviousEdit": "以前の編集を使用", + "quickOpen": "クイックオープン", + "notesAll": "すべてのノート", + "notesUncategorized": "未分類", + "notesRecovery": "復元", + "notesRecentlyEdited": "最近編集した順", + "notesOldestEdited": "古い編集順", + "notesTitleAscending": "タイトル昇順", + "notesTitleDescending": "タイトル降順", + "notesMove": "カテゴリに移動", + "notesRemoveFavorite": "お気に入りを解除", + "notesOffline": "オフラインで利用可能にする", + "notesOfflineRemove": "オフライン保持を解除", + "notesIndexing": "ノートを索引作成中…", + "notesSearchHelp": "語句は AND で結合します。\"引用句\"、title:、category: を使えます。", + "notesSourceLocation": "ソース位置", + "notesRecoveryHelp": "保持されたローカルノートとローカル履歴のみ。サーバーのごみ箱や履歴は利用できません。", + "notesImport": "ノートをインポート", + "notesExport": "ノートをエクスポート", + "notesImportDistinct": "別のノートとしてインポート", + "notesAvailableOffline": "オフラインで利用可能", + "notesIncomplete": "不完全", + "notesSelected": "選択したノート", + "notesNoHistory": "保持された版がありません。履歴は無効、期限切れ、または消去済みの場合があります。", + "notesTextAvailable": "テキストはローカルで利用可能", + "notesLocalVersion": "ローカルの作業版", + "notesAttachments": "添付ファイル", + "notesSkipped": "スキップ", + "notesFailed": "失敗", + "notesCompleted": "完了", + "notesImportNew": "新しいインポートを開始", + "notesImportResume": "中断したインポートを再開", + "notesImportAlreadyImported": "この操作ではインポート済み", + "references": "参照", + "referenceIncoming": "被参照", + "referenceOutgoing": "参照先", + "referenceChooseTarget": "リンク先を選択", + "referenceWholeDocument": "文書全体", + "referenceSourceLocation": "参照元の位置", + "referenceTargetLocation": "参照先の位置", + "referenceResolved": "解決済み", + "referenceMissing": "見つかりません", + "referenceAmbiguous": "曖昧", + "referenceUnavailable": "このコンテキストでは利用できません", + "referenceUnsupported": "未対応", + "referenceNoPortableId": "この参照先には確認済みの移植可能なノートURLがありません。", + "wsIncludeLines": "行を含める", + "wsIncludeSymbol": "シンボルを含める", + "wsEmphasizeLines": "表示された行を強調", + "wsPropertiesReadOnly": "この表示ではプロパティは読み取り専用です。", + "wsPropertiesProtected": "この要素は保護されています。ソースを編集してください。", + "wsPropertiesConflict": "文書または要素が変わりました。下書きを戻して再選択してください。", + "wsPropertiesUnresolved": "現在、参照を解決できません。", + "wsUseDefault": "既定値を使用", + "wsPropertiesSelection": "単一の要素を選択してください。", + "wsImageDisplay": "画像の表示", + "wsImageThumbnail": "クリック可能なサムネイル", + "wsEnabled": "有効", + "wsDisabled": "無効", + "wsWidth": "幅", + "wsHeight": "高さ", + "wsPropertiesInactive": "公開時の折りたたみが無効の場合、このプロパティは作用しません。", + "wsNavigationAll": "タイトルのあるすべての要素", + "wsNavigationNone": "ナビゲーションなし", + "wsNavigationCustom": "カスタムナビゲーション", + "wsTitleOverride": "タイトルの上書き", + "wsCompactLayout": "コンパクト", + "wsWideLayout": "ワイド", + "wsCodeSourceConflict": "src を設定する前に、このブロック内に記述したコードを移動または削除してください。Writerside はソース参照がある空でないコードブロックを拒否します。", + "wsInherited": "リストから継承", + "wsExternalImageSize": "Writerside は外部画像のサイズを確実には設定できません。既存の寸法は確認して削除できます。", + "wsPropertiesNotSerialized": "この変更はソースに反映されませんでした。下書きは保持されています。", + "wsListItem": "リスト項目", + "wsSourceFile": "ソースファイル", + "wsLinkTarget": "リンク先", + "wsPreviewImage": "プレビュー画像", + "wsIncludedElement": "インクルードする要素", + "wsHelpModule": "ヘルプモジュール", + "wsActionId": "アクション ID", + "wsKeymapHelpInstance": "キーマップのヘルプインスタンス", + "wsShowInSource": "ソースで表示", + "wsOriginElement": "要素の指定値", + "wsOriginAncestor": "親要素", + "wsOriginModule": "モジュールの既定値", + "wsOriginBuiltIn": "組み込みの既定値", + "wsOriginUnresolved": "未解決", + "wsEffectiveProperty": "有効値: {value} — {origin}", + "wsElementSelector": "要素", + "wsGeneratedAnchorValue": "生成値: {anchor}", + "wsDefaultProperty": "既定値: {value}", + "wsDefaultPropertyOrigin": "既定値: {value} · {origin}", + "wsResolvedProperty": "解決値: {value}", + "wsPropertyUnresolved": "未解決", + "wsModule": "モジュール", + "wsParent": "親要素", + "wsListType": "リストの種類", + "wsStartingNumber": "開始番号", + "wsListSorting": "並べ替え", + "wsLetteredList": "英字リスト", + "wsCheckboxList": "チェックボックスリスト", + "wsMarkerlessList": "マーカーなしのリスト", + "wsAscending": "昇順", + "wsDescending": "降順", + "wsNoSorting": "並べ替えなし", + "wsAsWritten": "記述どおり", + "wsSentenceCase": "文頭のみ大文字", + "wsTitleCase": "タイトル形式", + "wsLowerCase": "小文字", + "wsUpperCase": "大文字", + "wsNativeMarkers": "Markdown マーカー", + "wsListMarkerStart": "開始マーカー: {number}", + "wsListLevel": "階層 {level}", + "wsModuleCapsIgnored": "固定されたビルダーはモジュールの <caps> ルールを無視します。ローカルの caps 属性または default-property を使用してください。", + "wsInsertElements": "要素を挿入", + "wsAdmonitionsAndQuotes": "注意書きと引用", + "wsSemanticText": "セマンティックテキスト", + "wsPropertiesUnavailable": "確認中の要素は利用できません。続行するには要素を確認してください。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 49ab8b8c..9920f6f7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "모듈의 대소문자 설정은 게시되지 않습니다.", + "wsTitleOverridesAction": "인스턴스 및 목차 제목…", + "wsHeadingNotPublished": "이 빌더에서는 게시되지 않습니다.", + "wsMarkdownInstanceTitleLimitation": "고정된 빌더는 Markdown 주제의 인스턴스 제목을 게시하지 않습니다. 기존 마크업은 보존됩니다. 소스에서 확인하세요.", + "wsTopicIdentity": "주제 ID", + "wsElementAnchor": "요소 앵커", + "wsHeadingText": "제목 텍스트", + "wsDefaultTitle": "기본 제목", + "wsTopicNavigation": "주제 탐색", + "wsHeadingOptions": "제목 속성", + "wsDocumentOptions": "문서 설정", + "wsSettingsPresent": "작성된 설정 있음", + "wsTitleOverrideExplanation": "게시 제목을 재정의하며 제목 텍스트는 변경하지 않습니다.", + "wsIgnoreVariables": "변수를 리터럴 텍스트로 처리", + "wsIgnoreVariablesExplanation": "활성화하면 변수처럼 보이는 텍스트를 치환하지 않습니다.", + "wsInstanceSelection": "도움말 인스턴스", + "wsInstanceExplanation": "이름, 그룹, 목록, 제외 또는 식으로 도움말 출력을 선택합니다.", + "wsFilterExplanation": "조건에 따라 재사용 콘텐츠를 필터링하며 도움말 인스턴스를 선택하지 않습니다.", + "wsContextHelp": "상황별 도움말 ID", + "wsCapitalization": "대소문자", + "wsLibraryTopic": "라이브러리 주제", + "wsLastModified": "최종 수정", + "wsMissingTopicHeading": "주제 H1이 없습니다. 소스에서 추가하세요.", + "wsMultipleTopicHeadings": "첫 H1이 주제 제목을 결정합니다. 이후 H1은 별도 제목입니다.", + "wsTopicIdentityMismatch": "루트 id가 파일명 기반 주제 ID와 다릅니다. 주제 파일 이름 바꾸기 또는 소스를 사용하세요.", + "wsUnsupportedProperties": "지원하지 않는 속성은 보존됩니다. 소스에서 확인하세요.", + "wsGeneratedAnchor": "생성된 앵커이며 값을 입력하면 재정의됩니다.", + "wsHeadingBuilderLimitation": "고정된 빌더는 H1 속성을 게시하지 않습니다. 속성은 XML 루트가 아닌 제목에 속합니다.", "closeOtherTabs": "다른 탭 닫기", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "템플릿에서 토픽...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "정리된 요청과 일치할 수 있습니다. 동일한 노트인지 확인되지 않았습니다.", "nextcloudSelectCandidate": "검토할 서버 노트 선택", "nextcloudCreateSeparate": "별도 노트 만들기", - "nextcloudCreateSeparateWarning": "첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다." + "nextcloudCreateSeparateWarning": "첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다.", + "wsSemanticFormats": "의미 기반 서식", + "wsUiControl": "UI 컨트롤", + "wsFilePath": "파일 / 경로", + "wsUiPath": "UI 경로", + "wsDefinitionList": "정의 목록", + "wsIncludeContent": "재사용 가능한 콘텐츠 포함", + "wsVariableReference": "변수 참조", + "wsConvertList": "목록을 절차로 변환", + "wsElementProperties": "요소 속성", + "wsTopicProperties": "주제 속성", + "wsCollapsible": "접기 가능", + "wsPublishedState": "게시 시 초기 상태", + "wsExpanded": "펼침", + "wsCollapsed": "접힘", + "wsCollapsedTitle": "접힌 제목", + "wsTerm": "용어", + "wsListLayout": "목록 레이아웃", + "wsSyncGroup": "동기화 그룹", + "wsTabKey": "탭 키", + "wsSwitcherLabel": "전환 레이블", + "wsNavigationElements": "탐색 요소", + "wsNavigationDepth": "탐색 깊이", + "wsAddItem": "항목 추가", + "wsStep": "단계", + "wsPropertyInvalid": "잘못된 속성 값", + "wsFilter": "필터", + "wsIdentifier": "요소 ID", + "wsTldr": "요약 (TLDR)", + "wsFullLayout": "전체 너비", + "wsMediumLayout": "중간 (1:2)", + "wsNarrowLayout": "좁은 용어 열", + "wsTitle": "제목", + "wsSwitcherKey": "전환 키", + "nextcloudServerSettings": "서버 Notes 설정", + "nextcloudNotesPath": "노트 폴더 경로", + "nextcloudFileSuffix": "새 노트의 파일 확장자", + "nextcloudServerSettingsExplanation": "이 서버 Notes 설정은 다른 클라이언트에도 적용됩니다. 경로를 변경해도 파일이 이동하지 않으며 확장자를 변경해도 기존 노트의 이름이 바뀌지 않습니다.", + "nextcloudSettingsSaved": "서버 설정 저장됨", + "nextcloudSettingsNormalized": "서버에서 정규화한 값으로 저장됨", + "nextcloudPendingUpload": "첨부 파일 업로드 대기 중", + "nextcloudServerCheck": "마지막 서버 확인: {time}", + "nextcloudSettingsLoading": "서버 설정을 불러오는 중", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "서버 설정을 저장하는 중", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "이전 로컬 편집", + "nextcloudUsePreviousEdit": "이전 편집 사용", + "quickOpen": "빠른 열기", + "notesAll": "모든 노트", + "notesUncategorized": "미분류", + "notesRecovery": "복구", + "notesRecentlyEdited": "최근 편집순", + "notesOldestEdited": "오래된 편집순", + "notesTitleAscending": "제목 오름차순", + "notesTitleDescending": "제목 내림차순", + "notesMove": "범주로 이동", + "notesRemoveFavorite": "즐겨찾기 해제", + "notesOffline": "오프라인 사용 설정", + "notesOfflineRemove": "오프라인 보존 해제", + "notesIndexing": "노트 색인 생성 중…", + "notesSearchHelp": "검색어는 AND로 결합합니다. \"인용 구문\", title:, category:를 사용하세요.", + "notesSourceLocation": "소스 위치", + "notesRecoveryHelp": "보존된 로컬 노트와 로컬 기록만 표시합니다. 서버 휴지통과 기록은 사용할 수 없습니다.", + "notesImport": "노트 가져오기", + "notesExport": "노트 내보내기", + "notesImportDistinct": "별도 노트로 가져오기", + "notesAvailableOffline": "오프라인 사용 가능", + "notesIncomplete": "불완전", + "notesSelected": "선택한 노트", + "notesNoHistory": "보존된 버전이 없습니다. 기록이 비활성화되었거나 만료 또는 삭제되었을 수 있습니다.", + "notesTextAvailable": "텍스트를 로컬에서 사용 가능", + "notesLocalVersion": "로컬 작업 버전", + "notesAttachments": "첨부 파일", + "notesSkipped": "건너뜀", + "notesFailed": "실패", + "notesCompleted": "완료", + "notesImportNew": "새 가져오기 시작", + "notesImportResume": "중단된 가져오기 재개", + "notesImportAlreadyImported": "이 작업에서 이미 가져옴", + "references": "참조", + "referenceIncoming": "들어오는 참조", + "referenceOutgoing": "나가는 참조", + "referenceChooseTarget": "링크 대상 선택", + "referenceWholeDocument": "전체 문서", + "referenceSourceLocation": "원본 위치", + "referenceTargetLocation": "대상 위치", + "referenceResolved": "해결됨", + "referenceMissing": "없음", + "referenceAmbiguous": "모호함", + "referenceUnavailable": "이 맥락에서 사용할 수 없음", + "referenceUnsupported": "지원되지 않음", + "referenceNoPortableId": "이 대상에는 확인된 이식 가능한 노트 URL이 없습니다.", + "wsIncludeLines": "줄 포함", + "wsIncludeSymbol": "기호 포함", + "wsEmphasizeLines": "표시된 줄 강조", + "wsPropertiesReadOnly": "이 보기에서 속성은 읽기 전용입니다.", + "wsPropertiesProtected": "보호된 요소입니다. 소스를 편집하세요.", + "wsPropertiesConflict": "문서 또는 요소가 변경되었습니다. 초안을 되돌리고 다시 선택하세요.", + "wsPropertiesUnresolved": "현재 참조를 확인할 수 없습니다.", + "wsUseDefault": "기본값 사용", + "wsPropertiesSelection": "요소 하나를 선택하세요.", + "wsImageDisplay": "이미지 표시", + "wsImageThumbnail": "클릭 가능한 미리 보기", + "wsEnabled": "사용", + "wsDisabled": "사용 안 함", + "wsWidth": "너비", + "wsHeight": "높이", + "wsPropertiesInactive": "게시된 접기가 꺼져 있으면 이 속성은 적용되지 않습니다.", + "wsNavigationAll": "제목이 있는 모든 요소", + "wsNavigationNone": "탐색 없음", + "wsNavigationCustom": "사용자 지정 탐색", + "wsTitleOverride": "제목 재정의", + "wsCompactLayout": "간결", + "wsWideLayout": "넓게", + "wsCodeSourceConflict": "src를 설정하기 전에 이 블록에 작성된 코드를 이동하거나 제거하세요. Writerside는 소스 참조가 있는 비어 있지 않은 코드 블록을 허용하지 않습니다.", + "wsInherited": "목록에서 상속", + "wsExternalImageSize": "Writerside는 외부 이미지 크기를 안정적으로 설정하지 못합니다. 기존 크기는 확인하거나 제거할 수 있습니다.", + "wsPropertiesNotSerialized": "이 변경 사항은 소스에 반영되지 않았습니다. 초안은 유지됩니다.", + "wsListItem": "목록 항목", + "wsSourceFile": "소스 파일", + "wsLinkTarget": "링크 대상", + "wsPreviewImage": "미리보기 이미지", + "wsIncludedElement": "포함할 요소", + "wsHelpModule": "도움말 모듈", + "wsActionId": "액션 ID", + "wsKeymapHelpInstance": "키맵 도움말 인스턴스", + "wsShowInSource": "소스에서 표시", + "wsOriginElement": "요소 재정의", + "wsOriginAncestor": "상위 요소", + "wsOriginModule": "모듈 기본값", + "wsOriginBuiltIn": "내장 기본값", + "wsOriginUnresolved": "확인되지 않음", + "wsEffectiveProperty": "적용 값: {value} — {origin}", + "wsElementSelector": "요소", + "wsGeneratedAnchorValue": "생성된 값: {anchor}", + "wsDefaultProperty": "기본값: {value}", + "wsDefaultPropertyOrigin": "기본값: {value} · {origin}", + "wsResolvedProperty": "해석된 값: {value}", + "wsPropertyUnresolved": "미해결", + "wsModule": "모듈", + "wsParent": "부모 요소", + "wsListType": "목록 유형", + "wsStartingNumber": "시작 번호", + "wsListSorting": "정렬", + "wsLetteredList": "알파벳 목록", + "wsCheckboxList": "체크박스 목록", + "wsMarkerlessList": "표시 없는 목록", + "wsAscending": "오름차순", + "wsDescending": "내림차순", + "wsNoSorting": "정렬 안 함", + "wsAsWritten": "작성된 대로", + "wsSentenceCase": "문장 형식", + "wsTitleCase": "제목 형식", + "wsLowerCase": "소문자", + "wsUpperCase": "대문자", + "wsNativeMarkers": "Markdown 표시", + "wsListMarkerStart": "시작 표시: {number}", + "wsListLevel": "수준 {level}", + "wsModuleCapsIgnored": "고정된 빌더는 모듈의 <caps> 규칙을 무시합니다. 로컬 caps 속성 또는 default-property를 사용하세요.", + "wsInsertElements": "요소 삽입", + "wsAdmonitionsAndQuotes": "안내 및 인용", + "wsSemanticText": "의미 텍스트", + "wsPropertiesUnavailable": "검사 중인 요소를 사용할 수 없습니다. 계속하려면 요소를 검사하세요." } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 218d41f3..d3fa2c48 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Modulens bokstavstil publiseres ikke.", + "wsTitleOverridesAction": "Titler for instans og innholdsfortegnelse…", + "wsHeadingNotPublished": "Publiseres ikke av denne byggeren.", + "wsMarkdownInstanceTitleLimitation": "Den fastlåste byggeren publiserer ikke instanstitler i Markdown-emner. Eksisterende markering bevares; se kilden.", + "wsTopicIdentity": "Emne-ID", + "wsElementAnchor": "Elementanker", + "wsHeadingText": "Overskriftstekst", + "wsDefaultTitle": "Standardtittel", + "wsTopicNavigation": "Emnenavigasjon", + "wsHeadingOptions": "Overskriftsattributter", + "wsDocumentOptions": "Dokumentinnstillinger", + "wsSettingsPresent": "Eksplisitte innstillinger finnes", + "wsTitleOverrideExplanation": "Overstyrer publisert tittel; overskriftsteksten beholdes.", + "wsIgnoreVariables": "Behandle variabeltekst bokstavelig", + "wsIgnoreVariablesExplanation": "Når aktivert erstattes ikke tekst som ligner variabler.", + "wsInstanceSelection": "Hjelpeinstanser", + "wsInstanceExplanation": "Velg hjelpeutgaver med navn, grupper, lister, unntak eller uttrykk.", + "wsFilterExplanation": "Filtrer gjenbrukbart innhold etter kriterier; dette velger ikke en hjelpeinstans.", + "wsContextHelp": "Konteksthjelp-ID", + "wsCapitalization": "Store og små bokstaver", + "wsLibraryTopic": "Bibliotekemne", + "wsLastModified": "Sist endret", + "wsMissingTopicHeading": "Ingen H1 for emnet. Legg den til i kilden.", + "wsMultipleTopicHeadings": "Den første H1 bestemmer emnetittelen. Senere H1 er egne overskrifter.", + "wsTopicIdentityMismatch": "Rotens id avviker fra filnavnets emne-ID. Bruk Gi emnefil nytt navn eller se kilden.", + "wsUnsupportedProperties": "Ustøttede attributter bevares. Se dem i kilden.", + "wsGeneratedAnchor": "Generert anker; en verdi oppretter en overstyring.", + "wsHeadingBuilderLimitation": "Den fastlåste byggeren publiserer ikke H1-attributter. De tilhører overskriften, ikke XML-roten.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Mulig samsvar etter normalisering; identiteten er ubekreftet.", "nextcloudSelectCandidate": "Velg et servernotat som skal gjennomgås", "nextcloudCreateSeparate": "Opprett et separat notat", - "nextcloudCreateSeparateWarning": "Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes." + "nextcloudCreateSeparateWarning": "Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes.", + "wsSemanticFormats": "Semantisk formatering", + "wsUiControl": "UI-kontroll", + "wsFilePath": "Fil / Sti", + "wsUiPath": "UI-sti", + "wsDefinitionList": "Definisjonsliste", + "wsIncludeContent": "Inkluder gjenbrukbart innhold", + "wsVariableReference": "Variabelreferanse", + "wsConvertList": "Konverter liste til prosedyre", + "wsElementProperties": "Elementegenskaper", + "wsTopicProperties": "Emneegenskaper", + "wsCollapsible": "Sammenleggbar", + "wsPublishedState": "Publisert starttilstand", + "wsExpanded": "Utvidet", + "wsCollapsed": "Sammenlagt", + "wsCollapsedTitle": "Sammenlagt tittel", + "wsTerm": "Begrep", + "wsListLayout": "Listeoppsett", + "wsSyncGroup": "Synkroniseringsgruppe", + "wsTabKey": "Fanenøkkel", + "wsSwitcherLabel": "Bytteetikett", + "wsNavigationElements": "Navigasjonselementer", + "wsNavigationDepth": "Navigasjonsdybde", + "wsAddItem": "Legg til element", + "wsStep": "Trinn", + "wsPropertyInvalid": "Ugyldig egenskapsverdi", + "wsFilter": "Innholdsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Sammendrag (TLDR)", + "wsFullLayout": "Full bredde", + "wsMediumLayout": "Middels (1:2)", + "wsNarrowLayout": "Smal termkolonne", + "wsTitle": "Tittel", + "wsSwitcherKey": "Byttenøkkel", + "nextcloudServerSettings": "Notes-innstillinger på serveren", + "nextcloudNotesPath": "Sti til notatmappen", + "nextcloudFileSuffix": "Filendelse for nye notater", + "nextcloudServerSettingsExplanation": "Disse Notes-innstillingene påvirker andre klienter. Endring av stien flytter ikke filer; endring av filendelsen gir ikke eksisterende notater nye navn.", + "nextcloudSettingsSaved": "Serverinnstillinger lagret", + "nextcloudSettingsNormalized": "Lagret med verdier normalisert av serveren", + "nextcloudPendingUpload": "Vedlegg venter på opplasting", + "nextcloudServerCheck": "Siste serverkontroll: {time}", + "nextcloudSettingsLoading": "Laster serverinnstillinger", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Lagrer serverinnstillinger", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Forrige lokale endring", + "nextcloudUsePreviousEdit": "Bruk forrige endring", + "quickOpen": "Hurtigåpning", + "notesAll": "Alle notater", + "notesUncategorized": "Uten kategori", + "notesRecovery": "Gjenoppretting", + "notesRecentlyEdited": "Nylig redigert", + "notesOldestEdited": "Eldste redigering", + "notesTitleAscending": "Tittel A–Z", + "notesTitleDescending": "Tittel Z–A", + "notesMove": "Flytt til kategori", + "notesRemoveFavorite": "Fjern favoritt", + "notesOffline": "Gjør tilgjengelig frakoblet", + "notesOfflineRemove": "Fjern frakoblet oppbevaring", + "notesIndexing": "Indekserer notater…", + "notesSearchHelp": "Ord bruker OG. Bruk \"siterte fraser\", title: og category:.", + "notesSourceLocation": "Kildeplassering", + "notesRecoveryHelp": "Kun beholdte lokale notater og lokal historikk. Serverens papirkurv og historikk er utilgjengelige.", + "notesImport": "Importer notater", + "notesExport": "Eksporter notater", + "notesImportDistinct": "Importer som eget notat", + "notesAvailableOffline": "Tilgjengelig frakoblet", + "notesIncomplete": "Ufullstendig", + "notesSelected": "Valgte notater", + "notesNoHistory": "Ingen beholdte revisjoner. Historikken kan være deaktivert, utløpt eller slettet.", + "notesTextAvailable": "Tekst tilgjengelig lokalt", + "notesLocalVersion": "Lokal arbeidsversjon", + "notesAttachments": "Vedlegg", + "notesSkipped": "Hoppet over", + "notesFailed": "Mislyktes", + "notesCompleted": "Fullført", + "notesImportNew": "Start en ny import", + "notesImportResume": "Fortsett avbrutt import", + "notesImportAlreadyImported": "Allerede importert i denne operasjonen", + "references": "Referanser", + "referenceIncoming": "Innkommende", + "referenceOutgoing": "Utgående", + "referenceChooseTarget": "Velg lenkemål", + "referenceWholeDocument": "Hele dokumentet", + "referenceSourceLocation": "Kildeplassering", + "referenceTargetLocation": "Målplassering", + "referenceResolved": "Løst", + "referenceMissing": "Mangler", + "referenceAmbiguous": "Tvetydig", + "referenceUnavailable": "Utilgjengelig i denne konteksten", + "referenceUnsupported": "Ikke støttet", + "referenceNoPortableId": "Dette målet har ingen bekreftet portabel notatadresse.", + "wsIncludeLines": "Inkluder linjer", + "wsIncludeSymbol": "Inkluder symbol", + "wsEmphasizeLines": "Fremhev viste linjer", + "wsPropertiesReadOnly": "Egenskapene er skrivebeskyttet i denne visningen.", + "wsPropertiesProtected": "Elementet er beskyttet. Rediger kilden.", + "wsPropertiesConflict": "Dokument eller element endret. Tilbakestill utkastet og velg på nytt.", + "wsPropertiesUnresolved": "Referansen kan ikke løses nå.", + "wsUseDefault": "Bruk standard", + "wsPropertiesSelection": "Velg ett element.", + "wsImageDisplay": "Bildevisning", + "wsImageThumbnail": "Klikkbart miniatyrbilde", + "wsEnabled": "Aktivert", + "wsDisabled": "Deaktivert", + "wsWidth": "Bredde", + "wsHeight": "Høyde", + "wsPropertiesInactive": "Egenskapen virker bare med publisert sammenfolding.", + "wsNavigationAll": "Alle elementer med tittel", + "wsNavigationNone": "Ingen navigasjon", + "wsNavigationCustom": "Egendefinert navigasjon", + "wsTitleOverride": "Overstyr tittel", + "wsCompactLayout": "Kompakt", + "wsWideLayout": "Bred", + "wsCodeSourceConflict": "Flytt eller fjern koden skrevet i denne blokken før du setter src. Writerside avviser en ikke-tom kodeblokk med en kildereferanse.", + "wsInherited": "Arv fra listen", + "wsExternalImageSize": "Writerside endrer ikke størrelsen på eksterne bilder pålitelig. Eksisterende mål kan ses og fjernes.", + "wsPropertiesNotSerialized": "Denne endringen ble ikke skrevet til kilden. Utkastet er beholdt.", + "wsListItem": "Listeelement", + "wsSourceFile": "Kildefil", + "wsLinkTarget": "Lenkemål", + "wsPreviewImage": "Forhåndsvisningsbilde", + "wsIncludedElement": "Inkludert element", + "wsHelpModule": "Hjelpemodul", + "wsActionId": "Handlings-ID", + "wsKeymapHelpInstance": "Hjelpeinstans for tastaturkart", + "wsShowInSource": "Vis i Kilde", + "wsOriginElement": "Elementoverstyring", + "wsOriginAncestor": "Overordnet element", + "wsOriginModule": "Modulstandard", + "wsOriginBuiltIn": "Innebygd standard", + "wsOriginUnresolved": "Uavklart", + "wsEffectiveProperty": "Effektiv: {value} — {origin}", + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Generert: {anchor}", + "wsDefaultProperty": "Standard: {value}", + "wsDefaultPropertyOrigin": "Standard: {value} · {origin}", + "wsResolvedProperty": "Avklart: {value}", + "wsPropertyUnresolved": "Uavklart", + "wsModule": "Modul", + "wsParent": "Overordnet element", + "wsListType": "Listetype", + "wsStartingNumber": "Startnummer", + "wsListSorting": "Sortering", + "wsLetteredList": "Bokstavliste", + "wsCheckboxList": "Liste med avkrysningsbokser", + "wsMarkerlessList": "Liste uten markører", + "wsAscending": "Stigende", + "wsDescending": "Synkende", + "wsNoSorting": "Ingen sortering", + "wsAsWritten": "Som skrevet", + "wsSentenceCase": "Stor forbokstav i setning", + "wsTitleCase": "Store forbokstaver i tittel", + "wsLowerCase": "Små bokstaver", + "wsUpperCase": "Store bokstaver", + "wsNativeMarkers": "Markdown-markører", + "wsListMarkerStart": "Første markør: {number}", + "wsListLevel": "Nivå {level}", + "wsModuleCapsIgnored": "Den fastsatte byggeren ignorerer modulens <caps>-regler. Bruk et lokalt caps-attributt eller default-property.", + "wsInsertElements": "Sett inn elementer", + "wsAdmonitionsAndQuotes": "Merknader og sitater", + "wsSemanticText": "Semantisk tekst", + "wsPropertiesUnavailable": "Det inspiserte elementet er utilgjengelig. Inspiser et element for å fortsette." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 308d7c3b..5729425b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Hoofdlettergebruik van de module wordt niet gepubliceerd.", + "wsTitleOverridesAction": "Instantie- en inhoudsopgavetitels…", + "wsHeadingNotPublished": "Wordt niet gepubliceerd door deze bouwer.", + "wsMarkdownInstanceTitleLimitation": "De vastgelegde builder publiceert geen instantietitels in Markdown-onderwerpen. Bestaande opmaak blijft behouden; bekijk de bron.", + "wsTopicIdentity": "Onderwerp-ID", + "wsElementAnchor": "Elementanker", + "wsHeadingText": "Koptekst", + "wsDefaultTitle": "Standaardtitel", + "wsTopicNavigation": "Onderwerpnavigatie", + "wsHeadingOptions": "Kopattributen", + "wsDocumentOptions": "Documentinstellingen", + "wsSettingsPresent": "Expliciete instellingen aanwezig", + "wsTitleOverrideExplanation": "Vervangt de gepubliceerde titel; de koptekst blijft gelijk.", + "wsIgnoreVariables": "Variabeletekst letterlijk behandelen", + "wsIgnoreVariablesExplanation": "Indien ingeschakeld wordt tekst die op variabelen lijkt niet vervangen.", + "wsInstanceSelection": "Helpinstanties", + "wsInstanceExplanation": "Selecteer helpuitvoer via namen, groepen, lijsten, uitsluitingen of expressies.", + "wsFilterExplanation": "Filter herbruikbare inhoud op criteria; dit selecteert geen helpinstantie.", + "wsContextHelp": "Contexthelp-ID", + "wsCapitalization": "Hoofdlettergebruik", + "wsLibraryTopic": "Bibliotheekonderwerp", + "wsLastModified": "Laatst gewijzigd", + "wsMissingTopicHeading": "Geen onderwerp-H1 aanwezig. Voeg deze toe in de bron.", + "wsMultipleTopicHeadings": "De eerste H1 bepaalt de onderwerptitel. Latere H1’s zijn afzonderlijke koppen.", + "wsTopicIdentityMismatch": "De root-id verschilt van de bestands-ID. Gebruik Onderwerpbestand hernoemen of bekijk de bron.", + "wsUnsupportedProperties": "Niet-ondersteunde attributen blijven behouden. Bekijk ze in de bron.", + "wsGeneratedAnchor": "Gegenereerd anker; een waarde maakt een overschrijving.", + "wsHeadingBuilderLimitation": "De vastgelegde builder publiceert geen H1-attributen. Ze horen bij de kop, niet bij de XML-root.", "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...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "Mogelijke overeenkomst na normalisatie; identiteit onbevestigd.", "nextcloudSelectCandidate": "Selecteer een servernotitie om te bekijken", "nextcloudCreateSeparate": "Een afzonderlijke notitie maken", - "nextcloudCreateSeparateWarning": "Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd." + "nextcloudCreateSeparateWarning": "Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd.", + "wsSemanticFormats": "Semantische opmaak", + "wsUiControl": "UI-besturingselement", + "wsFilePath": "Bestand / Pad", + "wsUiPath": "UI-pad", + "wsDefinitionList": "Definitielijst", + "wsIncludeContent": "Herbruikbare inhoud opnemen", + "wsVariableReference": "Variabelereferentie", + "wsConvertList": "Lijst omzetten naar procedure", + "wsElementProperties": "Elementeigenschappen", + "wsTopicProperties": "Onderwerpeigenschappen", + "wsCollapsible": "Inklapbaar", + "wsPublishedState": "Gepubliceerde beginstatus", + "wsExpanded": "Uitgeklapt", + "wsCollapsed": "Ingeklapt", + "wsCollapsedTitle": "Ingeklapte titel", + "wsTerm": "Begrip", + "wsListLayout": "Lijstindeling", + "wsSyncGroup": "Synchronisatiegroep", + "wsTabKey": "Tabsleutel", + "wsSwitcherLabel": "Schakelaarlabel", + "wsNavigationElements": "Navigatie-elementen", + "wsNavigationDepth": "Navigatiediepte", + "wsAddItem": "Item toevoegen", + "wsStep": "Stap", + "wsPropertyInvalid": "Ongeldige eigenschapswaarde", + "wsFilter": "Inhoudsfilter", + "wsIdentifier": "Element-ID", + "wsTldr": "Samenvatting (TLDR)", + "wsFullLayout": "Volledige breedte", + "wsMediumLayout": "Gemiddeld (1:2)", + "wsNarrowLayout": "Smalle begripskolom", + "wsTitle": "Titel", + "wsSwitcherKey": "Keuzeschakelaar", + "nextcloudServerSettings": "Notes-instellingen op de server", + "nextcloudNotesPath": "Pad van de notitiemap", + "nextcloudFileSuffix": "Bestandsextensie voor nieuwe notities", + "nextcloudServerSettingsExplanation": "Deze Notes-instellingen gelden ook voor andere clients. Een ander pad verplaatst geen bestanden; een andere extensie hernoemt bestaande notities niet.", + "nextcloudSettingsSaved": "Serverinstellingen opgeslagen", + "nextcloudSettingsNormalized": "Opgeslagen met door de server genormaliseerde waarden", + "nextcloudPendingUpload": "Bijlage wacht op upload", + "nextcloudServerCheck": "Laatste servercontrole: {time}", + "nextcloudSettingsLoading": "Serverinstellingen laden", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Serverinstellingen opslaan", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Vorige lokale wijziging", + "nextcloudUsePreviousEdit": "Vorige wijziging gebruiken", + "quickOpen": "Snel openen", + "notesAll": "Alle notities", + "notesUncategorized": "Zonder categorie", + "notesRecovery": "Herstel", + "notesRecentlyEdited": "Recent bewerkt", + "notesOldestEdited": "Oudste bewerking", + "notesTitleAscending": "Titel A–Z", + "notesTitleDescending": "Titel Z–A", + "notesMove": "Naar categorie verplaatsen", + "notesRemoveFavorite": "Favoriet verwijderen", + "notesOffline": "Offline beschikbaar maken", + "notesOfflineRemove": "Offlinebewaring verwijderen", + "notesIndexing": "Notities indexeren…", + "notesSearchHelp": "Termen gebruiken EN. Gebruik \"geciteerde zinnen\", title: en category:.", + "notesSourceLocation": "Bronlocatie", + "notesRecoveryHelp": "Alleen bewaarde lokale notities en lokale geschiedenis. Prullenbak en geschiedenis van de server zijn niet beschikbaar.", + "notesImport": "Notities importeren", + "notesExport": "Notities exporteren", + "notesImportDistinct": "Als afzonderlijke notitie importeren", + "notesAvailableOffline": "Offline beschikbaar", + "notesIncomplete": "Onvolledig", + "notesSelected": "Geselecteerde notities", + "notesNoHistory": "Geen bewaarde revisies. Geschiedenis kan uitgeschakeld, verlopen of gewist zijn.", + "notesTextAvailable": "Tekst lokaal beschikbaar", + "notesLocalVersion": "Lokale werkversie", + "notesAttachments": "Bijlagen", + "notesSkipped": "Overgeslagen", + "notesFailed": "Mislukt", + "notesCompleted": "Voltooid", + "notesImportNew": "Nieuwe import starten", + "notesImportResume": "Onderbroken import hervatten", + "notesImportAlreadyImported": "Al geïmporteerd in deze bewerking", + "references": "Verwijzingen", + "referenceIncoming": "Inkomend", + "referenceOutgoing": "Uitgaand", + "referenceChooseTarget": "Linkdoel kiezen", + "referenceWholeDocument": "Heel document", + "referenceSourceLocation": "Bronlocatie", + "referenceTargetLocation": "Doellocatie", + "referenceResolved": "Opgelost", + "referenceMissing": "Ontbreekt", + "referenceAmbiguous": "Dubbelzinnig", + "referenceUnavailable": "Niet beschikbaar in deze context", + "referenceUnsupported": "Niet ondersteund", + "referenceNoPortableId": "Dit doel heeft geen geverifieerde overdraagbare notitie-URL.", + "wsIncludeLines": "Regels opnemen", + "wsIncludeSymbol": "Symbool opnemen", + "wsEmphasizeLines": "Weergegeven regels markeren", + "wsPropertiesReadOnly": "Eigenschappen zijn alleen-lezen in deze weergave.", + "wsPropertiesProtected": "Dit element is beschermd. Bewerk de bron.", + "wsPropertiesConflict": "Document of element gewijzigd. Herstel het concept en selecteer opnieuw.", + "wsPropertiesUnresolved": "De verwijzing kan momenteel niet worden opgelost.", + "wsUseDefault": "Standaard gebruiken", + "wsPropertiesSelection": "Selecteer één element.", + "wsImageDisplay": "Afbeeldingsweergave", + "wsImageThumbnail": "Klikbare miniatuur", + "wsEnabled": "Ingeschakeld", + "wsDisabled": "Uitgeschakeld", + "wsWidth": "Breedte", + "wsHeight": "Hoogte", + "wsPropertiesInactive": "Deze eigenschap werkt alleen bij gepubliceerd inklappen.", + "wsNavigationAll": "Alle elementen met titel", + "wsNavigationNone": "Geen navigatie", + "wsNavigationCustom": "Aangepaste navigatie", + "wsTitleOverride": "Titel overschrijven", + "wsCompactLayout": "Compacte indeling", + "wsWideLayout": "Breed", + "wsCodeSourceConflict": "Verplaats of verwijder de code in dit blok voordat u src instelt. Writerside weigert een niet-leeg codeblok met een bronverwijzing.", + "wsInherited": "Overnemen van lijst", + "wsExternalImageSize": "Writerside past het formaat van externe afbeeldingen niet betrouwbaar aan. Bestaande maten kunnen worden bekeken en verwijderd.", + "wsPropertiesNotSerialized": "Deze wijziging is niet in de bron verwerkt. Het concept is behouden.", + "wsListItem": "Lijstitem", + "wsSourceFile": "Bronbestand", + "wsLinkTarget": "Linkdoel", + "wsPreviewImage": "Voorbeeldafbeelding", + "wsIncludedElement": "Opgenomen element", + "wsHelpModule": "Helpmodule", + "wsActionId": "Actie-ID", + "wsKeymapHelpInstance": "Helpinstantie van toetsindeling", + "wsShowInSource": "Tonen in Bron", + "wsOriginElement": "Elementoverschrijving", + "wsOriginAncestor": "Bovenliggend element", + "wsOriginModule": "Modulestandaard", + "wsOriginBuiltIn": "Ingebouwde standaard", + "wsOriginUnresolved": "Niet opgelost", + "wsEffectiveProperty": "Effectief: {value} — {origin}", + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Gegenereerd: {anchor}", + "wsDefaultProperty": "Standaard: {value}", + "wsDefaultPropertyOrigin": "Standaard: {value} · {origin}", + "wsResolvedProperty": "Opgelost: {value}", + "wsPropertyUnresolved": "Onopgelost", + "wsModule": "Module", + "wsParent": "Bovenliggend element", + "wsListType": "Lijsttype", + "wsStartingNumber": "Startnummer", + "wsListSorting": "Sortering", + "wsLetteredList": "Letterlijst", + "wsCheckboxList": "Lijst met selectievakjes", + "wsMarkerlessList": "Lijst zonder markeringen", + "wsAscending": "Oplopend", + "wsDescending": "Aflopend", + "wsNoSorting": "Geen sortering", + "wsAsWritten": "Zoals geschreven", + "wsSentenceCase": "Zinskapitalisatie", + "wsTitleCase": "Titelkapitalisatie", + "wsLowerCase": "Kleine letters", + "wsUpperCase": "Hoofdletters", + "wsNativeMarkers": "Markdown-markeringen", + "wsListMarkerStart": "Beginmarkering: {number}", + "wsListLevel": "Niveau {level}", + "wsModuleCapsIgnored": "De vastgezette builder negeert <caps>-regels van de module. Gebruik een lokaal caps-attribuut of default-property.", + "wsInsertElements": "Elementen invoegen", + "wsAdmonitionsAndQuotes": "Opmerkingen en citaten", + "wsSemanticText": "Semantische tekst", + "wsPropertiesUnavailable": "Het geïnspecteerde element is niet beschikbaar. Inspecteer een element om door te gaan." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f65627db..d481fa81 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Reguła wielkości liter modułu nie jest publikowana.", + "wsTitleOverridesAction": "Tytuły instancji i spisu treści…", + "wsHeadingNotPublished": "Ten generator tego nie publikuje.", + "wsMarkdownInstanceTitleLimitation": "Przypięty builder nie publikuje tytułów instancji w tematach Markdown. Istniejące znaczniki są zachowane; sprawdź źródło.", + "wsTopicIdentity": "ID tematu", + "wsElementAnchor": "Kotwica elementu", + "wsHeadingText": "Tekst nagłówka", + "wsDefaultTitle": "Tytuł domyślny", + "wsTopicNavigation": "Nawigacja tematu", + "wsHeadingOptions": "Atrybuty nagłówka", + "wsDocumentOptions": "Ustawienia dokumentu", + "wsSettingsPresent": "Obecne jawne ustawienia", + "wsTitleOverrideExplanation": "Zastępuje opublikowany tytuł; tekst nagłówka pozostaje bez zmian.", + "wsIgnoreVariables": "Traktuj zmienne jako tekst dosłowny", + "wsIgnoreVariablesExplanation": "Po włączeniu tekst przypominający zmienne nie jest podstawiany.", + "wsInstanceSelection": "Instancje pomocy", + "wsInstanceExplanation": "Wybierz wyjścia pomocy za pomocą nazw, grup, list, wykluczeń lub wyrażeń.", + "wsFilterExplanation": "Filtruje treść wielokrotnego użytku według kryteriów; nie wybiera instancji pomocy.", + "wsContextHelp": "ID pomocy kontekstowej", + "wsCapitalization": "Wielkość liter", + "wsLibraryTopic": "Temat biblioteki", + "wsLastModified": "Ostatnia zmiana", + "wsMissingTopicHeading": "Brak H1 tematu. Dodaj go w źródle.", + "wsMultipleTopicHeadings": "Pierwszy H1 określa tytuł tematu. Kolejne H1 są osobnymi nagłówkami.", + "wsTopicIdentityMismatch": "Id korzenia różni się od ID z nazwy pliku. Użyj zmiany nazwy pliku tematu lub sprawdź źródło.", + "wsUnsupportedProperties": "Nieobsługiwane atrybuty są zachowane. Sprawdź je w źródle.", + "wsGeneratedAnchor": "Wygenerowana kotwica; wpisanie wartości tworzy nadpisanie.", + "wsHeadingBuilderLimitation": "Przypięty builder nie publikuje atrybutów H1. Należą do nagłówka, nie korzenia XML.", "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...", @@ -3445,5 +3473,168 @@ "nextcloudPossibleCandidate": "Możliwa zgodność po normalizacji żądania; tożsamość niepotwierdzona.", "nextcloudSelectCandidate": "Wybierz notatkę z serwera do sprawdzenia", "nextcloudCreateSeparate": "Utwórz osobną notatkę", - "nextcloudCreateSeparateWarning": "Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte." + "nextcloudCreateSeparateWarning": "Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte.", + "wsSemanticFormats": "Formatowanie semantyczne", + "wsUiControl": "Element interfejsu", + "wsFilePath": "Plik / Ścieżka", + "wsUiPath": "Ścieżka interfejsu", + "wsDefinitionList": "Lista definicji", + "wsIncludeContent": "Dołącz treść wielokrotnego użytku", + "wsVariableReference": "Odwołanie do zmiennej", + "wsConvertList": "Przekształć listę w procedurę", + "wsElementProperties": "Właściwości elementu", + "wsTopicProperties": "Właściwości tematu", + "wsCollapsible": "Możliwość zwijania", + "wsPublishedState": "Opublikowany stan początkowy", + "wsExpanded": "Rozwinięty", + "wsCollapsed": "Zwinięty", + "wsCollapsedTitle": "Zwinięty tytuł", + "wsTerm": "Termin", + "wsListLayout": "Układ listy", + "wsSyncGroup": "Grupa synchronizacji", + "wsTabKey": "Klucz karty", + "wsSwitcherLabel": "Etykieta przełącznika", + "wsNavigationElements": "Elementy nawigacji", + "wsNavigationDepth": "Głębokość nawigacji", + "wsAddItem": "Dodaj element", + "wsStep": "Krok", + "wsPropertyInvalid": "Nieprawidłowa wartość właściwości", + "wsFilter": "Filtr", + "wsIdentifier": "Identyfikator elementu", + "wsTldr": "Podsumowanie (TLDR)", + "wsFullLayout": "Pełna szerokość", + "wsMediumLayout": "Średni (1:2)", + "wsNarrowLayout": "Wąska kolumna terminu", + "wsTitle": "Tytuł", + "wsSwitcherKey": "Klucz przełącznika", + "nextcloudServerSettings": "Ustawienia Notes na serwerze", + "nextcloudNotesPath": "Ścieżka folderu notatek", + "nextcloudFileSuffix": "Rozszerzenie nowych notatek", + "nextcloudServerSettingsExplanation": "Te ustawienia Notes dotyczą innych klientów. Zmiana ścieżki nie przenosi plików; zmiana rozszerzenia nie zmienia nazw istniejących notatek.", + "nextcloudSettingsSaved": "Zapisano ustawienia serwera", + "nextcloudSettingsNormalized": "Zapisano wartości znormalizowane przez serwer", + "nextcloudPendingUpload": "Załącznik oczekuje na wysłanie", + "nextcloudServerCheck": "Ostatnie sprawdzenie serwera: {time}", + "nextcloudSettingsLoading": "Wczytywanie ustawień serwera", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Zapisywanie ustawień serwera", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Poprzednia lokalna zmiana", + "nextcloudUsePreviousEdit": "Użyj poprzedniej zmiany", + "quickOpen": "Szybkie otwieranie", + "notesAll": "Wszystkie notatki", + "notesUncategorized": "Bez kategorii", + "notesRecovery": "Odzyskiwanie", + "notesRecentlyEdited": "Ostatnio edytowane", + "notesOldestEdited": "Najstarsza edycja", + "notesTitleAscending": "Tytuł A–Z", + "notesTitleDescending": "Tytuł Z–A", + "notesMove": "Przenieś do kategorii", + "notesRemoveFavorite": "Usuń z ulubionych", + "notesOffline": "Udostępnij offline", + "notesOfflineRemove": "Usuń przechowywanie offline", + "notesIndexing": "Indeksowanie notatek…", + "notesSearchHelp": "Terminy używają ORAZ. Użyj \"cytowanych fraz\", title: i category:.", + "notesSourceLocation": "Pozycja w źródle", + "notesRecoveryHelp": "Tylko zachowane notatki lokalne i historia lokalna. Kosz i historia serwera są niedostępne.", + "notesImport": "Importuj notatki", + "notesExport": "Eksportuj notatki", + "notesImportDistinct": "Importuj jako osobną notatkę", + "notesAvailableOffline": "Dostępne offline", + "notesIncomplete": "Niekompletne", + "notesSelected": "Wybrane notatki", + "notesNoHistory": "Brak zachowanych wersji. Historia może być wyłączona, wygasła lub wyczyszczona.", + "notesTextAvailable": "Tekst dostępny lokalnie", + "notesLocalVersion": "Lokalna wersja robocza", + "notesAttachments": "Załączniki", + "notesSkipped": "Pominięto", + "notesFailed": "Niepowodzenie", + "notesCompleted": "Ukończono", + "notesImportNew": "Rozpocznij nowy import", + "notesImportResume": "Wznów przerwany import", + "notesImportAlreadyImported": "Już zaimportowano w tej operacji", + "references": "Odwołania", + "referenceIncoming": "Przychodzące", + "referenceOutgoing": "Wychodzące", + "referenceChooseTarget": "Wybierz cel linku", + "referenceWholeDocument": "Cały dokument", + "referenceSourceLocation": "Lokalizacja źródła", + "referenceTargetLocation": "Lokalizacja celu", + "referenceResolved": "Rozwiązane", + "referenceMissing": "Brak", + "referenceAmbiguous": "Niejednoznaczne", + "referenceUnavailable": "Niedostępne w tym kontekście", + "referenceUnsupported": "Nieobsługiwane", + "referenceNoPortableId": "Ten cel nie ma zweryfikowanego przenośnego adresu URL notatki.", + "wsIncludeLines": "Dołącz wiersze", + "wsIncludeSymbol": "Dołącz symbol", + "wsEmphasizeLines": "Wyróżnij wyświetlane wiersze", + "wsPropertiesReadOnly": "W tym widoku właściwości są tylko do odczytu.", + "wsPropertiesProtected": "Element jest chroniony. Edytuj jego źródło.", + "wsPropertiesConflict": "Dokument lub element zmieniono. Cofnij szkic i wybierz ponownie.", + "wsPropertiesUnresolved": "Nie można obecnie rozwiązać odwołania.", + "wsUseDefault": "Użyj domyślnej wartości", + "wsPropertiesSelection": "Wybierz jeden element.", + "wsImageDisplay": "Wyświetlanie obrazu", + "wsImageThumbnail": "Klikalna miniatura", + "wsEnabled": "Włączone", + "wsDisabled": "Wyłączone", + "wsWidth": "Szerokość", + "wsHeight": "Wysokość", + "wsPropertiesInactive": "Ta właściwość działa tylko przy opublikowanym zwijaniu.", + "wsNavigationAll": "Wszystkie elementy z tytułem", + "wsNavigationNone": "Bez nawigacji", + "wsNavigationCustom": "Własna nawigacja", + "wsTitleOverride": "Zastąp tytuł", + "wsCompactLayout": "Zwarty", + "wsWideLayout": "Szeroki", + "wsCodeSourceConflict": "Przenieś lub usuń kod napisany w tym bloku przed ustawieniem src. Writerside odrzuca niepusty blok kodu z odwołaniem do źródła.", + "wsInherited": "Dziedzicz z listy", + "wsExternalImageSize": "Writerside nie ustawia niezawodnie rozmiaru obrazów zewnętrznych. Istniejące wymiary można sprawdzić i usunąć.", + "wsPropertiesNotSerialized": "Ta zmiana nie została zapisana w źródle. Wersja robocza została zachowana.", + "wsListItem": "Element listy", + "wsSourceFile": "Plik źródłowy", + "wsLinkTarget": "Cel linku", + "wsPreviewImage": "Obraz podglądu", + "wsIncludedElement": "Dołączany element", + "wsHelpModule": "Moduł pomocy", + "wsActionId": "ID akcji", + "wsKeymapHelpInstance": "Instancja pomocy mapy klawiszy", + "wsShowInSource": "Pokaż w Źródle", + "wsOriginElement": "Wartość elementu", + "wsOriginAncestor": "Element nadrzędny", + "wsOriginModule": "Domyślna wartość modułu", + "wsOriginBuiltIn": "Wbudowana wartość domyślna", + "wsOriginUnresolved": "Nierozwiązane", + "wsEffectiveProperty": "Efektywna wartość: {value} — {origin}", + "wsElementSelector": "Element", + "wsGeneratedAnchorValue": "Wygenerowano: {anchor}", + "wsDefaultProperty": "Domyślnie: {value}", + "wsDefaultPropertyOrigin": "Domyślnie: {value} · {origin}", + "wsResolvedProperty": "Rozwiązano: {value}", + "wsPropertyUnresolved": "Nierozwiązane", + "wsModule": "Moduł", + "wsParent": "Element nadrzędny", + "wsListType": "Typ listy", + "wsStartingNumber": "Numer początkowy", + "wsListSorting": "Sortowanie", + "wsLetteredList": "Lista literowa", + "wsCheckboxList": "Lista pól wyboru", + "wsMarkerlessList": "Lista bez znaczników", + "wsAscending": "Rosnąco", + "wsDescending": "Malejąco", + "wsNoSorting": "Bez sortowania", + "wsAsWritten": "Jak napisano", + "wsSentenceCase": "Wielkość liter zdania", + "wsTitleCase": "Wielkość liter tytułu", + "wsLowerCase": "Małe litery", + "wsUpperCase": "Wielkie litery", + "wsNativeMarkers": "Znaczniki Markdown", + "wsListMarkerStart": "Znacznik początkowy: {number}", + "wsListLevel": "Poziom {level}", + "wsModuleCapsIgnored": "Przypięty kompilator ignoruje reguły <caps> modułu. Użyj lokalnego atrybutu caps lub default-property.", + "wsInsertElements": "Wstaw elementy", + "wsAdmonitionsAndQuotes": "Uwagi i cytaty", + "wsSemanticText": "Tekst semantyczny", + "wsPropertiesUnavailable": "Sprawdzany element jest niedostępny. Sprawdź element, aby kontynuować." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 448b4cee..8e5c7ee5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "A capitalização do módulo não é publicada.", + "wsTitleOverridesAction": "Títulos da instância e do índice…", + "wsHeadingNotPublished": "Não é publicado por este gerador.", + "wsMarkdownInstanceTitleLimitation": "O builder fixado não publica títulos por instância em tópicos Markdown. A marcação existente é preservada; consulte o código-fonte.", + "wsTopicIdentity": "ID do tópico", + "wsElementAnchor": "Âncora do elemento", + "wsHeadingText": "Texto do cabeçalho", + "wsDefaultTitle": "Título predefinido", + "wsTopicNavigation": "Navegação do tópico", + "wsHeadingOptions": "Atributos do cabeçalho", + "wsDocumentOptions": "Definições do documento", + "wsSettingsPresent": "Existem definições explícitas", + "wsTitleOverrideExplanation": "Substitui o título publicado; o texto do cabeçalho mantém-se.", + "wsIgnoreVariables": "Tratar variáveis como texto literal", + "wsIgnoreVariablesExplanation": "Quando ativo, o texto semelhante a variáveis não é substituído.", + "wsInstanceSelection": "Instâncias de ajuda", + "wsInstanceExplanation": "Selecione saídas por nomes, grupos, listas, exclusões ou expressões.", + "wsFilterExplanation": "Filtra conteúdo reutilizável por critérios; não seleciona uma instância de ajuda.", + "wsContextHelp": "ID de ajuda contextual", + "wsCapitalization": "Capitalização", + "wsLibraryTopic": "Tópico de biblioteca", + "wsLastModified": "Última alteração", + "wsMissingTopicHeading": "Não existe H1 do tópico. Adicione-o no código-fonte.", + "wsMultipleTopicHeadings": "O primeiro H1 determina o título do tópico. Os restantes são cabeçalhos separados.", + "wsTopicIdentityMismatch": "O id da raiz difere do ID baseado no ficheiro. Use Renomear ficheiro do tópico ou consulte o código-fonte.", + "wsUnsupportedProperties": "Os atributos não suportados são preservados. Consulte-os no código-fonte.", + "wsGeneratedAnchor": "Âncora gerada; introduzir um valor cria uma substituição.", + "wsHeadingBuilderLimitation": "O builder fixado não publica atributos H1. Pertencem ao cabeçalho, não à raiz XML.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Possível correspondência após normalização; identidade não confirmada.", "nextcloudSelectCandidate": "Selecionar uma nota do servidor para rever", "nextcloudCreateSeparate": "Criar uma nota separada", - "nextcloudCreateSeparateWarning": "O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado." + "nextcloudCreateSeparateWarning": "O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado.", + "wsSemanticFormats": "Formatação semântica", + "wsUiControl": "Controlo da interface", + "wsFilePath": "Ficheiro / Caminho", + "wsUiPath": "Caminho da interface", + "wsDefinitionList": "Lista de definições", + "wsIncludeContent": "Incluir conteúdo reutilizável", + "wsVariableReference": "Referência de variável", + "wsConvertList": "Converter lista em procedimento", + "wsElementProperties": "Propriedades do elemento", + "wsTopicProperties": "Propriedades do tópico", + "wsCollapsible": "Recolhível", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Recolhido", + "wsCollapsedTitle": "Título recolhido", + "wsTerm": "Termo", + "wsListLayout": "Disposição da lista", + "wsSyncGroup": "Grupo de sincronização", + "wsTabKey": "Chave do separador", + "wsSwitcherLabel": "Etiqueta do seletor", + "wsNavigationElements": "Elementos de navegação", + "wsNavigationDepth": "Profundidade de navegação", + "wsAddItem": "Adicionar item", + "wsStep": "Passo", + "wsPropertyInvalid": "Valor de propriedade inválido", + "wsFilter": "Filtro", + "wsIdentifier": "ID do elemento", + "wsTldr": "Resumo (TLDR)", + "wsFullLayout": "Largura total", + "wsMediumLayout": "Médio (1:2)", + "wsNarrowLayout": "Coluna de termo estreita", + "wsTitle": "Título", + "wsSwitcherKey": "Chave do seletor", + "nextcloudServerSettings": "Definições de Notes do servidor", + "nextcloudNotesPath": "Caminho da pasta de notas", + "nextcloudFileSuffix": "Extensão de novas notas", + "nextcloudServerSettingsExplanation": "Estas definições de Notes afetam outros clientes. Alterar o caminho não move ficheiros; alterar a extensão não renomeia notas existentes.", + "nextcloudSettingsSaved": "Definições do servidor guardadas", + "nextcloudSettingsNormalized": "Guardado com valores normalizados pelo servidor", + "nextcloudPendingUpload": "Envio de anexo pendente", + "nextcloudServerCheck": "Última verificação do servidor: {time}", + "nextcloudSettingsLoading": "A carregar as definições do servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "A guardar as definições do servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edição local anterior", + "nextcloudUsePreviousEdit": "Usar edição anterior", + "quickOpen": "Abertura rápida", + "notesAll": "Todas as notas", + "notesUncategorized": "Sem categoria", + "notesRecovery": "Recuperação", + "notesRecentlyEdited": "Editadas recentemente", + "notesOldestEdited": "Edições mais antigas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover para categoria", + "notesRemoveFavorite": "Remover favorito", + "notesOffline": "Disponibilizar offline", + "notesOfflineRemove": "Remover retenção offline", + "notesIndexing": "A indexar notas…", + "notesSearchHelp": "Os termos usam E. Use \"frases entre aspas\", title: e category:.", + "notesSourceLocation": "Localização no código fonte", + "notesRecoveryHelp": "Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como nota distinta", + "notesAvailableOffline": "Disponível offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas selecionadas", + "notesNoHistory": "Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.", + "notesTextAvailable": "Texto disponível localmente", + "notesLocalVersion": "Versão de trabalho local", + "notesAttachments": "Anexos", + "notesSkipped": "Ignorado", + "notesFailed": "Falhou", + "notesCompleted": "Concluído", + "notesImportNew": "Iniciar nova importação", + "notesImportResume": "Retomar importação interrompida", + "notesImportAlreadyImported": "Já importado nesta operação", + "references": "Referências", + "referenceIncoming": "Recebidas", + "referenceOutgoing": "Enviadas", + "referenceChooseTarget": "Escolher destino da ligação", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Localização de origem", + "referenceTargetLocation": "Localização de destino", + "referenceResolved": "Resolvida", + "referenceMissing": "Em falta", + "referenceAmbiguous": "Ambígua", + "referenceUnavailable": "Indisponível neste contexto", + "referenceUnsupported": "Não suportada", + "referenceNoPortableId": "Este destino não tem um URL portátil de nota verificado.", + "wsIncludeLines": "Incluir linhas", + "wsIncludeSymbol": "Incluir símbolo", + "wsEmphasizeLines": "Realçar linhas exibidas", + "wsPropertiesReadOnly": "Propriedades somente leitura nesta visualização.", + "wsPropertiesProtected": "Elemento protegido. Edite sua fonte.", + "wsPropertiesConflict": "Documento ou elemento alterado. Reverta o rascunho e selecione novamente.", + "wsPropertiesUnresolved": "Não é possível resolver a referência no momento.", + "wsUseDefault": "Usar padrão", + "wsPropertiesSelection": "Selecione um único elemento.", + "wsImageDisplay": "Exibição de imagem", + "wsImageThumbnail": "Miniatura clicável", + "wsEnabled": "Ativado", + "wsDisabled": "Desativado", + "wsWidth": "Largura", + "wsHeight": "Altura", + "wsPropertiesInactive": "Esta propriedade não tem efeito sem recolhimento publicado.", + "wsNavigationAll": "Todos os elementos com título", + "wsNavigationNone": "Sem navegação", + "wsNavigationCustom": "Navegação personalizada", + "wsTitleOverride": "Substituir título", + "wsCompactLayout": "Compacto", + "wsWideLayout": "Largo", + "wsCodeSourceConflict": "Mova ou remova o código escrito neste bloco antes de definir src. O Writerside rejeita um bloco de código não vazio com uma referência de origem.", + "wsInherited": "Herdar da lista", + "wsExternalImageSize": "Writerside não dimensiona imagens externas de forma confiável. As dimensões existentes podem ser consultadas e removidas.", + "wsPropertiesNotSerialized": "Esta alteração não foi aplicada ao código fonte. O rascunho foi mantido.", + "wsListItem": "Item da lista", + "wsSourceFile": "Arquivo de origem", + "wsLinkTarget": "Destino do link", + "wsPreviewImage": "Imagem de prévia", + "wsIncludedElement": "Elemento incluído", + "wsHelpModule": "Módulo de ajuda", + "wsActionId": "ID da ação", + "wsKeymapHelpInstance": "Instância de ajuda do mapa de teclas", + "wsShowInSource": "Mostrar no Fonte", + "wsOriginElement": "Valor do elemento", + "wsOriginAncestor": "Elemento pai", + "wsOriginModule": "Padrão do módulo", + "wsOriginBuiltIn": "Padrão integrado", + "wsOriginUnresolved": "Não resolvido", + "wsEffectiveProperty": "Efetivo: {value} — {origin}", + "wsElementSelector": "Elemento", + "wsGeneratedAnchorValue": "Gerado: {anchor}", + "wsDefaultProperty": "Padrão: {value}", + "wsDefaultPropertyOrigin": "Padrão: {value} · {origin}", + "wsResolvedProperty": "Resolvido: {value}", + "wsPropertyUnresolved": "Não resolvido", + "wsModule": "Módulo", + "wsParent": "Elemento pai", + "wsListType": "Tipo de lista", + "wsStartingNumber": "Número inicial", + "wsListSorting": "Ordenação", + "wsLetteredList": "Lista alfabética", + "wsCheckboxList": "Lista de caixas de seleção", + "wsMarkerlessList": "Lista sem marcadores", + "wsAscending": "Ascendente", + "wsDescending": "Descendente", + "wsNoSorting": "Sem ordenação", + "wsAsWritten": "Como escrito", + "wsSentenceCase": "Maiúscula inicial", + "wsTitleCase": "Maiúsculas de título", + "wsLowerCase": "Minúsculas", + "wsUpperCase": "Maiúsculas", + "wsNativeMarkers": "Marcadores Markdown", + "wsListMarkerStart": "Marcador inicial: {number}", + "wsListLevel": "Nível {level}", + "wsModuleCapsIgnored": "O compilador fixado ignora as regras <caps> do módulo. Use um atributo caps local ou default-property.", + "wsInsertElements": "Inserir elementos", + "wsAdmonitionsAndQuotes": "Avisos e citações", + "wsSemanticText": "Texto semântico", + "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar." } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 5f6cdadf..c95e7ec6 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "A capitalização do módulo não é publicada.", + "wsTitleOverridesAction": "Títulos da instância e do índice…", + "wsHeadingNotPublished": "Não é publicado por este gerador.", + "wsMarkdownInstanceTitleLimitation": "O builder fixado não publica títulos por instância em tópicos Markdown. A marcação existente é preservada; consulte o código-fonte.", + "wsTopicIdentity": "ID do tópico", + "wsElementAnchor": "Âncora do elemento", + "wsHeadingText": "Texto do cabeçalho", + "wsDefaultTitle": "Título padrão", + "wsTopicNavigation": "Navegação do tópico", + "wsHeadingOptions": "Atributos do cabeçalho", + "wsDocumentOptions": "Configurações do documento", + "wsSettingsPresent": "Existem configurações explícitas", + "wsTitleOverrideExplanation": "Substitui o título publicado; o texto do cabeçalho permanece.", + "wsIgnoreVariables": "Tratar variáveis como texto literal", + "wsIgnoreVariablesExplanation": "Quando ativo, o texto semelhante a variáveis não é substituído.", + "wsInstanceSelection": "Instâncias de ajuda", + "wsInstanceExplanation": "Selecione saídas por nomes, grupos, listas, exclusões ou expressões.", + "wsFilterExplanation": "Filtra conteúdo reutilizável por critérios; não seleciona uma instância de ajuda.", + "wsContextHelp": "ID de ajuda contextual", + "wsCapitalization": "Capitalização", + "wsLibraryTopic": "Tópico de biblioteca", + "wsLastModified": "Última alteração", + "wsMissingTopicHeading": "Não existe H1 do tópico. Adicione-o no código-fonte.", + "wsMultipleTopicHeadings": "O primeiro H1 determina o título do tópico. Os restantes são cabeçalhos separados.", + "wsTopicIdentityMismatch": "O id da raiz difere do ID baseado no arquivo. Use Renomear arquivo do tópico ou consulte o código-fonte.", + "wsUnsupportedProperties": "Os atributos não suportados são preservados. Consulte-os no código-fonte.", + "wsGeneratedAnchor": "Âncora gerada; introduzir um valor cria uma substituição.", + "wsHeadingBuilderLimitation": "O builder fixado não publica atributos H1. Pertencem ao cabeçalho, não à raiz XML.", "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...", @@ -3419,5 +3447,168 @@ "nextcloudPossibleCandidate": "Possível correspondência após normalização; identidade não confirmada.", "nextcloudSelectCandidate": "Selecionar uma nota do servidor para revisar", "nextcloudCreateSeparate": "Criar uma nota separada", - "nextcloudCreateSeparateWarning": "A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída." + "nextcloudCreateSeparateWarning": "A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída.", + "wsSemanticFormats": "Formatação semântica", + "wsUiControl": "Controle da interface", + "wsFilePath": "Arquivo / Caminho", + "wsUiPath": "Caminho da interface", + "wsDefinitionList": "Lista de definições", + "wsIncludeContent": "Incluir conteúdo reutilizável", + "wsVariableReference": "Referência de variável", + "wsConvertList": "Converter lista em procedimento", + "wsElementProperties": "Propriedades do elemento", + "wsTopicProperties": "Propriedades do tópico", + "wsCollapsible": "Recolhível", + "wsPublishedState": "Estado inicial publicado", + "wsExpanded": "Expandido", + "wsCollapsed": "Recolhido", + "wsCollapsedTitle": "Título recolhido", + "wsTerm": "Termo", + "wsListLayout": "Disposição da lista", + "wsSyncGroup": "Grupo de sincronização", + "wsTabKey": "Chave do aba", + "wsSwitcherLabel": "Etiqueta do seletor", + "wsNavigationElements": "Elementos de navegação", + "wsNavigationDepth": "Profundidade de navegação", + "wsAddItem": "Adicionar item", + "wsStep": "Passo", + "wsPropertyInvalid": "Valor de propriedade inválido", + "wsFilter": "Filtro", + "wsIdentifier": "ID do elemento", + "wsTldr": "Resumo (TLDR)", + "wsFullLayout": "Largura total", + "wsMediumLayout": "Médio (1:2)", + "wsNarrowLayout": "Coluna de termo estreita", + "wsTitle": "Título", + "wsSwitcherKey": "Chave do seletor", + "nextcloudServerSettings": "Configurações de Notes do servidor", + "nextcloudNotesPath": "Caminho da pasta de notas", + "nextcloudFileSuffix": "Extensão de novas notas", + "nextcloudServerSettingsExplanation": "Estas configurações de Notes afetam outros clientes. Alterar o caminho não move arquivos; alterar a extensão não renomeia notas existentes.", + "nextcloudSettingsSaved": "Configurações do servidor salvas", + "nextcloudSettingsNormalized": "Salvo com valores normalizados pelo servidor", + "nextcloudPendingUpload": "Envio de anexo pendente", + "nextcloudServerCheck": "Última verificação do servidor: {time}", + "nextcloudSettingsLoading": "Carregando configurações do servidor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Salvando configurações do servidor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Edição local anterior", + "nextcloudUsePreviousEdit": "Usar edição anterior", + "quickOpen": "Abertura rápida", + "notesAll": "Todas as notas", + "notesUncategorized": "Sem categoria", + "notesRecovery": "Recuperação", + "notesRecentlyEdited": "Editadas recentemente", + "notesOldestEdited": "Edições mais antigas", + "notesTitleAscending": "Título A–Z", + "notesTitleDescending": "Título Z–A", + "notesMove": "Mover para categoria", + "notesRemoveFavorite": "Remover favorito", + "notesOffline": "Disponibilizar offline", + "notesOfflineRemove": "Remover retenção offline", + "notesIndexing": "Indexando notas…", + "notesSearchHelp": "Os termos usam E. Use \"frases entre aspas\", title: e category:.", + "notesSourceLocation": "Localização no código fonte", + "notesRecoveryHelp": "Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.", + "notesImport": "Importar notas", + "notesExport": "Exportar notas", + "notesImportDistinct": "Importar como nota distinta", + "notesAvailableOffline": "Disponível offline", + "notesIncomplete": "Incompleto", + "notesSelected": "Notas selecionadas", + "notesNoHistory": "Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.", + "notesTextAvailable": "Texto disponível localmente", + "notesLocalVersion": "Versão de trabalho local", + "notesAttachments": "Anexos", + "notesSkipped": "Ignorado", + "notesFailed": "Falhou", + "notesCompleted": "Concluído", + "notesImportNew": "Iniciar nova importação", + "notesImportResume": "Retomar importação interrompida", + "notesImportAlreadyImported": "Já importado nesta operação", + "references": "Referências", + "referenceIncoming": "Recebidas", + "referenceOutgoing": "Enviadas", + "referenceChooseTarget": "Escolher destino do link", + "referenceWholeDocument": "Documento completo", + "referenceSourceLocation": "Local de origem", + "referenceTargetLocation": "Local de destino", + "referenceResolved": "Resolvida", + "referenceMissing": "Ausente", + "referenceAmbiguous": "Ambígua", + "referenceUnavailable": "Indisponível neste contexto", + "referenceUnsupported": "Não compatível", + "referenceNoPortableId": "Este destino não tem uma URL portátil de nota verificada.", + "wsIncludeLines": "Incluir linhas", + "wsIncludeSymbol": "Incluir símbolo", + "wsEmphasizeLines": "Realçar linhas exibidas", + "wsPropertiesReadOnly": "Propriedades somente leitura nesta visualização.", + "wsPropertiesProtected": "Elemento protegido. Edite sua fonte.", + "wsPropertiesConflict": "Documento ou elemento alterado. Reverta o rascunho e selecione novamente.", + "wsPropertiesUnresolved": "Não é possível resolver a referência no momento.", + "wsUseDefault": "Usar padrão", + "wsPropertiesSelection": "Selecione um único elemento.", + "wsImageDisplay": "Exibição de imagem", + "wsImageThumbnail": "Miniatura clicável", + "wsEnabled": "Ativado", + "wsDisabled": "Desativado", + "wsWidth": "Largura", + "wsHeight": "Altura", + "wsPropertiesInactive": "Esta propriedade não tem efeito sem recolhimento publicado.", + "wsNavigationAll": "Todos os elementos com título", + "wsNavigationNone": "Sem navegação", + "wsNavigationCustom": "Navegação personalizada", + "wsTitleOverride": "Substituir título", + "wsCompactLayout": "Compacto", + "wsWideLayout": "Largo", + "wsCodeSourceConflict": "Mova ou remova o código escrito neste bloco antes de definir src. O Writerside rejeita um bloco de código não vazio com uma referência de origem.", + "wsInherited": "Herdar da lista", + "wsExternalImageSize": "Writerside não dimensiona imagens externas de forma confiável. As dimensões existentes podem ser consultadas e removidas.", + "wsPropertiesNotSerialized": "Esta alteração não foi aplicada ao código fonte. O rascunho foi mantido.", + "wsListItem": "Item da lista", + "wsSourceFile": "Arquivo de origem", + "wsLinkTarget": "Destino do link", + "wsPreviewImage": "Imagem de prévia", + "wsIncludedElement": "Elemento incluído", + "wsHelpModule": "Módulo de ajuda", + "wsActionId": "ID da ação", + "wsKeymapHelpInstance": "Instância de ajuda do mapa de teclas", + "wsShowInSource": "Mostrar no Fonte", + "wsOriginElement": "Valor do elemento", + "wsOriginAncestor": "Elemento pai", + "wsOriginModule": "Padrão do módulo", + "wsOriginBuiltIn": "Padrão integrado", + "wsOriginUnresolved": "Não resolvido", + "wsEffectiveProperty": "Efetivo: {value} — {origin}", + "wsElementSelector": "Elemento", + "wsGeneratedAnchorValue": "Gerado: {anchor}", + "wsDefaultProperty": "Padrão: {value}", + "wsDefaultPropertyOrigin": "Padrão: {value} · {origin}", + "wsResolvedProperty": "Resolvido: {value}", + "wsPropertyUnresolved": "Não resolvido", + "wsModule": "Módulo", + "wsParent": "Elemento pai", + "wsListType": "Tipo de lista", + "wsStartingNumber": "Número inicial", + "wsListSorting": "Ordenação", + "wsLetteredList": "Lista alfabética", + "wsCheckboxList": "Lista de caixas de seleção", + "wsMarkerlessList": "Lista sem marcadores", + "wsAscending": "Crescente", + "wsDescending": "Decrescente", + "wsNoSorting": "Sem ordenação", + "wsAsWritten": "Como escrito", + "wsSentenceCase": "Maiúscula inicial", + "wsTitleCase": "Maiúsculas de título", + "wsLowerCase": "Minúsculas", + "wsUpperCase": "Maiúsculas", + "wsNativeMarkers": "Marcadores Markdown", + "wsListMarkerStart": "Marcador inicial: {number}", + "wsListLevel": "Nível {level}", + "wsModuleCapsIgnored": "O compilador fixado ignora as regras <caps> do módulo. Use um atributo caps local ou default-property.", + "wsInsertElements": "Inserir elementos", + "wsAdmonitionsAndQuotes": "Avisos e citações", + "wsSemanticText": "Texto semântico", + "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e2553671..6de1a317 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Правило регистра модуля не публикуется.", + "wsTitleOverridesAction": "Заголовки экземпляра и оглавления…", + "wsHeadingNotPublished": "Не публикуется этим сборщиком.", + "wsMarkdownInstanceTitleLimitation": "Закреплённый builder не публикует заголовки экземпляров в темах Markdown. Разметка сохраняется; проверьте исходник.", + "wsTopicIdentity": "ID темы", + "wsElementAnchor": "Якорь элемента", + "wsHeadingText": "Текст заголовка", + "wsDefaultTitle": "Заголовок по умолчанию", + "wsTopicNavigation": "Навигация темы", + "wsHeadingOptions": "Атрибуты заголовка", + "wsDocumentOptions": "Настройки документа", + "wsSettingsPresent": "Есть явные настройки", + "wsTitleOverrideExplanation": "Переопределяет опубликованный заголовок; текст заголовка не меняется.", + "wsIgnoreVariables": "Считать переменные буквальным текстом", + "wsIgnoreVariablesExplanation": "Если включено, текст, похожий на переменные, не подставляется.", + "wsInstanceSelection": "Экземпляры справки", + "wsInstanceExplanation": "Выберите выпуски справки по именам, группам, спискам, исключениям или выражениям.", + "wsFilterExplanation": "Фильтрует повторно используемое содержимое по критериям; не выбирает экземпляр справки.", + "wsContextHelp": "ID контекстной справки", + "wsCapitalization": "Регистр", + "wsLibraryTopic": "Библиотечная тема", + "wsLastModified": "Последнее изменение", + "wsMissingTopicHeading": "H1 темы отсутствует. Добавьте его в исходнике.", + "wsMultipleTopicHeadings": "Первый H1 определяет заголовок темы. Остальные H1 — отдельные заголовки.", + "wsTopicIdentityMismatch": "Корневой id отличается от ID по имени файла. Используйте переименование файла темы или проверьте исходник.", + "wsUnsupportedProperties": "Неподдерживаемые атрибуты сохраняются. Проверьте их в исходнике.", + "wsGeneratedAnchor": "Сгенерированный якорь; значение создаёт переопределение.", + "wsHeadingBuilderLimitation": "Закреплённый builder не публикует атрибуты H1. Они принадлежат заголовку, а не корню XML.", "closeOtherTabs": "Закрыть другие вкладки", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Тема из шаблона...", @@ -3445,5 +3473,168 @@ "nextcloudPossibleCandidate": "Возможное совпадение после нормализации; идентичность не подтверждена.", "nextcloudSelectCandidate": "Выберите заметку на сервере для проверки", "nextcloudCreateSeparate": "Создать отдельную заметку", - "nextcloudCreateSeparateWarning": "Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены." + "nextcloudCreateSeparateWarning": "Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены.", + "wsSemanticFormats": "Семантическое форматирование", + "wsUiControl": "Элемент интерфейса", + "wsFilePath": "Файл / Путь", + "wsUiPath": "Путь в интерфейсе", + "wsDefinitionList": "Список определений", + "wsIncludeContent": "Включить повторно используемый контент", + "wsVariableReference": "Ссылка на переменную", + "wsConvertList": "Преобразовать список в процедуру", + "wsElementProperties": "Свойства элемента", + "wsTopicProperties": "Свойства темы", + "wsCollapsible": "Сворачиваемый", + "wsPublishedState": "Начальное состояние публикации", + "wsExpanded": "Развёрнуто", + "wsCollapsed": "Свёрнуто", + "wsCollapsedTitle": "Заголовок в свёрнутом виде", + "wsTerm": "Термин", + "wsListLayout": "Макет списка", + "wsSyncGroup": "Группа синхронизации", + "wsTabKey": "Ключ вкладки", + "wsSwitcherLabel": "Подпись переключателя", + "wsNavigationElements": "Элементы навигации", + "wsNavigationDepth": "Глубина навигации", + "wsAddItem": "Добавить элемент", + "wsStep": "Шаг", + "wsPropertyInvalid": "Недопустимое значение свойства", + "wsFilter": "Фильтр", + "wsIdentifier": "Идентификатор элемента", + "wsTldr": "Краткое содержание (TLDR)", + "wsFullLayout": "Полная ширина", + "wsMediumLayout": "Средний (1:2)", + "wsNarrowLayout": "Узкий столбец термина", + "wsTitle": "Заголовок", + "wsSwitcherKey": "Ключ переключателя", + "nextcloudServerSettings": "Настройки Notes на сервере", + "nextcloudNotesPath": "Путь к папке заметок", + "nextcloudFileSuffix": "Расширение новых заметок", + "nextcloudServerSettingsExplanation": "Эти настройки Notes влияют на другие клиенты. Изменение пути не перемещает файлы; изменение расширения не переименовывает существующие заметки.", + "nextcloudSettingsSaved": "Настройки сервера сохранены", + "nextcloudSettingsNormalized": "Сохранено со значениями, нормализованными сервером", + "nextcloudPendingUpload": "Вложение ожидает загрузки", + "nextcloudServerCheck": "Последняя проверка сервера: {time}", + "nextcloudSettingsLoading": "Загрузка настроек сервера", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Сохранение настроек сервера", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Предыдущая локальная правка", + "nextcloudUsePreviousEdit": "Использовать предыдущую правку", + "quickOpen": "Быстрое открытие", + "notesAll": "Все заметки", + "notesUncategorized": "Без категории", + "notesRecovery": "Восстановление", + "notesRecentlyEdited": "Недавно изменённые", + "notesOldestEdited": "Давно изменённые", + "notesTitleAscending": "Название А–Я", + "notesTitleDescending": "Название Я–А", + "notesMove": "Переместить в категорию", + "notesRemoveFavorite": "Убрать из избранного", + "notesOffline": "Сохранить для работы без сети", + "notesOfflineRemove": "Убрать сохранение без сети", + "notesIndexing": "Индексация заметок…", + "notesSearchHelp": "Термины объединяются И. Используйте \"фразы в кавычках\", title: и category:.", + "notesSourceLocation": "Место в исходнике", + "notesRecoveryHelp": "Только сохранённые локальные заметки и локальная история. Корзина и история сервера недоступны.", + "notesImport": "Импорт заметок", + "notesExport": "Экспорт заметок", + "notesImportDistinct": "Импортировать как отдельную заметку", + "notesAvailableOffline": "Доступно без сети", + "notesIncomplete": "Неполно", + "notesSelected": "Выбранные заметки", + "notesNoHistory": "Нет сохранённых версий. История может быть отключена, устарела или очищена.", + "notesTextAvailable": "Текст доступен локально", + "notesLocalVersion": "Локальная рабочая версия", + "notesAttachments": "Вложения", + "notesSkipped": "Пропущено", + "notesFailed": "Ошибка", + "notesCompleted": "Завершено", + "notesImportNew": "Начать новый импорт", + "notesImportResume": "Продолжить прерванный импорт", + "notesImportAlreadyImported": "Уже импортировано в этой операции", + "references": "Ссылки", + "referenceIncoming": "Входящие", + "referenceOutgoing": "Исходящие", + "referenceChooseTarget": "Выберите цель ссылки", + "referenceWholeDocument": "Весь документ", + "referenceSourceLocation": "Положение источника", + "referenceTargetLocation": "Положение цели", + "referenceResolved": "Найдена", + "referenceMissing": "Отсутствует", + "referenceAmbiguous": "Неоднозначная", + "referenceUnavailable": "Недоступна в этом контексте", + "referenceUnsupported": "Не поддерживается", + "referenceNoPortableId": "У этой цели нет проверенного переносимого URL заметки.", + "wsIncludeLines": "Включить строки", + "wsIncludeSymbol": "Включить символ", + "wsEmphasizeLines": "Выделить отображаемые строки", + "wsPropertiesReadOnly": "В этом режиме свойства доступны только для чтения.", + "wsPropertiesProtected": "Элемент защищён. Измените исходный код.", + "wsPropertiesConflict": "Документ или элемент изменился. Отмените черновик и выберите заново.", + "wsPropertiesUnresolved": "Сейчас невозможно разрешить ссылку.", + "wsUseDefault": "Использовать по умолчанию", + "wsPropertiesSelection": "Выберите один элемент.", + "wsImageDisplay": "Отображение изображения", + "wsImageThumbnail": "Нажимаемая миниатюра", + "wsEnabled": "Включено", + "wsDisabled": "Выключено", + "wsWidth": "Ширина", + "wsHeight": "Высота", + "wsPropertiesInactive": "Свойство действует только при сворачивании в публикации.", + "wsNavigationAll": "Все элементы с заголовками", + "wsNavigationNone": "Без навигации", + "wsNavigationCustom": "Своя навигация", + "wsTitleOverride": "Переопределить заголовок", + "wsCompactLayout": "Компактный", + "wsWideLayout": "Широкий", + "wsCodeSourceConflict": "Переместите или удалите код, написанный в этом блоке, прежде чем задать src. Writerside отклоняет непустой блок кода со ссылкой на источник.", + "wsInherited": "Наследовать от списка", + "wsExternalImageSize": "Writerside ненадёжно задаёт размеры внешних изображений. Существующие размеры можно просмотреть и удалить.", + "wsPropertiesNotSerialized": "Это изменение не записано в исходный текст. Черновик сохранён.", + "wsListItem": "Элемент списка", + "wsSourceFile": "Исходный файл", + "wsLinkTarget": "Цель ссылки", + "wsPreviewImage": "Изображение предпросмотра", + "wsIncludedElement": "Включаемый элемент", + "wsHelpModule": "Модуль справки", + "wsActionId": "ID действия", + "wsKeymapHelpInstance": "Экземпляр справки раскладки", + "wsShowInSource": "Показать в исходнике", + "wsOriginElement": "Значение элемента", + "wsOriginAncestor": "Родительский элемент", + "wsOriginModule": "По умолчанию в модуле", + "wsOriginBuiltIn": "Встроенное значение", + "wsOriginUnresolved": "Не разрешено", + "wsEffectiveProperty": "Действующее значение: {value} — {origin}", + "wsElementSelector": "Элемент", + "wsGeneratedAnchorValue": "Сгенерировано: {anchor}", + "wsDefaultProperty": "По умолчанию: {value}", + "wsDefaultPropertyOrigin": "По умолчанию: {value} · {origin}", + "wsResolvedProperty": "Разрешено: {value}", + "wsPropertyUnresolved": "Не разрешено", + "wsModule": "Модуль", + "wsParent": "Родительский элемент", + "wsListType": "Тип списка", + "wsStartingNumber": "Начальный номер", + "wsListSorting": "Сортировка", + "wsLetteredList": "Буквенный список", + "wsCheckboxList": "Список с флажками", + "wsMarkerlessList": "Список без маркеров", + "wsAscending": "По возрастанию", + "wsDescending": "По убыванию", + "wsNoSorting": "Без сортировки", + "wsAsWritten": "Как написано", + "wsSentenceCase": "Регистр предложения", + "wsTitleCase": "Регистр заголовка", + "wsLowerCase": "Строчные буквы", + "wsUpperCase": "Прописные буквы", + "wsNativeMarkers": "Маркеры Markdown", + "wsListMarkerStart": "Начальный маркер: {number}", + "wsListLevel": "Уровень {level}", + "wsModuleCapsIgnored": "Закреплённый сборщик игнорирует правила <caps> модуля. Используйте локальный атрибут caps или default-property.", + "wsInsertElements": "Вставить элементы", + "wsAdmonitionsAndQuotes": "Примечания и цитаты", + "wsSemanticText": "Семантический текст", + "wsPropertiesUnavailable": "Проверяемый элемент недоступен. Выберите элемент для продолжения." } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 8c2ca4d1..6d2251ae 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Modülün harf biçimi yayımlanmaz.", + "wsTitleOverridesAction": "Örnek ve içindekiler başlıkları…", + "wsHeadingNotPublished": "Bu oluşturucu tarafından yayımlanmaz.", + "wsMarkdownInstanceTitleLimitation": "Sabitlenmiş derleyici Markdown konularında örnek başlıklarını yayımlamaz. Mevcut işaretleme korunur; Kaynakta inceleyin.", + "wsTopicIdentity": "Konu kimliği", + "wsElementAnchor": "Öğe çapası", + "wsHeadingText": "Başlık metni", + "wsDefaultTitle": "Varsayılan başlık", + "wsTopicNavigation": "Konu gezinmesi", + "wsHeadingOptions": "Başlık öznitelikleri", + "wsDocumentOptions": "Belge ayarları", + "wsSettingsPresent": "Yazılmış ayarlar mevcut", + "wsTitleOverrideExplanation": "Yayımlanan başlığı geçersiz kılar; başlık metni değişmez.", + "wsIgnoreVariables": "Değişkenleri düz metin olarak işle", + "wsIgnoreVariablesExplanation": "Etkinleştirildiğinde değişken benzeri metin değiştirilmez.", + "wsInstanceSelection": "Yardım örnekleri", + "wsInstanceExplanation": "Yardım çıktılarını adlar, gruplar, listeler, dışlamalar veya ifadelerle seçin.", + "wsFilterExplanation": "Yeniden kullanılan içeriği ölçütlerle süzer; yardım örneği seçmez.", + "wsContextHelp": "Bağlam yardımı kimliği", + "wsCapitalization": "Büyük/küçük harf", + "wsLibraryTopic": "Kitaplık konusu", + "wsLastModified": "Son değiştirilme", + "wsMissingTopicHeading": "Konu H1 başlığı yok. Kaynakta ekleyin.", + "wsMultipleTopicHeadings": "İlk H1 konu başlığını belirler. Sonraki H1’ler ayrı başlıklardır.", + "wsTopicIdentityMismatch": "Kök id, dosya adından türetilen konu kimliğinden farklı. Konu dosyasını yeniden adlandırın veya kaynağı inceleyin.", + "wsUnsupportedProperties": "Desteklenmeyen öznitelikler korunur. Kaynakta inceleyin.", + "wsGeneratedAnchor": "Oluşturulmuş çapa; bir değer geçersiz kılma oluşturur.", + "wsHeadingBuilderLimitation": "Sabitlenmiş derleyici H1 özniteliklerini yayımlamaz. XML köküne değil başlığa aittirler.", "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...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "Düzenlenmiş istekle olası eşleşme; kimlik doğrulanmadı.", "nextcloudSelectCandidate": "İncelemek için bir sunucu notu seçin", "nextcloudCreateSeparate": "Ayrı bir not oluştur", - "nextcloudCreateSeparateWarning": "İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez." + "nextcloudCreateSeparateWarning": "İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez.", + "wsSemanticFormats": "Anlamsal biçimlendirme", + "wsUiControl": "Arayüz denetimi", + "wsFilePath": "Dosya / Yol", + "wsUiPath": "Arayüz yolu", + "wsDefinitionList": "Tanım listesi", + "wsIncludeContent": "Yeniden kullanılabilir içerik ekle", + "wsVariableReference": "Değişken başvurusu", + "wsConvertList": "Listeyi prosedüre dönüştür", + "wsElementProperties": "Öğe özellikleri", + "wsTopicProperties": "Konu özellikleri", + "wsCollapsible": "Daraltılabilir", + "wsPublishedState": "Yayımlanan başlangıç durumu", + "wsExpanded": "Genişletilmiş", + "wsCollapsed": "Daraltılmış", + "wsCollapsedTitle": "Daraltılmış başlık", + "wsTerm": "Terim", + "wsListLayout": "Liste düzeni", + "wsSyncGroup": "Eşitleme grubu", + "wsTabKey": "Sekme anahtarı", + "wsSwitcherLabel": "Değiştirici etiketi", + "wsNavigationElements": "Gezinme öğeleri", + "wsNavigationDepth": "Gezinme derinliği", + "wsAddItem": "Öğe ekle", + "wsStep": "Adım", + "wsPropertyInvalid": "Geçersiz özellik değeri", + "wsFilter": "Filtre", + "wsIdentifier": "Öğe kimliği", + "wsTldr": "Özet (TLDR)", + "wsFullLayout": "Tam genişlik", + "wsMediumLayout": "Orta (1:2)", + "wsNarrowLayout": "Dar terim sütunu", + "wsTitle": "Başlık", + "wsSwitcherKey": "Değiştirici anahtarı", + "nextcloudServerSettings": "Sunucu Notes ayarları", + "nextcloudNotesPath": "Not klasörünün yolu", + "nextcloudFileSuffix": "Yeni notların dosya uzantısı", + "nextcloudServerSettingsExplanation": "Bu sunucu Notes ayarları diğer istemcileri de etkiler. Yolun değiştirilmesi dosyaları taşımaz; uzantının değiştirilmesi mevcut notları yeniden adlandırmaz.", + "nextcloudSettingsSaved": "Sunucu ayarları kaydedildi", + "nextcloudSettingsNormalized": "Sunucunun normalleştirdiği değerlerle kaydedildi", + "nextcloudPendingUpload": "Ek yüklemesi bekliyor", + "nextcloudServerCheck": "Son sunucu kontrolü: {time}", + "nextcloudSettingsLoading": "Sunucu ayarları yükleniyor", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Sunucu ayarları kaydediliyor", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Önceki yerel düzenleme", + "nextcloudUsePreviousEdit": "Önceki düzenlemeyi kullan", + "quickOpen": "Hızlı aç", + "notesAll": "Tüm notlar", + "notesUncategorized": "Kategorisiz", + "notesRecovery": "Kurtarma", + "notesRecentlyEdited": "Son düzenlenen", + "notesOldestEdited": "En eski düzenleme", + "notesTitleAscending": "Başlık A–Z", + "notesTitleDescending": "Başlık Z–A", + "notesMove": "Kategoriye taşı", + "notesRemoveFavorite": "Favoriyi kaldır", + "notesOffline": "Çevrimdışı kullanılabilir yap", + "notesOfflineRemove": "Çevrimdışı saklamayı kaldır", + "notesIndexing": "Notlar dizine ekleniyor…", + "notesSearchHelp": "Terimler VE ile birleştirilir. \"Alıntı ifadeler\", title: ve category: kullanın.", + "notesSourceLocation": "Kaynak konumu", + "notesRecoveryHelp": "Yalnızca saklanan yerel notlar ve yerel geçmiş. Sunucu çöp kutusu ve geçmişi kullanılamaz.", + "notesImport": "Notları içe aktar", + "notesExport": "Notları dışa aktar", + "notesImportDistinct": "Ayrı bir not olarak içe aktar", + "notesAvailableOffline": "Çevrimdışı kullanılabilir", + "notesIncomplete": "Eksik", + "notesSelected": "Seçilen notlar", + "notesNoHistory": "Saklanan sürüm yok. Geçmiş kapalı, süresi dolmuş veya temizlenmiş olabilir.", + "notesTextAvailable": "Metin yerel olarak kullanılabilir", + "notesLocalVersion": "Yerel çalışma sürümü", + "notesAttachments": "Ekler", + "notesSkipped": "Atlandı", + "notesFailed": "Başarısız", + "notesCompleted": "Tamamlandı", + "notesImportNew": "Yeni içe aktarma başlat", + "notesImportResume": "Kesilen içe aktarmayı sürdür", + "notesImportAlreadyImported": "Bu işlemde zaten içe aktarıldı", + "references": "Başvurular", + "referenceIncoming": "Gelen", + "referenceOutgoing": "Giden", + "referenceChooseTarget": "Bağlantı hedefini seç", + "referenceWholeDocument": "Tüm belge", + "referenceSourceLocation": "Kaynak konumu", + "referenceTargetLocation": "Hedef konumu", + "referenceResolved": "Çözüldü", + "referenceMissing": "Eksik", + "referenceAmbiguous": "Belirsiz", + "referenceUnavailable": "Bu bağlamda kullanılamıyor", + "referenceUnsupported": "Desteklenmiyor", + "referenceNoPortableId": "Bu hedefin doğrulanmış taşınabilir not URL’si yok.", + "wsIncludeLines": "Satırları dahil et", + "wsIncludeSymbol": "Sembolü dahil et", + "wsEmphasizeLines": "Görüntülenen satırları vurgula", + "wsPropertiesReadOnly": "Bu görünümde özellikler salt okunurdur.", + "wsPropertiesProtected": "Bu öğe korunuyor. Kaynağını düzenleyin.", + "wsPropertiesConflict": "Belge veya öğe değişti. Taslağı geri alın ve yeniden seçin.", + "wsPropertiesUnresolved": "Başvuru şu anda çözümlenemiyor.", + "wsUseDefault": "Varsayılanı kullan", + "wsPropertiesSelection": "Tek bir öğe seçin.", + "wsImageDisplay": "Görüntü görünümü", + "wsImageThumbnail": "Tıklanabilir küçük resim", + "wsEnabled": "Etkin", + "wsDisabled": "Devre dışı", + "wsWidth": "Genişlik", + "wsHeight": "Yükseklik", + "wsPropertiesInactive": "Bu özellik yalnızca yayımlanan daraltma etkinken geçerlidir.", + "wsNavigationAll": "Başlıklı tüm öğeler", + "wsNavigationNone": "Gezinme yok", + "wsNavigationCustom": "Özel gezinme", + "wsTitleOverride": "Başlığı geçersiz kıl", + "wsCompactLayout": "Kompakt", + "wsWideLayout": "Geniş", + "wsCodeSourceConflict": "src ayarlamadan önce bu blokta yazılan kodu taşıyın veya kaldırın. Writerside, kaynak başvurusu olan boş olmayan kod bloğunu reddeder.", + "wsInherited": "Listeden devral", + "wsExternalImageSize": "Writerside dış görüntülerin boyutunu güvenilir biçimde ayarlamaz. Mevcut boyutlar incelenebilir ve kaldırılabilir.", + "wsPropertiesNotSerialized": "Bu değişiklik kaynağa uygulanmadı. Taslak korundu.", + "wsListItem": "Liste öğesi", + "wsSourceFile": "Kaynak dosya", + "wsLinkTarget": "Bağlantı hedefi", + "wsPreviewImage": "Önizleme resmi", + "wsIncludedElement": "Dahil edilen öğe", + "wsHelpModule": "Yardım modülü", + "wsActionId": "Eylem kimliği", + "wsKeymapHelpInstance": "Tuş eşlemesi yardım örneği", + "wsShowInSource": "Kaynakta göster", + "wsOriginElement": "Öğe geçersiz kılması", + "wsOriginAncestor": "Üst öğe", + "wsOriginModule": "Modül varsayılanı", + "wsOriginBuiltIn": "Yerleşik varsayılan", + "wsOriginUnresolved": "Çözümlenemedi", + "wsEffectiveProperty": "Etkin: {value} — {origin}", + "wsElementSelector": "Öğe", + "wsGeneratedAnchorValue": "Oluşturulan: {anchor}", + "wsDefaultProperty": "Varsayılan: {value}", + "wsDefaultPropertyOrigin": "Varsayılan: {value} · {origin}", + "wsResolvedProperty": "Çözümlenen: {value}", + "wsPropertyUnresolved": "Çözümlenmedi", + "wsModule": "Modül", + "wsParent": "Üst öğe", + "wsListType": "Liste türü", + "wsStartingNumber": "Başlangıç numarası", + "wsListSorting": "Sıralama", + "wsLetteredList": "Harfli liste", + "wsCheckboxList": "Onay kutulu liste", + "wsMarkerlessList": "İşaretsiz liste", + "wsAscending": "Artan", + "wsDescending": "Azalan", + "wsNoSorting": "Sıralama yok", + "wsAsWritten": "Yazıldığı gibi", + "wsSentenceCase": "Cümle düzeni", + "wsTitleCase": "Başlık düzeni", + "wsLowerCase": "Küçük harf", + "wsUpperCase": "Büyük harf", + "wsNativeMarkers": "Markdown işaretleri", + "wsListMarkerStart": "İlk işaret: {number}", + "wsListLevel": "Düzey {level}", + "wsModuleCapsIgnored": "Sabitlenmiş derleyici modülün <caps> kurallarını yok sayar. Yerel caps özniteliği veya default-property kullanın.", + "wsInsertElements": "Öğe ekle", + "wsAdmonitionsAndQuotes": "Uyarılar ve alıntılar", + "wsSemanticText": "Anlamsal metin", + "wsPropertiesUnavailable": "İncelenen öğe kullanılamıyor. Devam etmek için bir öğeyi inceleyin." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a3f6a69d..3331cd25 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Правило регістру модуля не публікується.", + "wsTitleOverridesAction": "Заголовки екземпляра та змісту…", + "wsHeadingNotPublished": "Не публікується цим збирачем.", + "wsMarkdownInstanceTitleLimitation": "Закріплений builder не публікує заголовки примірників у темах Markdown. Розмітку збережено; перевірте джерело.", + "wsTopicIdentity": "ID теми", + "wsElementAnchor": "Якір елемента", + "wsHeadingText": "Текст заголовка", + "wsDefaultTitle": "Типовий заголовок", + "wsTopicNavigation": "Навігація теми", + "wsHeadingOptions": "Атрибути заголовка", + "wsDocumentOptions": "Налаштування документа", + "wsSettingsPresent": "Є явні налаштування", + "wsTitleOverrideExplanation": "Перевизначає опублікований заголовок; текст заголовка не змінюється.", + "wsIgnoreVariables": "Вважати змінні буквальним текстом", + "wsIgnoreVariablesExplanation": "Якщо ввімкнено, текст, схожий на змінні, не підставляється.", + "wsInstanceSelection": "Примірники довідки", + "wsInstanceExplanation": "Виберіть випуски за іменами, групами, списками, винятками або виразами.", + "wsFilterExplanation": "Фільтрує повторно використовуваний вміст за критеріями; не вибирає примірник довідки.", + "wsContextHelp": "ID контекстної довідки", + "wsCapitalization": "Регістр", + "wsLibraryTopic": "Бібліотечна тема", + "wsLastModified": "Остання зміна", + "wsMissingTopicHeading": "H1 теми відсутній. Додайте його у джерелі.", + "wsMultipleTopicHeadings": "Перший H1 визначає заголовок теми. Решта H1 є окремими заголовками.", + "wsTopicIdentityMismatch": "Кореневий id відрізняється від ID імені файлу. Перейменуйте файл теми або перевірте джерело.", + "wsUnsupportedProperties": "Непідтримувані атрибути збережено. Перевірте їх у джерелі.", + "wsGeneratedAnchor": "Згенерований якір; значення створює перевизначення.", + "wsHeadingBuilderLimitation": "Закріплений builder не публікує атрибути H1. Вони належать заголовку, а не кореню XML.", "closeOtherTabs": "Закрити інші вкладки", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "Тема з шаблону...", @@ -3445,5 +3473,168 @@ "nextcloudPossibleCandidate": "Можливий збіг після нормалізації; тотожність не підтверджено.", "nextcloudSelectCandidate": "Виберіть нотатку на сервері для перегляду", "nextcloudCreateSeparate": "Створити окрему нотатку", - "nextcloudCreateSeparateWarning": "Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено." + "nextcloudCreateSeparateWarning": "Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено.", + "wsSemanticFormats": "Семантичне форматування", + "wsUiControl": "Елемент інтерфейсу", + "wsFilePath": "Файл / Шлях", + "wsUiPath": "Шлях в інтерфейсі", + "wsDefinitionList": "Список визначень", + "wsIncludeContent": "Додати повторно використовуваний вміст", + "wsVariableReference": "Посилання на змінну", + "wsConvertList": "Перетворити список на процедуру", + "wsElementProperties": "Властивості елемента", + "wsTopicProperties": "Властивості теми", + "wsCollapsible": "Згортання", + "wsPublishedState": "Початковий стан публікації", + "wsExpanded": "Розгорнуто", + "wsCollapsed": "Згорнуто", + "wsCollapsedTitle": "Заголовок у згорнутому стані", + "wsTerm": "Термін", + "wsListLayout": "Макет списку", + "wsSyncGroup": "Група синхронізації", + "wsTabKey": "Ключ вкладки", + "wsSwitcherLabel": "Підпис перемикача", + "wsNavigationElements": "Елементи навігації", + "wsNavigationDepth": "Глибина навігації", + "wsAddItem": "Додати елемент", + "wsStep": "Крок", + "wsPropertyInvalid": "Неприпустиме значення властивості", + "wsFilter": "Фільтр", + "wsIdentifier": "Ідентифікатор елемента", + "wsTldr": "Короткий виклад (TLDR)", + "wsFullLayout": "Повна ширина", + "wsMediumLayout": "Середній (1:2)", + "wsNarrowLayout": "Вузький стовпець терміна", + "wsTitle": "Заголовок", + "wsSwitcherKey": "Ключ перемикача", + "nextcloudServerSettings": "Налаштування Notes на сервері", + "nextcloudNotesPath": "Шлях до теки нотаток", + "nextcloudFileSuffix": "Розширення нових нотаток", + "nextcloudServerSettingsExplanation": "Ці налаштування Notes впливають на інші клієнти. Зміна шляху не переміщує файли; зміна розширення не перейменовує наявні нотатки.", + "nextcloudSettingsSaved": "Налаштування сервера збережено", + "nextcloudSettingsNormalized": "Збережено зі значеннями, нормалізованими сервером", + "nextcloudPendingUpload": "Вкладення очікує завантаження", + "nextcloudServerCheck": "Остання перевірка сервера: {time}", + "nextcloudSettingsLoading": "Завантаження налаштувань сервера", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Збереження налаштувань сервера", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Попередня локальна зміна", + "nextcloudUsePreviousEdit": "Використати попередню зміну", + "quickOpen": "Швидке відкриття", + "notesAll": "Усі нотатки", + "notesUncategorized": "Без категорії", + "notesRecovery": "Відновлення", + "notesRecentlyEdited": "Нещодавно змінені", + "notesOldestEdited": "Давно змінені", + "notesTitleAscending": "Назва А–Я", + "notesTitleDescending": "Назва Я–А", + "notesMove": "Перемістити до категорії", + "notesRemoveFavorite": "Вилучити з обраного", + "notesOffline": "Зберегти для роботи без мережі", + "notesOfflineRemove": "Вилучити збереження без мережі", + "notesIndexing": "Індексація нотаток…", + "notesSearchHelp": "Терміни поєднуються І. Використовуйте \"фрази в лапках\", title: та category:.", + "notesSourceLocation": "Місце в джерелі", + "notesRecoveryHelp": "Лише збережені локальні нотатки та локальна історія. Кошик та історія сервера недоступні.", + "notesImport": "Імпорт нотаток", + "notesExport": "Експорт нотаток", + "notesImportDistinct": "Імпортувати як окрему нотатку", + "notesAvailableOffline": "Доступно без мережі", + "notesIncomplete": "Неповно", + "notesSelected": "Вибрані нотатки", + "notesNoHistory": "Немає збережених версій. Історію може бути вимкнено, прострочено або очищено.", + "notesTextAvailable": "Текст доступний локально", + "notesLocalVersion": "Локальна робоча версія", + "notesAttachments": "Вкладення", + "notesSkipped": "Пропущено", + "notesFailed": "Помилка", + "notesCompleted": "Завершено", + "notesImportNew": "Почати новий імпорт", + "notesImportResume": "Продовжити перерваний імпорт", + "notesImportAlreadyImported": "Уже імпортовано в цій операції", + "references": "Посилання", + "referenceIncoming": "Вхідні", + "referenceOutgoing": "Вихідні", + "referenceChooseTarget": "Виберіть ціль посилання", + "referenceWholeDocument": "Увесь документ", + "referenceSourceLocation": "Позиція джерела", + "referenceTargetLocation": "Позиція цілі", + "referenceResolved": "Знайдено", + "referenceMissing": "Відсутнє", + "referenceAmbiguous": "Неоднозначне", + "referenceUnavailable": "Недоступне в цьому контексті", + "referenceUnsupported": "Не підтримується", + "referenceNoPortableId": "Ця ціль не має перевіреної переносної URL-адреси нотатки.", + "wsIncludeLines": "Включити рядки", + "wsIncludeSymbol": "Включити символ", + "wsEmphasizeLines": "Виділити показані рядки", + "wsPropertiesReadOnly": "У цьому режимі властивості доступні лише для читання.", + "wsPropertiesProtected": "Елемент захищено. Змініть його джерело.", + "wsPropertiesConflict": "Документ або елемент змінився. Скасуйте чернетку й виберіть знову.", + "wsPropertiesUnresolved": "Наразі неможливо розв’язати посилання.", + "wsUseDefault": "Використати типове", + "wsPropertiesSelection": "Виберіть один елемент.", + "wsImageDisplay": "Показ зображення", + "wsImageThumbnail": "Мініатюра з посиланням", + "wsEnabled": "Увімкнено", + "wsDisabled": "Вимкнено", + "wsWidth": "Ширина", + "wsHeight": "Висота", + "wsPropertiesInactive": "Властивість діє лише зі згортанням у публікації.", + "wsNavigationAll": "Усі елементи із заголовками", + "wsNavigationNone": "Без навігації", + "wsNavigationCustom": "Власна навігація", + "wsTitleOverride": "Перевизначити заголовок", + "wsCompactLayout": "Компактний", + "wsWideLayout": "Широкий", + "wsCodeSourceConflict": "Перемістіть або видаліть код, написаний у цьому блоці, перш ніж задати src. Writerside відхиляє непорожній блок коду з посиланням на джерело.", + "wsInherited": "Успадкувати зі списку", + "wsExternalImageSize": "Writerside ненадійно задає розміри зовнішніх зображень. Наявні розміри можна переглянути та видалити.", + "wsPropertiesNotSerialized": "Цю зміну не записано у вихідний текст. Чернетку збережено.", + "wsListItem": "Елемент списку", + "wsSourceFile": "Вихідний файл", + "wsLinkTarget": "Ціль посилання", + "wsPreviewImage": "Зображення перегляду", + "wsIncludedElement": "Включений елемент", + "wsHelpModule": "Модуль довідки", + "wsActionId": "ID дії", + "wsKeymapHelpInstance": "Екземпляр довідки розкладки", + "wsShowInSource": "Показати у джерелі", + "wsOriginElement": "Значення елемента", + "wsOriginAncestor": "Батьківський елемент", + "wsOriginModule": "Типове значення модуля", + "wsOriginBuiltIn": "Вбудоване типове значення", + "wsOriginUnresolved": "Не визначено", + "wsEffectiveProperty": "Дійсне значення: {value} — {origin}", + "wsElementSelector": "Елемент", + "wsGeneratedAnchorValue": "Згенеровано: {anchor}", + "wsDefaultProperty": "Типово: {value}", + "wsDefaultPropertyOrigin": "Типово: {value} · {origin}", + "wsResolvedProperty": "Визначено: {value}", + "wsPropertyUnresolved": "Не визначено", + "wsModule": "Модуль", + "wsParent": "Батьківський елемент", + "wsListType": "Тип списку", + "wsStartingNumber": "Початковий номер", + "wsListSorting": "Сортування", + "wsLetteredList": "Літерний список", + "wsCheckboxList": "Список із прапорцями", + "wsMarkerlessList": "Список без маркерів", + "wsAscending": "За зростанням", + "wsDescending": "За спаданням", + "wsNoSorting": "Без сортування", + "wsAsWritten": "Як написано", + "wsSentenceCase": "Регістр речення", + "wsTitleCase": "Регістр заголовка", + "wsLowerCase": "Малі літери", + "wsUpperCase": "Великі літери", + "wsNativeMarkers": "Маркери Markdown", + "wsListMarkerStart": "Початковий маркер: {number}", + "wsListLevel": "Рівень {level}", + "wsModuleCapsIgnored": "Закріплений збирач ігнорує правила <caps> модуля. Використовуйте локальний атрибут caps або default-property.", + "wsInsertElements": "Вставити елементи", + "wsAdmonitionsAndQuotes": "Примітки та цитати", + "wsSemanticText": "Семантичний текст", + "wsPropertiesUnavailable": "Елемент, що перевіряється, недоступний. Виберіть елемент для продовження." } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 27e538ac..81f61b10 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "Quy tắc viết hoa của mô-đun không được xuất bản.", + "wsTitleOverridesAction": "Tiêu đề phiên bản và mục lục…", + "wsHeadingNotPublished": "Trình dựng này không xuất bản.", + "wsMarkdownInstanceTitleLimitation": "Builder cố định không xuất bản tiêu đề phiên bản trong chủ đề Markdown. Giữ nguyên đánh dấu hiện có; kiểm tra trong Nguồn.", + "wsTopicIdentity": "ID chủ đề", + "wsElementAnchor": "Neo phần tử", + "wsHeadingText": "Văn bản tiêu đề", + "wsDefaultTitle": "Tiêu đề mặc định", + "wsTopicNavigation": "Điều hướng chủ đề", + "wsHeadingOptions": "Thuộc tính tiêu đề", + "wsDocumentOptions": "Thiết lập tài liệu", + "wsSettingsPresent": "Có thiết lập được ghi rõ", + "wsTitleOverrideExplanation": "Ghi đè tiêu đề xuất bản; văn bản tiêu đề không đổi.", + "wsIgnoreVariables": "Xử lý biến như văn bản nguyên dạng", + "wsIgnoreVariablesExplanation": "Khi bật, văn bản giống biến không được thay thế.", + "wsInstanceSelection": "Phiên bản trợ giúp", + "wsInstanceExplanation": "Chọn đầu ra theo tên, nhóm, danh sách, loại trừ hoặc biểu thức.", + "wsFilterExplanation": "Lọc nội dung tái sử dụng theo tiêu chí; không chọn phiên bản trợ giúp.", + "wsContextHelp": "ID trợ giúp ngữ cảnh", + "wsCapitalization": "Kiểu chữ hoa", + "wsLibraryTopic": "Chủ đề thư viện", + "wsLastModified": "Sửa đổi lần cuối", + "wsMissingTopicHeading": "Chưa có H1 chủ đề. Thêm trong Nguồn.", + "wsMultipleTopicHeadings": "H1 đầu tiên xác định tiêu đề chủ đề. Các H1 sau là tiêu đề riêng.", + "wsTopicIdentityMismatch": "id gốc khác ID theo tên tệp. Dùng Đổi tên tệp chủ đề hoặc kiểm tra Nguồn.", + "wsUnsupportedProperties": "Thuộc tính không hỗ trợ được giữ lại. Kiểm tra trong Nguồn.", + "wsGeneratedAnchor": "Neo được tạo tự động; nhập giá trị để ghi đè.", + "wsHeadingBuilderLimitation": "Builder cố định không xuất bản thuộc tính H1. Chúng thuộc tiêu đề, không thuộc gốc XML.", "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...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "Có thể khớp sau khi chuẩn hóa yêu cầu; chưa xác nhận danh tính.", "nextcloudSelectCandidate": "Chọn ghi chú trên máy chủ để xem xét", "nextcloudCreateSeparate": "Tạo ghi chú riêng", - "nextcloudCreateSeparateWarning": "Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp." + "nextcloudCreateSeparateWarning": "Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp.", + "wsSemanticFormats": "Định dạng ngữ nghĩa", + "wsUiControl": "Điều khiển giao diện", + "wsFilePath": "Tệp / Đường dẫn", + "wsUiPath": "Đường dẫn giao diện", + "wsDefinitionList": "Danh sách định nghĩa", + "wsIncludeContent": "Chèn nội dung tái sử dụng", + "wsVariableReference": "Tham chiếu biến", + "wsConvertList": "Chuyển danh sách thành quy trình", + "wsElementProperties": "Thuộc tính phần tử", + "wsTopicProperties": "Thuộc tính chủ đề", + "wsCollapsible": "Có thể thu gọn", + "wsPublishedState": "Trạng thái ban đầu khi xuất bản", + "wsExpanded": "Mở rộng", + "wsCollapsed": "Thu gọn", + "wsCollapsedTitle": "Tiêu đề thu gọn", + "wsTerm": "Thuật ngữ", + "wsListLayout": "Bố cục danh sách", + "wsSyncGroup": "Nhóm đồng bộ", + "wsTabKey": "Khóa thẻ", + "wsSwitcherLabel": "Nhãn bộ chuyển", + "wsNavigationElements": "Phần tử điều hướng", + "wsNavigationDepth": "Độ sâu điều hướng", + "wsAddItem": "Thêm mục", + "wsStep": "Bước", + "wsPropertyInvalid": "Giá trị thuộc tính không hợp lệ", + "wsFilter": "Bộ lọc", + "wsIdentifier": "ID phần tử", + "wsTldr": "Tóm tắt (TLDR)", + "wsFullLayout": "Toàn chiều rộng", + "wsMediumLayout": "Trung bình (1:2)", + "wsNarrowLayout": "Cột thuật ngữ hẹp", + "wsTitle": "Tiêu đề", + "wsSwitcherKey": "Khóa chuyển đổi", + "nextcloudServerSettings": "Cài đặt Notes trên máy chủ", + "nextcloudNotesPath": "Đường dẫn thư mục ghi chú", + "nextcloudFileSuffix": "Phần mở rộng của ghi chú mới", + "nextcloudServerSettingsExplanation": "Các cài đặt Notes này ảnh hưởng đến các ứng dụng khách khác. Đổi đường dẫn không di chuyển tệp; đổi phần mở rộng không đổi tên ghi chú hiện có.", + "nextcloudSettingsSaved": "Đã lưu cài đặt máy chủ", + "nextcloudSettingsNormalized": "Đã lưu các giá trị được máy chủ chuẩn hóa", + "nextcloudPendingUpload": "Đang chờ tải tệp đính kèm lên", + "nextcloudServerCheck": "Lần kiểm tra máy chủ gần nhất: {time}", + "nextcloudSettingsLoading": "Đang tải cài đặt máy chủ", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "Đang lưu cài đặt máy chủ", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "Chỉnh sửa cục bộ trước", + "nextcloudUsePreviousEdit": "Dùng chỉnh sửa trước", + "quickOpen": "Mở nhanh", + "notesAll": "Tất cả ghi chú", + "notesUncategorized": "Chưa phân loại", + "notesRecovery": "Khôi phục", + "notesRecentlyEdited": "Sửa gần đây", + "notesOldestEdited": "Sửa lâu nhất", + "notesTitleAscending": "Tiêu đề A–Z", + "notesTitleDescending": "Tiêu đề Z–A", + "notesMove": "Chuyển tới danh mục", + "notesRemoveFavorite": "Bỏ yêu thích", + "notesOffline": "Cho phép dùng ngoại tuyến", + "notesOfflineRemove": "Bỏ yêu cầu lưu ngoại tuyến", + "notesIndexing": "Đang lập chỉ mục ghi chú…", + "notesSearchHelp": "Các từ kết hợp bằng VÀ. Dùng \"cụm từ trích dẫn\", title: và category:.", + "notesSourceLocation": "Vị trí mã nguồn", + "notesRecoveryHelp": "Chỉ gồm ghi chú cục bộ được giữ và lịch sử cục bộ. Không có thùng rác hoặc lịch sử máy chủ.", + "notesImport": "Nhập ghi chú", + "notesExport": "Xuất ghi chú", + "notesImportDistinct": "Nhập thành ghi chú riêng", + "notesAvailableOffline": "Có sẵn ngoại tuyến", + "notesIncomplete": "Chưa đầy đủ", + "notesSelected": "Ghi chú đã chọn", + "notesNoHistory": "Không có phiên bản được giữ. Lịch sử có thể bị tắt, hết hạn hoặc bị xóa.", + "notesTextAvailable": "Văn bản có sẵn cục bộ", + "notesLocalVersion": "Phiên bản làm việc cục bộ", + "notesAttachments": "Tệp đính kèm", + "notesSkipped": "Đã bỏ qua", + "notesFailed": "Thất bại", + "notesCompleted": "Hoàn tất", + "notesImportNew": "Bắt đầu nhập mới", + "notesImportResume": "Tiếp tục lần nhập bị gián đoạn", + "notesImportAlreadyImported": "Đã nhập trong thao tác này", + "references": "Tham chiếu", + "referenceIncoming": "Đến", + "referenceOutgoing": "Đi", + "referenceChooseTarget": "Chọn đích liên kết", + "referenceWholeDocument": "Toàn bộ tài liệu", + "referenceSourceLocation": "Vị trí nguồn", + "referenceTargetLocation": "Vị trí đích", + "referenceResolved": "Đã phân giải", + "referenceMissing": "Không tìm thấy", + "referenceAmbiguous": "Không rõ ràng", + "referenceUnavailable": "Không khả dụng trong ngữ cảnh này", + "referenceUnsupported": "Không được hỗ trợ", + "referenceNoPortableId": "Đích này không có URL ghi chú di động đã xác minh.", + "wsIncludeLines": "Gồm các dòng", + "wsIncludeSymbol": "Gồm ký hiệu", + "wsEmphasizeLines": "Tô nổi các dòng hiển thị", + "wsPropertiesReadOnly": "Thuộc tính chỉ đọc trong chế độ này.", + "wsPropertiesProtected": "Phần tử được bảo vệ. Hãy sửa mã nguồn.", + "wsPropertiesConflict": "Tài liệu hoặc phần tử đã đổi. Hoàn nguyên bản nháp và chọn lại.", + "wsPropertiesUnresolved": "Hiện không thể phân giải tham chiếu.", + "wsUseDefault": "Dùng mặc định", + "wsPropertiesSelection": "Chọn một phần tử.", + "wsImageDisplay": "Hiển thị ảnh", + "wsImageThumbnail": "Ảnh thu nhỏ có thể nhấp", + "wsEnabled": "Bật", + "wsDisabled": "Tắt", + "wsWidth": "Chiều rộng", + "wsHeight": "Chiều cao", + "wsPropertiesInactive": "Thuộc tính chỉ có tác dụng khi bật thu gọn lúc xuất bản.", + "wsNavigationAll": "Mọi phần tử có tiêu đề", + "wsNavigationNone": "Không điều hướng", + "wsNavigationCustom": "Điều hướng tùy chỉnh", + "wsTitleOverride": "Ghi đè tiêu đề", + "wsCompactLayout": "Gọn", + "wsWideLayout": "Rộng", + "wsCodeSourceConflict": "Di chuyển hoặc xóa mã được viết trong khối này trước khi đặt src. Writerside từ chối khối mã không trống có tham chiếu nguồn.", + "wsInherited": "Kế thừa từ danh sách", + "wsExternalImageSize": "Writerside không đặt kích thước ảnh bên ngoài một cách tin cậy. Có thể xem và xóa kích thước hiện có.", + "wsPropertiesNotSerialized": "Thay đổi này chưa được ghi vào nguồn. Bản nháp đã được giữ lại.", + "wsListItem": "Mục danh sách", + "wsSourceFile": "Tệp nguồn", + "wsLinkTarget": "Đích liên kết", + "wsPreviewImage": "Ảnh xem trước", + "wsIncludedElement": "Phần tử được đưa vào", + "wsHelpModule": "Mô-đun trợ giúp", + "wsActionId": "ID hành động", + "wsKeymapHelpInstance": "Phiên bản trợ giúp sơ đồ phím", + "wsShowInSource": "Hiện trong Nguồn", + "wsOriginElement": "Giá trị ghi đè của phần tử", + "wsOriginAncestor": "Phần tử cha", + "wsOriginModule": "Mặc định của mô-đun", + "wsOriginBuiltIn": "Mặc định tích hợp", + "wsOriginUnresolved": "Chưa phân giải", + "wsEffectiveProperty": "Hiệu lực: {value} — {origin}", + "wsElementSelector": "Phần tử", + "wsGeneratedAnchorValue": "Được tạo: {anchor}", + "wsDefaultProperty": "Mặc định: {value}", + "wsDefaultPropertyOrigin": "Mặc định: {value} · {origin}", + "wsResolvedProperty": "Đã phân giải: {value}", + "wsPropertyUnresolved": "Chưa phân giải", + "wsModule": "Mô-đun", + "wsParent": "Phần tử cha", + "wsListType": "Loại danh sách", + "wsStartingNumber": "Số bắt đầu", + "wsListSorting": "Sắp xếp", + "wsLetteredList": "Danh sách chữ cái", + "wsCheckboxList": "Danh sách hộp kiểm", + "wsMarkerlessList": "Danh sách không dấu đầu dòng", + "wsAscending": "Tăng dần", + "wsDescending": "Giảm dần", + "wsNoSorting": "Không sắp xếp", + "wsAsWritten": "Theo văn bản gốc", + "wsSentenceCase": "Viết hoa kiểu câu", + "wsTitleCase": "Viết hoa kiểu tiêu đề", + "wsLowerCase": "Chữ thường", + "wsUpperCase": "Chữ hoa", + "wsNativeMarkers": "Dấu Markdown", + "wsListMarkerStart": "Dấu bắt đầu: {number}", + "wsListLevel": "Cấp {level}", + "wsModuleCapsIgnored": "Trình dựng cố định bỏ qua quy tắc <caps> của mô-đun. Dùng thuộc tính caps cục bộ hoặc default-property.", + "wsInsertElements": "Chèn phần tử", + "wsAdmonitionsAndQuotes": "Lưu ý và trích dẫn", + "wsSemanticText": "Văn bản ngữ nghĩa", + "wsPropertiesUnavailable": "Phần tử đang kiểm tra không khả dụng. Kiểm tra một phần tử để tiếp tục." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3210e922..40ecc186 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,6 +1,36 @@ { + "wsModuleCapsNotPublished": "模块的大小写规则不会发布。", + "wsTitleOverridesAction": "实例与目录标题…", + "wsHeadingNotPublished": "此构建器不会发布此属性。", + "wsMarkdownInstanceTitleLimitation": "固定版本构建器不发布 Markdown 主题的实例标题覆盖。保留现有标记,请在源码中查看。", + "wsTopicIdentity": "主题 ID", + "wsElementAnchor": "元素锚点", + "wsHeadingText": "标题文本", + "wsDefaultTitle": "默认标题", + "wsTopicNavigation": "主题导航", + "wsHeadingOptions": "标题属性", + "wsDocumentOptions": "文档设置", + "wsSettingsPresent": "存在已编写设置", + "wsTitleOverrideExplanation": "覆盖发布的标题;标题文本保持不变。", + "wsIgnoreVariables": "将变量视为字面文本", + "wsIgnoreVariablesExplanation": "启用时,不替换类似变量的文本。", + "wsInstanceSelection": "帮助实例", + "wsInstanceExplanation": "通过名称、组、列表、排除项或表达式选择帮助输出。", + "wsFilterExplanation": "按条件筛选可复用内容;这不会选择帮助实例。", + "wsContextHelp": "上下文帮助 ID", + "wsCapitalization": "大小写", + "wsLibraryTopic": "库主题", + "wsLastModified": "最后修改", + "wsMissingTopicHeading": "尚未编写主题 H1。请在源码中添加。", + "wsMultipleTopicHeadings": "第一个 H1 决定主题标题,后续 H1 是独立标题。", + "wsTopicIdentityMismatch": "根 id 与文件名派生的主题 ID 不同。请重命名主题文件或查看源码。", + "wsUnsupportedProperties": "保留不受支持的属性。请在源码中查看。", + "wsGeneratedAnchor": "自动生成的锚点;输入值可覆盖。", + "wsHeadingBuilderLimitation": "固定版本构建器不发布 H1 属性。属性属于标题,而非 XML 根元素。", "closeOtherTabs": "关闭其他标签页", - "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, + "@closeOtherTabs": { + "description": "Document tab context menu action that closes every other document and comparison tab while retaining this document." + }, "tocTopicFromTemplate": "从模板创建主题...", "tocSaveAsTemplate": "保存为模板", "tocTemplateDialog": "从模板创建主题", @@ -2552,9 +2582,15 @@ "@errorWritersideTopicRenameIncompleteProjectIndex": { "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", "placeholders": { - "module": {"type": "String"}, - "count": {"type": "String"}, - "paths": {"type": "String"} + "module": { + "type": "String" + }, + "count": { + "type": "String" + }, + "paths": { + "type": "String" + } } }, "errorWritersideTopicRenameProjectBuffersDirty": "Writerside 项目文件有未保存的更改。请先处理这些更改,再准备新的重命名预览。", @@ -4958,5 +4994,172 @@ "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", "nextcloudSelectCandidate": "选择要检查的服务器笔记", "nextcloudCreateSeparate": "创建独立笔记", - "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。", + "wsSemanticFormats": "语义格式", + "wsUiControl": "界面控件", + "wsFilePath": "文件 / 路径", + "wsUiPath": "界面路径", + "wsDefinitionList": "定义列表", + "wsIncludeContent": "包含可复用内容", + "wsVariableReference": "变量引用", + "wsConvertList": "将列表转换为过程", + "wsElementProperties": "元素属性", + "wsTopicProperties": "主题属性", + "wsCollapsible": "可折叠", + "wsPublishedState": "发布时的初始状态", + "wsExpanded": "展开", + "wsCollapsed": "折叠", + "wsCollapsedTitle": "折叠标题", + "wsTerm": "术语", + "wsListLayout": "列表布局", + "wsSyncGroup": "同步组", + "wsTabKey": "选项卡键", + "wsSwitcherLabel": "切换器标签", + "wsNavigationElements": "导航元素", + "wsNavigationDepth": "导航深度", + "wsAddItem": "添加项目", + "wsStep": "步骤", + "wsPropertyInvalid": "属性值无效", + "wsFilter": "筛选器", + "wsIdentifier": "元素 ID", + "wsTldr": "摘要 (TLDR)", + "wsFullLayout": "全宽", + "wsMediumLayout": "中等 (1:2)", + "wsNarrowLayout": "窄术语列", + "wsTitle": "标题", + "wsSwitcherKey": "切换键", + "nextcloudServerSettings": "服务器 Notes 设置", + "nextcloudNotesPath": "笔记文件夹路径", + "nextcloudFileSuffix": "新笔记的文件后缀", + "nextcloudServerSettingsExplanation": "这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。", + "nextcloudSettingsSaved": "服务器设置已保存", + "nextcloudSettingsNormalized": "已保存服务器规范化后的值", + "nextcloudPendingUpload": "附件等待上传", + "nextcloudServerCheck": "上次服务器检查:{time}", + "nextcloudSettingsLoading": "正在加载服务器设置", + "@nextcloudSettingsLoading": { + "description": "Accessible progress label while reading server Notes settings." + }, + "nextcloudSettingsSaving": "正在保存服务器设置", + "@nextcloudSettingsSaving": { + "description": "Accessible progress label while writing server Notes settings." + }, + "nextcloudPreviousLocalEdit": "先前的本地编辑", + "nextcloudUsePreviousEdit": "使用先前编辑", + "quickOpen": "快速打开", + "notesAll": "所有笔记", + "notesUncategorized": "未分类", + "notesRecovery": "恢复", + "notesRecentlyEdited": "最近编辑", + "notesOldestEdited": "最早编辑", + "notesTitleAscending": "标题升序", + "notesTitleDescending": "标题降序", + "notesMove": "移至分类", + "notesRemoveFavorite": "取消收藏", + "notesOffline": "设为离线可用", + "notesOfflineRemove": "移除离线保留要求", + "notesIndexing": "正在索引笔记…", + "notesSearchHelp": "词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。", + "notesSourceLocation": "源文件位置", + "notesRecoveryHelp": "仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。", + "notesImport": "导入笔记", + "notesExport": "导出笔记", + "notesImportDistinct": "导入为独立笔记", + "notesAvailableOffline": "离线可用", + "notesIncomplete": "不完整", + "notesSelected": "已选笔记", + "notesNoHistory": "没有保留的版本。历史可能已禁用、过期或清除。", + "notesTextAvailable": "文本已在本地可用", + "notesLocalVersion": "本地工作版本", + "notesAttachments": "附件", + "notesSkipped": "已跳过", + "notesFailed": "失败", + "notesCompleted": "已完成", + "notesImportNew": "开始新导入", + "notesImportResume": "继续中断的导入", + "notesImportAlreadyImported": "已在此操作中导入", + "references": "引用", + "referenceIncoming": "传入", + "referenceOutgoing": "传出", + "referenceChooseTarget": "选择链接目标", + "referenceWholeDocument": "整个文档", + "referenceSourceLocation": "源位置", + "referenceTargetLocation": "目标位置", + "referenceResolved": "已解析", + "referenceMissing": "缺失", + "referenceAmbiguous": "不明确", + "referenceUnavailable": "当前上下文中不可用", + "referenceUnsupported": "不支持", + "referenceNoPortableId": "此目标没有经过验证的可移植笔记网址。", + "wsIncludeLines": "包含行", + "wsIncludeSymbol": "包含符号", + "wsEmphasizeLines": "突出显示已显示的行", + "wsPropertiesReadOnly": "此视图中的属性为只读。", + "wsPropertiesProtected": "此元素受保护。请编辑其源代码。", + "wsPropertiesConflict": "文档或元素已更改。请还原草稿并重新选择。", + "wsPropertiesUnresolved": "目前无法解析此引用。", + "wsUseDefault": "使用默认值", + "wsPropertiesSelection": "请选择单个元素。", + "wsImageDisplay": "图像显示", + "wsImageThumbnail": "可点击缩略图", + "wsEnabled": "已启用", + "wsDisabled": "已禁用", + "wsWidth": "宽度", + "wsHeight": "高度", + "wsPropertiesInactive": "未启用发布折叠时,此属性无效。", + "wsNavigationAll": "所有有标题的元素", + "wsNavigationNone": "无导航", + "wsNavigationCustom": "自定义导航", + "wsTitleOverride": "覆盖标题", + "wsCompactLayout": "紧凑", + "wsWideLayout": "宽", + "wsCodeSourceConflict": "设置 src 前,请移走或删除此代码块中编写的代码。Writerside 不接受带有源引用的非空代码块。", + "wsInherited": "从列表继承", + "wsExternalImageSize": "Writerside 无法可靠地调整外部托管图片的尺寸。可以查看和移除已有尺寸。", + "wsPropertiesNotSerialized": "此更改未写入源文本。草稿已保留。", + "wsListItem": "列表项", + "wsSourceFile": "源文件", + "wsLinkTarget": "链接目标", + "wsPreviewImage": "预览图像", + "wsIncludedElement": "包含的元素", + "wsHelpModule": "帮助模块", + "wsActionId": "操作 ID", + "wsKeymapHelpInstance": "键位映射帮助实例", + "wsShowInSource": "在源代码中显示", + "wsOriginElement": "元素覆盖值", + "wsOriginAncestor": "父元素", + "wsOriginModule": "模块默认值", + "wsOriginBuiltIn": "内置默认值", + "wsOriginUnresolved": "未解析", + "wsEffectiveProperty": "有效值:{value} — {origin}", + "wsElementSelector": "元素", + "wsGeneratedAnchorValue": "生成值:{anchor}", + "wsDefaultProperty": "默认值:{value}", + "wsDefaultPropertyOrigin": "默认值:{value} · {origin}", + "wsResolvedProperty": "解析值:{value}", + "wsPropertyUnresolved": "未解析", + "wsModule": "模块", + "wsParent": "父元素", + "wsListType": "列表类型", + "wsStartingNumber": "起始编号", + "wsListSorting": "排序", + "wsLetteredList": "字母列表", + "wsCheckboxList": "复选框列表", + "wsMarkerlessList": "无标记列表", + "wsAscending": "升序", + "wsDescending": "降序", + "wsNoSorting": "不排序", + "wsAsWritten": "按原文", + "wsSentenceCase": "句首大写", + "wsTitleCase": "标题大写", + "wsLowerCase": "小写", + "wsUpperCase": "大写", + "wsNativeMarkers": "Markdown 标记", + "wsListMarkerStart": "起始标记:{number}", + "wsListLevel": "层级 {level}", + "wsModuleCapsIgnored": "固定版本的构建器会忽略模块的 <caps> 规则。请使用本地 caps 属性或 default-property。", + "wsInsertElements": "插入元素", + "wsAdmonitionsAndQuotes": "提示和引用", + "wsSemanticText": "语义文本", + "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index d6863563..8cb2aaa8 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1,4 +1,32 @@ { + "wsModuleCapsNotPublished": "模块的大小写规则不会发布。", + "wsTitleOverridesAction": "实例与目录标题…", + "wsHeadingNotPublished": "此构建器不会发布此属性。", + "wsMarkdownInstanceTitleLimitation": "固定版本构建器不发布 Markdown 主题的实例标题覆盖。保留现有标记,请在源码中查看。", + "wsTopicIdentity": "主题 ID", + "wsElementAnchor": "元素锚点", + "wsHeadingText": "标题文本", + "wsDefaultTitle": "默认标题", + "wsTopicNavigation": "主题导航", + "wsHeadingOptions": "标题属性", + "wsDocumentOptions": "文档设置", + "wsSettingsPresent": "存在已编写设置", + "wsTitleOverrideExplanation": "覆盖发布的标题;标题文本保持不变。", + "wsIgnoreVariables": "将变量视为字面文本", + "wsIgnoreVariablesExplanation": "启用时,不替换类似变量的文本。", + "wsInstanceSelection": "帮助实例", + "wsInstanceExplanation": "通过名称、组、列表、排除项或表达式选择帮助输出。", + "wsFilterExplanation": "按条件筛选可复用内容;这不会选择帮助实例。", + "wsContextHelp": "上下文帮助 ID", + "wsCapitalization": "大小写", + "wsLibraryTopic": "库主题", + "wsLastModified": "最后修改", + "wsMissingTopicHeading": "尚未编写主题 H1。请在源码中添加。", + "wsMultipleTopicHeadings": "第一个 H1 决定主题标题,后续 H1 是独立标题。", + "wsTopicIdentityMismatch": "根 id 与文件名派生的主题 ID 不同。请重命名主题文件或查看源码。", + "wsUnsupportedProperties": "保留不受支持的属性。请在源码中查看。", + "wsGeneratedAnchor": "自动生成的锚点;输入值可覆盖。", + "wsHeadingBuilderLimitation": "固定版本构建器不发布 H1 属性。属性属于标题,而非 XML 根元素。", "closeOtherTabs": "关闭其他标签页", "@closeOtherTabs": {"description": "Document tab context menu action that closes every other document and comparison tab while retaining this document."}, "tocTopicFromTemplate": "从模板创建主题...", @@ -4958,5 +4986,168 @@ "nextcloudPossibleCandidate": "可能与规范化后的请求匹配;身份尚未确认。", "nextcloudSelectCandidate": "选择要检查的服务器笔记", "nextcloudCreateSeparate": "创建独立笔记", - "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。" + "nextcloudCreateSeparateWarning": "首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。", + "wsSemanticFormats": "语义格式", + "wsUiControl": "界面控件", + "wsFilePath": "文件 / 路径", + "wsUiPath": "界面路径", + "wsDefinitionList": "定义列表", + "wsIncludeContent": "包含可复用内容", + "wsVariableReference": "变量引用", + "wsConvertList": "将列表转换为过程", + "wsElementProperties": "元素属性", + "wsTopicProperties": "主题属性", + "wsCollapsible": "可折叠", + "wsPublishedState": "发布时的初始状态", + "wsExpanded": "展开", + "wsCollapsed": "折叠", + "wsCollapsedTitle": "折叠标题", + "wsTerm": "术语", + "wsListLayout": "列表布局", + "wsSyncGroup": "同步组", + "wsTabKey": "选项卡键", + "wsSwitcherLabel": "切换器标签", + "wsNavigationElements": "导航元素", + "wsNavigationDepth": "导航深度", + "wsAddItem": "添加项目", + "wsStep": "步骤", + "wsPropertyInvalid": "属性值无效", + "wsFilter": "筛选器", + "wsIdentifier": "元素 ID", + "wsTldr": "摘要 (TLDR)", + "wsFullLayout": "全宽", + "wsMediumLayout": "中等 (1:2)", + "wsNarrowLayout": "窄术语列", + "wsTitle": "标题", + "wsSwitcherKey": "切换键", + "nextcloudServerSettings": "服务器 Notes 设置", + "nextcloudNotesPath": "笔记文件夹路径", + "nextcloudFileSuffix": "新笔记的文件后缀", + "nextcloudServerSettingsExplanation": "这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。", + "nextcloudSettingsSaved": "服务器设置已保存", + "nextcloudSettingsNormalized": "已保存服务器规范化后的值", + "nextcloudPendingUpload": "附件等待上传", + "nextcloudServerCheck": "上次服务器检查:{time}", + "nextcloudSettingsLoading": "正在加载服务器设置", + "@nextcloudSettingsLoading": {"description": "Accessible progress label while reading server Notes settings."}, + "nextcloudSettingsSaving": "正在保存服务器设置", + "@nextcloudSettingsSaving": {"description": "Accessible progress label while writing server Notes settings."}, + "nextcloudPreviousLocalEdit": "先前的本地编辑", + "nextcloudUsePreviousEdit": "使用先前编辑", + "quickOpen": "快速打开", + "notesAll": "所有笔记", + "notesUncategorized": "未分类", + "notesRecovery": "恢复", + "notesRecentlyEdited": "最近编辑", + "notesOldestEdited": "最早编辑", + "notesTitleAscending": "标题升序", + "notesTitleDescending": "标题降序", + "notesMove": "移至分类", + "notesRemoveFavorite": "取消收藏", + "notesOffline": "设为离线可用", + "notesOfflineRemove": "移除离线保留要求", + "notesIndexing": "正在索引笔记…", + "notesSearchHelp": "词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。", + "notesSourceLocation": "源文件位置", + "notesRecoveryHelp": "仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。", + "notesImport": "导入笔记", + "notesExport": "导出笔记", + "notesImportDistinct": "导入为独立笔记", + "notesAvailableOffline": "离线可用", + "notesIncomplete": "不完整", + "notesSelected": "已选笔记", + "notesNoHistory": "没有保留的版本。历史可能已禁用、过期或清除。", + "notesTextAvailable": "文本已在本地可用", + "notesLocalVersion": "本地工作版本", + "notesAttachments": "附件", + "notesSkipped": "已跳过", + "notesFailed": "失败", + "notesCompleted": "已完成", + "notesImportNew": "开始新导入", + "notesImportResume": "继续中断的导入", + "notesImportAlreadyImported": "已在此操作中导入", + "references": "引用", + "referenceIncoming": "传入", + "referenceOutgoing": "传出", + "referenceChooseTarget": "选择链接目标", + "referenceWholeDocument": "整个文档", + "referenceSourceLocation": "源位置", + "referenceTargetLocation": "目标位置", + "referenceResolved": "已解析", + "referenceMissing": "缺失", + "referenceAmbiguous": "不明确", + "referenceUnavailable": "当前上下文中不可用", + "referenceUnsupported": "不支持", + "referenceNoPortableId": "此目标没有经过验证的可移植笔记网址。", + "wsIncludeLines": "包含行", + "wsIncludeSymbol": "包含符号", + "wsEmphasizeLines": "突出显示已显示的行", + "wsPropertiesReadOnly": "此视图中的属性为只读。", + "wsPropertiesProtected": "此元素受保护。请编辑其源代码。", + "wsPropertiesConflict": "文档或元素已更改。请还原草稿并重新选择。", + "wsPropertiesUnresolved": "目前无法解析此引用。", + "wsUseDefault": "使用默认值", + "wsPropertiesSelection": "请选择单个元素。", + "wsImageDisplay": "图像显示", + "wsImageThumbnail": "可点击缩略图", + "wsEnabled": "已启用", + "wsDisabled": "已禁用", + "wsWidth": "宽度", + "wsHeight": "高度", + "wsPropertiesInactive": "未启用发布折叠时,此属性无效。", + "wsNavigationAll": "所有有标题的元素", + "wsNavigationNone": "无导航", + "wsNavigationCustom": "自定义导航", + "wsTitleOverride": "覆盖标题", + "wsCompactLayout": "紧凑", + "wsWideLayout": "宽", + "wsCodeSourceConflict": "设置 src 前,请移走或删除此代码块中编写的代码。Writerside 不接受带有源引用的非空代码块。", + "wsInherited": "从列表继承", + "wsExternalImageSize": "Writerside 无法可靠地调整外部托管图片的尺寸。可以查看和移除已有尺寸。", + "wsPropertiesNotSerialized": "此更改未写入源文本。草稿已保留。", + "wsListItem": "列表项", + "wsSourceFile": "源文件", + "wsLinkTarget": "链接目标", + "wsPreviewImage": "预览图像", + "wsIncludedElement": "包含的元素", + "wsHelpModule": "帮助模块", + "wsActionId": "操作 ID", + "wsKeymapHelpInstance": "键位映射帮助实例", + "wsShowInSource": "在源代码中显示", + "wsOriginElement": "元素覆盖值", + "wsOriginAncestor": "父元素", + "wsOriginModule": "模块默认值", + "wsOriginBuiltIn": "内置默认值", + "wsOriginUnresolved": "未解析", + "wsEffectiveProperty": "有效值:{value} — {origin}", + "wsElementSelector": "元素", + "wsGeneratedAnchorValue": "生成值:{anchor}", + "wsDefaultProperty": "默认值:{value}", + "wsDefaultPropertyOrigin": "默认值:{value} · {origin}", + "wsResolvedProperty": "解析值:{value}", + "wsPropertyUnresolved": "未解析", + "wsModule": "模块", + "wsParent": "父元素", + "wsListType": "列表类型", + "wsStartingNumber": "起始编号", + "wsListSorting": "排序", + "wsLetteredList": "字母列表", + "wsCheckboxList": "复选框列表", + "wsMarkerlessList": "无标记列表", + "wsAscending": "升序", + "wsDescending": "降序", + "wsNoSorting": "不排序", + "wsAsWritten": "按原文", + "wsSentenceCase": "句首大写", + "wsTitleCase": "标题大写", + "wsLowerCase": "小写", + "wsUpperCase": "大写", + "wsNativeMarkers": "Markdown 标记", + "wsListMarkerStart": "起始标记:{number}", + "wsListLevel": "层级 {level}", + "wsModuleCapsIgnored": "固定版本的构建器会忽略模块的 <caps> 规则。请使用本地 caps 属性或 default-property。", + "wsInsertElements": "插入元素", + "wsAdmonitionsAndQuotes": "提示和引用", + "wsSemanticText": "语义文本", + "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5b1d05a6..3150b175 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -139,6 +139,174 @@ abstract class AppLocalizations { Locale('zh', 'CN'), ]; + /// No description provided for @wsModuleCapsNotPublished. + /// + /// In en, this message translates to: + /// **'Module capitalization is not published.'** + String get wsModuleCapsNotPublished; + + /// Opens the existing instance-specific and TOC title workflow, separate from the default title. + /// + /// In en, this message translates to: + /// **'Instance and TOC titles…'** + String get wsTitleOverridesAction; + + /// Compact property-specific publishing limitation; full explanation remains in contextual help. + /// + /// In en, this message translates to: + /// **'Not published by this builder.'** + String get wsHeadingNotPublished; + + /// Pinned-builder limitation shown in the existing instance/TOC title workflow. + /// + /// In en, this message translates to: + /// **'The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.'** + String get wsMarkdownInstanceTitleLimitation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Topic ID'** + String get wsTopicIdentity; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Element anchor'** + String get wsElementAnchor; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Heading text'** + String get wsHeadingText; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Default title'** + String get wsDefaultTitle; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Topic navigation'** + String get wsTopicNavigation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Heading attributes'** + String get wsHeadingOptions; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Document settings'** + String get wsDocumentOptions; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Authored settings present'** + String get wsSettingsPresent; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Overrides the published heading title; the heading text remains unchanged.'** + String get wsTitleOverrideExplanation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Treat variable text literally'** + String get wsIgnoreVariables; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'When enabled, variable-looking text is not substituted.'** + String get wsIgnoreVariablesExplanation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Help instances'** + String get wsInstanceSelection; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Select help outputs with names, groups, lists, exclusions, or expressions.'** + String get wsInstanceExplanation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Filter reusable content by criteria; this does not select a help instance.'** + String get wsFilterExplanation; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Context help ID'** + String get wsContextHelp; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Capitalization'** + String get wsCapitalization; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Library topic'** + String get wsLibraryTopic; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Last modified'** + String get wsLastModified; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'No topic H1 is authored. Add the topic heading in Source.'** + String get wsMissingTopicHeading; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'The first H1 owns the topic title. Later H1s are separate headings.'** + String get wsMultipleTopicHeadings; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'The authored root id differs from the filename-derived Topic ID. Use Rename topic file or inspect Source.'** + String get wsTopicIdentityMismatch; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Unsupported authored attributes are preserved. Inspect them in Source.'** + String get wsUnsupportedProperties; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'Generated anchor; setting a value authors an override.'** + String get wsGeneratedAnchor; + + /// Writerside inspector source ownership, applicability, or field explanation. + /// + /// In en, this message translates to: + /// **'The pinned builder does not publish topic H1 attributes: inline attributes render literally and separate-line attributes are ignored. These belong to the heading, not the XML root.'** + String get wsHeadingBuilderLimitation; + /// Document tab context menu action that closes every other document and comparison tab while retaining this document. /// /// In en, this message translates to: @@ -8117,6 +8285,972 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.'** String get nextcloudCreateSeparateWarning; + + /// No description provided for @wsSemanticFormats. + /// + /// In en, this message translates to: + /// **'Semantic formatting'** + String get wsSemanticFormats; + + /// No description provided for @wsUiControl. + /// + /// In en, this message translates to: + /// **'UI Control'** + String get wsUiControl; + + /// No description provided for @wsFilePath. + /// + /// In en, this message translates to: + /// **'File / Path'** + String get wsFilePath; + + /// No description provided for @wsUiPath. + /// + /// In en, this message translates to: + /// **'UI Path'** + String get wsUiPath; + + /// No description provided for @wsDefinitionList. + /// + /// In en, this message translates to: + /// **'Definition List'** + String get wsDefinitionList; + + /// No description provided for @wsIncludeContent. + /// + /// In en, this message translates to: + /// **'Include Reusable Content'** + String get wsIncludeContent; + + /// No description provided for @wsVariableReference. + /// + /// In en, this message translates to: + /// **'Variable Reference'** + String get wsVariableReference; + + /// No description provided for @wsConvertList. + /// + /// In en, this message translates to: + /// **'Convert List to Procedure'** + String get wsConvertList; + + /// No description provided for @wsElementProperties. + /// + /// In en, this message translates to: + /// **'Element Properties'** + String get wsElementProperties; + + /// No description provided for @wsTopicProperties. + /// + /// In en, this message translates to: + /// **'Topic Properties'** + String get wsTopicProperties; + + /// No description provided for @wsCollapsible. + /// + /// In en, this message translates to: + /// **'Collapsible'** + String get wsCollapsible; + + /// No description provided for @wsPublishedState. + /// + /// In en, this message translates to: + /// **'Published initial state'** + String get wsPublishedState; + + /// No description provided for @wsExpanded. + /// + /// In en, this message translates to: + /// **'Expanded'** + String get wsExpanded; + + /// No description provided for @wsCollapsed. + /// + /// In en, this message translates to: + /// **'Collapsed'** + String get wsCollapsed; + + /// No description provided for @wsCollapsedTitle. + /// + /// In en, this message translates to: + /// **'Collapsed title'** + String get wsCollapsedTitle; + + /// No description provided for @wsTerm. + /// + /// In en, this message translates to: + /// **'Term'** + String get wsTerm; + + /// No description provided for @wsListLayout. + /// + /// In en, this message translates to: + /// **'List layout'** + String get wsListLayout; + + /// No description provided for @wsSyncGroup. + /// + /// In en, this message translates to: + /// **'Synchronization group'** + String get wsSyncGroup; + + /// No description provided for @wsTabKey. + /// + /// In en, this message translates to: + /// **'Tab key'** + String get wsTabKey; + + /// No description provided for @wsSwitcherLabel. + /// + /// In en, this message translates to: + /// **'Switcher label'** + String get wsSwitcherLabel; + + /// No description provided for @wsNavigationElements. + /// + /// In en, this message translates to: + /// **'Navigation elements'** + String get wsNavigationElements; + + /// No description provided for @wsNavigationDepth. + /// + /// In en, this message translates to: + /// **'Navigation depth'** + String get wsNavigationDepth; + + /// No description provided for @wsAddItem. + /// + /// In en, this message translates to: + /// **'Add item'** + String get wsAddItem; + + /// No description provided for @wsStep. + /// + /// In en, this message translates to: + /// **'Step'** + String get wsStep; + + /// No description provided for @wsPropertyInvalid. + /// + /// In en, this message translates to: + /// **'Invalid property value'** + String get wsPropertyInvalid; + + /// No description provided for @wsFilter. + /// + /// In en, this message translates to: + /// **'Filter'** + String get wsFilter; + + /// No description provided for @wsIdentifier. + /// + /// In en, this message translates to: + /// **'Element ID'** + String get wsIdentifier; + + /// No description provided for @wsTldr. + /// + /// In en, this message translates to: + /// **'TLDR'** + String get wsTldr; + + /// No description provided for @wsFullLayout. + /// + /// In en, this message translates to: + /// **'Full width'** + String get wsFullLayout; + + /// No description provided for @wsMediumLayout. + /// + /// In en, this message translates to: + /// **'Medium (1:2)'** + String get wsMediumLayout; + + /// No description provided for @wsNarrowLayout. + /// + /// In en, this message translates to: + /// **'Narrow term'** + String get wsNarrowLayout; + + /// No description provided for @wsTitle. + /// + /// In en, this message translates to: + /// **'Title'** + String get wsTitle; + + /// No description provided for @wsSwitcherKey. + /// + /// In en, this message translates to: + /// **'Switcher key'** + String get wsSwitcherKey; + + /// No description provided for @nextcloudServerSettings. + /// + /// In en, this message translates to: + /// **'Server Notes settings'** + String get nextcloudServerSettings; + + /// No description provided for @nextcloudNotesPath. + /// + /// In en, this message translates to: + /// **'Notes folder path'** + String get nextcloudNotesPath; + + /// No description provided for @nextcloudFileSuffix. + /// + /// In en, this message translates to: + /// **'New-note file suffix'** + String get nextcloudFileSuffix; + + /// No description provided for @nextcloudServerSettingsExplanation. + /// + /// In en, this message translates to: + /// **'These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.'** + String get nextcloudServerSettingsExplanation; + + /// No description provided for @nextcloudSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings saved'** + String get nextcloudSettingsSaved; + + /// No description provided for @nextcloudSettingsNormalized. + /// + /// In en, this message translates to: + /// **'Saved with server-normalized values'** + String get nextcloudSettingsNormalized; + + /// No description provided for @nextcloudPendingUpload. + /// + /// In en, this message translates to: + /// **'Attachment upload pending'** + String get nextcloudPendingUpload; + + /// No description provided for @nextcloudServerCheck. + /// + /// In en, this message translates to: + /// **'Last server check: {time}'** + String nextcloudServerCheck(String time); + + /// Accessible progress label while reading server Notes settings. + /// + /// In en, this message translates to: + /// **'Loading server settings'** + String get nextcloudSettingsLoading; + + /// Accessible progress label while writing server Notes settings. + /// + /// In en, this message translates to: + /// **'Saving server settings'** + String get nextcloudSettingsSaving; + + /// Label for the competing accepted local metadata edit, distinct from server state. + /// + /// In en, this message translates to: + /// **'Previous local edit'** + String get nextcloudPreviousLocalEdit; + + /// Conflict action that keeps the exact previously reviewed local metadata values. + /// + /// In en, this message translates to: + /// **'Use previous edit'** + String get nextcloudUsePreviousEdit; + + /// No description provided for @quickOpen. + /// + /// In en, this message translates to: + /// **'Quick Open'** + String get quickOpen; + + /// No description provided for @notesAll. + /// + /// In en, this message translates to: + /// **'All notes'** + String get notesAll; + + /// No description provided for @notesUncategorized. + /// + /// In en, this message translates to: + /// **'Uncategorized'** + String get notesUncategorized; + + /// No description provided for @notesRecovery. + /// + /// In en, this message translates to: + /// **'Recovery'** + String get notesRecovery; + + /// No description provided for @notesRecentlyEdited. + /// + /// In en, this message translates to: + /// **'Recently edited'** + String get notesRecentlyEdited; + + /// No description provided for @notesOldestEdited. + /// + /// In en, this message translates to: + /// **'Oldest edited'** + String get notesOldestEdited; + + /// No description provided for @notesTitleAscending. + /// + /// In en, this message translates to: + /// **'Title A–Z'** + String get notesTitleAscending; + + /// No description provided for @notesTitleDescending. + /// + /// In en, this message translates to: + /// **'Title Z–A'** + String get notesTitleDescending; + + /// No description provided for @notesMove. + /// + /// In en, this message translates to: + /// **'Move to category'** + String get notesMove; + + /// No description provided for @notesRemoveFavorite. + /// + /// In en, this message translates to: + /// **'Remove favorite'** + String get notesRemoveFavorite; + + /// No description provided for @notesOffline. + /// + /// In en, this message translates to: + /// **'Make available offline'** + String get notesOffline; + + /// No description provided for @notesOfflineRemove. + /// + /// In en, this message translates to: + /// **'Remove offline requirement'** + String get notesOfflineRemove; + + /// No description provided for @notesIndexing. + /// + /// In en, this message translates to: + /// **'Indexing notes…'** + String get notesIndexing; + + /// No description provided for @notesSearchHelp. + /// + /// In en, this message translates to: + /// **'Terms use AND. Use \"quoted phrases\", title: and category:.'** + String get notesSearchHelp; + + /// No description provided for @notesSourceLocation. + /// + /// In en, this message translates to: + /// **'Source location'** + String get notesSourceLocation; + + /// No description provided for @notesRecoveryHelp. + /// + /// In en, this message translates to: + /// **'Retained local notes and Local History only. Server trash and history are unavailable.'** + String get notesRecoveryHelp; + + /// No description provided for @notesImport. + /// + /// In en, this message translates to: + /// **'Import notes'** + String get notesImport; + + /// No description provided for @notesExport. + /// + /// In en, this message translates to: + /// **'Export notes'** + String get notesExport; + + /// No description provided for @notesImportDistinct. + /// + /// In en, this message translates to: + /// **'Import as a distinct note'** + String get notesImportDistinct; + + /// No description provided for @notesAvailableOffline. + /// + /// In en, this message translates to: + /// **'Available offline'** + String get notesAvailableOffline; + + /// No description provided for @notesIncomplete. + /// + /// In en, this message translates to: + /// **'Incomplete'** + String get notesIncomplete; + + /// No description provided for @notesSelected. + /// + /// In en, this message translates to: + /// **'Selected notes'** + String get notesSelected; + + /// No description provided for @notesNoHistory. + /// + /// In en, this message translates to: + /// **'No retained revisions. History may be disabled, expired or cleared.'** + String get notesNoHistory; + + /// No description provided for @notesTextAvailable. + /// + /// In en, this message translates to: + /// **'Text available locally'** + String get notesTextAvailable; + + /// No description provided for @notesLocalVersion. + /// + /// In en, this message translates to: + /// **'Local working version'** + String get notesLocalVersion; + + /// No description provided for @notesAttachments. + /// + /// In en, this message translates to: + /// **'Attachments'** + String get notesAttachments; + + /// No description provided for @notesSkipped. + /// + /// In en, this message translates to: + /// **'Skipped'** + String get notesSkipped; + + /// No description provided for @notesFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get notesFailed; + + /// No description provided for @notesCompleted. + /// + /// In en, this message translates to: + /// **'Completed'** + String get notesCompleted; + + /// No description provided for @notesImportNew. + /// + /// In en, this message translates to: + /// **'Start a new import'** + String get notesImportNew; + + /// No description provided for @notesImportResume. + /// + /// In en, this message translates to: + /// **'Resume interrupted import'** + String get notesImportResume; + + /// No description provided for @notesImportAlreadyImported. + /// + /// In en, this message translates to: + /// **'Already imported in this operation'** + String get notesImportAlreadyImported; + + /// No description provided for @references. + /// + /// In en, this message translates to: + /// **'References'** + String get references; + + /// No description provided for @referenceIncoming. + /// + /// In en, this message translates to: + /// **'Incoming'** + String get referenceIncoming; + + /// No description provided for @referenceOutgoing. + /// + /// In en, this message translates to: + /// **'Outgoing'** + String get referenceOutgoing; + + /// No description provided for @referenceChooseTarget. + /// + /// In en, this message translates to: + /// **'Choose link target'** + String get referenceChooseTarget; + + /// No description provided for @referenceWholeDocument. + /// + /// In en, this message translates to: + /// **'Whole document'** + String get referenceWholeDocument; + + /// No description provided for @referenceSourceLocation. + /// + /// In en, this message translates to: + /// **'Source location'** + String get referenceSourceLocation; + + /// No description provided for @referenceTargetLocation. + /// + /// In en, this message translates to: + /// **'Target location'** + String get referenceTargetLocation; + + /// No description provided for @referenceResolved. + /// + /// In en, this message translates to: + /// **'Resolved'** + String get referenceResolved; + + /// No description provided for @referenceMissing. + /// + /// In en, this message translates to: + /// **'Missing'** + String get referenceMissing; + + /// No description provided for @referenceAmbiguous. + /// + /// In en, this message translates to: + /// **'Ambiguous'** + String get referenceAmbiguous; + + /// No description provided for @referenceUnavailable. + /// + /// In en, this message translates to: + /// **'Unavailable in this context'** + String get referenceUnavailable; + + /// No description provided for @referenceUnsupported. + /// + /// In en, this message translates to: + /// **'Unsupported'** + String get referenceUnsupported; + + /// No description provided for @referenceNoPortableId. + /// + /// In en, this message translates to: + /// **'This target has no verified portable note URL.'** + String get referenceNoPortableId; + + /// No description provided for @wsIncludeLines. + /// + /// In en, this message translates to: + /// **'Include lines'** + String get wsIncludeLines; + + /// No description provided for @wsIncludeSymbol. + /// + /// In en, this message translates to: + /// **'Include symbol'** + String get wsIncludeSymbol; + + /// No description provided for @wsEmphasizeLines. + /// + /// In en, this message translates to: + /// **'Emphasize displayed lines'** + String get wsEmphasizeLines; + + /// No description provided for @wsPropertiesReadOnly. + /// + /// In en, this message translates to: + /// **'Properties are read-only in this view.'** + String get wsPropertiesReadOnly; + + /// No description provided for @wsPropertiesProtected. + /// + /// In en, this message translates to: + /// **'This element is protected. Edit its source to change properties.'** + String get wsPropertiesProtected; + + /// No description provided for @wsPropertiesConflict. + /// + /// In en, this message translates to: + /// **'The document or element changed. Revert this draft and inspect the current element.'** + String get wsPropertiesConflict; + + /// No description provided for @wsPropertiesUnresolved. + /// + /// In en, this message translates to: + /// **'The reference cannot currently be resolved.'** + String get wsPropertiesUnresolved; + + /// No description provided for @wsUseDefault. + /// + /// In en, this message translates to: + /// **'Use default'** + String get wsUseDefault; + + /// No description provided for @wsPropertiesSelection. + /// + /// In en, this message translates to: + /// **'Select a single element to inspect its properties.'** + String get wsPropertiesSelection; + + /// No description provided for @wsImageDisplay. + /// + /// In en, this message translates to: + /// **'Image display'** + String get wsImageDisplay; + + /// No description provided for @wsImageThumbnail. + /// + /// In en, this message translates to: + /// **'Clickable thumbnail'** + String get wsImageThumbnail; + + /// No description provided for @wsEnabled. + /// + /// In en, this message translates to: + /// **'Enabled'** + String get wsEnabled; + + /// No description provided for @wsDisabled. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get wsDisabled; + + /// No description provided for @wsWidth. + /// + /// In en, this message translates to: + /// **'Width'** + String get wsWidth; + + /// No description provided for @wsHeight. + /// + /// In en, this message translates to: + /// **'Height'** + String get wsHeight; + + /// No description provided for @wsPropertiesInactive. + /// + /// In en, this message translates to: + /// **'This property has no effect while published collapse is disabled.'** + String get wsPropertiesInactive; + + /// No description provided for @wsNavigationAll. + /// + /// In en, this message translates to: + /// **'All titled elements'** + String get wsNavigationAll; + + /// No description provided for @wsNavigationNone. + /// + /// In en, this message translates to: + /// **'No navigation'** + String get wsNavigationNone; + + /// No description provided for @wsNavigationCustom. + /// + /// In en, this message translates to: + /// **'Custom navigation'** + String get wsNavigationCustom; + + /// No description provided for @wsTitleOverride. + /// + /// In en, this message translates to: + /// **'Title override'** + String get wsTitleOverride; + + /// No description provided for @wsCompactLayout. + /// + /// In en, this message translates to: + /// **'Compact'** + String get wsCompactLayout; + + /// No description provided for @wsWideLayout. + /// + /// In en, this message translates to: + /// **'Wide'** + String get wsWideLayout; + + /// No description provided for @wsCodeSourceConflict. + /// + /// In en, this message translates to: + /// **'Move or remove the code authored in this block before setting src. Writerside rejects a nonempty code block with a source reference.'** + String get wsCodeSourceConflict; + + /// No description provided for @wsInherited. + /// + /// In en, this message translates to: + /// **'Inherit from list'** + String get wsInherited; + + /// No description provided for @wsExternalImageSize. + /// + /// In en, this message translates to: + /// **'Writerside does not reliably size externally hosted images. Existing dimensions can be inspected and removed.'** + String get wsExternalImageSize; + + /// No description provided for @wsPropertiesNotSerialized. + /// + /// In en, this message translates to: + /// **'This change did not reach the source. The draft has been retained.'** + String get wsPropertiesNotSerialized; + + /// No description provided for @wsListItem. + /// + /// In en, this message translates to: + /// **'List item'** + String get wsListItem; + + /// No description provided for @wsSourceFile. + /// + /// In en, this message translates to: + /// **'Source file'** + String get wsSourceFile; + + /// No description provided for @wsLinkTarget. + /// + /// In en, this message translates to: + /// **'Link target'** + String get wsLinkTarget; + + /// No description provided for @wsPreviewImage. + /// + /// In en, this message translates to: + /// **'Preview image'** + String get wsPreviewImage; + + /// No description provided for @wsIncludedElement. + /// + /// In en, this message translates to: + /// **'Included element'** + String get wsIncludedElement; + + /// No description provided for @wsHelpModule. + /// + /// In en, this message translates to: + /// **'Help module'** + String get wsHelpModule; + + /// No description provided for @wsActionId. + /// + /// In en, this message translates to: + /// **'Action ID'** + String get wsActionId; + + /// No description provided for @wsKeymapHelpInstance. + /// + /// In en, this message translates to: + /// **'Keymap help instance'** + String get wsKeymapHelpInstance; + + /// No description provided for @wsShowInSource. + /// + /// In en, this message translates to: + /// **'Show in Source'** + String get wsShowInSource; + + /// No description provided for @wsOriginElement. + /// + /// In en, this message translates to: + /// **'element override'** + String get wsOriginElement; + + /// No description provided for @wsOriginAncestor. + /// + /// In en, this message translates to: + /// **'parent element'** + String get wsOriginAncestor; + + /// No description provided for @wsOriginModule. + /// + /// In en, this message translates to: + /// **'module default'** + String get wsOriginModule; + + /// No description provided for @wsOriginBuiltIn. + /// + /// In en, this message translates to: + /// **'built-in default'** + String get wsOriginBuiltIn; + + /// No description provided for @wsOriginUnresolved. + /// + /// In en, this message translates to: + /// **'unresolved'** + String get wsOriginUnresolved; + + /// Effective Writerside property value and its localized origin. Attribute names are displayed separately without translation. + /// + /// In en, this message translates to: + /// **'Effective: {value} — {origin}'** + String wsEffectiveProperty(String value, String origin); + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Element'** + String get wsElementSelector; + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Generated: {anchor}'** + String wsGeneratedAnchorValue(String anchor); + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Default: {value}'** + String wsDefaultProperty(String value); + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Default: {value} · {origin}'** + String wsDefaultPropertyOrigin(String value, String origin); + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Resolved: {value}'** + String wsResolvedProperty(String value); + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Unresolved'** + String get wsPropertyUnresolved; + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Module'** + String get wsModule; + + /// Compact Element Properties control label or supporting value; source ownership and default resolution are unchanged. + /// + /// In en, this message translates to: + /// **'Parent'** + String get wsParent; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'List type'** + String get wsListType; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Starting number'** + String get wsStartingNumber; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Sorting'** + String get wsListSorting; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Lettered list'** + String get wsLetteredList; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Checkbox list'** + String get wsCheckboxList; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'List without markers'** + String get wsMarkerlessList; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Ascending'** + String get wsAscending; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Descending'** + String get wsDescending; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'No sorting'** + String get wsNoSorting; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'As written'** + String get wsAsWritten; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Sentence case'** + String get wsSentenceCase; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Title case'** + String get wsTitleCase; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Lowercase'** + String get wsLowerCase; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Uppercase'** + String get wsUpperCase; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Markdown markers'** + String get wsNativeMarkers; + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Marker start: {number}'** + String wsListMarkerStart(String number); + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'Level {level}'** + String wsListLevel(String level); + + /// Writerside list ownership or verified capitalization presentation. + /// + /// In en, this message translates to: + /// **'The pinned builder ignores module <caps> rules. Use a local caps override or default-property.'** + String get wsModuleCapsIgnored; + + /// No description provided for @wsInsertElements. + /// + /// In en, this message translates to: + /// **'Insert elements'** + String get wsInsertElements; + + /// No description provided for @wsAdmonitionsAndQuotes. + /// + /// In en, this message translates to: + /// **'Admonitions and quotes'** + String get wsAdmonitionsAndQuotes; + + /// No description provided for @wsSemanticText. + /// + /// In en, this message translates to: + /// **'Semantic text'** + String get wsSemanticText; + + /// No description provided for @wsPropertiesUnavailable. + /// + /// In en, this message translates to: + /// **'The inspected element is unavailable. Inspect an element to continue.'** + String get wsPropertiesUnavailable; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 6a9cc5f3..97db9500 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -10,6 +10,99 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get wsModuleCapsNotPublished => 'لا يُنشر تنسيق أحرف الوحدة.'; + + @override + String get wsTitleOverridesAction => 'عناوين المثيل وجدول المحتويات…'; + + @override + String get wsHeadingNotPublished => 'لا ينشرها هذا المنشئ.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'لا ينشر الباني المثبّت عناوين النسخ في مواضيع Markdown. تُحفظ العلامات الحالية؛ افحصها في المصدر.'; + + @override + String get wsTopicIdentity => 'معرّف الموضوع'; + + @override + String get wsElementAnchor => 'مرساة العنصر'; + + @override + String get wsHeadingText => 'نص العنوان'; + + @override + String get wsDefaultTitle => 'العنوان الافتراضي'; + + @override + String get wsTopicNavigation => 'تنقّل الموضوع'; + + @override + String get wsHeadingOptions => 'سمات العنوان'; + + @override + String get wsDocumentOptions => 'إعدادات المستند'; + + @override + String get wsSettingsPresent => 'توجد إعدادات مكتوبة'; + + @override + String get wsTitleOverrideExplanation => + 'يتجاوز العنوان المنشور؛ يبقى نص العنوان كما هو.'; + + @override + String get wsIgnoreVariables => 'معاملة المتغيرات كنص حرفي'; + + @override + String get wsIgnoreVariablesExplanation => + 'عند التفعيل، لا يُستبدل النص الذي يشبه المتغيرات.'; + + @override + String get wsInstanceSelection => 'نُسخ المساعدة'; + + @override + String get wsInstanceExplanation => + 'اختر المخرجات بالأسماء أو المجموعات أو القوائم أو الاستثناءات أو التعبيرات.'; + + @override + String get wsFilterExplanation => + 'يرشّح المحتوى المعاد استخدامه حسب المعايير؛ لا يختار نسخة مساعدة.'; + + @override + String get wsContextHelp => 'معرّف المساعدة السياقية'; + + @override + String get wsCapitalization => 'حالة الأحرف'; + + @override + String get wsLibraryTopic => 'موضوع مكتبة'; + + @override + String get wsLastModified => 'آخر تعديل'; + + @override + String get wsMissingTopicHeading => 'لا يوجد H1 للموضوع. أضفه في المصدر.'; + + @override + String get wsMultipleTopicHeadings => + 'يحدّد أول H1 عنوان الموضوع. عناوين H1 اللاحقة مستقلة.'; + + @override + String get wsTopicIdentityMismatch => + 'يختلف id الجذر عن معرّف الموضوع المشتق من اسم الملف. استخدم إعادة تسمية ملف الموضوع أو افحص المصدر.'; + + @override + String get wsUnsupportedProperties => + 'تُحفظ السمات غير المدعومة. افحصها في المصدر.'; + + @override + String get wsGeneratedAnchor => 'مرساة مولّدة؛ إدخال قيمة ينشئ تجاوزًا.'; + + @override + String get wsHeadingBuilderLimitation => + 'لا ينشر الباني المثبّت سمات H1. تخص العنوان وليس جذر XML.'; + @override String get closeOtherTabs => 'إغلاق علامات التبويب الأخرى'; @@ -4818,4 +4911,516 @@ class AppLocalizationsAr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'قد يكون الطلب الأول قد أنشأ ملاحظة على الخادم بالفعل. ينشئ هذا ملاحظة منفصلة بعملك المحلي المحفوظ. لن تُحذف أي ملاحظة يُشتبه بأنها مكررة.'; + + @override + String get wsSemanticFormats => 'تنسيق دلالي'; + + @override + String get wsUiControl => 'عنصر تحكم في الواجهة'; + + @override + String get wsFilePath => 'ملف / مسار'; + + @override + String get wsUiPath => 'مسار الواجهة'; + + @override + String get wsDefinitionList => 'قائمة تعريفات'; + + @override + String get wsIncludeContent => 'تضمين محتوى قابل لإعادة الاستخدام'; + + @override + String get wsVariableReference => 'مرجع متغير'; + + @override + String get wsConvertList => 'تحويل القائمة إلى إجراء'; + + @override + String get wsElementProperties => 'خصائص العنصر'; + + @override + String get wsTopicProperties => 'خصائص الموضوع'; + + @override + String get wsCollapsible => 'قابل للطي'; + + @override + String get wsPublishedState => 'الحالة الأولية عند النشر'; + + @override + String get wsExpanded => 'موسع'; + + @override + String get wsCollapsed => 'مطوي'; + + @override + String get wsCollapsedTitle => 'العنوان عند الطي'; + + @override + String get wsTerm => 'المصطلح'; + + @override + String get wsListLayout => 'تخطيط القائمة'; + + @override + String get wsSyncGroup => 'مجموعة المزامنة'; + + @override + String get wsTabKey => 'مفتاح علامة التبويب'; + + @override + String get wsSwitcherLabel => 'تسمية المبدل'; + + @override + String get wsNavigationElements => 'عناصر التنقل'; + + @override + String get wsNavigationDepth => 'عمق التنقل'; + + @override + String get wsAddItem => 'إضافة عنصر'; + + @override + String get wsStep => 'خطوة'; + + @override + String get wsPropertyInvalid => 'قيمة خاصية غير صالحة'; + + @override + String get wsFilter => 'عامل التصفية'; + + @override + String get wsIdentifier => 'معرّف العنصر'; + + @override + String get wsTldr => 'ملخّص (TLDR)'; + + @override + String get wsFullLayout => 'عرض كامل'; + + @override + String get wsMediumLayout => 'متوسط (1:2)'; + + @override + String get wsNarrowLayout => 'مصطلح ضيق'; + + @override + String get wsTitle => 'العنوان'; + + @override + String get wsSwitcherKey => 'مفتاح المبدّل'; + + @override + String get nextcloudServerSettings => 'إعدادات Notes على الخادم'; + + @override + String get nextcloudNotesPath => 'مسار مجلد الملاحظات'; + + @override + String get nextcloudFileSuffix => 'لاحقة ملفات الملاحظات الجديدة'; + + @override + String get nextcloudServerSettingsExplanation => + 'تؤثر إعدادات Notes هذه على العملاء الآخرين. تغيير المسار لا ينقل الملفات؛ وتغيير اللاحقة لا يعيد تسمية الملاحظات الحالية.'; + + @override + String get nextcloudSettingsSaved => 'حُفظت إعدادات الخادم'; + + @override + String get nextcloudSettingsNormalized => + 'حُفظت القيم بعد توحيدها على الخادم'; + + @override + String get nextcloudPendingUpload => 'رفع المرفق قيد الانتظار'; + + @override + String nextcloudServerCheck(String time) { + return 'آخر فحص للخادم: $time'; + } + + @override + String get nextcloudSettingsLoading => 'جارٍ تحميل إعدادات الخادم'; + + @override + String get nextcloudSettingsSaving => 'جارٍ حفظ إعدادات الخادم'; + + @override + String get nextcloudPreviousLocalEdit => 'التعديل المحلي السابق'; + + @override + String get nextcloudUsePreviousEdit => 'استخدام التعديل السابق'; + + @override + String get quickOpen => 'فتح سريع'; + + @override + String get notesAll => 'كل الملاحظات'; + + @override + String get notesUncategorized => 'بلا تصنيف'; + + @override + String get notesRecovery => 'استعادة'; + + @override + String get notesRecentlyEdited => 'المعدلة مؤخراً'; + + @override + String get notesOldestEdited => 'الأقدم تعديلاً'; + + @override + String get notesTitleAscending => 'العنوان تصاعدياً'; + + @override + String get notesTitleDescending => 'العنوان تنازلياً'; + + @override + String get notesMove => 'نقل إلى تصنيف'; + + @override + String get notesRemoveFavorite => 'إزالة من المفضلة'; + + @override + String get notesOffline => 'إتاحة دون اتصال'; + + @override + String get notesOfflineRemove => 'إزالة الاحتفاظ دون اتصال'; + + @override + String get notesIndexing => 'جارٍ فهرسة الملاحظات…'; + + @override + String get notesSearchHelp => + 'تُجمع الكلمات باستخدام AND. استخدم \"عبارات مقتبسة\" و title: و category:.'; + + @override + String get notesSourceLocation => 'الموضع في المصدر'; + + @override + String get notesRecoveryHelp => + 'الملاحظات المحلية المحتفظ بها والسجل المحلي فقط. سلة المحذوفات وسجل الخادم غير متاحين.'; + + @override + String get notesImport => 'استيراد ملاحظات'; + + @override + String get notesExport => 'تصدير ملاحظات'; + + @override + String get notesImportDistinct => 'استيراد كملاحظة مستقلة'; + + @override + String get notesAvailableOffline => 'متاح دون اتصال'; + + @override + String get notesIncomplete => 'غير مكتمل'; + + @override + String get notesSelected => 'الملاحظات المحددة'; + + @override + String get notesNoHistory => + 'لا توجد مراجعات محفوظة. قد يكون السجل معطلاً أو منتهياً أو ممسوحاً.'; + + @override + String get notesTextAvailable => 'النص متاح محليًا'; + + @override + String get notesLocalVersion => 'نسخة العمل المحلية'; + + @override + String get notesAttachments => 'المرفقات'; + + @override + String get notesSkipped => 'تم التخطي'; + + @override + String get notesFailed => 'فشل'; + + @override + String get notesCompleted => 'مكتمل'; + + @override + String get notesImportNew => 'بدء استيراد جديد'; + + @override + String get notesImportResume => 'استئناف الاستيراد المنقطع'; + + @override + String get notesImportAlreadyImported => 'تم الاستيراد بالفعل في هذه العملية'; + + @override + String get references => 'المراجع'; + + @override + String get referenceIncoming => 'الواردة'; + + @override + String get referenceOutgoing => 'الصادرة'; + + @override + String get referenceChooseTarget => 'اختر هدف الرابط'; + + @override + String get referenceWholeDocument => 'المستند بالكامل'; + + @override + String get referenceSourceLocation => 'موقع المصدر'; + + @override + String get referenceTargetLocation => 'موقع الهدف'; + + @override + String get referenceResolved => 'تم الحل'; + + @override + String get referenceMissing => 'مفقود'; + + @override + String get referenceAmbiguous => 'ملتبس'; + + @override + String get referenceUnavailable => 'غير متاح في هذا السياق'; + + @override + String get referenceUnsupported => 'غير مدعوم'; + + @override + String get referenceNoPortableId => + 'ليس لهذا الهدف عنوان URL محمول ومُتحقَّق منه للملاحظة.'; + + @override + String get wsIncludeLines => 'تضمين الأسطر'; + + @override + String get wsIncludeSymbol => 'تضمين الرمز'; + + @override + String get wsEmphasizeLines => 'تمييز الأسطر المعروضة'; + + @override + String get wsPropertiesReadOnly => 'الخصائص للقراءة فقط في هذا العرض.'; + + @override + String get wsPropertiesProtected => 'هذا العنصر محمي. حرر مصدره.'; + + @override + String get wsPropertiesConflict => + 'تغير المستند أو العنصر. تراجع عن المسودة وحدد العنصر مجدداً.'; + + @override + String get wsPropertiesUnresolved => 'لا يمكن حل المرجع حالياً.'; + + @override + String get wsUseDefault => 'استخدام الافتراضي'; + + @override + String get wsPropertiesSelection => 'حدد عنصراً واحداً.'; + + @override + String get wsImageDisplay => 'عرض الصورة'; + + @override + String get wsImageThumbnail => 'صورة مصغرة قابلة للنقر'; + + @override + String get wsEnabled => 'مفعّل'; + + @override + String get wsDisabled => 'معطّل'; + + @override + String get wsWidth => 'العرض'; + + @override + String get wsHeight => 'الارتفاع'; + + @override + String get wsPropertiesInactive => + 'لا تؤثر هذه الخاصية إلا عند تفعيل الطي المنشور.'; + + @override + String get wsNavigationAll => 'كل العناصر ذات العناوين'; + + @override + String get wsNavigationNone => 'بلا تنقل'; + + @override + String get wsNavigationCustom => 'تنقل مخصص'; + + @override + String get wsTitleOverride => 'تجاوز العنوان'; + + @override + String get wsCompactLayout => 'مضغوط'; + + @override + String get wsWideLayout => 'عريض'; + + @override + String get wsCodeSourceConflict => + 'انقل أو أزل الشفرة المكتوبة في هذه الكتلة قبل تعيين src. يرفض Writerside كتلة شفرة غير فارغة لها مرجع مصدر.'; + + @override + String get wsInherited => 'وراثة من القائمة'; + + @override + String get wsExternalImageSize => + 'لا يحدد Writerside أبعاد الصور الخارجية بشكل موثوق. يمكنك فحص الأبعاد الحالية وإزالتها.'; + + @override + String get wsPropertiesNotSerialized => + 'لم يُطبّق هذا التغيير على المصدر. تم الاحتفاظ بالمسودة.'; + + @override + String get wsListItem => 'عنصر قائمة'; + + @override + String get wsSourceFile => 'ملف المصدر'; + + @override + String get wsLinkTarget => 'وجهة الرابط'; + + @override + String get wsPreviewImage => 'صورة المعاينة'; + + @override + String get wsIncludedElement => 'العنصر المُضمَّن'; + + @override + String get wsHelpModule => 'وحدة المساعدة'; + + @override + String get wsActionId => 'معرّف الإجراء'; + + @override + String get wsKeymapHelpInstance => 'نسخة مساعدة خريطة المفاتيح'; + + @override + String get wsShowInSource => 'إظهار في المصدر'; + + @override + String get wsOriginElement => 'تجاوز العنصر'; + + @override + String get wsOriginAncestor => 'العنصر الأب'; + + @override + String get wsOriginModule => 'القيمة الافتراضية للوحدة'; + + @override + String get wsOriginBuiltIn => 'القيمة الافتراضية المضمّنة'; + + @override + String get wsOriginUnresolved => 'غير محلول'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'القيمة الفعلية: $value — $origin'; + } + + @override + String get wsElementSelector => 'العنصر'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'مُولَّد: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'الافتراضي: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'الافتراضي: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'القيمة المحلولة: $value'; + } + + @override + String get wsPropertyUnresolved => 'غير محلول'; + + @override + String get wsModule => 'الوحدة'; + + @override + String get wsParent => 'العنصر الأب'; + + @override + String get wsListType => 'نوع القائمة'; + + @override + String get wsStartingNumber => 'رقم البداية'; + + @override + String get wsListSorting => 'الفرز'; + + @override + String get wsLetteredList => 'قائمة حرفية'; + + @override + String get wsCheckboxList => 'قائمة مربعات اختيار'; + + @override + String get wsMarkerlessList => 'قائمة دون علامات'; + + @override + String get wsAscending => 'تصاعدي'; + + @override + String get wsDescending => 'تنازلي'; + + @override + String get wsNoSorting => 'دون فرز'; + + @override + String get wsAsWritten => 'كما كُتب'; + + @override + String get wsSentenceCase => 'حالة أحرف الجملة'; + + @override + String get wsTitleCase => 'حالة أحرف العنوان'; + + @override + String get wsLowerCase => 'أحرف صغيرة'; + + @override + String get wsUpperCase => 'أحرف كبيرة'; + + @override + String get wsNativeMarkers => 'علامات Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'علامة البداية: $number'; + } + + @override + String wsListLevel(String level) { + return 'المستوى $level'; + } + + @override + String get wsModuleCapsIgnored => + 'يتجاهل الباني المثبّت قواعد <caps> للوحدة. استخدم سمة caps محلية أو default-property.'; + + @override + String get wsInsertElements => 'إدراج عناصر'; + + @override + String get wsAdmonitionsAndQuotes => 'تنبيهات واقتباسات'; + + @override + String get wsSemanticText => 'نص دلالي'; + + @override + String get wsPropertiesUnavailable => + 'العنصر الذي يتم فحصه غير متاح. افحص عنصرًا للمتابعة.'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 81c2afce..721df893 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -10,6 +10,103 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Modul-Großschreibung wird nicht veröffentlicht.'; + + @override + String get wsTitleOverridesAction => 'Instanz- und Inhaltsverzeichnistitel…'; + + @override + String get wsHeadingNotPublished => + 'Wird von diesem Builder nicht veröffentlicht.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Der festgelegte Builder veröffentlicht keine instanzspezifischen Titel in Markdown-Themen. Bestehendes Markup bleibt erhalten; prüfen Sie es im Quelltext.'; + + @override + String get wsTopicIdentity => 'Themen-ID'; + + @override + String get wsElementAnchor => 'Elementanker'; + + @override + String get wsHeadingText => 'Überschriftentext'; + + @override + String get wsDefaultTitle => 'Standardtitel'; + + @override + String get wsTopicNavigation => 'Themennavigation'; + + @override + String get wsHeadingOptions => 'Überschriftenattribute'; + + @override + String get wsDocumentOptions => 'Dokumenteinstellungen'; + + @override + String get wsSettingsPresent => 'Explizite Einstellungen vorhanden'; + + @override + String get wsTitleOverrideExplanation => + 'Überschreibt den veröffentlichten Titel; der Überschriftentext bleibt erhalten.'; + + @override + String get wsIgnoreVariables => 'Variablentext wörtlich behandeln'; + + @override + String get wsIgnoreVariablesExplanation => + 'Bei Aktivierung wird variablenähnlicher Text nicht ersetzt.'; + + @override + String get wsInstanceSelection => 'Hilfeinstanzen'; + + @override + String get wsInstanceExplanation => + 'Hilfeausgaben über Namen, Gruppen, Listen, Ausschlüsse oder Ausdrücke auswählen.'; + + @override + String get wsFilterExplanation => + 'Wiederverwendbare Inhalte nach Kriterien filtern; dies wählt keine Hilfeinstanz aus.'; + + @override + String get wsContextHelp => 'Kontexthilfe-ID'; + + @override + String get wsCapitalization => 'Groß- und Kleinschreibung'; + + @override + String get wsLibraryTopic => 'Bibliotheksthema'; + + @override + String get wsLastModified => 'Zuletzt geändert'; + + @override + String get wsMissingTopicHeading => + 'Keine Themenüberschrift H1 vorhanden. Fügen Sie sie im Quelltext hinzu.'; + + @override + String get wsMultipleTopicHeadings => + 'Die erste H1 bestimmt den Thementitel. Weitere H1 sind eigene Überschriften.'; + + @override + String get wsTopicIdentityMismatch => + 'Die Root-ID weicht von der dateibasierten Themen-ID ab. Verwenden Sie Thema umbenennen oder den Quelltext.'; + + @override + String get wsUnsupportedProperties => + 'Nicht unterstützte Attribute bleiben erhalten. Prüfen Sie sie im Quelltext.'; + + @override + String get wsGeneratedAnchor => + 'Generierter Anker; ein eingegebener Wert erstellt eine Überschreibung.'; + + @override + String get wsHeadingBuilderLimitation => + 'Der festgelegte Builder veröffentlicht keine H1-Attribute. Sie gehören zur Überschrift, nicht zur XML-Root.'; + @override String get closeOtherTabs => 'Andere Tabs schließen'; @@ -4831,4 +4928,521 @@ class AppLocalizationsDe extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Die erste Anfrage hat möglicherweise bereits eine Servernotiz erstellt. Dies erstellt eine separate Notiz mit Ihrer behaltenen lokalen Arbeit. Vermutete Duplikate werden nicht gelöscht.'; + + @override + String get wsSemanticFormats => 'Semantische Formatierung'; + + @override + String get wsUiControl => 'UI-Steuerelement'; + + @override + String get wsFilePath => 'Datei / Pfad'; + + @override + String get wsUiPath => 'UI-Pfad'; + + @override + String get wsDefinitionList => 'Definitionsliste'; + + @override + String get wsIncludeContent => 'Wiederverwendbaren Inhalt einbinden'; + + @override + String get wsVariableReference => 'Variablenreferenz'; + + @override + String get wsConvertList => 'Liste in Prozedur umwandeln'; + + @override + String get wsElementProperties => 'Elementeigenschaften'; + + @override + String get wsTopicProperties => 'Themeneigenschaften'; + + @override + String get wsCollapsible => 'Einklappbar'; + + @override + String get wsPublishedState => 'Anfangszustand bei Veröffentlichung'; + + @override + String get wsExpanded => 'Ausgeklappt'; + + @override + String get wsCollapsed => 'Eingeklappt'; + + @override + String get wsCollapsedTitle => 'Titel im eingeklappten Zustand'; + + @override + String get wsTerm => 'Begriff'; + + @override + String get wsListLayout => 'Listenlayout'; + + @override + String get wsSyncGroup => 'Synchronisierungsgruppe'; + + @override + String get wsTabKey => 'Registerschlüssel'; + + @override + String get wsSwitcherLabel => 'Beschriftung des Umschalters'; + + @override + String get wsNavigationElements => 'Navigationselemente'; + + @override + String get wsNavigationDepth => 'Navigationstiefe'; + + @override + String get wsAddItem => 'Element hinzufügen'; + + @override + String get wsStep => 'Schritt'; + + @override + String get wsPropertyInvalid => 'Ungültiger Eigenschaftswert'; + + @override + String get wsFilter => 'Inhaltsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Kurzfassung (TLDR)'; + + @override + String get wsFullLayout => 'Volle Breite'; + + @override + String get wsMediumLayout => 'Mittel (1:2)'; + + @override + String get wsNarrowLayout => 'Schmale Begriffsspalte'; + + @override + String get wsTitle => 'Titel'; + + @override + String get wsSwitcherKey => 'Umschaltschlüssel'; + + @override + String get nextcloudServerSettings => 'Notes-Einstellungen auf dem Server'; + + @override + String get nextcloudNotesPath => 'Pfad des Notizordners'; + + @override + String get nextcloudFileSuffix => 'Dateiendung neuer Notizen'; + + @override + String get nextcloudServerSettingsExplanation => + 'Diese Notes-Einstellungen gelten auch für andere Clients. Ein Pfadwechsel verschiebt keine Dateien; eine neue Endung benennt bestehende Notizen nicht um.'; + + @override + String get nextcloudSettingsSaved => 'Servereinstellungen gespeichert'; + + @override + String get nextcloudSettingsNormalized => + 'Mit vom Server normalisierten Werten gespeichert'; + + @override + String get nextcloudPendingUpload => 'Anhang wartet auf Upload'; + + @override + String nextcloudServerCheck(String time) { + return 'Letzte Serverprüfung: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Servereinstellungen werden geladen'; + + @override + String get nextcloudSettingsSaving => + 'Servereinstellungen werden gespeichert'; + + @override + String get nextcloudPreviousLocalEdit => 'Vorherige lokale Änderung'; + + @override + String get nextcloudUsePreviousEdit => 'Vorherige Änderung verwenden'; + + @override + String get quickOpen => 'Schnell öffnen'; + + @override + String get notesAll => 'Alle Notizen'; + + @override + String get notesUncategorized => 'Ohne Kategorie'; + + @override + String get notesRecovery => 'Wiederherstellung'; + + @override + String get notesRecentlyEdited => 'Zuletzt bearbeitet'; + + @override + String get notesOldestEdited => 'Älteste Bearbeitung'; + + @override + String get notesTitleAscending => 'Titel A–Z'; + + @override + String get notesTitleDescending => 'Titel Z–A'; + + @override + String get notesMove => 'In Kategorie verschieben'; + + @override + String get notesRemoveFavorite => 'Favorit entfernen'; + + @override + String get notesOffline => 'Offline verfügbar machen'; + + @override + String get notesOfflineRemove => 'Offline-Aufbewahrung entfernen'; + + @override + String get notesIndexing => 'Notizen werden indiziert…'; + + @override + String get notesSearchHelp => + 'Begriffe verwenden UND. Verwenden Sie \"Wortgruppen\", title: und category:.'; + + @override + String get notesSourceLocation => 'Quellposition'; + + @override + String get notesRecoveryHelp => + 'Nur lokal aufbewahrte Notizen und lokaler Verlauf. Server-Papierkorb und Server-Verlauf sind nicht verfügbar.'; + + @override + String get notesImport => 'Notizen importieren'; + + @override + String get notesExport => 'Notizen exportieren'; + + @override + String get notesImportDistinct => 'Als eigene Notiz importieren'; + + @override + String get notesAvailableOffline => 'Offline verfügbar'; + + @override + String get notesIncomplete => 'Unvollständig'; + + @override + String get notesSelected => 'Ausgewählte Notizen'; + + @override + String get notesNoHistory => + 'Keine aufbewahrten Versionen. Der Verlauf kann deaktiviert, abgelaufen oder gelöscht sein.'; + + @override + String get notesTextAvailable => 'Text lokal verfügbar'; + + @override + String get notesLocalVersion => 'Lokale Arbeitsversion'; + + @override + String get notesAttachments => 'Anhänge'; + + @override + String get notesSkipped => 'Übersprungen'; + + @override + String get notesFailed => 'Fehlgeschlagen'; + + @override + String get notesCompleted => 'Abgeschlossen'; + + @override + String get notesImportNew => 'Neuen Import starten'; + + @override + String get notesImportResume => 'Unterbrochenen Import fortsetzen'; + + @override + String get notesImportAlreadyImported => + 'In diesem Vorgang bereits importiert'; + + @override + String get references => 'Verweise'; + + @override + String get referenceIncoming => 'Eingehend'; + + @override + String get referenceOutgoing => 'Ausgehend'; + + @override + String get referenceChooseTarget => 'Linkziel auswählen'; + + @override + String get referenceWholeDocument => 'Gesamtes Dokument'; + + @override + String get referenceSourceLocation => 'Quellposition'; + + @override + String get referenceTargetLocation => 'Zielposition'; + + @override + String get referenceResolved => 'Aufgelöst'; + + @override + String get referenceMissing => 'Fehlt'; + + @override + String get referenceAmbiguous => 'Mehrdeutig'; + + @override + String get referenceUnavailable => 'In diesem Kontext nicht verfügbar'; + + @override + String get referenceUnsupported => 'Nicht unterstützt'; + + @override + String get referenceNoPortableId => + 'Dieses Ziel hat keine bestätigte portable Notiz-URL.'; + + @override + String get wsIncludeLines => 'Zeilen einschließen'; + + @override + String get wsIncludeSymbol => 'Symbol einschließen'; + + @override + String get wsEmphasizeLines => 'Angezeigte Zeilen hervorheben'; + + @override + String get wsPropertiesReadOnly => + 'Eigenschaften sind in dieser Ansicht schreibgeschützt.'; + + @override + String get wsPropertiesProtected => + 'Dieses Element ist geschützt. Bearbeiten Sie seine Quelle.'; + + @override + String get wsPropertiesConflict => + 'Dokument oder Element geändert. Entwurf zurücksetzen und erneut auswählen.'; + + @override + String get wsPropertiesUnresolved => + 'Der Verweis kann derzeit nicht aufgelöst werden.'; + + @override + String get wsUseDefault => 'Standard verwenden'; + + @override + String get wsPropertiesSelection => 'Ein einzelnes Element auswählen.'; + + @override + String get wsImageDisplay => 'Bilddarstellung'; + + @override + String get wsImageThumbnail => 'Anklickbare Miniaturansicht'; + + @override + String get wsEnabled => 'Aktiviert'; + + @override + String get wsDisabled => 'Deaktiviert'; + + @override + String get wsWidth => 'Breite'; + + @override + String get wsHeight => 'Höhe'; + + @override + String get wsPropertiesInactive => + 'Ohne veröffentlichtes Einklappen hat diese Eigenschaft keine Wirkung.'; + + @override + String get wsNavigationAll => 'Alle Elemente mit Titel'; + + @override + String get wsNavigationNone => 'Keine Navigation'; + + @override + String get wsNavigationCustom => 'Benutzerdefinierte Navigation'; + + @override + String get wsTitleOverride => 'Titel überschreiben'; + + @override + String get wsCompactLayout => 'Kompakt'; + + @override + String get wsWideLayout => 'Breit'; + + @override + String get wsCodeSourceConflict => + 'Verschieben oder entfernen Sie den Code in diesem Block, bevor Sie src setzen. Writerside lehnt nicht leere Codeblöcke mit einer Quellreferenz ab.'; + + @override + String get wsInherited => 'Von Liste erben'; + + @override + String get wsExternalImageSize => + 'Writerside skaliert extern gehostete Bilder nicht zuverlässig. Vorhandene Maße können geprüft und entfernt werden.'; + + @override + String get wsPropertiesNotSerialized => + 'Diese Änderung wurde nicht in den Quelltext übernommen. Der Entwurf wurde beibehalten.'; + + @override + String get wsListItem => 'Listeneintrag'; + + @override + String get wsSourceFile => 'Quelldatei'; + + @override + String get wsLinkTarget => 'Linkziel'; + + @override + String get wsPreviewImage => 'Vorschaubild'; + + @override + String get wsIncludedElement => 'Eingebundenes Element'; + + @override + String get wsHelpModule => 'Hilfemodul'; + + @override + String get wsActionId => 'Aktions-ID'; + + @override + String get wsKeymapHelpInstance => 'Hilfeinstanz der Tastaturbelegung'; + + @override + String get wsShowInSource => 'Im Quelltext anzeigen'; + + @override + String get wsOriginElement => 'Elementüberschreibung'; + + @override + String get wsOriginAncestor => 'Übergeordnetes Element'; + + @override + String get wsOriginModule => 'Modulstandard'; + + @override + String get wsOriginBuiltIn => 'Integrierter Standard'; + + @override + String get wsOriginUnresolved => 'Nicht aufgelöst'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effektiv: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Generiert: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Standard: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Standard: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Aufgelöst: $value'; + } + + @override + String get wsPropertyUnresolved => 'Nicht aufgelöst'; + + @override + String get wsModule => 'Modul'; + + @override + String get wsParent => 'Übergeordnetes Element'; + + @override + String get wsListType => 'Listentyp'; + + @override + String get wsStartingNumber => 'Startnummer'; + + @override + String get wsListSorting => 'Sortierung'; + + @override + String get wsLetteredList => 'Buchstabenliste'; + + @override + String get wsCheckboxList => 'Liste mit Kontrollkästchen'; + + @override + String get wsMarkerlessList => 'Liste ohne Aufzählungszeichen'; + + @override + String get wsAscending => 'Aufsteigend'; + + @override + String get wsDescending => 'Absteigend'; + + @override + String get wsNoSorting => 'Keine Sortierung'; + + @override + String get wsAsWritten => 'Wie geschrieben'; + + @override + String get wsSentenceCase => 'Satzschreibung'; + + @override + String get wsTitleCase => 'Titelschreibung'; + + @override + String get wsLowerCase => 'Kleinbuchstaben'; + + @override + String get wsUpperCase => 'Großbuchstaben'; + + @override + String get wsNativeMarkers => 'Markdown-Listenzeichen'; + + @override + String wsListMarkerStart(String number) { + return 'Listenbeginn: $number'; + } + + @override + String wsListLevel(String level) { + return 'Ebene $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Der festgelegte Builder ignoriert <caps>-Regeln des Moduls. Verwenden Sie ein lokales caps-Attribut oder default-property.'; + + @override + String get wsInsertElements => 'Elemente einfügen'; + + @override + String get wsAdmonitionsAndQuotes => 'Hinweise und Zitate'; + + @override + String get wsSemanticText => 'Semantischer Text'; + + @override + String get wsPropertiesUnavailable => + 'Das betrachtete Element ist nicht verfügbar. Wählen Sie ein Element zum Fortfahren.'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ae6f199d..f07b1c4f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Module capitalization is not published.'; + + @override + String get wsTitleOverridesAction => 'Instance and TOC titles…'; + + @override + String get wsHeadingNotPublished => 'Not published by this builder.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.'; + + @override + String get wsTopicIdentity => 'Topic ID'; + + @override + String get wsElementAnchor => 'Element anchor'; + + @override + String get wsHeadingText => 'Heading text'; + + @override + String get wsDefaultTitle => 'Default title'; + + @override + String get wsTopicNavigation => 'Topic navigation'; + + @override + String get wsHeadingOptions => 'Heading attributes'; + + @override + String get wsDocumentOptions => 'Document settings'; + + @override + String get wsSettingsPresent => 'Authored settings present'; + + @override + String get wsTitleOverrideExplanation => + 'Overrides the published heading title; the heading text remains unchanged.'; + + @override + String get wsIgnoreVariables => 'Treat variable text literally'; + + @override + String get wsIgnoreVariablesExplanation => + 'When enabled, variable-looking text is not substituted.'; + + @override + String get wsInstanceSelection => 'Help instances'; + + @override + String get wsInstanceExplanation => + 'Select help outputs with names, groups, lists, exclusions, or expressions.'; + + @override + String get wsFilterExplanation => + 'Filter reusable content by criteria; this does not select a help instance.'; + + @override + String get wsContextHelp => 'Context help ID'; + + @override + String get wsCapitalization => 'Capitalization'; + + @override + String get wsLibraryTopic => 'Library topic'; + + @override + String get wsLastModified => 'Last modified'; + + @override + String get wsMissingTopicHeading => + 'No topic H1 is authored. Add the topic heading in Source.'; + + @override + String get wsMultipleTopicHeadings => + 'The first H1 owns the topic title. Later H1s are separate headings.'; + + @override + String get wsTopicIdentityMismatch => + 'The authored root id differs from the filename-derived Topic ID. Use Rename topic file or inspect Source.'; + + @override + String get wsUnsupportedProperties => + 'Unsupported authored attributes are preserved. Inspect them in Source.'; + + @override + String get wsGeneratedAnchor => + 'Generated anchor; setting a value authors an override.'; + + @override + String get wsHeadingBuilderLimitation => + 'The pinned builder does not publish topic H1 attributes: inline attributes render literally and separate-line attributes are ignored. These belong to the heading, not the XML root.'; + @override String get closeOtherTabs => 'Close other tabs'; @@ -4802,4 +4898,519 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'The first request may already have created a server note. This creates a separate note with your retained local work. No suspected duplicate will be deleted.'; + + @override + String get wsSemanticFormats => 'Semantic formatting'; + + @override + String get wsUiControl => 'UI Control'; + + @override + String get wsFilePath => 'File / Path'; + + @override + String get wsUiPath => 'UI Path'; + + @override + String get wsDefinitionList => 'Definition List'; + + @override + String get wsIncludeContent => 'Include Reusable Content'; + + @override + String get wsVariableReference => 'Variable Reference'; + + @override + String get wsConvertList => 'Convert List to Procedure'; + + @override + String get wsElementProperties => 'Element Properties'; + + @override + String get wsTopicProperties => 'Topic Properties'; + + @override + String get wsCollapsible => 'Collapsible'; + + @override + String get wsPublishedState => 'Published initial state'; + + @override + String get wsExpanded => 'Expanded'; + + @override + String get wsCollapsed => 'Collapsed'; + + @override + String get wsCollapsedTitle => 'Collapsed title'; + + @override + String get wsTerm => 'Term'; + + @override + String get wsListLayout => 'List layout'; + + @override + String get wsSyncGroup => 'Synchronization group'; + + @override + String get wsTabKey => 'Tab key'; + + @override + String get wsSwitcherLabel => 'Switcher label'; + + @override + String get wsNavigationElements => 'Navigation elements'; + + @override + String get wsNavigationDepth => 'Navigation depth'; + + @override + String get wsAddItem => 'Add item'; + + @override + String get wsStep => 'Step'; + + @override + String get wsPropertyInvalid => 'Invalid property value'; + + @override + String get wsFilter => 'Filter'; + + @override + String get wsIdentifier => 'Element ID'; + + @override + String get wsTldr => 'TLDR'; + + @override + String get wsFullLayout => 'Full width'; + + @override + String get wsMediumLayout => 'Medium (1:2)'; + + @override + String get wsNarrowLayout => 'Narrow term'; + + @override + String get wsTitle => 'Title'; + + @override + String get wsSwitcherKey => 'Switcher key'; + + @override + String get nextcloudServerSettings => 'Server Notes settings'; + + @override + String get nextcloudNotesPath => 'Notes folder path'; + + @override + String get nextcloudFileSuffix => 'New-note file suffix'; + + @override + String get nextcloudServerSettingsExplanation => + 'These server Notes settings affect other clients. Changing the path does not migrate files; changing the suffix does not rename existing notes.'; + + @override + String get nextcloudSettingsSaved => 'Server settings saved'; + + @override + String get nextcloudSettingsNormalized => + 'Saved with server-normalized values'; + + @override + String get nextcloudPendingUpload => 'Attachment upload pending'; + + @override + String nextcloudServerCheck(String time) { + return 'Last server check: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Loading server settings'; + + @override + String get nextcloudSettingsSaving => 'Saving server settings'; + + @override + String get nextcloudPreviousLocalEdit => 'Previous local edit'; + + @override + String get nextcloudUsePreviousEdit => 'Use previous edit'; + + @override + String get quickOpen => 'Quick Open'; + + @override + String get notesAll => 'All notes'; + + @override + String get notesUncategorized => 'Uncategorized'; + + @override + String get notesRecovery => 'Recovery'; + + @override + String get notesRecentlyEdited => 'Recently edited'; + + @override + String get notesOldestEdited => 'Oldest edited'; + + @override + String get notesTitleAscending => 'Title A–Z'; + + @override + String get notesTitleDescending => 'Title Z–A'; + + @override + String get notesMove => 'Move to category'; + + @override + String get notesRemoveFavorite => 'Remove favorite'; + + @override + String get notesOffline => 'Make available offline'; + + @override + String get notesOfflineRemove => 'Remove offline requirement'; + + @override + String get notesIndexing => 'Indexing notes…'; + + @override + String get notesSearchHelp => + 'Terms use AND. Use \"quoted phrases\", title: and category:.'; + + @override + String get notesSourceLocation => 'Source location'; + + @override + String get notesRecoveryHelp => + 'Retained local notes and Local History only. Server trash and history are unavailable.'; + + @override + String get notesImport => 'Import notes'; + + @override + String get notesExport => 'Export notes'; + + @override + String get notesImportDistinct => 'Import as a distinct note'; + + @override + String get notesAvailableOffline => 'Available offline'; + + @override + String get notesIncomplete => 'Incomplete'; + + @override + String get notesSelected => 'Selected notes'; + + @override + String get notesNoHistory => + 'No retained revisions. History may be disabled, expired or cleared.'; + + @override + String get notesTextAvailable => 'Text available locally'; + + @override + String get notesLocalVersion => 'Local working version'; + + @override + String get notesAttachments => 'Attachments'; + + @override + String get notesSkipped => 'Skipped'; + + @override + String get notesFailed => 'Failed'; + + @override + String get notesCompleted => 'Completed'; + + @override + String get notesImportNew => 'Start a new import'; + + @override + String get notesImportResume => 'Resume interrupted import'; + + @override + String get notesImportAlreadyImported => 'Already imported in this operation'; + + @override + String get references => 'References'; + + @override + String get referenceIncoming => 'Incoming'; + + @override + String get referenceOutgoing => 'Outgoing'; + + @override + String get referenceChooseTarget => 'Choose link target'; + + @override + String get referenceWholeDocument => 'Whole document'; + + @override + String get referenceSourceLocation => 'Source location'; + + @override + String get referenceTargetLocation => 'Target location'; + + @override + String get referenceResolved => 'Resolved'; + + @override + String get referenceMissing => 'Missing'; + + @override + String get referenceAmbiguous => 'Ambiguous'; + + @override + String get referenceUnavailable => 'Unavailable in this context'; + + @override + String get referenceUnsupported => 'Unsupported'; + + @override + String get referenceNoPortableId => + 'This target has no verified portable note URL.'; + + @override + String get wsIncludeLines => 'Include lines'; + + @override + String get wsIncludeSymbol => 'Include symbol'; + + @override + String get wsEmphasizeLines => 'Emphasize displayed lines'; + + @override + String get wsPropertiesReadOnly => 'Properties are read-only in this view.'; + + @override + String get wsPropertiesProtected => + 'This element is protected. Edit its source to change properties.'; + + @override + String get wsPropertiesConflict => + 'The document or element changed. Revert this draft and inspect the current element.'; + + @override + String get wsPropertiesUnresolved => + 'The reference cannot currently be resolved.'; + + @override + String get wsUseDefault => 'Use default'; + + @override + String get wsPropertiesSelection => + 'Select a single element to inspect its properties.'; + + @override + String get wsImageDisplay => 'Image display'; + + @override + String get wsImageThumbnail => 'Clickable thumbnail'; + + @override + String get wsEnabled => 'Enabled'; + + @override + String get wsDisabled => 'Disabled'; + + @override + String get wsWidth => 'Width'; + + @override + String get wsHeight => 'Height'; + + @override + String get wsPropertiesInactive => + 'This property has no effect while published collapse is disabled.'; + + @override + String get wsNavigationAll => 'All titled elements'; + + @override + String get wsNavigationNone => 'No navigation'; + + @override + String get wsNavigationCustom => 'Custom navigation'; + + @override + String get wsTitleOverride => 'Title override'; + + @override + String get wsCompactLayout => 'Compact'; + + @override + String get wsWideLayout => 'Wide'; + + @override + String get wsCodeSourceConflict => + 'Move or remove the code authored in this block before setting src. Writerside rejects a nonempty code block with a source reference.'; + + @override + String get wsInherited => 'Inherit from list'; + + @override + String get wsExternalImageSize => + 'Writerside does not reliably size externally hosted images. Existing dimensions can be inspected and removed.'; + + @override + String get wsPropertiesNotSerialized => + 'This change did not reach the source. The draft has been retained.'; + + @override + String get wsListItem => 'List item'; + + @override + String get wsSourceFile => 'Source file'; + + @override + String get wsLinkTarget => 'Link target'; + + @override + String get wsPreviewImage => 'Preview image'; + + @override + String get wsIncludedElement => 'Included element'; + + @override + String get wsHelpModule => 'Help module'; + + @override + String get wsActionId => 'Action ID'; + + @override + String get wsKeymapHelpInstance => 'Keymap help instance'; + + @override + String get wsShowInSource => 'Show in Source'; + + @override + String get wsOriginElement => 'element override'; + + @override + String get wsOriginAncestor => 'parent element'; + + @override + String get wsOriginModule => 'module default'; + + @override + String get wsOriginBuiltIn => 'built-in default'; + + @override + String get wsOriginUnresolved => 'unresolved'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effective: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Generated: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Default: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Default: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Resolved: $value'; + } + + @override + String get wsPropertyUnresolved => 'Unresolved'; + + @override + String get wsModule => 'Module'; + + @override + String get wsParent => 'Parent'; + + @override + String get wsListType => 'List type'; + + @override + String get wsStartingNumber => 'Starting number'; + + @override + String get wsListSorting => 'Sorting'; + + @override + String get wsLetteredList => 'Lettered list'; + + @override + String get wsCheckboxList => 'Checkbox list'; + + @override + String get wsMarkerlessList => 'List without markers'; + + @override + String get wsAscending => 'Ascending'; + + @override + String get wsDescending => 'Descending'; + + @override + String get wsNoSorting => 'No sorting'; + + @override + String get wsAsWritten => 'As written'; + + @override + String get wsSentenceCase => 'Sentence case'; + + @override + String get wsTitleCase => 'Title case'; + + @override + String get wsLowerCase => 'Lowercase'; + + @override + String get wsUpperCase => 'Uppercase'; + + @override + String get wsNativeMarkers => 'Markdown markers'; + + @override + String wsListMarkerStart(String number) { + return 'Marker start: $number'; + } + + @override + String wsListLevel(String level) { + return 'Level $level'; + } + + @override + String get wsModuleCapsIgnored => + 'The pinned builder ignores module <caps> rules. Use a local caps override or default-property.'; + + @override + String get wsInsertElements => 'Insert elements'; + + @override + String get wsAdmonitionsAndQuotes => 'Admonitions and quotes'; + + @override + String get wsSemanticText => 'Semantic text'; + + @override + String get wsPropertiesUnavailable => + 'The inspected element is unavailable. Inspect an element to continue.'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 744490fa..243eae28 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'No se publica la capitalización del módulo.'; + + @override + String get wsTitleOverridesAction => 'Títulos de instancia e índice…'; + + @override + String get wsHeadingNotPublished => 'Este generador no lo publica.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'El constructor fijado no publica títulos por instancia en temas Markdown. Se conserva el marcado existente; examínelo en el código fuente.'; + + @override + String get wsTopicIdentity => 'ID del tema'; + + @override + String get wsElementAnchor => 'Ancla del elemento'; + + @override + String get wsHeadingText => 'Texto del encabezado'; + + @override + String get wsDefaultTitle => 'Título predeterminado'; + + @override + String get wsTopicNavigation => 'Navegación del tema'; + + @override + String get wsHeadingOptions => 'Atributos del encabezado'; + + @override + String get wsDocumentOptions => 'Ajustes del documento'; + + @override + String get wsSettingsPresent => 'Hay ajustes explícitos'; + + @override + String get wsTitleOverrideExplanation => + 'Reemplaza el título publicado; el texto del encabezado no cambia.'; + + @override + String get wsIgnoreVariables => 'Tratar las variables como texto literal'; + + @override + String get wsIgnoreVariablesExplanation => + 'Al activarlo, el texto con aspecto de variable no se sustituye.'; + + @override + String get wsInstanceSelection => 'Instancias de ayuda'; + + @override + String get wsInstanceExplanation => + 'Seleccione salidas de ayuda mediante nombres, grupos, listas, exclusiones o expresiones.'; + + @override + String get wsFilterExplanation => + 'Filtra contenido reutilizable por criterios; no selecciona una instancia de ayuda.'; + + @override + String get wsContextHelp => 'ID de ayuda contextual'; + + @override + String get wsCapitalization => 'Mayúsculas y minúsculas'; + + @override + String get wsLibraryTopic => 'Tema de biblioteca'; + + @override + String get wsLastModified => 'Última modificación'; + + @override + String get wsMissingTopicHeading => + 'No hay un H1 de tema. Añádalo en el código fuente.'; + + @override + String get wsMultipleTopicHeadings => + 'El primer H1 define el título del tema. Los demás H1 son encabezados independientes.'; + + @override + String get wsTopicIdentityMismatch => + 'El id raíz difiere del ID derivado del archivo. Use Renombrar archivo de tema o examine el código fuente.'; + + @override + String get wsUnsupportedProperties => + 'Se conservan los atributos no compatibles. Examínelos en el código fuente.'; + + @override + String get wsGeneratedAnchor => + 'Ancla generada; introducir un valor crea una anulación.'; + + @override + String get wsHeadingBuilderLimitation => + 'El constructor fijado no publica los atributos del H1. Pertenecen al encabezado, no a la raíz XML.'; + @override String get closeOtherTabs => 'Cerrar las otras pestañas'; @@ -4837,4 +4933,519 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La primera solicitud puede haber creado ya una nota en el servidor. Esto crea una nota aparte con su trabajo local conservado. No se eliminará ningún posible duplicado.'; + + @override + String get wsSemanticFormats => 'Formato semántico'; + + @override + String get wsUiControl => 'Control de interfaz'; + + @override + String get wsFilePath => 'Archivo / Ruta'; + + @override + String get wsUiPath => 'Ruta de interfaz'; + + @override + String get wsDefinitionList => 'Lista de definiciones'; + + @override + String get wsIncludeContent => 'Incluir contenido reutilizable'; + + @override + String get wsVariableReference => 'Referencia de variable'; + + @override + String get wsConvertList => 'Convertir lista en procedimiento'; + + @override + String get wsElementProperties => 'Propiedades del elemento'; + + @override + String get wsTopicProperties => 'Propiedades del tema'; + + @override + String get wsCollapsible => 'Contraíble'; + + @override + String get wsPublishedState => 'Estado inicial publicado'; + + @override + String get wsExpanded => 'Expandido'; + + @override + String get wsCollapsed => 'Contraído'; + + @override + String get wsCollapsedTitle => 'Título contraído'; + + @override + String get wsTerm => 'Término'; + + @override + String get wsListLayout => 'Diseño de la lista'; + + @override + String get wsSyncGroup => 'Grupo de sincronización'; + + @override + String get wsTabKey => 'Clave de pestaña'; + + @override + String get wsSwitcherLabel => 'Etiqueta del selector'; + + @override + String get wsNavigationElements => 'Elementos de navegación'; + + @override + String get wsNavigationDepth => 'Profundidad de navegación'; + + @override + String get wsAddItem => 'Añadir elemento'; + + @override + String get wsStep => 'Paso'; + + @override + String get wsPropertyInvalid => 'Valor de propiedad no válido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID del elemento'; + + @override + String get wsTldr => 'Resumen (TLDR)'; + + @override + String get wsFullLayout => 'Ancho completo'; + + @override + String get wsMediumLayout => 'Medio (1:2)'; + + @override + String get wsNarrowLayout => 'Término estrecho'; + + @override + String get wsTitle => 'Título'; + + @override + String get wsSwitcherKey => 'Clave del selector'; + + @override + String get nextcloudServerSettings => 'Ajustes de Notes del servidor'; + + @override + String get nextcloudNotesPath => 'Ruta de la carpeta de notas'; + + @override + String get nextcloudFileSuffix => 'Extensión de las notas nuevas'; + + @override + String get nextcloudServerSettingsExplanation => + 'Estos ajustes de Notes afectan a otros clientes. Cambiar la ruta no mueve archivos; cambiar la extensión no renombra las notas existentes.'; + + @override + String get nextcloudSettingsSaved => 'Ajustes del servidor guardados'; + + @override + String get nextcloudSettingsNormalized => + 'Guardado con valores normalizados por el servidor'; + + @override + String get nextcloudPendingUpload => 'Carga de adjunto pendiente'; + + @override + String nextcloudServerCheck(String time) { + return 'Última comprobación del servidor: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Cargando ajustes del servidor'; + + @override + String get nextcloudSettingsSaving => 'Guardando ajustes del servidor'; + + @override + String get nextcloudPreviousLocalEdit => 'Edición local anterior'; + + @override + String get nextcloudUsePreviousEdit => 'Usar edición anterior'; + + @override + String get quickOpen => 'Apertura rápida'; + + @override + String get notesAll => 'Todas las notas'; + + @override + String get notesUncategorized => 'Sin categoría'; + + @override + String get notesRecovery => 'Recuperación'; + + @override + String get notesRecentlyEdited => 'Editadas recientemente'; + + @override + String get notesOldestEdited => 'Ediciones más antiguas'; + + @override + String get notesTitleAscending => 'Título A–Z'; + + @override + String get notesTitleDescending => 'Título Z–A'; + + @override + String get notesMove => 'Mover a categoría'; + + @override + String get notesRemoveFavorite => 'Quitar favorito'; + + @override + String get notesOffline => 'Hacer disponible sin conexión'; + + @override + String get notesOfflineRemove => 'Quitar conservación sin conexión'; + + @override + String get notesIndexing => 'Indexando notas…'; + + @override + String get notesSearchHelp => + 'Los términos usan Y. Use \"frases entre comillas\", title: y category:.'; + + @override + String get notesSourceLocation => 'Ubicación en el código fuente'; + + @override + String get notesRecoveryHelp => + 'Solo notas locales conservadas e historial local. La papelera y el historial del servidor no están disponibles.'; + + @override + String get notesImport => 'Importar notas'; + + @override + String get notesExport => 'Exportar notas'; + + @override + String get notesImportDistinct => 'Importar como una nota distinta'; + + @override + String get notesAvailableOffline => 'Disponible sin conexión'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Notas seleccionadas'; + + @override + String get notesNoHistory => + 'No hay revisiones conservadas. El historial puede estar desactivado, caducado o borrado.'; + + @override + String get notesTextAvailable => 'Texto disponible localmente'; + + @override + String get notesLocalVersion => 'Versión de trabajo local'; + + @override + String get notesAttachments => 'Adjuntos'; + + @override + String get notesSkipped => 'Omitido'; + + @override + String get notesFailed => 'Fallido'; + + @override + String get notesCompleted => 'Completado'; + + @override + String get notesImportNew => 'Iniciar una nueva importación'; + + @override + String get notesImportResume => 'Reanudar importación interrumpida'; + + @override + String get notesImportAlreadyImported => 'Ya importado en esta operación'; + + @override + String get references => 'Referencias'; + + @override + String get referenceIncoming => 'Entrantes'; + + @override + String get referenceOutgoing => 'Salientes'; + + @override + String get referenceChooseTarget => 'Elegir destino del enlace'; + + @override + String get referenceWholeDocument => 'Documento completo'; + + @override + String get referenceSourceLocation => 'Ubicación de origen'; + + @override + String get referenceTargetLocation => 'Ubicación de destino'; + + @override + String get referenceResolved => 'Resuelta'; + + @override + String get referenceMissing => 'No encontrada'; + + @override + String get referenceAmbiguous => 'Ambigua'; + + @override + String get referenceUnavailable => 'No disponible en este contexto'; + + @override + String get referenceUnsupported => 'No compatible'; + + @override + String get referenceNoPortableId => + 'Este destino no tiene una URL portátil de nota verificada.'; + + @override + String get wsIncludeLines => 'Incluir líneas'; + + @override + String get wsIncludeSymbol => 'Incluir símbolo'; + + @override + String get wsEmphasizeLines => 'Resaltar líneas mostradas'; + + @override + String get wsPropertiesReadOnly => + 'Propiedades de solo lectura en esta vista.'; + + @override + String get wsPropertiesProtected => + 'Este elemento está protegido. Edite su fuente.'; + + @override + String get wsPropertiesConflict => + 'El documento o elemento cambió. Revierta el borrador y seleccione de nuevo.'; + + @override + String get wsPropertiesUnresolved => + 'No se puede resolver la referencia actualmente.'; + + @override + String get wsUseDefault => 'Usar valor predeterminado'; + + @override + String get wsPropertiesSelection => 'Seleccione un solo elemento.'; + + @override + String get wsImageDisplay => 'Visualización de imagen'; + + @override + String get wsImageThumbnail => 'Miniatura pulsable'; + + @override + String get wsEnabled => 'Activado'; + + @override + String get wsDisabled => 'Desactivado'; + + @override + String get wsWidth => 'Ancho'; + + @override + String get wsHeight => 'Alto'; + + @override + String get wsPropertiesInactive => + 'Esta propiedad no tiene efecto sin contraer en la publicación.'; + + @override + String get wsNavigationAll => 'Todos los elementos con título'; + + @override + String get wsNavigationNone => 'Sin navegación'; + + @override + String get wsNavigationCustom => 'Navegación personalizada'; + + @override + String get wsTitleOverride => 'Sustituir título'; + + @override + String get wsCompactLayout => 'Compacto'; + + @override + String get wsWideLayout => 'Ancho'; + + @override + String get wsCodeSourceConflict => + 'Mueve o elimina el código escrito en este bloque antes de establecer src. Writerside rechaza bloques de código no vacíos con una referencia de origen.'; + + @override + String get wsInherited => 'Heredar de la lista'; + + @override + String get wsExternalImageSize => + 'Writerside no dimensiona con fiabilidad las imágenes externas. Puede revisar y quitar dimensiones existentes.'; + + @override + String get wsPropertiesNotSerialized => + 'Este cambio no se aplicó al código fuente. Se ha conservado el borrador.'; + + @override + String get wsListItem => 'Elemento de lista'; + + @override + String get wsSourceFile => 'Archivo de origen'; + + @override + String get wsLinkTarget => 'Destino del enlace'; + + @override + String get wsPreviewImage => 'Imagen de vista previa'; + + @override + String get wsIncludedElement => 'Elemento incluido'; + + @override + String get wsHelpModule => 'Módulo de ayuda'; + + @override + String get wsActionId => 'ID de acción'; + + @override + String get wsKeymapHelpInstance => 'Instancia de ayuda del mapa de teclas'; + + @override + String get wsShowInSource => 'Mostrar en Fuente'; + + @override + String get wsOriginElement => 'Valor del elemento'; + + @override + String get wsOriginAncestor => 'Elemento padre'; + + @override + String get wsOriginModule => 'Predeterminado del módulo'; + + @override + String get wsOriginBuiltIn => 'Predeterminado integrado'; + + @override + String get wsOriginUnresolved => 'Sin resolver'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Efectivo: $value — $origin'; + } + + @override + String get wsElementSelector => 'Elemento'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Generado: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Predeterminado: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Predeterminado: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Resuelto: $value'; + } + + @override + String get wsPropertyUnresolved => 'Sin resolver'; + + @override + String get wsModule => 'Módulo'; + + @override + String get wsParent => 'Elemento padre'; + + @override + String get wsListType => 'Tipo de lista'; + + @override + String get wsStartingNumber => 'Número inicial'; + + @override + String get wsListSorting => 'Ordenación'; + + @override + String get wsLetteredList => 'Lista alfabética'; + + @override + String get wsCheckboxList => 'Lista de casillas'; + + @override + String get wsMarkerlessList => 'Lista sin marcadores'; + + @override + String get wsAscending => 'Ascendente'; + + @override + String get wsDescending => 'Descendente'; + + @override + String get wsNoSorting => 'Sin ordenar'; + + @override + String get wsAsWritten => 'Como está escrito'; + + @override + String get wsSentenceCase => 'Mayúscula inicial'; + + @override + String get wsTitleCase => 'Mayúsculas de título'; + + @override + String get wsLowerCase => 'Minúsculas'; + + @override + String get wsUpperCase => 'Mayúsculas'; + + @override + String get wsNativeMarkers => 'Marcadores Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Inicio del marcador: $number'; + } + + @override + String wsListLevel(String level) { + return 'Nivel $level'; + } + + @override + String get wsModuleCapsIgnored => + 'El compilador fijado ignora las reglas <caps> del módulo. Use un atributo caps local o default-property.'; + + @override + String get wsInsertElements => 'Insertar elementos'; + + @override + String get wsAdmonitionsAndQuotes => 'Avisos y citas'; + + @override + String get wsSemanticText => 'Texto semántico'; + + @override + String get wsPropertiesUnavailable => + 'El elemento inspeccionado no está disponible. Inspeccione otro elemento para continuar.'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 1aa28afd..c8f55cf4 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -10,6 +10,99 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get wsModuleCapsNotPublished => 'Mooduli suurtähereeglit ei avaldata.'; + + @override + String get wsTitleOverridesAction => 'Eksemplari ja sisukorra pealkirjad…'; + + @override + String get wsHeadingNotPublished => 'See koostaja ei avalda seda.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Kinnitatud koostaja ei avalda Markdowni teemade instantsipealkirju. Olemasolev märgistus säilitatakse; vaata lähteteksti.'; + + @override + String get wsTopicIdentity => 'Teema ID'; + + @override + String get wsElementAnchor => 'Elemendi ankur'; + + @override + String get wsHeadingText => 'Pealkirja tekst'; + + @override + String get wsDefaultTitle => 'Vaikepealkiri'; + + @override + String get wsTopicNavigation => 'Teema navigeerimine'; + + @override + String get wsHeadingOptions => 'Pealkirja atribuudid'; + + @override + String get wsDocumentOptions => 'Dokumendi sätted'; + + @override + String get wsSettingsPresent => 'Koostatud sätted olemas'; + + @override + String get wsTitleOverrideExplanation => + 'Asendab avaldatud pealkirja; pealkirja tekst ei muutu.'; + + @override + String get wsIgnoreVariables => 'Käsitle muutujaid sõnasõnalise tekstina'; + + @override + String get wsIgnoreVariablesExplanation => + 'Lubamisel ei asendata muutujalaadset teksti.'; + + @override + String get wsInstanceSelection => 'Spikri instantsid'; + + @override + String get wsInstanceExplanation => + 'Vali väljundid nimede, rühmade, loendite, välistuste või avaldistega.'; + + @override + String get wsFilterExplanation => + 'Filtreerib korduvkasutatavat sisu tingimuste järgi; ei vali spikri instantsi.'; + + @override + String get wsContextHelp => 'Kontekstispikri ID'; + + @override + String get wsCapitalization => 'Suur- ja väiketähed'; + + @override + String get wsLibraryTopic => 'Teegi teema'; + + @override + String get wsLastModified => 'Viimati muudetud'; + + @override + String get wsMissingTopicHeading => 'Teema H1 puudub. Lisa see lähtetekstis.'; + + @override + String get wsMultipleTopicHeadings => + 'Esimene H1 määrab teema pealkirja. Hilisemad H1-d on eraldi pealkirjad.'; + + @override + String get wsTopicIdentityMismatch => + 'Juure id erineb failinimest tulenevast teema ID-st. Nimeta teemafail ümber või vaata lähteteksti.'; + + @override + String get wsUnsupportedProperties => + 'Toetamata atribuudid säilitatakse. Vaata neid lähtetekstis.'; + + @override + String get wsGeneratedAnchor => 'Loodud ankur; väärtus loob ülekirjutuse.'; + + @override + String get wsHeadingBuilderLimitation => + 'Kinnitatud koostaja ei avalda H1 atribuute. Need kuuluvad pealkirjale, mitte XML-juurele.'; + @override String get closeOtherTabs => 'Sulge teised vahelehed'; @@ -4788,4 +4881,517 @@ class AppLocalizationsEt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Esimene päring võis serveris juba märkuse luua. See loob eraldi märkuse koos säilitatud kohaliku tööga. Võimalikke duplikaate ei kustutata.'; + + @override + String get wsSemanticFormats => 'Semantiline vormindus'; + + @override + String get wsUiControl => 'Kasutajaliidese juhtelement'; + + @override + String get wsFilePath => 'Fail / Tee'; + + @override + String get wsUiPath => 'Kasutajaliidese tee'; + + @override + String get wsDefinitionList => 'Definitsiooniloend'; + + @override + String get wsIncludeContent => 'Lisa korduskasutatav sisu'; + + @override + String get wsVariableReference => 'Muutuja viide'; + + @override + String get wsConvertList => 'Teisenda loend protseduuriks'; + + @override + String get wsElementProperties => 'Elemendi omadused'; + + @override + String get wsTopicProperties => 'Teema omadused'; + + @override + String get wsCollapsible => 'Ahendatav'; + + @override + String get wsPublishedState => 'Avaldatud algolek'; + + @override + String get wsExpanded => 'Laiendatud'; + + @override + String get wsCollapsed => 'Ahendatud'; + + @override + String get wsCollapsedTitle => 'Ahendatud pealkiri'; + + @override + String get wsTerm => 'Termin'; + + @override + String get wsListLayout => 'Loendi paigutus'; + + @override + String get wsSyncGroup => 'Sünkroonimisrühm'; + + @override + String get wsTabKey => 'Vahelehe võti'; + + @override + String get wsSwitcherLabel => 'Lüliti silt'; + + @override + String get wsNavigationElements => 'Navigeerimise elemendid'; + + @override + String get wsNavigationDepth => 'Navigeerimise sügavus'; + + @override + String get wsAddItem => 'Lisa element'; + + @override + String get wsStep => 'Samm'; + + @override + String get wsPropertyInvalid => 'Vigane omaduse väärtus'; + + @override + String get wsFilter => 'Sisufilter'; + + @override + String get wsIdentifier => 'Elemendi ID'; + + @override + String get wsTldr => 'Kokkuvõte (TLDR)'; + + @override + String get wsFullLayout => 'Täislaius'; + + @override + String get wsMediumLayout => 'Keskmine (1:2)'; + + @override + String get wsNarrowLayout => 'Kitsas terminiveerg'; + + @override + String get wsTitle => 'Pealkiri'; + + @override + String get wsSwitcherKey => 'Valikulüliti võti'; + + @override + String get nextcloudServerSettings => 'Serveri Notesi seaded'; + + @override + String get nextcloudNotesPath => 'Märkmete kausta tee'; + + @override + String get nextcloudFileSuffix => 'Uute märkmete faililaiend'; + + @override + String get nextcloudServerSettingsExplanation => + 'Need Notesi seaded mõjutavad teisi kliente. Tee muutmine ei teisalda faile; laiendi muutmine ei nimeta olemasolevaid märkmeid ümber.'; + + @override + String get nextcloudSettingsSaved => 'Serveri seaded salvestatud'; + + @override + String get nextcloudSettingsNormalized => + 'Salvestatud serveri normaliseeritud väärtustega'; + + @override + String get nextcloudPendingUpload => 'Manuse üleslaadimine ootel'; + + @override + String nextcloudServerCheck(String time) { + return 'Viimane serverikontroll: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Serveri seadete laadimine'; + + @override + String get nextcloudSettingsSaving => 'Serveri seadete salvestamine'; + + @override + String get nextcloudPreviousLocalEdit => 'Eelmine kohalik muudatus'; + + @override + String get nextcloudUsePreviousEdit => 'Kasuta eelmist muudatust'; + + @override + String get quickOpen => 'Kiiravamine'; + + @override + String get notesAll => 'Kõik märkmed'; + + @override + String get notesUncategorized => 'Kategooriata'; + + @override + String get notesRecovery => 'Taastamine'; + + @override + String get notesRecentlyEdited => 'Hiljuti muudetud'; + + @override + String get notesOldestEdited => 'Vanim muutmine'; + + @override + String get notesTitleAscending => 'Pealkiri A–Z'; + + @override + String get notesTitleDescending => 'Pealkiri Z–A'; + + @override + String get notesMove => 'Teisalda kategooriasse'; + + @override + String get notesRemoveFavorite => 'Eemalda lemmik'; + + @override + String get notesOffline => 'Tee võrguta kättesaadavaks'; + + @override + String get notesOfflineRemove => 'Eemalda võrguta säilitamine'; + + @override + String get notesIndexing => 'Märkmete indekseerimine…'; + + @override + String get notesSearchHelp => + 'Mõisted ühendatakse JA abil. Kasuta \"tsiteeritud fraase\", title: ja category:.'; + + @override + String get notesSourceLocation => 'Lähtekoht'; + + @override + String get notesRecoveryHelp => + 'Ainult säilitatud kohalikud märkmed ja kohalik ajalugu. Serveri prügikast ja ajalugu pole saadaval.'; + + @override + String get notesImport => 'Impordi märkmed'; + + @override + String get notesExport => 'Ekspordi märkmed'; + + @override + String get notesImportDistinct => 'Impordi eraldi märkmena'; + + @override + String get notesAvailableOffline => 'Võrguta saadaval'; + + @override + String get notesIncomplete => 'Mittetäielik'; + + @override + String get notesSelected => 'Valitud märkmed'; + + @override + String get notesNoHistory => + 'Säilitatud versioone pole. Ajalugu võib olla keelatud, aegunud või kustutatud.'; + + @override + String get notesTextAvailable => 'Tekst on kohalikult saadaval'; + + @override + String get notesLocalVersion => 'Kohalik tööversioon'; + + @override + String get notesAttachments => 'Manused'; + + @override + String get notesSkipped => 'Vahele jäetud'; + + @override + String get notesFailed => 'Ebaõnnestus'; + + @override + String get notesCompleted => 'Lõpetatud'; + + @override + String get notesImportNew => 'Alusta uut importimist'; + + @override + String get notesImportResume => 'Jätka katkestatud importimist'; + + @override + String get notesImportAlreadyImported => 'Selles toimingus juba imporditud'; + + @override + String get references => 'Viited'; + + @override + String get referenceIncoming => 'Sissetulevad'; + + @override + String get referenceOutgoing => 'Väljaminevad'; + + @override + String get referenceChooseTarget => 'Vali lingi siht'; + + @override + String get referenceWholeDocument => 'Kogu dokument'; + + @override + String get referenceSourceLocation => 'Allika asukoht'; + + @override + String get referenceTargetLocation => 'Sihtkoha asukoht'; + + @override + String get referenceResolved => 'Lahendatud'; + + @override + String get referenceMissing => 'Puudub'; + + @override + String get referenceAmbiguous => 'Mitmetähenduslik'; + + @override + String get referenceUnavailable => 'Selles kontekstis pole saadaval'; + + @override + String get referenceUnsupported => 'Pole toetatud'; + + @override + String get referenceNoPortableId => + 'Sellel sihtkohal pole kinnitatud teisaldatavat märkme URL-i.'; + + @override + String get wsIncludeLines => 'Kaasa read'; + + @override + String get wsIncludeSymbol => 'Kaasa sümbol'; + + @override + String get wsEmphasizeLines => 'Tõsta kuvatud read esile'; + + @override + String get wsPropertiesReadOnly => + 'Selles vaates on omadused kirjutuskaitstud.'; + + @override + String get wsPropertiesProtected => 'Element on kaitstud. Muuda lähtekoodi.'; + + @override + String get wsPropertiesConflict => + 'Dokument või element muutus. Taasta mustand ja vali uuesti.'; + + @override + String get wsPropertiesUnresolved => 'Viidet ei saa praegu lahendada.'; + + @override + String get wsUseDefault => 'Kasuta vaikeväärtust'; + + @override + String get wsPropertiesSelection => 'Vali üks element.'; + + @override + String get wsImageDisplay => 'Pildi kuvamine'; + + @override + String get wsImageThumbnail => 'Klõpsatav pisipilt'; + + @override + String get wsEnabled => 'Lubatud'; + + @override + String get wsDisabled => 'Keelatud'; + + @override + String get wsWidth => 'Laius'; + + @override + String get wsHeight => 'Kõrgus'; + + @override + String get wsPropertiesInactive => + 'Omadus toimib ainult avaldatud ahendamise korral.'; + + @override + String get wsNavigationAll => 'Kõik pealkirjaga elemendid'; + + @override + String get wsNavigationNone => 'Navigeerimine puudub'; + + @override + String get wsNavigationCustom => 'Kohandatud navigeerimine'; + + @override + String get wsTitleOverride => 'Pealkirja ülekirjutus'; + + @override + String get wsCompactLayout => 'Kompaktne'; + + @override + String get wsWideLayout => 'Lai'; + + @override + String get wsCodeSourceConflict => + 'Enne src määramist teisalda või eemalda sellesse plokki kirjutatud kood. Writerside ei luba lähteviitega mittetühja koodiplokki.'; + + @override + String get wsInherited => 'Päri loendilt'; + + @override + String get wsExternalImageSize => + 'Writerside ei määra väliste piltide mõõtmeid usaldusväärselt. Olemasolevaid mõõtmeid saab vaadata ja eemaldada.'; + + @override + String get wsPropertiesNotSerialized => + 'Muudatust ei rakendatud lähtetekstile. Mustand on alles.'; + + @override + String get wsListItem => 'Loendiüksus'; + + @override + String get wsSourceFile => 'Lähtefail'; + + @override + String get wsLinkTarget => 'Lingi siht'; + + @override + String get wsPreviewImage => 'Eelvaatepilt'; + + @override + String get wsIncludedElement => 'Kaasatud element'; + + @override + String get wsHelpModule => 'Abimoodul'; + + @override + String get wsActionId => 'Toimingu ID'; + + @override + String get wsKeymapHelpInstance => 'Klahvikaardi abieksemplar'; + + @override + String get wsShowInSource => 'Näita lähtekoodis'; + + @override + String get wsOriginElement => 'Elemendi ülekirjutus'; + + @override + String get wsOriginAncestor => 'Ülemelement'; + + @override + String get wsOriginModule => 'Mooduli vaikeväärtus'; + + @override + String get wsOriginBuiltIn => 'Sisseehitatud vaikeväärtus'; + + @override + String get wsOriginUnresolved => 'Lahendamata'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Tegelik: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Genereeritud: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Vaikimisi: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Vaikimisi: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Lahendatud: $value'; + } + + @override + String get wsPropertyUnresolved => 'Lahendamata'; + + @override + String get wsModule => 'Moodul'; + + @override + String get wsParent => 'Ülemelement'; + + @override + String get wsListType => 'Loendi tüüp'; + + @override + String get wsStartingNumber => 'Algusnumber'; + + @override + String get wsListSorting => 'Sortimine'; + + @override + String get wsLetteredList => 'Tähtedega loend'; + + @override + String get wsCheckboxList => 'Märkeruutudega loend'; + + @override + String get wsMarkerlessList => 'Markeriteta loend'; + + @override + String get wsAscending => 'Kasvav'; + + @override + String get wsDescending => 'Kahanev'; + + @override + String get wsNoSorting => 'Sortimata'; + + @override + String get wsAsWritten => 'Nagu kirjutatud'; + + @override + String get wsSentenceCase => 'Lause tähesuurus'; + + @override + String get wsTitleCase => 'Pealkirja tähesuurus'; + + @override + String get wsLowerCase => 'Väiketähed'; + + @override + String get wsUpperCase => 'Suurtähed'; + + @override + String get wsNativeMarkers => 'Markdowni markerid'; + + @override + String wsListMarkerStart(String number) { + return 'Algusmarker: $number'; + } + + @override + String wsListLevel(String level) { + return 'Tase $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Fikseeritud koostaja eirab mooduli <caps>-reegleid. Kasuta kohalikku caps-atribuuti või default-property sätet.'; + + @override + String get wsInsertElements => 'Lisa elemendid'; + + @override + String get wsAdmonitionsAndQuotes => 'Märkused ja tsitaadid'; + + @override + String get wsSemanticText => 'Semantiline tekst'; + + @override + String get wsPropertiesUnavailable => + 'Vaadeldav element pole saadaval. Jätkamiseks vali element.'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5255ea37..2fa72377 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -10,6 +10,101 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get wsModuleCapsNotPublished => 'سبک حروف ماژول منتشر نمی‌شود.'; + + @override + String get wsTitleOverridesAction => 'عنوان‌های نمونه و فهرست…'; + + @override + String get wsHeadingNotPublished => 'این سازنده آن را منتشر نمی‌کند.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'سازنده تثبیت‌شده عنوان نمونه را در موضوع‌های Markdown منتشر نمی‌کند. نشانه‌گذاری موجود حفظ می‌شود؛ منبع را بررسی کنید.'; + + @override + String get wsTopicIdentity => 'شناسه موضوع'; + + @override + String get wsElementAnchor => 'لنگر عنصر'; + + @override + String get wsHeadingText => 'متن عنوان'; + + @override + String get wsDefaultTitle => 'عنوان پیش‌فرض'; + + @override + String get wsTopicNavigation => 'پیمایش موضوع'; + + @override + String get wsHeadingOptions => 'ویژگی‌های عنوان'; + + @override + String get wsDocumentOptions => 'تنظیمات سند'; + + @override + String get wsSettingsPresent => 'تنظیمات صریح موجود است'; + + @override + String get wsTitleOverrideExplanation => + 'عنوان منتشرشده را بازنویسی می‌کند؛ متن عنوان تغییر نمی‌کند.'; + + @override + String get wsIgnoreVariables => 'متغیرها را متن لفظی در نظر بگیر'; + + @override + String get wsIgnoreVariablesExplanation => + 'وقتی فعال باشد، متن شبیه متغیر جایگزین نمی‌شود.'; + + @override + String get wsInstanceSelection => 'نمونه‌های راهنما'; + + @override + String get wsInstanceExplanation => + 'خروجی را با نام، گروه، فهرست، استثنا یا عبارت انتخاب کنید.'; + + @override + String get wsFilterExplanation => + 'محتوای قابل استفاده مجدد را با معیارها فیلتر می‌کند؛ نمونه راهنما را انتخاب نمی‌کند.'; + + @override + String get wsContextHelp => 'شناسه راهنمای زمینه‌ای'; + + @override + String get wsCapitalization => 'بزرگی حروف'; + + @override + String get wsLibraryTopic => 'موضوع کتابخانه'; + + @override + String get wsLastModified => 'آخرین تغییر'; + + @override + String get wsMissingTopicHeading => + 'H1 موضوع وجود ندارد. آن را در منبع اضافه کنید.'; + + @override + String get wsMultipleTopicHeadings => + 'اولین H1 عنوان موضوع را تعیین می‌کند. H1های بعدی مستقل‌اند.'; + + @override + String get wsTopicIdentityMismatch => + 'id ریشه با شناسه مشتق از نام فایل متفاوت است. فایل موضوع را تغییر نام دهید یا منبع را بررسی کنید.'; + + @override + String get wsUnsupportedProperties => + 'ویژگی‌های پشتیبانی‌نشده حفظ می‌شوند. در منبع بررسی کنید.'; + + @override + String get wsGeneratedAnchor => + 'لنگر تولیدشده؛ وارد کردن مقدار آن را بازنویسی می‌کند.'; + + @override + String get wsHeadingBuilderLimitation => + 'سازنده تثبیت‌شده ویژگی‌های H1 را منتشر نمی‌کند. متعلق به عنوان هستند، نه ریشه XML.'; + @override String get closeOtherTabs => 'بستن زبانه‌های دیگر'; @@ -4826,4 +4921,517 @@ class AppLocalizationsFa extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'ممکن است درخواست نخست قبلاً یک یادداشت سرور ایجاد کرده باشد. این کار یک یادداشت جداگانه با کار محلی حفظ‌شدهٔ شما ایجاد می‌کند. هیچ یادداشت مشکوک به تکراری بودن حذف نمی‌شود.'; + + @override + String get wsSemanticFormats => 'قالب‌بندی معنایی'; + + @override + String get wsUiControl => 'کنترل رابط کاربری'; + + @override + String get wsFilePath => 'فایل / مسیر'; + + @override + String get wsUiPath => 'مسیر رابط کاربری'; + + @override + String get wsDefinitionList => 'فهرست تعریف‌ها'; + + @override + String get wsIncludeContent => 'درج محتوای قابل استفاده مجدد'; + + @override + String get wsVariableReference => 'ارجاع به متغیر'; + + @override + String get wsConvertList => 'تبدیل فهرست به رویه'; + + @override + String get wsElementProperties => 'ویژگی‌های عنصر'; + + @override + String get wsTopicProperties => 'ویژگی‌های موضوع'; + + @override + String get wsCollapsible => 'قابل جمع شدن'; + + @override + String get wsPublishedState => 'حالت اولیه هنگام انتشار'; + + @override + String get wsExpanded => 'باز'; + + @override + String get wsCollapsed => 'جمع شده'; + + @override + String get wsCollapsedTitle => 'عنوان در حالت جمع شده'; + + @override + String get wsTerm => 'اصطلاح'; + + @override + String get wsListLayout => 'چیدمان فهرست'; + + @override + String get wsSyncGroup => 'گروه همگام‌سازی'; + + @override + String get wsTabKey => 'کلید زبانه'; + + @override + String get wsSwitcherLabel => 'برچسب تغییر‌دهنده'; + + @override + String get wsNavigationElements => 'عناصر پیمایش'; + + @override + String get wsNavigationDepth => 'عمق پیمایش'; + + @override + String get wsAddItem => 'افزودن مورد'; + + @override + String get wsStep => 'گام'; + + @override + String get wsPropertyInvalid => 'مقدار ویژگی نامعتبر'; + + @override + String get wsFilter => 'فیلتر'; + + @override + String get wsIdentifier => 'شناسه عنصر'; + + @override + String get wsTldr => 'خلاصه (TLDR)'; + + @override + String get wsFullLayout => 'عرض کامل'; + + @override + String get wsMediumLayout => 'متوسط (1:2)'; + + @override + String get wsNarrowLayout => 'ستون باریک اصطلاح'; + + @override + String get wsTitle => 'عنوان'; + + @override + String get wsSwitcherKey => 'کلید انتخابگر'; + + @override + String get nextcloudServerSettings => 'تنظیمات Notes سرور'; + + @override + String get nextcloudNotesPath => 'مسیر پوشهٔ یادداشت‌ها'; + + @override + String get nextcloudFileSuffix => 'پسوند فایل یادداشت‌های جدید'; + + @override + String get nextcloudServerSettingsExplanation => + 'این تنظیمات Notes روی کارخواه‌های دیگر هم اثر می‌گذارد. تغییر مسیر فایل‌ها را جابه‌جا نمی‌کند؛ تغییر پسوند نام یادداشت‌های موجود را تغییر نمی‌دهد.'; + + @override + String get nextcloudSettingsSaved => 'تنظیمات سرور ذخیره شد'; + + @override + String get nextcloudSettingsNormalized => + 'با مقادیر نرمال‌شدهٔ سرور ذخیره شد'; + + @override + String get nextcloudPendingUpload => 'بارگذاری پیوست در انتظار است'; + + @override + String nextcloudServerCheck(String time) { + return 'آخرین بررسی سرور: $time'; + } + + @override + String get nextcloudSettingsLoading => 'در حال بارگیری تنظیمات سرور'; + + @override + String get nextcloudSettingsSaving => 'در حال ذخیره تنظیمات سرور'; + + @override + String get nextcloudPreviousLocalEdit => 'ویرایش محلی قبلی'; + + @override + String get nextcloudUsePreviousEdit => 'استفاده از ویرایش قبلی'; + + @override + String get quickOpen => 'باز کردن سریع'; + + @override + String get notesAll => 'همه یادداشت‌ها'; + + @override + String get notesUncategorized => 'بدون دسته'; + + @override + String get notesRecovery => 'بازیابی'; + + @override + String get notesRecentlyEdited => 'تازه ویرایش‌شده'; + + @override + String get notesOldestEdited => 'قدیمی‌ترین ویرایش'; + + @override + String get notesTitleAscending => 'عنوان صعودی'; + + @override + String get notesTitleDescending => 'عنوان نزولی'; + + @override + String get notesMove => 'انتقال به دسته'; + + @override + String get notesRemoveFavorite => 'حذف از علاقه‌مندی‌ها'; + + @override + String get notesOffline => 'در دسترس کردن آفلاین'; + + @override + String get notesOfflineRemove => 'حذف نگهداری آفلاین'; + + @override + String get notesIndexing => 'در حال نمایه‌سازی یادداشت‌ها…'; + + @override + String get notesSearchHelp => + 'واژه‌ها با AND ترکیب می‌شوند. از \"عبارت نقل‌شده\" و title: و category: استفاده کنید.'; + + @override + String get notesSourceLocation => 'موقعیت در منبع'; + + @override + String get notesRecoveryHelp => + 'فقط یادداشت‌های محلی نگهداری‌شده و تاریخچه محلی. زباله‌دان و تاریخچه سرور در دسترس نیست.'; + + @override + String get notesImport => 'وارد کردن یادداشت‌ها'; + + @override + String get notesExport => 'صادر کردن یادداشت‌ها'; + + @override + String get notesImportDistinct => 'وارد کردن به عنوان یادداشت جدا'; + + @override + String get notesAvailableOffline => 'در دسترس آفلاین'; + + @override + String get notesIncomplete => 'ناقص'; + + @override + String get notesSelected => 'یادداشت‌های انتخاب‌شده'; + + @override + String get notesNoHistory => + 'نسخه نگهداری‌شده‌ای نیست. تاریخچه ممکن است غیرفعال، منقضی یا پاک شده باشد.'; + + @override + String get notesTextAvailable => 'متن به صورت محلی در دسترس است'; + + @override + String get notesLocalVersion => 'نسخهٔ کاری محلی'; + + @override + String get notesAttachments => 'پیوست‌ها'; + + @override + String get notesSkipped => 'رد شد'; + + @override + String get notesFailed => 'ناموفق'; + + @override + String get notesCompleted => 'تکمیل شد'; + + @override + String get notesImportNew => 'شروع وارد کردن جدید'; + + @override + String get notesImportResume => 'ادامه وارد کردن متوقف‌شده'; + + @override + String get notesImportAlreadyImported => 'قبلاً در این عملیات وارد شده است'; + + @override + String get references => 'ارجاع‌ها'; + + @override + String get referenceIncoming => 'ورودی'; + + @override + String get referenceOutgoing => 'خروجی'; + + @override + String get referenceChooseTarget => 'انتخاب مقصد پیوند'; + + @override + String get referenceWholeDocument => 'کل سند'; + + @override + String get referenceSourceLocation => 'مکان منبع'; + + @override + String get referenceTargetLocation => 'مکان مقصد'; + + @override + String get referenceResolved => 'حل‌شده'; + + @override + String get referenceMissing => 'یافت نشد'; + + @override + String get referenceAmbiguous => 'مبهم'; + + @override + String get referenceUnavailable => 'در این زمینه در دسترس نیست'; + + @override + String get referenceUnsupported => 'پشتیبانی نمی‌شود'; + + @override + String get referenceNoPortableId => + 'این مقصد نشانی قابل‌انتقال و تأییدشده‌ای برای یادداشت ندارد.'; + + @override + String get wsIncludeLines => 'شامل کردن خط‌ها'; + + @override + String get wsIncludeSymbol => 'شامل کردن نماد'; + + @override + String get wsEmphasizeLines => 'برجسته کردن خط‌های نمایش داده‌شده'; + + @override + String get wsPropertiesReadOnly => 'ویژگی‌ها در این نما فقط خواندنی هستند.'; + + @override + String get wsPropertiesProtected => + 'این عنصر محافظت شده است. منبع آن را ویرایش کنید.'; + + @override + String get wsPropertiesConflict => + 'سند یا عنصر تغییر کرد. پیش‌نویس را برگردانید و دوباره انتخاب کنید.'; + + @override + String get wsPropertiesUnresolved => 'مرجع اکنون قابل حل نیست.'; + + @override + String get wsUseDefault => 'استفاده از پیش‌فرض'; + + @override + String get wsPropertiesSelection => 'یک عنصر را انتخاب کنید.'; + + @override + String get wsImageDisplay => 'نمایش تصویر'; + + @override + String get wsImageThumbnail => 'تصویر بندانگشتی قابل کلیک'; + + @override + String get wsEnabled => 'فعال'; + + @override + String get wsDisabled => 'غیرفعال'; + + @override + String get wsWidth => 'عرض'; + + @override + String get wsHeight => 'ارتفاع'; + + @override + String get wsPropertiesInactive => + 'این ویژگی تنها با فعال بودن جمع‌شدن در انتشار اثر دارد.'; + + @override + String get wsNavigationAll => 'همه عناصر دارای عنوان'; + + @override + String get wsNavigationNone => 'بدون پیمایش'; + + @override + String get wsNavigationCustom => 'پیمایش سفارشی'; + + @override + String get wsTitleOverride => 'بازنویسی عنوان'; + + @override + String get wsCompactLayout => 'فشرده'; + + @override + String get wsWideLayout => 'عریض'; + + @override + String get wsCodeSourceConflict => + 'پیش از تنظیم src، کد نوشته‌شده در این بلوک را منتقل یا حذف کنید. Writerside بلوک کد غیرخالی با ارجاع منبع را نمی‌پذیرد.'; + + @override + String get wsInherited => 'ارث‌بری از فهرست'; + + @override + String get wsExternalImageSize => + 'Writerside اندازه تصاویر خارجی را به‌طور قابل اتکا تنظیم نمی‌کند. اندازه‌های موجود قابل بررسی و حذف هستند.'; + + @override + String get wsPropertiesNotSerialized => + 'این تغییر در متن منبع اعمال نشد. پیش‌نویس حفظ شده است.'; + + @override + String get wsListItem => 'مورد فهرست'; + + @override + String get wsSourceFile => 'فایل منبع'; + + @override + String get wsLinkTarget => 'مقصد پیوند'; + + @override + String get wsPreviewImage => 'تصویر پیش‌نمایش'; + + @override + String get wsIncludedElement => 'عنصر گنجانده‌شده'; + + @override + String get wsHelpModule => 'ماژول راهنما'; + + @override + String get wsActionId => 'شناسهٔ عمل'; + + @override + String get wsKeymapHelpInstance => 'نمونهٔ راهنمای نگاشت کلید'; + + @override + String get wsShowInSource => 'نمایش در منبع'; + + @override + String get wsOriginElement => 'بازنویسی عنصر'; + + @override + String get wsOriginAncestor => 'عنصر والد'; + + @override + String get wsOriginModule => 'پیش‌فرض ماژول'; + + @override + String get wsOriginBuiltIn => 'پیش‌فرض داخلی'; + + @override + String get wsOriginUnresolved => 'حل‌نشده'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'مقدار مؤثر: $value — $origin'; + } + + @override + String get wsElementSelector => 'عنصر'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'تولیدشده: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'پیش‌فرض: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'پیش‌فرض: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'مقدار حل‌شده: $value'; + } + + @override + String get wsPropertyUnresolved => 'حل‌نشده'; + + @override + String get wsModule => 'ماژول'; + + @override + String get wsParent => 'عنصر والد'; + + @override + String get wsListType => 'نوع فهرست'; + + @override + String get wsStartingNumber => 'شمارهٔ آغاز'; + + @override + String get wsListSorting => 'مرتب‌سازی'; + + @override + String get wsLetteredList => 'فهرست حروفی'; + + @override + String get wsCheckboxList => 'فهرست کادرهای انتخاب'; + + @override + String get wsMarkerlessList => 'فهرست بدون نشانگر'; + + @override + String get wsAscending => 'صعودی'; + + @override + String get wsDescending => 'نزولی'; + + @override + String get wsNoSorting => 'بدون مرتب‌سازی'; + + @override + String get wsAsWritten => 'همان‌طور که نوشته شده'; + + @override + String get wsSentenceCase => 'حالت حروف جمله'; + + @override + String get wsTitleCase => 'حالت حروف عنوان'; + + @override + String get wsLowerCase => 'حروف کوچک'; + + @override + String get wsUpperCase => 'حروف بزرگ'; + + @override + String get wsNativeMarkers => 'نشانگرهای Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'نشانگر آغاز: $number'; + } + + @override + String wsListLevel(String level) { + return 'سطح $level'; + } + + @override + String get wsModuleCapsIgnored => + 'سازندهٔ ثابت قواعد <caps> ماژول را نادیده می‌گیرد. از ویژگی caps محلی یا default-property استفاده کنید.'; + + @override + String get wsInsertElements => 'درج عناصر'; + + @override + String get wsAdmonitionsAndQuotes => 'یادآوری‌ها و نقل‌قول‌ها'; + + @override + String get wsSemanticText => 'متن معنایی'; + + @override + String get wsPropertiesUnavailable => + 'عنصر بررسی‌شده در دسترس نیست. برای ادامه عنصری را بررسی کنید.'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index ad479e4f..65489800 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'La casse du module n’est pas publiée.'; + + @override + String get wsTitleOverridesAction => 'Titres de l’instance et du sommaire…'; + + @override + String get wsHeadingNotPublished => 'Non publié par ce générateur.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Le constructeur fixé ne publie pas les titres par instance des sujets Markdown. Le balisage existant est conservé ; consultez la source.'; + + @override + String get wsTopicIdentity => 'ID du sujet'; + + @override + String get wsElementAnchor => 'Ancre de l’élément'; + + @override + String get wsHeadingText => 'Texte du titre'; + + @override + String get wsDefaultTitle => 'Titre par défaut'; + + @override + String get wsTopicNavigation => 'Navigation du sujet'; + + @override + String get wsHeadingOptions => 'Attributs du titre'; + + @override + String get wsDocumentOptions => 'Paramètres du document'; + + @override + String get wsSettingsPresent => 'Paramètres explicites présents'; + + @override + String get wsTitleOverrideExplanation => + 'Remplace le titre publié ; le texte du titre reste inchangé.'; + + @override + String get wsIgnoreVariables => 'Traiter les variables littéralement'; + + @override + String get wsIgnoreVariablesExplanation => + 'Si activé, le texte ressemblant à une variable n’est pas substitué.'; + + @override + String get wsInstanceSelection => 'Instances d’aide'; + + @override + String get wsInstanceExplanation => + 'Sélectionnez les sorties d’aide par noms, groupes, listes, exclusions ou expressions.'; + + @override + String get wsFilterExplanation => + 'Filtre le contenu réutilisable selon des critères ; ne sélectionne pas une instance d’aide.'; + + @override + String get wsContextHelp => 'ID d’aide contextuelle'; + + @override + String get wsCapitalization => 'Casse'; + + @override + String get wsLibraryTopic => 'Sujet de bibliothèque'; + + @override + String get wsLastModified => 'Dernière modification'; + + @override + String get wsMissingTopicHeading => + 'Aucun H1 de sujet. Ajoutez-le dans la source.'; + + @override + String get wsMultipleTopicHeadings => + 'Le premier H1 définit le titre du sujet. Les autres H1 sont des titres distincts.'; + + @override + String get wsTopicIdentityMismatch => + 'L’id racine diffère de l’ID issu du nom du fichier. Utilisez Renommer le fichier du sujet ou consultez la source.'; + + @override + String get wsUnsupportedProperties => + 'Les attributs non pris en charge sont conservés. Consultez-les dans la source.'; + + @override + String get wsGeneratedAnchor => + 'Ancre générée ; saisir une valeur crée un remplacement.'; + + @override + String get wsHeadingBuilderLimitation => + 'Le constructeur fixé ne publie pas les attributs H1. Ils appartiennent au titre, pas à la racine XML.'; + @override String get closeOtherTabs => 'Fermer les autres onglets'; @@ -4839,4 +4935,520 @@ class AppLocalizationsFr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La première requête a peut-être déjà créé une note sur le serveur. Ceci crée une note distincte avec votre travail local conservé. Aucun doublon supposé ne sera supprimé.'; + + @override + String get wsSemanticFormats => 'Mise en forme sémantique'; + + @override + String get wsUiControl => 'Contrôle d’interface'; + + @override + String get wsFilePath => 'Fichier / Chemin'; + + @override + String get wsUiPath => 'Chemin d’interface'; + + @override + String get wsDefinitionList => 'Liste de définitions'; + + @override + String get wsIncludeContent => 'Inclure du contenu réutilisable'; + + @override + String get wsVariableReference => 'Référence de variable'; + + @override + String get wsConvertList => 'Convertir la liste en procédure'; + + @override + String get wsElementProperties => 'Propriétés de l’élément'; + + @override + String get wsTopicProperties => 'Propriétés du sujet'; + + @override + String get wsCollapsible => 'Repliable'; + + @override + String get wsPublishedState => 'État initial publié'; + + @override + String get wsExpanded => 'Développé'; + + @override + String get wsCollapsed => 'Replié'; + + @override + String get wsCollapsedTitle => 'Titre replié'; + + @override + String get wsTerm => 'Terme'; + + @override + String get wsListLayout => 'Disposition de la liste'; + + @override + String get wsSyncGroup => 'Groupe de synchronisation'; + + @override + String get wsTabKey => 'Clé de l’onglet'; + + @override + String get wsSwitcherLabel => 'Libellé du sélecteur'; + + @override + String get wsNavigationElements => 'Éléments de navigation'; + + @override + String get wsNavigationDepth => 'Profondeur de navigation'; + + @override + String get wsAddItem => 'Ajouter un élément'; + + @override + String get wsStep => 'Étape'; + + @override + String get wsPropertyInvalid => 'Valeur de propriété non valide'; + + @override + String get wsFilter => 'Filtre'; + + @override + String get wsIdentifier => 'ID de l’élément'; + + @override + String get wsTldr => 'Résumé (TLDR)'; + + @override + String get wsFullLayout => 'Pleine largeur'; + + @override + String get wsMediumLayout => 'Moyen (1:2)'; + + @override + String get wsNarrowLayout => 'Terme étroit'; + + @override + String get wsTitle => 'Titre'; + + @override + String get wsSwitcherKey => 'Clé du sélecteur'; + + @override + String get nextcloudServerSettings => 'Paramètres Notes du serveur'; + + @override + String get nextcloudNotesPath => 'Chemin du dossier de notes'; + + @override + String get nextcloudFileSuffix => 'Extension des nouvelles notes'; + + @override + String get nextcloudServerSettingsExplanation => + 'Ces paramètres Notes du serveur affectent les autres clients. Changer le chemin ne déplace pas les fichiers ; changer l’extension ne renomme pas les notes existantes.'; + + @override + String get nextcloudSettingsSaved => 'Paramètres serveur enregistrés'; + + @override + String get nextcloudSettingsNormalized => + 'Enregistré avec les valeurs normalisées par le serveur'; + + @override + String get nextcloudPendingUpload => 'Envoi de pièce jointe en attente'; + + @override + String nextcloudServerCheck(String time) { + return 'Dernière vérification du serveur : $time'; + } + + @override + String get nextcloudSettingsLoading => 'Chargement des paramètres du serveur'; + + @override + String get nextcloudSettingsSaving => + 'Enregistrement des paramètres du serveur'; + + @override + String get nextcloudPreviousLocalEdit => 'Modification locale précédente'; + + @override + String get nextcloudUsePreviousEdit => 'Utiliser la modification précédente'; + + @override + String get quickOpen => 'Ouverture rapide'; + + @override + String get notesAll => 'Toutes les notes'; + + @override + String get notesUncategorized => 'Sans catégorie'; + + @override + String get notesRecovery => 'Récupération'; + + @override + String get notesRecentlyEdited => 'Modifiées récemment'; + + @override + String get notesOldestEdited => 'Modifiées les plus anciennes'; + + @override + String get notesTitleAscending => 'Titre A–Z'; + + @override + String get notesTitleDescending => 'Titre Z–A'; + + @override + String get notesMove => 'Déplacer vers une catégorie'; + + @override + String get notesRemoveFavorite => 'Retirer des favoris'; + + @override + String get notesOffline => 'Rendre disponible hors ligne'; + + @override + String get notesOfflineRemove => 'Retirer la conservation hors ligne'; + + @override + String get notesIndexing => 'Indexation des notes…'; + + @override + String get notesSearchHelp => + 'Les termes utilisent ET. Utilisez \"des expressions\", title: et category:.'; + + @override + String get notesSourceLocation => 'Emplacement source'; + + @override + String get notesRecoveryHelp => + 'Notes locales conservées et historique local uniquement. La corbeille et l’historique du serveur sont indisponibles.'; + + @override + String get notesImport => 'Importer des notes'; + + @override + String get notesExport => 'Exporter des notes'; + + @override + String get notesImportDistinct => 'Importer comme une note distincte'; + + @override + String get notesAvailableOffline => 'Disponible hors ligne'; + + @override + String get notesIncomplete => 'Incomplet'; + + @override + String get notesSelected => 'Notes sélectionnées'; + + @override + String get notesNoHistory => + 'Aucune révision conservée. L’historique peut être désactivé, expiré ou effacé.'; + + @override + String get notesTextAvailable => 'Texte disponible localement'; + + @override + String get notesLocalVersion => 'Version de travail locale'; + + @override + String get notesAttachments => 'Pièces jointes'; + + @override + String get notesSkipped => 'Ignoré'; + + @override + String get notesFailed => 'Échec'; + + @override + String get notesCompleted => 'Terminé'; + + @override + String get notesImportNew => 'Démarrer une nouvelle importation'; + + @override + String get notesImportResume => 'Reprendre l’importation interrompue'; + + @override + String get notesImportAlreadyImported => 'Déjà importé dans cette opération'; + + @override + String get references => 'Références'; + + @override + String get referenceIncoming => 'Entrantes'; + + @override + String get referenceOutgoing => 'Sortantes'; + + @override + String get referenceChooseTarget => 'Choisir la cible du lien'; + + @override + String get referenceWholeDocument => 'Document entier'; + + @override + String get referenceSourceLocation => 'Emplacement source'; + + @override + String get referenceTargetLocation => 'Emplacement cible'; + + @override + String get referenceResolved => 'Résolue'; + + @override + String get referenceMissing => 'Manquante'; + + @override + String get referenceAmbiguous => 'Ambiguë'; + + @override + String get referenceUnavailable => 'Indisponible dans ce contexte'; + + @override + String get referenceUnsupported => 'Non prise en charge'; + + @override + String get referenceNoPortableId => + 'Cette cible ne possède pas d’URL de note portable vérifiée.'; + + @override + String get wsIncludeLines => 'Inclure les lignes'; + + @override + String get wsIncludeSymbol => 'Inclure le symbole'; + + @override + String get wsEmphasizeLines => 'Surligner les lignes affichées'; + + @override + String get wsPropertiesReadOnly => + 'Propriétés en lecture seule dans cette vue.'; + + @override + String get wsPropertiesProtected => + 'Cet élément est protégé. Modifiez sa source.'; + + @override + String get wsPropertiesConflict => + 'Le document ou élément a changé. Annulez le brouillon et sélectionnez de nouveau.'; + + @override + String get wsPropertiesUnresolved => + 'La référence ne peut pas être résolue actuellement.'; + + @override + String get wsUseDefault => 'Utiliser la valeur par défaut'; + + @override + String get wsPropertiesSelection => 'Sélectionnez un seul élément.'; + + @override + String get wsImageDisplay => 'Affichage de l’image'; + + @override + String get wsImageThumbnail => 'Miniature cliquable'; + + @override + String get wsEnabled => 'Activé'; + + @override + String get wsDisabled => 'Désactivé'; + + @override + String get wsWidth => 'Largeur'; + + @override + String get wsHeight => 'Hauteur'; + + @override + String get wsPropertiesInactive => + 'Sans repli publié, cette propriété est sans effet.'; + + @override + String get wsNavigationAll => 'Tous les éléments avec titre'; + + @override + String get wsNavigationNone => 'Aucune navigation'; + + @override + String get wsNavigationCustom => 'Navigation personnalisée'; + + @override + String get wsTitleOverride => 'Remplacer le titre'; + + @override + String get wsCompactLayout => 'Disposition compacte'; + + @override + String get wsWideLayout => 'Large'; + + @override + String get wsCodeSourceConflict => + 'Déplacez ou supprimez le code écrit dans ce bloc avant de définir src. Writerside refuse un bloc de code non vide avec une référence source.'; + + @override + String get wsInherited => 'Hériter de la liste'; + + @override + String get wsExternalImageSize => + 'Writerside ne dimensionne pas de manière fiable les images externes. Les dimensions existantes restent consultables et supprimables.'; + + @override + String get wsPropertiesNotSerialized => + 'Cette modification n’a pas été appliquée à la source. Le brouillon a été conservé.'; + + @override + String get wsListItem => 'Élément de liste'; + + @override + String get wsSourceFile => 'Fichier source'; + + @override + String get wsLinkTarget => 'Cible du lien'; + + @override + String get wsPreviewImage => 'Image d’aperçu'; + + @override + String get wsIncludedElement => 'Élément inclus'; + + @override + String get wsHelpModule => 'Module d’aide'; + + @override + String get wsActionId => 'ID d’action'; + + @override + String get wsKeymapHelpInstance => 'Instance d’aide du clavier'; + + @override + String get wsShowInSource => 'Afficher dans Source'; + + @override + String get wsOriginElement => 'Valeur de l’élément'; + + @override + String get wsOriginAncestor => 'Élément parent'; + + @override + String get wsOriginModule => 'Valeur par défaut du module'; + + @override + String get wsOriginBuiltIn => 'Valeur intégrée par défaut'; + + @override + String get wsOriginUnresolved => 'Non résolu'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effectif : $value — $origin'; + } + + @override + String get wsElementSelector => 'Élément'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Généré : $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Par défaut : $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Par défaut : $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Résolu : $value'; + } + + @override + String get wsPropertyUnresolved => 'Non résolu'; + + @override + String get wsModule => 'Module'; + + @override + String get wsParent => 'Élément parent'; + + @override + String get wsListType => 'Type de liste'; + + @override + String get wsStartingNumber => 'Numéro de départ'; + + @override + String get wsListSorting => 'Tri'; + + @override + String get wsLetteredList => 'Liste alphabétique'; + + @override + String get wsCheckboxList => 'Liste à cases à cocher'; + + @override + String get wsMarkerlessList => 'Liste sans marqueurs'; + + @override + String get wsAscending => 'Croissant'; + + @override + String get wsDescending => 'Décroissant'; + + @override + String get wsNoSorting => 'Sans tri'; + + @override + String get wsAsWritten => 'Tel qu’écrit'; + + @override + String get wsSentenceCase => 'Casse de phrase'; + + @override + String get wsTitleCase => 'Casse de titre'; + + @override + String get wsLowerCase => 'Minuscules'; + + @override + String get wsUpperCase => 'Majuscules'; + + @override + String get wsNativeMarkers => 'Marqueurs Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Premier marqueur : $number'; + } + + @override + String wsListLevel(String level) { + return 'Niveau $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Le compilateur fixé ignore les règles <caps> du module. Utilisez un attribut caps local ou default-property.'; + + @override + String get wsInsertElements => 'Insérer des éléments'; + + @override + String get wsAdmonitionsAndQuotes => 'Avertissements et citations'; + + @override + String get wsSemanticText => 'Texte sémantique'; + + @override + String get wsPropertiesUnavailable => + 'L’élément inspecté est indisponible. Inspectez un élément pour continuer.'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 864bd746..a27cb5b2 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'मॉड्यूल का अक्षर-शैली नियम प्रकाशित नहीं होता।'; + + @override + String get wsTitleOverridesAction => 'इंस्टेंस और विषय-सूची के शीर्षक…'; + + @override + String get wsHeadingNotPublished => 'यह बिल्डर इसे प्रकाशित नहीं करता।'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'निश्चित बिल्डर Markdown विषयों में इंस्टेंस शीर्षक प्रकाशित नहीं करता। मौजूदा मार्कअप सुरक्षित है; स्रोत में देखें।'; + + @override + String get wsTopicIdentity => 'विषय ID'; + + @override + String get wsElementAnchor => 'तत्व एंकर'; + + @override + String get wsHeadingText => 'शीर्षक पाठ'; + + @override + String get wsDefaultTitle => 'डिफ़ॉल्ट शीर्षक'; + + @override + String get wsTopicNavigation => 'विषय नेविगेशन'; + + @override + String get wsHeadingOptions => 'शीर्षक विशेषताएँ'; + + @override + String get wsDocumentOptions => 'दस्तावेज़ सेटिंग'; + + @override + String get wsSettingsPresent => 'लिखी हुई सेटिंग मौजूद हैं'; + + @override + String get wsTitleOverrideExplanation => + 'प्रकाशित शीर्षक को बदलता है; शीर्षक पाठ अपरिवर्तित रहता है।'; + + @override + String get wsIgnoreVariables => 'चरों को शाब्दिक पाठ मानें'; + + @override + String get wsIgnoreVariablesExplanation => + 'सक्षम होने पर चर जैसा पाठ प्रतिस्थापित नहीं होता।'; + + @override + String get wsInstanceSelection => 'सहायता इंस्टेंस'; + + @override + String get wsInstanceExplanation => + 'नाम, समूह, सूची, बहिष्करण या अभिव्यक्ति से सहायता आउटपुट चुनें।'; + + @override + String get wsFilterExplanation => + 'पुनः उपयोग की सामग्री को मानदंड से फ़िल्टर करता है; सहायता इंस्टेंस नहीं चुनता।'; + + @override + String get wsContextHelp => 'प्रासंगिक सहायता ID'; + + @override + String get wsCapitalization => 'अक्षर केस'; + + @override + String get wsLibraryTopic => 'लाइब्रेरी विषय'; + + @override + String get wsLastModified => 'अंतिम संशोधन'; + + @override + String get wsMissingTopicHeading => + 'विषय H1 मौजूद नहीं है। स्रोत में जोड़ें।'; + + @override + String get wsMultipleTopicHeadings => + 'पहला H1 विषय शीर्षक निर्धारित करता है। बाद के H1 अलग शीर्षक हैं।'; + + @override + String get wsTopicIdentityMismatch => + 'मूल id फ़ाइलनाम से बने विषय ID से अलग है। विषय फ़ाइल का नाम बदलें या स्रोत देखें।'; + + @override + String get wsUnsupportedProperties => + 'असमर्थित विशेषताएँ सुरक्षित रहती हैं। स्रोत में देखें।'; + + @override + String get wsGeneratedAnchor => + 'बना हुआ एंकर; मान दर्ज करने से ओवरराइड बनता है।'; + + @override + String get wsHeadingBuilderLimitation => + 'निश्चित बिल्डर H1 विशेषताएँ प्रकाशित नहीं करता। वे शीर्षक की हैं, XML मूल की नहीं।'; + @override String get closeOtherTabs => 'अन्य टैब बंद करें'; @@ -4778,4 +4874,518 @@ class AppLocalizationsHi extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'पहले अनुरोध ने सर्वर पर पहले ही नोट बना दिया हो सकता है। यह आपके सुरक्षित स्थानीय काम के साथ अलग नोट बनाता है। किसी संभावित प्रतिलिपि को हटाया नहीं जाएगा।'; + + @override + String get wsSemanticFormats => 'अर्थपूर्ण स्वरूपण'; + + @override + String get wsUiControl => 'यूआई नियंत्रण'; + + @override + String get wsFilePath => 'फ़ाइल / पथ'; + + @override + String get wsUiPath => 'यूआई पथ'; + + @override + String get wsDefinitionList => 'परिभाषा सूची'; + + @override + String get wsIncludeContent => 'पुनः उपयोग योग्य सामग्री शामिल करें'; + + @override + String get wsVariableReference => 'चर संदर्भ'; + + @override + String get wsConvertList => 'सूची को प्रक्रिया में बदलें'; + + @override + String get wsElementProperties => 'तत्व के गुण'; + + @override + String get wsTopicProperties => 'विषय के गुण'; + + @override + String get wsCollapsible => 'समेटने योग्य'; + + @override + String get wsPublishedState => 'प्रकाशित आरंभिक स्थिति'; + + @override + String get wsExpanded => 'विस्तृत'; + + @override + String get wsCollapsed => 'संक्षिप्त'; + + @override + String get wsCollapsedTitle => 'संक्षिप्त शीर्षक'; + + @override + String get wsTerm => 'शब्द'; + + @override + String get wsListLayout => 'सूची लेआउट'; + + @override + String get wsSyncGroup => 'सिंक समूह'; + + @override + String get wsTabKey => 'टैब कुंजी'; + + @override + String get wsSwitcherLabel => 'स्विचर लेबल'; + + @override + String get wsNavigationElements => 'नेविगेशन तत्व'; + + @override + String get wsNavigationDepth => 'नेविगेशन गहराई'; + + @override + String get wsAddItem => 'आइटम जोड़ें'; + + @override + String get wsStep => 'चरण'; + + @override + String get wsPropertyInvalid => 'अमान्य गुण मान'; + + @override + String get wsFilter => 'फ़िल्टर'; + + @override + String get wsIdentifier => 'तत्व आईडी'; + + @override + String get wsTldr => 'सारांश (TLDR)'; + + @override + String get wsFullLayout => 'पूरी चौड़ाई'; + + @override + String get wsMediumLayout => 'मध्यम (1:2)'; + + @override + String get wsNarrowLayout => 'संकीर्ण शब्द स्तंभ'; + + @override + String get wsTitle => 'शीर्षक'; + + @override + String get wsSwitcherKey => 'स्विचर कुंजी'; + + @override + String get nextcloudServerSettings => 'सर्वर Notes सेटिंग्स'; + + @override + String get nextcloudNotesPath => 'नोट फ़ोल्डर का पथ'; + + @override + String get nextcloudFileSuffix => 'नए नोट की फ़ाइल का प्रत्यय'; + + @override + String get nextcloudServerSettingsExplanation => + 'ये सर्वर Notes सेटिंग्स दूसरे क्लाइंट को भी प्रभावित करती हैं। पथ बदलने से फ़ाइलें स्थानांतरित नहीं होतीं; प्रत्यय बदलने से मौजूदा नोट का नाम नहीं बदलता।'; + + @override + String get nextcloudSettingsSaved => 'सर्वर सेटिंग्स सहेजी गईं'; + + @override + String get nextcloudSettingsNormalized => + 'सर्वर द्वारा सामान्यीकृत मानों के साथ सहेजा गया'; + + @override + String get nextcloudPendingUpload => 'अटैचमेंट अपलोड लंबित है'; + + @override + String nextcloudServerCheck(String time) { + return 'सर्वर की अंतिम जाँच: $time'; + } + + @override + String get nextcloudSettingsLoading => 'सर्वर सेटिंग लोड हो रही हैं'; + + @override + String get nextcloudSettingsSaving => 'सर्वर सेटिंग सहेजी जा रही हैं'; + + @override + String get nextcloudPreviousLocalEdit => 'पिछला स्थानीय संपादन'; + + @override + String get nextcloudUsePreviousEdit => 'पिछला संपादन इस्तेमाल करें'; + + @override + String get quickOpen => 'त्वरित खोलें'; + + @override + String get notesAll => 'सभी नोट्स'; + + @override + String get notesUncategorized => 'बिना श्रेणी'; + + @override + String get notesRecovery => 'पुनर्प्राप्ति'; + + @override + String get notesRecentlyEdited => 'हाल में संपादित'; + + @override + String get notesOldestEdited => 'सबसे पुराना संपादन'; + + @override + String get notesTitleAscending => 'शीर्षक आरोही'; + + @override + String get notesTitleDescending => 'शीर्षक अवरोही'; + + @override + String get notesMove => 'श्रेणी में ले जाएँ'; + + @override + String get notesRemoveFavorite => 'पसंदीदा हटाएँ'; + + @override + String get notesOffline => 'ऑफ़लाइन उपलब्ध करें'; + + @override + String get notesOfflineRemove => 'ऑफ़लाइन संरक्षण हटाएँ'; + + @override + String get notesIndexing => 'नोट्स की अनुक्रमणिका बन रही है…'; + + @override + String get notesSearchHelp => + 'शब्द AND से जुड़ते हैं। \"उद्धृत वाक्यांश\", title: और category: का उपयोग करें।'; + + @override + String get notesSourceLocation => 'स्रोत स्थान'; + + @override + String get notesRecoveryHelp => + 'केवल संरक्षित स्थानीय नोट्स और स्थानीय इतिहास। सर्वर का कचरा और इतिहास उपलब्ध नहीं है।'; + + @override + String get notesImport => 'नोट्स आयात करें'; + + @override + String get notesExport => 'नोट्स निर्यात करें'; + + @override + String get notesImportDistinct => 'अलग नोट के रूप में आयात करें'; + + @override + String get notesAvailableOffline => 'ऑफ़लाइन उपलब्ध'; + + @override + String get notesIncomplete => 'अपूर्ण'; + + @override + String get notesSelected => 'चुने गए नोट्स'; + + @override + String get notesNoHistory => + 'कोई संरक्षित संशोधन नहीं। इतिहास बंद, समाप्त या साफ़ किया गया हो सकता है।'; + + @override + String get notesTextAvailable => 'पाठ स्थानीय रूप से उपलब्ध है'; + + @override + String get notesLocalVersion => 'स्थानीय कार्य संस्करण'; + + @override + String get notesAttachments => 'अटैचमेंट'; + + @override + String get notesSkipped => 'छोड़ा गया'; + + @override + String get notesFailed => 'विफल'; + + @override + String get notesCompleted => 'पूर्ण'; + + @override + String get notesImportNew => 'नया आयात शुरू करें'; + + @override + String get notesImportResume => 'बाधित आयात फिर से शुरू करें'; + + @override + String get notesImportAlreadyImported => + 'इस प्रक्रिया में पहले ही आयात किया गया'; + + @override + String get references => 'संदर्भ'; + + @override + String get referenceIncoming => 'आने वाले'; + + @override + String get referenceOutgoing => 'जाने वाले'; + + @override + String get referenceChooseTarget => 'लिंक लक्ष्य चुनें'; + + @override + String get referenceWholeDocument => 'पूरा दस्तावेज़'; + + @override + String get referenceSourceLocation => 'स्रोत स्थान'; + + @override + String get referenceTargetLocation => 'लक्ष्य स्थान'; + + @override + String get referenceResolved => 'हल हुआ'; + + @override + String get referenceMissing => 'गुम'; + + @override + String get referenceAmbiguous => 'अस्पष्ट'; + + @override + String get referenceUnavailable => 'इस संदर्भ में अनुपलब्ध'; + + @override + String get referenceUnsupported => 'असमर्थित'; + + @override + String get referenceNoPortableId => + 'इस लक्ष्य का सत्यापित पोर्टेबल नोट URL नहीं है.'; + + @override + String get wsIncludeLines => 'पंक्तियाँ शामिल करें'; + + @override + String get wsIncludeSymbol => 'प्रतीक शामिल करें'; + + @override + String get wsEmphasizeLines => 'दिखाई गई पंक्तियाँ हाइलाइट करें'; + + @override + String get wsPropertiesReadOnly => 'इस दृश्य में गुण केवल पढ़ने के लिए हैं।'; + + @override + String get wsPropertiesProtected => + 'यह तत्व सुरक्षित है। इसका स्रोत संपादित करें।'; + + @override + String get wsPropertiesConflict => + 'दस्तावेज़ या तत्व बदल गया। मसौदा वापस करें और फिर चुनें।'; + + @override + String get wsPropertiesUnresolved => 'संदर्भ अभी हल नहीं हो सकता।'; + + @override + String get wsUseDefault => 'डिफ़ॉल्ट उपयोग करें'; + + @override + String get wsPropertiesSelection => 'एक तत्व चुनें।'; + + @override + String get wsImageDisplay => 'छवि प्रदर्शन'; + + @override + String get wsImageThumbnail => 'क्लिक करने योग्य थंबनेल'; + + @override + String get wsEnabled => 'सक्षम'; + + @override + String get wsDisabled => 'अक्षम'; + + @override + String get wsWidth => 'चौड़ाई'; + + @override + String get wsHeight => 'ऊँचाई'; + + @override + String get wsPropertiesInactive => + 'प्रकाशित संकुचन बंद होने पर इस गुण का प्रभाव नहीं होता।'; + + @override + String get wsNavigationAll => 'सभी शीर्षक वाले तत्व'; + + @override + String get wsNavigationNone => 'नेविगेशन नहीं'; + + @override + String get wsNavigationCustom => 'कस्टम नेविगेशन'; + + @override + String get wsTitleOverride => 'शीर्षक बदलें'; + + @override + String get wsCompactLayout => 'संक्षिप्त'; + + @override + String get wsWideLayout => 'चौड़ा'; + + @override + String get wsCodeSourceConflict => + 'src सेट करने से पहले इस ब्लॉक में लिखे कोड को हटाएँ या स्थानांतरित करें। Writerside स्रोत संदर्भ वाले गैर-खाली कोड ब्लॉक को अस्वीकार करता है।'; + + @override + String get wsInherited => 'सूची से प्राप्त करें'; + + @override + String get wsExternalImageSize => + 'Writerside बाहरी छवियों का आकार विश्वसनीय ढंग से नहीं बदलता। मौजूदा आयाम देखे और हटाए जा सकते हैं।'; + + @override + String get wsPropertiesNotSerialized => + 'यह बदलाव स्रोत में लागू नहीं हुआ। मसौदा सुरक्षित रखा गया है।'; + + @override + String get wsListItem => 'सूची आइटम'; + + @override + String get wsSourceFile => 'स्रोत फ़ाइल'; + + @override + String get wsLinkTarget => 'लिंक लक्ष्य'; + + @override + String get wsPreviewImage => 'पूर्वावलोकन छवि'; + + @override + String get wsIncludedElement => 'शामिल तत्व'; + + @override + String get wsHelpModule => 'सहायता मॉड्यूल'; + + @override + String get wsActionId => 'कार्रवाई ID'; + + @override + String get wsKeymapHelpInstance => 'कीमैप सहायता इंस्टेंस'; + + @override + String get wsShowInSource => 'स्रोत में दिखाएँ'; + + @override + String get wsOriginElement => 'तत्व का ओवरराइड'; + + @override + String get wsOriginAncestor => 'मूल तत्व'; + + @override + String get wsOriginModule => 'मॉड्यूल का डिफ़ॉल्ट'; + + @override + String get wsOriginBuiltIn => 'अंतर्निहित डिफ़ॉल्ट'; + + @override + String get wsOriginUnresolved => 'अनसुलझा'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'प्रभावी: $value — $origin'; + } + + @override + String get wsElementSelector => 'तत्व'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'जनित: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'डिफ़ॉल्ट: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'डिफ़ॉल्ट: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'समाधित: $value'; + } + + @override + String get wsPropertyUnresolved => 'असमाधित'; + + @override + String get wsModule => 'मॉड्यूल'; + + @override + String get wsParent => 'मूल तत्व'; + + @override + String get wsListType => 'सूची प्रकार'; + + @override + String get wsStartingNumber => 'आरंभ संख्या'; + + @override + String get wsListSorting => 'क्रमबद्ध करना'; + + @override + String get wsLetteredList => 'अक्षर सूची'; + + @override + String get wsCheckboxList => 'चेकबॉक्स सूची'; + + @override + String get wsMarkerlessList => 'बिना चिह्नों की सूची'; + + @override + String get wsAscending => 'आरोही'; + + @override + String get wsDescending => 'अवरोही'; + + @override + String get wsNoSorting => 'क्रमबद्ध नहीं'; + + @override + String get wsAsWritten => 'जैसा लिखा है'; + + @override + String get wsSentenceCase => 'वाक्य अक्षर शैली'; + + @override + String get wsTitleCase => 'शीर्षक अक्षर शैली'; + + @override + String get wsLowerCase => 'छोटे अक्षर'; + + @override + String get wsUpperCase => 'बड़े अक्षर'; + + @override + String get wsNativeMarkers => 'Markdown चिह्न'; + + @override + String wsListMarkerStart(String number) { + return 'आरंभ चिह्न: $number'; + } + + @override + String wsListLevel(String level) { + return 'स्तर $level'; + } + + @override + String get wsModuleCapsIgnored => + 'निर्धारित बिल्डर मॉड्यूल के <caps> नियमों को अनदेखा करता है। स्थानीय caps विशेषता या default-property का उपयोग करें।'; + + @override + String get wsInsertElements => 'तत्व जोड़ें'; + + @override + String get wsAdmonitionsAndQuotes => 'सूचनाएँ और उद्धरण'; + + @override + String get wsSemanticText => 'अर्थपूर्ण पाठ'; + + @override + String get wsPropertiesUnavailable => + 'जाँचा गया तत्व उपलब्ध नहीं है। जारी रखने के लिए कोई तत्व जाँचें।'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 350e4af3..d04626c4 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsId extends AppLocalizations { AppLocalizationsId([String locale = 'id']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Kapitalisasi modul tidak diterbitkan.'; + + @override + String get wsTitleOverridesAction => 'Judul instans dan daftar isi…'; + + @override + String get wsHeadingNotPublished => 'Tidak diterbitkan oleh pembuat ini.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Builder yang dipatok tidak menerbitkan judul instans pada topik Markdown. Markup yang ada tetap disimpan; periksa di Sumber.'; + + @override + String get wsTopicIdentity => 'ID topik'; + + @override + String get wsElementAnchor => 'Jangkar elemen'; + + @override + String get wsHeadingText => 'Teks judul'; + + @override + String get wsDefaultTitle => 'Judul bawaan'; + + @override + String get wsTopicNavigation => 'Navigasi topik'; + + @override + String get wsHeadingOptions => 'Atribut judul'; + + @override + String get wsDocumentOptions => 'Pengaturan dokumen'; + + @override + String get wsSettingsPresent => 'Ada pengaturan tertulis'; + + @override + String get wsTitleOverrideExplanation => + 'Mengganti judul yang diterbitkan; teks judul tidak berubah.'; + + @override + String get wsIgnoreVariables => 'Perlakukan variabel sebagai teks literal'; + + @override + String get wsIgnoreVariablesExplanation => + 'Jika diaktifkan, teks yang menyerupai variabel tidak diganti.'; + + @override + String get wsInstanceSelection => 'Instans bantuan'; + + @override + String get wsInstanceExplanation => + 'Pilih keluaran bantuan dengan nama, grup, daftar, pengecualian, atau ekspresi.'; + + @override + String get wsFilterExplanation => + 'Menyaring konten pakai ulang menurut kriteria; tidak memilih instans bantuan.'; + + @override + String get wsContextHelp => 'ID bantuan kontekstual'; + + @override + String get wsCapitalization => 'Kapitalisasi'; + + @override + String get wsLibraryTopic => 'Topik pustaka'; + + @override + String get wsLastModified => 'Terakhir diubah'; + + @override + String get wsMissingTopicHeading => + 'Tidak ada H1 topik. Tambahkan di Sumber.'; + + @override + String get wsMultipleTopicHeadings => + 'H1 pertama menentukan judul topik. H1 berikutnya adalah judul terpisah.'; + + @override + String get wsTopicIdentityMismatch => + 'id akar berbeda dari ID berdasarkan nama berkas. Gunakan Ubah nama berkas topik atau periksa Sumber.'; + + @override + String get wsUnsupportedProperties => + 'Atribut yang tidak didukung tetap disimpan. Periksa di Sumber.'; + + @override + String get wsGeneratedAnchor => + 'Jangkar yang dihasilkan; memasukkan nilai membuat penimpaan.'; + + @override + String get wsHeadingBuilderLimitation => + 'Builder yang dipatok tidak menerbitkan atribut H1. Atribut milik judul, bukan akar XML.'; + @override String get closeOtherTabs => 'Tutup tab lainnya'; @@ -4788,4 +4884,516 @@ class AppLocalizationsId extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Permintaan pertama mungkin sudah membuat catatan server. Ini membuat catatan terpisah dengan pekerjaan lokal yang dipertahankan. Tidak ada dugaan duplikat yang akan dihapus.'; + + @override + String get wsSemanticFormats => 'Pemformatan semantik'; + + @override + String get wsUiControl => 'Kontrol antarmuka'; + + @override + String get wsFilePath => 'Berkas / Jalur'; + + @override + String get wsUiPath => 'Jalur antarmuka'; + + @override + String get wsDefinitionList => 'Daftar definisi'; + + @override + String get wsIncludeContent => 'Sertakan konten yang dapat digunakan ulang'; + + @override + String get wsVariableReference => 'Referensi variabel'; + + @override + String get wsConvertList => 'Ubah daftar menjadi prosedur'; + + @override + String get wsElementProperties => 'Properti elemen'; + + @override + String get wsTopicProperties => 'Properti topik'; + + @override + String get wsCollapsible => 'Dapat diciutkan'; + + @override + String get wsPublishedState => 'Status awal publikasi'; + + @override + String get wsExpanded => 'Diperluas'; + + @override + String get wsCollapsed => 'Diciutkan'; + + @override + String get wsCollapsedTitle => 'Judul saat diciutkan'; + + @override + String get wsTerm => 'Istilah'; + + @override + String get wsListLayout => 'Tata letak daftar'; + + @override + String get wsSyncGroup => 'Grup sinkronisasi'; + + @override + String get wsTabKey => 'Kunci tab'; + + @override + String get wsSwitcherLabel => 'Label pengalih'; + + @override + String get wsNavigationElements => 'Elemen navigasi'; + + @override + String get wsNavigationDepth => 'Kedalaman navigasi'; + + @override + String get wsAddItem => 'Tambah item'; + + @override + String get wsStep => 'Langkah'; + + @override + String get wsPropertyInvalid => 'Nilai properti tidak valid'; + + @override + String get wsFilter => 'Filter konten'; + + @override + String get wsIdentifier => 'ID elemen'; + + @override + String get wsTldr => 'Ringkasan (TLDR)'; + + @override + String get wsFullLayout => 'Lebar penuh'; + + @override + String get wsMediumLayout => 'Sedang (1:2)'; + + @override + String get wsNarrowLayout => 'Kolom istilah sempit'; + + @override + String get wsTitle => 'Judul'; + + @override + String get wsSwitcherKey => 'Kunci pemilih'; + + @override + String get nextcloudServerSettings => 'Pengaturan Notes server'; + + @override + String get nextcloudNotesPath => 'Jalur folder catatan'; + + @override + String get nextcloudFileSuffix => 'Ekstensi berkas catatan baru'; + + @override + String get nextcloudServerSettingsExplanation => + 'Pengaturan Notes server ini memengaruhi klien lain. Mengubah jalur tidak memindahkan berkas; mengubah ekstensi tidak mengganti nama catatan yang ada.'; + + @override + String get nextcloudSettingsSaved => 'Pengaturan server disimpan'; + + @override + String get nextcloudSettingsNormalized => + 'Disimpan dengan nilai yang dinormalisasi server'; + + @override + String get nextcloudPendingUpload => 'Unggahan lampiran tertunda'; + + @override + String nextcloudServerCheck(String time) { + return 'Pemeriksaan server terakhir: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Memuat pengaturan server'; + + @override + String get nextcloudSettingsSaving => 'Menyimpan pengaturan server'; + + @override + String get nextcloudPreviousLocalEdit => 'Edit lokal sebelumnya'; + + @override + String get nextcloudUsePreviousEdit => 'Gunakan edit sebelumnya'; + + @override + String get quickOpen => 'Buka cepat'; + + @override + String get notesAll => 'Semua catatan'; + + @override + String get notesUncategorized => 'Tanpa kategori'; + + @override + String get notesRecovery => 'Pemulihan'; + + @override + String get notesRecentlyEdited => 'Baru diedit'; + + @override + String get notesOldestEdited => 'Edit terlama'; + + @override + String get notesTitleAscending => 'Judul A–Z'; + + @override + String get notesTitleDescending => 'Judul Z–A'; + + @override + String get notesMove => 'Pindah ke kategori'; + + @override + String get notesRemoveFavorite => 'Hapus favorit'; + + @override + String get notesOffline => 'Sediakan secara luring'; + + @override + String get notesOfflineRemove => 'Hapus penyimpanan luring'; + + @override + String get notesIndexing => 'Mengindeks catatan…'; + + @override + String get notesSearchHelp => + 'Istilah digabung dengan DAN. Gunakan \"frasa kutipan\", title: dan category:.'; + + @override + String get notesSourceLocation => 'Lokasi sumber'; + + @override + String get notesRecoveryHelp => + 'Hanya catatan lokal tersimpan dan riwayat lokal. Sampah dan riwayat server tidak tersedia.'; + + @override + String get notesImport => 'Impor catatan'; + + @override + String get notesExport => 'Ekspor catatan'; + + @override + String get notesImportDistinct => 'Impor sebagai catatan terpisah'; + + @override + String get notesAvailableOffline => 'Tersedia secara luring'; + + @override + String get notesIncomplete => 'Tidak lengkap'; + + @override + String get notesSelected => 'Catatan terpilih'; + + @override + String get notesNoHistory => + 'Tidak ada revisi tersimpan. Riwayat mungkin dinonaktifkan, kedaluwarsa atau dihapus.'; + + @override + String get notesTextAvailable => 'Teks tersedia secara lokal'; + + @override + String get notesLocalVersion => 'Versi kerja lokal'; + + @override + String get notesAttachments => 'Lampiran'; + + @override + String get notesSkipped => 'Dilewati'; + + @override + String get notesFailed => 'Gagal'; + + @override + String get notesCompleted => 'Selesai'; + + @override + String get notesImportNew => 'Mulai impor baru'; + + @override + String get notesImportResume => 'Lanjutkan impor yang terputus'; + + @override + String get notesImportAlreadyImported => 'Sudah diimpor dalam operasi ini'; + + @override + String get references => 'Referensi'; + + @override + String get referenceIncoming => 'Masuk'; + + @override + String get referenceOutgoing => 'Keluar'; + + @override + String get referenceChooseTarget => 'Pilih tujuan tautan'; + + @override + String get referenceWholeDocument => 'Seluruh dokumen'; + + @override + String get referenceSourceLocation => 'Lokasi sumber'; + + @override + String get referenceTargetLocation => 'Lokasi tujuan'; + + @override + String get referenceResolved => 'Terselesaikan'; + + @override + String get referenceMissing => 'Tidak ditemukan'; + + @override + String get referenceAmbiguous => 'Ambigu'; + + @override + String get referenceUnavailable => 'Tidak tersedia dalam konteks ini'; + + @override + String get referenceUnsupported => 'Tidak didukung'; + + @override + String get referenceNoPortableId => + 'Tujuan ini tidak memiliki URL catatan portabel yang terverifikasi.'; + + @override + String get wsIncludeLines => 'Sertakan baris'; + + @override + String get wsIncludeSymbol => 'Sertakan simbol'; + + @override + String get wsEmphasizeLines => 'Sorot baris yang ditampilkan'; + + @override + String get wsPropertiesReadOnly => 'Properti hanya baca pada tampilan ini.'; + + @override + String get wsPropertiesProtected => 'Elemen dilindungi. Edit sumbernya.'; + + @override + String get wsPropertiesConflict => + 'Dokumen atau elemen berubah. Kembalikan draf dan pilih lagi.'; + + @override + String get wsPropertiesUnresolved => 'Referensi belum dapat diselesaikan.'; + + @override + String get wsUseDefault => 'Gunakan bawaan'; + + @override + String get wsPropertiesSelection => 'Pilih satu elemen.'; + + @override + String get wsImageDisplay => 'Tampilan gambar'; + + @override + String get wsImageThumbnail => 'Thumbnail yang dapat diklik'; + + @override + String get wsEnabled => 'Aktif'; + + @override + String get wsDisabled => 'Nonaktif'; + + @override + String get wsWidth => 'Lebar'; + + @override + String get wsHeight => 'Tinggi'; + + @override + String get wsPropertiesInactive => + 'Properti hanya berlaku saat penciutan publikasi diaktifkan.'; + + @override + String get wsNavigationAll => 'Semua elemen berjudul'; + + @override + String get wsNavigationNone => 'Tanpa navigasi'; + + @override + String get wsNavigationCustom => 'Navigasi khusus'; + + @override + String get wsTitleOverride => 'Timpa judul'; + + @override + String get wsCompactLayout => 'Ringkas'; + + @override + String get wsWideLayout => 'Lebar'; + + @override + String get wsCodeSourceConflict => + 'Pindahkan atau hapus kode yang ditulis dalam blok ini sebelum menetapkan src. Writerside menolak blok kode yang tidak kosong dengan referensi sumber.'; + + @override + String get wsInherited => 'Warisi dari daftar'; + + @override + String get wsExternalImageSize => + 'Writerside tidak mengatur ukuran gambar eksternal dengan andal. Ukuran yang ada dapat dilihat dan dihapus.'; + + @override + String get wsPropertiesNotSerialized => + 'Perubahan ini tidak diterapkan ke sumber. Draf tetap disimpan.'; + + @override + String get wsListItem => 'Item daftar'; + + @override + String get wsSourceFile => 'Berkas sumber'; + + @override + String get wsLinkTarget => 'Tujuan tautan'; + + @override + String get wsPreviewImage => 'Gambar pratinjau'; + + @override + String get wsIncludedElement => 'Elemen yang disertakan'; + + @override + String get wsHelpModule => 'Modul bantuan'; + + @override + String get wsActionId => 'ID tindakan'; + + @override + String get wsKeymapHelpInstance => 'Instans bantuan peta tombol'; + + @override + String get wsShowInSource => 'Tampilkan di Sumber'; + + @override + String get wsOriginElement => 'Penggantian elemen'; + + @override + String get wsOriginAncestor => 'Elemen induk'; + + @override + String get wsOriginModule => 'Bawaan modul'; + + @override + String get wsOriginBuiltIn => 'Bawaan internal'; + + @override + String get wsOriginUnresolved => 'Belum teratasi'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Efektif: $value — $origin'; + } + + @override + String get wsElementSelector => 'Elemen'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Dihasilkan: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Default: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Default: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Diselesaikan: $value'; + } + + @override + String get wsPropertyUnresolved => 'Belum diselesaikan'; + + @override + String get wsModule => 'Modul'; + + @override + String get wsParent => 'Induk'; + + @override + String get wsListType => 'Jenis daftar'; + + @override + String get wsStartingNumber => 'Nomor awal'; + + @override + String get wsListSorting => 'Pengurutan'; + + @override + String get wsLetteredList => 'Daftar huruf'; + + @override + String get wsCheckboxList => 'Daftar kotak centang'; + + @override + String get wsMarkerlessList => 'Daftar tanpa penanda'; + + @override + String get wsAscending => 'Menaik'; + + @override + String get wsDescending => 'Menurun'; + + @override + String get wsNoSorting => 'Tanpa pengurutan'; + + @override + String get wsAsWritten => 'Sesuai tulisan'; + + @override + String get wsSentenceCase => 'Kapitalisasi kalimat'; + + @override + String get wsTitleCase => 'Kapitalisasi judul'; + + @override + String get wsLowerCase => 'Huruf kecil'; + + @override + String get wsUpperCase => 'Huruf besar'; + + @override + String get wsNativeMarkers => 'Penanda Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Penanda awal: $number'; + } + + @override + String wsListLevel(String level) { + return 'Tingkat $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Builder yang dipatok mengabaikan aturan <caps> modul. Gunakan atribut caps lokal atau default-property.'; + + @override + String get wsInsertElements => 'Sisipkan elemen'; + + @override + String get wsAdmonitionsAndQuotes => 'Pemberitahuan dan kutipan'; + + @override + String get wsSemanticText => 'Teks semantik'; + + @override + String get wsPropertiesUnavailable => + 'Elemen yang diperiksa tidak tersedia. Periksa elemen untuk melanjutkan.'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 9d934a04..eaf42622 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'La capitalizzazione del modulo non viene pubblicata.'; + + @override + String get wsTitleOverridesAction => 'Titoli dell’istanza e dell’indice…'; + + @override + String get wsHeadingNotPublished => 'Non pubblicato da questo generatore.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Il builder fissato non pubblica titoli per istanza negli argomenti Markdown. Il markup esistente è conservato; consultalo nel sorgente.'; + + @override + String get wsTopicIdentity => 'ID argomento'; + + @override + String get wsElementAnchor => 'Ancora elemento'; + + @override + String get wsHeadingText => 'Testo intestazione'; + + @override + String get wsDefaultTitle => 'Titolo predefinito'; + + @override + String get wsTopicNavigation => 'Navigazione argomento'; + + @override + String get wsHeadingOptions => 'Attributi intestazione'; + + @override + String get wsDocumentOptions => 'Impostazioni documento'; + + @override + String get wsSettingsPresent => 'Impostazioni esplicite presenti'; + + @override + String get wsTitleOverrideExplanation => + 'Sostituisce il titolo pubblicato; il testo dell’intestazione non cambia.'; + + @override + String get wsIgnoreVariables => 'Tratta le variabili come testo letterale'; + + @override + String get wsIgnoreVariablesExplanation => + 'Se attivo, il testo simile a una variabile non viene sostituito.'; + + @override + String get wsInstanceSelection => 'Istanze della guida'; + + @override + String get wsInstanceExplanation => + 'Seleziona le uscite tramite nomi, gruppi, elenchi, esclusioni o espressioni.'; + + @override + String get wsFilterExplanation => + 'Filtra contenuti riutilizzabili per criteri; non seleziona un’istanza della guida.'; + + @override + String get wsContextHelp => 'ID guida contestuale'; + + @override + String get wsCapitalization => 'Maiuscole e minuscole'; + + @override + String get wsLibraryTopic => 'Argomento libreria'; + + @override + String get wsLastModified => 'Ultima modifica'; + + @override + String get wsMissingTopicHeading => + 'Nessun H1 dell’argomento. Aggiungilo nel sorgente.'; + + @override + String get wsMultipleTopicHeadings => + 'Il primo H1 determina il titolo dell’argomento. Gli altri H1 sono intestazioni separate.'; + + @override + String get wsTopicIdentityMismatch => + 'L’id radice differisce dall’ID del nome file. Usa Rinomina file argomento o consulta il sorgente.'; + + @override + String get wsUnsupportedProperties => + 'Gli attributi non supportati sono conservati. Consultali nel sorgente.'; + + @override + String get wsGeneratedAnchor => + 'Ancora generata; un valore crea una sostituzione.'; + + @override + String get wsHeadingBuilderLimitation => + 'Il builder fissato non pubblica gli attributi H1. Appartengono all’intestazione, non alla radice XML.'; + @override String get closeOtherTabs => 'Chiudi le altre schede'; @@ -4830,4 +4926,521 @@ class AppLocalizationsIt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'La prima richiesta potrebbe aver già creato una nota sul server. Questa azione crea una nota separata con il lavoro locale conservato. Nessun presunto duplicato verrà eliminato.'; + + @override + String get wsSemanticFormats => 'Formattazione semantica'; + + @override + String get wsUiControl => 'Controllo dell’interfaccia'; + + @override + String get wsFilePath => 'File / Percorso'; + + @override + String get wsUiPath => 'Percorso dell’interfaccia'; + + @override + String get wsDefinitionList => 'Elenco di definizioni'; + + @override + String get wsIncludeContent => 'Includi contenuto riutilizzabile'; + + @override + String get wsVariableReference => 'Riferimento a variabile'; + + @override + String get wsConvertList => 'Converti elenco in procedura'; + + @override + String get wsElementProperties => 'Proprietà dell’elemento'; + + @override + String get wsTopicProperties => 'Proprietà dell’argomento'; + + @override + String get wsCollapsible => 'Comprimibile'; + + @override + String get wsPublishedState => 'Stato iniziale pubblicato'; + + @override + String get wsExpanded => 'Espanso'; + + @override + String get wsCollapsed => 'Compresso'; + + @override + String get wsCollapsedTitle => 'Titolo compresso'; + + @override + String get wsTerm => 'Termine'; + + @override + String get wsListLayout => 'Disposizione dell’elenco'; + + @override + String get wsSyncGroup => 'Gruppo di sincronizzazione'; + + @override + String get wsTabKey => 'Chiave della scheda'; + + @override + String get wsSwitcherLabel => 'Etichetta del selettore'; + + @override + String get wsNavigationElements => 'Elementi di navigazione'; + + @override + String get wsNavigationDepth => 'Profondità di navigazione'; + + @override + String get wsAddItem => 'Aggiungi elemento'; + + @override + String get wsStep => 'Passaggio'; + + @override + String get wsPropertyInvalid => 'Valore della proprietà non valido'; + + @override + String get wsFilter => 'Filtro'; + + @override + String get wsIdentifier => 'ID elemento'; + + @override + String get wsTldr => 'Riepilogo (TLDR)'; + + @override + String get wsFullLayout => 'Larghezza piena'; + + @override + String get wsMediumLayout => 'Medio (1:2)'; + + @override + String get wsNarrowLayout => 'Termine stretto'; + + @override + String get wsTitle => 'Titolo'; + + @override + String get wsSwitcherKey => 'Chiave del selettore'; + + @override + String get nextcloudServerSettings => 'Impostazioni Notes del server'; + + @override + String get nextcloudNotesPath => 'Percorso della cartella delle note'; + + @override + String get nextcloudFileSuffix => 'Estensione delle nuove note'; + + @override + String get nextcloudServerSettingsExplanation => + 'Queste impostazioni Notes influiscono sugli altri client. Cambiare il percorso non sposta i file; cambiare l’estensione non rinomina le note esistenti.'; + + @override + String get nextcloudSettingsSaved => 'Impostazioni del server salvate'; + + @override + String get nextcloudSettingsNormalized => + 'Salvato con i valori normalizzati dal server'; + + @override + String get nextcloudPendingUpload => 'Caricamento allegato in attesa'; + + @override + String nextcloudServerCheck(String time) { + return 'Ultimo controllo del server: $time'; + } + + @override + String get nextcloudSettingsLoading => + 'Caricamento delle impostazioni del server'; + + @override + String get nextcloudSettingsSaving => + 'Salvataggio delle impostazioni del server'; + + @override + String get nextcloudPreviousLocalEdit => 'Modifica locale precedente'; + + @override + String get nextcloudUsePreviousEdit => 'Usa la modifica precedente'; + + @override + String get quickOpen => 'Apertura rapida'; + + @override + String get notesAll => 'Tutte le note'; + + @override + String get notesUncategorized => 'Senza categoria'; + + @override + String get notesRecovery => 'Recupero'; + + @override + String get notesRecentlyEdited => 'Modificate di recente'; + + @override + String get notesOldestEdited => 'Modifiche più vecchie'; + + @override + String get notesTitleAscending => 'Titolo A–Z'; + + @override + String get notesTitleDescending => 'Titolo Z–A'; + + @override + String get notesMove => 'Sposta nella categoria'; + + @override + String get notesRemoveFavorite => 'Rimuovi preferito'; + + @override + String get notesOffline => 'Rendi disponibile offline'; + + @override + String get notesOfflineRemove => 'Rimuovi conservazione offline'; + + @override + String get notesIndexing => 'Indicizzazione delle note…'; + + @override + String get notesSearchHelp => + 'I termini usano E. Usa \"frasi tra virgolette\", title: e category:.'; + + @override + String get notesSourceLocation => 'Posizione nel sorgente'; + + @override + String get notesRecoveryHelp => + 'Solo note locali conservate e cronologia locale. Cestino e cronologia del server non disponibili.'; + + @override + String get notesImport => 'Importa note'; + + @override + String get notesExport => 'Esporta note'; + + @override + String get notesImportDistinct => 'Importa come nota distinta'; + + @override + String get notesAvailableOffline => 'Disponibile offline'; + + @override + String get notesIncomplete => 'Incompleto'; + + @override + String get notesSelected => 'Note selezionate'; + + @override + String get notesNoHistory => + 'Nessuna revisione conservata. La cronologia può essere disabilitata, scaduta o cancellata.'; + + @override + String get notesTextAvailable => 'Testo disponibile localmente'; + + @override + String get notesLocalVersion => 'Versione di lavoro locale'; + + @override + String get notesAttachments => 'Allegati'; + + @override + String get notesSkipped => 'Saltato'; + + @override + String get notesFailed => 'Non riuscito'; + + @override + String get notesCompleted => 'Completato'; + + @override + String get notesImportNew => 'Avvia una nuova importazione'; + + @override + String get notesImportResume => 'Riprendi importazione interrotta'; + + @override + String get notesImportAlreadyImported => 'Già importato in questa operazione'; + + @override + String get references => 'Riferimenti'; + + @override + String get referenceIncoming => 'In entrata'; + + @override + String get referenceOutgoing => 'In uscita'; + + @override + String get referenceChooseTarget => 'Scegli destinazione del collegamento'; + + @override + String get referenceWholeDocument => 'Intero documento'; + + @override + String get referenceSourceLocation => 'Posizione di origine'; + + @override + String get referenceTargetLocation => 'Posizione di destinazione'; + + @override + String get referenceResolved => 'Risolto'; + + @override + String get referenceMissing => 'Mancante'; + + @override + String get referenceAmbiguous => 'Ambiguo'; + + @override + String get referenceUnavailable => 'Non disponibile in questo contesto'; + + @override + String get referenceUnsupported => 'Non supportato'; + + @override + String get referenceNoPortableId => + 'Questa destinazione non ha un URL portabile della nota verificato.'; + + @override + String get wsIncludeLines => 'Includi righe'; + + @override + String get wsIncludeSymbol => 'Includi simbolo'; + + @override + String get wsEmphasizeLines => 'Evidenzia righe visualizzate'; + + @override + String get wsPropertiesReadOnly => + 'Proprietà di sola lettura in questa vista.'; + + @override + String get wsPropertiesProtected => + 'Elemento protetto. Modifica la sorgente.'; + + @override + String get wsPropertiesConflict => + 'Documento o elemento modificato. Annulla la bozza e seleziona di nuovo.'; + + @override + String get wsPropertiesUnresolved => + 'Impossibile risolvere il riferimento al momento.'; + + @override + String get wsUseDefault => 'Usa predefinito'; + + @override + String get wsPropertiesSelection => 'Seleziona un solo elemento.'; + + @override + String get wsImageDisplay => 'Visualizzazione immagine'; + + @override + String get wsImageThumbnail => 'Miniatura cliccabile'; + + @override + String get wsEnabled => 'Abilitato'; + + @override + String get wsDisabled => 'Disabilitato'; + + @override + String get wsWidth => 'Larghezza'; + + @override + String get wsHeight => 'Altezza'; + + @override + String get wsPropertiesInactive => + 'Questa proprietà non ha effetto senza compressione pubblicata.'; + + @override + String get wsNavigationAll => 'Tutti gli elementi con titolo'; + + @override + String get wsNavigationNone => 'Nessuna navigazione'; + + @override + String get wsNavigationCustom => 'Navigazione personalizzata'; + + @override + String get wsTitleOverride => 'Sostituisci titolo'; + + @override + String get wsCompactLayout => 'Compatto'; + + @override + String get wsWideLayout => 'Largo'; + + @override + String get wsCodeSourceConflict => + 'Sposta o rimuovi il codice scritto in questo blocco prima di impostare src. Writerside rifiuta un blocco di codice non vuoto con un riferimento sorgente.'; + + @override + String get wsInherited => 'Eredita dall’elenco'; + + @override + String get wsExternalImageSize => + 'Writerside non dimensiona in modo affidabile le immagini esterne. Le dimensioni esistenti possono essere consultate e rimosse.'; + + @override + String get wsPropertiesNotSerialized => + 'Questa modifica non è stata applicata al sorgente. La bozza è stata conservata.'; + + @override + String get wsListItem => 'Elemento elenco'; + + @override + String get wsSourceFile => 'File sorgente'; + + @override + String get wsLinkTarget => 'Destinazione del collegamento'; + + @override + String get wsPreviewImage => 'Immagine di anteprima'; + + @override + String get wsIncludedElement => 'Elemento incluso'; + + @override + String get wsHelpModule => 'Modulo di aiuto'; + + @override + String get wsActionId => 'ID azione'; + + @override + String get wsKeymapHelpInstance => 'Istanza di aiuto della mappa tasti'; + + @override + String get wsShowInSource => 'Mostra nel sorgente'; + + @override + String get wsOriginElement => 'Valore dell’elemento'; + + @override + String get wsOriginAncestor => 'Elemento padre'; + + @override + String get wsOriginModule => 'Predefinito del modulo'; + + @override + String get wsOriginBuiltIn => 'Predefinito integrato'; + + @override + String get wsOriginUnresolved => 'Non risolto'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effettivo: $value — $origin'; + } + + @override + String get wsElementSelector => 'Elemento'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Generato: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Predefinito: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Predefinito: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Risolto: $value'; + } + + @override + String get wsPropertyUnresolved => 'Non risolto'; + + @override + String get wsModule => 'Modulo'; + + @override + String get wsParent => 'Elemento padre'; + + @override + String get wsListType => 'Tipo di elenco'; + + @override + String get wsStartingNumber => 'Numero iniziale'; + + @override + String get wsListSorting => 'Ordinamento'; + + @override + String get wsLetteredList => 'Elenco alfabetico'; + + @override + String get wsCheckboxList => 'Elenco con caselle'; + + @override + String get wsMarkerlessList => 'Elenco senza indicatori'; + + @override + String get wsAscending => 'Crescente'; + + @override + String get wsDescending => 'Decrescente'; + + @override + String get wsNoSorting => 'Nessun ordinamento'; + + @override + String get wsAsWritten => 'Come scritto'; + + @override + String get wsSentenceCase => 'Maiuscola iniziale'; + + @override + String get wsTitleCase => 'Maiuscole del titolo'; + + @override + String get wsLowerCase => 'Minuscolo'; + + @override + String get wsUpperCase => 'Maiuscolo'; + + @override + String get wsNativeMarkers => 'Indicatori Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Indicatore iniziale: $number'; + } + + @override + String wsListLevel(String level) { + return 'Livello $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Il builder fissato ignora le regole <caps> del modulo. Usare un attributo caps locale o default-property.'; + + @override + String get wsInsertElements => 'Inserisci elementi'; + + @override + String get wsAdmonitionsAndQuotes => 'Avvisi e citazioni'; + + @override + String get wsSemanticText => 'Testo semantico'; + + @override + String get wsPropertiesUnavailable => + 'L’elemento esaminato non è disponibile. Esamina un elemento per continuare.'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index a4c4d779..02686a7f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -10,6 +10,93 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get wsModuleCapsNotPublished => 'モジュールの大文字・小文字設定は公開されません。'; + + @override + String get wsTitleOverridesAction => 'インスタンスと目次のタイトル…'; + + @override + String get wsHeadingNotPublished => 'このビルダーでは公開されません。'; + + @override + String get wsMarkdownInstanceTitleLimitation => + '固定バージョンのビルダーは Markdown トピックのインスタンス別タイトルを公開しません。既存のマークアップは保持されます。ソースで確認してください。'; + + @override + String get wsTopicIdentity => 'トピック ID'; + + @override + String get wsElementAnchor => '要素アンカー'; + + @override + String get wsHeadingText => '見出しテキスト'; + + @override + String get wsDefaultTitle => '既定のタイトル'; + + @override + String get wsTopicNavigation => 'トピックナビゲーション'; + + @override + String get wsHeadingOptions => '見出しの属性'; + + @override + String get wsDocumentOptions => '文書設定'; + + @override + String get wsSettingsPresent => '明示的な設定あり'; + + @override + String get wsTitleOverrideExplanation => '公開タイトルを上書きします。見出しテキストは変わりません。'; + + @override + String get wsIgnoreVariables => '変数をリテラルテキストとして扱う'; + + @override + String get wsIgnoreVariablesExplanation => '有効にすると、変数のようなテキストは置換されません。'; + + @override + String get wsInstanceSelection => 'ヘルプインスタンス'; + + @override + String get wsInstanceExplanation => '名前、グループ、リスト、除外、式でヘルプ出力を選択します。'; + + @override + String get wsFilterExplanation => '再利用する内容を条件で絞り込みます。ヘルプインスタンスの選択ではありません。'; + + @override + String get wsContextHelp => 'コンテキストヘルプ ID'; + + @override + String get wsCapitalization => '大文字・小文字'; + + @override + String get wsLibraryTopic => 'ライブラリトピック'; + + @override + String get wsLastModified => '最終更新'; + + @override + String get wsMissingTopicHeading => 'トピックの H1 がありません。ソースで追加してください。'; + + @override + String get wsMultipleTopicHeadings => '最初の H1 がトピックタイトルです。後続の H1 は別の見出しです。'; + + @override + String get wsTopicIdentityMismatch => + 'ルート id がファイル名によるトピック ID と異なります。トピックファイルの名前変更かソースを使用してください。'; + + @override + String get wsUnsupportedProperties => '未対応の属性は保持されます。ソースで確認してください。'; + + @override + String get wsGeneratedAnchor => '自動生成アンカー。値の入力で上書きします。'; + + @override + String get wsHeadingBuilderLimitation => + '固定バージョンのビルダーは H1 属性を公開しません。属性の所有者は見出しであり、XML ルートではありません。'; + @override String get closeOtherTabs => '他のタブを閉じる'; @@ -4655,4 +4742,508 @@ class AppLocalizationsJa extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => '最初のリクエストですでにサーバーノートが作成された可能性があります。保持したローカル作業から別のノートを作成します。重複の疑いがあるノートは削除されません。'; + + @override + String get wsSemanticFormats => '意味に基づく書式'; + + @override + String get wsUiControl => 'UI コントロール'; + + @override + String get wsFilePath => 'ファイル / パス'; + + @override + String get wsUiPath => 'UI パス'; + + @override + String get wsDefinitionList => '定義リスト'; + + @override + String get wsIncludeContent => '再利用可能なコンテンツを含める'; + + @override + String get wsVariableReference => '変数参照'; + + @override + String get wsConvertList => 'リストを手順に変換'; + + @override + String get wsElementProperties => '要素のプロパティ'; + + @override + String get wsTopicProperties => 'トピックのプロパティ'; + + @override + String get wsCollapsible => '折りたたみ可能'; + + @override + String get wsPublishedState => '公開時の初期状態'; + + @override + String get wsExpanded => '展開'; + + @override + String get wsCollapsed => '折りたたみ'; + + @override + String get wsCollapsedTitle => '折りたたみ時のタイトル'; + + @override + String get wsTerm => '用語'; + + @override + String get wsListLayout => 'リストのレイアウト'; + + @override + String get wsSyncGroup => '同期グループ'; + + @override + String get wsTabKey => 'タブのキー'; + + @override + String get wsSwitcherLabel => '切り替えラベル'; + + @override + String get wsNavigationElements => 'ナビゲーション要素'; + + @override + String get wsNavigationDepth => 'ナビゲーションの深さ'; + + @override + String get wsAddItem => '項目を追加'; + + @override + String get wsStep => 'ステップ'; + + @override + String get wsPropertyInvalid => '無効なプロパティ値'; + + @override + String get wsFilter => 'フィルター'; + + @override + String get wsIdentifier => '要素 ID'; + + @override + String get wsTldr => '要約 (TLDR)'; + + @override + String get wsFullLayout => '全幅'; + + @override + String get wsMediumLayout => '中幅 (1:2)'; + + @override + String get wsNarrowLayout => '狭い用語列'; + + @override + String get wsTitle => 'タイトル'; + + @override + String get wsSwitcherKey => '切り替えキー'; + + @override + String get nextcloudServerSettings => 'サーバーのNotes設定'; + + @override + String get nextcloudNotesPath => 'ノートフォルダーのパス'; + + @override + String get nextcloudFileSuffix => '新しいノートの拡張子'; + + @override + String get nextcloudServerSettingsExplanation => + 'このサーバーのNotes設定は他のクライアントにも影響します。パスを変更してもファイルは移動せず、拡張子を変更しても既存のノートの名前は変わりません。'; + + @override + String get nextcloudSettingsSaved => 'サーバー設定を保存しました'; + + @override + String get nextcloudSettingsNormalized => 'サーバーが正規化した値で保存しました'; + + @override + String get nextcloudPendingUpload => '添付ファイルのアップロード待ち'; + + @override + String nextcloudServerCheck(String time) { + return '前回のサーバー確認: $time'; + } + + @override + String get nextcloudSettingsLoading => 'サーバー設定を読み込み中'; + + @override + String get nextcloudSettingsSaving => 'サーバー設定を保存中'; + + @override + String get nextcloudPreviousLocalEdit => '以前のローカル編集'; + + @override + String get nextcloudUsePreviousEdit => '以前の編集を使用'; + + @override + String get quickOpen => 'クイックオープン'; + + @override + String get notesAll => 'すべてのノート'; + + @override + String get notesUncategorized => '未分類'; + + @override + String get notesRecovery => '復元'; + + @override + String get notesRecentlyEdited => '最近編集した順'; + + @override + String get notesOldestEdited => '古い編集順'; + + @override + String get notesTitleAscending => 'タイトル昇順'; + + @override + String get notesTitleDescending => 'タイトル降順'; + + @override + String get notesMove => 'カテゴリに移動'; + + @override + String get notesRemoveFavorite => 'お気に入りを解除'; + + @override + String get notesOffline => 'オフラインで利用可能にする'; + + @override + String get notesOfflineRemove => 'オフライン保持を解除'; + + @override + String get notesIndexing => 'ノートを索引作成中…'; + + @override + String get notesSearchHelp => + '語句は AND で結合します。\"引用句\"、title:、category: を使えます。'; + + @override + String get notesSourceLocation => 'ソース位置'; + + @override + String get notesRecoveryHelp => '保持されたローカルノートとローカル履歴のみ。サーバーのごみ箱や履歴は利用できません。'; + + @override + String get notesImport => 'ノートをインポート'; + + @override + String get notesExport => 'ノートをエクスポート'; + + @override + String get notesImportDistinct => '別のノートとしてインポート'; + + @override + String get notesAvailableOffline => 'オフラインで利用可能'; + + @override + String get notesIncomplete => '不完全'; + + @override + String get notesSelected => '選択したノート'; + + @override + String get notesNoHistory => '保持された版がありません。履歴は無効、期限切れ、または消去済みの場合があります。'; + + @override + String get notesTextAvailable => 'テキストはローカルで利用可能'; + + @override + String get notesLocalVersion => 'ローカルの作業版'; + + @override + String get notesAttachments => '添付ファイル'; + + @override + String get notesSkipped => 'スキップ'; + + @override + String get notesFailed => '失敗'; + + @override + String get notesCompleted => '完了'; + + @override + String get notesImportNew => '新しいインポートを開始'; + + @override + String get notesImportResume => '中断したインポートを再開'; + + @override + String get notesImportAlreadyImported => 'この操作ではインポート済み'; + + @override + String get references => '参照'; + + @override + String get referenceIncoming => '被参照'; + + @override + String get referenceOutgoing => '参照先'; + + @override + String get referenceChooseTarget => 'リンク先を選択'; + + @override + String get referenceWholeDocument => '文書全体'; + + @override + String get referenceSourceLocation => '参照元の位置'; + + @override + String get referenceTargetLocation => '参照先の位置'; + + @override + String get referenceResolved => '解決済み'; + + @override + String get referenceMissing => '見つかりません'; + + @override + String get referenceAmbiguous => '曖昧'; + + @override + String get referenceUnavailable => 'このコンテキストでは利用できません'; + + @override + String get referenceUnsupported => '未対応'; + + @override + String get referenceNoPortableId => 'この参照先には確認済みの移植可能なノートURLがありません。'; + + @override + String get wsIncludeLines => '行を含める'; + + @override + String get wsIncludeSymbol => 'シンボルを含める'; + + @override + String get wsEmphasizeLines => '表示された行を強調'; + + @override + String get wsPropertiesReadOnly => 'この表示ではプロパティは読み取り専用です。'; + + @override + String get wsPropertiesProtected => 'この要素は保護されています。ソースを編集してください。'; + + @override + String get wsPropertiesConflict => '文書または要素が変わりました。下書きを戻して再選択してください。'; + + @override + String get wsPropertiesUnresolved => '現在、参照を解決できません。'; + + @override + String get wsUseDefault => '既定値を使用'; + + @override + String get wsPropertiesSelection => '単一の要素を選択してください。'; + + @override + String get wsImageDisplay => '画像の表示'; + + @override + String get wsImageThumbnail => 'クリック可能なサムネイル'; + + @override + String get wsEnabled => '有効'; + + @override + String get wsDisabled => '無効'; + + @override + String get wsWidth => '幅'; + + @override + String get wsHeight => '高さ'; + + @override + String get wsPropertiesInactive => '公開時の折りたたみが無効の場合、このプロパティは作用しません。'; + + @override + String get wsNavigationAll => 'タイトルのあるすべての要素'; + + @override + String get wsNavigationNone => 'ナビゲーションなし'; + + @override + String get wsNavigationCustom => 'カスタムナビゲーション'; + + @override + String get wsTitleOverride => 'タイトルの上書き'; + + @override + String get wsCompactLayout => 'コンパクト'; + + @override + String get wsWideLayout => 'ワイド'; + + @override + String get wsCodeSourceConflict => + 'src を設定する前に、このブロック内に記述したコードを移動または削除してください。Writerside はソース参照がある空でないコードブロックを拒否します。'; + + @override + String get wsInherited => 'リストから継承'; + + @override + String get wsExternalImageSize => + 'Writerside は外部画像のサイズを確実には設定できません。既存の寸法は確認して削除できます。'; + + @override + String get wsPropertiesNotSerialized => 'この変更はソースに反映されませんでした。下書きは保持されています。'; + + @override + String get wsListItem => 'リスト項目'; + + @override + String get wsSourceFile => 'ソースファイル'; + + @override + String get wsLinkTarget => 'リンク先'; + + @override + String get wsPreviewImage => 'プレビュー画像'; + + @override + String get wsIncludedElement => 'インクルードする要素'; + + @override + String get wsHelpModule => 'ヘルプモジュール'; + + @override + String get wsActionId => 'アクション ID'; + + @override + String get wsKeymapHelpInstance => 'キーマップのヘルプインスタンス'; + + @override + String get wsShowInSource => 'ソースで表示'; + + @override + String get wsOriginElement => '要素の指定値'; + + @override + String get wsOriginAncestor => '親要素'; + + @override + String get wsOriginModule => 'モジュールの既定値'; + + @override + String get wsOriginBuiltIn => '組み込みの既定値'; + + @override + String get wsOriginUnresolved => '未解決'; + + @override + String wsEffectiveProperty(String value, String origin) { + return '有効値: $value — $origin'; + } + + @override + String get wsElementSelector => '要素'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return '生成値: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return '既定値: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return '既定値: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return '解決値: $value'; + } + + @override + String get wsPropertyUnresolved => '未解決'; + + @override + String get wsModule => 'モジュール'; + + @override + String get wsParent => '親要素'; + + @override + String get wsListType => 'リストの種類'; + + @override + String get wsStartingNumber => '開始番号'; + + @override + String get wsListSorting => '並べ替え'; + + @override + String get wsLetteredList => '英字リスト'; + + @override + String get wsCheckboxList => 'チェックボックスリスト'; + + @override + String get wsMarkerlessList => 'マーカーなしのリスト'; + + @override + String get wsAscending => '昇順'; + + @override + String get wsDescending => '降順'; + + @override + String get wsNoSorting => '並べ替えなし'; + + @override + String get wsAsWritten => '記述どおり'; + + @override + String get wsSentenceCase => '文頭のみ大文字'; + + @override + String get wsTitleCase => 'タイトル形式'; + + @override + String get wsLowerCase => '小文字'; + + @override + String get wsUpperCase => '大文字'; + + @override + String get wsNativeMarkers => 'Markdown マーカー'; + + @override + String wsListMarkerStart(String number) { + return '開始マーカー: $number'; + } + + @override + String wsListLevel(String level) { + return '階層 $level'; + } + + @override + String get wsModuleCapsIgnored => + '固定されたビルダーはモジュールの <caps> ルールを無視します。ローカルの caps 属性または default-property を使用してください。'; + + @override + String get wsInsertElements => '要素を挿入'; + + @override + String get wsAdmonitionsAndQuotes => '注意書きと引用'; + + @override + String get wsSemanticText => 'セマンティックテキスト'; + + @override + String get wsPropertiesUnavailable => '確認中の要素は利用できません。続行するには要素を確認してください。'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 0e9a24d3..8d060c70 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -10,6 +10,94 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get wsModuleCapsNotPublished => '모듈의 대소문자 설정은 게시되지 않습니다.'; + + @override + String get wsTitleOverridesAction => '인스턴스 및 목차 제목…'; + + @override + String get wsHeadingNotPublished => '이 빌더에서는 게시되지 않습니다.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + '고정된 빌더는 Markdown 주제의 인스턴스 제목을 게시하지 않습니다. 기존 마크업은 보존됩니다. 소스에서 확인하세요.'; + + @override + String get wsTopicIdentity => '주제 ID'; + + @override + String get wsElementAnchor => '요소 앵커'; + + @override + String get wsHeadingText => '제목 텍스트'; + + @override + String get wsDefaultTitle => '기본 제목'; + + @override + String get wsTopicNavigation => '주제 탐색'; + + @override + String get wsHeadingOptions => '제목 속성'; + + @override + String get wsDocumentOptions => '문서 설정'; + + @override + String get wsSettingsPresent => '작성된 설정 있음'; + + @override + String get wsTitleOverrideExplanation => '게시 제목을 재정의하며 제목 텍스트는 변경하지 않습니다.'; + + @override + String get wsIgnoreVariables => '변수를 리터럴 텍스트로 처리'; + + @override + String get wsIgnoreVariablesExplanation => '활성화하면 변수처럼 보이는 텍스트를 치환하지 않습니다.'; + + @override + String get wsInstanceSelection => '도움말 인스턴스'; + + @override + String get wsInstanceExplanation => '이름, 그룹, 목록, 제외 또는 식으로 도움말 출력을 선택합니다.'; + + @override + String get wsFilterExplanation => + '조건에 따라 재사용 콘텐츠를 필터링하며 도움말 인스턴스를 선택하지 않습니다.'; + + @override + String get wsContextHelp => '상황별 도움말 ID'; + + @override + String get wsCapitalization => '대소문자'; + + @override + String get wsLibraryTopic => '라이브러리 주제'; + + @override + String get wsLastModified => '최종 수정'; + + @override + String get wsMissingTopicHeading => '주제 H1이 없습니다. 소스에서 추가하세요.'; + + @override + String get wsMultipleTopicHeadings => '첫 H1이 주제 제목을 결정합니다. 이후 H1은 별도 제목입니다.'; + + @override + String get wsTopicIdentityMismatch => + '루트 id가 파일명 기반 주제 ID와 다릅니다. 주제 파일 이름 바꾸기 또는 소스를 사용하세요.'; + + @override + String get wsUnsupportedProperties => '지원하지 않는 속성은 보존됩니다. 소스에서 확인하세요.'; + + @override + String get wsGeneratedAnchor => '생성된 앵커이며 값을 입력하면 재정의됩니다.'; + + @override + String get wsHeadingBuilderLimitation => + '고정된 빌더는 H1 속성을 게시하지 않습니다. 속성은 XML 루트가 아닌 제목에 속합니다.'; + @override String get closeOtherTabs => '다른 탭 닫기'; @@ -4650,4 +4738,510 @@ class AppLocalizationsKo extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => '첫 요청으로 서버 노트가 이미 만들어졌을 수 있습니다. 보존된 로컬 작업으로 별도 노트를 만듭니다. 중복으로 의심되는 노트는 삭제하지 않습니다.'; + + @override + String get wsSemanticFormats => '의미 기반 서식'; + + @override + String get wsUiControl => 'UI 컨트롤'; + + @override + String get wsFilePath => '파일 / 경로'; + + @override + String get wsUiPath => 'UI 경로'; + + @override + String get wsDefinitionList => '정의 목록'; + + @override + String get wsIncludeContent => '재사용 가능한 콘텐츠 포함'; + + @override + String get wsVariableReference => '변수 참조'; + + @override + String get wsConvertList => '목록을 절차로 변환'; + + @override + String get wsElementProperties => '요소 속성'; + + @override + String get wsTopicProperties => '주제 속성'; + + @override + String get wsCollapsible => '접기 가능'; + + @override + String get wsPublishedState => '게시 시 초기 상태'; + + @override + String get wsExpanded => '펼침'; + + @override + String get wsCollapsed => '접힘'; + + @override + String get wsCollapsedTitle => '접힌 제목'; + + @override + String get wsTerm => '용어'; + + @override + String get wsListLayout => '목록 레이아웃'; + + @override + String get wsSyncGroup => '동기화 그룹'; + + @override + String get wsTabKey => '탭 키'; + + @override + String get wsSwitcherLabel => '전환 레이블'; + + @override + String get wsNavigationElements => '탐색 요소'; + + @override + String get wsNavigationDepth => '탐색 깊이'; + + @override + String get wsAddItem => '항목 추가'; + + @override + String get wsStep => '단계'; + + @override + String get wsPropertyInvalid => '잘못된 속성 값'; + + @override + String get wsFilter => '필터'; + + @override + String get wsIdentifier => '요소 ID'; + + @override + String get wsTldr => '요약 (TLDR)'; + + @override + String get wsFullLayout => '전체 너비'; + + @override + String get wsMediumLayout => '중간 (1:2)'; + + @override + String get wsNarrowLayout => '좁은 용어 열'; + + @override + String get wsTitle => '제목'; + + @override + String get wsSwitcherKey => '전환 키'; + + @override + String get nextcloudServerSettings => '서버 Notes 설정'; + + @override + String get nextcloudNotesPath => '노트 폴더 경로'; + + @override + String get nextcloudFileSuffix => '새 노트의 파일 확장자'; + + @override + String get nextcloudServerSettingsExplanation => + '이 서버 Notes 설정은 다른 클라이언트에도 적용됩니다. 경로를 변경해도 파일이 이동하지 않으며 확장자를 변경해도 기존 노트의 이름이 바뀌지 않습니다.'; + + @override + String get nextcloudSettingsSaved => '서버 설정 저장됨'; + + @override + String get nextcloudSettingsNormalized => '서버에서 정규화한 값으로 저장됨'; + + @override + String get nextcloudPendingUpload => '첨부 파일 업로드 대기 중'; + + @override + String nextcloudServerCheck(String time) { + return '마지막 서버 확인: $time'; + } + + @override + String get nextcloudSettingsLoading => '서버 설정을 불러오는 중'; + + @override + String get nextcloudSettingsSaving => '서버 설정을 저장하는 중'; + + @override + String get nextcloudPreviousLocalEdit => '이전 로컬 편집'; + + @override + String get nextcloudUsePreviousEdit => '이전 편집 사용'; + + @override + String get quickOpen => '빠른 열기'; + + @override + String get notesAll => '모든 노트'; + + @override + String get notesUncategorized => '미분류'; + + @override + String get notesRecovery => '복구'; + + @override + String get notesRecentlyEdited => '최근 편집순'; + + @override + String get notesOldestEdited => '오래된 편집순'; + + @override + String get notesTitleAscending => '제목 오름차순'; + + @override + String get notesTitleDescending => '제목 내림차순'; + + @override + String get notesMove => '범주로 이동'; + + @override + String get notesRemoveFavorite => '즐겨찾기 해제'; + + @override + String get notesOffline => '오프라인 사용 설정'; + + @override + String get notesOfflineRemove => '오프라인 보존 해제'; + + @override + String get notesIndexing => '노트 색인 생성 중…'; + + @override + String get notesSearchHelp => + '검색어는 AND로 결합합니다. \"인용 구문\", title:, category:를 사용하세요.'; + + @override + String get notesSourceLocation => '소스 위치'; + + @override + String get notesRecoveryHelp => + '보존된 로컬 노트와 로컬 기록만 표시합니다. 서버 휴지통과 기록은 사용할 수 없습니다.'; + + @override + String get notesImport => '노트 가져오기'; + + @override + String get notesExport => '노트 내보내기'; + + @override + String get notesImportDistinct => '별도 노트로 가져오기'; + + @override + String get notesAvailableOffline => '오프라인 사용 가능'; + + @override + String get notesIncomplete => '불완전'; + + @override + String get notesSelected => '선택한 노트'; + + @override + String get notesNoHistory => '보존된 버전이 없습니다. 기록이 비활성화되었거나 만료 또는 삭제되었을 수 있습니다.'; + + @override + String get notesTextAvailable => '텍스트를 로컬에서 사용 가능'; + + @override + String get notesLocalVersion => '로컬 작업 버전'; + + @override + String get notesAttachments => '첨부 파일'; + + @override + String get notesSkipped => '건너뜀'; + + @override + String get notesFailed => '실패'; + + @override + String get notesCompleted => '완료'; + + @override + String get notesImportNew => '새 가져오기 시작'; + + @override + String get notesImportResume => '중단된 가져오기 재개'; + + @override + String get notesImportAlreadyImported => '이 작업에서 이미 가져옴'; + + @override + String get references => '참조'; + + @override + String get referenceIncoming => '들어오는 참조'; + + @override + String get referenceOutgoing => '나가는 참조'; + + @override + String get referenceChooseTarget => '링크 대상 선택'; + + @override + String get referenceWholeDocument => '전체 문서'; + + @override + String get referenceSourceLocation => '원본 위치'; + + @override + String get referenceTargetLocation => '대상 위치'; + + @override + String get referenceResolved => '해결됨'; + + @override + String get referenceMissing => '없음'; + + @override + String get referenceAmbiguous => '모호함'; + + @override + String get referenceUnavailable => '이 맥락에서 사용할 수 없음'; + + @override + String get referenceUnsupported => '지원되지 않음'; + + @override + String get referenceNoPortableId => '이 대상에는 확인된 이식 가능한 노트 URL이 없습니다.'; + + @override + String get wsIncludeLines => '줄 포함'; + + @override + String get wsIncludeSymbol => '기호 포함'; + + @override + String get wsEmphasizeLines => '표시된 줄 강조'; + + @override + String get wsPropertiesReadOnly => '이 보기에서 속성은 읽기 전용입니다.'; + + @override + String get wsPropertiesProtected => '보호된 요소입니다. 소스를 편집하세요.'; + + @override + String get wsPropertiesConflict => '문서 또는 요소가 변경되었습니다. 초안을 되돌리고 다시 선택하세요.'; + + @override + String get wsPropertiesUnresolved => '현재 참조를 확인할 수 없습니다.'; + + @override + String get wsUseDefault => '기본값 사용'; + + @override + String get wsPropertiesSelection => '요소 하나를 선택하세요.'; + + @override + String get wsImageDisplay => '이미지 표시'; + + @override + String get wsImageThumbnail => '클릭 가능한 미리 보기'; + + @override + String get wsEnabled => '사용'; + + @override + String get wsDisabled => '사용 안 함'; + + @override + String get wsWidth => '너비'; + + @override + String get wsHeight => '높이'; + + @override + String get wsPropertiesInactive => '게시된 접기가 꺼져 있으면 이 속성은 적용되지 않습니다.'; + + @override + String get wsNavigationAll => '제목이 있는 모든 요소'; + + @override + String get wsNavigationNone => '탐색 없음'; + + @override + String get wsNavigationCustom => '사용자 지정 탐색'; + + @override + String get wsTitleOverride => '제목 재정의'; + + @override + String get wsCompactLayout => '간결'; + + @override + String get wsWideLayout => '넓게'; + + @override + String get wsCodeSourceConflict => + 'src를 설정하기 전에 이 블록에 작성된 코드를 이동하거나 제거하세요. Writerside는 소스 참조가 있는 비어 있지 않은 코드 블록을 허용하지 않습니다.'; + + @override + String get wsInherited => '목록에서 상속'; + + @override + String get wsExternalImageSize => + 'Writerside는 외부 이미지 크기를 안정적으로 설정하지 못합니다. 기존 크기는 확인하거나 제거할 수 있습니다.'; + + @override + String get wsPropertiesNotSerialized => '이 변경 사항은 소스에 반영되지 않았습니다. 초안은 유지됩니다.'; + + @override + String get wsListItem => '목록 항목'; + + @override + String get wsSourceFile => '소스 파일'; + + @override + String get wsLinkTarget => '링크 대상'; + + @override + String get wsPreviewImage => '미리보기 이미지'; + + @override + String get wsIncludedElement => '포함할 요소'; + + @override + String get wsHelpModule => '도움말 모듈'; + + @override + String get wsActionId => '액션 ID'; + + @override + String get wsKeymapHelpInstance => '키맵 도움말 인스턴스'; + + @override + String get wsShowInSource => '소스에서 표시'; + + @override + String get wsOriginElement => '요소 재정의'; + + @override + String get wsOriginAncestor => '상위 요소'; + + @override + String get wsOriginModule => '모듈 기본값'; + + @override + String get wsOriginBuiltIn => '내장 기본값'; + + @override + String get wsOriginUnresolved => '확인되지 않음'; + + @override + String wsEffectiveProperty(String value, String origin) { + return '적용 값: $value — $origin'; + } + + @override + String get wsElementSelector => '요소'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return '생성된 값: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return '기본값: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return '기본값: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return '해석된 값: $value'; + } + + @override + String get wsPropertyUnresolved => '미해결'; + + @override + String get wsModule => '모듈'; + + @override + String get wsParent => '부모 요소'; + + @override + String get wsListType => '목록 유형'; + + @override + String get wsStartingNumber => '시작 번호'; + + @override + String get wsListSorting => '정렬'; + + @override + String get wsLetteredList => '알파벳 목록'; + + @override + String get wsCheckboxList => '체크박스 목록'; + + @override + String get wsMarkerlessList => '표시 없는 목록'; + + @override + String get wsAscending => '오름차순'; + + @override + String get wsDescending => '내림차순'; + + @override + String get wsNoSorting => '정렬 안 함'; + + @override + String get wsAsWritten => '작성된 대로'; + + @override + String get wsSentenceCase => '문장 형식'; + + @override + String get wsTitleCase => '제목 형식'; + + @override + String get wsLowerCase => '소문자'; + + @override + String get wsUpperCase => '대문자'; + + @override + String get wsNativeMarkers => 'Markdown 표시'; + + @override + String wsListMarkerStart(String number) { + return '시작 표시: $number'; + } + + @override + String wsListLevel(String level) { + return '수준 $level'; + } + + @override + String get wsModuleCapsIgnored => + '고정된 빌더는 모듈의 <caps> 규칙을 무시합니다. 로컬 caps 속성 또는 default-property를 사용하세요.'; + + @override + String get wsInsertElements => '요소 삽입'; + + @override + String get wsAdmonitionsAndQuotes => '안내 및 인용'; + + @override + String get wsSemanticText => '의미 텍스트'; + + @override + String get wsPropertiesUnavailable => + '검사 중인 요소를 사용할 수 없습니다. 계속하려면 요소를 검사하세요.'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 01153870..020f3e77 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -10,6 +10,103 @@ import 'app_localizations.dart'; class AppLocalizationsNb extends AppLocalizations { AppLocalizationsNb([String locale = 'nb']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Modulens bokstavstil publiseres ikke.'; + + @override + String get wsTitleOverridesAction => + 'Titler for instans og innholdsfortegnelse…'; + + @override + String get wsHeadingNotPublished => 'Publiseres ikke av denne byggeren.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Den fastlåste byggeren publiserer ikke instanstitler i Markdown-emner. Eksisterende markering bevares; se kilden.'; + + @override + String get wsTopicIdentity => 'Emne-ID'; + + @override + String get wsElementAnchor => 'Elementanker'; + + @override + String get wsHeadingText => 'Overskriftstekst'; + + @override + String get wsDefaultTitle => 'Standardtittel'; + + @override + String get wsTopicNavigation => 'Emnenavigasjon'; + + @override + String get wsHeadingOptions => 'Overskriftsattributter'; + + @override + String get wsDocumentOptions => 'Dokumentinnstillinger'; + + @override + String get wsSettingsPresent => 'Eksplisitte innstillinger finnes'; + + @override + String get wsTitleOverrideExplanation => + 'Overstyrer publisert tittel; overskriftsteksten beholdes.'; + + @override + String get wsIgnoreVariables => 'Behandle variabeltekst bokstavelig'; + + @override + String get wsIgnoreVariablesExplanation => + 'Når aktivert erstattes ikke tekst som ligner variabler.'; + + @override + String get wsInstanceSelection => 'Hjelpeinstanser'; + + @override + String get wsInstanceExplanation => + 'Velg hjelpeutgaver med navn, grupper, lister, unntak eller uttrykk.'; + + @override + String get wsFilterExplanation => + 'Filtrer gjenbrukbart innhold etter kriterier; dette velger ikke en hjelpeinstans.'; + + @override + String get wsContextHelp => 'Konteksthjelp-ID'; + + @override + String get wsCapitalization => 'Store og små bokstaver'; + + @override + String get wsLibraryTopic => 'Bibliotekemne'; + + @override + String get wsLastModified => 'Sist endret'; + + @override + String get wsMissingTopicHeading => + 'Ingen H1 for emnet. Legg den til i kilden.'; + + @override + String get wsMultipleTopicHeadings => + 'Den første H1 bestemmer emnetittelen. Senere H1 er egne overskrifter.'; + + @override + String get wsTopicIdentityMismatch => + 'Rotens id avviker fra filnavnets emne-ID. Bruk Gi emnefil nytt navn eller se kilden.'; + + @override + String get wsUnsupportedProperties => + 'Ustøttede attributter bevares. Se dem i kilden.'; + + @override + String get wsGeneratedAnchor => + 'Generert anker; en verdi oppretter en overstyring.'; + + @override + String get wsHeadingBuilderLimitation => + 'Den fastlåste byggeren publiserer ikke H1-attributter. De tilhører overskriften, ikke XML-roten.'; + @override String get closeOtherTabs => 'Lukk andre faner'; @@ -4791,4 +4888,518 @@ class AppLocalizationsNb extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Den første forespørselen kan allerede ha opprettet et servernotat. Dette oppretter et separat notat med ditt beholdte lokale arbeid. Ingen antatte duplikater slettes.'; + + @override + String get wsSemanticFormats => 'Semantisk formatering'; + + @override + String get wsUiControl => 'UI-kontroll'; + + @override + String get wsFilePath => 'Fil / Sti'; + + @override + String get wsUiPath => 'UI-sti'; + + @override + String get wsDefinitionList => 'Definisjonsliste'; + + @override + String get wsIncludeContent => 'Inkluder gjenbrukbart innhold'; + + @override + String get wsVariableReference => 'Variabelreferanse'; + + @override + String get wsConvertList => 'Konverter liste til prosedyre'; + + @override + String get wsElementProperties => 'Elementegenskaper'; + + @override + String get wsTopicProperties => 'Emneegenskaper'; + + @override + String get wsCollapsible => 'Sammenleggbar'; + + @override + String get wsPublishedState => 'Publisert starttilstand'; + + @override + String get wsExpanded => 'Utvidet'; + + @override + String get wsCollapsed => 'Sammenlagt'; + + @override + String get wsCollapsedTitle => 'Sammenlagt tittel'; + + @override + String get wsTerm => 'Begrep'; + + @override + String get wsListLayout => 'Listeoppsett'; + + @override + String get wsSyncGroup => 'Synkroniseringsgruppe'; + + @override + String get wsTabKey => 'Fanenøkkel'; + + @override + String get wsSwitcherLabel => 'Bytteetikett'; + + @override + String get wsNavigationElements => 'Navigasjonselementer'; + + @override + String get wsNavigationDepth => 'Navigasjonsdybde'; + + @override + String get wsAddItem => 'Legg til element'; + + @override + String get wsStep => 'Trinn'; + + @override + String get wsPropertyInvalid => 'Ugyldig egenskapsverdi'; + + @override + String get wsFilter => 'Innholdsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Sammendrag (TLDR)'; + + @override + String get wsFullLayout => 'Full bredde'; + + @override + String get wsMediumLayout => 'Middels (1:2)'; + + @override + String get wsNarrowLayout => 'Smal termkolonne'; + + @override + String get wsTitle => 'Tittel'; + + @override + String get wsSwitcherKey => 'Byttenøkkel'; + + @override + String get nextcloudServerSettings => 'Notes-innstillinger på serveren'; + + @override + String get nextcloudNotesPath => 'Sti til notatmappen'; + + @override + String get nextcloudFileSuffix => 'Filendelse for nye notater'; + + @override + String get nextcloudServerSettingsExplanation => + 'Disse Notes-innstillingene påvirker andre klienter. Endring av stien flytter ikke filer; endring av filendelsen gir ikke eksisterende notater nye navn.'; + + @override + String get nextcloudSettingsSaved => 'Serverinnstillinger lagret'; + + @override + String get nextcloudSettingsNormalized => + 'Lagret med verdier normalisert av serveren'; + + @override + String get nextcloudPendingUpload => 'Vedlegg venter på opplasting'; + + @override + String nextcloudServerCheck(String time) { + return 'Siste serverkontroll: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Laster serverinnstillinger'; + + @override + String get nextcloudSettingsSaving => 'Lagrer serverinnstillinger'; + + @override + String get nextcloudPreviousLocalEdit => 'Forrige lokale endring'; + + @override + String get nextcloudUsePreviousEdit => 'Bruk forrige endring'; + + @override + String get quickOpen => 'Hurtigåpning'; + + @override + String get notesAll => 'Alle notater'; + + @override + String get notesUncategorized => 'Uten kategori'; + + @override + String get notesRecovery => 'Gjenoppretting'; + + @override + String get notesRecentlyEdited => 'Nylig redigert'; + + @override + String get notesOldestEdited => 'Eldste redigering'; + + @override + String get notesTitleAscending => 'Tittel A–Z'; + + @override + String get notesTitleDescending => 'Tittel Z–A'; + + @override + String get notesMove => 'Flytt til kategori'; + + @override + String get notesRemoveFavorite => 'Fjern favoritt'; + + @override + String get notesOffline => 'Gjør tilgjengelig frakoblet'; + + @override + String get notesOfflineRemove => 'Fjern frakoblet oppbevaring'; + + @override + String get notesIndexing => 'Indekserer notater…'; + + @override + String get notesSearchHelp => + 'Ord bruker OG. Bruk \"siterte fraser\", title: og category:.'; + + @override + String get notesSourceLocation => 'Kildeplassering'; + + @override + String get notesRecoveryHelp => + 'Kun beholdte lokale notater og lokal historikk. Serverens papirkurv og historikk er utilgjengelige.'; + + @override + String get notesImport => 'Importer notater'; + + @override + String get notesExport => 'Eksporter notater'; + + @override + String get notesImportDistinct => 'Importer som eget notat'; + + @override + String get notesAvailableOffline => 'Tilgjengelig frakoblet'; + + @override + String get notesIncomplete => 'Ufullstendig'; + + @override + String get notesSelected => 'Valgte notater'; + + @override + String get notesNoHistory => + 'Ingen beholdte revisjoner. Historikken kan være deaktivert, utløpt eller slettet.'; + + @override + String get notesTextAvailable => 'Tekst tilgjengelig lokalt'; + + @override + String get notesLocalVersion => 'Lokal arbeidsversjon'; + + @override + String get notesAttachments => 'Vedlegg'; + + @override + String get notesSkipped => 'Hoppet over'; + + @override + String get notesFailed => 'Mislyktes'; + + @override + String get notesCompleted => 'Fullført'; + + @override + String get notesImportNew => 'Start en ny import'; + + @override + String get notesImportResume => 'Fortsett avbrutt import'; + + @override + String get notesImportAlreadyImported => + 'Allerede importert i denne operasjonen'; + + @override + String get references => 'Referanser'; + + @override + String get referenceIncoming => 'Innkommende'; + + @override + String get referenceOutgoing => 'Utgående'; + + @override + String get referenceChooseTarget => 'Velg lenkemål'; + + @override + String get referenceWholeDocument => 'Hele dokumentet'; + + @override + String get referenceSourceLocation => 'Kildeplassering'; + + @override + String get referenceTargetLocation => 'Målplassering'; + + @override + String get referenceResolved => 'Løst'; + + @override + String get referenceMissing => 'Mangler'; + + @override + String get referenceAmbiguous => 'Tvetydig'; + + @override + String get referenceUnavailable => 'Utilgjengelig i denne konteksten'; + + @override + String get referenceUnsupported => 'Ikke støttet'; + + @override + String get referenceNoPortableId => + 'Dette målet har ingen bekreftet portabel notatadresse.'; + + @override + String get wsIncludeLines => 'Inkluder linjer'; + + @override + String get wsIncludeSymbol => 'Inkluder symbol'; + + @override + String get wsEmphasizeLines => 'Fremhev viste linjer'; + + @override + String get wsPropertiesReadOnly => + 'Egenskapene er skrivebeskyttet i denne visningen.'; + + @override + String get wsPropertiesProtected => 'Elementet er beskyttet. Rediger kilden.'; + + @override + String get wsPropertiesConflict => + 'Dokument eller element endret. Tilbakestill utkastet og velg på nytt.'; + + @override + String get wsPropertiesUnresolved => 'Referansen kan ikke løses nå.'; + + @override + String get wsUseDefault => 'Bruk standard'; + + @override + String get wsPropertiesSelection => 'Velg ett element.'; + + @override + String get wsImageDisplay => 'Bildevisning'; + + @override + String get wsImageThumbnail => 'Klikkbart miniatyrbilde'; + + @override + String get wsEnabled => 'Aktivert'; + + @override + String get wsDisabled => 'Deaktivert'; + + @override + String get wsWidth => 'Bredde'; + + @override + String get wsHeight => 'Høyde'; + + @override + String get wsPropertiesInactive => + 'Egenskapen virker bare med publisert sammenfolding.'; + + @override + String get wsNavigationAll => 'Alle elementer med tittel'; + + @override + String get wsNavigationNone => 'Ingen navigasjon'; + + @override + String get wsNavigationCustom => 'Egendefinert navigasjon'; + + @override + String get wsTitleOverride => 'Overstyr tittel'; + + @override + String get wsCompactLayout => 'Kompakt'; + + @override + String get wsWideLayout => 'Bred'; + + @override + String get wsCodeSourceConflict => + 'Flytt eller fjern koden skrevet i denne blokken før du setter src. Writerside avviser en ikke-tom kodeblokk med en kildereferanse.'; + + @override + String get wsInherited => 'Arv fra listen'; + + @override + String get wsExternalImageSize => + 'Writerside endrer ikke størrelsen på eksterne bilder pålitelig. Eksisterende mål kan ses og fjernes.'; + + @override + String get wsPropertiesNotSerialized => + 'Denne endringen ble ikke skrevet til kilden. Utkastet er beholdt.'; + + @override + String get wsListItem => 'Listeelement'; + + @override + String get wsSourceFile => 'Kildefil'; + + @override + String get wsLinkTarget => 'Lenkemål'; + + @override + String get wsPreviewImage => 'Forhåndsvisningsbilde'; + + @override + String get wsIncludedElement => 'Inkludert element'; + + @override + String get wsHelpModule => 'Hjelpemodul'; + + @override + String get wsActionId => 'Handlings-ID'; + + @override + String get wsKeymapHelpInstance => 'Hjelpeinstans for tastaturkart'; + + @override + String get wsShowInSource => 'Vis i Kilde'; + + @override + String get wsOriginElement => 'Elementoverstyring'; + + @override + String get wsOriginAncestor => 'Overordnet element'; + + @override + String get wsOriginModule => 'Modulstandard'; + + @override + String get wsOriginBuiltIn => 'Innebygd standard'; + + @override + String get wsOriginUnresolved => 'Uavklart'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effektiv: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Generert: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Standard: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Standard: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Avklart: $value'; + } + + @override + String get wsPropertyUnresolved => 'Uavklart'; + + @override + String get wsModule => 'Modul'; + + @override + String get wsParent => 'Overordnet element'; + + @override + String get wsListType => 'Listetype'; + + @override + String get wsStartingNumber => 'Startnummer'; + + @override + String get wsListSorting => 'Sortering'; + + @override + String get wsLetteredList => 'Bokstavliste'; + + @override + String get wsCheckboxList => 'Liste med avkrysningsbokser'; + + @override + String get wsMarkerlessList => 'Liste uten markører'; + + @override + String get wsAscending => 'Stigende'; + + @override + String get wsDescending => 'Synkende'; + + @override + String get wsNoSorting => 'Ingen sortering'; + + @override + String get wsAsWritten => 'Som skrevet'; + + @override + String get wsSentenceCase => 'Stor forbokstav i setning'; + + @override + String get wsTitleCase => 'Store forbokstaver i tittel'; + + @override + String get wsLowerCase => 'Små bokstaver'; + + @override + String get wsUpperCase => 'Store bokstaver'; + + @override + String get wsNativeMarkers => 'Markdown-markører'; + + @override + String wsListMarkerStart(String number) { + return 'Første markør: $number'; + } + + @override + String wsListLevel(String level) { + return 'Nivå $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Den fastsatte byggeren ignorerer modulens <caps>-regler. Bruk et lokalt caps-attributt eller default-property.'; + + @override + String get wsInsertElements => 'Sett inn elementer'; + + @override + String get wsAdmonitionsAndQuotes => 'Merknader og sitater'; + + @override + String get wsSemanticText => 'Semantisk tekst'; + + @override + String get wsPropertiesUnavailable => + 'Det inspiserte elementet er utilgjengelig. Inspiser et element for å fortsette.'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 46021aec..5f8ee0e5 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -10,6 +10,103 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Hoofdlettergebruik van de module wordt niet gepubliceerd.'; + + @override + String get wsTitleOverridesAction => 'Instantie- en inhoudsopgavetitels…'; + + @override + String get wsHeadingNotPublished => + 'Wordt niet gepubliceerd door deze bouwer.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'De vastgelegde builder publiceert geen instantietitels in Markdown-onderwerpen. Bestaande opmaak blijft behouden; bekijk de bron.'; + + @override + String get wsTopicIdentity => 'Onderwerp-ID'; + + @override + String get wsElementAnchor => 'Elementanker'; + + @override + String get wsHeadingText => 'Koptekst'; + + @override + String get wsDefaultTitle => 'Standaardtitel'; + + @override + String get wsTopicNavigation => 'Onderwerpnavigatie'; + + @override + String get wsHeadingOptions => 'Kopattributen'; + + @override + String get wsDocumentOptions => 'Documentinstellingen'; + + @override + String get wsSettingsPresent => 'Expliciete instellingen aanwezig'; + + @override + String get wsTitleOverrideExplanation => + 'Vervangt de gepubliceerde titel; de koptekst blijft gelijk.'; + + @override + String get wsIgnoreVariables => 'Variabeletekst letterlijk behandelen'; + + @override + String get wsIgnoreVariablesExplanation => + 'Indien ingeschakeld wordt tekst die op variabelen lijkt niet vervangen.'; + + @override + String get wsInstanceSelection => 'Helpinstanties'; + + @override + String get wsInstanceExplanation => + 'Selecteer helpuitvoer via namen, groepen, lijsten, uitsluitingen of expressies.'; + + @override + String get wsFilterExplanation => + 'Filter herbruikbare inhoud op criteria; dit selecteert geen helpinstantie.'; + + @override + String get wsContextHelp => 'Contexthelp-ID'; + + @override + String get wsCapitalization => 'Hoofdlettergebruik'; + + @override + String get wsLibraryTopic => 'Bibliotheekonderwerp'; + + @override + String get wsLastModified => 'Laatst gewijzigd'; + + @override + String get wsMissingTopicHeading => + 'Geen onderwerp-H1 aanwezig. Voeg deze toe in de bron.'; + + @override + String get wsMultipleTopicHeadings => + 'De eerste H1 bepaalt de onderwerptitel. Latere H1’s zijn afzonderlijke koppen.'; + + @override + String get wsTopicIdentityMismatch => + 'De root-id verschilt van de bestands-ID. Gebruik Onderwerpbestand hernoemen of bekijk de bron.'; + + @override + String get wsUnsupportedProperties => + 'Niet-ondersteunde attributen blijven behouden. Bekijk ze in de bron.'; + + @override + String get wsGeneratedAnchor => + 'Gegenereerd anker; een waarde maakt een overschrijving.'; + + @override + String get wsHeadingBuilderLimitation => + 'De vastgelegde builder publiceert geen H1-attributen. Ze horen bij de kop, niet bij de XML-root.'; + @override String get closeOtherTabs => 'Andere tabbladen sluiten'; @@ -4836,4 +4933,519 @@ class AppLocalizationsNl extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Het eerste verzoek heeft mogelijk al een servernotitie gemaakt. Dit maakt een afzonderlijke notitie met uw behouden lokale werk. Vermoedelijke duplicaten worden niet verwijderd.'; + + @override + String get wsSemanticFormats => 'Semantische opmaak'; + + @override + String get wsUiControl => 'UI-besturingselement'; + + @override + String get wsFilePath => 'Bestand / Pad'; + + @override + String get wsUiPath => 'UI-pad'; + + @override + String get wsDefinitionList => 'Definitielijst'; + + @override + String get wsIncludeContent => 'Herbruikbare inhoud opnemen'; + + @override + String get wsVariableReference => 'Variabelereferentie'; + + @override + String get wsConvertList => 'Lijst omzetten naar procedure'; + + @override + String get wsElementProperties => 'Elementeigenschappen'; + + @override + String get wsTopicProperties => 'Onderwerpeigenschappen'; + + @override + String get wsCollapsible => 'Inklapbaar'; + + @override + String get wsPublishedState => 'Gepubliceerde beginstatus'; + + @override + String get wsExpanded => 'Uitgeklapt'; + + @override + String get wsCollapsed => 'Ingeklapt'; + + @override + String get wsCollapsedTitle => 'Ingeklapte titel'; + + @override + String get wsTerm => 'Begrip'; + + @override + String get wsListLayout => 'Lijstindeling'; + + @override + String get wsSyncGroup => 'Synchronisatiegroep'; + + @override + String get wsTabKey => 'Tabsleutel'; + + @override + String get wsSwitcherLabel => 'Schakelaarlabel'; + + @override + String get wsNavigationElements => 'Navigatie-elementen'; + + @override + String get wsNavigationDepth => 'Navigatiediepte'; + + @override + String get wsAddItem => 'Item toevoegen'; + + @override + String get wsStep => 'Stap'; + + @override + String get wsPropertyInvalid => 'Ongeldige eigenschapswaarde'; + + @override + String get wsFilter => 'Inhoudsfilter'; + + @override + String get wsIdentifier => 'Element-ID'; + + @override + String get wsTldr => 'Samenvatting (TLDR)'; + + @override + String get wsFullLayout => 'Volledige breedte'; + + @override + String get wsMediumLayout => 'Gemiddeld (1:2)'; + + @override + String get wsNarrowLayout => 'Smalle begripskolom'; + + @override + String get wsTitle => 'Titel'; + + @override + String get wsSwitcherKey => 'Keuzeschakelaar'; + + @override + String get nextcloudServerSettings => 'Notes-instellingen op de server'; + + @override + String get nextcloudNotesPath => 'Pad van de notitiemap'; + + @override + String get nextcloudFileSuffix => 'Bestandsextensie voor nieuwe notities'; + + @override + String get nextcloudServerSettingsExplanation => + 'Deze Notes-instellingen gelden ook voor andere clients. Een ander pad verplaatst geen bestanden; een andere extensie hernoemt bestaande notities niet.'; + + @override + String get nextcloudSettingsSaved => 'Serverinstellingen opgeslagen'; + + @override + String get nextcloudSettingsNormalized => + 'Opgeslagen met door de server genormaliseerde waarden'; + + @override + String get nextcloudPendingUpload => 'Bijlage wacht op upload'; + + @override + String nextcloudServerCheck(String time) { + return 'Laatste servercontrole: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Serverinstellingen laden'; + + @override + String get nextcloudSettingsSaving => 'Serverinstellingen opslaan'; + + @override + String get nextcloudPreviousLocalEdit => 'Vorige lokale wijziging'; + + @override + String get nextcloudUsePreviousEdit => 'Vorige wijziging gebruiken'; + + @override + String get quickOpen => 'Snel openen'; + + @override + String get notesAll => 'Alle notities'; + + @override + String get notesUncategorized => 'Zonder categorie'; + + @override + String get notesRecovery => 'Herstel'; + + @override + String get notesRecentlyEdited => 'Recent bewerkt'; + + @override + String get notesOldestEdited => 'Oudste bewerking'; + + @override + String get notesTitleAscending => 'Titel A–Z'; + + @override + String get notesTitleDescending => 'Titel Z–A'; + + @override + String get notesMove => 'Naar categorie verplaatsen'; + + @override + String get notesRemoveFavorite => 'Favoriet verwijderen'; + + @override + String get notesOffline => 'Offline beschikbaar maken'; + + @override + String get notesOfflineRemove => 'Offlinebewaring verwijderen'; + + @override + String get notesIndexing => 'Notities indexeren…'; + + @override + String get notesSearchHelp => + 'Termen gebruiken EN. Gebruik \"geciteerde zinnen\", title: en category:.'; + + @override + String get notesSourceLocation => 'Bronlocatie'; + + @override + String get notesRecoveryHelp => + 'Alleen bewaarde lokale notities en lokale geschiedenis. Prullenbak en geschiedenis van de server zijn niet beschikbaar.'; + + @override + String get notesImport => 'Notities importeren'; + + @override + String get notesExport => 'Notities exporteren'; + + @override + String get notesImportDistinct => 'Als afzonderlijke notitie importeren'; + + @override + String get notesAvailableOffline => 'Offline beschikbaar'; + + @override + String get notesIncomplete => 'Onvolledig'; + + @override + String get notesSelected => 'Geselecteerde notities'; + + @override + String get notesNoHistory => + 'Geen bewaarde revisies. Geschiedenis kan uitgeschakeld, verlopen of gewist zijn.'; + + @override + String get notesTextAvailable => 'Tekst lokaal beschikbaar'; + + @override + String get notesLocalVersion => 'Lokale werkversie'; + + @override + String get notesAttachments => 'Bijlagen'; + + @override + String get notesSkipped => 'Overgeslagen'; + + @override + String get notesFailed => 'Mislukt'; + + @override + String get notesCompleted => 'Voltooid'; + + @override + String get notesImportNew => 'Nieuwe import starten'; + + @override + String get notesImportResume => 'Onderbroken import hervatten'; + + @override + String get notesImportAlreadyImported => 'Al geïmporteerd in deze bewerking'; + + @override + String get references => 'Verwijzingen'; + + @override + String get referenceIncoming => 'Inkomend'; + + @override + String get referenceOutgoing => 'Uitgaand'; + + @override + String get referenceChooseTarget => 'Linkdoel kiezen'; + + @override + String get referenceWholeDocument => 'Heel document'; + + @override + String get referenceSourceLocation => 'Bronlocatie'; + + @override + String get referenceTargetLocation => 'Doellocatie'; + + @override + String get referenceResolved => 'Opgelost'; + + @override + String get referenceMissing => 'Ontbreekt'; + + @override + String get referenceAmbiguous => 'Dubbelzinnig'; + + @override + String get referenceUnavailable => 'Niet beschikbaar in deze context'; + + @override + String get referenceUnsupported => 'Niet ondersteund'; + + @override + String get referenceNoPortableId => + 'Dit doel heeft geen geverifieerde overdraagbare notitie-URL.'; + + @override + String get wsIncludeLines => 'Regels opnemen'; + + @override + String get wsIncludeSymbol => 'Symbool opnemen'; + + @override + String get wsEmphasizeLines => 'Weergegeven regels markeren'; + + @override + String get wsPropertiesReadOnly => + 'Eigenschappen zijn alleen-lezen in deze weergave.'; + + @override + String get wsPropertiesProtected => + 'Dit element is beschermd. Bewerk de bron.'; + + @override + String get wsPropertiesConflict => + 'Document of element gewijzigd. Herstel het concept en selecteer opnieuw.'; + + @override + String get wsPropertiesUnresolved => + 'De verwijzing kan momenteel niet worden opgelost.'; + + @override + String get wsUseDefault => 'Standaard gebruiken'; + + @override + String get wsPropertiesSelection => 'Selecteer één element.'; + + @override + String get wsImageDisplay => 'Afbeeldingsweergave'; + + @override + String get wsImageThumbnail => 'Klikbare miniatuur'; + + @override + String get wsEnabled => 'Ingeschakeld'; + + @override + String get wsDisabled => 'Uitgeschakeld'; + + @override + String get wsWidth => 'Breedte'; + + @override + String get wsHeight => 'Hoogte'; + + @override + String get wsPropertiesInactive => + 'Deze eigenschap werkt alleen bij gepubliceerd inklappen.'; + + @override + String get wsNavigationAll => 'Alle elementen met titel'; + + @override + String get wsNavigationNone => 'Geen navigatie'; + + @override + String get wsNavigationCustom => 'Aangepaste navigatie'; + + @override + String get wsTitleOverride => 'Titel overschrijven'; + + @override + String get wsCompactLayout => 'Compacte indeling'; + + @override + String get wsWideLayout => 'Breed'; + + @override + String get wsCodeSourceConflict => + 'Verplaats of verwijder de code in dit blok voordat u src instelt. Writerside weigert een niet-leeg codeblok met een bronverwijzing.'; + + @override + String get wsInherited => 'Overnemen van lijst'; + + @override + String get wsExternalImageSize => + 'Writerside past het formaat van externe afbeeldingen niet betrouwbaar aan. Bestaande maten kunnen worden bekeken en verwijderd.'; + + @override + String get wsPropertiesNotSerialized => + 'Deze wijziging is niet in de bron verwerkt. Het concept is behouden.'; + + @override + String get wsListItem => 'Lijstitem'; + + @override + String get wsSourceFile => 'Bronbestand'; + + @override + String get wsLinkTarget => 'Linkdoel'; + + @override + String get wsPreviewImage => 'Voorbeeldafbeelding'; + + @override + String get wsIncludedElement => 'Opgenomen element'; + + @override + String get wsHelpModule => 'Helpmodule'; + + @override + String get wsActionId => 'Actie-ID'; + + @override + String get wsKeymapHelpInstance => 'Helpinstantie van toetsindeling'; + + @override + String get wsShowInSource => 'Tonen in Bron'; + + @override + String get wsOriginElement => 'Elementoverschrijving'; + + @override + String get wsOriginAncestor => 'Bovenliggend element'; + + @override + String get wsOriginModule => 'Modulestandaard'; + + @override + String get wsOriginBuiltIn => 'Ingebouwde standaard'; + + @override + String get wsOriginUnresolved => 'Niet opgelost'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Effectief: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Gegenereerd: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Standaard: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Standaard: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Opgelost: $value'; + } + + @override + String get wsPropertyUnresolved => 'Onopgelost'; + + @override + String get wsModule => 'Module'; + + @override + String get wsParent => 'Bovenliggend element'; + + @override + String get wsListType => 'Lijsttype'; + + @override + String get wsStartingNumber => 'Startnummer'; + + @override + String get wsListSorting => 'Sortering'; + + @override + String get wsLetteredList => 'Letterlijst'; + + @override + String get wsCheckboxList => 'Lijst met selectievakjes'; + + @override + String get wsMarkerlessList => 'Lijst zonder markeringen'; + + @override + String get wsAscending => 'Oplopend'; + + @override + String get wsDescending => 'Aflopend'; + + @override + String get wsNoSorting => 'Geen sortering'; + + @override + String get wsAsWritten => 'Zoals geschreven'; + + @override + String get wsSentenceCase => 'Zinskapitalisatie'; + + @override + String get wsTitleCase => 'Titelkapitalisatie'; + + @override + String get wsLowerCase => 'Kleine letters'; + + @override + String get wsUpperCase => 'Hoofdletters'; + + @override + String get wsNativeMarkers => 'Markdown-markeringen'; + + @override + String wsListMarkerStart(String number) { + return 'Beginmarkering: $number'; + } + + @override + String wsListLevel(String level) { + return 'Niveau $level'; + } + + @override + String get wsModuleCapsIgnored => + 'De vastgezette builder negeert <caps>-regels van de module. Gebruik een lokaal caps-attribuut of default-property.'; + + @override + String get wsInsertElements => 'Elementen invoegen'; + + @override + String get wsAdmonitionsAndQuotes => 'Opmerkingen en citaten'; + + @override + String get wsSemanticText => 'Semantische tekst'; + + @override + String get wsPropertiesUnavailable => + 'Het geïnspecteerde element is niet beschikbaar. Inspecteer een element om door te gaan.'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 04b550f9..08351df2 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -10,6 +10,101 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Reguła wielkości liter modułu nie jest publikowana.'; + + @override + String get wsTitleOverridesAction => 'Tytuły instancji i spisu treści…'; + + @override + String get wsHeadingNotPublished => 'Ten generator tego nie publikuje.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Przypięty builder nie publikuje tytułów instancji w tematach Markdown. Istniejące znaczniki są zachowane; sprawdź źródło.'; + + @override + String get wsTopicIdentity => 'ID tematu'; + + @override + String get wsElementAnchor => 'Kotwica elementu'; + + @override + String get wsHeadingText => 'Tekst nagłówka'; + + @override + String get wsDefaultTitle => 'Tytuł domyślny'; + + @override + String get wsTopicNavigation => 'Nawigacja tematu'; + + @override + String get wsHeadingOptions => 'Atrybuty nagłówka'; + + @override + String get wsDocumentOptions => 'Ustawienia dokumentu'; + + @override + String get wsSettingsPresent => 'Obecne jawne ustawienia'; + + @override + String get wsTitleOverrideExplanation => + 'Zastępuje opublikowany tytuł; tekst nagłówka pozostaje bez zmian.'; + + @override + String get wsIgnoreVariables => 'Traktuj zmienne jako tekst dosłowny'; + + @override + String get wsIgnoreVariablesExplanation => + 'Po włączeniu tekst przypominający zmienne nie jest podstawiany.'; + + @override + String get wsInstanceSelection => 'Instancje pomocy'; + + @override + String get wsInstanceExplanation => + 'Wybierz wyjścia pomocy za pomocą nazw, grup, list, wykluczeń lub wyrażeń.'; + + @override + String get wsFilterExplanation => + 'Filtruje treść wielokrotnego użytku według kryteriów; nie wybiera instancji pomocy.'; + + @override + String get wsContextHelp => 'ID pomocy kontekstowej'; + + @override + String get wsCapitalization => 'Wielkość liter'; + + @override + String get wsLibraryTopic => 'Temat biblioteki'; + + @override + String get wsLastModified => 'Ostatnia zmiana'; + + @override + String get wsMissingTopicHeading => 'Brak H1 tematu. Dodaj go w źródle.'; + + @override + String get wsMultipleTopicHeadings => + 'Pierwszy H1 określa tytuł tematu. Kolejne H1 są osobnymi nagłówkami.'; + + @override + String get wsTopicIdentityMismatch => + 'Id korzenia różni się od ID z nazwy pliku. Użyj zmiany nazwy pliku tematu lub sprawdź źródło.'; + + @override + String get wsUnsupportedProperties => + 'Nieobsługiwane atrybuty są zachowane. Sprawdź je w źródle.'; + + @override + String get wsGeneratedAnchor => + 'Wygenerowana kotwica; wpisanie wartości tworzy nadpisanie.'; + + @override + String get wsHeadingBuilderLimitation => + 'Przypięty builder nie publikuje atrybutów H1. Należą do nagłówka, nie korzenia XML.'; + @override String get closeOtherTabs => 'Zamknij pozostałe karty'; @@ -4844,4 +4939,518 @@ class AppLocalizationsPl extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Pierwsze żądanie mogło już utworzyć notatkę na serwerze. Ta czynność tworzy osobną notatkę z zachowaną pracą lokalną. Żadne podejrzane duplikaty nie zostaną usunięte.'; + + @override + String get wsSemanticFormats => 'Formatowanie semantyczne'; + + @override + String get wsUiControl => 'Element interfejsu'; + + @override + String get wsFilePath => 'Plik / Ścieżka'; + + @override + String get wsUiPath => 'Ścieżka interfejsu'; + + @override + String get wsDefinitionList => 'Lista definicji'; + + @override + String get wsIncludeContent => 'Dołącz treść wielokrotnego użytku'; + + @override + String get wsVariableReference => 'Odwołanie do zmiennej'; + + @override + String get wsConvertList => 'Przekształć listę w procedurę'; + + @override + String get wsElementProperties => 'Właściwości elementu'; + + @override + String get wsTopicProperties => 'Właściwości tematu'; + + @override + String get wsCollapsible => 'Możliwość zwijania'; + + @override + String get wsPublishedState => 'Opublikowany stan początkowy'; + + @override + String get wsExpanded => 'Rozwinięty'; + + @override + String get wsCollapsed => 'Zwinięty'; + + @override + String get wsCollapsedTitle => 'Zwinięty tytuł'; + + @override + String get wsTerm => 'Termin'; + + @override + String get wsListLayout => 'Układ listy'; + + @override + String get wsSyncGroup => 'Grupa synchronizacji'; + + @override + String get wsTabKey => 'Klucz karty'; + + @override + String get wsSwitcherLabel => 'Etykieta przełącznika'; + + @override + String get wsNavigationElements => 'Elementy nawigacji'; + + @override + String get wsNavigationDepth => 'Głębokość nawigacji'; + + @override + String get wsAddItem => 'Dodaj element'; + + @override + String get wsStep => 'Krok'; + + @override + String get wsPropertyInvalid => 'Nieprawidłowa wartość właściwości'; + + @override + String get wsFilter => 'Filtr'; + + @override + String get wsIdentifier => 'Identyfikator elementu'; + + @override + String get wsTldr => 'Podsumowanie (TLDR)'; + + @override + String get wsFullLayout => 'Pełna szerokość'; + + @override + String get wsMediumLayout => 'Średni (1:2)'; + + @override + String get wsNarrowLayout => 'Wąska kolumna terminu'; + + @override + String get wsTitle => 'Tytuł'; + + @override + String get wsSwitcherKey => 'Klucz przełącznika'; + + @override + String get nextcloudServerSettings => 'Ustawienia Notes na serwerze'; + + @override + String get nextcloudNotesPath => 'Ścieżka folderu notatek'; + + @override + String get nextcloudFileSuffix => 'Rozszerzenie nowych notatek'; + + @override + String get nextcloudServerSettingsExplanation => + 'Te ustawienia Notes dotyczą innych klientów. Zmiana ścieżki nie przenosi plików; zmiana rozszerzenia nie zmienia nazw istniejących notatek.'; + + @override + String get nextcloudSettingsSaved => 'Zapisano ustawienia serwera'; + + @override + String get nextcloudSettingsNormalized => + 'Zapisano wartości znormalizowane przez serwer'; + + @override + String get nextcloudPendingUpload => 'Załącznik oczekuje na wysłanie'; + + @override + String nextcloudServerCheck(String time) { + return 'Ostatnie sprawdzenie serwera: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Wczytywanie ustawień serwera'; + + @override + String get nextcloudSettingsSaving => 'Zapisywanie ustawień serwera'; + + @override + String get nextcloudPreviousLocalEdit => 'Poprzednia lokalna zmiana'; + + @override + String get nextcloudUsePreviousEdit => 'Użyj poprzedniej zmiany'; + + @override + String get quickOpen => 'Szybkie otwieranie'; + + @override + String get notesAll => 'Wszystkie notatki'; + + @override + String get notesUncategorized => 'Bez kategorii'; + + @override + String get notesRecovery => 'Odzyskiwanie'; + + @override + String get notesRecentlyEdited => 'Ostatnio edytowane'; + + @override + String get notesOldestEdited => 'Najstarsza edycja'; + + @override + String get notesTitleAscending => 'Tytuł A–Z'; + + @override + String get notesTitleDescending => 'Tytuł Z–A'; + + @override + String get notesMove => 'Przenieś do kategorii'; + + @override + String get notesRemoveFavorite => 'Usuń z ulubionych'; + + @override + String get notesOffline => 'Udostępnij offline'; + + @override + String get notesOfflineRemove => 'Usuń przechowywanie offline'; + + @override + String get notesIndexing => 'Indeksowanie notatek…'; + + @override + String get notesSearchHelp => + 'Terminy używają ORAZ. Użyj \"cytowanych fraz\", title: i category:.'; + + @override + String get notesSourceLocation => 'Pozycja w źródle'; + + @override + String get notesRecoveryHelp => + 'Tylko zachowane notatki lokalne i historia lokalna. Kosz i historia serwera są niedostępne.'; + + @override + String get notesImport => 'Importuj notatki'; + + @override + String get notesExport => 'Eksportuj notatki'; + + @override + String get notesImportDistinct => 'Importuj jako osobną notatkę'; + + @override + String get notesAvailableOffline => 'Dostępne offline'; + + @override + String get notesIncomplete => 'Niekompletne'; + + @override + String get notesSelected => 'Wybrane notatki'; + + @override + String get notesNoHistory => + 'Brak zachowanych wersji. Historia może być wyłączona, wygasła lub wyczyszczona.'; + + @override + String get notesTextAvailable => 'Tekst dostępny lokalnie'; + + @override + String get notesLocalVersion => 'Lokalna wersja robocza'; + + @override + String get notesAttachments => 'Załączniki'; + + @override + String get notesSkipped => 'Pominięto'; + + @override + String get notesFailed => 'Niepowodzenie'; + + @override + String get notesCompleted => 'Ukończono'; + + @override + String get notesImportNew => 'Rozpocznij nowy import'; + + @override + String get notesImportResume => 'Wznów przerwany import'; + + @override + String get notesImportAlreadyImported => 'Już zaimportowano w tej operacji'; + + @override + String get references => 'Odwołania'; + + @override + String get referenceIncoming => 'Przychodzące'; + + @override + String get referenceOutgoing => 'Wychodzące'; + + @override + String get referenceChooseTarget => 'Wybierz cel linku'; + + @override + String get referenceWholeDocument => 'Cały dokument'; + + @override + String get referenceSourceLocation => 'Lokalizacja źródła'; + + @override + String get referenceTargetLocation => 'Lokalizacja celu'; + + @override + String get referenceResolved => 'Rozwiązane'; + + @override + String get referenceMissing => 'Brak'; + + @override + String get referenceAmbiguous => 'Niejednoznaczne'; + + @override + String get referenceUnavailable => 'Niedostępne w tym kontekście'; + + @override + String get referenceUnsupported => 'Nieobsługiwane'; + + @override + String get referenceNoPortableId => + 'Ten cel nie ma zweryfikowanego przenośnego adresu URL notatki.'; + + @override + String get wsIncludeLines => 'Dołącz wiersze'; + + @override + String get wsIncludeSymbol => 'Dołącz symbol'; + + @override + String get wsEmphasizeLines => 'Wyróżnij wyświetlane wiersze'; + + @override + String get wsPropertiesReadOnly => + 'W tym widoku właściwości są tylko do odczytu.'; + + @override + String get wsPropertiesProtected => + 'Element jest chroniony. Edytuj jego źródło.'; + + @override + String get wsPropertiesConflict => + 'Dokument lub element zmieniono. Cofnij szkic i wybierz ponownie.'; + + @override + String get wsPropertiesUnresolved => 'Nie można obecnie rozwiązać odwołania.'; + + @override + String get wsUseDefault => 'Użyj domyślnej wartości'; + + @override + String get wsPropertiesSelection => 'Wybierz jeden element.'; + + @override + String get wsImageDisplay => 'Wyświetlanie obrazu'; + + @override + String get wsImageThumbnail => 'Klikalna miniatura'; + + @override + String get wsEnabled => 'Włączone'; + + @override + String get wsDisabled => 'Wyłączone'; + + @override + String get wsWidth => 'Szerokość'; + + @override + String get wsHeight => 'Wysokość'; + + @override + String get wsPropertiesInactive => + 'Ta właściwość działa tylko przy opublikowanym zwijaniu.'; + + @override + String get wsNavigationAll => 'Wszystkie elementy z tytułem'; + + @override + String get wsNavigationNone => 'Bez nawigacji'; + + @override + String get wsNavigationCustom => 'Własna nawigacja'; + + @override + String get wsTitleOverride => 'Zastąp tytuł'; + + @override + String get wsCompactLayout => 'Zwarty'; + + @override + String get wsWideLayout => 'Szeroki'; + + @override + String get wsCodeSourceConflict => + 'Przenieś lub usuń kod napisany w tym bloku przed ustawieniem src. Writerside odrzuca niepusty blok kodu z odwołaniem do źródła.'; + + @override + String get wsInherited => 'Dziedzicz z listy'; + + @override + String get wsExternalImageSize => + 'Writerside nie ustawia niezawodnie rozmiaru obrazów zewnętrznych. Istniejące wymiary można sprawdzić i usunąć.'; + + @override + String get wsPropertiesNotSerialized => + 'Ta zmiana nie została zapisana w źródle. Wersja robocza została zachowana.'; + + @override + String get wsListItem => 'Element listy'; + + @override + String get wsSourceFile => 'Plik źródłowy'; + + @override + String get wsLinkTarget => 'Cel linku'; + + @override + String get wsPreviewImage => 'Obraz podglądu'; + + @override + String get wsIncludedElement => 'Dołączany element'; + + @override + String get wsHelpModule => 'Moduł pomocy'; + + @override + String get wsActionId => 'ID akcji'; + + @override + String get wsKeymapHelpInstance => 'Instancja pomocy mapy klawiszy'; + + @override + String get wsShowInSource => 'Pokaż w Źródle'; + + @override + String get wsOriginElement => 'Wartość elementu'; + + @override + String get wsOriginAncestor => 'Element nadrzędny'; + + @override + String get wsOriginModule => 'Domyślna wartość modułu'; + + @override + String get wsOriginBuiltIn => 'Wbudowana wartość domyślna'; + + @override + String get wsOriginUnresolved => 'Nierozwiązane'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Efektywna wartość: $value — $origin'; + } + + @override + String get wsElementSelector => 'Element'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Wygenerowano: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Domyślnie: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Domyślnie: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Rozwiązano: $value'; + } + + @override + String get wsPropertyUnresolved => 'Nierozwiązane'; + + @override + String get wsModule => 'Moduł'; + + @override + String get wsParent => 'Element nadrzędny'; + + @override + String get wsListType => 'Typ listy'; + + @override + String get wsStartingNumber => 'Numer początkowy'; + + @override + String get wsListSorting => 'Sortowanie'; + + @override + String get wsLetteredList => 'Lista literowa'; + + @override + String get wsCheckboxList => 'Lista pól wyboru'; + + @override + String get wsMarkerlessList => 'Lista bez znaczników'; + + @override + String get wsAscending => 'Rosnąco'; + + @override + String get wsDescending => 'Malejąco'; + + @override + String get wsNoSorting => 'Bez sortowania'; + + @override + String get wsAsWritten => 'Jak napisano'; + + @override + String get wsSentenceCase => 'Wielkość liter zdania'; + + @override + String get wsTitleCase => 'Wielkość liter tytułu'; + + @override + String get wsLowerCase => 'Małe litery'; + + @override + String get wsUpperCase => 'Wielkie litery'; + + @override + String get wsNativeMarkers => 'Znaczniki Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Znacznik początkowy: $number'; + } + + @override + String wsListLevel(String level) { + return 'Poziom $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Przypięty kompilator ignoruje reguły <caps> modułu. Użyj lokalnego atrybutu caps lub default-property.'; + + @override + String get wsInsertElements => 'Wstaw elementy'; + + @override + String get wsAdmonitionsAndQuotes => 'Uwagi i cytaty'; + + @override + String get wsSemanticText => 'Tekst semantyczny'; + + @override + String get wsPropertiesUnavailable => + 'Sprawdzany element jest niedostępny. Sprawdź element, aby kontynuować.'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 96acc585..fee2c505 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'A capitalização do módulo não é publicada.'; + + @override + String get wsTitleOverridesAction => 'Títulos da instância e do índice…'; + + @override + String get wsHeadingNotPublished => 'Não é publicado por este gerador.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'O builder fixado não publica títulos por instância em tópicos Markdown. A marcação existente é preservada; consulte o código-fonte.'; + + @override + String get wsTopicIdentity => 'ID do tópico'; + + @override + String get wsElementAnchor => 'Âncora do elemento'; + + @override + String get wsHeadingText => 'Texto do cabeçalho'; + + @override + String get wsDefaultTitle => 'Título predefinido'; + + @override + String get wsTopicNavigation => 'Navegação do tópico'; + + @override + String get wsHeadingOptions => 'Atributos do cabeçalho'; + + @override + String get wsDocumentOptions => 'Definições do documento'; + + @override + String get wsSettingsPresent => 'Existem definições explícitas'; + + @override + String get wsTitleOverrideExplanation => + 'Substitui o título publicado; o texto do cabeçalho mantém-se.'; + + @override + String get wsIgnoreVariables => 'Tratar variáveis como texto literal'; + + @override + String get wsIgnoreVariablesExplanation => + 'Quando ativo, o texto semelhante a variáveis não é substituído.'; + + @override + String get wsInstanceSelection => 'Instâncias de ajuda'; + + @override + String get wsInstanceExplanation => + 'Selecione saídas por nomes, grupos, listas, exclusões ou expressões.'; + + @override + String get wsFilterExplanation => + 'Filtra conteúdo reutilizável por critérios; não seleciona uma instância de ajuda.'; + + @override + String get wsContextHelp => 'ID de ajuda contextual'; + + @override + String get wsCapitalization => 'Capitalização'; + + @override + String get wsLibraryTopic => 'Tópico de biblioteca'; + + @override + String get wsLastModified => 'Última alteração'; + + @override + String get wsMissingTopicHeading => + 'Não existe H1 do tópico. Adicione-o no código-fonte.'; + + @override + String get wsMultipleTopicHeadings => + 'O primeiro H1 determina o título do tópico. Os restantes são cabeçalhos separados.'; + + @override + String get wsTopicIdentityMismatch => + 'O id da raiz difere do ID baseado no ficheiro. Use Renomear ficheiro do tópico ou consulte o código-fonte.'; + + @override + String get wsUnsupportedProperties => + 'Os atributos não suportados são preservados. Consulte-os no código-fonte.'; + + @override + String get wsGeneratedAnchor => + 'Âncora gerada; introduzir um valor cria uma substituição.'; + + @override + String get wsHeadingBuilderLimitation => + 'O builder fixado não publica atributos H1. Pertencem ao cabeçalho, não à raiz XML.'; + @override String get closeOtherTabs => 'Fechar os outros separadores'; @@ -4851,4849 +4947,5973 @@ class AppLocalizationsPt extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'O primeiro pedido pode já ter criado uma nota no servidor. Isto cria uma nota separada com o trabalho local preservado. Nenhum possível duplicado será eliminado.'; -} -/// The translations for Portuguese, as used in Brazil (`pt_BR`). -class AppLocalizationsPtBr extends AppLocalizationsPt { - AppLocalizationsPtBr() : super('pt_BR'); + @override + String get wsSemanticFormats => 'Formatação semântica'; @override - String get closeOtherTabs => 'Fechar as outras abas'; + String get wsUiControl => 'Controlo da interface'; @override - String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; + String get wsFilePath => 'Ficheiro / Caminho'; @override - String get tocSaveAsTemplate => 'Salvar como modelo'; + String get wsUiPath => 'Caminho da interface'; @override - String get tocTemplateDialog => 'Criar tópico a partir de modelo'; + String get wsDefinitionList => 'Lista de definições'; @override - String get tocTemplatesEditor => 'Modelos de arquivos e código'; + String get wsIncludeContent => 'Incluir conteúdo reutilizável'; @override - String get tocEditTemplates => 'Editar modelos...'; + String get wsVariableReference => 'Referência de variável'; @override - String get tocCreateCustomTemplate => 'Criar modelo personalizado...'; + String get wsConvertList => 'Converter lista em procedimento'; @override - String get tocTemplateCreated => 'Modelo criado'; + String get wsElementProperties => 'Propriedades do elemento'; @override - String get tocTemplateDefault => 'Padrão'; + String get wsTopicProperties => 'Propriedades do tópico'; @override - String get tocTemplateCustom => 'Personalizados'; + String get wsCollapsible => 'Recolhível'; @override - String get tocTemplateFilename => 'Nome do arquivo:'; + String get wsPublishedState => 'Estado inicial publicado'; @override - String get tocTemplateMarkdown => 'Markdown (.md)'; + String get wsExpanded => 'Expandido'; @override - String get tocTemplateXml => 'XML (.topic)'; + String get wsCollapsed => 'Recolhido'; @override - String get tocTemplatesInternal => 'Internos'; + String get wsCollapsedTitle => 'Título recolhido'; @override - String get tocTemplateUnnamed => 'Sem nome'; + String get wsTerm => 'Termo'; @override - String get tocTemplateNew => 'Novo modelo...'; + String get wsListLayout => 'Disposição da lista'; @override - String get tocTemplateName => 'Nome:'; + String get wsSyncGroup => 'Grupo de sincronização'; @override - String get tocTemplateExtension => 'Extensão:'; + String get wsTabKey => 'Chave do separador'; @override - String get tocTemplateLoadError => - 'Não foi possível carregar os modelos. Os modelos existentes não foram alterados.'; + String get wsSwitcherLabel => 'Etiqueta do seletor'; @override - String get tocTemplateSaveError => - 'Não foi possível salvar os modelos. Suas alterações continuam disponíveis aqui.'; + String get wsNavigationElements => 'Elementos de navegação'; @override - String get tocTemplateConflict => - 'Os modelos foram alterados em outra janela. Cancele e reabra o editor.'; + String get wsNavigationDepth => 'Profundidade de navegação'; @override - String get tocTemplateDuplicateName => - 'Já existe um modelo com este nome e extensão.'; + String get wsAddItem => 'Adicionar item'; @override - String get tocTemplateFormat => 'Formato:'; + String get wsStep => 'Passo'; @override - String get tocTemplateReset => 'Redefinir'; + String get wsPropertyInvalid => 'Valor de propriedade inválido'; @override - String get tocTemplateRetry => 'Tentar novamente'; + String get wsFilter => 'Filtro'; @override - String tocRemoveElementsDialog(int count) { - return 'Remover $count elementos do sumário'; - } + String get wsIdentifier => 'ID do elemento'; @override - String get tocRemoveElementsRetainFiles => - 'Os arquivos de origem associados a estes elementos do sumário não serão excluídos.'; + String get wsTldr => 'Resumo (TLDR)'; @override - String get tocOk => 'OK'; + String get wsFullLayout => 'Largura total'; @override - String get tocSelectExistingTopic => - 'Selecionar tópico para adicionar à instância atual'; + String get wsMediumLayout => 'Médio (1:2)'; @override - String get tocGroupName => 'Nome do grupo'; + String get wsNarrowLayout => 'Coluna de termo estreita'; @override - String get tocMoveLineUp => 'Mover linha para cima'; + String get wsTitle => 'Título'; @override - String get tocMoveLineDown => 'Mover linha para baixo'; + String get wsSwitcherKey => 'Chave do seletor'; @override - String get tocEmptyMdTopic => 'Tópico MD vazio'; + String get nextcloudServerSettings => 'Definições de Notes do servidor'; @override - String get tocEmptyXmlTopic => 'Tópico XML vazio'; + String get nextcloudNotesPath => 'Caminho da pasta de notas'; @override - String get tocCopySpecial => 'Cópia especial'; + String get nextcloudFileSuffix => 'Extensão de novas notas'; @override - String tocCopyFileName(String name) { - return 'Nome do arquivo do tópico \'$name\''; - } + String get nextcloudServerSettingsExplanation => + 'Estas definições de Notes afetam outros clientes. Alterar o caminho não move ficheiros; alterar a extensão não renomeia notas existentes.'; @override - String get tocCopyFilePath => 'Caminho do arquivo do tópico'; + String get nextcloudSettingsSaved => 'Definições do servidor guardadas'; @override - String tocCopyTopicTitle(String title) { - return 'Título do tópico \'$title\''; - } + String get nextcloudSettingsNormalized => + 'Guardado com valores normalizados pelo servidor'; @override - String tocCopyElementId(String id) { - return 'ID do elemento do índice \'$id\''; + String get nextcloudPendingUpload => 'Envio de anexo pendente'; + + @override + String nextcloudServerCheck(String time) { + return 'Última verificação do servidor: $time'; } @override - String get tocCopyFileNameUnavailable => 'Nome do arquivo do tópico'; + String get nextcloudSettingsLoading => 'A carregar as definições do servidor'; @override - String get tocCopyTopicTitleUnavailable => 'Título do tópico'; + String get nextcloudSettingsSaving => 'A guardar as definições do servidor'; @override - String get tocCopyElementIdUnavailable => 'ID do elemento do índice'; + String get nextcloudPreviousLocalEdit => 'Edição local anterior'; @override - String get tocEditTitleAction => 'Editar título...'; + String get nextcloudUsePreviousEdit => 'Usar edição anterior'; @override - String get tocEditTitleDialog => 'Editar título'; + String get quickOpen => 'Abertura rápida'; @override - String get tocRemoveElementAction => 'Remover elemento do índice...'; + String get notesAll => 'Todas as notas'; @override - String get tocSetHomePage => 'Definir como página inicial'; + String get notesUncategorized => 'Sem categoria'; @override - String tocGoToElement(String fileName) { - return 'Ir para o elemento do índice em \'$fileName\''; - } + String get notesRecovery => 'Recuperação'; @override - String get tocSynchronize => 'Sincronizar índice e editor'; + String get notesRecentlyEdited => 'Editadas recentemente'; @override - String get tocEmptyGroupAction => 'Grupo vazio'; + String get notesOldestEdited => 'Edições mais antigas'; @override - String get tocNewEmptyGroup => 'Novo grupo vazio'; + String get notesTitleAscending => 'Título A–Z'; @override - String get tocGroupAction => 'Agrupar'; + String get notesTitleDescending => 'Título Z–A'; @override - String get tocNewGroup => 'Novo grupo'; + String get notesMove => 'Mover para categoria'; @override - String get tocSortChildren => 'Ordenar subtópicos alfabeticamente'; + String get notesRemoveFavorite => 'Remover favorito'; @override - String get tocLinkTopicFiles => 'Vincular arquivos de tópicos ao índice...'; + String get notesOffline => 'Disponibilizar offline'; @override - String get addLocalMarkdownFiles => 'Adicionar arquivos Markdown locais'; + String get notesOfflineRemove => 'Remover retenção offline'; @override - String get tocTitleField => 'Título no índice:'; + String get notesIndexing => 'A indexar notas…'; @override - String get tocTopicTitleField => 'Título do tópico:'; + String get notesSearchHelp => + 'Os termos usam E. Use \"frases entre aspas\", title: e category:.'; @override - String get tocAdvancedSettings => 'Configurações avançadas'; + String get notesSourceLocation => 'Localização no código fonte'; @override - String tocInstanceTitleField(String instanceId) { - return 'Título para \'$instanceId\':'; - } + String get notesRecoveryHelp => + 'Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.'; @override - String get tocOnlyTitleField => 'Título exclusivo do índice:'; + String get notesImport => 'Importar notas'; @override - String get tocInstanceTitleExplanation => - 'Usado apenas na instância atual. Por padrão, é herdado do título do tópico.'; + String get notesExport => 'Exportar notas'; @override - String get tocOnlyTitleExplanation => - 'Usado apenas no índice. Por padrão, é herdado do título do tópico ou do título específico da instância, se definido. Os títulos são explicados'; + String get notesImportDistinct => 'Importar como nota distinta'; @override - String get tocTitleDocumentationLink => 'aqui'; + String get notesAvailableOffline => 'Disponível offline'; @override - String get tocFindUsages => 'Localizar'; + String get notesIncomplete => 'Incompleto'; @override - String get tocRefactorMenu => 'Refatorar'; + String get notesSelected => 'Notas selecionadas'; @override - String get tocSafeDelete => 'Exclusão segura'; + String get notesNoHistory => + 'Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.'; @override - String get tocDuplicate => 'Duplicar'; + String get notesTextAvailable => 'Texto disponível localmente'; @override - String get tocDuplicateFilename => 'Nome do arquivo do tópico:'; + String get notesLocalVersion => 'Versão de trabalho local'; @override - String get tocDuplicateDialog => 'Duplicar tópico'; + String get notesAttachments => 'Anexos'; @override - String get pdfTitlePage => 'Página de título'; + String get notesSkipped => 'Ignorado'; @override - String get pdfIncludeTitlePage => 'Incluir página de título'; + String get notesFailed => 'Falhou'; @override - String get pdfTitlePageNote => - 'Esta página não tem cabeçalho, rodapé nem número de página visível.'; + String get notesCompleted => 'Concluído'; @override - String get pdfTitlePageTitle => 'Título'; + String get notesImportNew => 'Iniciar nova importação'; @override - String get pdfTitlePageSubtitle => 'Subtítulo'; + String get notesImportResume => 'Retomar importação interrompida'; @override - String get pdfTitlePageAuthor => 'Autor'; + String get notesImportAlreadyImported => 'Já importado nesta operação'; @override - String get pdfTitlePageOrganization => 'Organização'; + String get references => 'Referências'; @override - String get pdfTitlePageVersion => 'Versão'; + String get referenceIncoming => 'Recebidas'; @override - String get pdfTitlePageDate => 'Data'; + String get referenceOutgoing => 'Enviadas'; @override - String get pdfTitlePageTitleRequired => - 'Insira um título para a página de título.'; + String get referenceChooseTarget => 'Escolher destino da ligação'; @override - String get pdfTocSectionNote => - 'Começa em uma página separada e pode ocupar várias páginas.'; + String get referenceWholeDocument => 'Documento completo'; @override - String get appTitle => 'BusyMark'; + String get referenceSourceLocation => 'Localização de origem'; @override - String get appSubtitle => - 'Editor para arquivos Markdown e projetos de documentação compatíveis com o Writerside.'; + String get referenceTargetLocation => 'Localização de destino'; @override - String get aboutBusyMark => 'Sobre BusyMark'; + String get referenceResolved => 'Resolvida'; @override - String get aboutTagline => 'Editor de Markdown e Writerside'; + String get referenceMissing => 'Em falta'; @override - String get aboutLicenseLabel => 'Licença'; + String get referenceAmbiguous => 'Ambígua'; @override - String get aboutLicenseName => 'Apache License 2.0'; + String get referenceUnavailable => 'Indisponível neste contexto'; @override - String get aboutWebsite => 'Site'; + String get referenceUnsupported => 'Não suportada'; @override - String get aboutSourceCode => 'Código-fonte'; + String get referenceNoPortableId => + 'Este destino não tem um URL portátil de nota verificado.'; @override - String get reportIssue => 'Comunicar um problema'; + String get wsIncludeLines => 'Incluir linhas'; @override - String get feedbackCategory => 'Categoria'; + String get wsIncludeSymbol => 'Incluir símbolo'; @override - String get feedbackChooseCategory => 'Escolha uma categoria'; + String get wsEmphasizeLines => 'Realçar linhas exibidas'; @override - String get feedbackCategoryProblem => 'Problema ou erro'; + String get wsPropertiesReadOnly => + 'Propriedades somente leitura nesta visualização.'; @override - String get feedbackCategoryFeature => 'Pedido de funcionalidade'; + String get wsPropertiesProtected => 'Elemento protegido. Edite sua fonte.'; @override - String get feedbackCategoryPrivacySecurity => - 'Preocupação com privacidade ou segurança'; + String get wsPropertiesConflict => + 'Documento ou elemento alterado. Reverta o rascunho e selecione novamente.'; @override - String get feedbackCategoryUsability => 'Preocupação de usabilidade'; + String get wsPropertiesUnresolved => + 'Não é possível resolver a referência no momento.'; @override - String get feedbackCategoryOther => 'Outro'; + String get wsUseDefault => 'Usar padrão'; @override - String get feedbackSubject => 'Assunto'; + String get wsPropertiesSelection => 'Selecione um único elemento.'; @override - String get feedbackMessage => 'Mensagem detalhada'; + String get wsImageDisplay => 'Exibição de imagem'; @override - String get feedbackReplyEmail => 'E-mail para resposta (opcional)'; + String get wsImageThumbnail => 'Miniatura clicável'; @override - String get feedbackIncludeTechnicalDetails => 'Incluir detalhes técnicos'; + String get wsEnabled => 'Ativado'; @override - String get feedbackTechnicalDetailsDisclosure => - 'Quando esta opção está ativada, adiciona apenas a versão do sistema operacional Linux e o locale do BusyMark. Nenhum log, arquivo, dados de conta ou outro diagnóstico é anexado.'; + String get wsDisabled => 'Desativado'; @override - String get feedbackSubmit => 'Enviar'; + String get wsWidth => 'Largura'; @override - String get feedbackSubmitting => 'Enviando…'; + String get wsHeight => 'Altura'; @override - String get feedbackCategoryRequired => 'Escolha uma categoria.'; + String get wsPropertiesInactive => + 'Esta propriedade não tem efeito sem recolhimento publicado.'; @override - String get feedbackSubjectLength => - 'O assunto deve ter entre 3 e 120 caracteres.'; + String get wsNavigationAll => 'Todos os elementos com título'; @override - String get feedbackMessageLength => - 'A mensagem deve ter entre 10 e 5000 caracteres.'; + String get wsNavigationNone => 'Sem navegação'; @override - String get feedbackReplyEmailInvalid => - 'Digite um endereço de e-mail válido ou deixe este campo vazio.'; + String get wsNavigationCustom => 'Navegação personalizada'; @override - String get feedbackConnectionFailure => - 'O BusyMark não conseguiu estabelecer conexão. Verifique a conexão com a Internet e tente novamente.'; + String get wsTitleOverride => 'Substituir título'; @override - String get feedbackTimeoutFailure => - 'O pedido excedeu o tempo limite. Tente novamente.'; + String get wsCompactLayout => 'Compacto'; @override - String get feedbackRateLimitedFailure => - 'Foram enviados relatórios demais a partir desta conexão. Aguarde e tente novamente.'; + String get wsWideLayout => 'Largo'; @override - String get feedbackRejectedFailure => - 'O servidor rejeitou o relatório. Verifique os campos do formulário e tente novamente.'; + String get wsCodeSourceConflict => + 'Mova ou remova o código escrito neste bloco antes de definir src. O Writerside rejeita um bloco de código não vazio com uma referência de origem.'; @override - String get feedbackServerFailure => - 'O servidor não conseguiu aceitar o relatório. Tente novamente mais tarde.'; + String get wsInherited => 'Herdar da lista'; @override - String feedbackSuccess(String id) { - return 'Comentários enviados. ID de referência: $id'; - } + String get wsExternalImageSize => + 'Writerside não dimensiona imagens externas de forma confiável. As dimensões existentes podem ser consultadas e removidas.'; @override - String get advanced => 'Avançado'; + String get wsPropertiesNotSerialized => + 'Esta alteração não foi aplicada ao código fonte. O rascunho foi mantido.'; @override - String get addToGit => 'Adicionar ao Git'; + String get wsListItem => 'Item da lista'; @override - String get appearance => 'Aparência'; + String get wsSourceFile => 'Arquivo de origem'; @override - String get apply => 'Aplicar'; + String get wsLinkTarget => 'Destino do link'; @override - String get back => 'Voltar'; + String get wsPreviewImage => 'Imagem de prévia'; @override - String get bottomLeft => 'Canto inferior esquerdo'; + String get wsIncludedElement => 'Elemento incluído'; @override - String get bottomRight => 'Canto inferior direito'; + String get wsHelpModule => 'Módulo de ajuda'; @override - String get cancel => 'Cancelar'; + String get wsActionId => 'ID da ação'; @override - String get choose => 'Escolher'; + String get wsKeymapHelpInstance => 'Instância de ajuda do mapa de teclas'; @override - String get chooseLocation => 'Escolher local'; + String get wsShowInSource => 'Mostrar no Fonte'; @override - String get copy => 'Copiar'; + String get wsOriginElement => 'Valor do elemento'; @override - String get copyPlainText => 'Copiar como texto simples'; + String get wsOriginAncestor => 'Elemento pai'; @override - String get clipboardCopyFailed => - 'Não foi possível copiar a seleção para a área de transferência.'; + String get wsOriginModule => 'Padrão do módulo'; @override - String get copyName => 'Copiar nome'; + String get wsOriginBuiltIn => 'Padrão integrado'; @override - String get copyFileName => 'Copiar nome do arquivo'; + String get wsOriginUnresolved => 'Não resolvido'; @override - String get copyPath => 'Copiar caminho'; + String wsEffectiveProperty(String value, String origin) { + return 'Efetivo: $value — $origin'; + } @override - String get create => 'Criar'; + String get wsElementSelector => 'Elemento'; @override - String get creating => 'Criando...'; + String wsGeneratedAnchorValue(String anchor) { + return 'Gerado: $anchor'; + } @override - String get cut => 'Recortar'; + String wsDefaultProperty(String value) { + return 'Padrão: $value'; + } @override - String get promoteSection => 'Promover seção'; + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Padrão: $value · $origin'; + } @override - String get demoteSection => 'Rebaixar seção'; + String wsResolvedProperty(String value) { + return 'Resolvido: $value'; + } @override - String get moveSectionUp => 'Mover seção para cima'; + String get wsPropertyUnresolved => 'Não resolvido'; @override - String get moveSectionDown => 'Mover seção para baixo'; + String get wsModule => 'Módulo'; @override - String get confirmDeleteSectionTitle => 'Excluir seção?'; + String get wsParent => 'Elemento pai'; @override - String confirmDeleteSectionMessage(String name) { - return 'Excluir “$name” e todo o conteúdo da seção? Isso não pode ser desfeito.'; - } + String get wsListType => 'Tipo de lista'; @override - String get darkTheme => 'Escuro'; + String get wsStartingNumber => 'Número inicial'; @override - String get delete => 'Excluir'; + String get wsListSorting => 'Ordenação'; @override - String get discard => 'Descartar'; + String get wsLetteredList => 'Lista alfabética'; @override - String get editor => 'Editor'; + String get wsCheckboxList => 'Lista de caixas de seleção'; @override - String get file => 'Arquivo'; + String get wsMarkerlessList => 'Lista sem marcadores'; @override - String get fileHistory => 'Histórico do arquivo'; + String get wsAscending => 'Ascendente'; @override - String get folder => 'Pasta'; + String get wsDescending => 'Descendente'; @override - String get insert => 'Inserir'; + String get wsNoSorting => 'Sem ordenação'; @override - String get keyboardShortcuts => 'Atalhos de teclado'; + String get wsAsWritten => 'Como escrito'; @override - String get commandPalette => 'Paleta de comandos'; + String get wsSentenceCase => 'Maiúscula inicial'; @override - String get commandPaletteHint => 'Digite um comando'; + String get wsTitleCase => 'Maiúsculas de título'; @override - String get commandPaletteEmpty => 'Nenhum comando correspondente'; + String get wsLowerCase => 'Minúsculas'; @override - String get commandUnavailableInContext => - 'Este comando não está disponível no contexto atual.'; + String get wsUpperCase => 'Maiúsculas'; @override - String get lightTheme => 'Claro'; + String get wsNativeMarkers => 'Marcadores Markdown'; @override - String get mainMenu => 'Menu principal'; + String wsListMarkerStart(String number) { + return 'Marcador inicial: $number'; + } @override - String get fullScreen => 'Tela cheia'; + String wsListLevel(String level) { + return 'Nível $level'; + } @override - String get markdown => 'Markdown'; + String get wsModuleCapsIgnored => + 'O compilador fixado ignora as regras <caps> do módulo. Use um atributo caps local ou default-property.'; @override - String get open => 'Abrir'; + String get wsInsertElements => 'Inserir elementos'; @override - String get openInFiles => 'Abrir em Arquivos'; + String get wsAdmonitionsAndQuotes => 'Avisos e citações'; @override - String get pathActions => 'Ações do caminho'; + String get wsSemanticText => 'Texto semântico'; @override - String get outline => 'Estrutura'; + String get wsPropertiesUnavailable => + 'O elemento inspecionado está indisponível. Inspecione um elemento para continuar.'; +} - @override - String get overwrite => 'Sobrescrever'; +/// The translations for Portuguese, as used in Brazil (`pt_BR`). +class AppLocalizationsPtBr extends AppLocalizationsPt { + AppLocalizationsPtBr() : super('pt_BR'); @override - String get paste => 'Colar'; + String get wsModuleCapsNotPublished => + 'A capitalização do módulo não é publicada.'; @override - String get reading => 'Leitura'; + String get wsTitleOverridesAction => 'Títulos da instância e do índice…'; @override - String get removeFromRecent => 'Remover dos recentes'; + String get wsHeadingNotPublished => 'Não é publicado por este gerador.'; @override - String get recent => 'Recentes'; + String get wsMarkdownInstanceTitleLimitation => + 'O builder fixado não publica títulos por instância em tópicos Markdown. A marcação existente é preservada; consulte o código-fonte.'; @override - String get redo => 'Refazer'; + String get wsTopicIdentity => 'ID do tópico'; @override - String get save => 'Salvar'; + String get wsElementAnchor => 'Âncora do elemento'; @override - String get search => 'Pesquisar'; + String get wsHeadingText => 'Texto do cabeçalho'; @override - String get selectAll => 'Selecionar tudo'; + String get wsDefaultTitle => 'Título padrão'; @override - String get settings => 'Configurações'; + String get wsTopicNavigation => 'Navegação do tópico'; @override - String get source => 'Código-fonte'; + String get wsHeadingOptions => 'Atributos do cabeçalho'; @override - String get split => 'Dividido'; + String get wsDocumentOptions => 'Configurações do documento'; @override - String get systemTheme => 'Sistema'; + String get wsSettingsPresent => 'Existem configurações explícitas'; @override - String get theme => 'Tema'; + String get wsTitleOverrideExplanation => + 'Substitui o título publicado; o texto do cabeçalho permanece.'; @override - String get appLanguage => 'Idioma'; + String get wsIgnoreVariables => 'Tratar variáveis como texto literal'; @override - String get systemLanguage => 'Sistema'; + String get wsIgnoreVariablesExplanation => + 'Quando ativo, o texto semelhante a variáveis não é substituído.'; @override - String get languageEnglish => 'English'; + String get wsInstanceSelection => 'Instâncias de ajuda'; @override - String get languageGerman => 'Deutsch'; + String get wsInstanceExplanation => + 'Selecione saídas por nomes, grupos, listas, exclusões ou expressões.'; @override - String get languageDutch => 'Nederlands'; + String get wsFilterExplanation => + 'Filtra conteúdo reutilizável por critérios; não seleciona uma instância de ajuda.'; @override - String get languageTurkish => 'Türkçe'; + String get wsContextHelp => 'ID de ajuda contextual'; @override - String get languageItalian => 'Italiano'; + String get wsCapitalization => 'Capitalização'; @override - String get languageNorwegian => 'Norsk bokmål'; + String get wsLibraryTopic => 'Tópico de biblioteca'; @override - String get languageFrench => 'Français'; + String get wsLastModified => 'Última alteração'; @override - String get languageRussian => 'Русский'; + String get wsMissingTopicHeading => + 'Não existe H1 do tópico. Adicione-o no código-fonte.'; @override - String get languageUkrainian => 'Українська'; + String get wsMultipleTopicHeadings => + 'O primeiro H1 determina o título do tópico. Os restantes são cabeçalhos separados.'; @override - String get languagePolish => 'Polski'; + String get wsTopicIdentityMismatch => + 'O id da raiz difere do ID baseado no arquivo. Use Renomear arquivo do tópico ou consulte o código-fonte.'; @override - String get languageSpanish => 'Español'; + String get wsUnsupportedProperties => + 'Os atributos não suportados são preservados. Consulte-os no código-fonte.'; @override - String get languagePortuguese => 'Português'; + String get wsGeneratedAnchor => + 'Âncora gerada; introduzir um valor cria uma substituição.'; @override - String get languageArabic => 'العربية'; + String get wsHeadingBuilderLimitation => + 'O builder fixado não publica atributos H1. Pertencem ao cabeçalho, não à raiz XML.'; @override - String get languagePersian => 'فارسی'; + String get closeOtherTabs => 'Fechar as outras abas'; @override - String get languageHindi => 'हिन्दी'; + String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; @override - String get languageIndonesian => 'Bahasa Indonesia'; + String get tocSaveAsTemplate => 'Salvar como modelo'; @override - String get languageEstonian => 'Eesti'; + String get tocTemplateDialog => 'Criar tópico a partir de modelo'; @override - String get languageVietnamese => 'Tiếng Việt'; + String get tocTemplatesEditor => 'Modelos de arquivos e código'; @override - String get languageJapanese => '日本語'; + String get tocEditTemplates => 'Editar modelos...'; @override - String get languageKorean => '한국어'; + String get tocCreateCustomTemplate => 'Criar modelo personalizado...'; @override - String get languageSimplifiedChinese => '简体中文'; + String get tocTemplateCreated => 'Modelo criado'; @override - String get toggleSidebar => 'Painel lateral'; + String get tocTemplateDefault => 'Padrão'; @override - String get topLeft => 'Canto superior esquerdo'; + String get tocTemplateCustom => 'Personalizados'; @override - String get topRight => 'Canto superior direito'; + String get tocTemplateFilename => 'Nome do arquivo:'; @override - String get undo => 'Desfazer'; + String get tocTemplateMarkdown => 'Markdown (.md)'; @override - String get validate => 'Validar'; + String get tocTemplateXml => 'XML (.topic)'; @override - String get validation => 'Validação'; + String get tocTemplatesInternal => 'Internos'; @override - String get viewMode => 'Modo de exibição'; + String get tocTemplateUnnamed => 'Sem nome'; @override - String get welcome => 'Bem-vindo'; + String get tocTemplateNew => 'Novo modelo...'; @override - String get writerside => 'Writerside'; + String get tocTemplateName => 'Nome:'; @override - String get xml => 'XML'; + String get tocTemplateExtension => 'Extensão:'; @override - String get fileTypeMarkdown => 'Markdown'; + String get tocTemplateLoadError => + 'Não foi possível carregar os modelos. Os modelos existentes não foram alterados.'; @override - String get fileTypeImages => 'Imagens'; + String get tocTemplateSaveError => + 'Não foi possível salvar os modelos. Suas alterações continuam disponíveis aqui.'; @override - String get openMarkdownFile => 'Abrir arquivo Markdown'; + String get tocTemplateConflict => + 'Os modelos foram alterados em outra janela. Cancele e reabra o editor.'; @override - String get markdownFileExtensions => '.md ou .markdown'; + String get tocTemplateDuplicateName => + 'Já existe um modelo com este nome e extensão.'; @override - String get openFolderOrWritersideProject => - 'Abrir pasta ou projeto Writerside'; + String get tocTemplateFormat => 'Formato:'; @override - String get markdownFolderOrWritersideProject => - 'Pasta Markdown ou projeto compatível com Writerside'; + String get tocTemplateReset => 'Redefinir'; @override - String get noOpenFile => 'Nenhum arquivo aberto'; + String get tocTemplateRetry => 'Tentar novamente'; @override - String get shortcutDeleteTreeItemDescription => - 'Excluir o item selecionado em Arquivos ou remover o tópico selecionado do sumário'; + String tocRemoveElementsDialog(int count) { + return 'Remover $count elementos do sumário'; + } @override - String get shortcutGroupGeneral => 'Geral'; + String get tocRemoveElementsRetainFiles => + 'Os arquivos de origem associados a estes elementos do sumário não serão excluídos.'; @override - String get shortcutNewDocument => 'Criar'; + String get tocOk => 'OK'; @override - String get shortcutNewDocumentDescription => - 'Criar arquivo Markdown ou projeto Writerside'; + String get tocSelectExistingTopic => + 'Selecionar tópico para adicionar à instância atual'; @override - String get shortcutOpenDescription => - 'Abrir um arquivo Markdown, uma pasta ou um projeto Writerside'; + String get tocGroupName => 'Nome do grupo'; @override - String get shortcutSaveDescription => 'Salvar o documento atual'; + String get tocMoveLineUp => 'Mover linha para cima'; @override - String get shortcutSearchDescription => - 'Pesquisar no espaço de trabalho atual'; + String get tocMoveLineDown => 'Mover linha para baixo'; @override - String get shortcutKeyboardShortcutsDescription => - 'Mostrar esta referência de atalhos de teclado'; + String get tocEmptyMdTopic => 'Tópico MD vazio'; @override - String get shortcutSyntaxReferenceDescription => - 'Abrir a referência de sintaxe'; + String get tocEmptyXmlTopic => 'Tópico XML vazio'; @override - String get shortcutSettingsDescription => - 'Abrir as configurações do BusyMark'; + String get tocCopySpecial => 'Cópia especial'; @override - String get shortcutNextTab => 'Próxima guia'; + String tocCopyFileName(String name) { + return 'Nome do arquivo do tópico \'$name\''; + } @override - String get shortcutNextTabDescription => 'Ir para a próxima guia aberta'; + String get tocCopyFilePath => 'Caminho do arquivo do tópico'; @override - String get shortcutPreviousTab => 'Guia anterior'; + String tocCopyTopicTitle(String title) { + return 'Título do tópico \'$title\''; + } @override - String get shortcutPreviousTabDescription => 'Ir para a guia aberta anterior'; + String tocCopyElementId(String id) { + return 'ID do elemento do índice \'$id\''; + } @override - String get shortcutCloseTab => 'Fechar guia'; + String get tocCopyFileNameUnavailable => 'Nome do arquivo do tópico'; @override - String get shortcutCloseTabDescription => 'Fechar a guia ativa'; + String get tocCopyTopicTitleUnavailable => 'Título do tópico'; @override - String get shortcutCloseAllTabs => 'Fechar todas as guias'; + String get tocCopyElementIdUnavailable => 'ID do elemento do índice'; @override - String get shortcutCloseAllTabsDescription => 'Fechar todas as guias abertas'; + String get tocEditTitleAction => 'Editar título...'; @override - String get shortcutGroupTextEditing => 'Edição de texto'; + String get tocEditTitleDialog => 'Editar título'; @override - String get shortcutSelectAllDescription => - 'No modo Código-fonte, selecionar todo o texto; no modo Editor, pressionar duas vezes para selecionar todos os blocos'; + String get tocRemoveElementAction => 'Remover elemento do índice...'; @override - String get shortcutCutDescription => 'Recortar o texto selecionado'; + String get tocSetHomePage => 'Definir como página inicial'; @override - String get shortcutCopyDescription => 'Copiar o texto selecionado'; + String tocGoToElement(String fileName) { + return 'Ir para o elemento do índice em \'$fileName\''; + } @override - String get shortcutPasteDescription => 'Colar da área de transferência'; + String get tocSynchronize => 'Sincronizar índice e editor'; @override - String get shortcutPastePlainTextDescription => - 'Colar o texto da área de transferência sem formatação'; + String get tocEmptyGroupAction => 'Grupo vazio'; @override - String get shortcutUndoDescription => 'Desfazer a última edição'; + String get tocNewEmptyGroup => 'Novo grupo vazio'; @override - String get shortcutRedoDescription => 'Refazer a última edição desfeita'; + String get tocGroupAction => 'Agrupar'; @override - String get shortcutInsertIndentation => 'Inserir recuo'; + String get tocNewGroup => 'Novo grupo'; @override - String get shortcutInsertIndentationDescription => - 'Inserir recuo na posição do cursor'; + String get tocSortChildren => 'Ordenar subtópicos alfabeticamente'; @override - String get shortcutOutdentSource => 'Diminuir recuo do código-fonte'; + String get tocLinkTopicFiles => 'Vincular arquivos de tópicos ao índice...'; @override - String get shortcutOutdentSourceDescription => - 'Remover um nível de recuo no modo Código-fonte'; + String get addLocalMarkdownFiles => 'Adicionar arquivos Markdown locais'; @override - String get shortcutEscape => - 'Fechar a pesquisa ou limpar a seleção de blocos'; + String get tocTitleField => 'Título no índice:'; @override - String get shortcutEscapeDescription => - 'Fechar a pesquisa do espaço de trabalho ou limpar uma seleção de blocos no modo Editor'; + String get tocTopicTitleField => 'Título do tópico:'; @override - String get shortcutGroupFormatting => 'Formatação'; + String get tocAdvancedSettings => 'Configurações avançadas'; @override - String get shortcutBoldDescription => 'Alternar negrito no texto selecionado'; + String tocInstanceTitleField(String instanceId) { + return 'Título para \'$instanceId\':'; + } @override - String get shortcutItalicDescription => - 'Alternar itálico no texto selecionado'; + String get tocOnlyTitleField => 'Título exclusivo do índice:'; @override - String get shortcutUnderlineDescription => - 'Alternar sublinhado no texto selecionado'; + String get tocInstanceTitleExplanation => + 'Usado apenas na instância atual. Por padrão, é herdado do título do tópico.'; @override - String get shortcutLinkDescription => 'Inserir ou editar um link'; + String get tocOnlyTitleExplanation => + 'Usado apenas no índice. Por padrão, é herdado do título do tópico ou do título específico da instância, se definido. Os títulos são explicados'; @override - String get shortcutInlineCodeDescription => - 'Alternar código embutido no texto selecionado'; + String get tocTitleDocumentationLink => 'aqui'; @override - String get shortcutStrikethroughDescription => - 'Alternar tachado no texto selecionado'; + String get tocFindUsages => 'Localizar'; @override - String get shortcutGroupBlocks => 'Blocos'; + String get tocRefactorMenu => 'Refatorar'; @override - String get shortcutParagraphDescription => - 'Definir o bloco atual como parágrafo'; + String get tocSafeDelete => 'Exclusão segura'; @override - String get shortcutHeading1Description => - 'Definir o bloco atual como Título 1'; + String get tocDuplicate => 'Duplicar'; @override - String get shortcutHeading2Description => - 'Definir o bloco atual como Título 2'; + String get tocDuplicateFilename => 'Nome do arquivo do tópico:'; @override - String get shortcutHeading3Description => - 'Definir o bloco atual como Título 3'; + String get tocDuplicateDialog => 'Duplicar tópico'; @override - String get shortcutHeading4Description => - 'Definir o bloco atual como Título 4'; + String get pdfTitlePage => 'Página de título'; @override - String get shortcutHeading5Description => - 'Definir o bloco atual como Título 5'; + String get pdfIncludeTitlePage => 'Incluir página de título'; @override - String get shortcutHeading6Description => - 'Definir o bloco atual como Título 6'; + String get pdfTitlePageNote => + 'Esta página não tem cabeçalho, rodapé nem número de página visível.'; @override - String get shortcutGroupLists => 'Listas'; + String get pdfTitlePageTitle => 'Título'; @override - String get numberedList => 'Lista numerada'; + String get pdfTitlePageSubtitle => 'Subtítulo'; @override - String get shortcutNumberedListDescription => - 'Alternar formatação de lista numerada'; + String get pdfTitlePageAuthor => 'Autor'; @override - String get bulletedList => 'Lista com marcadores'; + String get pdfTitlePageOrganization => 'Organização'; @override - String get shortcutBulletedListDescription => - 'Alternar formatação de lista com marcadores'; + String get pdfTitlePageVersion => 'Versão'; @override - String get checklist => 'Lista de verificação'; + String get pdfTitlePageDate => 'Data'; @override - String get shortcutChecklistDescription => - 'Alternar formatação de lista de verificação'; + String get pdfTitlePageTitleRequired => + 'Insira um título para a página de título.'; @override - String get shortcutGroupSidebar => 'Barra lateral'; + String get pdfTocSectionNote => + 'Começa em uma página separada e pode ocupar várias páginas.'; @override - String get sidebarViewMenu => 'Visualização da barra lateral'; + String get appTitle => 'BusyMark'; @override - String get createMarkdownFile => 'Criar arquivo Markdown'; + String get appSubtitle => + 'Editor para arquivos Markdown e projetos de documentação compatíveis com o Writerside.'; @override - String get createMarkdownFileDescription => - 'Iniciar um documento Markdown local não salvo'; + String get aboutBusyMark => 'Sobre BusyMark'; @override - String get createWritersideProject => 'Criar projeto Writerside'; + String get aboutTagline => 'Editor de Markdown e Writerside'; @override - String get createWritersideProjectDescription => - 'Iniciar um projeto local compatível com Writerside'; + String get aboutLicenseLabel => 'Licença'; @override - String get defaultProjectName => 'Documentação'; + String get aboutLicenseName => 'Apache License 2.0'; @override - String get defaultInstanceName => 'Guia do usuário'; + String get aboutWebsite => 'Site'; @override - String get defaultStartTopicTitle => 'Primeiros passos'; + String get aboutSourceCode => 'Código-fonte'; @override - String get projectName => 'Nome do projeto'; + String get reportIssue => 'Comunicar um problema'; @override - String get directoryName => 'Nome do diretório'; + String get feedbackCategory => 'Categoria'; @override - String get instanceName => 'Nome da instância'; + String get feedbackChooseCategory => 'Escolha uma categoria'; @override - String get instanceId => 'ID da instância'; + String get feedbackCategoryProblem => 'Problema ou erro'; @override - String get startTopicTitle => 'Título do tópico inicial'; + String get feedbackCategoryFeature => 'Pedido de funcionalidade'; @override - String get location => 'Local'; + String get feedbackCategoryPrivacySecurity => + 'Preocupação com privacidade ou segurança'; @override - String get projectNameRequired => 'O nome do projeto é obrigatório.'; + String get feedbackCategoryUsability => 'Preocupação de usabilidade'; @override - String get directoryNameRequired => 'O nome do diretório é obrigatório.'; + String get feedbackCategoryOther => 'Outro'; @override - String get useSingleSafeDirectoryName => - 'Use um único nome de diretório seguro.'; + String get feedbackSubject => 'Assunto'; @override - String get useLowercaseIdentifier => - 'Use um identificador em minúsculas com letras, números, sublinhados ou hifens.'; + String get feedbackMessage => 'Mensagem detalhada'; @override - String get startTopicTitleRequired => - 'O título do tópico inicial é obrigatório.'; + String get feedbackReplyEmail => 'E-mail para resposta (opcional)'; @override - String get createWritersideProjectFailed => - 'Não foi possível criar o projeto Writerside.'; + String get feedbackIncludeTechnicalDetails => 'Incluir detalhes técnicos'; @override - String get settingsTitle => 'Configurações do BusyMark'; + String get feedbackTechnicalDetailsDisclosure => + 'Quando esta opção está ativada, adiciona apenas a versão do sistema operacional Linux e o locale do BusyMark. Nenhum log, arquivo, dados de conta ou outro diagnóstico é anexado.'; @override - String get autoSave => 'Salvamento automático'; + String get feedbackSubmit => 'Enviar'; @override - String get autoSaveDescription => - 'Salva automaticamente as alterações do arquivo após um curto período de inatividade.'; + String get feedbackSubmitting => 'Enviando…'; @override - String get wordWrap => 'Quebra de linha'; + String get feedbackCategoryRequired => 'Escolha uma categoria.'; @override - String get wordWrapDescription => - 'Quebre linhas longas para ajustá-las à largura do editor em vez de rolar horizontalmente.'; + String get feedbackSubjectLength => + 'O assunto deve ter entre 3 e 120 caracteres.'; @override - String get editorFontSize => 'Tamanho da fonte do editor'; + String get feedbackMessageLength => + 'A mensagem deve ter entre 10 e 5000 caracteres.'; @override - String get validateOnEdit => 'Validar ao editar'; + String get feedbackReplyEmailInvalid => + 'Digite um endereço de e-mail válido ou deixe este campo vazio.'; @override - String get clearRecentWorkspaces => 'Limpar espaços de trabalho recentes'; + String get feedbackConnectionFailure => + 'O BusyMark não conseguiu estabelecer conexão. Verifique a conexão com a Internet e tente novamente.'; @override - String get editingButtonsPosition => 'Posição dos botões de edição'; + String get feedbackTimeoutFailure => + 'O pedido excedeu o tempo limite. Tente novamente.'; @override - String get editingButtonsPositionDescription => - 'Escolha onde os botões flutuantes de edição WYSIWYG aparecem.'; + String get feedbackRateLimitedFailure => + 'Foram enviados relatórios demais a partir desta conexão. Aguarde e tente novamente.'; @override - String get editingButtonsDirection => 'Orientação dos botões de edição'; + String get feedbackRejectedFailure => + 'O servidor rejeitou o relatório. Verifique os campos do formulário e tente novamente.'; @override - String get editingButtonsDirectionDescription => - 'Escolha se os botões flutuantes de edição WYSIWYG ficam dispostos horizontalmente ou verticalmente.'; + String get feedbackServerFailure => + 'O servidor não conseguiu aceitar o relatório. Tente novamente mais tarde.'; @override - String get horizontal => 'Horizontal'; + String feedbackSuccess(String id) { + return 'Comentários enviados. ID de referência: $id'; + } @override - String get vertical => 'Vertical'; + String get advanced => 'Avançado'; @override - String get privacy => 'Privacidade'; + String get addToGit => 'Adicionar ao Git'; @override - String get allowRemoteImages => 'Carregar imagens remotas'; + String get appearance => 'Aparência'; @override - String get allowRemoteImagesDescription => - 'Permitir que a pré-visualização do Markdown e o editor carreguem imagens de URLs HTTP e HTTPS.'; + String get apply => 'Aplicar'; @override - String get clearRemoteImagePermissions => - 'Limpar permissões de imagens remotas'; + String get back => 'Voltar'; @override - String get clearRemoteImagePermissionsDescription => - 'Esquecer os espaços de trabalho autorizados a carregar imagens remotas.'; + String get bottomLeft => 'Canto inferior esquerdo'; @override - String get clearGitWorkspaceTrust => - 'Limpar espaços de trabalho confiáveis para o Git'; + String get bottomRight => 'Canto inferior direito'; @override - String get clearGitWorkspaceTrustDescription => - 'Perguntar antes de ativar recursos do Git em espaços de trabalho considerados confiáveis anteriormente.'; + String get cancel => 'Cancelar'; @override - String get settingsEditingButtonsSectionTitle => 'Botões de edição'; + String get choose => 'Escolher'; @override - String get spellingDictionaries => 'Dicionários ortográficos'; + String get chooseLocation => 'Escolher local'; @override - String get spellingDictionariesDescription => - 'Instalar e gerenciar dicionários ortográficos'; + String get copy => 'Copiar'; @override - String get settingsDictionariesSectionTitle => 'Dicionários'; + String get copyPlainText => 'Copiar como texto simples'; @override - String get availableSpellingDictionaries => 'Dicionários disponíveis'; + String get clipboardCopyFailed => + 'Não foi possível copiar a seleção para a área de transferência.'; @override - String get customSpellingDictionaries => 'Dicionários personalizados'; + String get copyName => 'Copiar nome'; @override - String get spellingDictionaryProblems => 'Problemas de dicionários'; + String get copyFileName => 'Copiar nome do arquivo'; @override - String get settingsWindowSectionTitle => 'Janela'; + String get copyPath => 'Copiar caminho'; @override - String get settingsReopenWorkspaceOnStartupTitle => - 'Reabrir o espaço de trabalho anterior ao iniciar'; + String get create => 'Criar'; @override - String get settingsReopenWorkspaceOnStartupDescription => - 'Abra o espaço de trabalho e as abas da sessão anterior quando o BusyMark iniciar.'; + String get creating => 'Criando...'; @override - String get settingsConfirmCloseWithUnsavedChangesTitle => - 'Confirmar antes de fechar com alterações não salvas'; + String get cut => 'Recortar'; @override - String get settingsConfirmCloseWithUnsavedChangesDescription => - 'Perguntar antes de fechar o BusyMark quando documentos tiverem alterações não salvas.'; + String get promoteSection => 'Promover seção'; @override - String get closeUnsavedChangesTitle => 'Alterações não salvas'; + String get demoteSection => 'Rebaixar seção'; @override - String get closeUnsavedChangesSingleMessage => - 'Este documento tem alterações não salvas. Salvar as alterações antes de fechar o BusyMark?'; + String get moveSectionUp => 'Mover seção para cima'; @override - String closeUnsavedChangesMultipleMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: - '$count documentos têm alterações não salvas. Salvar as alterações antes de fechar o BusyMark?', - one: - '1 documento tem alterações não salvas. Salvar as alterações antes de fechar o BusyMark?', - zero: 'Salvar as alterações antes de fechar o BusyMark?', - ); - return '$_temp0'; - } + String get moveSectionDown => 'Mover seção para baixo'; @override - String get closeUnsavedChangesCancel => 'Cancelar'; + String get confirmDeleteSectionTitle => 'Excluir seção?'; @override - String get closeUnsavedChangesDiscard => 'Descartar'; + String confirmDeleteSectionMessage(String name) { + return 'Excluir “$name” e todo o conteúdo da seção? Isso não pode ser desfeito.'; + } @override - String get closeUnsavedChangesSave => 'Salvar'; + String get darkTheme => 'Escuro'; @override - String get currentFile => 'arquivo atual'; + String get delete => 'Excluir'; @override - String get unsavedChanges => 'Alterações não salvas'; + String get discard => 'Descartar'; @override - String unsavedChangesMessage(String fileName) { - return 'Você tem alterações não salvas em $fileName. Salvá-las antes de continuar?'; - } + String get editor => 'Editor'; @override - String unsavedChangesMultipleMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: - '$count documentos com alterações não salvas. Deseja salvá-los antes de continuar?', - one: - 'Há 1 documento com alterações não salvas. Deseja salvá-lo antes de continuar?', - ); - return '$_temp0'; - } + String get file => 'Arquivo'; @override - String get fileChangedOnDisk => 'Arquivo alterado no disco'; + String get fileHistory => 'Histórico do arquivo'; @override - String get fileChangedOnDiskMessage => - 'Este arquivo foi alterado no disco desde que foi aberto. Sobrescrever?'; + String get folder => 'Pasta'; @override - String get untitledMarkdownFileName => 'Sem título.md'; + String get insert => 'Inserir'; @override - String get unorderedList => 'Lista não ordenada'; + String get keyboardShortcuts => 'Atalhos de teclado'; @override - String get orderedList => 'Lista ordenada'; + String get commandPalette => 'Paleta de comandos'; @override - String get taskList => 'Lista de tarefas'; + String get commandPaletteHint => 'Digite um comando'; @override - String get toggleTaskChecked => 'Marcar/desmarcar tarefa'; + String get commandPaletteEmpty => 'Nenhum comando correspondente'; @override - String get indentListItem => 'Recuar item da lista'; + String get commandUnavailableInContext => + 'Este comando não está disponível no contexto atual.'; @override - String get outdentListItem => 'Remover recuo do item da lista'; + String get lightTheme => 'Claro'; @override - String get blockquote => 'Citação em bloco'; + String get mainMenu => 'Menu principal'; @override - String get codeBlock => 'Bloco de código'; + String get fullScreen => 'Tela cheia'; @override - String get codeBlockLanguage => 'Linguagem do bloco de código'; + String get markdown => 'Markdown'; @override - String get image => 'Imagem'; + String get open => 'Abrir'; @override - String get video => 'Vídeo'; + String get openInFiles => 'Abrir em Arquivos'; @override - String get openVideo => 'Reproduzir vídeo'; + String get pathActions => 'Ações do caminho'; @override - String get pauseVideo => 'Pausar vídeo'; + String get outline => 'Estrutura'; @override - String get videoUnavailable => 'Vídeo indisponível'; + String get overwrite => 'Sobrescrever'; @override - String get videoPreview => 'Pré-visualização do vídeo'; + String get paste => 'Colar'; @override - String get diagnosticWritersideVideoMissingSource => - 'O vídeo não tem o atributo src.'; + String get reading => 'Leitura'; @override - String diagnosticWritersideVideoUnsupportedSource(String source) { - return 'Fonte de vídeo não suportada: $source'; - } + String get removeFromRecent => 'Remover dos recentes'; @override - String diagnosticWritersideVideoMissingFile(String source) { - return 'O arquivo de vídeo não existe: $source'; - } + String get recent => 'Recentes'; @override - String diagnosticWritersideVideoMissingPreview(String preview) { - return 'A imagem de pré-visualização do vídeo não existe: $preview'; - } + String get redo => 'Refazer'; @override - String get inlineImage => 'Imagem embutida'; + String get save => 'Salvar'; @override - String get table => 'Tabela'; + String get search => 'Pesquisar'; @override - String get htmlBlock => 'Bloco HTML'; + String get selectAll => 'Selecionar tudo'; @override - String get htmlContentDefault => 'Conteúdo HTML'; + String get settings => 'Configurações'; @override - String get shortcutHtmlBlockDescription => 'Inserir ou editar um bloco HTML'; + String get source => 'Código-fonte'; @override - String get renderedHtml => 'HTML renderizado'; + String get split => 'Dividido'; @override - String get editHtml => 'Editar HTML'; + String get systemTheme => 'Sistema'; @override - String get htmlSource => 'Código-fonte HTML'; + String get theme => 'Tema'; @override - String get thematicBreak => 'Separador temático'; + String get appLanguage => 'Idioma'; @override - String get bold => 'Negrito'; + String get systemLanguage => 'Sistema'; @override - String get italic => 'Itálico'; + String get languageEnglish => 'English'; @override - String get underline => 'Sublinhado'; + String get languageGerman => 'Deutsch'; @override - String get strikethrough => 'Tachado'; + String get languageDutch => 'Nederlands'; @override - String get inlineCode => 'Código embutido'; + String get languageTurkish => 'Türkçe'; @override - String get link => 'Link'; + String get languageItalian => 'Italiano'; @override - String get hardLineBreak => 'Quebra de linha forçada'; + String get languageNorwegian => 'Norsk bokmål'; @override - String get insertBlankLine => 'Inserir linha em branco'; + String get languageFrench => 'Français'; @override - String get textStyle => 'Estilo de texto'; + String get languageRussian => 'Русский'; @override - String get paragraph => 'Parágrafo'; + String get languageUkrainian => 'Українська'; @override - String get heading1 => 'Título 1'; + String get languagePolish => 'Polski'; @override - String get heading2 => 'Título 2'; + String get languageSpanish => 'Español'; @override - String get heading3 => 'Título 3'; + String get languagePortuguese => 'Português'; @override - String get heading4 => 'Título 4'; + String get languageArabic => 'العربية'; @override - String get heading5 => 'Título 5'; + String get languagePersian => 'فارسی'; @override - String get heading6 => 'Título 6'; + String get languageHindi => 'हिन्दी'; @override - String headingLevelAbbreviation(int level) { - return 'H$level'; - } + String get languageIndonesian => 'Bahasa Indonesia'; @override - String get deleteTable => 'Excluir tabela'; + String get languageEstonian => 'Eesti'; @override - String tableColumnNumber(int columnNumber) { - return 'Coluna $columnNumber'; - } + String get languageVietnamese => 'Tiếng Việt'; @override - String get insertColumnLeft => 'Inserir coluna à esquerda'; + String get languageJapanese => '日本語'; @override - String get insertColumnRight => 'Inserir coluna à direita'; + String get languageKorean => '한국어'; @override - String get deleteColumn => 'Excluir coluna'; + String get languageSimplifiedChinese => '简体中文'; @override - String get tableAlignmentUnspecified => 'Alinhamento: não especificado'; + String get toggleSidebar => 'Painel lateral'; @override - String get tableAlignmentLeft => 'Alinhamento: esquerda'; + String get topLeft => 'Canto superior esquerdo'; @override - String get tableAlignmentCenter => 'Alinhamento: centro'; + String get topRight => 'Canto superior direito'; @override - String get tableAlignmentRight => 'Alinhamento: direita'; + String get undo => 'Desfazer'; @override - String tableRowNumber(int rowNumber) { - return 'Linha $rowNumber'; - } + String get validate => 'Validar'; @override - String get insertRowAbove => 'Inserir linha acima'; + String get validation => 'Validação'; @override - String get insertRowBelow => 'Inserir linha abaixo'; + String get viewMode => 'Modo de exibição'; @override - String get deleteRow => 'Excluir linha'; + String get welcome => 'Bem-vindo'; @override - String get tableHeaderHint => 'Cabeçalho'; + String get writerside => 'Writerside'; @override - String get tableCellHint => 'Célula'; + String get xml => 'XML'; @override - String get language => 'Linguagem'; + String get fileTypeMarkdown => 'Markdown'; @override - String get hideEditingButtons => 'Ocultar botões de edição'; + String get fileTypeImages => 'Imagens'; @override - String get showEditingButtons => 'Mostrar botões de edição'; + String get openMarkdownFile => 'Abrir arquivo Markdown'; @override - String get altText => 'Texto alternativo'; + String get markdownFileExtensions => '.md ou .markdown'; @override - String get editorPlaceholderText => 'texto'; + String get openFolderOrWritersideProject => + 'Abrir pasta ou projeto Writerside'; @override - String get editorPlaceholderCode => 'código'; + String get markdownFolderOrWritersideProject => + 'Pasta Markdown ou projeto compatível com Writerside'; @override - String get editorPlaceholderAltText => 'texto alternativo'; + String get noOpenFile => 'Nenhum arquivo aberto'; @override - String get describeTheImage => 'Descreva a imagem'; + String get shortcutDeleteTreeItemDescription => + 'Excluir o item selecionado em Arquivos ou remover o tópico selecionado do sumário'; @override - String get columns => 'Colunas'; + String get shortcutGroupGeneral => 'Geral'; @override - String get rows => 'Linhas'; + String get shortcutNewDocument => 'Criar'; @override - String tableHeaderNumber(int columnNumber) { - return 'Cabeçalho $columnNumber'; - } + String get shortcutNewDocumentDescription => + 'Criar arquivo Markdown ou projeto Writerside'; @override - String get tableCellDefault => 'Célula'; + String get shortcutOpenDescription => + 'Abrir um arquivo Markdown, uma pasta ou um projeto Writerside'; @override - String get noImageSource => 'Nenhuma origem de imagem'; + String get shortcutSaveDescription => 'Salvar o documento atual'; @override - String get remoteImageBlocked => 'Imagem remota bloqueada'; + String get shortcutSearchDescription => + 'Pesquisar no espaço de trabalho atual'; @override - String get remoteImageBlockedTooltip => - 'Escolha se o BusyMark pode carregar imagens remotas.'; + String get shortcutKeyboardShortcutsDescription => + 'Mostrar esta referência de atalhos de teclado'; @override - String get remoteImagesBlockedTitle => 'As imagens remotas estão bloqueadas'; + String get shortcutSyntaxReferenceDescription => + 'Abrir a referência de sintaxe'; @override - String get remoteImagesBlockedMessage => - 'Este documento faz referência a imagens da Internet. Carregá-las pode revelar informações de rede ao servidor que as hospeda.'; + String get shortcutSettingsDescription => + 'Abrir as configurações do BusyMark'; @override - String get loadRemoteImagesForWorkspace => - 'Carregar neste espaço de trabalho'; + String get shortcutNextTab => 'Próxima guia'; @override - String get alwaysLoadRemoteImages => 'Sempre carregar imagens remotas'; + String get shortcutNextTabDescription => 'Ir para a próxima guia aberta'; @override - String get hideSidebar => 'Ocultar painel lateral'; + String get shortcutPreviousTab => 'Guia anterior'; @override - String get showSidebar => 'Mostrar painel lateral'; + String get shortcutPreviousTabDescription => 'Ir para a guia aberta anterior'; @override - String get showPreview => 'Mostrar pré-visualização'; + String get shortcutCloseTab => 'Fechar guia'; @override - String get hidePreview => 'Ocultar pré-visualização'; + String get shortcutCloseTabDescription => 'Fechar a guia ativa'; @override - String get workspaceKindUnsavedMarkdown => 'Arquivo Markdown não salvo'; + String get shortcutCloseAllTabs => 'Fechar todas as guias'; @override - String get workspaceKindSingleMarkdown => 'Arquivo Markdown único'; + String get shortcutCloseAllTabsDescription => 'Fechar todas as guias abertas'; @override - String get workspaceKindMarkdownFolder => 'Pasta Markdown'; + String get shortcutGroupTextEditing => 'Edição de texto'; @override - String get workspaceKindWritersideModule => 'Módulo Writerside'; + String get shortcutSelectAllDescription => + 'No modo Código-fonte, selecionar todo o texto; no modo Editor, pressionar duas vezes para selecionar todos os blocos'; @override - String get problems => 'Problemas'; + String get shortcutCutDescription => 'Recortar o texto selecionado'; @override - String diagnosticCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count diagnósticos', - one: '1 diagnóstico', - zero: 'Nenhum diagnóstico', - ); - return '$_temp0'; - } + String get shortcutCopyDescription => 'Copiar o texto selecionado'; @override - String get files => 'Arquivos'; + String get shortcutPasteDescription => 'Colar da área de transferência'; @override - String get toc => 'Índice'; + String get shortcutPastePlainTextDescription => + 'Colar o texto da área de transferência sem formatação'; @override - String get tocActions => 'Ações do sumário'; + String get shortcutUndoDescription => 'Desfazer a última edição'; @override - String get markdownUnsaved => 'Markdown - não salvo'; + String get shortcutRedoDescription => 'Refazer a última edição desfeita'; @override - String workspaceDetail(String kind, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count arquivos', - one: '1 arquivo', - ); - return '$kind - $_temp0'; - } + String get shortcutInsertIndentation => 'Inserir recuo'; @override - String get noFiles => 'Nenhum arquivo'; + String get shortcutInsertIndentationDescription => + 'Inserir recuo na posição do cursor'; @override - String get newFile => 'Novo arquivo'; + String get shortcutOutdentSource => 'Diminuir recuo do código-fonte'; @override - String get noWritersideToc => 'Nenhum TOC do Writerside'; + String get shortcutOutdentSourceDescription => + 'Remover um nível de recuo no modo Código-fonte'; @override - String get tocSection => 'Seção do TOC'; + String get shortcutEscape => + 'Fechar a pesquisa ou limpar a seleção de blocos'; @override - String get newTopic => 'Novo tópico'; + String get shortcutEscapeDescription => + 'Fechar a pesquisa do espaço de trabalho ou limpar uma seleção de blocos no modo Editor'; @override - String get newChildTopic => 'Novo subtópico'; + String get shortcutGroupFormatting => 'Formatação'; @override - String get newSiblingTopic => 'Novo tópico'; + String get shortcutBoldDescription => 'Alternar negrito no texto selecionado'; @override - String get renameTopicFile => 'Renomear arquivo do tópico'; + String get shortcutItalicDescription => + 'Alternar itálico no texto selecionado'; @override - String get topicPlacement => 'Posição no TOC'; + String get shortcutUnderlineDescription => + 'Alternar sublinhado no texto selecionado'; @override - String get tocRoot => 'Na raiz do TOC'; + String get shortcutLinkDescription => 'Inserir ou editar um link'; @override - String get afterSelectedTopic => 'Após o tópico selecionado'; + String get shortcutInlineCodeDescription => + 'Alternar código embutido no texto selecionado'; @override - String get insideSelectedTopic => 'Dentro do tópico selecionado'; + String get shortcutStrikethroughDescription => + 'Alternar tachado no texto selecionado'; @override - String get pasteAfterTopic => 'Colar depois'; + String get shortcutGroupBlocks => 'Blocos'; @override - String get pasteAsChildTopic => 'Colar como subtópico'; + String get shortcutParagraphDescription => + 'Definir o bloco atual como parágrafo'; @override - String get removeFromToc => 'Remover do TOC'; + String get shortcutHeading1Description => + 'Definir o bloco atual como Título 1'; @override - String get confirmRemoveFromTocTitle => 'Remover do TOC?'; + String get shortcutHeading2Description => + 'Definir o bloco atual como Título 2'; @override - String confirmRemoveFromTocMessage(String name) { - return 'Remover $name deste TOC? O arquivo do tópico será mantido.'; - } + String get shortcutHeading3Description => + 'Definir o bloco atual como Título 3'; @override - String get confirmDeleteTopicTitle => 'Excluir o arquivo do tópico?'; + String get shortcutHeading4Description => + 'Definir o bloco atual como Título 4'; @override - String confirmDeleteTopicMessage(String name) { - return 'Excluir $name e removê-lo de todos os TOCs? Isso não pode ser desfeito.'; - } + String get shortcutHeading5Description => + 'Definir o bloco atual como Título 5'; @override - String get safeDeleteTopicFile => - 'Excluir o arquivo do tópico com segurança…'; + String get shortcutHeading6Description => + 'Definir o bloco atual como Título 6'; @override - String get removeTocElement => 'Remover elemento do TOC'; + String get shortcutGroupLists => 'Listas'; @override - String get removeTocElements => 'Remover elementos do TOC'; + String get numberedList => 'Lista numerada'; @override - String get reviewUsages => 'Revisar usos'; + String get shortcutNumberedListDescription => + 'Alternar formatação de lista numerada'; @override - String get deleteTopicFile => 'Excluir arquivo do tópico'; + String get bulletedList => 'Lista com marcadores'; @override - String get removeAction => 'Remover'; + String get shortcutBulletedListDescription => + 'Alternar formatação de lista com marcadores'; @override - String topicRemovalSummary(String topic) { - return 'Remover o elemento “$topic” do índice? O arquivo-fonte associado ao índice não será excluído.'; - } + String get checklist => 'Lista de verificação'; @override - String safeDeleteTopicSummary(String topic) { - return 'Exclua “$topic” e atualize com segurança as referências a ele em todo este projeto Writerside.'; - } + String get shortcutChecklistDescription => + 'Alternar formatação de lista de verificação'; @override - String childTopicsPromoted(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Os $count tópicos filhos subirão um nível.', - one: 'O tópico filho subirá um nível.', - ); - return '$_temp0'; - } + String get shortcutGroupSidebar => 'Barra lateral'; @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.'; + String get sidebarViewMenu => 'Visualização da barra lateral'; @override - String get newHomePage => 'Nova página inicial'; + String get createMarkdownFile => 'Criar arquivo Markdown'; @override - String get chooseHomePage => 'Escolher página inicial'; + String get createMarkdownFileDescription => + 'Iniciar um documento Markdown local não salvo'; @override - String get noHomePageReplacement => - 'Adicione ou vincule outro tópico elegível a esta instância antes de remover sua página inicial.'; + String get createWritersideProject => 'Criar projeto Writerside'; @override - String get topicIsStartPageRemovalWarning => - 'Este tópico é a página inicial de uma instância. Defina outra página inicial antes de excluir seu arquivo de origem.'; + String get createWritersideProjectDescription => + 'Iniciar um projeto local compatível com Writerside'; @override - String topicUsagesCount(int count) { - return '$count usos encontrados.'; - } + String get defaultProjectName => 'Documentação'; @override - String get noBreakingTopicUsages => - 'Não foram encontradas referências que deixariam de funcionar.'; + String get defaultInstanceName => 'Guia do usuário'; @override - String get topicUsagesFound => - 'O BusyMark encontrou as seguintes referências a este tópico.'; + String get defaultStartTopicTitle => 'Primeiros passos'; @override - String get topicUsageTocElements => 'Elementos do TOC'; + String get projectName => 'Nome do projeto'; @override - String get topicUsageStartPages => 'Páginas iniciais'; + String get directoryName => 'Nome do diretório'; @override - String get topicUsageTopicLinks => 'Links para tópicos'; + String get instanceName => 'Nome da instância'; @override - String get topicUsageIncludes => 'Inclusões'; + String get instanceId => 'ID da instância'; @override - String usageCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count usos', - one: '1 uso', - ); - return '$_temp0'; - } + String get startTopicTitle => 'Título do tópico inicial'; @override - String get refactoringOptions => 'Opções de refatoração'; + String get location => 'Local'; @override - String get updateUsagesAutomatically => 'Atualizar usos automaticamente'; + String get projectNameRequired => 'O nome do projeto é obrigatório.'; @override - String get updateUsagesAutomaticallyDescription => - 'Remova referências dos TOCs e inclusões e preserve o texto dos links.'; + String get directoryNameRequired => 'O nome do diretório é obrigatório.'; @override - String get manualUsageUpdatesRequired => - 'Alguns usos exigem alterações manuais antes desta refatoração.'; + String get useSingleSafeDirectoryName => + 'Use um único nome de diretório seguro.'; @override - String get setRedirectTo => 'Redirecionar para:'; + String get useLowercaseIdentifier => + 'Use um identificador em minúsculas com letras, números, sublinhados ou hifens.'; @override - String get noRedirectDescription => - 'Não redirecionar a página publicada antiga.'; + String get startTopicTitleRequired => + 'O título do tópico inicial é obrigatório.'; @override - String get redirectTarget => 'Destino do redirecionamento'; + String get createWritersideProjectFailed => + 'Não foi possível criar o projeto Writerside.'; @override - String get remainingUsagesBlockRemoval => - 'Revise e atualize os usos restantes antes de continuar ou ative as atualizações automáticas quando estiverem disponíveis.'; + String get settingsTitle => 'Configurações do BusyMark'; @override - String usagesOfTopic(String topic) { - return 'Usos de $topic'; - } + String get autoSave => 'Salvamento automático'; @override - String get noUsagesFound => 'Nenhum uso encontrado.'; + String get autoSaveDescription => + 'Salva automaticamente as alterações do arquivo após um curto período de inatividade.'; @override - String get outsideSelectedInstance => 'Fora da instância selecionada'; + String get wordWrap => 'Quebra de linha'; @override - String get doRefactor => 'Refatorar'; + String get wordWrapDescription => + 'Quebre linhas longas para ajustá-las à largura do editor em vez de rolar horizontalmente.'; @override - String get preview => 'Prévia'; + String get editorFontSize => 'Tamanho da fonte do editor'; @override - String get topicRenamePreviewTitle => 'Prévia da renomeação'; + String get validateOnEdit => 'Validar ao editar'; @override - String topicRenamePathChange(String oldPath, String newPath) { - return 'Caminho: $oldPath → $newPath'; - } + String get clearRecentWorkspaces => 'Limpar espaços de trabalho recentes'; @override - String topicRenameIdChange(String oldId, String newId) { - return 'ID do tópico: $oldId → $newId'; - } + String get editingButtonsPosition => 'Posição dos botões de edição'; @override - String topicRenameUrlChange(String instance, String oldName, String newName) { - return 'Instância $instance: $oldName → $newName'; - } + String get editingButtonsPositionDescription => + 'Escolha onde os botões flutuantes de edição WYSIWYG aparecem.'; @override - String get topicRenameAffectedFiles => 'Arquivos afetados'; + String get editingButtonsDirection => 'Orientação dos botões de edição'; @override - String get orphanTopicTitle => 'O arquivo do tópico não é mais usado'; + String get editingButtonsDirectionDescription => + 'Escolha se os botões flutuantes de edição WYSIWYG ficam dispostos horizontalmente ou verticalmente.'; @override - String get keepTopicFile => 'Manter o arquivo do tópico'; + String get horizontal => 'Horizontal'; @override - String orphanTopicMessage(String topic) { - return '“$topic” não é mais usado em nenhum lugar deste projeto Writerside. Exclua o arquivo ou mantenha-o para uso em outra instância.'; - } + String get vertical => 'Vertical'; @override - String get defaultNewTopicTitle => 'Novo tópico'; + String get privacy => 'Privacidade'; @override - String get topicTitle => 'Título do tópico'; + String get allowRemoteImages => 'Carregar imagens remotas'; @override - String get fileName => 'Nome do arquivo'; + String get allowRemoteImagesDescription => + 'Permitir que a pré-visualização do Markdown e o editor carreguem imagens de URLs HTTP e HTTPS.'; @override - String get topicTitleRequired => 'O título do tópico é obrigatório.'; + String get clearRemoteImagePermissions => + 'Limpar permissões de imagens remotas'; @override - String get fileNameRequired => 'O nome do arquivo é obrigatório.'; + String get clearRemoteImagePermissionsDescription => + 'Esquecer os espaços de trabalho autorizados a carregar imagens remotas.'; @override - String get rename => 'Renomear'; + String get clearGitWorkspaceTrust => + 'Limpar espaços de trabalho confiáveis para o Git'; @override - String get confirmDeleteFileTitle => 'Excluir arquivo?'; + String get clearGitWorkspaceTrustDescription => + 'Perguntar antes de ativar recursos do Git em espaços de trabalho considerados confiáveis anteriormente.'; @override - String get confirmDeleteFolderTitle => 'Excluir pasta?'; + String get settingsEditingButtonsSectionTitle => 'Botões de edição'; @override - String confirmDeleteFileMessage(String name) { - return 'Excluir $name? Isso não pode ser desfeito.'; - } + String get spellingDictionaries => 'Dicionários ortográficos'; @override - String confirmDeleteFolderMessage(String name) { - return 'Excluir $name e todos os arquivos dentro dela? Isso não pode ser desfeito.'; - } + String get spellingDictionariesDescription => + 'Instalar e gerenciar dicionários ortográficos'; @override - String get useSingleSafeFileName => 'Use um único nome de arquivo seguro.'; + String get settingsDictionariesSectionTitle => 'Dicionários'; @override - String useExpectedExtension(String extension) { - return 'Use a extensão $extension para o formato selecionado.'; - } + String get availableSpellingDictionaries => 'Dicionários disponíveis'; @override - String get useIdentifierCharacters => - 'Use letras, números, sublinhados ou hifens antes da extensão.'; + String get customSpellingDictionaries => 'Dicionários personalizados'; @override - String get topicIdAlreadyExists => 'O ID do tópico já existe.'; + String get spellingDictionaryProblems => 'Problemas de dicionários'; @override - String get createWritersideTopicFailed => - 'Não foi possível criar o tópico do Writerside.'; + String get settingsWindowSectionTitle => 'Janela'; @override - String get noOutline => 'Sem estrutura'; + String get settingsReopenWorkspaceOnStartupTitle => + 'Reabrir o espaço de trabalho anterior ao iniciar'; @override - String expandKind(String kind) { - return 'Expandir $kind'; - } + String get settingsReopenWorkspaceOnStartupDescription => + 'Abra o espaço de trabalho e as abas da sessão anterior quando o BusyMark iniciar.'; @override - String collapseKind(String kind) { - return 'Recolher $kind'; - } + String get settingsConfirmCloseWithUnsavedChangesTitle => + 'Confirmar antes de fechar com alterações não salvas'; @override - String get foldKindSection => 'seção'; + String get settingsConfirmCloseWithUnsavedChangesDescription => + 'Perguntar antes de fechar o BusyMark quando documentos tiverem alterações não salvas.'; @override - String get foldKindList => 'lista'; + String get closeUnsavedChangesTitle => 'Alterações não salvas'; @override - String get foldKindQuote => 'citação'; + String get closeUnsavedChangesSingleMessage => + 'Este documento tem alterações não salvas. Salvar as alterações antes de fechar o BusyMark?'; @override - String get foldKindTag => 'tag'; + String closeUnsavedChangesMultipleMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count documentos têm alterações não salvas. Salvar as alterações antes de fechar o BusyMark?', + one: + '1 documento tem alterações não salvas. Salvar as alterações antes de fechar o BusyMark?', + zero: 'Salvar as alterações antes de fechar o BusyMark?', + ); + return '$_temp0'; + } @override - String get sourceSearchPreviousMatch => 'Correspondência anterior'; + String get closeUnsavedChangesCancel => 'Cancelar'; @override - String get sourceSearchNextMatch => 'Próxima correspondência'; + String get closeUnsavedChangesDiscard => 'Descartar'; @override - String get sourceSearchCaseSensitive => - 'Diferenciar maiúsculas de minúsculas'; + String get closeUnsavedChangesSave => 'Salvar'; @override - String get sourceSearchWholeWord => 'Palavra inteira'; + String get currentFile => 'arquivo atual'; @override - String get sourceSearchRegex => 'Expressão regular'; + String get unsavedChanges => 'Alterações não salvas'; @override - String get sourceSearchReplacement => 'Substituir por'; + String unsavedChangesMessage(String fileName) { + return 'Você tem alterações não salvas em $fileName. Salvá-las antes de continuar?'; + } @override - String get sourceSearchReplaceCurrent => 'Substituir correspondência atual'; + String unsavedChangesMultipleMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count documentos com alterações não salvas. Deseja salvá-los antes de continuar?', + one: + 'Há 1 documento com alterações não salvas. Deseja salvá-lo antes de continuar?', + ); + return '$_temp0'; + } @override - String get sourceSearchReplaceAndFindNext => 'Substituir e localizar próximo'; + String get fileChangedOnDisk => 'Arquivo alterado no disco'; @override - String get sourceSearchReplaceAll => 'Substituir tudo'; + String get fileChangedOnDiskMessage => + 'Este arquivo foi alterado no disco desde que foi aberto. Sobrescrever?'; @override - String get workspaceReplace => 'Substituir no espaço de trabalho'; + String get untitledMarkdownFileName => 'Sem título.md'; @override - String get reviewReplacements => 'Revisar substituições'; + String get unorderedList => 'Lista não ordenada'; @override - String get applyReplacements => 'Aplicar substituições'; + String get orderedList => 'Lista ordenada'; @override - String get skippedFiles => 'Arquivos ignorados'; + String get taskList => 'Lista de tarefas'; @override - String get workspaceReplaceDirtyBuffer => 'Conteúdo não salvo do editor'; + String get toggleTaskChecked => 'Marcar/desmarcar tarefa'; @override - String get workspaceReplaceDiskContent => 'Conteúdo salvo no disco'; + String get indentListItem => 'Recuar item da lista'; @override - String selectFileMatches(int count) { - return 'Selecionar todas as $count correspondências'; - } + String get outdentListItem => 'Remover recuo do item da lista'; @override - String workspaceReplaceApplied(int matches, int files, int skipped) { - return 'Foram substituídas $matches correspondências em $files arquivos; $skipped ignoradas.'; - } + String get blockquote => 'Citação em bloco'; @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Quebra de linha final'; - } + String get codeBlock => 'Bloco de código'; @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Sem quebra de linha final'; - } + String get codeBlockLanguage => 'Linguagem do bloco de código'; @override - String get normalizeLineEndings => 'Normalizar finais de linha'; + String get image => 'Imagem'; @override - String get mixedLineEndingsSavePrompt => - 'Este documento contém finais de linha mistos. Escolha um formato.'; - - @override - String workspaceReplaceMixedLineEndings(String fileName) { - return '$fileName usa finais de linha mistos. Escolha o formato antes de substituir.'; - } + String get video => 'Vídeo'; @override - String get workspaceReplaceIssueOversized => - 'Um arquivo grande demais foi ignorado.'; + String get openVideo => 'Reproduzir vídeo'; @override - String get workspaceReplaceIssueUnreadable => - 'Um arquivo que não pôde ser lido foi ignorado.'; + String get pauseVideo => 'Pausar vídeo'; @override - String get workspaceReplaceIssueInvalidUtf8 => - 'Um arquivo que não é UTF-8 válido foi ignorado.'; + String get videoUnavailable => 'Vídeo indisponível'; @override - String get workspaceReplaceIssueTruncated => - 'A prévia de substituições foi truncada.'; + String get videoPreview => 'Pré-visualização do vídeo'; @override - String get workspaceReplaceIssueFileChanged => - 'Um arquivo alterado após a prévia foi ignorado.'; + String get diagnosticWritersideVideoMissingSource => + 'O vídeo não tem o atributo src.'; @override - String get workspaceReplaceIssueBufferChanged => - 'Um buffer do editor alterado após a prévia foi ignorado.'; + String diagnosticWritersideVideoUnsupportedSource(String source) { + return 'Fonte de vídeo não suportada: $source'; + } @override - String get workspaceReplaceIssueNormalizationRequired => - 'Escolha a normalização LF ou CRLF antes de substituir.'; + String diagnosticWritersideVideoMissingFile(String source) { + return 'O arquivo de vídeo não existe: $source'; + } @override - String get workspaceReplaceIssuePartialConflict => - 'A reversão foi interrompida porque o arquivo foi alterado simultaneamente. Algumas substituições podem permanecer; o conteúdo deslocado foi preservado no caminho abaixo.'; + String diagnosticWritersideVideoMissingPreview(String preview) { + return 'A imagem de pré-visualização do vídeo não existe: $preview'; + } @override - String get workspaceReplaceIssueApplyFailed => - 'Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.'; + String get inlineImage => 'Imagem embutida'; @override - String externalChangesTitle(String fileName) { - return 'Alterações externas — $fileName'; - } + String get table => 'Tabela'; @override - String get externalFileDeleted => 'Este arquivo foi excluído do disco.'; + String get htmlBlock => 'Bloco HTML'; @override - String get externalFileChanged => - 'Este arquivo mudou no disco enquanto você tem alterações não salvas.'; + String get htmlContentDefault => 'Conteúdo HTML'; @override - String recoveredDocumentReview(String fileName) { - return 'O conteúdo não salvo de $fileName foi recuperado. Revise-o e depois salve, salve como ou descarte-o.'; - } + String get shortcutHtmlBlockDescription => 'Inserir ou editar um bloco HTML'; @override - String get compare => 'Comparar'; + String get renderedHtml => 'HTML renderizado'; @override - String get reloadFromDisk => 'Recarregar do disco'; + String get editHtml => 'Editar HTML'; @override - String get keepMine => 'Manter minha versão'; + String get htmlSource => 'Código-fonte HTML'; @override - String get saveAs => 'Salvar como'; + String get thematicBreak => 'Separador temático'; @override - String get sourceSearchInvalidRegex => 'Expressão regular inválida'; + String get bold => 'Negrito'; @override - String get sourceSearchZeroLengthUnsupported => - 'Não há suporte para correspondências regex de comprimento zero (por exemplo, ^, \$ ou antecipações que correspondem apenas a uma posição).'; + String get italic => 'Itálico'; @override - String get workspaceSearchIncomplete => - 'Apenas alguns resultados são exibidos. Mostre mais ou restrinja a pesquisa.'; + String get underline => 'Sublinhado'; @override - String get workspaceSearchShowMore => 'Mostrar mais resultados'; + String get strikethrough => 'Tachado'; @override - String get workspaceSearchSkippedFiles => - 'A pesquisa está incompleta. Estes arquivos excedem 1 MiB ou não puderam ser lidos:'; + String get inlineCode => 'Código embutido'; @override - String get sourceLargeFileFeaturesPaused => - 'Arquivo grande: o realce e o recolhimento estão pausados'; + String get link => 'Link'; @override - String get nothingToRead => 'Nenhum conteúdo para ler'; + String get hardLineBreak => 'Quebra de linha forçada'; @override - String get admonition => 'Bloco de destaque'; + String get insertBlankLine => 'Inserir linha em branco'; @override - String get quote => 'Citação'; + String get textStyle => 'Estilo de texto'; @override - String get note => 'Observação'; + String get paragraph => 'Parágrafo'; @override - String get tip => 'Dica'; + String get heading1 => 'Título 1'; @override - String get warning => 'Aviso'; + String get heading2 => 'Título 2'; @override - String get tabs => 'Guias'; + String get heading3 => 'Título 3'; @override - String get tab => 'Guia'; + String get heading4 => 'Título 4'; @override - String get procedure => 'Procedimento'; + String get heading5 => 'Título 5'; @override - String get step => 'Etapa'; + String get heading6 => 'Título 6'; @override - String get topic => 'Tópico'; + String headingLevelAbbreviation(int level) { + return 'H$level'; + } @override - String get chapter => 'Capítulo'; + String get deleteTable => 'Excluir tabela'; @override - String couldNotOpenTarget(String target) { - return 'Não foi possível abrir $target'; + String tableColumnNumber(int columnNumber) { + return 'Coluna $columnNumber'; } @override - String linkTargetNotFound(String targetPath) { - return 'Destino do link não encontrado: $targetPath'; - } + String get insertColumnLeft => 'Inserir coluna à esquerda'; @override - String get cannotOpenFileTypeInEditor => - 'Não é possível abrir este tipo de arquivo no editor'; + String get insertColumnRight => 'Inserir coluna à direita'; @override - String anchorNotFound(String anchor) { - return 'Âncora não encontrada: $anchor'; - } + String get deleteColumn => 'Excluir coluna'; @override - String get noProblemsFound => 'Nenhum problema encontrado'; + String get tableAlignmentUnspecified => 'Alinhamento: não especificado'; @override - String get noResults => 'Nenhum resultado'; + String get tableAlignmentLeft => 'Alinhamento: esquerda'; @override - String searchResultLine(String relativePath, int lineNumber) { - return '$relativePath - linha $lineNumber'; - } + String get tableAlignmentCenter => 'Alinhamento: centro'; @override - String get untitledResult => 'Resultado sem título'; + String get tableAlignmentRight => 'Alinhamento: direita'; @override - String get documentKindMarkdownFile => 'Arquivo Markdown'; + String tableRowNumber(int rowNumber) { + return 'Linha $rowNumber'; + } @override - String get documentKindWritersideMarkdownTopic => - 'Tópico Markdown do Writerside'; + String get insertRowAbove => 'Inserir linha acima'; @override - String get documentKindWritersideXmlTopic => 'Tópico XML do Writerside'; + String get insertRowBelow => 'Inserir linha abaixo'; @override - String get documentKindWritersideTree => 'Árvore do Writerside'; + String get deleteRow => 'Excluir linha'; @override - String get documentKindConfigurationFile => 'Arquivo de configuração'; + String get tableHeaderHint => 'Cabeçalho'; @override - String get documentKindVariablesFile => 'Arquivo de variáveis'; + String get tableCellHint => 'Célula'; @override - String get documentKindCategoriesFile => 'Arquivo de categorias'; + String get language => 'Linguagem'; @override - String get documentKindResourceFile => 'Arquivo de recursos'; + String get hideEditingButtons => 'Ocultar botões de edição'; @override - String workspaceErrorOpenFailed(String error) { - return 'Falha ao abrir: $error'; - } + String get showEditingButtons => 'Mostrar botões de edição'; @override - String workspaceErrorCreateWritersideProjectFailed(String error) { - return 'Não foi possível criar o projeto Writerside: $error'; - } + String get altText => 'Texto alternativo'; @override - String workspaceErrorCreateWritersideTopicFailed(String error) { - return 'Não foi possível criar o tópico do Writerside: $error'; - } + String get editorPlaceholderText => 'texto'; @override - String workspaceErrorCouldNotOpenFile(String error) { - return 'Não foi possível abrir o arquivo: $error'; - } + String get editorPlaceholderCode => 'código'; @override - String get workspaceErrorChooseWhereToSaveMarkdown => - 'Escolha onde salvar este arquivo Markdown.'; + String get editorPlaceholderAltText => 'texto alternativo'; @override - String get workspaceErrorSaveBlockedFileChangedOnDisk => - 'Salvamento bloqueado: o arquivo foi alterado no disco.'; + String get describeTheImage => 'Descreva a imagem'; @override - String workspaceErrorSaveFailed(String error) { - return 'Falha ao salvar: $error'; - } + String get columns => 'Colunas'; @override - String workspaceErrorFileOperationFailed(String error) { - return 'Falha na operação de arquivo: $error'; - } + String get rows => 'Linhas'; @override - String workspaceErrorValidationFailed(String error) { - return 'Falha na validação: $error'; + String tableHeaderNumber(int columnNumber) { + return 'Cabeçalho $columnNumber'; } @override - String workspaceRecoveryRestored(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: - '$count documentos não salvos foram recuperados. Revise cada um antes de salvar ou descartar.', - one: - '1 documento não salvo foi recuperado. Revise-o antes de salvar ou descartar.', - ); - return '$_temp0'; - } + String get tableCellDefault => 'Célula'; @override - String workspaceRecoveryDamaged(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: - '$count registros de recuperação danificados não puderam ser restaurados. Os registros de recuperação válidos permanecem disponíveis.', - one: - '1 registro de recuperação danificado não pôde ser restaurado. O arquivo de recuperação original foi preservado para inspeção.', - ); - return '$_temp0'; - } + String get noImageSource => 'Nenhuma origem de imagem'; @override - String errorPathDoesNotExist(String path) { - return 'O caminho não existe: $path'; - } + String get remoteImageBlocked => 'Imagem remota bloqueada'; @override - String errorTargetDirectoryNotEmpty(String path) { - return 'O diretório de destino já existe e não está vazio: $path'; - } + String get remoteImageBlockedTooltip => + 'Escolha se o BusyMark pode carregar imagens remotas.'; @override - String errorTargetPathNotDirectory(String path) { - return 'O caminho de destino já existe e não é um diretório: $path'; - } + String get remoteImagesBlockedTitle => 'As imagens remotas estão bloqueadas'; @override - String errorGeneratedFileAlreadyExists(String path) { - return 'O arquivo gerado já existe: $path'; - } + String get remoteImagesBlockedMessage => + 'Este documento faz referência a imagens da Internet. Carregá-las pode revelar informações de rede ao servidor que as hospeda.'; @override - String get errorParentDirectoryRequired => 'O diretório pai é obrigatório.'; + String get loadRemoteImagesForWorkspace => + 'Carregar neste espaço de trabalho'; @override - String errorParentDirectoryMissing(String path) { - return 'O diretório pai não existe: $path'; - } + String get alwaysLoadRemoteImages => 'Sempre carregar imagens remotas'; @override - String errorDirectoryMissing(String path) { - return 'O diretório não existe: $path'; - } + String get hideSidebar => 'Ocultar painel lateral'; @override - String errorPathAlreadyExists(String path) { - return 'O caminho já existe: $path'; - } + String get showSidebar => 'Mostrar painel lateral'; @override - String get errorFileNameRequired => 'O nome do arquivo é obrigatório.'; + String get showPreview => 'Mostrar pré-visualização'; @override - String get errorFileNameUnsafe => - 'O nome do arquivo deve ser um único segmento de caminho seguro.'; + String get hidePreview => 'Ocultar pré-visualização'; @override - String get errorFileOperationInvalidTarget => - 'Não é possível mover uma pasta para dentro dela mesma.'; + String get workspaceKindUnsavedMarkdown => 'Arquivo Markdown não salvo'; @override - String get errorFileOperationOutsideRoot => - 'A operação de arquivo deve permanecer dentro do espaço de trabalho.'; + String get workspaceKindSingleMarkdown => 'Arquivo Markdown único'; @override - String get errorFileOperationRoot => - 'A raiz do espaço de trabalho não pode ser alterada pela árvore de arquivos.'; + String get workspaceKindMarkdownFolder => 'Pasta Markdown'; @override - String get errorProjectNameRequired => 'O nome do projeto é obrigatório.'; + String get workspaceKindWritersideModule => 'Módulo Writerside'; @override - String get errorDirectoryNameRequired => 'O nome do diretório é obrigatório.'; + String get problems => 'Problemas'; @override - String get errorDirectoryNameUnsafe => - 'O nome do diretório deve ser um único segmento de caminho seguro.'; + String diagnosticCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count diagnósticos', + one: '1 diagnóstico', + zero: 'Nenhum diagnóstico', + ); + return '$_temp0'; + } @override - String get errorInstanceIdInvalid => - 'O ID da instância deve começar com uma letra minúscula e conter apenas letras minúsculas, números, sublinhados e hifens.'; + String get files => 'Arquivos'; @override - String get errorTopicFileInvalid => - 'O nome do arquivo do tópico deve ser um nome de arquivo Markdown sem separadores de caminho.'; + String get toc => 'Índice'; @override - String get errorTopicTitleRequired => 'O título do tópico é obrigatório.'; + String get tocActions => 'Ações do sumário'; @override - String errorWritersideModuleRootMissing(String path) { - return 'A raiz do módulo Writerside não existe: $path'; - } + String get markdownUnsaved => 'Markdown - não salvo'; @override - String get errorWritersideModuleNotOpen => - 'Um módulo Writerside deve estar aberto para criar um tópico.'; + String workspaceDetail(String kind, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count arquivos', + one: '1 arquivo', + ); + return '$kind - $_temp0'; + } @override - String get errorWritersideInstanceTreeMissing => - 'O módulo Writerside não tem uma árvore de instância.'; + String get noFiles => 'Nenhum arquivo'; @override - String errorWritersideTreeFileMissing(String path) { - return 'O arquivo de árvore do Writerside não existe: $path'; - } + String get newFile => 'Novo arquivo'; @override - String errorTopicIdAlreadyExists(String topicId) { - return 'O ID do tópico \"$topicId\" já existe neste módulo de ajuda.'; - } + String get noWritersideToc => 'Nenhum TOC do Writerside'; @override - String errorTopicFileAlreadyExists(String path) { - return 'O arquivo do tópico já existe: $path'; - } + String get tocSection => 'Seção do TOC'; @override - String errorReferenceTopicMissing(String topic) { - return 'O tópico de referência não está presente na árvore selecionada: $topic'; - } + String get newTopic => 'Novo tópico'; @override - String get errorWritersideTocNodeMissing => - 'A entrada selecionada do TOC não existe mais.'; + String get newChildTopic => 'Novo subtópico'; @override - String get errorWritersideTocInvalidMove => - 'Uma entrada do TOC não pode ser movida para dentro de si mesma nem de um de seus descendentes.'; + String get newSiblingTopic => 'Novo tópico'; @override - String errorWritersideStartTopicDelete(String topic) { - return 'Não é possível excluir a página inicial $topic. Escolha outra página inicial primeiro.'; - } + String get renameTopicFile => 'Renomear arquivo do tópico'; @override - String get errorWritersideSafeDeleteRequired => - 'Use a exclusão segura para arquivos de tópicos do Writerside.'; + String get topicPlacement => 'Posição no TOC'; @override - String get errorWritersideTopicUsageScanFailed => - 'Não foi possível concluir a verificação dos usos do tópico. Nenhum arquivo foi alterado.'; + String get tocRoot => 'Na raiz do TOC'; @override - String get errorWritersideTopicUsagesRemain => - 'Alguns usos do tópico ainda precisam de atenção. Revise-os antes de continuar.'; + String get afterSelectedTopic => 'Após o tópico selecionado'; @override - String get errorWritersideRedirectInvalid => - 'O destino de redirecionamento selecionado não é mais válido. Selecione-o novamente.'; + String get insideSelectedTopic => 'Dentro do tópico selecionado'; @override - String errorWritersideRollbackFailed(String paths) { - return 'Não foi possível reverter completamente a remoção do tópico. Revise estes caminhos antes de continuar: $paths'; - } + String get pasteAfterTopic => 'Colar depois'; @override - String errorWritersideTopicRenameIncompleteProjectIndex( - String module, - String count, - String paths, - ) { - return 'A renomeação não pode atualizar com segurança todas as referências do projeto porque $count arquivo(s) de tópico no módulo $module não foram analisados: $paths'; - } + String get pasteAsChildTopic => 'Colar como subtópico'; @override - String get errorWritersideTopicRenameProjectBuffersDirty => - 'Os arquivos do projeto Writerside têm alterações não salvas. Resolva-as e prepare uma nova visualização da renomeação.'; + String get removeFromToc => 'Remover do TOC'; @override - String get errorWritersideTopicRenameProjectInventoryChanged => - 'O conjunto de módulos do projeto Writerside mudou. Prepare uma nova visualização da renomeação antes da refatoração.'; + String get confirmRemoveFromTocTitle => 'Remover do TOC?'; @override - String get errorTopicsRootUnsafe => - 'A raiz dos tópicos deve ser um diretório relativo seguro.'; + String confirmRemoveFromTocMessage(String name) { + return 'Remover $name deste TOC? O arquivo do tópico será mantido.'; + } @override - String get errorTopicFileNameUnsafe => - 'O nome do arquivo do tópico deve ser um único segmento de caminho seguro.'; + String get confirmDeleteTopicTitle => 'Excluir o arquivo do tópico?'; @override - String errorTopicFileExtensionMismatch(String extension) { - return 'A extensão do arquivo do tópico deve corresponder ao formato selecionado ($extension).'; + String confirmDeleteTopicMessage(String name) { + return 'Excluir $name e removê-lo de todos os TOCs? Isso não pode ser desfeito.'; } @override - String get errorTopicFileNameInvalid => - 'O nome do arquivo do tópico deve conter apenas letras, números, sublinhados e hifens.'; + String get safeDeleteTopicFile => + 'Excluir o arquivo do tópico com segurança…'; @override - String errorUnknown(String code) { - return 'Erro desconhecido: $code'; - } + String get removeTocElement => 'Remover elemento do TOC'; @override - String diagnosticWorkspaceFileStatFailed(String error) { - return 'Não foi possível ler os metadados do arquivo: $error'; - } + String get removeTocElements => 'Remover elementos do TOC'; @override - String get diagnosticWorkspaceScanSkipped => - 'Espaço de trabalho grande detectado. Alguns arquivos foram ignorados para manter o aplicativo responsivo.'; + String get reviewUsages => 'Revisar usos'; @override - String diagnosticWorkspaceScanInspectFailed(String error) { - return 'Não foi possível inspecionar a entrada do espaço de trabalho: $error'; - } + String get deleteTopicFile => 'Excluir arquivo do tópico'; @override - String get diagnosticWorkspaceFileTooLarge => - 'O arquivo excede o limite beta de análise automática.'; + String get removeAction => 'Remover'; @override - String diagnosticWorkspaceFileReadFailed(String error) { - return 'Não foi possível ler o arquivo Markdown: $error'; + String topicRemovalSummary(String topic) { + return 'Remover o elemento “$topic” do índice? O arquivo-fonte associado ao índice não será excluído.'; } @override - String get diagnosticMarkdownAttributeMalformed => - 'O bloco de atributos de título do Writerside está malformado.'; + String safeDeleteTopicSummary(String topic) { + return 'Exclua “$topic” e atualize com segurança as referências a ele em todo este projeto Writerside.'; + } @override - String diagnosticMarkdownHeadingDuplicateId(String id) { - return 'ID de título duplicado \"$id\".'; + String childTopicsPromoted(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Os $count tópicos filhos subirão um nível.', + one: 'O tópico filho subirá um nível.', + ); + return '$_temp0'; } @override - String get diagnosticWritersideTopicH1ConvertedToChapter => - 'Títulos H1 adicionais no nível superior são tratados como capítulos.'; + 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 diagnosticWritersideMarkdownTopicMissingTitle => - 'O tópico Markdown do Writerside não tem H1 nem título no front matter.'; + String get newHomePage => 'Nova página inicial'; @override - String get diagnosticWritersideXmlTopicMissingTitle => - 'O tópico XML não tem título.'; + String get chooseHomePage => 'Escolher página inicial'; @override - String diagnosticWritersideTopicFileMissingTitle(String fileName) { - return 'O tópico \"$fileName\" não tem título.'; - } + 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 diagnosticMarkdownFrontMatterMalformed => - 'O front matter não foi fechado.'; + String get topicIsStartPageRemovalWarning => + '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 get diagnosticMarkdownRawHtmlUnsafe => 'Elemento HTML inseguro.'; + String topicUsagesCount(int count) { + return '$count usos encontrados.'; + } @override - String diagnosticMarkdownLinkUnresolvedTarget(String targetPath) { - return 'O destino do link não existe: $targetPath'; - } + String get noBreakingTopicUsages => + 'Não foram encontradas referências que deixariam de funcionar.'; @override - String diagnosticMarkdownLinkUnresolvedAnchor(String anchor) { - return 'A âncora \"$anchor\" não existe.'; - } + String get topicUsagesFound => + 'O BusyMark encontrou as seguintes referências a este tópico.'; @override - String diagnosticMarkdownImageMissingAlt(String destination) { - return 'A imagem \"$destination\" não tem texto alternativo.'; - } + String get topicUsageTocElements => 'Elementos do TOC'; @override - String diagnosticMarkdownImageMissingFile(String destination) { - return 'A imagem não existe: $destination'; - } + String get topicUsageStartPages => 'Páginas iniciais'; @override - String diagnosticInvalidXml(String message) { - return 'XML inválido: $message'; - } + String get topicUsageTopicLinks => 'Links para tópicos'; @override - String get diagnosticWritersideConfigInvalidRoot => - 'A raiz do arquivo writerside.cfg deve ser <ihp>.'; + String get topicUsageIncludes => 'Inclusões'; @override - String get diagnosticWritersideConfigMissingSnippetsSrc => - 'A declaração de snippets está sem o atributo src.'; + String usageCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count usos', + one: '1 uso', + ); + return '$_temp0'; + } @override - String get diagnosticWritersideConfigMissingInstanceGroupsSrc => - 'A declaração de instance-groups está sem o atributo src.'; + String get refactoringOptions => 'Opções de refatoração'; @override - String diagnosticWritersideConfigInvalidKeymapsMode(String mode) { - return 'Modo de keymaps não suportado: $mode'; - } + String get updateUsagesAutomatically => 'Atualizar usos automaticamente'; @override - String get diagnosticWritersideConfigMissingInstanceSrc => - 'A declaração de instância está sem o atributo src.'; + String get updateUsagesAutomaticallyDescription => + 'Remova referências dos TOCs e inclusões e preserve o texto dos links.'; @override - String get diagnosticWritersideConfigMissingInstance => - 'O arquivo writerside.cfg não registra uma instância.'; + String get manualUsageUpdatesRequired => + 'Alguns usos exigem alterações manuais antes desta refatoração.'; @override - String get diagnosticWritersideTreeInvalidRoot => - 'A raiz do arquivo .tree deve ser <instance-profile>.'; + String get setRedirectTo => 'Redirecionar para:'; @override - String get diagnosticWritersideTreeMissingId => - 'O perfil da instância está sem o atributo id.'; + String get noRedirectDescription => + 'Não redirecionar a página publicada antiga.'; @override - String diagnosticWritersideTreeIdMismatch(String id) { - return 'O nome-base do arquivo .tree não corresponde ao ID da instância \"$id\".'; - } + String get redirectTarget => 'Destino do redirecionamento'; @override - String get diagnosticWritersideTreeMissingStartPage => - 'A instância que não é de biblioteca não tem start-page.'; + String get remainingUsagesBlockRemoval => + 'Revise e atualize os usos restantes antes de continuar ou ative as atualizações automáticas quando estiverem disponíveis.'; @override - String diagnosticWritersideStartPageMissing(String startPage) { - return 'A página inicial \"$startPage\" não existe.'; + String usagesOfTopic(String topic) { + return 'Usos de $topic'; } @override - String diagnosticWritersideTreeDuplicateTopic(String topic) { - return 'O tópico \"$topic\" aparece mais de uma vez no TOC desta instância.'; - } + String get noUsagesFound => 'Nenhum uso encontrado.'; @override - String get diagnosticWritersideVariableMalformedDeclaration => - 'A declaração de variável deve ter nome e valor.'; + String get outsideSelectedInstance => 'Fora da instância selecionada'; @override - String diagnosticWritersideVariableDuplicateName(String name) { - return 'A variável \"$name\" é declarada mais de uma vez.'; - } + String get doRefactor => 'Refatorar'; @override - String get diagnosticWritersideCategoryMissingId => - 'A categoria está sem o atributo id.'; + String get preview => 'Prévia'; @override - String diagnosticWritersideCategoryDuplicateId(String id) { - return 'A categoria \"$id\" é declarada mais de uma vez.'; + String get topicRenamePreviewTitle => 'Prévia da renomeação'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Caminho: $oldPath → $newPath'; } @override - String diagnosticWritersideCategoryDuplicateOrder(String order) { - return 'A ordem da categoria \"$order\" foi declarada mais de uma vez.'; + String topicRenameIdChange(String oldId, String newId) { + return 'ID do tópico: $oldId → $newId'; } @override - String get diagnosticWritersideTopicInvalidRoot => - 'A raiz do arquivo .topic deve ser <topic>.'; + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instância $instance: $oldName → $newName'; + } @override - String get diagnosticWritersideTopicMissingRootId => - 'A raiz do tópico XML está sem o atributo id.'; + String get topicRenameAffectedFiles => 'Arquivos afetados'; @override - String diagnosticWritersideTopicRootIdMismatch(String id, String expectedId) { - return 'O ID raiz do tópico XML \"$id\" deve corresponder ao nome do arquivo \"$expectedId\".'; - } + String get orphanTopicTitle => 'O arquivo do tópico não é mais usado'; @override - String diagnosticWritersideTopicDuplicateElementId(String elementId) { - return 'O ID do elemento \"$elementId\" aparece mais de uma vez.'; + String get keepTopicFile => 'Manter o arquivo do tópico'; + + @override + String orphanTopicMessage(String topic) { + return '“$topic” não é mais usado em nenhum lugar deste projeto Writerside. Exclua o arquivo ou mantenha-o para uso em outra instância.'; } @override - String get diagnosticWritersideTopicAnchorMissingHref => - '<a> está sem o atributo href.'; + String get defaultNewTopicTitle => 'Novo tópico'; @override - String get diagnosticWritersideConfigMissing => - 'O modo Writerside requer o arquivo writerside.cfg.'; + String get topicTitle => 'Título do tópico'; @override - String diagnosticWritersideConfigMissingBuildConfigDirectory( - String relativePath, - ) { - return 'O diretório configurado para a compilação está ausente: $relativePath'; - } + String get fileName => 'Nome do arquivo'; @override - String diagnosticWritersideConfigMissingApiSpecificationsDirectory( - String relativePath, - ) { - return 'O diretório de especificações de API configurado está ausente: $relativePath'; - } + String get topicTitleRequired => 'O título do tópico é obrigatório.'; @override - String diagnosticWritersideConfigMissingSnippetsDirectory( - String relativePath, - ) { - return 'O diretório de snippets configurado está ausente: $relativePath'; - } + String get fileNameRequired => 'O nome do arquivo é obrigatório.'; @override - String diagnosticWritersideConfigMissingVarsFile(String relativePath) { - return 'O arquivo de variáveis configurado está ausente: $relativePath'; - } + String get rename => 'Renomear'; @override - String diagnosticWritersideConfigMissingCategoriesFile(String relativePath) { - return 'O arquivo de categorias configurado está ausente: $relativePath'; - } + String get confirmDeleteFileTitle => 'Excluir arquivo?'; @override - String diagnosticWritersideConfigMissingInstanceGroupsFile( - String relativePath, - ) { - return 'O arquivo de grupos de instâncias configurado está ausente: $relativePath'; - } + String get confirmDeleteFolderTitle => 'Excluir pasta?'; @override - String diagnosticWritersideConfigMissingInstanceTree(String source) { - return 'A árvore de instância registrada \"$source\" não existe.'; + String confirmDeleteFileMessage(String name) { + return 'Excluir $name? Isso não pode ser desfeito.'; } @override - String diagnosticWritersideTopicReadFailed(String error) { - return 'Não foi possível ler o arquivo do tópico: $error'; + String confirmDeleteFolderMessage(String name) { + return 'Excluir $name e todos os arquivos dentro dela? Isso não pode ser desfeito.'; } @override - String diagnosticWritersideDefaultTopicsDirectoryMissing( - String relativePath, - ) { - return 'O diretório de tópicos padrão está ausente: $relativePath'; + String get useSingleSafeFileName => 'Use um único nome de arquivo seguro.'; + + @override + String useExpectedExtension(String extension) { + return 'Use a extensão $extension para o formato selecionado.'; } @override - String diagnosticWritersideTopicsDirectoryMissing(String relativePath) { - return 'O diretório de tópicos configurado está ausente: $relativePath'; + String get useIdentifierCharacters => + 'Use letras, números, sublinhados ou hifens antes da extensão.'; + + @override + String get topicIdAlreadyExists => 'O ID do tópico já existe.'; + + @override + String get createWritersideTopicFailed => + 'Não foi possível criar o tópico do Writerside.'; + + @override + String get noOutline => 'Sem estrutura'; + + @override + String expandKind(String kind) { + return 'Expandir $kind'; } @override - String diagnosticWritersideImagesDirectoryMissing(String relativePath) { - return 'O diretório de imagens configurado está ausente: $relativePath'; + String collapseKind(String kind) { + return 'Recolher $kind'; } @override - String diagnosticWritersideTopicDuplicateId(String id) { - return 'O ID do elemento \"$id\" aparece mais de uma vez.'; + String get foldKindSection => 'seção'; + + @override + String get foldKindList => 'lista'; + + @override + String get foldKindQuote => 'citação'; + + @override + String get foldKindTag => 'tag'; + + @override + String get sourceSearchPreviousMatch => 'Correspondência anterior'; + + @override + String get sourceSearchNextMatch => 'Próxima correspondência'; + + @override + String get sourceSearchCaseSensitive => + 'Diferenciar maiúsculas de minúsculas'; + + @override + String get sourceSearchWholeWord => 'Palavra inteira'; + + @override + String get sourceSearchRegex => 'Expressão regular'; + + @override + String get sourceSearchReplacement => 'Substituir por'; + + @override + String get sourceSearchReplaceCurrent => 'Substituir correspondência atual'; + + @override + String get sourceSearchReplaceAndFindNext => 'Substituir e localizar próximo'; + + @override + String get sourceSearchReplaceAll => 'Substituir tudo'; + + @override + String get workspaceReplace => 'Substituir no espaço de trabalho'; + + @override + String get reviewReplacements => 'Revisar substituições'; + + @override + String get applyReplacements => 'Aplicar substituições'; + + @override + String get skippedFiles => 'Arquivos ignorados'; + + @override + String get workspaceReplaceDirtyBuffer => 'Conteúdo não salvo do editor'; + + @override + String get workspaceReplaceDiskContent => 'Conteúdo salvo no disco'; + + @override + String selectFileMatches(int count) { + return 'Selecionar todas as $count correspondências'; } @override - String diagnosticWritersideWebFileNameInvalid( - String instanceId, - String webFileName, - String topic, - ) { - return 'A instância \"$instanceId\" publica o nome de arquivo da Web inválido \"$webFileName\" para o tópico \"$topic\".'; + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Foram substituídas $matches correspondências em $files arquivos; $skipped ignoradas.'; } @override - String diagnosticWritersideWebFileNameCollision( - String instanceId, - String webFileName, - String firstTopic, - String secondTopic, - ) { - return 'A instância \"$instanceId\" publica \"$firstTopic\" e \"$secondTopic\" como \"$webFileName\".'; + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Quebra de linha final'; } @override - String diagnosticWritersideTreeMissingTopic(String topic) { - return 'O TOC faz referência ao tópico ausente \"$topic\".'; + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Sem quebra de linha final'; } @override - String diagnosticWritersideTreeInvalidHref(String href) { - return 'O href externo \"$href\" é inválido.'; + String get normalizeLineEndings => 'Normalizar finais de linha'; + + @override + String get mixedLineEndingsSavePrompt => + 'Este documento contém finais de linha mistos. Escolha um formato.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName usa finais de linha mistos. Escolha o formato antes de substituir.'; } @override - String diagnosticWritersideVariableUnresolved(String name) { - return 'A variável \"%$name%\" não foi declarada.'; + String get workspaceReplaceIssueOversized => + 'Um arquivo grande demais foi ignorado.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Um arquivo que não pôde ser lido foi ignorado.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Um arquivo que não é UTF-8 válido foi ignorado.'; + + @override + String get workspaceReplaceIssueTruncated => + 'A prévia de substituições foi truncada.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Um arquivo alterado após a prévia foi ignorado.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Um buffer do editor alterado após a prévia foi ignorado.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Escolha a normalização LF ou CRLF antes de substituir.'; + + @override + String get workspaceReplaceIssuePartialConflict => + 'A reversão foi interrompida porque o arquivo foi alterado simultaneamente. Algumas substituições podem permanecer; o conteúdo deslocado foi preservado no caminho abaixo.'; + + @override + String get workspaceReplaceIssueApplyFailed => + 'Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.'; + + @override + String externalChangesTitle(String fileName) { + return 'Alterações externas — $fileName'; } @override - String diagnosticWritersideTopicLinkUnresolved(String destination) { - return 'O link para o tópico \"$destination\" não pode ser resolvido.'; + String get externalFileDeleted => 'Este arquivo foi excluído do disco.'; + + @override + String get externalFileChanged => + 'Este arquivo mudou no disco enquanto você tem alterações não salvas.'; + + @override + String recoveredDocumentReview(String fileName) { + return 'O conteúdo não salvo de $fileName foi recuperado. Revise-o e depois salve, salve como ou descarte-o.'; } @override - String diagnosticWritersideAnchorUnresolved( - String anchor, - String targetName, - ) { - return 'A âncora \"$anchor\" não existe em \"$targetName\".'; - } + String get compare => 'Comparar'; + + @override + String get reloadFromDisk => 'Recarregar do disco'; + + @override + String get keepMine => 'Manter minha versão'; + + @override + String get saveAs => 'Salvar como'; + + @override + String get sourceSearchInvalidRegex => 'Expressão regular inválida'; + + @override + String get sourceSearchZeroLengthUnsupported => + 'Não há suporte para correspondências regex de comprimento zero (por exemplo, ^, \$ ou antecipações que correspondem apenas a uma posição).'; + + @override + String get workspaceSearchIncomplete => + 'Apenas alguns resultados são exibidos. Mostre mais ou restrinja a pesquisa.'; + + @override + String get workspaceSearchShowMore => 'Mostrar mais resultados'; + + @override + String get workspaceSearchSkippedFiles => + 'A pesquisa está incompleta. Estes arquivos excedem 1 MiB ou não puderam ser lidos:'; + + @override + String get sourceLargeFileFeaturesPaused => + 'Arquivo grande: o realce e o recolhimento estão pausados'; + + @override + String get nothingToRead => 'Nenhum conteúdo para ler'; + + @override + String get admonition => 'Bloco de destaque'; + + @override + String get quote => 'Citação'; + + @override + String get note => 'Observação'; + + @override + String get tip => 'Dica'; + + @override + String get warning => 'Aviso'; + + @override + String get tabs => 'Guias'; + + @override + String get tab => 'Guia'; + + @override + String get procedure => 'Procedimento'; + + @override + String get step => 'Etapa'; + + @override + String get topic => 'Tópico'; + + @override + String get chapter => 'Capítulo'; + + @override + String couldNotOpenTarget(String target) { + return 'Não foi possível abrir $target'; + } + + @override + String linkTargetNotFound(String targetPath) { + return 'Destino do link não encontrado: $targetPath'; + } + + @override + String get cannotOpenFileTypeInEditor => + 'Não é possível abrir este tipo de arquivo no editor'; + + @override + String anchorNotFound(String anchor) { + return 'Âncora não encontrada: $anchor'; + } + + @override + String get noProblemsFound => 'Nenhum problema encontrado'; + + @override + String get noResults => 'Nenhum resultado'; + + @override + String searchResultLine(String relativePath, int lineNumber) { + return '$relativePath - linha $lineNumber'; + } + + @override + String get untitledResult => 'Resultado sem título'; + + @override + String get documentKindMarkdownFile => 'Arquivo Markdown'; + + @override + String get documentKindWritersideMarkdownTopic => + 'Tópico Markdown do Writerside'; + + @override + String get documentKindWritersideXmlTopic => 'Tópico XML do Writerside'; + + @override + String get documentKindWritersideTree => 'Árvore do Writerside'; + + @override + String get documentKindConfigurationFile => 'Arquivo de configuração'; + + @override + String get documentKindVariablesFile => 'Arquivo de variáveis'; + + @override + String get documentKindCategoriesFile => 'Arquivo de categorias'; + + @override + String get documentKindResourceFile => 'Arquivo de recursos'; + + @override + String workspaceErrorOpenFailed(String error) { + return 'Falha ao abrir: $error'; + } + + @override + String workspaceErrorCreateWritersideProjectFailed(String error) { + return 'Não foi possível criar o projeto Writerside: $error'; + } + + @override + String workspaceErrorCreateWritersideTopicFailed(String error) { + return 'Não foi possível criar o tópico do Writerside: $error'; + } + + @override + String workspaceErrorCouldNotOpenFile(String error) { + return 'Não foi possível abrir o arquivo: $error'; + } + + @override + String get workspaceErrorChooseWhereToSaveMarkdown => + 'Escolha onde salvar este arquivo Markdown.'; + + @override + String get workspaceErrorSaveBlockedFileChangedOnDisk => + 'Salvamento bloqueado: o arquivo foi alterado no disco.'; + + @override + String workspaceErrorSaveFailed(String error) { + return 'Falha ao salvar: $error'; + } + + @override + String workspaceErrorFileOperationFailed(String error) { + return 'Falha na operação de arquivo: $error'; + } + + @override + String workspaceErrorValidationFailed(String error) { + return 'Falha na validação: $error'; + } + + @override + String workspaceRecoveryRestored(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count documentos não salvos foram recuperados. Revise cada um antes de salvar ou descartar.', + one: + '1 documento não salvo foi recuperado. Revise-o antes de salvar ou descartar.', + ); + return '$_temp0'; + } + + @override + String workspaceRecoveryDamaged(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count registros de recuperação danificados não puderam ser restaurados. Os registros de recuperação válidos permanecem disponíveis.', + one: + '1 registro de recuperação danificado não pôde ser restaurado. O arquivo de recuperação original foi preservado para inspeção.', + ); + return '$_temp0'; + } + + @override + String errorPathDoesNotExist(String path) { + return 'O caminho não existe: $path'; + } + + @override + String errorTargetDirectoryNotEmpty(String path) { + return 'O diretório de destino já existe e não está vazio: $path'; + } + + @override + String errorTargetPathNotDirectory(String path) { + return 'O caminho de destino já existe e não é um diretório: $path'; + } + + @override + String errorGeneratedFileAlreadyExists(String path) { + return 'O arquivo gerado já existe: $path'; + } + + @override + String get errorParentDirectoryRequired => 'O diretório pai é obrigatório.'; + + @override + String errorParentDirectoryMissing(String path) { + return 'O diretório pai não existe: $path'; + } + + @override + String errorDirectoryMissing(String path) { + return 'O diretório não existe: $path'; + } + + @override + String errorPathAlreadyExists(String path) { + return 'O caminho já existe: $path'; + } + + @override + String get errorFileNameRequired => 'O nome do arquivo é obrigatório.'; + + @override + String get errorFileNameUnsafe => + 'O nome do arquivo deve ser um único segmento de caminho seguro.'; + + @override + String get errorFileOperationInvalidTarget => + 'Não é possível mover uma pasta para dentro dela mesma.'; + + @override + String get errorFileOperationOutsideRoot => + 'A operação de arquivo deve permanecer dentro do espaço de trabalho.'; + + @override + String get errorFileOperationRoot => + 'A raiz do espaço de trabalho não pode ser alterada pela árvore de arquivos.'; + + @override + String get errorProjectNameRequired => 'O nome do projeto é obrigatório.'; + + @override + String get errorDirectoryNameRequired => 'O nome do diretório é obrigatório.'; + + @override + String get errorDirectoryNameUnsafe => + 'O nome do diretório deve ser um único segmento de caminho seguro.'; + + @override + String get errorInstanceIdInvalid => + 'O ID da instância deve começar com uma letra minúscula e conter apenas letras minúsculas, números, sublinhados e hifens.'; + + @override + String get errorTopicFileInvalid => + 'O nome do arquivo do tópico deve ser um nome de arquivo Markdown sem separadores de caminho.'; + + @override + String get errorTopicTitleRequired => 'O título do tópico é obrigatório.'; + + @override + String errorWritersideModuleRootMissing(String path) { + return 'A raiz do módulo Writerside não existe: $path'; + } + + @override + String get errorWritersideModuleNotOpen => + 'Um módulo Writerside deve estar aberto para criar um tópico.'; + + @override + String get errorWritersideInstanceTreeMissing => + 'O módulo Writerside não tem uma árvore de instância.'; + + @override + String errorWritersideTreeFileMissing(String path) { + return 'O arquivo de árvore do Writerside não existe: $path'; + } + + @override + String errorTopicIdAlreadyExists(String topicId) { + return 'O ID do tópico \"$topicId\" já existe neste módulo de ajuda.'; + } + + @override + String errorTopicFileAlreadyExists(String path) { + return 'O arquivo do tópico já existe: $path'; + } + + @override + String errorReferenceTopicMissing(String topic) { + return 'O tópico de referência não está presente na árvore selecionada: $topic'; + } + + @override + String get errorWritersideTocNodeMissing => + 'A entrada selecionada do TOC não existe mais.'; + + @override + String get errorWritersideTocInvalidMove => + 'Uma entrada do TOC não pode ser movida para dentro de si mesma nem de um de seus descendentes.'; + + @override + String errorWritersideStartTopicDelete(String topic) { + return 'Não é possível excluir a página inicial $topic. Escolha outra página inicial primeiro.'; + } + + @override + String get errorWritersideSafeDeleteRequired => + 'Use a exclusão segura para arquivos de tópicos do Writerside.'; + + @override + String get errorWritersideTopicUsageScanFailed => + 'Não foi possível concluir a verificação dos usos do tópico. Nenhum arquivo foi alterado.'; + + @override + String get errorWritersideTopicUsagesRemain => + 'Alguns usos do tópico ainda precisam de atenção. Revise-os antes de continuar.'; + + @override + String get errorWritersideRedirectInvalid => + 'O destino de redirecionamento selecionado não é mais válido. Selecione-o novamente.'; + + @override + String errorWritersideRollbackFailed(String paths) { + return 'Não foi possível reverter completamente a remoção do tópico. Revise estes caminhos antes de continuar: $paths'; + } + + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'A renomeação não pode atualizar com segurança todas as referências do projeto porque $count arquivo(s) de tópico no módulo $module não foram analisados: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Os arquivos do projeto Writerside têm alterações não salvas. Resolva-as e prepare uma nova visualização da renomeação.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'O conjunto de módulos do projeto Writerside mudou. Prepare uma nova visualização da renomeação antes da refatoração.'; + + @override + String get errorTopicsRootUnsafe => + 'A raiz dos tópicos deve ser um diretório relativo seguro.'; + + @override + String get errorTopicFileNameUnsafe => + 'O nome do arquivo do tópico deve ser um único segmento de caminho seguro.'; + + @override + String errorTopicFileExtensionMismatch(String extension) { + return 'A extensão do arquivo do tópico deve corresponder ao formato selecionado ($extension).'; + } + + @override + String get errorTopicFileNameInvalid => + 'O nome do arquivo do tópico deve conter apenas letras, números, sublinhados e hifens.'; + + @override + String errorUnknown(String code) { + return 'Erro desconhecido: $code'; + } + + @override + String diagnosticWorkspaceFileStatFailed(String error) { + return 'Não foi possível ler os metadados do arquivo: $error'; + } + + @override + String get diagnosticWorkspaceScanSkipped => + 'Espaço de trabalho grande detectado. Alguns arquivos foram ignorados para manter o aplicativo responsivo.'; + + @override + String diagnosticWorkspaceScanInspectFailed(String error) { + return 'Não foi possível inspecionar a entrada do espaço de trabalho: $error'; + } + + @override + String get diagnosticWorkspaceFileTooLarge => + 'O arquivo excede o limite beta de análise automática.'; + + @override + String diagnosticWorkspaceFileReadFailed(String error) { + return 'Não foi possível ler o arquivo Markdown: $error'; + } + + @override + String get diagnosticMarkdownAttributeMalformed => + 'O bloco de atributos de título do Writerside está malformado.'; + + @override + String diagnosticMarkdownHeadingDuplicateId(String id) { + return 'ID de título duplicado \"$id\".'; + } + + @override + String get diagnosticWritersideTopicH1ConvertedToChapter => + 'Títulos H1 adicionais no nível superior são tratados como capítulos.'; + + @override + String get diagnosticWritersideMarkdownTopicMissingTitle => + 'O tópico Markdown do Writerside não tem H1 nem título no front matter.'; + + @override + String get diagnosticWritersideXmlTopicMissingTitle => + 'O tópico XML não tem título.'; + + @override + String diagnosticWritersideTopicFileMissingTitle(String fileName) { + return 'O tópico \"$fileName\" não tem título.'; + } + + @override + String get diagnosticMarkdownFrontMatterMalformed => + 'O front matter não foi fechado.'; + + @override + String get diagnosticMarkdownRawHtmlUnsafe => 'Elemento HTML inseguro.'; + + @override + String diagnosticMarkdownLinkUnresolvedTarget(String targetPath) { + return 'O destino do link não existe: $targetPath'; + } + + @override + String diagnosticMarkdownLinkUnresolvedAnchor(String anchor) { + return 'A âncora \"$anchor\" não existe.'; + } + + @override + String diagnosticMarkdownImageMissingAlt(String destination) { + return 'A imagem \"$destination\" não tem texto alternativo.'; + } + + @override + String diagnosticMarkdownImageMissingFile(String destination) { + return 'A imagem não existe: $destination'; + } + + @override + String diagnosticInvalidXml(String message) { + return 'XML inválido: $message'; + } + + @override + String get diagnosticWritersideConfigInvalidRoot => + 'A raiz do arquivo writerside.cfg deve ser <ihp>.'; + + @override + String get diagnosticWritersideConfigMissingSnippetsSrc => + 'A declaração de snippets está sem o atributo src.'; + + @override + String get diagnosticWritersideConfigMissingInstanceGroupsSrc => + 'A declaração de instance-groups está sem o atributo src.'; + + @override + String diagnosticWritersideConfigInvalidKeymapsMode(String mode) { + return 'Modo de keymaps não suportado: $mode'; + } + + @override + String get diagnosticWritersideConfigMissingInstanceSrc => + 'A declaração de instância está sem o atributo src.'; + + @override + String get diagnosticWritersideConfigMissingInstance => + 'O arquivo writerside.cfg não registra uma instância.'; + + @override + String get diagnosticWritersideTreeInvalidRoot => + 'A raiz do arquivo .tree deve ser <instance-profile>.'; + + @override + String get diagnosticWritersideTreeMissingId => + 'O perfil da instância está sem o atributo id.'; + + @override + String diagnosticWritersideTreeIdMismatch(String id) { + return 'O nome-base do arquivo .tree não corresponde ao ID da instância \"$id\".'; + } + + @override + String get diagnosticWritersideTreeMissingStartPage => + 'A instância que não é de biblioteca não tem start-page.'; + + @override + String diagnosticWritersideStartPageMissing(String startPage) { + return 'A página inicial \"$startPage\" não existe.'; + } + + @override + String diagnosticWritersideTreeDuplicateTopic(String topic) { + return 'O tópico \"$topic\" aparece mais de uma vez no TOC desta instância.'; + } + + @override + String get diagnosticWritersideVariableMalformedDeclaration => + 'A declaração de variável deve ter nome e valor.'; + + @override + String diagnosticWritersideVariableDuplicateName(String name) { + return 'A variável \"$name\" é declarada mais de uma vez.'; + } + + @override + String get diagnosticWritersideCategoryMissingId => + 'A categoria está sem o atributo id.'; + + @override + String diagnosticWritersideCategoryDuplicateId(String id) { + return 'A categoria \"$id\" é declarada mais de uma vez.'; + } + + @override + String diagnosticWritersideCategoryDuplicateOrder(String order) { + return 'A ordem da categoria \"$order\" foi declarada mais de uma vez.'; + } + + @override + String get diagnosticWritersideTopicInvalidRoot => + 'A raiz do arquivo .topic deve ser <topic>.'; + + @override + String get diagnosticWritersideTopicMissingRootId => + 'A raiz do tópico XML está sem o atributo id.'; + + @override + String diagnosticWritersideTopicRootIdMismatch(String id, String expectedId) { + return 'O ID raiz do tópico XML \"$id\" deve corresponder ao nome do arquivo \"$expectedId\".'; + } + + @override + String diagnosticWritersideTopicDuplicateElementId(String elementId) { + return 'O ID do elemento \"$elementId\" aparece mais de uma vez.'; + } + + @override + String get diagnosticWritersideTopicAnchorMissingHref => + '<a> está sem o atributo href.'; + + @override + String get diagnosticWritersideConfigMissing => + 'O modo Writerside requer o arquivo writerside.cfg.'; + + @override + String diagnosticWritersideConfigMissingBuildConfigDirectory( + String relativePath, + ) { + return 'O diretório configurado para a compilação está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingApiSpecificationsDirectory( + String relativePath, + ) { + return 'O diretório de especificações de API configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingSnippetsDirectory( + String relativePath, + ) { + return 'O diretório de snippets configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingVarsFile(String relativePath) { + return 'O arquivo de variáveis configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingCategoriesFile(String relativePath) { + return 'O arquivo de categorias configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingInstanceGroupsFile( + String relativePath, + ) { + return 'O arquivo de grupos de instâncias configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideConfigMissingInstanceTree(String source) { + return 'A árvore de instância registrada \"$source\" não existe.'; + } + + @override + String diagnosticWritersideTopicReadFailed(String error) { + return 'Não foi possível ler o arquivo do tópico: $error'; + } + + @override + String diagnosticWritersideDefaultTopicsDirectoryMissing( + String relativePath, + ) { + return 'O diretório de tópicos padrão está ausente: $relativePath'; + } + + @override + String diagnosticWritersideTopicsDirectoryMissing(String relativePath) { + return 'O diretório de tópicos configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideImagesDirectoryMissing(String relativePath) { + return 'O diretório de imagens configurado está ausente: $relativePath'; + } + + @override + String diagnosticWritersideTopicDuplicateId(String id) { + return 'O ID do elemento \"$id\" aparece mais de uma vez.'; + } + + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'A instância \"$instanceId\" publica o nome de arquivo da Web inválido \"$webFileName\" para o tópico \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'A instância \"$instanceId\" publica \"$firstTopic\" e \"$secondTopic\" como \"$webFileName\".'; + } + + @override + String diagnosticWritersideTreeMissingTopic(String topic) { + return 'O TOC faz referência ao tópico ausente \"$topic\".'; + } + + @override + String diagnosticWritersideTreeInvalidHref(String href) { + return 'O href externo \"$href\" é inválido.'; + } + + @override + String diagnosticWritersideVariableUnresolved(String name) { + return 'A variável \"%$name%\" não foi declarada.'; + } + + @override + String diagnosticWritersideTopicLinkUnresolved(String destination) { + return 'O link para o tópico \"$destination\" não pode ser resolvido.'; + } + + @override + String diagnosticWritersideAnchorUnresolved( + String anchor, + String targetName, + ) { + return 'A âncora \"$anchor\" não existe em \"$targetName\".'; + } + + @override + String get diagnosticWritersideIncludeMissingFrom => + '<include> está sem o atributo from.'; + + @override + String diagnosticWritersideIncludeSourceMissing(String from) { + return 'A origem da inclusão \"$from\" não existe.'; + } + + @override + String diagnosticWritersideIncludeElementMissing( + String elementId, + String from, + ) { + return 'O elemento incluído \"$elementId\" não existe em \"$from\".'; + } + + @override + String diagnosticWritersideCategoryUnresolved(String ref) { + return 'A categoria seealso \"$ref\" não foi declarada.'; + } + + @override + String diagnosticWritersideTopicAmbiguousReference(String reference) { + return 'A referência de tópico \"$reference\" é ambígua.'; + } + + @override + String diagnosticUnknown(String code) { + return 'Diagnóstico desconhecido: $code'; + } + + @override + String get close => 'Fechar'; + + @override + String get git => 'Git'; + + @override + String get gitDiff => 'Diff do Git'; + + @override + String get gitShowDiff => 'Mostrar diff'; + + @override + String gitDiffHunkRange(String oldRange, String newRange) { + return 'anterior $oldRange → novo $newRange'; + } + + @override + String get gitDiffNoLines => 'sem linhas'; + + @override + String get gitUnavailableTitle => 'Git não está disponível'; + + @override + String gitUnavailableMessage(String reason) { + String _temp0 = intl.Intl.selectLogic(reason, { + 'other': + 'Instale o Git ou configure o BusyMark para usar um executável do Git disponível. $reason', + }); + return '$_temp0'; + } + + @override + String get gitTrustRequiredTitle => + 'Confiar neste espaço de trabalho para o Git?'; + + @override + String get gitTrustRequiredMessage => + 'Os repositórios Git podem executar programas por meio de hooks, filtros e outras configurações. Confie neste espaço de trabalho antes que o BusyMark leia os dados do repositório ou ative ações do Git.'; + + @override + String get gitTrustWorkspace => 'Confiar no espaço de trabalho'; + + @override + String get gitNotRepositoryTitle => 'Não é um repositório Git'; + + @override + String get gitNotRepositoryMessage => + 'Este espaço de trabalho não está em um repositório Git.'; + + @override + String get gitInitializeRepository => 'Inicializar repositório'; + + @override + String get gitDetachedHead => 'HEAD desanexado'; + + @override + String gitDetachedHeadAt(String commit) { + return 'Desanexado em $commit'; + } + + @override + String get gitNoUpstream => 'Sem upstream'; + + @override + String gitAheadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count commits não enviados', + one: '1 commit não enviado', + ); + return '$_temp0'; + } + + @override + String gitBehindCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count commits para obter', + one: '1 commit para obter', + ); + return '$_temp0'; + } + + @override + String get gitClean => 'Limpo'; + + @override + String get gitConflicts => 'Conflitos'; + + @override + String get gitChanges => 'Alterações'; + + @override + String get gitStaged => 'Preparados'; + + @override + String get gitUnstaged => 'Não preparados'; + + @override + String get gitHistory => 'Histórico'; + + @override + String get gitBranches => 'Branches'; + + @override + String get gitActions => 'Ações do Git'; + + @override + String get gitPull => 'Pull'; + + @override + String get gitFetch => 'Buscar'; + + @override + String get gitPush => 'Push'; + + @override + String get gitCommit => 'Commit'; + + @override + String get gitSelectForCommit => 'Adicionar arquivo ao índice'; + + @override + String get gitRemoveFromCommit => 'Remover arquivo do índice'; + + @override + String get gitDiscard => 'Descartar'; + + @override + String get gitOpenFile => 'Abrir arquivo'; + + @override + String get gitMarkResolved => 'Marcar como resolvido'; + + @override + String get gitUntracked => 'Arquivos não rastreados'; + + @override + String get gitCommitMessage => 'Mensagem de commit'; + + @override + String get gitCommitSelectedFiles => 'Arquivos selecionados'; + + @override + String get gitCommitNoSelectedFiles => + 'Adicione pelo menos um arquivo ao índice antes de criar o commit.'; + + @override + String gitStagedFileCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count arquivos preparados', + one: '1 arquivo preparado', + ); + return '$_temp0'; + } + + @override + String get gitOutsideWorkspace => 'Fora do espaço de trabalho'; + + @override + String get gitCommitMessageRequired => 'Digite uma mensagem de commit.'; + + @override + String get gitCreateBranch => 'Criar branch'; + + @override + String get gitNewBranch => 'Nova branch'; + + @override + String get gitBranchName => 'Nome da branch'; + + @override + String get gitSwitchBranch => 'Trocar'; + + @override + String get gitNoChanges => 'Nenhuma alteração'; + + @override + String get gitNoHistory => 'Nenhum histórico'; + + @override + String get gitNoBranches => 'Nenhuma branch'; + + @override + String get gitNoDiff => 'Nenhum diff para exibir'; + + @override + String get gitBinaryFile => + 'Arquivo binário. O BusyMark não exibe patches binários.'; + + @override + String gitBinaryFileInfo(int size) { + return 'Arquivo binário ($size bytes). O BusyMark não exibe patches binários.'; + } + + @override + String get gitUnsavedChangesBanner => + 'As alterações não salvas do editor não são incluídas até serem salvas.'; + + @override + String get gitConfirmDiscardTitle => 'Descartar alterações do Git?'; + + @override + String gitConfirmDiscardTracked(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Todas as alterações preparadas e não preparadas nos arquivos rastreados selecionados serão restauradas para HEAD.', + one: + 'Todas as alterações preparadas e não preparadas no arquivo rastreado selecionado serão restauradas para HEAD.', + ); + return '$_temp0'; + } + + @override + String gitConfirmDiscardUntracked(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Os arquivos não rastreados selecionados serão excluídos.', + one: 'O arquivo não rastreado selecionado será excluído.', + ); + return '$_temp0'; + } + + @override + String gitConfirmDiscardMixed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Os arquivos selecionados serão restaurados ou excluídos de acordo com o status do Git.', + one: + 'O arquivo selecionado será restaurado ou excluído de acordo com o status do Git.', + ); + return '$_temp0'; + } + + @override + String gitConfirmSwitchBranchTitle(String branch) { + return 'Trocar para $branch?'; + } + + @override + String get gitConfirmSwitchBranchMessage => + 'O BusyMark recarregará o espaço de trabalho do disco depois que o Git trocar de branch.'; + + @override + String get gitConfirmPushSetUpstreamTitle => 'Definir branch upstream?'; + + @override + String gitConfirmPushSetUpstreamMessage(String branch) { + return 'Esta branch não tem upstream. O BusyMark pode enviar $branch e definir seu upstream quando houver exatamente um remoto configurado.'; + } + + @override + String get gitProjectHistory => 'Histórico do projeto'; + + @override + String get gitFileHistory => 'Histórico do arquivo'; + + @override + String get gitFileHistoryRequiresOpenFile => + 'O histórico do arquivo requer um arquivo Markdown aberto.'; + + @override + String get gitLoadMore => 'Carregar mais'; + + @override + String get gitChangesInCommit => 'Alterações neste commit'; + + @override + String get gitCompareWithCurrent => 'Comparar com a versão atual'; + + @override + String get gitRestoreVersion => 'Restaurar esta versão'; + + @override + String get gitConfirmRestoreTitle => 'Restaurar esta versão do arquivo?'; + + @override + String get gitConfirmRestoreMessage => + 'O BusyMark substituirá o arquivo atual da árvore de trabalho pela versão selecionada do commit. O arquivo restaurado permanecerá não preparado.'; + + @override + String get gitCommitActions => 'Ações do commit'; + + @override + String get gitResetCurrentBranchToHere => 'Redefinir a branch atual aqui…'; + + @override + String gitResetCurrentBranchTitle(String branch, String commit) { + return 'Redefinir $branch para $commit?'; + } + + @override + String gitResetCurrentBranchMessage(String branch, String commit) { + return 'Isto move a branch $branch para o commit $commit. Escolha como o Git deve atualizar o índice e a árvore de trabalho.'; + } + + @override + String get gitReset => 'Redefinir'; + + @override + String get gitResetModeSoft => 'Soft'; + + @override + String get gitResetModeSoftDescription => + 'Mover apenas a branch. Manter o índice e a árvore de trabalho inalterados; as diferenças em relação ao commit selecionado permanecem preparadas.'; + + @override + String get gitResetModeMixed => 'Mixed'; + + @override + String get gitResetModeMixedDescription => + 'Mover a branch e redefinir o índice. Manter a árvore de trabalho inalterada, deixando as diferenças não preparadas.'; + + @override + String get gitResetModeHard => 'Hard'; + + @override + String get gitResetModeHardDescription => + 'Mover a branch e redefinir o índice e a árvore de trabalho. As alterações rastreadas são descartadas; os arquivos não rastreados que bloqueiam a operação podem ser excluídos.'; + + @override + String get gitResetModeKeep => 'Keep'; + + @override + String get gitResetModeKeepDescription => + 'Mover a branch e redefinir os arquivos rastreados, preservando as alterações locais. O Git aborta se essas alterações entrarem em conflito com a redefinição.'; + + @override + String gitAdditionsDeletions(int additions, int deletions) { + return '+$additions -$deletions'; + } + + @override + String get fileActions => 'Ações do arquivo'; + + @override + String get actions => 'Ações'; + + @override + String get gitStatusAdded => 'Adicionado'; + + @override + String get gitStatusDeleted => 'Excluído'; + + @override + String get gitStatusRenamed => 'Renomeado'; + + @override + String get gitStatusCopied => 'Copiado'; + + @override + String get gitStatusUntracked => 'Não rastreado'; + + @override + String get gitStatusConflicted => 'Em conflito'; + + @override + String get gitStatusIgnored => 'Ignorado'; + + @override + String get gitStatusTypeChanged => 'Tipo alterado'; + + @override + String get gitStatusModified => 'Modificado'; + + @override + String get gitStatusUnknown => 'Desconhecido'; + + @override + String get gitErrorUnavailable => 'Git não está disponível.'; + + @override + String get gitErrorNotRepository => + 'Este espaço de trabalho não é um repositório Git.'; + + @override + String get gitErrorUnsafePath => + 'O BusyMark bloqueou um caminho Git inseguro.'; + + @override + String get gitErrorInvalidBranchName => 'Digite um nome de branch válido.'; + + @override + String get gitErrorNoRemote => 'Nenhum remoto Git está configurado.'; + + @override + String get gitErrorNoUpstream => 'Nenhuma branch upstream está configurada.'; + + @override + String get gitErrorMultipleRemotes => + 'Há vários remotos configurados. Escolha um upstream fora desta versão do BusyMark.'; + + @override + String get gitErrorDirtyWorkspace => + 'Salve ou descarte as alterações do editor do BusyMark antes de trocar de branch.'; + + @override + String get gitErrorResetDirtyWorkspace => + 'Salve ou descarte as alterações no editor do BusyMark antes de redefinir a branch atual.'; + + @override + String get gitErrorRestoreStagedFile => + 'Remova o arquivo do índice antes de restaurar uma versão anterior.'; + + @override + String get gitErrorResetDetachedHead => + 'Alterne para uma branch antes de redefini-la.'; + + @override + String get gitErrorDiverged => + 'A branch divergiu. Resolva o merge ou o rebase fora desta versão do BusyMark.'; + + @override + String get gitErrorAuthorIdentity => + 'O Git precisa do nome e do e-mail do autor antes de criar um commit.'; + + @override + String get gitAuthorIdentityTitle => 'Identidade do autor do Git'; + + @override + String get gitAuthorIdentityMessage => + 'Informe a identidade que o Git deve registrar nos commits. O BusyMark irá salvá-la e tentar este commit novamente.'; + + @override + String get gitAuthorName => 'Nome'; + + @override + String get gitAuthorEmail => 'E-mail'; + + @override + String get gitAuthorIdentityGlobal => 'Usar em todos os repositórios'; + + @override + String get gitAuthorIdentityGlobalDescription => + 'Quando instalado como Snap, isto se aplica aos repositórios abertos no BusyMark.'; + + @override + String get gitSaveIdentityAndCommit => 'Salvar e criar commit'; + + @override + String get gitErrorAuthentication => 'A autenticação do Git falhou.'; + + @override + String get gitErrorNetwork => 'A operação de rede do Git falhou.'; + + @override + String get gitErrorConflict => 'O Git relatou conflitos não resolvidos.'; + + @override + String get gitErrorCommandFailed => 'O comando Git falhou.'; + + @override + String get syntaxReference => 'Referência de sintaxe'; + + @override + String get syntaxReferenceMarkdownBlocks => 'Blocos Markdown'; + + @override + String get syntaxReferenceMarkdownBlocksDescription => + 'Estruturas de bloco compatíveis no código Markdown e na pré-visualização.'; + + @override + String get syntaxReferenceInlineFormatting => 'Markdown em linha'; + + @override + String get syntaxReferenceInlineFormattingDescription => + 'Formatação dentro de parágrafos, itens de lista e células de tabela.'; + + @override + String get syntaxReferenceRawHtmlBlocks => 'Blocos de HTML bruto'; + + @override + String get syntaxReferenceRawHtmlBlocksDescription => + 'Tags HTML de bloco seguras renderizadas pelos widgets de pré-visualização do BusyMark.'; + + @override + String get syntaxReferenceRawHtmlInline => 'Tags de HTML bruto em linha'; + + @override + String get syntaxReferenceRawHtmlInlineDescription => + 'Tags HTML em linha seguras renderizadas sem mostrar as tags literais.'; + + @override + String get syntaxReferenceHeadings => 'Títulos'; + + @override + String get syntaxReferenceParagraphs => 'Parágrafos'; + + @override + String get syntaxReferenceLists => 'Listas'; + + @override + String get syntaxReferenceHtmlContainers => 'Contêineres'; + + @override + String get syntaxReferenceHtmlTextBlocks => 'Blocos de texto'; + + @override + String get syntaxReferenceHtmlFigures => 'Figuras e imagens'; + + @override + String get syntaxReferenceHtmlPreformatted => 'Código pré-formatado'; + + @override + String get syntaxReferenceHtmlDisclosure => 'Blocos expansíveis'; + + @override + String get syntaxReferenceHtmlDescriptionLists => 'Listas de descrição'; + + @override + String get syntaxReferenceHtmlFormattingTags => 'Tags de formatação'; + + @override + String get syntaxReferenceHtmlInlineCodeTags => 'Tags de código em linha'; + + @override + String get syntaxReferenceHtmlNeutralInlineTags => 'Tags de texto semântico'; + + @override + String get syntaxReferenceSanitizedPreviewDescription => + 'O HTML permitido é convertido em blocos de pré-visualização do BusyMark e não é renderizado em um navegador.'; + + @override + String get syntaxReferenceSourcePreservedDescription => + 'HTML bruto não editado é salvo exatamente como texto fonte.'; + + @override + String get syntaxReferenceMarkdownInsideHtmlDescription => + 'Marcadores Markdown dentro de HTML bruto são exibidos como texto literal.'; + + @override + String get syntaxReferenceBlockedContentDescription => + 'Scripts, estilos, frames, formulários, SVG, MathML, eventos e atributos inseguros são bloqueados.'; + + @override + String get syntaxReferenceSafeUrlsDescription => + 'Links permitem http, https, mailto, tel, URLs relativas e fragmentos; esquemas inseguros são bloqueados.'; + + @override + String get syntaxReferenceCategory => 'Categoria'; + + @override + String get syntaxReferenceCategoryHtml => 'HTML'; + + @override + String get syntaxReferenceCategoryDiagramsAndApi => 'Diagramas e API'; + + @override + String get syntaxReferenceCategoryMathematics => 'Matemática'; + + @override + String get syntaxReferenceExample => 'Exemplo'; @override - String get diagnosticWritersideIncludeMissingFrom => - '<include> está sem o atributo from.'; + String get syntaxReferenceIdentifiers => 'Identificadores e aliases'; @override - String diagnosticWritersideIncludeSourceMissing(String from) { - return 'A origem da inclusão \"$from\" não existe.'; - } + String get syntaxReferenceScope => 'Escopo'; @override - String diagnosticWritersideIncludeElementMissing( - String elementId, - String from, - ) { - return 'O elemento incluído \"$elementId\" não existe em \"$from\".'; - } + String get syntaxReferenceLimitation => 'Limitação do BusyMark'; @override - String diagnosticWritersideCategoryUnresolved(String ref) { - return 'A categoria seealso \"$ref\" não foi declarada.'; - } + String get syntaxReferenceOfficialDocumentation => 'Documentação oficial'; @override - String diagnosticWritersideTopicAmbiguousReference(String reference) { - return 'A referência de tópico \"$reference\" é ambígua.'; - } + String get syntaxReferenceScopeWritersideMarkdown => + 'Somente Markdown do Writerside'; @override - String diagnosticUnknown(String code) { - return 'Diagnóstico desconhecido: $code'; - } + String get syntaxReferenceScopeWritersideMarkdownAndXml => + 'Somente Markdown e XML do Writerside'; @override - String get close => 'Fechar'; + String get syntaxReferenceMarkdownDescription => + 'As formas essenciais de Markdown que o BusyMark pode criar e visualizar.'; @override - String get git => 'Git'; + String get syntaxReferenceParagraphExample => 'Um parágrafo de texto.'; @override - String get gitDiff => 'Diff do Git'; + String get syntaxReferenceTableLimitation => + 'As tabelas usam a sintaxe de barras verticais do GitHub Flavored Markdown.'; @override - String get gitShowDiff => 'Mostrar diff'; + String get syntaxReferenceHardBreakIdentifiers => + 'dois espaços no fim da linha, \\, <br>'; @override - String gitDiffHunkRange(String oldRange, String newRange) { - return 'anterior $oldRange → novo $newRange'; - } + String get syntaxReferenceHtmlDescription => + 'O BusyMark aceita um subconjunto seguro e específico de HTML bruto no código Markdown.'; @override - String get gitDiffNoLines => 'sem linhas'; + String get syntaxReferenceDiagramsDescription => + 'Blocos cercados Mermaid, PlantUML, D2 e OpenAPI funcionam no código Markdown. Os identificadores não diferenciam maiúsculas de minúsculas, e o BusyMark preserva a grafia original.'; @override - String get gitUnavailableTitle => 'Git não está disponível'; + String get syntaxReferenceMermaid => 'Mermaid'; @override - String gitUnavailableMessage(String reason) { - String _temp0 = intl.Intl.selectLogic(reason, { - 'other': - 'Instale o Git ou configure o BusyMark para usar um executável do Git disponível. $reason', - }); - return '$_temp0'; - } + String get syntaxReferencePlantUml => 'PlantUML'; @override - String get gitTrustRequiredTitle => - 'Confiar neste espaço de trabalho para o Git?'; + String get syntaxReferenceD2 => 'D2'; @override - String get gitTrustRequiredMessage => - 'Os repositórios Git podem executar programas por meio de hooks, filtros e outras configurações. Confie neste espaço de trabalho antes que o BusyMark leia os dados do repositório ou ative ações do Git.'; + String get syntaxReferenceOpenApi => 'OpenAPI'; @override - String get gitTrustWorkspace => 'Confiar no espaço de trabalho'; + String get syntaxReferenceOpenApiLimitation => + 'Use conteúdo YAML ou JSON em um bloco cercado. O BusyMark não trata um documento YAML ou JSON completo qualquer como referência OpenAPI.'; @override - String get gitNotRepositoryTitle => 'Não é um repositório Git'; + String get syntaxReferenceSemanticDiagramBlocks => + 'Blocos de código semânticos para diagramas'; @override - String get gitNotRepositoryMessage => - 'Este espaço de trabalho não está em um repositório Git.'; + String get syntaxReferenceSemanticDiagramLimitation => + 'As formas semânticas code-block e src aceitam Mermaid, PlantUML e D2, não OpenAPI, e somente em projetos Writerside.'; @override - String get gitInitializeRepository => 'Inicializar repositório'; + String get syntaxReferenceReferencedDiagramSource => + 'Origem de diagrama referenciada'; @override - String get gitDetachedHead => 'HEAD desanexado'; + String get syntaxReferenceReferencedDiagramLimitation => + 'Os caminhos devem ser relativos e permanecer dentro do projeto Writerside aberto; a forma de bloco cercado com src é exclusiva do Markdown do Writerside.'; @override - String gitDetachedHeadAt(String commit) { - return 'Desanexado em $commit'; - } + String get syntaxReferenceMathematicsDescription => + 'O BusyMark aceita expressões TeX, não documentos TeX ou LaTeX completos.'; @override - String get gitNoUpstream => 'Sem upstream'; + String get syntaxReferenceInlineMath => 'Matemática em linha'; @override - String gitAheadCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count commits não enviados', - one: '1 commit não enviado', - ); - return '$_temp0'; - } + String get syntaxReferenceGithubMath => + 'Matemática do GitHub com cifrão e crase'; @override - String gitBehindCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count commits para obter', - one: '1 commit para obter', - ); - return '$_temp0'; - } + String get syntaxReferenceDisplayMath => 'Matemática em bloco'; @override - String get gitClean => 'Limpo'; + String get syntaxReferenceMathFence => 'Bloco cercado math'; @override - String get gitConflicts => 'Conflitos'; + String get syntaxReferenceTexFence => 'Bloco cercado tex'; @override - String get gitChanges => 'Alterações'; + String get syntaxReferenceMathDelimitersLimitation => + 'O BusyMark não reconhece \\(...\\) nem \\[...\\] como delimitadores matemáticos do Markdown.'; @override - String get gitStaged => 'Preparados'; + String get syntaxReferenceTexFenceLimitation => + 'Fora do modo Writerside, um bloco tex continua sendo um bloco de código comum.'; @override - String get gitUnstaged => 'Não preparados'; + String get syntaxReferenceWritersideMathElement => + 'Elemento math do Writerside'; @override - String get gitHistory => 'Histórico'; + String get syntaxReferenceWritersideMathElementLimitation => + 'O elemento math é sintaxe semântica do Writerside, não MathML HTML bruto permitido.'; @override - String get gitBranches => 'Branches'; + String get syntaxReferenceSemanticTexBlock => 'Bloco de código TeX semântico'; @override - String get gitActions => 'Ações do Git'; + String get syntaxReferenceWritersideDescription => + 'Estas extensões específicas são interpretadas somente em projetos Writerside abertos.'; @override - String get gitPull => 'Pull'; + String get syntaxReferenceAdmonitionBlockquote => 'Citação de aviso'; @override - String get gitFetch => 'Buscar'; + String get syntaxReferenceAdmonitionLimitation => + 'Uma citação simples é uma dica no Markdown do Writerside; no Markdown comum, continua sendo uma citação normal.'; @override - String get gitPush => 'Push'; + String get syntaxReferenceSemanticAdmonitions => 'Avisos semânticos'; @override - String get gitCommit => 'Commit'; + String get syntaxReferenceSemanticMarkupLimitation => + 'O Markdown comum não interpreta estes elementos semânticos do Writerside.'; @override - String get gitSelectForCommit => 'Adicionar arquivo ao índice'; + String get syntaxReferenceCollapsibleHeading => 'Título recolhível'; @override - String get gitRemoveFromCommit => 'Remover arquivo do índice'; + String get syntaxReferenceCollapsibleCode => 'Bloco de código recolhível'; @override - String get gitDiscard => 'Descartar'; + String get syntaxReferenceSemanticCollapsibles => + 'Conteúdo semântico recolhível'; @override - String get gitOpenFile => 'Abrir arquivo'; + String get syntaxReferenceSemanticCollapsiblesLimitation => + 'O BusyMark aceita as formas recolhíveis chapter, procedure, code-block e lista de definições, não todo o catálogo do Writerside.'; @override - String get gitMarkResolved => 'Marcar como resolvido'; + String get syntaxReferenceSemanticCodeBlocks => + 'Blocos de código semânticos para matemática e diagramas'; @override - String get gitUntracked => 'Arquivos não rastreados'; + String get syntaxReferenceVideo => 'Vídeo do Writerside'; @override - String get gitCommitMessage => 'Mensagem de commit'; + String get syntaxReferenceVideoLimitation => + 'Vídeo local usa uma imagem preview-src local; fontes hospedadas devem ser URLs HTTPS compatíveis do YouTube ou Vimeo.'; @override - String get gitCommitSelectedFiles => 'Arquivos selecionados'; + String get exportAsPdf => 'Exportar como PDF'; @override - String get gitCommitNoSelectedFiles => - 'Adicione pelo menos um arquivo ao índice antes de criar o commit.'; + String get pdfExportDescription => + 'Escolha o esquema da página para criar um PDF bem acabado e independente.'; @override - String gitStagedFileCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count arquivos preparados', - one: '1 arquivo preparado', - ); - return '$_temp0'; - } + String get pdfRemoteImagesNote => + 'As imagens remotas não são transferidas durante a exportação. As imagens locais são incluídas quando disponíveis.'; @override - String get gitOutsideWorkspace => 'Fora do espaço de trabalho'; + String get pdfPageSize => 'Tamanho da página'; @override - String get gitCommitMessageRequired => 'Digite uma mensagem de commit.'; + String get pdfPageSizeA4 => 'A4'; @override - String get gitCreateBranch => 'Criar branch'; + String get pdfPageSizeLetter => 'Carta'; @override - String get gitNewBranch => 'Nova branch'; + String get pdfOrientation => 'Orientação'; @override - String get gitBranchName => 'Nome da branch'; + String get pdfPortrait => 'Vertical'; @override - String get gitSwitchBranch => 'Trocar'; + String get pdfLandscape => 'Horizontal'; @override - String get gitNoChanges => 'Nenhuma alteração'; + String get pdfMargins => 'Margens'; @override - String get gitNoHistory => 'Nenhum histórico'; + String get pdfMarginNarrow => 'Estreitas'; @override - String get gitNoBranches => 'Nenhuma branch'; + String get pdfMarginNormal => 'Normais'; @override - String get gitNoDiff => 'Nenhum diff para exibir'; + String get pdfMarginWide => 'Largas'; @override - String get gitBinaryFile => - 'Arquivo binário. O BusyMark não exibe patches binários.'; + String get pdfIncludePageNumbers => 'Incluir números de página'; @override - String gitBinaryFileInfo(int size) { - return 'Arquivo binário ($size bytes). O BusyMark não exibe patches binários.'; - } + String get export => 'Exportar'; @override - String get gitUnsavedChangesBanner => - 'As alterações não salvas do editor não são incluídas até serem salvas.'; + String get exportFormat => 'Formato de saída'; @override - String get gitConfirmDiscardTitle => 'Descartar alterações do Git?'; + String get exportingPdf => 'Exportando PDF…'; @override - String gitConfirmDiscardTracked(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: - 'Todas as alterações preparadas e não preparadas nos arquivos rastreados selecionados serão restauradas para HEAD.', - one: - 'Todas as alterações preparadas e não preparadas no arquivo rastreado selecionado serão restauradas para HEAD.', - ); - return '$_temp0'; - } + String get fileTypePdf => 'Documento PDF'; @override - String gitConfirmDiscardUntracked(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Os arquivos não rastreados selecionados serão excluídos.', - one: 'O arquivo não rastreado selecionado será excluído.', - ); - return '$_temp0'; + String pdfExported(String fileName) { + return '$fileName foi exportado.'; } @override - String gitConfirmDiscardMixed(int count) { + String pdfExportedWithWarnings(String fileName, int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: - 'Os arquivos selecionados serão restaurados ou excluídos de acordo com o status do Git.', - one: - 'O arquivo selecionado será restaurado ou excluído de acordo com o status do Git.', + other: '$count avisos', + one: '1 aviso', ); - return '$_temp0'; - } - - @override - String gitConfirmSwitchBranchTitle(String branch) { - return 'Trocar para $branch?'; + return '$fileName foi exportado com $_temp0.'; } @override - String get gitConfirmSwitchBranchMessage => - 'O BusyMark recarregará o espaço de trabalho do disco depois que o Git trocar de branch.'; - - @override - String get gitConfirmPushSetUpstreamTitle => 'Definir branch upstream?'; + String get pdfExportUnavailable => + 'O componente de exportação para PDF está ausente. Reinstale o BusyMark e tente novamente.'; @override - String gitConfirmPushSetUpstreamMessage(String branch) { - return 'Esta branch não tem upstream. O BusyMark pode enviar $branch e definir seu upstream quando houver exatamente um remoto configurado.'; - } + String get pdfExportTimedOut => + 'A exportação para PDF demorou demasiado e foi interrompida.'; @override - String get gitProjectHistory => 'Histórico do projeto'; + String get pdfExportFailed => + 'O BusyMark não conseguiu exportar este documento como PDF.'; @override - String get gitFileHistory => 'Histórico do arquivo'; + String get visualizationRendering => 'Renderizando…'; @override - String get gitFileHistoryRequiresOpenFile => - 'O histórico do arquivo requer um arquivo Markdown aberto.'; + String get visualizationStale => 'Exibindo a última renderização válida'; @override - String get gitLoadMore => 'Carregar mais'; + String get visualizationShowSource => 'Mostrar código-fonte'; @override - String get gitChangesInCommit => 'Alterações neste commit'; + String get visualizationShowRender => 'Mostrar renderização'; @override - String get gitCompareWithCurrent => 'Comparar com a versão atual'; + String get visualizationFitWidth => 'Ajustar à largura'; @override - String get gitRestoreVersion => 'Restaurar esta versão'; + String get visualizationSaveImage => 'Salvar imagem'; @override - String get gitConfirmRestoreTitle => 'Restaurar esta versão do arquivo?'; + String get visualizationCopyImage => 'Copiar imagem'; @override - String get gitConfirmRestoreMessage => - 'O BusyMark substituirá o arquivo atual da árvore de trabalho pela versão selecionada do commit. O arquivo restaurado permanecerá não preparado.'; + String get visualizationImageCopied => 'Imagem copiada'; @override - String get gitCommitActions => 'Ações do commit'; + String get visualizationOpenApiReference => 'Abrir referência da API'; @override - String get gitResetCurrentBranchToHere => 'Redefinir a branch atual aqui…'; + String get visualizationValid => 'Válido'; @override - String gitResetCurrentBranchTitle(String branch, String commit) { - return 'Redefinir $branch para $commit?'; - } + String get visualizationInvalid => 'Inválido'; @override - String gitResetCurrentBranchMessage(String branch, String commit) { - return 'Isto move a branch $branch para o commit $commit. Escolha como o Git deve atualizar o índice e a árvore de trabalho.'; - } + String get visualizationServers => 'Servidores'; @override - String get gitReset => 'Redefinir'; + String get visualizationPaths => 'Caminhos'; @override - String get gitResetModeSoft => 'Soft'; + String get visualizationOperations => 'Operações'; @override - String get gitResetModeSoftDescription => - 'Mover apenas a branch. Manter o índice e a árvore de trabalho inalterados; as diferenças em relação ao commit selecionado permanecem preparadas.'; + String get visualizationTags => 'Etiquetas'; @override - String get gitResetModeMixed => 'Mixed'; + String get visualizationNoOperations => 'Nenhuma operação correspondente'; @override - String get gitResetModeMixedDescription => - 'Mover a branch e redefinir o índice. Manter a árvore de trabalho inalterada, deixando as diferenças não preparadas.'; + String get visualizationSearchOperations => 'Pesquisar operações'; @override - String get gitResetModeHard => 'Hard'; + String get visualizationRenderFailed => + 'Não foi possível renderizar esta visualização.'; @override - String get gitResetModeHardDescription => - 'Mover a branch e redefinir o índice e a árvore de trabalho. As alterações rastreadas são descartadas; os arquivos não rastreados que bloqueiam a operação podem ser excluídos.'; + String get visualizationRetry => 'Tentar novamente'; @override - String get gitResetModeKeep => 'Keep'; + String visualizationSaved(String fileName) { + return '$fileName salvo'; + } @override - String get gitResetModeKeepDescription => - 'Mover a branch e redefinir os arquivos rastreados, preservando as alterações locais. O Git aborta se essas alterações entrarem em conflito com a redefinição.'; + String get shortcutExportPdfDescription => + 'Exportar o documento ativo ou o módulo do Writerside como PDF.'; @override - String gitAdditionsDeletions(int additions, int deletions) { - return '+$additions -$deletions'; - } + String get instances => 'Instâncias'; @override - String get fileActions => 'Ações do arquivo'; + String get newInstance => 'Nova instância'; @override - String get actions => 'Ações'; + String get newTocLibrary => 'Nova biblioteca de sumário'; @override - String get gitStatusAdded => 'Adicionado'; + String get editInstance => 'Editar instância'; @override - String get gitStatusDeleted => 'Excluído'; + String get openTocFile => 'Abrir arquivo do índice'; @override - String get gitStatusRenamed => 'Renomeado'; + String get createInstance => 'Criar instância'; @override - String get gitStatusCopied => 'Copiado'; + String get createTocLibrary => 'Criar biblioteca de sumário'; @override - String get gitStatusUntracked => 'Não rastreado'; + String get instanceContent => 'Conteúdo'; @override - String get gitStatusConflicted => 'Em conflito'; + String get instanceContentSource => 'Criar a partir de'; @override - String get gitStatusIgnored => 'Ignorado'; + String get emptyInstance => 'Instância vazia'; @override - String get gitStatusTypeChanged => 'Tipo alterado'; + String get markdownFiles => 'Arquivos Markdown locais'; @override - String get gitStatusModified => 'Modificado'; + String get chooseMarkdownFolder => 'Escolher pasta de Markdown'; @override - String get gitStatusUnknown => 'Desconhecido'; + String get errorWritersideInstanceImportSourceRequired => + 'Escolha uma pasta que contenha arquivos Markdown.'; @override - String get gitErrorUnavailable => 'Git não está disponível.'; + String get instanceAppearance => 'Aparência'; @override - String get gitErrorNotRepository => - 'Este espaço de trabalho não é um repositório Git.'; + String get instanceColor => 'Cor do ícone'; @override - String get gitErrorUnsafePath => - 'O BusyMark bloqueou um caminho Git inseguro.'; + String get instanceVersion => 'Versão'; @override - String get gitErrorInvalidBranchName => 'Digite um nome de branch válido.'; + String instanceVersionInherited(String version) { + return 'Quando este campo está vazio, é usada a versão do projeto $version.'; + } @override - String get gitErrorNoRemote => 'Nenhum remoto Git está configurado.'; + String get instanceWebPath => 'Caminho web'; @override - String get gitErrorNoUpstream => 'Nenhuma branch upstream está configurada.'; + String get instanceStatus => 'Estado'; @override - String get gitErrorMultipleRemotes => - 'Há vários remotos configurados. Escolha um upstream fora desta versão do BusyMark.'; + String get instanceStatusRelease => 'Lançamento'; @override - String get gitErrorDirtyWorkspace => - 'Salve ou descarte as alterações do editor do BusyMark antes de trocar de branch.'; + String get instanceStatusEap => 'Acesso antecipado'; @override - String get gitErrorResetDirtyWorkspace => - 'Salve ou descarte as alterações no editor do BusyMark antes de redefinir a branch atual.'; + String get instanceStatusDeprecated => 'Obsoleta'; @override - String get gitErrorRestoreStagedFile => - 'Remova o arquivo do índice antes de restaurar uma versão anterior.'; + String get allowSearchEngineIndexing => + 'Permitir indexação por mecanismos de busca'; @override - String get gitErrorResetDetachedHead => - 'Alterne para uma branch antes de redefini-la.'; + String get allowSearchEngineIndexingDescription => + 'Permita que mecanismos de busca externos indexem esta saída.'; @override - String get gitErrorDiverged => - 'A branch divergiu. Resolva o merge ou o rebase fora desta versão do BusyMark.'; + String get offlineArtifact => 'Artefato offline'; @override - String get gitErrorAuthorIdentity => - 'O Git precisa do nome e do e-mail do autor antes de criar um commit.'; + String get offlineArtifactDescription => + 'Inclua os recursos para que a documentação gerada funcione de forma autônoma.'; @override - String get gitAuthorIdentityTitle => 'Identidade do autor do Git'; + String get instanceOutputSettings => 'Configurações de saída'; @override - String get gitAuthorIdentityMessage => - 'Informe a identidade que o Git deve registrar nos commits. O BusyMark irá salvá-la e tentar este commit novamente.'; + String get markdownImportSource => 'Origem Markdown'; @override - String get gitAuthorName => 'Nome'; + String get markdownImportFiles => 'Arquivos Markdown'; @override - String get gitAuthorEmail => 'E-mail'; + String get selectNone => 'Não selecionar nenhum'; @override - String get gitAuthorIdentityGlobal => 'Usar em todos os repositórios'; + String markdownFilesFound(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Foram encontrados $count arquivos Markdown', + one: 'Foi encontrado 1 arquivo Markdown', + ); + return '$_temp0'; + } @override - String get gitAuthorIdentityGlobalDescription => - 'Quando instalado como Snap, isto se aplica aos repositórios abertos no BusyMark.'; + String get noMarkdownFilesFound => + 'Não foram encontrados arquivos Markdown neste diretório.'; @override - String get gitSaveIdentityAndCommit => 'Salvar e criar commit'; + String get copyReferencedMedia => 'Copiar mídia referenciada'; @override - String get gitErrorAuthentication => 'A autenticação do Git falhou.'; + String get copyReferencedMediaDescription => + 'Copie imagens e vídeos locais referenciados pelos arquivos selecionados, preservando os caminhos relativos.'; @override - String get gitErrorNetwork => 'A operação de rede do Git falhou.'; + String get instanceIdRenameWarningTitle => 'Mudar o nome do ID da instância?'; @override - String get gitErrorConflict => 'O Git relatou conflitos não resolvidos.'; + String instanceIdRenameWarning(String oldId, String newId) { + return 'O BusyMark vai renomear o arquivo .tree e atualizar as referências do projeto Writerside de “$oldId” para “$newId”. Os scripts de publicação não são alterados e devem ser atualizados separadamente.'; + } @override - String get gitErrorCommandFailed => 'O comando Git falhou.'; + String get renameAndUpdateReferences => + 'Mudar o nome e atualizar referências'; @override - String get syntaxReference => 'Referência de sintaxe'; + String get tocLibraryDescription => + 'Uma biblioteca de sumário armazena seções reutilizáveis e não produz uma saída própria.'; @override - String get syntaxReferenceMarkdownBlocks => 'Blocos Markdown'; + String get defaultTocLibraryName => 'Sumário compartilhado'; @override - String get syntaxReferenceMarkdownBlocksDescription => - 'Estruturas de bloco compatíveis no código Markdown e na pré-visualização.'; + String get instanceColorAutomatic => 'Automático'; @override - String get syntaxReferenceInlineFormatting => 'Markdown em linha'; + String get instanceColorBlue => 'Azul'; @override - String get syntaxReferenceInlineFormattingDescription => - 'Formatação dentro de parágrafos, itens de lista e células de tabela.'; + String get instanceColorGreen => 'Verde'; @override - String get syntaxReferenceRawHtmlBlocks => 'Blocos de HTML bruto'; + String get instanceColorOrange => 'Laranja'; @override - String get syntaxReferenceRawHtmlBlocksDescription => - 'Tags HTML de bloco seguras renderizadas pelos widgets de pré-visualização do BusyMark.'; + String get instanceColorPurple => 'Roxo'; @override - String get syntaxReferenceRawHtmlInline => 'Tags de HTML bruto em linha'; + String get instanceColorRed => 'Vermelho'; @override - String get syntaxReferenceRawHtmlInlineDescription => - 'Tags HTML em linha seguras renderizadas sem mostrar as tags literais.'; + String get instanceColorTeal => 'Verde-azulado'; @override - String get syntaxReferenceHeadings => 'Títulos'; + String get instanceColorYellow => 'Amarelo'; @override - String get syntaxReferenceParagraphs => 'Parágrafos'; + String get errorWritersideInstanceNameRequired => + 'Digite um nome para a instância.'; @override - String get syntaxReferenceLists => 'Listas'; + String errorWritersideInstanceIdExists(String id) { + return 'Já existe uma instância com o ID “$id”.'; + } @override - String get syntaxReferenceHtmlContainers => 'Contêineres'; + String errorWritersideInstanceTreeExists(String path) { + return 'A árvore da instância já existe: $path'; + } @override - String get syntaxReferenceHtmlTextBlocks => 'Blocos de texto'; + String errorWritersideInstanceImportSourceMissing(String path) { + return 'O diretório de origem Markdown não existe: $path'; + } @override - String get syntaxReferenceHtmlFigures => 'Figuras e imagens'; + String get errorWritersideInstanceImportSelectionRequired => + 'Selecione pelo menos um arquivo Markdown para importar.'; @override - String get syntaxReferenceHtmlPreformatted => 'Código pré-formatado'; + String errorWritersideInstanceImportFileInvalid(String path) { + return 'Este não é um arquivo Markdown legível dentro da origem selecionada: $path'; + } @override - String get syntaxReferenceHtmlDisclosure => 'Blocos expansíveis'; + String errorWritersideInstanceImportTargetExists(String path) { + return 'A importação substituiria um arquivo existente do projeto: $path'; + } @override - String get syntaxReferenceHtmlDescriptionLists => 'Listas de descrição'; + String get errorWritersideInstanceFilesChanged => + 'Os arquivos da instância foram alterados no disco. Revise-os e tente novamente.'; @override - String get syntaxReferenceHtmlFormattingTags => 'Tags de formatação'; + String errorWritersideInstanceRollbackFailed(String paths) { + return 'O BusyMark não conseguiu reverter completamente a alteração da instância. Revise estes arquivos antes de continuar: $paths'; + } @override - String get syntaxReferenceHtmlInlineCodeTags => 'Tags de código em linha'; + String get errorWritersideInstanceLibraryImport => + 'Uma biblioteca de sumário não pode importar tópicos Markdown.'; @override - String get syntaxReferenceHtmlNeutralInlineTags => 'Tags de texto semântico'; + String get errorWritersideInstanceWebPathInvalid => + 'O caminho web deve ter uma única linha.'; @override - String get syntaxReferenceSanitizedPreviewDescription => - 'O HTML permitido é convertido em blocos de pré-visualização do BusyMark e não é renderizado em um navegador.'; + String get errorWritersideInstanceConfigurationInvalid => + 'A configuração da instância do Writerside é inválida. Corrija os diagnósticos e tente novamente.'; @override - String get syntaxReferenceSourcePreservedDescription => - 'HTML bruto não editado é salvo exatamente como texto fonte.'; + String get errorWritersideInstanceTemporaryFile => + 'O BusyMark não conseguiu preparar com segurança as alterações da instância.'; @override - String get syntaxReferenceMarkdownInsideHtmlDescription => - 'Marcadores Markdown dentro de HTML bruto são exibidos como texto literal.'; + String diagnosticWritersideTreeInvalidStatus(String status) { + return 'Estado de instância desconhecido “$status”. Use release, eap ou deprecated.'; + } @override - String get syntaxReferenceBlockedContentDescription => - 'Scripts, estilos, frames, formulários, SVG, MathML, eventos e atributos inseguros são bloqueados.'; + String diagnosticWritersideDuplicateInstanceId(String id) { + return 'O ID de instância “$id” é usado por mais de um arquivo de árvore.'; + } @override - String get syntaxReferenceSafeUrlsDescription => - 'Links permitem http, https, mailto, tel, URLs relativas e fragmentos; esquemas inseguros são bloqueados.'; + String get diagnosticWritersideBuildProfilesInvalidRoot => + 'buildprofiles.xml deve ter um elemento raiz <buildprofiles>.'; @override - String get syntaxReferenceCategory => 'Categoria'; + String diagnosticWritersideBuildProfilesInvalidBoolean( + String name, + String value, + ) { + return 'O valor $name “$value” deve ser true ou false.'; + } @override - String get syntaxReferenceCategoryHtml => 'HTML'; + String get diagnosticWritersideBuildProfileMissingInstance => + 'Um elemento <build-profile> deve especificar um ID de instância.'; @override - String get syntaxReferenceCategoryDiagramsAndApi => 'Diagramas e API'; + String get diagnosticWritersideTreeInvalidInclude => + 'Um <include> da árvore deve especificar from e element-id.'; @override - String get syntaxReferenceCategoryMathematics => 'Matemática'; + String get diagnosticWritersideTreeMissingSnippetId => + 'Um <snippet> da árvore deve especificar um id.'; @override - String get syntaxReferenceExample => 'Exemplo'; + String get diagnosticWritersideTreeInvalidCrossInstanceReference => + 'Uma referência de sumário entre instâncias deve especificar ref e in.'; @override - String get syntaxReferenceIdentifiers => 'Identificadores e aliases'; + String get diagnosticWritersideTreeConflictingTargets => + 'Um elemento do sumário não pode apontar para mais do que um tópico, referência, link ou redirecionamento.'; @override - String get syntaxReferenceScope => 'Escopo'; + String diagnosticWritersideTreeDuplicateElementId(String id) { + return 'O ID de elemento da árvore “$id” foi declarado mais de uma vez.'; + } @override - String get syntaxReferenceLimitation => 'Limitação do BusyMark'; + String get diagnosticWritersideInstanceGroupsInvalidRoot => + 'O arquivo de grupos de instâncias deve ter um elemento raiz <instance-groups>.'; @override - String get syntaxReferenceOfficialDocumentation => 'Documentação oficial'; + String get diagnosticWritersideInstanceGroupInvalid => + 'Um grupo de instâncias deve especificar um id e uma lista de instâncias não vazios.'; @override - String get syntaxReferenceScopeWritersideMarkdown => - 'Somente Markdown do Writerside'; + String diagnosticWritersideInstanceGroupDuplicateId(String id) { + return 'O ID do grupo de instâncias “$id” foi declarado mais de uma vez.'; + } @override - String get syntaxReferenceScopeWritersideMarkdownAndXml => - 'Somente Markdown e XML do Writerside'; + String diagnosticWritersideExternalTreeInclude( + String source, + String id, + String origin, + ) { + return 'A inclusão de sumário “$source#$id” pertence ao módulo externo “$origin” e não pode ser expandida neste espaço de trabalho.'; + } @override - String get syntaxReferenceMarkdownDescription => - 'As formas essenciais de Markdown que o BusyMark pode criar e visualizar.'; + String diagnosticWritersideTreeIncludeElementMissing( + String source, + String id, + ) { + return 'O elemento de árvore “$id” não existe na árvore registrada “$source”.'; + } @override - String get syntaxReferenceParagraphExample => 'Um parágrafo de texto.'; + String diagnosticWritersideTreeCircularInclude(String source, String id) { + return 'A inclusão de árvore “$source#$id” cria um ciclo.'; + } @override - String get syntaxReferenceTableLimitation => - 'As tabelas usam a sintaxe de barras verticais do GitHub Flavored Markdown.'; + String diagnosticWritersideUnknownInstanceGroup(String group) { + return 'A condição de instância referencia o grupo desconhecido “@$group”.'; + } @override - String get syntaxReferenceHardBreakIdentifiers => - 'dois espaços no fim da linha, \\, <br>'; + String diagnosticWritersideReferenceInstanceMissing(String instance) { + return 'A referência entre instâncias aponta para a instância desconhecida “$instance”.'; + } @override - String get syntaxReferenceHtmlDescription => - 'O BusyMark aceita um subconjunto seguro e específico de HTML bruto no código Markdown.'; + String diagnosticWritersideReferenceTopicMissing( + String topic, + String instance, + ) { + return 'O tópico “$topic” não está na instância referenciada “$instance”.'; + } @override - String get syntaxReferenceDiagramsDescription => - 'Blocos cercados Mermaid, PlantUML, D2 e OpenAPI funcionam no código Markdown. Os identificadores não diferenciam maiúsculas de minúsculas, e o BusyMark preserva a grafia original.'; + String get download => 'Baixar'; @override - String get syntaxReferenceMermaid => 'Mermaid'; + String get exportWritersideAsPdf => 'Exportar Writerside como PDF'; @override - String get syntaxReferencePlantUml => 'PlantUML'; + String get writersidePdfContent => 'Conteúdo da exportação'; @override - String get syntaxReferenceD2 => 'D2'; + String get writersidePdfPage => 'Página'; @override - String get syntaxReferenceOpenApi => 'OpenAPI'; + String get exportingWritersidePdf => 'Exportando PDF do Writerside…'; @override - String get syntaxReferenceOpenApiLimitation => - 'Use conteúdo YAML ou JSON em um bloco cercado. O BusyMark não trata um documento YAML ou JSON completo qualquer como referência OpenAPI.'; + String get ai => 'IA'; @override - String get syntaxReferenceSemanticDiagramBlocks => - 'Blocos de código semânticos para diagramas'; + String get aiLocalOllama => 'Ollama local'; @override - String get syntaxReferenceSemanticDiagramLimitation => - 'As formas semânticas code-block e src aceitam Mermaid, PlantUML e D2, não OpenAPI, e somente em projetos Writerside.'; + String get aiDisabled => 'Desativado'; @override - String get syntaxReferenceReferencedDiagramSource => - 'Origem de diagrama referenciada'; + String get aiExplicitEditingDescription => + 'A edição com IA é iniciada apenas de forma explícita. O BusyMark envia somente o contexto exibido ao provedor selecionado e nunca aplica uma proposta sem revisão.'; @override - String get syntaxReferenceReferencedDiagramLimitation => - 'Os caminhos devem ser relativos e permanecer dentro do projeto Writerside aberto; a forma de bloco cercado com src é exclusiva do Markdown do Writerside.'; + String get aiProvider => 'Provedor de IA'; @override - String get syntaxReferenceMathematicsDescription => - 'O BusyMark aceita expressões TeX, não documentos TeX ou LaTeX completos.'; + String get aiDefaultProvider => 'Provedor predefinido'; @override - String get syntaxReferenceInlineMath => 'Matemática em linha'; + String get aiConfigureProvider => 'Configurar provedor'; @override - String get syntaxReferenceGithubMath => - 'Matemática do GitHub com cifrão e crase'; + String get aiChooseProvider => 'Escolher provedor de IA'; @override - String get syntaxReferenceDisplayMath => 'Matemática em bloco'; + String get aiOllamaEndpoint => 'Endpoint do Ollama'; @override - String get syntaxReferenceMathFence => 'Bloco cercado math'; + String get aiOllamaModel => 'Modelo do Ollama'; @override - String get syntaxReferenceTexFence => 'Bloco cercado tex'; + String get aiTestConnection => 'Testar conexão'; @override - String get syntaxReferenceMathDelimitersLimitation => - 'O BusyMark não reconhece \\(...\\) nem \\[...\\] como delimitadores matemáticos do Markdown.'; + String get aiTestingConnection => 'Testando…'; @override - String get syntaxReferenceTexFenceLimitation => - 'Fora do modo Writerside, um bloco tex continua sendo um bloco de código comum.'; + String aiConnectionReady(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Foram encontrados $count modelos instalados', + one: 'Foi encontrado 1 modelo instalado', + ); + return 'Conectado. $_temp0.'; + } @override - String get syntaxReferenceWritersideMathElement => - 'Elemento math do Writerside'; + String get aiNoModels => 'Nenhum modelo selecionado.'; @override - String get syntaxReferenceWritersideMathElementLimitation => - 'O elemento math é sintaxe semântica do Writerside, não MathML HTML bruto permitido.'; + String get aiConnectionFailed => + 'O BusyMark não conseguiu verificar a geração de texto por IA.'; @override - String get syntaxReferenceSemanticTexBlock => 'Bloco de código TeX semântico'; + String get aiConfigureFirst => + 'Ative um provedor de IA e verifique um modelo em Configurações → IA.'; @override - String get syntaxReferenceWritersideDescription => - 'Estas extensões específicas são interpretadas somente em projetos Writerside abertos.'; + String get aiEditWithAi => 'Editar com IA'; @override - String get syntaxReferenceAdmonitionBlockquote => 'Citação de aviso'; + String get aiRefineWithAi => 'Melhorar com IA'; @override - String get syntaxReferenceAdmonitionLimitation => - 'Uma citação simples é uma dica no Markdown do Writerside; no Markdown comum, continua sendo uma citação normal.'; + String get aiInstruction => 'Instrução'; @override - String get syntaxReferenceSemanticAdmonitions => 'Avisos semânticos'; + String get aiChangeTarget => 'O que pode ser alterado'; @override - String get syntaxReferenceSemanticMarkupLimitation => - 'O Markdown comum não interpreta estes elementos semânticos do Writerside.'; + String get aiSharedContext => 'Contexto compartilhado com a IA'; @override - String get syntaxReferenceCollapsibleHeading => 'Título recolhível'; + String get aiTargetSelection => 'Conteúdo selecionado'; @override - String get syntaxReferenceCollapsibleCode => 'Bloco de código recolhível'; + String get aiTargetInsertAfterBlock => 'Inserir após o bloco atual'; @override - String get syntaxReferenceSemanticCollapsibles => - 'Conteúdo semântico recolhível'; + String get aiTargetCurrentBlock => 'Bloco atual'; @override - String get syntaxReferenceSemanticCollapsiblesLimitation => - 'O BusyMark aceita as formas recolhíveis chapter, procedure, code-block e lista de definições, não todo o catálogo do Writerside.'; + String get aiTargetCurrentSection => 'Seção atual'; @override - String get syntaxReferenceSemanticCodeBlocks => - 'Blocos de código semânticos para matemática e diagramas'; + String get aiTargetCompleteDocument => 'Documento completo'; @override - String get syntaxReferenceVideo => 'Vídeo do Writerside'; + String get aiContextNone => 'Sem contexto do documento'; @override - String get syntaxReferenceVideoLimitation => - 'Vídeo local usa uma imagem preview-src local; fontes hospedadas devem ser URLs HTTPS compatíveis do YouTube ou Vimeo.'; + String get aiContextSelection => 'Conteúdo selecionado'; @override - String get exportAsPdf => 'Exportar como PDF'; + String get aiContextCurrentBlock => 'Bloco atual'; @override - String get pdfExportDescription => - 'Escolha o esquema da página para criar um PDF bem acabado e independente.'; + String get aiContextCurrentSection => 'Seção atual'; @override - String get pdfRemoteImagesNote => - 'As imagens remotas não são transferidas durante a exportação. As imagens locais são incluídas quando disponíveis.'; + String get aiContextCompleteDocument => 'Documento completo'; @override - String get pdfPageSize => 'Tamanho da página'; + String get aiGenerating => 'Gerando proposta…'; @override - String get pdfPageSizeA4 => 'A4'; + String get aiProposal => 'Proposta de IA'; @override - String get pdfPageSizeLetter => 'Carta'; + String get aiGenerateProposal => 'Gerar proposta'; @override - String get pdfOrientation => 'Orientação'; + String aiContextDisclosure(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count caracteres', + one: '1 caractere', + zero: '0 caracteres', + ); + return 'O provedor selecionado receberá $_temp0 do contexto exibido.'; + } @override - String get pdfPortrait => 'Vertical'; + String get aiOriginal => 'Texto original'; @override - String get pdfLandscape => 'Horizontal'; + String get aiSuggested => 'Sugestão'; @override - String get pdfMargins => 'Margens'; + String get aiApplyProposal => 'Aplicar proposta'; @override - String get pdfMarginNarrow => 'Estreitas'; + String aiTokenUsage(int input, int output) { + return '$input tokens de entrada · $output tokens de saída'; + } @override - String get pdfMarginNormal => 'Normais'; + String get aiStaleProposal => + 'O documento foi alterado enquanto esta proposta era gerada. Execute a ação novamente.'; @override - String get pdfMarginWide => 'Largas'; + String get gitAiStagedChangesChanged => + 'As alterações preparadas mudaram enquanto esta mensagem de commit era gerada. Execute a ação novamente.'; @override - String get pdfIncludePageNumbers => 'Incluir números de página'; + String get aiViewContext => 'Ver contexto enviado'; @override - String get export => 'Exportar'; + String get aiReviewExactContent => 'Revisar conteúdo exato'; @override - String get exportFormat => 'Formato de saída'; + String get aiContentToChange => 'Conteúdo a alterar'; @override - String get exportingPdf => 'Exportando PDF…'; + String get aiContentSentToAi => 'Conteúdo enviado à IA'; @override - String get fileTypePdf => 'Documento PDF'; + String get aiApiKey => 'Chave de API'; @override - String pdfExported(String fileName) { - return '$fileName foi exportado.'; - } + String get aiApiKeyStoredHint => + 'Uma chave está armazenada no cofre de credenciais do sistema'; @override - String pdfExportedWithWarnings(String fileName, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count avisos', - one: '1 aviso', - ); - return '$fileName foi exportado com $_temp0.'; - } + String get aiApiKeyEnterHint => 'Insira uma chave de API do provedor'; @override - String get pdfExportUnavailable => - 'O componente de exportação para PDF está ausente. Reinstale o BusyMark e tente novamente.'; + String get aiReplaceApiKey => 'Substituir chave de API'; @override - String get pdfExportTimedOut => - 'A exportação para PDF demorou demasiado e foi interrompida.'; + String get aiSaveApiKey => 'Salvar chave de API com segurança'; @override - String get pdfExportFailed => - 'O BusyMark não conseguiu exportar este documento como PDF.'; + String get aiRemoveApiKey => 'Remover chave de API salva'; @override - String get visualizationRendering => 'Renderizando…'; + String get aiCredentialSaved => + 'A chave de API foi salva no cofre de credenciais do sistema.'; @override - String get visualizationStale => 'Exibindo a última renderização válida'; + String get aiCredentialRemoved => 'A chave de API salva foi removida.'; @override - String get visualizationShowSource => 'Mostrar código-fonte'; + String get aiModelRouting => 'Seleção de modelo'; @override - String get visualizationShowRender => 'Mostrar renderização'; + String get aiAutomaticRouting => 'Automática conforme a tarefa'; @override - String get visualizationFitWidth => 'Ajustar à largura'; + String get aiFixedModelRouting => 'Usar o modelo selecionado'; @override - String get visualizationSaveImage => 'Salvar imagem'; + String get aiPreferredModel => 'Modelo preferido'; @override - String get visualizationCopyImage => 'Copiar imagem'; + String get aiModel => 'Modelo'; @override - String get visualizationImageCopied => 'Imagem copiada'; + String aiUsageThisMonth(int requests, int input, int output) { + return '$requests solicitações · $input tokens de entrada · $output tokens de saída'; + } @override - String get visualizationOpenApiReference => 'Abrir referência da API'; + String aiCloudConsentTitle(String provider) { + return 'Enviar conteúdo para $provider?'; + } @override - String get visualizationValid => 'Válido'; + String aiCloudConsentEnable(String provider) { + return 'Ativar $provider'; + } @override - String get visualizationInvalid => 'Inválido'; + String get aiCloudConsentMessage => + 'Somente o conteúdo exibido em cada caixa de diálogo de revisão de IA é enviado. As solicitações não mantêm estado, as propostas exigem revisão e a chave de API é armazenada no cofre de credenciais do sistema Linux.'; @override - String get visualizationServers => 'Servidores'; + String aiCloudConsentRequired(String provider) { + return 'Primeiro, confirme o compartilhamento de dados com $provider em Configurações → IA.'; + } @override - String get visualizationPaths => 'Caminhos'; + String aiGenerationVerified(String model, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Há $count modelos compatíveis disponíveis', + one: 'Há 1 modelo compatível disponível', + zero: 'Há 0 modelos compatíveis disponíveis', + ); + return 'Geração verificada com $model. $_temp0.'; + } @override - String get visualizationOperations => 'Operações'; + String get aiColdStartObserved => + 'Foi detetado um arranque a frio do modelo local.'; @override - String get visualizationTags => 'Etiquetas'; + String get aiNoCompatibleModels => + 'Não há nenhum modelo compatível de geração de texto disponível.'; @override - String get visualizationNoOperations => 'Nenhuma operação correspondente'; + String get aiEnableProvider => 'Primeiro, ative um provedor de IA.'; @override - String get visualizationSearchOperations => 'Pesquisar operações'; + String get aiDraftCommitMessage => 'Criar rascunho da mensagem de commit'; @override - String get visualizationRenderFailed => - 'Não foi possível renderizar esta visualização.'; + String get aiDrafting => 'Criando rascunho…'; @override - String get visualizationRetry => 'Tentar novamente'; + String get aiDraftWithAi => 'Criar rascunho com IA'; @override - String visualizationSaved(String fileName) { - return '$fileName salvo'; + String diagnosticMarkdownHeadingSkippedLevel(int level, int previousLevel) { + return 'O título de nível $level vem após o nível $previousLevel; revise o aninhamento das seções.'; } @override - String get shortcutExportPdfDescription => - 'Exportar o documento ativo ou o módulo do Writerside como PDF.'; + String get diagnosticMarkdownLinkEmptyText => + 'O texto do link está vazio; forneça um nome acessível que descreva sua finalidade.'; @override - String get instances => 'Instâncias'; + String diagnosticMarkdownLinkReviewText(String text) { + return 'Verifique se o texto do link “$text” descreve sua finalidade no contexto.'; + } @override - String get newInstance => 'Nova instância'; + String get diagnosticMarkdownTableEmptyHeader => + 'Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.'; @override - String get newTocLibrary => 'Nova biblioteca de sumário'; + String get mathRenderFailed => + 'Não foi possível renderizar a expressão matemática.'; @override - String get editInstance => 'Editar instância'; + String get inlineMath => 'Matemática em linha'; @override - String get openTocFile => 'Abrir arquivo do índice'; + String get displayMath => 'Matemática em bloco'; @override - String get createInstance => 'Criar instância'; + String get goToDeclaration => 'Ir para a declaração'; @override - String get createTocLibrary => 'Criar biblioteca de sumário'; + String get findUsages => 'Encontrar usos'; @override - String get instanceContent => 'Conteúdo'; + String get cannotRenameSymbol => + 'Não é possível renomear o símbolo com segurança. Verifique o nome e atualize a referência antes de tentar novamente.'; @override - String get instanceContentSource => 'Criar a partir de'; + String get renameTopicFileInstead => + 'Os IDs dos tópicos vêm dos nomes dos arquivos. Use Renomear arquivo do tópico no sumário ou na barra lateral Arquivos.'; @override - String get emptyInstance => 'Instância vazia'; + String get keyboardLayout => 'Layout do teclado'; @override - String get markdownFiles => 'Arquivos Markdown locais'; + String get diagnosticWritersideUnsupported => + 'O conteúdo não compatível ou malformado é exibido como código-fonte.'; @override - String get chooseMarkdownFolder => 'Escolher pasta de Markdown'; + String diagnosticWritersideSource(String reference, String reason) { + return 'Não é possível resolver a origem “$reference”: $reason'; + } @override - String get errorWritersideInstanceImportSourceRequired => - 'Escolha uma pasta que contenha arquivos Markdown.'; + String diagnosticWritersideLink(String destination) { + return 'O destino do link não está disponível nesta instância: $destination'; + } @override - String get instanceAppearance => 'Aparência'; + String diagnosticWritersideSchema( + String element, + String attribute, + String reason, + ) { + return 'Marcação Writerside inválida: $element, $attribute. $reason'; + } @override - String get instanceColor => 'Cor do ícone'; + String get diagnosticWritersideLlmsTxt => + 'Use <llms-txt>true</llms-txt> ou <llms-txt>false</llms-txt>; single-file não é mais compatível.'; @override - String get instanceVersion => 'Versão'; + String diagnosticWritersideReference(String kind, String reference) { + return 'Não é possível resolver $kind: $reference'; + } @override - String instanceVersionInherited(String version) { - return 'Quando este campo está vazio, é usada a versão do projeto $version.'; - } + String get exportAsHtml => 'Exportar como HTML…'; @override - String get instanceWebPath => 'Caminho web'; + String get fileTypeHtml => 'Documento HTML'; @override - String get instanceStatus => 'Estado'; + String get exportingHtml => 'Exportando HTML…'; @override - String get instanceStatusRelease => 'Lançamento'; + String get htmlExported => 'Exportação HTML concluída'; @override - String get instanceStatusEap => 'Acesso antecipado'; + String get htmlExportFailed => 'Falha na exportação HTML'; @override - String get instanceStatusDeprecated => 'Obsoleta'; + String get htmlKeepAssetsTogether => + 'Ao copiar esta exportação, mantenha juntos o arquivo HTML e a pasta de recursos associada.'; @override - String get allowSearchEngineIndexing => - 'Permitir indexação por mecanismos de busca'; + String get htmlInstanceDescription => + 'Exporte uma instância como páginas vinculadas offline. Escolha um nome para uma pasta de destino exclusiva.'; @override - String get allowSearchEngineIndexingDescription => - 'Permita que mecanismos de busca externos indexem esta saída.'; + String get htmlInstance => 'Instância'; @override - String get offlineArtifact => 'Artefato offline'; + String get htmlOwnedDirectoryOnly => + 'Só é possível substituir uma pasta criada pela exportação HTML do BusyMark. Os arquivos modificados ou alheios serão preservados.'; @override - String get offlineArtifactDescription => - 'Inclua os recursos para que a documentação gerada funcione de forma autônoma.'; + String get showInFolder => 'Mostrar na pasta'; @override - String get instanceOutputSettings => 'Configurações de saída'; + String sortTableColumn(String column) { + return 'Ordenar $column'; + } @override - String get markdownImportSource => 'Origem Markdown'; + String get exportReset => 'Restaurar padrões'; @override - String get markdownImportFiles => 'Arquivos Markdown'; + String get exportToc => 'Incluir índice'; @override - String get selectNone => 'Não selecionar nenhum'; + String get exportTocDepth => 'Profundidade do índice'; @override - String markdownFilesFound(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Foram encontrados $count arquivos Markdown', - one: 'Foi encontrado 1 arquivo Markdown', - ); - return '$_temp0'; - } + String get exportNumberHeadings => 'Numerar títulos'; @override - String get noMarkdownFilesFound => - 'Não foram encontrados arquivos Markdown neste diretório.'; + String get exportLegal => 'US Legal'; @override - String get copyReferencedMedia => 'Copiar mídia referenciada'; + String get exportCustom => 'Personalizado'; @override - String get copyReferencedMediaDescription => - 'Copie imagens e vídeos locais referenciados pelos arquivos selecionados, preservando os caminhos relativos.'; + String get exportPageWidth => 'Largura da página (mm)'; @override - String get instanceIdRenameWarningTitle => 'Mudar o nome do ID da instância?'; + String get exportPageHeight => 'Altura da página (mm)'; @override - String instanceIdRenameWarning(String oldId, String newId) { - return 'O BusyMark vai renomear o arquivo .tree e atualizar as referências do projeto Writerside de “$oldId” para “$newId”. Os scripts de publicação não são alterados e devem ser atualizados separadamente.'; - } + String get exportMarginTop => 'Margem superior (mm)'; @override - String get renameAndUpdateReferences => - 'Mudar o nome e atualizar referências'; + String get exportMarginRight => 'Margem direita (mm)'; @override - String get tocLibraryDescription => - 'Uma biblioteca de sumário armazena seções reutilizáveis e não produz uma saída própria.'; + String get exportMarginBottom => 'Margem inferior (mm)'; @override - String get defaultTocLibraryName => 'Sumário compartilhado'; + String get exportMarginLeft => 'Margem esquerda (mm)'; @override - String get instanceColorAutomatic => 'Automático'; + String get exportTypography => 'Tipografia'; @override - String get instanceColorBlue => 'Azul'; + String get exportSans => 'Sem serifas'; @override - String get instanceColorGreen => 'Verde'; + String get exportSerif => 'Com serifas'; @override - String get instanceColorOrange => 'Laranja'; + String get exportBodySize => 'Tamanho do texto'; @override - String get instanceColorPurple => 'Roxo'; + String get exportCodeSize => 'Tamanho do código'; @override - String get instanceColorRed => 'Vermelho'; + String get exportRunningText => 'Cabeçalho e rodapé'; @override - String get instanceColorTeal => 'Verde-azulado'; + String get exportHeader => 'Cabeçalho'; @override - String get instanceColorYellow => 'Amarelo'; + String get exportFooter => 'Rodapé'; @override - String get errorWritersideInstanceNameRequired => - 'Digite um nome para a instância.'; + String get exportNone => 'Nenhum'; @override - String errorWritersideInstanceIdExists(String id) { - return 'Já existe uma instância com o ID “$id”.'; - } + String get exportDocumentTitle => 'Título do documento'; @override - String errorWritersideInstanceTreeExists(String path) { - return 'A árvore da instância já existe: $path'; - } + String get exportBottomLeft => 'Embaixo à esquerda'; @override - String errorWritersideInstanceImportSourceMissing(String path) { - return 'O diretório de origem Markdown não existe: $path'; - } + String get exportBottomCenter => 'Embaixo ao centro'; @override - String get errorWritersideInstanceImportSelectionRequired => - 'Selecione pelo menos um arquivo Markdown para importar.'; + String get exportBottomRight => 'Embaixo à direita'; @override - String errorWritersideInstanceImportFileInvalid(String path) { - return 'Este não é um arquivo Markdown legível dentro da origem selecionada: $path'; - } + String get exportFirstPage => 'Mostrar cabeçalho e rodapé na primeira página'; @override - String errorWritersideInstanceImportTargetExists(String path) { - return 'A importação substituiria um arquivo existente do projeto: $path'; - } + String get exportAccent => 'Cor de destaque / links'; @override - String get errorWritersideInstanceFilesChanged => - 'Os arquivos da instância foram alterados no disco. Revise-os e tente novamente.'; + String get exportTheme => 'Tema'; @override - String errorWritersideInstanceRollbackFailed(String paths) { - return 'O BusyMark não conseguiu reverter completamente a alteração da instância. Revise estes arquivos antes de continuar: $paths'; - } + String get exportAutomatic => 'Automático'; @override - String get errorWritersideInstanceLibraryImport => - 'Uma biblioteca de sumário não pode importar tópicos Markdown.'; + String get exportLayout => 'Layout'; @override - String get errorWritersideInstanceWebPathInvalid => - 'O caminho web deve ter uma única linha.'; + String get exportContentWidth => 'Largura máxima do conteúdo'; @override - String get errorWritersideInstanceConfigurationInvalid => - 'A configuração da instância do Writerside é inválida. Corrija os diagnósticos e tente novamente.'; + String get exportOutput => 'Saída'; @override - String get errorWritersideInstanceTemporaryFile => - 'O BusyMark não conseguiu preparar com segurança as alterações da instância.'; + String get exportPackaging => 'Empacotamento'; @override - String diagnosticWritersideTreeInvalidStatus(String status) { - return 'Estado de instância desconhecido “$status”. Use release, eap ou deprecated.'; - } + String get exportSingleFile => 'Um único arquivo HTML'; @override - String diagnosticWritersideDuplicateInstanceId(String id) { - return 'O ID de instância “$id” é usado por mais de um arquivo de árvore.'; - } + String get exportAssetsDirectory => 'HTML + pasta de recursos'; @override - String get diagnosticWritersideBuildProfilesInvalidRoot => - 'buildprofiles.xml deve ter um elemento raiz <buildprofiles>.'; + String get exportCustomCss => 'Folha de estilo personalizada…'; + + @override + String get exportRemoveCss => 'Remover folha de estilo'; @override - String diagnosticWritersideBuildProfilesInvalidBoolean( - String name, - String value, - ) { - return 'O valor $name “$value” deve ser true ou false.'; - } + String get exportCssNote => + 'Escolha um arquivo CSS UTF-8 opcional (até 256 KiB). Importações, URL de recursos e CSS executável ou com escapes são rejeitados. Os estilos seguem os do BusyMark. Nunca são baixados recursos remotos.'; @override - String get diagnosticWritersideBuildProfileMissingInstance => - 'Um elemento <build-profile> deve especificar um ID de instância.'; + String get exportSitePackagingNote => + 'O Writerside mantém páginas de tópicos separadas e vinculadas. O modo de arquivo único incorpora os recursos em cada página.'; @override - String get diagnosticWritersideTreeInvalidInclude => - 'Um <include> da árvore deve especificar from e element-id.'; + String get exportInvalidGeometry => + 'Deixe pelo menos 20 mm de largura e altura de conteúdo após as margens.'; @override - String get diagnosticWritersideTreeMissingSnippetId => - 'Um <snippet> da árvore deve especificar um id.'; + String get exportInvalidColor => 'Insira uma cor como #RRGGBB.'; @override - String get diagnosticWritersideTreeInvalidCrossInstanceReference => - 'Uma referência de sumário entre instâncias deve especificar ref e in.'; + String get exportInvalidCss => + 'Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.'; @override - String get diagnosticWritersideTreeConflictingTargets => - 'Um elemento do sumário não pode apontar para mais do que um tópico, referência, link ou redirecionamento.'; + String get clipboardHistory => 'Histórico da Área de Transferência'; @override - String diagnosticWritersideTreeDuplicateElementId(String id) { - return 'O ID de elemento da árvore “$id” foi declarado mais de uma vez.'; - } + String get clipboardHistorySearchHint => + 'Pesquisar no histórico da área de transferência'; @override - String get diagnosticWritersideInstanceGroupsInvalidRoot => - 'O arquivo de grupos de instâncias deve ter um elemento raiz <instance-groups>.'; + String get clipboardHistoryDescription => + 'Abrir o histórico da área de transferência local da sessão.'; @override - String get diagnosticWritersideInstanceGroupInvalid => - 'Um grupo de instâncias deve especificar um id e uma lista de instâncias não vazios.'; + String get clipboardCurrent => 'Área de transferência atual'; @override - String diagnosticWritersideInstanceGroupDuplicateId(String id) { - return 'O ID do grupo de instâncias “$id” foi declarado mais de uma vez.'; - } + String get clipboardCurrentExternal => + 'Área de transferência de outro aplicativo'; @override - String diagnosticWritersideExternalTreeInclude( - String source, - String id, - String origin, - ) { - return 'A inclusão de sumário “$source#$id” pertence ao módulo externo “$origin” e não pode ser expandida neste espaço de trabalho.'; + String clipboardDestination(String name) { + return 'Colar em $name'; } @override - String diagnosticWritersideTreeIncludeElementMissing( - String source, - String id, - ) { - return 'O elemento de árvore “$id” não existe na árvore registrada “$source”.'; - } + String get clipboardSessionOnly => + 'Armazenado apenas para esta sessão BusyMark.'; @override - String diagnosticWritersideTreeCircularInclude(String source, String id) { - return 'A inclusão de árvore “$source#$id” cria um ciclo.'; - } + String get clipboardNoItems => + 'O histórico da área de transferência está vazio.'; @override - String diagnosticWritersideUnknownInstanceGroup(String group) { - return 'A condição de instância referencia o grupo desconhecido “@$group”.'; - } + String get clipboardNoMatches => + 'Nenhum item da área de transferência corresponde a esta pesquisa.'; @override - String diagnosticWritersideReferenceInstanceMissing(String instance) { - return 'A referência entre instâncias aponta para a instância desconhecida “$instance”.'; - } + String get clipboardDisabled => + 'A coleção de Histórico da Área de Transferência está desativada nas Configurações.'; @override - String diagnosticWritersideReferenceTopicMissing( - String topic, - String instance, - ) { - return 'O tópico “$topic” não está na instância referenciada “$instance”.'; - } + String get clipboardUnavailable => + 'O conteúdo da área de transferência atual não está disponível ou não é suportado.'; @override - String get download => 'Baixar'; + String get clipboardTooLarge => + 'Este item da área de transferência é muito grande para ser armazenado.'; @override - String get exportWritersideAsPdf => 'Exportar Writerside como PDF'; + String get clipboardPastePlainText => 'Colar como Texto Simples'; @override - String get writersidePdfContent => 'Conteúdo da exportação'; + String get clipboardClearAll => 'Limpar o Histórico da Área de Transferência'; @override - String get writersidePdfPage => 'Página'; + String get clipboardEntryText => 'Texto'; @override - String get exportingWritersidePdf => 'Exportando PDF do Writerside…'; + String get clipboardEntryRichText => 'Texto formatado'; @override - String get ai => 'IA'; + String get clipboardEntryImage => 'Imagem'; @override - String get aiLocalOllama => 'Ollama local'; + String clipboardOrigin(String name) { + return 'Copiado de $name'; + } @override - String get aiDisabled => 'Desativado'; + String get localHistory => 'Histórico Local'; @override - String get aiExplicitEditingDescription => - 'A edição com IA é iniciada apenas de forma explícita. O BusyMark envia somente o contexto exibido ao provedor selecionado e nunca aplica uma proposta sem revisão.'; + String get localHistoryEllipsis => 'Histórico Local…'; @override - String get aiProvider => 'Provedor de IA'; + String get localHistoryDescription => + 'Mostrar revisões locais salvas e automáticas para o documento atual.'; @override - String get aiDefaultProvider => 'Provedor predefinido'; + String get findLocalHistoryEllipsis => 'Encontrar no Histórico Local…'; @override - String get aiConfigureProvider => 'Configurar provedor'; + String get findLocalHistoryDescription => + 'Pesquisar documentos e conteúdo de revisões no Histórico Local.'; @override - String get aiChooseProvider => 'Escolher provedor de IA'; + String get localHistoryNoDocuments => + 'Nenhum documento possui Histórico Local.'; @override - String get aiOllamaEndpoint => 'Endpoint do Ollama'; + String get localHistoryNoRevisions => + 'Este documento não possui revisões no Histórico Local.'; @override - String get aiOllamaModel => 'Modelo do Ollama'; + String get localHistoryNoMatches => + 'Nenhuma revisão no Histórico Local corresponde a esta pesquisa.'; @override - String get aiTestConnection => 'Testar conexão'; + String get localHistorySearchHint => 'Pesquisar no histórico local'; @override - String get aiTestingConnection => 'Testando…'; + String get localHistorySelectedRevision => 'Revisão selecionada'; @override - String aiConnectionReady(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Foram encontrados $count modelos instalados', - one: 'Foi encontrado 1 modelo instalado', - ); - return 'Conectado. $_temp0.'; - } + String get localHistoryCurrentEditor => 'Conteúdo atual do editor'; @override - String get aiNoModels => 'Nenhum modelo selecionado.'; + String get localHistoryCurrentDisk => 'Conteúdo atual no disco'; @override - String get aiConnectionFailed => - 'O BusyMark não conseguiu verificar a geração de texto por IA.'; + String get localHistoryMissingFile => 'O arquivo original está faltando.'; @override - String get aiConfigureFirst => - 'Ative um provedor de IA e verifique um modelo em Configurações → IA.'; + String get localHistoryRestoreRevision => 'Restaurar Versão'; @override - String get aiEditWithAi => 'Editar com IA'; + String get localHistoryRestoreOriginalLocation => + 'Restaurar para a Localização Original'; @override - String get aiRefineWithAi => 'Melhorar com IA'; + String get localHistoryRestoreNewLocation => + 'Restaurar para a Nova Localização…'; @override - String get aiInstruction => 'Instrução'; + String get localHistoryRestoreChange => 'Restaurar Alteração'; @override - String get aiChangeTarget => 'O que pode ser alterado'; + String get localHistoryRestoreUnavailable => + 'Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.'; @override - String get aiSharedContext => 'Contexto compartilhado com a IA'; + String get localHistoryComparisonSimplified => + 'Esta comparação grande foi simplificada. Restaurar alterações individuais não está disponível.'; @override - String get aiTargetSelection => 'Conteúdo selecionado'; + String localHistoryOlderChange(String text) { + return 'Alteração da versão anterior: $text'; + } @override - String get aiTargetInsertAfterBlock => 'Inserir após o bloco atual'; + String localHistoryCurrentChange(String text) { + return 'Alteração do conteúdo atual: $text'; + } @override - String get aiTargetCurrentBlock => 'Bloco atual'; + String get localHistoryDeleted => 'Excluído'; @override - String get aiTargetCurrentSection => 'Seção atual'; + String get localHistoryUntitled => 'Sem Título'; @override - String get aiTargetCompleteDocument => 'Documento completo'; + String get localHistoryClearDocument => 'Limpar o Histórico deste Documento'; @override - String get aiContextNone => 'Sem contexto do documento'; + String get localHistoryClearAll => 'Limpar todo o Histórico Local'; @override - String get aiContextSelection => 'Conteúdo selecionado'; + String get localHistoryClearDocumentTitle => + 'Limpar o histórico do documento?'; @override - String get aiContextCurrentBlock => 'Bloco atual'; + String get localHistoryClearAllTitle => 'Limpar todo o Histórico Local?'; @override - String get aiContextCurrentSection => 'Seção atual'; + String get localHistoryClearConfirmation => + 'Isso remove permanentemente as revisões armazenadas. Esta ação não pode ser desfeita.'; @override - String get aiContextCompleteDocument => 'Documento completo'; + String get localHistoryWarningIndexRebuilt => + 'O índice do Histórico Local foi danificado e reconstruído a partir de revisões íntegras.'; @override - String get aiGenerating => 'Gerando proposta…'; + String localHistoryWarningUnsupportedFormat(String version) { + return 'Esta loja de Histórico Local usa o formato não suportado $version.'; + } @override - String get aiProposal => 'Proposta de IA'; + String localHistoryWarningUnavailable(String detail) { + return 'Histórico Local não está disponível: $detail'; + } @override - String get aiGenerateProposal => 'Gerar proposta'; + String get localHistoryWarningRecordingDisabled => + 'A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.'; @override - String aiContextDisclosure(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count caracteres', - one: '1 caractere', - zero: '0 caracteres', - ); - return 'O provedor selecionado receberá $_temp0 do contexto exibido.'; + String localHistoryWarningPathChange(String detail) { + return 'O Histórico Local não conseguiu gravar a alteração do caminho: $detail'; } @override - String get aiOriginal => 'Texto original'; + String localHistoryWarningDeletedPath(String detail) { + return 'O Histórico Local não conseguiu reter o caminho excluído: $detail'; + } @override - String get aiSuggested => 'Sugestão'; + String get localHistoryWarningRevisionMissing => + 'A revisão selecionada está ausente ou corrompida.'; @override - String get aiApplyProposal => 'Aplicar proposta'; + String localHistoryWarningRevisionRead(String detail) { + return 'A revisão selecionada não pode ser lida: $detail'; + } @override - String aiTokenUsage(int input, int output) { - return '$input tokens de entrada · $output tokens de saída'; + String localHistoryWarningCapture(String detail) { + return 'O Histórico Local não conseguiu capturar esta revisão: $detail'; } @override - String get aiStaleProposal => - 'O documento foi alterado enquanto esta proposta era gerada. Execute a ação novamente.'; + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } @override - String get gitAiStagedChangesChanged => - 'As alterações preparadas mudaram enquanto esta mensagem de commit era gerada. Execute a ação novamente.'; + String get localHistoryReasonBaseline => 'Linha de Base'; @override - String get aiViewContext => 'Ver contexto enviado'; + String get localHistoryReasonSaved => 'Salvo'; @override - String get aiReviewExactContent => 'Revisar conteúdo exato'; + String get localHistoryReasonAutomaticCheckpoint => 'Checkpoint automático'; @override - String get aiContentToChange => 'Conteúdo a alterar'; + String get localHistoryReasonBeforeReload => 'Antes de recarregar'; @override - String get aiContentSentToAi => 'Conteúdo enviado à IA'; + String get localHistoryReasonBeforeDiscard => 'Antes de descartar'; @override - String get aiApiKey => 'Chave de API'; + String get localHistoryReasonBeforeRestore => 'Antes da restauração'; @override - String get aiApiKeyStoredHint => - 'Uma chave está armazenada no cofre de credenciais do sistema'; + String get localHistoryReasonBeforeDelete => 'Antes da exclusão'; @override - String get aiApiKeyEnterHint => 'Insira uma chave de API do provedor'; + String get localHistoryReasonExternalChange => 'Mudança externa'; @override - String get aiReplaceApiKey => 'Substituir chave de API'; + String get settingsHistory => 'Histórico'; @override - String get aiSaveApiKey => 'Salvar chave de API com segurança'; + String get settingsClipboardHistoryTitle => + 'Coletar Histórico da Área de Transferência'; @override - String get aiRemoveApiKey => 'Remover chave de API salva'; + String get settingsClipboardHistoryDescription => + 'Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.'; @override - String get aiCredentialSaved => - 'A chave de API foi salva no cofre de credenciais do sistema.'; + String get settingsLocalHistoryTitle => 'Gravar Histórico Local'; @override - String get aiCredentialRemoved => 'A chave de API salva foi removida.'; + String get settingsLocalHistoryDescription => + 'Armazenar revisões do documento neste dispositivo para recuperação e comparação.'; @override - String get aiModelRouting => 'Seleção de modelo'; + String get settingsHistoryCheckpoint => 'Intervalo de checkpoint automático'; @override - String get aiAutomaticRouting => 'Automática conforme a tarefa'; + String get settingsHistoryRetention => 'Tempo de retenção'; @override - String get aiFixedModelRouting => 'Usar o modelo selecionado'; + String get settingsHistoryStorage => 'Armazenamento máximo'; @override - String get aiPreferredModel => 'Modelo preferido'; + String get settingsHistoryExcludedPaths => 'Caminhos excluídos'; @override - String get aiModel => 'Modelo'; + String get settingsHistoryExcludedPathsHint => + 'Um caminho absoluto por linha'; @override - String aiUsageThisMonth(int requests, int input, int output) { - return '$requests solicitações · $input tokens de entrada · $output tokens de saída'; + String settingsSecondsValue(int value) { + return '$value segundos'; } @override - String aiCloudConsentTitle(String provider) { - return 'Enviar conteúdo para $provider?'; + String settingsDaysValue(int value) { + return '$value dias'; } @override - String aiCloudConsentEnable(String provider) { - return 'Ativar $provider'; + String settingsMebibytesValue(int value) { + return '$value MiB'; } @override - String get aiCloudConsentMessage => - 'Somente o conteúdo exibido em cada caixa de diálogo de revisão de IA é enviado. As solicitações não mantêm estado, as propostas exigem revisão e a chave de API é armazenada no cofre de credenciais do sistema Linux.'; + String get spelling => 'Ortografia'; @override - String aiCloudConsentRequired(String provider) { - return 'Primeiro, confirme o compartilhamento de dados com $provider em Configurações → IA.'; - } + String get automaticSpelling => 'Verificação ortográfica automática'; @override - String aiGenerationVerified(String model, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: 'Há $count modelos compatíveis disponíveis', - one: 'Há 1 modelo compatível disponível', - zero: 'Há 0 modelos compatíveis disponíveis', - ); - return 'Geração verificada com $model. $_temp0.'; - } + String get defaultSpellingLanguage => 'Idioma ortográfico padrão'; @override - String get aiColdStartObserved => - 'Foi detetado um arranque a frio do modelo local.'; + String get projectSpellingLanguage => + 'Idioma de verificação ortográfica do projeto'; @override - String get aiNoCompatibleModels => - 'Não há nenhum modelo compatível de geração de texto disponível.'; + String get chooseSpellingLanguage => 'Escolher idioma ortográfico'; @override - String get aiEnableProvider => 'Primeiro, ative um provedor de IA.'; + String get spellingLanguageUnsetStatus => 'Idioma…'; @override - String get aiDraftCommitMessage => 'Criar rascunho da mensagem de commit'; + String get spellingOffStatus => 'Ortografia desativada'; @override - String get aiDrafting => 'Criando rascunho…'; + String get checkSpelling => 'Verificar ortografia'; @override - String get aiDraftWithAi => 'Criar rascunho com IA'; + String get ignoreSpellingOnce => 'Ignorar uma vez'; @override - String diagnosticMarkdownHeadingSkippedLevel(int level, int previousLevel) { - return 'O título de nível $level vem após o nível $previousLevel; revise o aninhamento das seções.'; - } + String get ignoreSpellingDocument => 'Ignorar tudo neste documento'; @override - String get diagnosticMarkdownLinkEmptyText => - 'O texto do link está vazio; forneça um nome acessível que descreva sua finalidade.'; + String get addPersonalSpellingWord => 'Adicionar ao dicionário pessoal'; @override - String diagnosticMarkdownLinkReviewText(String text) { - return 'Verifique se o texto do link “$text” descreve sua finalidade no contexto.'; - } + String get addProjectSpellingWord => 'Adicionar ao dicionário do projeto'; @override - String get diagnosticMarkdownTableEmptyHeader => - 'Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.'; + String get inheritSpellingLanguage => + 'Usar o idioma do projeto ou do aplicativo'; @override - String get mathRenderFailed => - 'Não foi possível renderizar a expressão matemática.'; + String get disableDocumentSpelling => 'Não verificar a ortografia'; @override - String get inlineMath => 'Matemática em linha'; + String get importSpellingDictionary => 'Importar dicionário'; @override - String get displayMath => 'Matemática em bloco'; + String get personalSpellingDictionary => 'Dicionário pessoal'; @override - String get goToDeclaration => 'Ir para a declaração'; + String get projectSpellingDictionary => 'Dicionário do projeto'; @override - String get findUsages => 'Encontrar usos'; + String get noSpellingErrors => 'Nenhum erro ortográfico'; @override - String get cannotRenameSymbol => - 'Não é possível renomear o símbolo com segurança. Verifique o nome e atualize a referência antes de tentar novamente.'; + String get noSpellingSuggestions => 'Nenhuma sugestão ortográfica'; @override - String get renameTopicFileInstead => - 'Os IDs dos tópicos vêm dos nomes dos arquivos. Use Renomear arquivo do tópico no sumário ou na barra lateral Arquivos.'; + String get spellingWordCheckFailed => + 'Não foi possível verificar esta palavra'; @override - String get keyboardLayout => 'Layout do teclado'; + String get spellingCheckFailed => 'A verificação ortográfica falhou'; @override - String get diagnosticWritersideUnsupported => - 'O conteúdo não compatível ou malformado é exibido como código-fonte.'; + String get spellingSettingsLoadFailed => + 'Não foi possível carregar as configurações de verificação ortográfica'; @override - String diagnosticWritersideSource(String reference, String reason) { - return 'Não é possível resolver a origem “$reference”: $reason'; - } + String get retrySpellingSettings => + 'Tentar carregar novamente as configurações de verificação ortográfica'; @override - String diagnosticWritersideLink(String destination) { - return 'O destino do link não está disponível nesta instância: $destination'; - } + String get spellingCheckIncomplete => + 'A verificação ortográfica está incompleta'; @override - String diagnosticWritersideSchema( - String element, - String attribute, - String reason, - ) { - return 'Marcação Writerside inválida: $element, $attribute. $reason'; - } + String get spellingDictionaryNotInstalled => 'Dicionário não instalado'; @override - String get diagnosticWritersideLlmsTxt => - 'Use <llms-txt>true</llms-txt> ou <llms-txt>false</llms-txt>; single-file não é mais compatível.'; + String spellingDictionaryNotInstalledForLanguage(String dictionary) { + return 'O dicionário de $dictionary não está instalado'; + } @override - String diagnosticWritersideReference(String kind, String reference) { - return 'Não é possível resolver $kind: $reference'; + String spellingDictionaryNotInstalledWithSize(String size) { + return 'Não instalado · $size'; } @override - String get exportAsHtml => 'Exportar como HTML…'; + String get spellingDictionaryInstalled => 'Instalado'; @override - String get fileTypeHtml => 'Documento HTML'; + String get installSpellingDictionary => 'Instalar dicionário'; @override - String get exportingHtml => 'Exportando HTML…'; + String get removeSpellingDictionary => 'Remover dicionário'; @override - String get htmlExported => 'Exportação HTML concluída'; + String get cancelSpellingDictionaryInstall => + 'Cancelar download do dicionário'; @override - String get htmlExportFailed => 'Falha na exportação HTML'; + String get retrySpellingDictionaryInstall => + 'Tentar instalar o dicionário novamente'; @override - String get htmlKeepAssetsTogether => - 'Ao copiar esta exportação, mantenha juntos o arquivo HTML e a pasta de recursos associada.'; + String spellingDictionaryDownloading(String size) { + return 'Baixando $size…'; + } @override - String get htmlInstanceDescription => - 'Exporte uma instância como páginas vinculadas offline. Escolha um nome para uma pasta de destino exclusiva.'; + String get spellingDictionaryValidating => 'Validando dicionário…'; @override - String get htmlInstance => 'Instância'; + String get spellingDictionaryInstallFailed => + 'Falha na instalação — selecione para tentar novamente'; @override - String get htmlOwnedDirectoryOnly => - 'Só é possível substituir uma pasta criada pela exportação HTML do BusyMark. Os arquivos modificados ou alheios serão preservados.'; + String spellingDictionaryInstallPrompt(String dictionary, String size) { + return 'Instalar $dictionary ($size)? Depois de instalado, ficará disponível offline até ser removido.'; + } @override - String get showInFolder => 'Mostrar na pasta'; + String get spellingDictionaryUnavailable => 'Dicionário indisponível'; @override - String sortTableColumn(String column) { - return 'Ordenar $column'; + String get spellingChecking => 'Verificando a ortografia…'; + + @override + String exportInvalidRange(String field, String minimum, String maximum) { + return '$field: insira um número entre $minimum e $maximum.'; } @override - String get exportReset => 'Restaurar padrões'; + String get spellingDictionaryRecoveryConflict => + 'O dicionário ortográfico foi alterado externamente. Uma cópia de recuperação foi preservada.'; @override - String get exportToc => 'Incluir índice'; + String get windowMinimize => 'Minimizar'; @override - String get exportTocDepth => 'Profundidade do índice'; + String get windowMaximize => 'Maximizar'; @override - String get exportNumberHeadings => 'Numerar títulos'; + String get windowRestore => 'Restaurar'; @override - String get exportLegal => 'US Legal'; + String get nextcloudNotes => 'Nextcloud Notes'; @override - String get exportCustom => 'Personalizado'; + String get nextcloudConnect => 'Conectar'; @override - String get exportPageWidth => 'Largura da página (mm)'; + String get nextcloudReconnect => 'Reconectar'; @override - String get exportPageHeight => 'Altura da página (mm)'; + String get nextcloudDisconnect => 'Desconectar'; @override - String get exportMarginTop => 'Margem superior (mm)'; + String get nextcloudServer => 'URL do servidor'; @override - String get exportMarginRight => 'Margem direita (mm)'; + String get nextcloudSignInBrowser => 'Conclua o login no navegador.'; @override - String get exportMarginBottom => 'Margem inferior (mm)'; + String get nextcloudFavorites => 'Favoritos'; @override - String get exportMarginLeft => 'Margem esquerda (mm)'; + String get nextcloudNewNote => 'Nova nota'; @override - String get exportTypography => 'Tipografia'; + String get nextcloudSavedLocally => + 'Salvo localmente · sincronização pendente'; @override - String get exportSans => 'Sem serifas'; + String get nextcloudSyncing => 'Sincronizando'; @override - String get exportSerif => 'Com serifas'; + String get nextcloudSynced => 'Sincronizado'; @override - String get exportBodySize => 'Tamanho do texto'; + String get nextcloudOffline => 'Sem conexão'; @override - String get exportCodeSize => 'Tamanho do código'; + String get nextcloudLocked => 'Bloqueado pelo servidor'; @override - String get exportRunningText => 'Cabeçalho e rodapé'; + String get nextcloudReconnectRequired => 'Reconexão necessária'; @override - String get exportHeader => 'Cabeçalho'; + String get nextcloudCreationUncertain => 'Resultado da criação incerto'; @override - String get exportFooter => 'Rodapé'; + String get nextcloudReadOnly => 'Somente leitura'; @override - String get exportNone => 'Nenhum'; + String get nextcloudTakeRemote => 'Usar versão remota'; @override - String get exportDocumentTitle => 'Título do documento'; + String get nextcloudMerge => 'Mesclar'; @override - String get exportBottomLeft => 'Embaixo à esquerda'; + String get nextcloudDeleteNote => 'Excluir nota?'; @override - String get exportBottomCenter => 'Embaixo ao centro'; + String get nextcloudWorkspaceReplaceUnavailable => + 'A substituição no espaço de trabalho está indisponível para Nextcloud Notes. Substitua em cada nota.'; @override - String get exportBottomRight => 'Embaixo à direita'; + String get nextcloudSaveLocalCopy => 'Salvar cópia local…'; @override - String get exportFirstPage => 'Mostrar cabeçalho e rodapé na primeira página'; + String get nextcloudDisconnectWarning => + 'Remover esta conta exclui notas em cache e alterações pendentes deste dispositivo. Sincronize primeiro para conservar o trabalho no servidor.'; @override - String get exportAccent => 'Cor de destaque / links'; + String get nextcloudAttachmentRetryWarning => + 'Se o envio anterior foi concluído, tentar novamente pode deixar um anexo não utilizado no servidor.'; @override - String get exportTheme => 'Tema'; + String get nextcloudAttachmentReference => 'Referência do anexo no servidor'; @override - String get exportAutomatic => 'Automático'; + String get nextcloudAddAttachment => 'Adicionar anexo…'; @override - String get exportLayout => 'Layout'; + String get nextcloudDeleteAttachmentWarning => + 'Excluir este anexo torna seus links na nota indisponíveis. Os dados preservados podem ser recuperados pelo histórico local.'; @override - String get exportContentWidth => 'Largura máxima do conteúdo'; + String get nextcloudChooseConflictingAttributes => + 'Escolha cada atributo em conflito.'; @override - String get exportOutput => 'Saída'; + String get nextcloudUseServerNote => 'Usar esta nota do servidor'; @override - String get exportPackaging => 'Empacotamento'; + 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 exportSingleFile => 'Um único arquivo HTML'; + String get nextcloudExactCandidate => + 'Correspondência exata com a solicitação; identidade não confirmada.'; @override - String get exportAssetsDirectory => 'HTML + pasta de recursos'; + String get nextcloudPossibleCandidate => + 'Possível correspondência após normalização; identidade não confirmada.'; @override - String get exportCustomCss => 'Folha de estilo personalizada…'; + String get nextcloudSelectCandidate => + 'Selecionar uma nota do servidor para revisar'; @override - String get exportRemoveCss => 'Remover folha de estilo'; + String get nextcloudCreateSeparate => 'Criar uma nota separada'; @override - String get exportCssNote => - 'Escolha um arquivo CSS UTF-8 opcional (até 256 KiB). Importações, URL de recursos e CSS executável ou com escapes são rejeitados. Os estilos seguem os do BusyMark. Nunca são baixados recursos remotos.'; + String get nextcloudCreateSeparateWarning => + 'A primeira solicitação pode já ter criado uma nota no servidor. Isso cria uma nota separada com o trabalho local preservado. Nenhuma possível duplicata será excluída.'; + + @override + String get wsSemanticFormats => 'Formatação semântica'; + + @override + String get wsUiControl => 'Controle da interface'; + + @override + String get wsFilePath => 'Arquivo / Caminho'; @override - String get exportSitePackagingNote => - 'O Writerside mantém páginas de tópicos separadas e vinculadas. O modo de arquivo único incorpora os recursos em cada página.'; + String get wsUiPath => 'Caminho da interface'; @override - String get exportInvalidGeometry => - 'Deixe pelo menos 20 mm de largura e altura de conteúdo após as margens.'; + String get wsDefinitionList => 'Lista de definições'; @override - String get exportInvalidColor => 'Insira uma cor como #RRGGBB.'; + String get wsIncludeContent => 'Incluir conteúdo reutilizável'; @override - String get exportInvalidCss => - 'Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.'; + String get wsVariableReference => 'Referência de variável'; @override - String get clipboardHistory => 'Histórico da Área de Transferência'; + String get wsConvertList => 'Converter lista em procedimento'; @override - String get clipboardHistorySearchHint => - 'Pesquisar no histórico da área de transferência'; + String get wsElementProperties => 'Propriedades do elemento'; @override - String get clipboardHistoryDescription => - 'Abrir o histórico da área de transferência local da sessão.'; + String get wsTopicProperties => 'Propriedades do tópico'; @override - String get clipboardCurrent => 'Área de transferência atual'; + String get wsCollapsible => 'Recolhível'; @override - String get clipboardCurrentExternal => - 'Área de transferência de outro aplicativo'; + String get wsPublishedState => 'Estado inicial publicado'; @override - String clipboardDestination(String name) { - return 'Colar em $name'; - } + String get wsExpanded => 'Expandido'; @override - String get clipboardSessionOnly => - 'Armazenado apenas para esta sessão BusyMark.'; + String get wsCollapsed => 'Recolhido'; @override - String get clipboardNoItems => - 'O histórico da área de transferência está vazio.'; + String get wsCollapsedTitle => 'Título recolhido'; @override - String get clipboardNoMatches => - 'Nenhum item da área de transferência corresponde a esta pesquisa.'; + String get wsTerm => 'Termo'; @override - String get clipboardDisabled => - 'A coleção de Histórico da Área de Transferência está desativada nas Configurações.'; + String get wsListLayout => 'Disposição da lista'; @override - String get clipboardUnavailable => - 'O conteúdo da área de transferência atual não está disponível ou não é suportado.'; + String get wsSyncGroup => 'Grupo de sincronização'; @override - String get clipboardTooLarge => - 'Este item da área de transferência é muito grande para ser armazenado.'; + String get wsTabKey => 'Chave do aba'; @override - String get clipboardPastePlainText => 'Colar como Texto Simples'; + String get wsSwitcherLabel => 'Etiqueta do seletor'; @override - String get clipboardClearAll => 'Limpar o Histórico da Área de Transferência'; + String get wsNavigationElements => 'Elementos de navegação'; @override - String get clipboardEntryText => 'Texto'; + String get wsNavigationDepth => 'Profundidade de navegação'; @override - String get clipboardEntryRichText => 'Texto formatado'; + String get wsAddItem => 'Adicionar item'; @override - String get clipboardEntryImage => 'Imagem'; + String get wsStep => 'Passo'; @override - String clipboardOrigin(String name) { - return 'Copiado de $name'; - } + String get wsPropertyInvalid => 'Valor de propriedade inválido'; @override - String get localHistory => 'Histórico Local'; + String get wsFilter => 'Filtro'; @override - String get localHistoryEllipsis => 'Histórico Local…'; + String get wsIdentifier => 'ID do elemento'; @override - String get localHistoryDescription => - 'Mostrar revisões locais salvas e automáticas para o documento atual.'; + String get wsTldr => 'Resumo (TLDR)'; @override - String get findLocalHistoryEllipsis => 'Encontrar no Histórico Local…'; + String get wsFullLayout => 'Largura total'; @override - String get findLocalHistoryDescription => - 'Pesquisar documentos e conteúdo de revisões no Histórico Local.'; + String get wsMediumLayout => 'Médio (1:2)'; @override - String get localHistoryNoDocuments => - 'Nenhum documento possui Histórico Local.'; + String get wsNarrowLayout => 'Coluna de termo estreita'; @override - String get localHistoryNoRevisions => - 'Este documento não possui revisões no Histórico Local.'; + String get wsTitle => 'Título'; @override - String get localHistoryNoMatches => - 'Nenhuma revisão no Histórico Local corresponde a esta pesquisa.'; + String get wsSwitcherKey => 'Chave do seletor'; @override - String get localHistorySearchHint => 'Pesquisar no histórico local'; + String get nextcloudServerSettings => 'Configurações de Notes do servidor'; @override - String get localHistorySelectedRevision => 'Revisão selecionada'; + String get nextcloudNotesPath => 'Caminho da pasta de notas'; @override - String get localHistoryCurrentEditor => 'Conteúdo atual do editor'; + String get nextcloudFileSuffix => 'Extensão de novas notas'; @override - String get localHistoryCurrentDisk => 'Conteúdo atual no disco'; + String get nextcloudServerSettingsExplanation => + 'Estas configurações de Notes afetam outros clientes. Alterar o caminho não move arquivos; alterar a extensão não renomeia notas existentes.'; @override - String get localHistoryMissingFile => 'O arquivo original está faltando.'; + String get nextcloudSettingsSaved => 'Configurações do servidor salvas'; @override - String get localHistoryRestoreRevision => 'Restaurar Versão'; + String get nextcloudSettingsNormalized => + 'Salvo com valores normalizados pelo servidor'; @override - String get localHistoryRestoreOriginalLocation => - 'Restaurar para a Localização Original'; + String get nextcloudPendingUpload => 'Envio de anexo pendente'; @override - String get localHistoryRestoreNewLocation => - 'Restaurar para a Nova Localização…'; + String nextcloudServerCheck(String time) { + return 'Última verificação do servidor: $time'; + } @override - String get localHistoryRestoreChange => 'Restaurar Alteração'; + String get nextcloudSettingsLoading => 'Carregando configurações do servidor'; @override - String get localHistoryRestoreUnavailable => - 'Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.'; + String get nextcloudSettingsSaving => 'Salvando configurações do servidor'; @override - String get localHistoryComparisonSimplified => - 'Esta comparação grande foi simplificada. Restaurar alterações individuais não está disponível.'; + String get nextcloudPreviousLocalEdit => 'Edição local anterior'; @override - String localHistoryOlderChange(String text) { - return 'Alteração da versão anterior: $text'; - } + String get nextcloudUsePreviousEdit => 'Usar edição anterior'; @override - String localHistoryCurrentChange(String text) { - return 'Alteração do conteúdo atual: $text'; - } + String get quickOpen => 'Abertura rápida'; @override - String get localHistoryDeleted => 'Excluído'; + String get notesAll => 'Todas as notas'; @override - String get localHistoryUntitled => 'Sem Título'; + String get notesUncategorized => 'Sem categoria'; @override - String get localHistoryClearDocument => 'Limpar o Histórico deste Documento'; + String get notesRecovery => 'Recuperação'; @override - String get localHistoryClearAll => 'Limpar todo o Histórico Local'; + String get notesRecentlyEdited => 'Editadas recentemente'; @override - String get localHistoryClearDocumentTitle => - 'Limpar o histórico do documento?'; + String get notesOldestEdited => 'Edições mais antigas'; @override - String get localHistoryClearAllTitle => 'Limpar todo o Histórico Local?'; + String get notesTitleAscending => 'Título A–Z'; @override - String get localHistoryClearConfirmation => - 'Isso remove permanentemente as revisões armazenadas. Esta ação não pode ser desfeita.'; + String get notesTitleDescending => 'Título Z–A'; @override - String get localHistoryWarningIndexRebuilt => - 'O índice do Histórico Local foi danificado e reconstruído a partir de revisões íntegras.'; + String get notesMove => 'Mover para categoria'; @override - String localHistoryWarningUnsupportedFormat(String version) { - return 'Esta loja de Histórico Local usa o formato não suportado $version.'; - } + String get notesRemoveFavorite => 'Remover favorito'; @override - String localHistoryWarningUnavailable(String detail) { - return 'Histórico Local não está disponível: $detail'; - } + String get notesOffline => 'Disponibilizar offline'; @override - String get localHistoryWarningRecordingDisabled => - 'A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.'; + String get notesOfflineRemove => 'Remover retenção offline'; @override - String localHistoryWarningPathChange(String detail) { - return 'O Histórico Local não conseguiu gravar a alteração do caminho: $detail'; - } + String get notesIndexing => 'Indexando notas…'; @override - String localHistoryWarningDeletedPath(String detail) { - return 'O Histórico Local não conseguiu reter o caminho excluído: $detail'; - } + String get notesSearchHelp => + 'Os termos usam E. Use \"frases entre aspas\", title: e category:.'; @override - String get localHistoryWarningRevisionMissing => - 'A revisão selecionada está ausente ou corrompida.'; + String get notesSourceLocation => 'Localização no código fonte'; @override - String localHistoryWarningRevisionRead(String detail) { - return 'A revisão selecionada não pode ser lida: $detail'; - } + String get notesRecoveryHelp => + 'Apenas notas locais retidas e histórico local. O lixo e o histórico do servidor não estão disponíveis.'; @override - String localHistoryWarningCapture(String detail) { - return 'O Histórico Local não conseguiu capturar esta revisão: $detail'; - } + String get notesImport => 'Importar notas'; @override - String localHistoryRevisionAt(String reason, String timestamp) { - return '$reason – $timestamp'; - } + String get notesExport => 'Exportar notas'; @override - String get localHistoryReasonBaseline => 'Linha de Base'; + String get notesImportDistinct => 'Importar como nota distinta'; @override - String get localHistoryReasonSaved => 'Salvo'; + String get notesAvailableOffline => 'Disponível offline'; @override - String get localHistoryReasonAutomaticCheckpoint => 'Checkpoint automático'; + String get notesIncomplete => 'Incompleto'; @override - String get localHistoryReasonBeforeReload => 'Antes de recarregar'; + String get notesSelected => 'Notas selecionadas'; @override - String get localHistoryReasonBeforeDiscard => 'Antes de descartar'; + String get notesNoHistory => + 'Não há revisões retidas. O histórico pode estar desativado, expirado ou apagado.'; @override - String get localHistoryReasonBeforeRestore => 'Antes da restauração'; + String get notesTextAvailable => 'Texto disponível localmente'; @override - String get localHistoryReasonBeforeDelete => 'Antes da exclusão'; + String get notesLocalVersion => 'Versão de trabalho local'; @override - String get localHistoryReasonExternalChange => 'Mudança externa'; + String get notesAttachments => 'Anexos'; @override - String get settingsHistory => 'Histórico'; + String get notesSkipped => 'Ignorado'; @override - String get settingsClipboardHistoryTitle => - 'Coletar Histórico da Área de Transferência'; + String get notesFailed => 'Falhou'; @override - String get settingsClipboardHistoryDescription => - 'Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.'; + String get notesCompleted => 'Concluído'; @override - String get settingsLocalHistoryTitle => 'Gravar Histórico Local'; + String get notesImportNew => 'Iniciar nova importação'; @override - String get settingsLocalHistoryDescription => - 'Armazenar revisões do documento neste dispositivo para recuperação e comparação.'; + String get notesImportResume => 'Retomar importação interrompida'; @override - String get settingsHistoryCheckpoint => 'Intervalo de checkpoint automático'; + String get notesImportAlreadyImported => 'Já importado nesta operação'; @override - String get settingsHistoryRetention => 'Tempo de retenção'; + String get references => 'Referências'; @override - String get settingsHistoryStorage => 'Armazenamento máximo'; + String get referenceIncoming => 'Recebidas'; @override - String get settingsHistoryExcludedPaths => 'Caminhos excluídos'; + String get referenceOutgoing => 'Enviadas'; @override - String get settingsHistoryExcludedPathsHint => - 'Um caminho absoluto por linha'; + String get referenceChooseTarget => 'Escolher destino do link'; @override - String settingsSecondsValue(int value) { - return '$value segundos'; - } + String get referenceWholeDocument => 'Documento completo'; @override - String settingsDaysValue(int value) { - return '$value dias'; - } + String get referenceSourceLocation => 'Local de origem'; @override - String settingsMebibytesValue(int value) { - return '$value MiB'; - } + String get referenceTargetLocation => 'Local de destino'; @override - String get spelling => 'Ortografia'; + String get referenceResolved => 'Resolvida'; @override - String get automaticSpelling => 'Verificação ortográfica automática'; + String get referenceMissing => 'Ausente'; @override - String get defaultSpellingLanguage => 'Idioma ortográfico padrão'; + String get referenceAmbiguous => 'Ambígua'; @override - String get projectSpellingLanguage => - 'Idioma de verificação ortográfica do projeto'; + String get referenceUnavailable => 'Indisponível neste contexto'; @override - String get chooseSpellingLanguage => 'Escolher idioma ortográfico'; + String get referenceUnsupported => 'Não compatível'; @override - String get spellingLanguageUnsetStatus => 'Idioma…'; + String get referenceNoPortableId => + 'Este destino não tem uma URL portátil de nota verificada.'; @override - String get spellingOffStatus => 'Ortografia desativada'; + String get wsIncludeLines => 'Incluir linhas'; @override - String get checkSpelling => 'Verificar ortografia'; + String get wsIncludeSymbol => 'Incluir símbolo'; @override - String get ignoreSpellingOnce => 'Ignorar uma vez'; + String get wsEmphasizeLines => 'Realçar linhas exibidas'; @override - String get ignoreSpellingDocument => 'Ignorar tudo neste documento'; + String get wsPropertiesReadOnly => + 'Propriedades somente leitura nesta visualização.'; @override - String get addPersonalSpellingWord => 'Adicionar ao dicionário pessoal'; + String get wsPropertiesProtected => 'Elemento protegido. Edite sua fonte.'; @override - String get addProjectSpellingWord => 'Adicionar ao dicionário do projeto'; + String get wsPropertiesConflict => + 'Documento ou elemento alterado. Reverta o rascunho e selecione novamente.'; @override - String get inheritSpellingLanguage => - 'Usar o idioma do projeto ou do aplicativo'; + String get wsPropertiesUnresolved => + 'Não é possível resolver a referência no momento.'; @override - String get disableDocumentSpelling => 'Não verificar a ortografia'; + String get wsUseDefault => 'Usar padrão'; @override - String get importSpellingDictionary => 'Importar dicionário'; + String get wsPropertiesSelection => 'Selecione um único elemento.'; @override - String get personalSpellingDictionary => 'Dicionário pessoal'; + String get wsImageDisplay => 'Exibição de imagem'; @override - String get projectSpellingDictionary => 'Dicionário do projeto'; + String get wsImageThumbnail => 'Miniatura clicável'; @override - String get noSpellingErrors => 'Nenhum erro ortográfico'; + String get wsEnabled => 'Ativado'; @override - String get noSpellingSuggestions => 'Nenhuma sugestão ortográfica'; + String get wsDisabled => 'Desativado'; @override - String get spellingWordCheckFailed => - 'Não foi possível verificar esta palavra'; + String get wsWidth => 'Largura'; @override - String get spellingCheckFailed => 'A verificação ortográfica falhou'; + String get wsHeight => 'Altura'; @override - String get spellingSettingsLoadFailed => - 'Não foi possível carregar as configurações de verificação ortográfica'; + String get wsPropertiesInactive => + 'Esta propriedade não tem efeito sem recolhimento publicado.'; @override - String get retrySpellingSettings => - 'Tentar carregar novamente as configurações de verificação ortográfica'; + String get wsNavigationAll => 'Todos os elementos com título'; @override - String get spellingCheckIncomplete => - 'A verificação ortográfica está incompleta'; + String get wsNavigationNone => 'Sem navegação'; @override - String get spellingDictionaryNotInstalled => 'Dicionário não instalado'; + String get wsNavigationCustom => 'Navegação personalizada'; @override - String spellingDictionaryNotInstalledForLanguage(String dictionary) { - return 'O dicionário de $dictionary não está instalado'; - } + String get wsTitleOverride => 'Substituir título'; @override - String spellingDictionaryNotInstalledWithSize(String size) { - return 'Não instalado · $size'; - } + String get wsCompactLayout => 'Compacto'; @override - String get spellingDictionaryInstalled => 'Instalado'; + String get wsWideLayout => 'Largo'; @override - String get installSpellingDictionary => 'Instalar dicionário'; + String get wsCodeSourceConflict => + 'Mova ou remova o código escrito neste bloco antes de definir src. O Writerside rejeita um bloco de código não vazio com uma referência de origem.'; @override - String get removeSpellingDictionary => 'Remover dicionário'; + String get wsInherited => 'Herdar da lista'; @override - String get cancelSpellingDictionaryInstall => - 'Cancelar download do dicionário'; + String get wsExternalImageSize => + 'Writerside não dimensiona imagens externas de forma confiável. As dimensões existentes podem ser consultadas e removidas.'; @override - String get retrySpellingDictionaryInstall => - 'Tentar instalar o dicionário novamente'; + String get wsPropertiesNotSerialized => + 'Esta alteração não foi aplicada ao código fonte. O rascunho foi mantido.'; @override - String spellingDictionaryDownloading(String size) { - return 'Baixando $size…'; - } + String get wsListItem => 'Item da lista'; @override - String get spellingDictionaryValidating => 'Validando dicionário…'; + String get wsSourceFile => 'Arquivo de origem'; @override - String get spellingDictionaryInstallFailed => - 'Falha na instalação — selecione para tentar novamente'; + String get wsLinkTarget => 'Destino do link'; @override - String spellingDictionaryInstallPrompt(String dictionary, String size) { - return 'Instalar $dictionary ($size)? Depois de instalado, ficará disponível offline até ser removido.'; - } + String get wsPreviewImage => 'Imagem de prévia'; @override - String get spellingDictionaryUnavailable => 'Dicionário indisponível'; + String get wsIncludedElement => 'Elemento incluído'; @override - String get spellingChecking => 'Verificando a ortografia…'; + String get wsHelpModule => 'Módulo de ajuda'; @override - String exportInvalidRange(String field, String minimum, String maximum) { - return '$field: insira um número entre $minimum e $maximum.'; - } + String get wsActionId => 'ID da ação'; @override - String get spellingDictionaryRecoveryConflict => - 'O dicionário ortográfico foi alterado externamente. Uma cópia de recuperação foi preservada.'; + String get wsKeymapHelpInstance => 'Instância de ajuda do mapa de teclas'; @override - String get windowMinimize => 'Minimizar'; + String get wsShowInSource => 'Mostrar no Fonte'; @override - String get windowMaximize => 'Maximizar'; + String get wsOriginElement => 'Valor do elemento'; @override - String get windowRestore => 'Restaurar'; + String get wsOriginAncestor => 'Elemento pai'; @override - String get nextcloudNotes => 'Nextcloud Notes'; + String get wsOriginModule => 'Padrão do módulo'; @override - String get nextcloudConnect => 'Conectar'; + String get wsOriginBuiltIn => 'Padrão integrado'; @override - String get nextcloudReconnect => 'Reconectar'; + String get wsOriginUnresolved => 'Não resolvido'; @override - String get nextcloudDisconnect => 'Desconectar'; + String wsEffectiveProperty(String value, String origin) { + return 'Efetivo: $value — $origin'; + } @override - String get nextcloudServer => 'URL do servidor'; + String get wsElementSelector => 'Elemento'; @override - String get nextcloudSignInBrowser => 'Conclua o login no navegador.'; + String wsGeneratedAnchorValue(String anchor) { + return 'Gerado: $anchor'; + } @override - String get nextcloudFavorites => 'Favoritos'; + String wsDefaultProperty(String value) { + return 'Padrão: $value'; + } @override - String get nextcloudNewNote => 'Nova nota'; + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Padrão: $value · $origin'; + } @override - String get nextcloudSavedLocally => - 'Salvo localmente · sincronização pendente'; + String wsResolvedProperty(String value) { + return 'Resolvido: $value'; + } @override - String get nextcloudSyncing => 'Sincronizando'; + String get wsPropertyUnresolved => 'Não resolvido'; @override - String get nextcloudSynced => 'Sincronizado'; + String get wsModule => 'Módulo'; @override - String get nextcloudOffline => 'Sem conexão'; + String get wsParent => 'Elemento pai'; @override - String get nextcloudLocked => 'Bloqueado pelo servidor'; + String get wsListType => 'Tipo de lista'; @override - String get nextcloudReconnectRequired => 'Reconexão necessária'; + String get wsStartingNumber => 'Número inicial'; @override - String get nextcloudCreationUncertain => 'Resultado da criação incerto'; + String get wsListSorting => 'Ordenação'; @override - String get nextcloudReadOnly => 'Somente leitura'; + String get wsLetteredList => 'Lista alfabética'; @override - String get nextcloudTakeRemote => 'Usar versão remota'; + String get wsCheckboxList => 'Lista de caixas de seleção'; @override - String get nextcloudMerge => 'Mesclar'; + String get wsMarkerlessList => 'Lista sem marcadores'; @override - String get nextcloudDeleteNote => 'Excluir nota?'; + String get wsAscending => 'Crescente'; @override - String get nextcloudWorkspaceReplaceUnavailable => - 'A substituição no espaço de trabalho está indisponível para Nextcloud Notes. Substitua em cada nota.'; + String get wsDescending => 'Decrescente'; @override - String get nextcloudSaveLocalCopy => 'Salvar cópia local…'; + String get wsNoSorting => 'Sem ordenação'; @override - String get nextcloudDisconnectWarning => - 'Remover esta conta exclui notas em cache e alterações pendentes deste dispositivo. Sincronize primeiro para conservar o trabalho no servidor.'; + String get wsAsWritten => 'Como escrito'; @override - String get nextcloudAttachmentRetryWarning => - 'Se o envio anterior foi concluído, tentar novamente pode deixar um anexo não utilizado no servidor.'; + String get wsSentenceCase => 'Maiúscula inicial'; @override - String get nextcloudAttachmentReference => 'Referência do anexo no servidor'; + String get wsTitleCase => 'Maiúsculas de título'; @override - String get nextcloudAddAttachment => 'Adicionar anexo…'; + String get wsLowerCase => 'Minúsculas'; @override - String get nextcloudDeleteAttachmentWarning => - 'Excluir este anexo torna seus links na nota indisponíveis. Os dados preservados podem ser recuperados pelo histórico local.'; + String get wsUpperCase => 'Maiúsculas'; @override - String get nextcloudChooseConflictingAttributes => - 'Escolha cada atributo em conflito.'; + String get wsNativeMarkers => 'Marcadores Markdown'; @override - String get nextcloudUseServerNote => 'Usar esta nota do servidor'; + String wsListMarkerStart(String number) { + return 'Marcador inicial: $number'; + } @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.'; + String wsListLevel(String level) { + return 'Nível $level'; + } @override - String get nextcloudExactCandidate => - 'Correspondência exata com a solicitação; identidade não confirmada.'; + String get wsModuleCapsIgnored => + 'O compilador fixado ignora as regras <caps> do módulo. Use um atributo caps local ou default-property.'; @override - String get nextcloudPossibleCandidate => - 'Possível correspondência após normalização; identidade não confirmada.'; + String get wsInsertElements => 'Inserir elementos'; @override - String get nextcloudSelectCandidate => - 'Selecionar uma nota do servidor para revisar'; + String get wsAdmonitionsAndQuotes => 'Avisos e citações'; @override - String get nextcloudCreateSeparate => 'Criar uma nota separada'; + String get wsSemanticText => 'Texto semântico'; @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.'; + String get wsPropertiesUnavailable => + 'O elemento inspecionado está indisponível. Inspecione um elemento para continuar.'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 68d792a0..1f63a5fd 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Правило регистра модуля не публикуется.'; + + @override + String get wsTitleOverridesAction => 'Заголовки экземпляра и оглавления…'; + + @override + String get wsHeadingNotPublished => 'Не публикуется этим сборщиком.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Закреплённый builder не публикует заголовки экземпляров в темах Markdown. Разметка сохраняется; проверьте исходник.'; + + @override + String get wsTopicIdentity => 'ID темы'; + + @override + String get wsElementAnchor => 'Якорь элемента'; + + @override + String get wsHeadingText => 'Текст заголовка'; + + @override + String get wsDefaultTitle => 'Заголовок по умолчанию'; + + @override + String get wsTopicNavigation => 'Навигация темы'; + + @override + String get wsHeadingOptions => 'Атрибуты заголовка'; + + @override + String get wsDocumentOptions => 'Настройки документа'; + + @override + String get wsSettingsPresent => 'Есть явные настройки'; + + @override + String get wsTitleOverrideExplanation => + 'Переопределяет опубликованный заголовок; текст заголовка не меняется.'; + + @override + String get wsIgnoreVariables => 'Считать переменные буквальным текстом'; + + @override + String get wsIgnoreVariablesExplanation => + 'Если включено, текст, похожий на переменные, не подставляется.'; + + @override + String get wsInstanceSelection => 'Экземпляры справки'; + + @override + String get wsInstanceExplanation => + 'Выберите выпуски справки по именам, группам, спискам, исключениям или выражениям.'; + + @override + String get wsFilterExplanation => + 'Фильтрует повторно используемое содержимое по критериям; не выбирает экземпляр справки.'; + + @override + String get wsContextHelp => 'ID контекстной справки'; + + @override + String get wsCapitalization => 'Регистр'; + + @override + String get wsLibraryTopic => 'Библиотечная тема'; + + @override + String get wsLastModified => 'Последнее изменение'; + + @override + String get wsMissingTopicHeading => + 'H1 темы отсутствует. Добавьте его в исходнике.'; + + @override + String get wsMultipleTopicHeadings => + 'Первый H1 определяет заголовок темы. Остальные H1 — отдельные заголовки.'; + + @override + String get wsTopicIdentityMismatch => + 'Корневой id отличается от ID по имени файла. Используйте переименование файла темы или проверьте исходник.'; + + @override + String get wsUnsupportedProperties => + 'Неподдерживаемые атрибуты сохраняются. Проверьте их в исходнике.'; + + @override + String get wsGeneratedAnchor => + 'Сгенерированный якорь; значение создаёт переопределение.'; + + @override + String get wsHeadingBuilderLimitation => + 'Закреплённый builder не публикует атрибуты H1. Они принадлежат заголовку, а не корню XML.'; + @override String get closeOtherTabs => 'Закрыть другие вкладки'; @@ -4847,4 +4943,517 @@ class AppLocalizationsRu extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Первый запрос мог уже создать заметку на сервере. Будет создана отдельная заметка с сохранённой локальной работой. Предполагаемые дубликаты не будут удалены.'; + + @override + String get wsSemanticFormats => 'Семантическое форматирование'; + + @override + String get wsUiControl => 'Элемент интерфейса'; + + @override + String get wsFilePath => 'Файл / Путь'; + + @override + String get wsUiPath => 'Путь в интерфейсе'; + + @override + String get wsDefinitionList => 'Список определений'; + + @override + String get wsIncludeContent => 'Включить повторно используемый контент'; + + @override + String get wsVariableReference => 'Ссылка на переменную'; + + @override + String get wsConvertList => 'Преобразовать список в процедуру'; + + @override + String get wsElementProperties => 'Свойства элемента'; + + @override + String get wsTopicProperties => 'Свойства темы'; + + @override + String get wsCollapsible => 'Сворачиваемый'; + + @override + String get wsPublishedState => 'Начальное состояние публикации'; + + @override + String get wsExpanded => 'Развёрнуто'; + + @override + String get wsCollapsed => 'Свёрнуто'; + + @override + String get wsCollapsedTitle => 'Заголовок в свёрнутом виде'; + + @override + String get wsTerm => 'Термин'; + + @override + String get wsListLayout => 'Макет списка'; + + @override + String get wsSyncGroup => 'Группа синхронизации'; + + @override + String get wsTabKey => 'Ключ вкладки'; + + @override + String get wsSwitcherLabel => 'Подпись переключателя'; + + @override + String get wsNavigationElements => 'Элементы навигации'; + + @override + String get wsNavigationDepth => 'Глубина навигации'; + + @override + String get wsAddItem => 'Добавить элемент'; + + @override + String get wsStep => 'Шаг'; + + @override + String get wsPropertyInvalid => 'Недопустимое значение свойства'; + + @override + String get wsFilter => 'Фильтр'; + + @override + String get wsIdentifier => 'Идентификатор элемента'; + + @override + String get wsTldr => 'Краткое содержание (TLDR)'; + + @override + String get wsFullLayout => 'Полная ширина'; + + @override + String get wsMediumLayout => 'Средний (1:2)'; + + @override + String get wsNarrowLayout => 'Узкий столбец термина'; + + @override + String get wsTitle => 'Заголовок'; + + @override + String get wsSwitcherKey => 'Ключ переключателя'; + + @override + String get nextcloudServerSettings => 'Настройки Notes на сервере'; + + @override + String get nextcloudNotesPath => 'Путь к папке заметок'; + + @override + String get nextcloudFileSuffix => 'Расширение новых заметок'; + + @override + String get nextcloudServerSettingsExplanation => + 'Эти настройки Notes влияют на другие клиенты. Изменение пути не перемещает файлы; изменение расширения не переименовывает существующие заметки.'; + + @override + String get nextcloudSettingsSaved => 'Настройки сервера сохранены'; + + @override + String get nextcloudSettingsNormalized => + 'Сохранено со значениями, нормализованными сервером'; + + @override + String get nextcloudPendingUpload => 'Вложение ожидает загрузки'; + + @override + String nextcloudServerCheck(String time) { + return 'Последняя проверка сервера: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Загрузка настроек сервера'; + + @override + String get nextcloudSettingsSaving => 'Сохранение настроек сервера'; + + @override + String get nextcloudPreviousLocalEdit => 'Предыдущая локальная правка'; + + @override + String get nextcloudUsePreviousEdit => 'Использовать предыдущую правку'; + + @override + String get quickOpen => 'Быстрое открытие'; + + @override + String get notesAll => 'Все заметки'; + + @override + String get notesUncategorized => 'Без категории'; + + @override + String get notesRecovery => 'Восстановление'; + + @override + String get notesRecentlyEdited => 'Недавно изменённые'; + + @override + String get notesOldestEdited => 'Давно изменённые'; + + @override + String get notesTitleAscending => 'Название А–Я'; + + @override + String get notesTitleDescending => 'Название Я–А'; + + @override + String get notesMove => 'Переместить в категорию'; + + @override + String get notesRemoveFavorite => 'Убрать из избранного'; + + @override + String get notesOffline => 'Сохранить для работы без сети'; + + @override + String get notesOfflineRemove => 'Убрать сохранение без сети'; + + @override + String get notesIndexing => 'Индексация заметок…'; + + @override + String get notesSearchHelp => + 'Термины объединяются И. Используйте \"фразы в кавычках\", title: и category:.'; + + @override + String get notesSourceLocation => 'Место в исходнике'; + + @override + String get notesRecoveryHelp => + 'Только сохранённые локальные заметки и локальная история. Корзина и история сервера недоступны.'; + + @override + String get notesImport => 'Импорт заметок'; + + @override + String get notesExport => 'Экспорт заметок'; + + @override + String get notesImportDistinct => 'Импортировать как отдельную заметку'; + + @override + String get notesAvailableOffline => 'Доступно без сети'; + + @override + String get notesIncomplete => 'Неполно'; + + @override + String get notesSelected => 'Выбранные заметки'; + + @override + String get notesNoHistory => + 'Нет сохранённых версий. История может быть отключена, устарела или очищена.'; + + @override + String get notesTextAvailable => 'Текст доступен локально'; + + @override + String get notesLocalVersion => 'Локальная рабочая версия'; + + @override + String get notesAttachments => 'Вложения'; + + @override + String get notesSkipped => 'Пропущено'; + + @override + String get notesFailed => 'Ошибка'; + + @override + String get notesCompleted => 'Завершено'; + + @override + String get notesImportNew => 'Начать новый импорт'; + + @override + String get notesImportResume => 'Продолжить прерванный импорт'; + + @override + String get notesImportAlreadyImported => 'Уже импортировано в этой операции'; + + @override + String get references => 'Ссылки'; + + @override + String get referenceIncoming => 'Входящие'; + + @override + String get referenceOutgoing => 'Исходящие'; + + @override + String get referenceChooseTarget => 'Выберите цель ссылки'; + + @override + String get referenceWholeDocument => 'Весь документ'; + + @override + String get referenceSourceLocation => 'Положение источника'; + + @override + String get referenceTargetLocation => 'Положение цели'; + + @override + String get referenceResolved => 'Найдена'; + + @override + String get referenceMissing => 'Отсутствует'; + + @override + String get referenceAmbiguous => 'Неоднозначная'; + + @override + String get referenceUnavailable => 'Недоступна в этом контексте'; + + @override + String get referenceUnsupported => 'Не поддерживается'; + + @override + String get referenceNoPortableId => + 'У этой цели нет проверенного переносимого URL заметки.'; + + @override + String get wsIncludeLines => 'Включить строки'; + + @override + String get wsIncludeSymbol => 'Включить символ'; + + @override + String get wsEmphasizeLines => 'Выделить отображаемые строки'; + + @override + String get wsPropertiesReadOnly => + 'В этом режиме свойства доступны только для чтения.'; + + @override + String get wsPropertiesProtected => 'Элемент защищён. Измените исходный код.'; + + @override + String get wsPropertiesConflict => + 'Документ или элемент изменился. Отмените черновик и выберите заново.'; + + @override + String get wsPropertiesUnresolved => 'Сейчас невозможно разрешить ссылку.'; + + @override + String get wsUseDefault => 'Использовать по умолчанию'; + + @override + String get wsPropertiesSelection => 'Выберите один элемент.'; + + @override + String get wsImageDisplay => 'Отображение изображения'; + + @override + String get wsImageThumbnail => 'Нажимаемая миниатюра'; + + @override + String get wsEnabled => 'Включено'; + + @override + String get wsDisabled => 'Выключено'; + + @override + String get wsWidth => 'Ширина'; + + @override + String get wsHeight => 'Высота'; + + @override + String get wsPropertiesInactive => + 'Свойство действует только при сворачивании в публикации.'; + + @override + String get wsNavigationAll => 'Все элементы с заголовками'; + + @override + String get wsNavigationNone => 'Без навигации'; + + @override + String get wsNavigationCustom => 'Своя навигация'; + + @override + String get wsTitleOverride => 'Переопределить заголовок'; + + @override + String get wsCompactLayout => 'Компактный'; + + @override + String get wsWideLayout => 'Широкий'; + + @override + String get wsCodeSourceConflict => + 'Переместите или удалите код, написанный в этом блоке, прежде чем задать src. Writerside отклоняет непустой блок кода со ссылкой на источник.'; + + @override + String get wsInherited => 'Наследовать от списка'; + + @override + String get wsExternalImageSize => + 'Writerside ненадёжно задаёт размеры внешних изображений. Существующие размеры можно просмотреть и удалить.'; + + @override + String get wsPropertiesNotSerialized => + 'Это изменение не записано в исходный текст. Черновик сохранён.'; + + @override + String get wsListItem => 'Элемент списка'; + + @override + String get wsSourceFile => 'Исходный файл'; + + @override + String get wsLinkTarget => 'Цель ссылки'; + + @override + String get wsPreviewImage => 'Изображение предпросмотра'; + + @override + String get wsIncludedElement => 'Включаемый элемент'; + + @override + String get wsHelpModule => 'Модуль справки'; + + @override + String get wsActionId => 'ID действия'; + + @override + String get wsKeymapHelpInstance => 'Экземпляр справки раскладки'; + + @override + String get wsShowInSource => 'Показать в исходнике'; + + @override + String get wsOriginElement => 'Значение элемента'; + + @override + String get wsOriginAncestor => 'Родительский элемент'; + + @override + String get wsOriginModule => 'По умолчанию в модуле'; + + @override + String get wsOriginBuiltIn => 'Встроенное значение'; + + @override + String get wsOriginUnresolved => 'Не разрешено'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Действующее значение: $value — $origin'; + } + + @override + String get wsElementSelector => 'Элемент'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Сгенерировано: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'По умолчанию: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'По умолчанию: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Разрешено: $value'; + } + + @override + String get wsPropertyUnresolved => 'Не разрешено'; + + @override + String get wsModule => 'Модуль'; + + @override + String get wsParent => 'Родительский элемент'; + + @override + String get wsListType => 'Тип списка'; + + @override + String get wsStartingNumber => 'Начальный номер'; + + @override + String get wsListSorting => 'Сортировка'; + + @override + String get wsLetteredList => 'Буквенный список'; + + @override + String get wsCheckboxList => 'Список с флажками'; + + @override + String get wsMarkerlessList => 'Список без маркеров'; + + @override + String get wsAscending => 'По возрастанию'; + + @override + String get wsDescending => 'По убыванию'; + + @override + String get wsNoSorting => 'Без сортировки'; + + @override + String get wsAsWritten => 'Как написано'; + + @override + String get wsSentenceCase => 'Регистр предложения'; + + @override + String get wsTitleCase => 'Регистр заголовка'; + + @override + String get wsLowerCase => 'Строчные буквы'; + + @override + String get wsUpperCase => 'Прописные буквы'; + + @override + String get wsNativeMarkers => 'Маркеры Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Начальный маркер: $number'; + } + + @override + String wsListLevel(String level) { + return 'Уровень $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Закреплённый сборщик игнорирует правила <caps> модуля. Используйте локальный атрибут caps или default-property.'; + + @override + String get wsInsertElements => 'Вставить элементы'; + + @override + String get wsAdmonitionsAndQuotes => 'Примечания и цитаты'; + + @override + String get wsSemanticText => 'Семантический текст'; + + @override + String get wsPropertiesUnavailable => + 'Проверяемый элемент недоступен. Выберите элемент для продолжения.'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index a4ef4597..e79598c2 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -10,6 +10,100 @@ import 'app_localizations.dart'; class AppLocalizationsTr extends AppLocalizations { AppLocalizationsTr([String locale = 'tr']) : super(locale); + @override + String get wsModuleCapsNotPublished => 'Modülün harf biçimi yayımlanmaz.'; + + @override + String get wsTitleOverridesAction => 'Örnek ve içindekiler başlıkları…'; + + @override + String get wsHeadingNotPublished => 'Bu oluşturucu tarafından yayımlanmaz.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Sabitlenmiş derleyici Markdown konularında örnek başlıklarını yayımlamaz. Mevcut işaretleme korunur; Kaynakta inceleyin.'; + + @override + String get wsTopicIdentity => 'Konu kimliği'; + + @override + String get wsElementAnchor => 'Öğe çapası'; + + @override + String get wsHeadingText => 'Başlık metni'; + + @override + String get wsDefaultTitle => 'Varsayılan başlık'; + + @override + String get wsTopicNavigation => 'Konu gezinmesi'; + + @override + String get wsHeadingOptions => 'Başlık öznitelikleri'; + + @override + String get wsDocumentOptions => 'Belge ayarları'; + + @override + String get wsSettingsPresent => 'Yazılmış ayarlar mevcut'; + + @override + String get wsTitleOverrideExplanation => + 'Yayımlanan başlığı geçersiz kılar; başlık metni değişmez.'; + + @override + String get wsIgnoreVariables => 'Değişkenleri düz metin olarak işle'; + + @override + String get wsIgnoreVariablesExplanation => + 'Etkinleştirildiğinde değişken benzeri metin değiştirilmez.'; + + @override + String get wsInstanceSelection => 'Yardım örnekleri'; + + @override + String get wsInstanceExplanation => + 'Yardım çıktılarını adlar, gruplar, listeler, dışlamalar veya ifadelerle seçin.'; + + @override + String get wsFilterExplanation => + 'Yeniden kullanılan içeriği ölçütlerle süzer; yardım örneği seçmez.'; + + @override + String get wsContextHelp => 'Bağlam yardımı kimliği'; + + @override + String get wsCapitalization => 'Büyük/küçük harf'; + + @override + String get wsLibraryTopic => 'Kitaplık konusu'; + + @override + String get wsLastModified => 'Son değiştirilme'; + + @override + String get wsMissingTopicHeading => 'Konu H1 başlığı yok. Kaynakta ekleyin.'; + + @override + String get wsMultipleTopicHeadings => + 'İlk H1 konu başlığını belirler. Sonraki H1’ler ayrı başlıklardır.'; + + @override + String get wsTopicIdentityMismatch => + 'Kök id, dosya adından türetilen konu kimliğinden farklı. Konu dosyasını yeniden adlandırın veya kaynağı inceleyin.'; + + @override + String get wsUnsupportedProperties => + 'Desteklenmeyen öznitelikler korunur. Kaynakta inceleyin.'; + + @override + String get wsGeneratedAnchor => + 'Oluşturulmuş çapa; bir değer geçersiz kılma oluşturur.'; + + @override + String get wsHeadingBuilderLimitation => + 'Sabitlenmiş derleyici H1 özniteliklerini yayımlamaz. XML köküne değil başlığa aittirler.'; + @override String get closeOtherTabs => 'Diğer sekmeleri kapat'; @@ -4790,4 +4884,516 @@ class AppLocalizationsTr extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'İlk istek sunucuda zaten bir not oluşturmuş olabilir. Bu işlem korunan yerel çalışmanızla ayrı bir not oluşturur. Olası kopyalar silinmez.'; + + @override + String get wsSemanticFormats => 'Anlamsal biçimlendirme'; + + @override + String get wsUiControl => 'Arayüz denetimi'; + + @override + String get wsFilePath => 'Dosya / Yol'; + + @override + String get wsUiPath => 'Arayüz yolu'; + + @override + String get wsDefinitionList => 'Tanım listesi'; + + @override + String get wsIncludeContent => 'Yeniden kullanılabilir içerik ekle'; + + @override + String get wsVariableReference => 'Değişken başvurusu'; + + @override + String get wsConvertList => 'Listeyi prosedüre dönüştür'; + + @override + String get wsElementProperties => 'Öğe özellikleri'; + + @override + String get wsTopicProperties => 'Konu özellikleri'; + + @override + String get wsCollapsible => 'Daraltılabilir'; + + @override + String get wsPublishedState => 'Yayımlanan başlangıç durumu'; + + @override + String get wsExpanded => 'Genişletilmiş'; + + @override + String get wsCollapsed => 'Daraltılmış'; + + @override + String get wsCollapsedTitle => 'Daraltılmış başlık'; + + @override + String get wsTerm => 'Terim'; + + @override + String get wsListLayout => 'Liste düzeni'; + + @override + String get wsSyncGroup => 'Eşitleme grubu'; + + @override + String get wsTabKey => 'Sekme anahtarı'; + + @override + String get wsSwitcherLabel => 'Değiştirici etiketi'; + + @override + String get wsNavigationElements => 'Gezinme öğeleri'; + + @override + String get wsNavigationDepth => 'Gezinme derinliği'; + + @override + String get wsAddItem => 'Öğe ekle'; + + @override + String get wsStep => 'Adım'; + + @override + String get wsPropertyInvalid => 'Geçersiz özellik değeri'; + + @override + String get wsFilter => 'Filtre'; + + @override + String get wsIdentifier => 'Öğe kimliği'; + + @override + String get wsTldr => 'Özet (TLDR)'; + + @override + String get wsFullLayout => 'Tam genişlik'; + + @override + String get wsMediumLayout => 'Orta (1:2)'; + + @override + String get wsNarrowLayout => 'Dar terim sütunu'; + + @override + String get wsTitle => 'Başlık'; + + @override + String get wsSwitcherKey => 'Değiştirici anahtarı'; + + @override + String get nextcloudServerSettings => 'Sunucu Notes ayarları'; + + @override + String get nextcloudNotesPath => 'Not klasörünün yolu'; + + @override + String get nextcloudFileSuffix => 'Yeni notların dosya uzantısı'; + + @override + String get nextcloudServerSettingsExplanation => + 'Bu sunucu Notes ayarları diğer istemcileri de etkiler. Yolun değiştirilmesi dosyaları taşımaz; uzantının değiştirilmesi mevcut notları yeniden adlandırmaz.'; + + @override + String get nextcloudSettingsSaved => 'Sunucu ayarları kaydedildi'; + + @override + String get nextcloudSettingsNormalized => + 'Sunucunun normalleştirdiği değerlerle kaydedildi'; + + @override + String get nextcloudPendingUpload => 'Ek yüklemesi bekliyor'; + + @override + String nextcloudServerCheck(String time) { + return 'Son sunucu kontrolü: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Sunucu ayarları yükleniyor'; + + @override + String get nextcloudSettingsSaving => 'Sunucu ayarları kaydediliyor'; + + @override + String get nextcloudPreviousLocalEdit => 'Önceki yerel düzenleme'; + + @override + String get nextcloudUsePreviousEdit => 'Önceki düzenlemeyi kullan'; + + @override + String get quickOpen => 'Hızlı aç'; + + @override + String get notesAll => 'Tüm notlar'; + + @override + String get notesUncategorized => 'Kategorisiz'; + + @override + String get notesRecovery => 'Kurtarma'; + + @override + String get notesRecentlyEdited => 'Son düzenlenen'; + + @override + String get notesOldestEdited => 'En eski düzenleme'; + + @override + String get notesTitleAscending => 'Başlık A–Z'; + + @override + String get notesTitleDescending => 'Başlık Z–A'; + + @override + String get notesMove => 'Kategoriye taşı'; + + @override + String get notesRemoveFavorite => 'Favoriyi kaldır'; + + @override + String get notesOffline => 'Çevrimdışı kullanılabilir yap'; + + @override + String get notesOfflineRemove => 'Çevrimdışı saklamayı kaldır'; + + @override + String get notesIndexing => 'Notlar dizine ekleniyor…'; + + @override + String get notesSearchHelp => + 'Terimler VE ile birleştirilir. \"Alıntı ifadeler\", title: ve category: kullanın.'; + + @override + String get notesSourceLocation => 'Kaynak konumu'; + + @override + String get notesRecoveryHelp => + 'Yalnızca saklanan yerel notlar ve yerel geçmiş. Sunucu çöp kutusu ve geçmişi kullanılamaz.'; + + @override + String get notesImport => 'Notları içe aktar'; + + @override + String get notesExport => 'Notları dışa aktar'; + + @override + String get notesImportDistinct => 'Ayrı bir not olarak içe aktar'; + + @override + String get notesAvailableOffline => 'Çevrimdışı kullanılabilir'; + + @override + String get notesIncomplete => 'Eksik'; + + @override + String get notesSelected => 'Seçilen notlar'; + + @override + String get notesNoHistory => + 'Saklanan sürüm yok. Geçmiş kapalı, süresi dolmuş veya temizlenmiş olabilir.'; + + @override + String get notesTextAvailable => 'Metin yerel olarak kullanılabilir'; + + @override + String get notesLocalVersion => 'Yerel çalışma sürümü'; + + @override + String get notesAttachments => 'Ekler'; + + @override + String get notesSkipped => 'Atlandı'; + + @override + String get notesFailed => 'Başarısız'; + + @override + String get notesCompleted => 'Tamamlandı'; + + @override + String get notesImportNew => 'Yeni içe aktarma başlat'; + + @override + String get notesImportResume => 'Kesilen içe aktarmayı sürdür'; + + @override + String get notesImportAlreadyImported => 'Bu işlemde zaten içe aktarıldı'; + + @override + String get references => 'Başvurular'; + + @override + String get referenceIncoming => 'Gelen'; + + @override + String get referenceOutgoing => 'Giden'; + + @override + String get referenceChooseTarget => 'Bağlantı hedefini seç'; + + @override + String get referenceWholeDocument => 'Tüm belge'; + + @override + String get referenceSourceLocation => 'Kaynak konumu'; + + @override + String get referenceTargetLocation => 'Hedef konumu'; + + @override + String get referenceResolved => 'Çözüldü'; + + @override + String get referenceMissing => 'Eksik'; + + @override + String get referenceAmbiguous => 'Belirsiz'; + + @override + String get referenceUnavailable => 'Bu bağlamda kullanılamıyor'; + + @override + String get referenceUnsupported => 'Desteklenmiyor'; + + @override + String get referenceNoPortableId => + 'Bu hedefin doğrulanmış taşınabilir not URL’si yok.'; + + @override + String get wsIncludeLines => 'Satırları dahil et'; + + @override + String get wsIncludeSymbol => 'Sembolü dahil et'; + + @override + String get wsEmphasizeLines => 'Görüntülenen satırları vurgula'; + + @override + String get wsPropertiesReadOnly => 'Bu görünümde özellikler salt okunurdur.'; + + @override + String get wsPropertiesProtected => 'Bu öğe korunuyor. Kaynağını düzenleyin.'; + + @override + String get wsPropertiesConflict => + 'Belge veya öğe değişti. Taslağı geri alın ve yeniden seçin.'; + + @override + String get wsPropertiesUnresolved => 'Başvuru şu anda çözümlenemiyor.'; + + @override + String get wsUseDefault => 'Varsayılanı kullan'; + + @override + String get wsPropertiesSelection => 'Tek bir öğe seçin.'; + + @override + String get wsImageDisplay => 'Görüntü görünümü'; + + @override + String get wsImageThumbnail => 'Tıklanabilir küçük resim'; + + @override + String get wsEnabled => 'Etkin'; + + @override + String get wsDisabled => 'Devre dışı'; + + @override + String get wsWidth => 'Genişlik'; + + @override + String get wsHeight => 'Yükseklik'; + + @override + String get wsPropertiesInactive => + 'Bu özellik yalnızca yayımlanan daraltma etkinken geçerlidir.'; + + @override + String get wsNavigationAll => 'Başlıklı tüm öğeler'; + + @override + String get wsNavigationNone => 'Gezinme yok'; + + @override + String get wsNavigationCustom => 'Özel gezinme'; + + @override + String get wsTitleOverride => 'Başlığı geçersiz kıl'; + + @override + String get wsCompactLayout => 'Kompakt'; + + @override + String get wsWideLayout => 'Geniş'; + + @override + String get wsCodeSourceConflict => + 'src ayarlamadan önce bu blokta yazılan kodu taşıyın veya kaldırın. Writerside, kaynak başvurusu olan boş olmayan kod bloğunu reddeder.'; + + @override + String get wsInherited => 'Listeden devral'; + + @override + String get wsExternalImageSize => + 'Writerside dış görüntülerin boyutunu güvenilir biçimde ayarlamaz. Mevcut boyutlar incelenebilir ve kaldırılabilir.'; + + @override + String get wsPropertiesNotSerialized => + 'Bu değişiklik kaynağa uygulanmadı. Taslak korundu.'; + + @override + String get wsListItem => 'Liste öğesi'; + + @override + String get wsSourceFile => 'Kaynak dosya'; + + @override + String get wsLinkTarget => 'Bağlantı hedefi'; + + @override + String get wsPreviewImage => 'Önizleme resmi'; + + @override + String get wsIncludedElement => 'Dahil edilen öğe'; + + @override + String get wsHelpModule => 'Yardım modülü'; + + @override + String get wsActionId => 'Eylem kimliği'; + + @override + String get wsKeymapHelpInstance => 'Tuş eşlemesi yardım örneği'; + + @override + String get wsShowInSource => 'Kaynakta göster'; + + @override + String get wsOriginElement => 'Öğe geçersiz kılması'; + + @override + String get wsOriginAncestor => 'Üst öğe'; + + @override + String get wsOriginModule => 'Modül varsayılanı'; + + @override + String get wsOriginBuiltIn => 'Yerleşik varsayılan'; + + @override + String get wsOriginUnresolved => 'Çözümlenemedi'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Etkin: $value — $origin'; + } + + @override + String get wsElementSelector => 'Öğe'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Oluşturulan: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Varsayılan: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Varsayılan: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Çözümlenen: $value'; + } + + @override + String get wsPropertyUnresolved => 'Çözümlenmedi'; + + @override + String get wsModule => 'Modül'; + + @override + String get wsParent => 'Üst öğe'; + + @override + String get wsListType => 'Liste türü'; + + @override + String get wsStartingNumber => 'Başlangıç numarası'; + + @override + String get wsListSorting => 'Sıralama'; + + @override + String get wsLetteredList => 'Harfli liste'; + + @override + String get wsCheckboxList => 'Onay kutulu liste'; + + @override + String get wsMarkerlessList => 'İşaretsiz liste'; + + @override + String get wsAscending => 'Artan'; + + @override + String get wsDescending => 'Azalan'; + + @override + String get wsNoSorting => 'Sıralama yok'; + + @override + String get wsAsWritten => 'Yazıldığı gibi'; + + @override + String get wsSentenceCase => 'Cümle düzeni'; + + @override + String get wsTitleCase => 'Başlık düzeni'; + + @override + String get wsLowerCase => 'Küçük harf'; + + @override + String get wsUpperCase => 'Büyük harf'; + + @override + String get wsNativeMarkers => 'Markdown işaretleri'; + + @override + String wsListMarkerStart(String number) { + return 'İlk işaret: $number'; + } + + @override + String wsListLevel(String level) { + return 'Düzey $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Sabitlenmiş derleyici modülün <caps> kurallarını yok sayar. Yerel caps özniteliği veya default-property kullanın.'; + + @override + String get wsInsertElements => 'Öğe ekle'; + + @override + String get wsAdmonitionsAndQuotes => 'Uyarılar ve alıntılar'; + + @override + String get wsSemanticText => 'Anlamsal metin'; + + @override + String get wsPropertiesUnavailable => + 'İncelenen öğe kullanılamıyor. Devam etmek için bir öğeyi inceleyin.'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 830590c4..81c4de71 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -10,6 +10,102 @@ import 'app_localizations.dart'; class AppLocalizationsUk extends AppLocalizations { AppLocalizationsUk([String locale = 'uk']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Правило регістру модуля не публікується.'; + + @override + String get wsTitleOverridesAction => 'Заголовки екземпляра та змісту…'; + + @override + String get wsHeadingNotPublished => 'Не публікується цим збирачем.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Закріплений builder не публікує заголовки примірників у темах Markdown. Розмітку збережено; перевірте джерело.'; + + @override + String get wsTopicIdentity => 'ID теми'; + + @override + String get wsElementAnchor => 'Якір елемента'; + + @override + String get wsHeadingText => 'Текст заголовка'; + + @override + String get wsDefaultTitle => 'Типовий заголовок'; + + @override + String get wsTopicNavigation => 'Навігація теми'; + + @override + String get wsHeadingOptions => 'Атрибути заголовка'; + + @override + String get wsDocumentOptions => 'Налаштування документа'; + + @override + String get wsSettingsPresent => 'Є явні налаштування'; + + @override + String get wsTitleOverrideExplanation => + 'Перевизначає опублікований заголовок; текст заголовка не змінюється.'; + + @override + String get wsIgnoreVariables => 'Вважати змінні буквальним текстом'; + + @override + String get wsIgnoreVariablesExplanation => + 'Якщо ввімкнено, текст, схожий на змінні, не підставляється.'; + + @override + String get wsInstanceSelection => 'Примірники довідки'; + + @override + String get wsInstanceExplanation => + 'Виберіть випуски за іменами, групами, списками, винятками або виразами.'; + + @override + String get wsFilterExplanation => + 'Фільтрує повторно використовуваний вміст за критеріями; не вибирає примірник довідки.'; + + @override + String get wsContextHelp => 'ID контекстної довідки'; + + @override + String get wsCapitalization => 'Регістр'; + + @override + String get wsLibraryTopic => 'Бібліотечна тема'; + + @override + String get wsLastModified => 'Остання зміна'; + + @override + String get wsMissingTopicHeading => + 'H1 теми відсутній. Додайте його у джерелі.'; + + @override + String get wsMultipleTopicHeadings => + 'Перший H1 визначає заголовок теми. Решта H1 є окремими заголовками.'; + + @override + String get wsTopicIdentityMismatch => + 'Кореневий id відрізняється від ID імені файлу. Перейменуйте файл теми або перевірте джерело.'; + + @override + String get wsUnsupportedProperties => + 'Непідтримувані атрибути збережено. Перевірте їх у джерелі.'; + + @override + String get wsGeneratedAnchor => + 'Згенерований якір; значення створює перевизначення.'; + + @override + String get wsHeadingBuilderLimitation => + 'Закріплений builder не публікує атрибути H1. Вони належать заголовку, а не кореню XML.'; + @override String get closeOtherTabs => 'Закрити інші вкладки'; @@ -4857,4 +4953,517 @@ class AppLocalizationsUk extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Перший запит міг уже створити нотатку на сервері. Буде створено окрему нотатку зі збереженою локальною роботою. Ймовірні дублікати не буде видалено.'; + + @override + String get wsSemanticFormats => 'Семантичне форматування'; + + @override + String get wsUiControl => 'Елемент інтерфейсу'; + + @override + String get wsFilePath => 'Файл / Шлях'; + + @override + String get wsUiPath => 'Шлях в інтерфейсі'; + + @override + String get wsDefinitionList => 'Список визначень'; + + @override + String get wsIncludeContent => 'Додати повторно використовуваний вміст'; + + @override + String get wsVariableReference => 'Посилання на змінну'; + + @override + String get wsConvertList => 'Перетворити список на процедуру'; + + @override + String get wsElementProperties => 'Властивості елемента'; + + @override + String get wsTopicProperties => 'Властивості теми'; + + @override + String get wsCollapsible => 'Згортання'; + + @override + String get wsPublishedState => 'Початковий стан публікації'; + + @override + String get wsExpanded => 'Розгорнуто'; + + @override + String get wsCollapsed => 'Згорнуто'; + + @override + String get wsCollapsedTitle => 'Заголовок у згорнутому стані'; + + @override + String get wsTerm => 'Термін'; + + @override + String get wsListLayout => 'Макет списку'; + + @override + String get wsSyncGroup => 'Група синхронізації'; + + @override + String get wsTabKey => 'Ключ вкладки'; + + @override + String get wsSwitcherLabel => 'Підпис перемикача'; + + @override + String get wsNavigationElements => 'Елементи навігації'; + + @override + String get wsNavigationDepth => 'Глибина навігації'; + + @override + String get wsAddItem => 'Додати елемент'; + + @override + String get wsStep => 'Крок'; + + @override + String get wsPropertyInvalid => 'Неприпустиме значення властивості'; + + @override + String get wsFilter => 'Фільтр'; + + @override + String get wsIdentifier => 'Ідентифікатор елемента'; + + @override + String get wsTldr => 'Короткий виклад (TLDR)'; + + @override + String get wsFullLayout => 'Повна ширина'; + + @override + String get wsMediumLayout => 'Середній (1:2)'; + + @override + String get wsNarrowLayout => 'Вузький стовпець терміна'; + + @override + String get wsTitle => 'Заголовок'; + + @override + String get wsSwitcherKey => 'Ключ перемикача'; + + @override + String get nextcloudServerSettings => 'Налаштування Notes на сервері'; + + @override + String get nextcloudNotesPath => 'Шлях до теки нотаток'; + + @override + String get nextcloudFileSuffix => 'Розширення нових нотаток'; + + @override + String get nextcloudServerSettingsExplanation => + 'Ці налаштування Notes впливають на інші клієнти. Зміна шляху не переміщує файли; зміна розширення не перейменовує наявні нотатки.'; + + @override + String get nextcloudSettingsSaved => 'Налаштування сервера збережено'; + + @override + String get nextcloudSettingsNormalized => + 'Збережено зі значеннями, нормалізованими сервером'; + + @override + String get nextcloudPendingUpload => 'Вкладення очікує завантаження'; + + @override + String nextcloudServerCheck(String time) { + return 'Остання перевірка сервера: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Завантаження налаштувань сервера'; + + @override + String get nextcloudSettingsSaving => 'Збереження налаштувань сервера'; + + @override + String get nextcloudPreviousLocalEdit => 'Попередня локальна зміна'; + + @override + String get nextcloudUsePreviousEdit => 'Використати попередню зміну'; + + @override + String get quickOpen => 'Швидке відкриття'; + + @override + String get notesAll => 'Усі нотатки'; + + @override + String get notesUncategorized => 'Без категорії'; + + @override + String get notesRecovery => 'Відновлення'; + + @override + String get notesRecentlyEdited => 'Нещодавно змінені'; + + @override + String get notesOldestEdited => 'Давно змінені'; + + @override + String get notesTitleAscending => 'Назва А–Я'; + + @override + String get notesTitleDescending => 'Назва Я–А'; + + @override + String get notesMove => 'Перемістити до категорії'; + + @override + String get notesRemoveFavorite => 'Вилучити з обраного'; + + @override + String get notesOffline => 'Зберегти для роботи без мережі'; + + @override + String get notesOfflineRemove => 'Вилучити збереження без мережі'; + + @override + String get notesIndexing => 'Індексація нотаток…'; + + @override + String get notesSearchHelp => + 'Терміни поєднуються І. Використовуйте \"фрази в лапках\", title: та category:.'; + + @override + String get notesSourceLocation => 'Місце в джерелі'; + + @override + String get notesRecoveryHelp => + 'Лише збережені локальні нотатки та локальна історія. Кошик та історія сервера недоступні.'; + + @override + String get notesImport => 'Імпорт нотаток'; + + @override + String get notesExport => 'Експорт нотаток'; + + @override + String get notesImportDistinct => 'Імпортувати як окрему нотатку'; + + @override + String get notesAvailableOffline => 'Доступно без мережі'; + + @override + String get notesIncomplete => 'Неповно'; + + @override + String get notesSelected => 'Вибрані нотатки'; + + @override + String get notesNoHistory => + 'Немає збережених версій. Історію може бути вимкнено, прострочено або очищено.'; + + @override + String get notesTextAvailable => 'Текст доступний локально'; + + @override + String get notesLocalVersion => 'Локальна робоча версія'; + + @override + String get notesAttachments => 'Вкладення'; + + @override + String get notesSkipped => 'Пропущено'; + + @override + String get notesFailed => 'Помилка'; + + @override + String get notesCompleted => 'Завершено'; + + @override + String get notesImportNew => 'Почати новий імпорт'; + + @override + String get notesImportResume => 'Продовжити перерваний імпорт'; + + @override + String get notesImportAlreadyImported => 'Уже імпортовано в цій операції'; + + @override + String get references => 'Посилання'; + + @override + String get referenceIncoming => 'Вхідні'; + + @override + String get referenceOutgoing => 'Вихідні'; + + @override + String get referenceChooseTarget => 'Виберіть ціль посилання'; + + @override + String get referenceWholeDocument => 'Увесь документ'; + + @override + String get referenceSourceLocation => 'Позиція джерела'; + + @override + String get referenceTargetLocation => 'Позиція цілі'; + + @override + String get referenceResolved => 'Знайдено'; + + @override + String get referenceMissing => 'Відсутнє'; + + @override + String get referenceAmbiguous => 'Неоднозначне'; + + @override + String get referenceUnavailable => 'Недоступне в цьому контексті'; + + @override + String get referenceUnsupported => 'Не підтримується'; + + @override + String get referenceNoPortableId => + 'Ця ціль не має перевіреної переносної URL-адреси нотатки.'; + + @override + String get wsIncludeLines => 'Включити рядки'; + + @override + String get wsIncludeSymbol => 'Включити символ'; + + @override + String get wsEmphasizeLines => 'Виділити показані рядки'; + + @override + String get wsPropertiesReadOnly => + 'У цьому режимі властивості доступні лише для читання.'; + + @override + String get wsPropertiesProtected => 'Елемент захищено. Змініть його джерело.'; + + @override + String get wsPropertiesConflict => + 'Документ або елемент змінився. Скасуйте чернетку й виберіть знову.'; + + @override + String get wsPropertiesUnresolved => 'Наразі неможливо розв’язати посилання.'; + + @override + String get wsUseDefault => 'Використати типове'; + + @override + String get wsPropertiesSelection => 'Виберіть один елемент.'; + + @override + String get wsImageDisplay => 'Показ зображення'; + + @override + String get wsImageThumbnail => 'Мініатюра з посиланням'; + + @override + String get wsEnabled => 'Увімкнено'; + + @override + String get wsDisabled => 'Вимкнено'; + + @override + String get wsWidth => 'Ширина'; + + @override + String get wsHeight => 'Висота'; + + @override + String get wsPropertiesInactive => + 'Властивість діє лише зі згортанням у публікації.'; + + @override + String get wsNavigationAll => 'Усі елементи із заголовками'; + + @override + String get wsNavigationNone => 'Без навігації'; + + @override + String get wsNavigationCustom => 'Власна навігація'; + + @override + String get wsTitleOverride => 'Перевизначити заголовок'; + + @override + String get wsCompactLayout => 'Компактний'; + + @override + String get wsWideLayout => 'Широкий'; + + @override + String get wsCodeSourceConflict => + 'Перемістіть або видаліть код, написаний у цьому блоці, перш ніж задати src. Writerside відхиляє непорожній блок коду з посиланням на джерело.'; + + @override + String get wsInherited => 'Успадкувати зі списку'; + + @override + String get wsExternalImageSize => + 'Writerside ненадійно задає розміри зовнішніх зображень. Наявні розміри можна переглянути та видалити.'; + + @override + String get wsPropertiesNotSerialized => + 'Цю зміну не записано у вихідний текст. Чернетку збережено.'; + + @override + String get wsListItem => 'Елемент списку'; + + @override + String get wsSourceFile => 'Вихідний файл'; + + @override + String get wsLinkTarget => 'Ціль посилання'; + + @override + String get wsPreviewImage => 'Зображення перегляду'; + + @override + String get wsIncludedElement => 'Включений елемент'; + + @override + String get wsHelpModule => 'Модуль довідки'; + + @override + String get wsActionId => 'ID дії'; + + @override + String get wsKeymapHelpInstance => 'Екземпляр довідки розкладки'; + + @override + String get wsShowInSource => 'Показати у джерелі'; + + @override + String get wsOriginElement => 'Значення елемента'; + + @override + String get wsOriginAncestor => 'Батьківський елемент'; + + @override + String get wsOriginModule => 'Типове значення модуля'; + + @override + String get wsOriginBuiltIn => 'Вбудоване типове значення'; + + @override + String get wsOriginUnresolved => 'Не визначено'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Дійсне значення: $value — $origin'; + } + + @override + String get wsElementSelector => 'Елемент'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Згенеровано: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Типово: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Типово: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Визначено: $value'; + } + + @override + String get wsPropertyUnresolved => 'Не визначено'; + + @override + String get wsModule => 'Модуль'; + + @override + String get wsParent => 'Батьківський елемент'; + + @override + String get wsListType => 'Тип списку'; + + @override + String get wsStartingNumber => 'Початковий номер'; + + @override + String get wsListSorting => 'Сортування'; + + @override + String get wsLetteredList => 'Літерний список'; + + @override + String get wsCheckboxList => 'Список із прапорцями'; + + @override + String get wsMarkerlessList => 'Список без маркерів'; + + @override + String get wsAscending => 'За зростанням'; + + @override + String get wsDescending => 'За спаданням'; + + @override + String get wsNoSorting => 'Без сортування'; + + @override + String get wsAsWritten => 'Як написано'; + + @override + String get wsSentenceCase => 'Регістр речення'; + + @override + String get wsTitleCase => 'Регістр заголовка'; + + @override + String get wsLowerCase => 'Малі літери'; + + @override + String get wsUpperCase => 'Великі літери'; + + @override + String get wsNativeMarkers => 'Маркери Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Початковий маркер: $number'; + } + + @override + String wsListLevel(String level) { + return 'Рівень $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Закріплений збирач ігнорує правила <caps> модуля. Використовуйте локальний атрибут caps або default-property.'; + + @override + String get wsInsertElements => 'Вставити елементи'; + + @override + String get wsAdmonitionsAndQuotes => 'Примітки та цитати'; + + @override + String get wsSemanticText => 'Семантичний текст'; + + @override + String get wsPropertiesUnavailable => + 'Елемент, що перевіряється, недоступний. Виберіть елемент для продовження.'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 7fbddeeb..c1d9082e 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -10,6 +10,101 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get wsModuleCapsNotPublished => + 'Quy tắc viết hoa của mô-đun không được xuất bản.'; + + @override + String get wsTitleOverridesAction => 'Tiêu đề phiên bản và mục lục…'; + + @override + String get wsHeadingNotPublished => 'Trình dựng này không xuất bản.'; + + @override + String get wsMarkdownInstanceTitleLimitation => + 'Builder cố định không xuất bản tiêu đề phiên bản trong chủ đề Markdown. Giữ nguyên đánh dấu hiện có; kiểm tra trong Nguồn.'; + + @override + String get wsTopicIdentity => 'ID chủ đề'; + + @override + String get wsElementAnchor => 'Neo phần tử'; + + @override + String get wsHeadingText => 'Văn bản tiêu đề'; + + @override + String get wsDefaultTitle => 'Tiêu đề mặc định'; + + @override + String get wsTopicNavigation => 'Điều hướng chủ đề'; + + @override + String get wsHeadingOptions => 'Thuộc tính tiêu đề'; + + @override + String get wsDocumentOptions => 'Thiết lập tài liệu'; + + @override + String get wsSettingsPresent => 'Có thiết lập được ghi rõ'; + + @override + String get wsTitleOverrideExplanation => + 'Ghi đè tiêu đề xuất bản; văn bản tiêu đề không đổi.'; + + @override + String get wsIgnoreVariables => 'Xử lý biến như văn bản nguyên dạng'; + + @override + String get wsIgnoreVariablesExplanation => + 'Khi bật, văn bản giống biến không được thay thế.'; + + @override + String get wsInstanceSelection => 'Phiên bản trợ giúp'; + + @override + String get wsInstanceExplanation => + 'Chọn đầu ra theo tên, nhóm, danh sách, loại trừ hoặc biểu thức.'; + + @override + String get wsFilterExplanation => + 'Lọc nội dung tái sử dụng theo tiêu chí; không chọn phiên bản trợ giúp.'; + + @override + String get wsContextHelp => 'ID trợ giúp ngữ cảnh'; + + @override + String get wsCapitalization => 'Kiểu chữ hoa'; + + @override + String get wsLibraryTopic => 'Chủ đề thư viện'; + + @override + String get wsLastModified => 'Sửa đổi lần cuối'; + + @override + String get wsMissingTopicHeading => 'Chưa có H1 chủ đề. Thêm trong Nguồn.'; + + @override + String get wsMultipleTopicHeadings => + 'H1 đầu tiên xác định tiêu đề chủ đề. Các H1 sau là tiêu đề riêng.'; + + @override + String get wsTopicIdentityMismatch => + 'id gốc khác ID theo tên tệp. Dùng Đổi tên tệp chủ đề hoặc kiểm tra Nguồn.'; + + @override + String get wsUnsupportedProperties => + 'Thuộc tính không hỗ trợ được giữ lại. Kiểm tra trong Nguồn.'; + + @override + String get wsGeneratedAnchor => + 'Neo được tạo tự động; nhập giá trị để ghi đè.'; + + @override + String get wsHeadingBuilderLimitation => + 'Builder cố định không xuất bản thuộc tính H1. Chúng thuộc tiêu đề, không thuộc gốc XML.'; + @override String get closeOtherTabs => 'Đóng các thẻ khác'; @@ -4780,4 +4875,516 @@ class AppLocalizationsVi extends AppLocalizations { @override String get nextcloudCreateSeparateWarning => 'Yêu cầu đầu tiên có thể đã tạo ghi chú trên máy chủ. Thao tác này tạo ghi chú riêng với công việc cục bộ được giữ lại. Không xóa ghi chú nào bị nghi trùng lặp.'; + + @override + String get wsSemanticFormats => 'Định dạng ngữ nghĩa'; + + @override + String get wsUiControl => 'Điều khiển giao diện'; + + @override + String get wsFilePath => 'Tệp / Đường dẫn'; + + @override + String get wsUiPath => 'Đường dẫn giao diện'; + + @override + String get wsDefinitionList => 'Danh sách định nghĩa'; + + @override + String get wsIncludeContent => 'Chèn nội dung tái sử dụng'; + + @override + String get wsVariableReference => 'Tham chiếu biến'; + + @override + String get wsConvertList => 'Chuyển danh sách thành quy trình'; + + @override + String get wsElementProperties => 'Thuộc tính phần tử'; + + @override + String get wsTopicProperties => 'Thuộc tính chủ đề'; + + @override + String get wsCollapsible => 'Có thể thu gọn'; + + @override + String get wsPublishedState => 'Trạng thái ban đầu khi xuất bản'; + + @override + String get wsExpanded => 'Mở rộng'; + + @override + String get wsCollapsed => 'Thu gọn'; + + @override + String get wsCollapsedTitle => 'Tiêu đề thu gọn'; + + @override + String get wsTerm => 'Thuật ngữ'; + + @override + String get wsListLayout => 'Bố cục danh sách'; + + @override + String get wsSyncGroup => 'Nhóm đồng bộ'; + + @override + String get wsTabKey => 'Khóa thẻ'; + + @override + String get wsSwitcherLabel => 'Nhãn bộ chuyển'; + + @override + String get wsNavigationElements => 'Phần tử điều hướng'; + + @override + String get wsNavigationDepth => 'Độ sâu điều hướng'; + + @override + String get wsAddItem => 'Thêm mục'; + + @override + String get wsStep => 'Bước'; + + @override + String get wsPropertyInvalid => 'Giá trị thuộc tính không hợp lệ'; + + @override + String get wsFilter => 'Bộ lọc'; + + @override + String get wsIdentifier => 'ID phần tử'; + + @override + String get wsTldr => 'Tóm tắt (TLDR)'; + + @override + String get wsFullLayout => 'Toàn chiều rộng'; + + @override + String get wsMediumLayout => 'Trung bình (1:2)'; + + @override + String get wsNarrowLayout => 'Cột thuật ngữ hẹp'; + + @override + String get wsTitle => 'Tiêu đề'; + + @override + String get wsSwitcherKey => 'Khóa chuyển đổi'; + + @override + String get nextcloudServerSettings => 'Cài đặt Notes trên máy chủ'; + + @override + String get nextcloudNotesPath => 'Đường dẫn thư mục ghi chú'; + + @override + String get nextcloudFileSuffix => 'Phần mở rộng của ghi chú mới'; + + @override + String get nextcloudServerSettingsExplanation => + 'Các cài đặt Notes này ảnh hưởng đến các ứng dụng khách khác. Đổi đường dẫn không di chuyển tệp; đổi phần mở rộng không đổi tên ghi chú hiện có.'; + + @override + String get nextcloudSettingsSaved => 'Đã lưu cài đặt máy chủ'; + + @override + String get nextcloudSettingsNormalized => + 'Đã lưu các giá trị được máy chủ chuẩn hóa'; + + @override + String get nextcloudPendingUpload => 'Đang chờ tải tệp đính kèm lên'; + + @override + String nextcloudServerCheck(String time) { + return 'Lần kiểm tra máy chủ gần nhất: $time'; + } + + @override + String get nextcloudSettingsLoading => 'Đang tải cài đặt máy chủ'; + + @override + String get nextcloudSettingsSaving => 'Đang lưu cài đặt máy chủ'; + + @override + String get nextcloudPreviousLocalEdit => 'Chỉnh sửa cục bộ trước'; + + @override + String get nextcloudUsePreviousEdit => 'Dùng chỉnh sửa trước'; + + @override + String get quickOpen => 'Mở nhanh'; + + @override + String get notesAll => 'Tất cả ghi chú'; + + @override + String get notesUncategorized => 'Chưa phân loại'; + + @override + String get notesRecovery => 'Khôi phục'; + + @override + String get notesRecentlyEdited => 'Sửa gần đây'; + + @override + String get notesOldestEdited => 'Sửa lâu nhất'; + + @override + String get notesTitleAscending => 'Tiêu đề A–Z'; + + @override + String get notesTitleDescending => 'Tiêu đề Z–A'; + + @override + String get notesMove => 'Chuyển tới danh mục'; + + @override + String get notesRemoveFavorite => 'Bỏ yêu thích'; + + @override + String get notesOffline => 'Cho phép dùng ngoại tuyến'; + + @override + String get notesOfflineRemove => 'Bỏ yêu cầu lưu ngoại tuyến'; + + @override + String get notesIndexing => 'Đang lập chỉ mục ghi chú…'; + + @override + String get notesSearchHelp => + 'Các từ kết hợp bằng VÀ. Dùng \"cụm từ trích dẫn\", title: và category:.'; + + @override + String get notesSourceLocation => 'Vị trí mã nguồn'; + + @override + String get notesRecoveryHelp => + 'Chỉ gồm ghi chú cục bộ được giữ và lịch sử cục bộ. Không có thùng rác hoặc lịch sử máy chủ.'; + + @override + String get notesImport => 'Nhập ghi chú'; + + @override + String get notesExport => 'Xuất ghi chú'; + + @override + String get notesImportDistinct => 'Nhập thành ghi chú riêng'; + + @override + String get notesAvailableOffline => 'Có sẵn ngoại tuyến'; + + @override + String get notesIncomplete => 'Chưa đầy đủ'; + + @override + String get notesSelected => 'Ghi chú đã chọn'; + + @override + String get notesNoHistory => + 'Không có phiên bản được giữ. Lịch sử có thể bị tắt, hết hạn hoặc bị xóa.'; + + @override + String get notesTextAvailable => 'Văn bản có sẵn cục bộ'; + + @override + String get notesLocalVersion => 'Phiên bản làm việc cục bộ'; + + @override + String get notesAttachments => 'Tệp đính kèm'; + + @override + String get notesSkipped => 'Đã bỏ qua'; + + @override + String get notesFailed => 'Thất bại'; + + @override + String get notesCompleted => 'Hoàn tất'; + + @override + String get notesImportNew => 'Bắt đầu nhập mới'; + + @override + String get notesImportResume => 'Tiếp tục lần nhập bị gián đoạn'; + + @override + String get notesImportAlreadyImported => 'Đã nhập trong thao tác này'; + + @override + String get references => 'Tham chiếu'; + + @override + String get referenceIncoming => 'Đến'; + + @override + String get referenceOutgoing => 'Đi'; + + @override + String get referenceChooseTarget => 'Chọn đích liên kết'; + + @override + String get referenceWholeDocument => 'Toàn bộ tài liệu'; + + @override + String get referenceSourceLocation => 'Vị trí nguồn'; + + @override + String get referenceTargetLocation => 'Vị trí đích'; + + @override + String get referenceResolved => 'Đã phân giải'; + + @override + String get referenceMissing => 'Không tìm thấy'; + + @override + String get referenceAmbiguous => 'Không rõ ràng'; + + @override + String get referenceUnavailable => 'Không khả dụng trong ngữ cảnh này'; + + @override + String get referenceUnsupported => 'Không được hỗ trợ'; + + @override + String get referenceNoPortableId => + 'Đích này không có URL ghi chú di động đã xác minh.'; + + @override + String get wsIncludeLines => 'Gồm các dòng'; + + @override + String get wsIncludeSymbol => 'Gồm ký hiệu'; + + @override + String get wsEmphasizeLines => 'Tô nổi các dòng hiển thị'; + + @override + String get wsPropertiesReadOnly => 'Thuộc tính chỉ đọc trong chế độ này.'; + + @override + String get wsPropertiesProtected => 'Phần tử được bảo vệ. Hãy sửa mã nguồn.'; + + @override + String get wsPropertiesConflict => + 'Tài liệu hoặc phần tử đã đổi. Hoàn nguyên bản nháp và chọn lại.'; + + @override + String get wsPropertiesUnresolved => 'Hiện không thể phân giải tham chiếu.'; + + @override + String get wsUseDefault => 'Dùng mặc định'; + + @override + String get wsPropertiesSelection => 'Chọn một phần tử.'; + + @override + String get wsImageDisplay => 'Hiển thị ảnh'; + + @override + String get wsImageThumbnail => 'Ảnh thu nhỏ có thể nhấp'; + + @override + String get wsEnabled => 'Bật'; + + @override + String get wsDisabled => 'Tắt'; + + @override + String get wsWidth => 'Chiều rộng'; + + @override + String get wsHeight => 'Chiều cao'; + + @override + String get wsPropertiesInactive => + 'Thuộc tính chỉ có tác dụng khi bật thu gọn lúc xuất bản.'; + + @override + String get wsNavigationAll => 'Mọi phần tử có tiêu đề'; + + @override + String get wsNavigationNone => 'Không điều hướng'; + + @override + String get wsNavigationCustom => 'Điều hướng tùy chỉnh'; + + @override + String get wsTitleOverride => 'Ghi đè tiêu đề'; + + @override + String get wsCompactLayout => 'Gọn'; + + @override + String get wsWideLayout => 'Rộng'; + + @override + String get wsCodeSourceConflict => + 'Di chuyển hoặc xóa mã được viết trong khối này trước khi đặt src. Writerside từ chối khối mã không trống có tham chiếu nguồn.'; + + @override + String get wsInherited => 'Kế thừa từ danh sách'; + + @override + String get wsExternalImageSize => + 'Writerside không đặt kích thước ảnh bên ngoài một cách tin cậy. Có thể xem và xóa kích thước hiện có.'; + + @override + String get wsPropertiesNotSerialized => + 'Thay đổi này chưa được ghi vào nguồn. Bản nháp đã được giữ lại.'; + + @override + String get wsListItem => 'Mục danh sách'; + + @override + String get wsSourceFile => 'Tệp nguồn'; + + @override + String get wsLinkTarget => 'Đích liên kết'; + + @override + String get wsPreviewImage => 'Ảnh xem trước'; + + @override + String get wsIncludedElement => 'Phần tử được đưa vào'; + + @override + String get wsHelpModule => 'Mô-đun trợ giúp'; + + @override + String get wsActionId => 'ID hành động'; + + @override + String get wsKeymapHelpInstance => 'Phiên bản trợ giúp sơ đồ phím'; + + @override + String get wsShowInSource => 'Hiện trong Nguồn'; + + @override + String get wsOriginElement => 'Giá trị ghi đè của phần tử'; + + @override + String get wsOriginAncestor => 'Phần tử cha'; + + @override + String get wsOriginModule => 'Mặc định của mô-đun'; + + @override + String get wsOriginBuiltIn => 'Mặc định tích hợp'; + + @override + String get wsOriginUnresolved => 'Chưa phân giải'; + + @override + String wsEffectiveProperty(String value, String origin) { + return 'Hiệu lực: $value — $origin'; + } + + @override + String get wsElementSelector => 'Phần tử'; + + @override + String wsGeneratedAnchorValue(String anchor) { + return 'Được tạo: $anchor'; + } + + @override + String wsDefaultProperty(String value) { + return 'Mặc định: $value'; + } + + @override + String wsDefaultPropertyOrigin(String value, String origin) { + return 'Mặc định: $value · $origin'; + } + + @override + String wsResolvedProperty(String value) { + return 'Đã phân giải: $value'; + } + + @override + String get wsPropertyUnresolved => 'Chưa phân giải'; + + @override + String get wsModule => 'Mô-đun'; + + @override + String get wsParent => 'Phần tử cha'; + + @override + String get wsListType => 'Loại danh sách'; + + @override + String get wsStartingNumber => 'Số bắt đầu'; + + @override + String get wsListSorting => 'Sắp xếp'; + + @override + String get wsLetteredList => 'Danh sách chữ cái'; + + @override + String get wsCheckboxList => 'Danh sách hộp kiểm'; + + @override + String get wsMarkerlessList => 'Danh sách không dấu đầu dòng'; + + @override + String get wsAscending => 'Tăng dần'; + + @override + String get wsDescending => 'Giảm dần'; + + @override + String get wsNoSorting => 'Không sắp xếp'; + + @override + String get wsAsWritten => 'Theo văn bản gốc'; + + @override + String get wsSentenceCase => 'Viết hoa kiểu câu'; + + @override + String get wsTitleCase => 'Viết hoa kiểu tiêu đề'; + + @override + String get wsLowerCase => 'Chữ thường'; + + @override + String get wsUpperCase => 'Chữ hoa'; + + @override + String get wsNativeMarkers => 'Dấu Markdown'; + + @override + String wsListMarkerStart(String number) { + return 'Dấu bắt đầu: $number'; + } + + @override + String wsListLevel(String level) { + return 'Cấp $level'; + } + + @override + String get wsModuleCapsIgnored => + 'Trình dựng cố định bỏ qua quy tắc <caps> của mô-đun. Dùng thuộc tính caps cục bộ hoặc default-property.'; + + @override + String get wsInsertElements => 'Chèn phần tử'; + + @override + String get wsAdmonitionsAndQuotes => 'Lưu ý và trích dẫn'; + + @override + String get wsSemanticText => 'Văn bản ngữ nghĩa'; + + @override + String get wsPropertiesUnavailable => + 'Phần tử đang kiểm tra không khả dụng. Kiểm tra một phần tử để tiếp tục.'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 005f0425..501b563c 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -10,6 +10,92 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get wsModuleCapsNotPublished => '模块的大小写规则不会发布。'; + + @override + String get wsTitleOverridesAction => '实例与目录标题…'; + + @override + String get wsHeadingNotPublished => '此构建器不会发布此属性。'; + + @override + String get wsMarkdownInstanceTitleLimitation => + '固定版本构建器不发布 Markdown 主题的实例标题覆盖。保留现有标记,请在源码中查看。'; + + @override + String get wsTopicIdentity => '主题 ID'; + + @override + String get wsElementAnchor => '元素锚点'; + + @override + String get wsHeadingText => '标题文本'; + + @override + String get wsDefaultTitle => '默认标题'; + + @override + String get wsTopicNavigation => '主题导航'; + + @override + String get wsHeadingOptions => '标题属性'; + + @override + String get wsDocumentOptions => '文档设置'; + + @override + String get wsSettingsPresent => '存在已编写设置'; + + @override + String get wsTitleOverrideExplanation => '覆盖发布的标题;标题文本保持不变。'; + + @override + String get wsIgnoreVariables => '将变量视为字面文本'; + + @override + String get wsIgnoreVariablesExplanation => '启用时,不替换类似变量的文本。'; + + @override + String get wsInstanceSelection => '帮助实例'; + + @override + String get wsInstanceExplanation => '通过名称、组、列表、排除项或表达式选择帮助输出。'; + + @override + String get wsFilterExplanation => '按条件筛选可复用内容;这不会选择帮助实例。'; + + @override + String get wsContextHelp => '上下文帮助 ID'; + + @override + String get wsCapitalization => '大小写'; + + @override + String get wsLibraryTopic => '库主题'; + + @override + String get wsLastModified => '最后修改'; + + @override + String get wsMissingTopicHeading => '尚未编写主题 H1。请在源码中添加。'; + + @override + String get wsMultipleTopicHeadings => '第一个 H1 决定主题标题,后续 H1 是独立标题。'; + + @override + String get wsTopicIdentityMismatch => '根 id 与文件名派生的主题 ID 不同。请重命名主题文件或查看源码。'; + + @override + String get wsUnsupportedProperties => '保留不受支持的属性。请在源码中查看。'; + + @override + String get wsGeneratedAnchor => '自动生成的锚点;输入值可覆盖。'; + + @override + String get wsHeadingBuilderLimitation => + '固定版本构建器不发布 H1 属性。属性属于标题,而非 XML 根元素。'; + @override String get closeOtherTabs => '关闭其他标签页'; @@ -4577,4577 +4663,5667 @@ class AppLocalizationsZh extends AppLocalizations { @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 wsSemanticFormats => '语义格式'; @override - String get closeOtherTabs => '关闭其他标签页'; + String get wsUiControl => '界面控件'; @override - String get tocTopicFromTemplate => '从模板创建主题...'; + String get wsFilePath => '文件 / 路径'; @override - String get tocSaveAsTemplate => '保存为模板'; + String get wsUiPath => '界面路径'; @override - String get tocTemplateDialog => '从模板创建主题'; + String get wsDefinitionList => '定义列表'; @override - String get tocTemplatesEditor => '文件和代码模板'; + String get wsIncludeContent => '包含可复用内容'; @override - String get tocEditTemplates => '编辑模板...'; + String get wsVariableReference => '变量引用'; @override - String get tocCreateCustomTemplate => '创建自定义模板...'; + String get wsConvertList => '将列表转换为过程'; @override - String get tocTemplateCreated => '已创建模板'; + String get wsElementProperties => '元素属性'; @override - String get tocTemplateDefault => '默认'; + String get wsTopicProperties => '主题属性'; @override - String get tocTemplateCustom => '自定义'; + String get wsCollapsible => '可折叠'; @override - String get tocTemplateFilename => '文件名:'; + String get wsPublishedState => '发布时的初始状态'; @override - String get tocTemplateMarkdown => 'Markdown (.md)'; + String get wsExpanded => '展开'; @override - String get tocTemplateXml => 'XML (.topic)'; + String get wsCollapsed => '折叠'; @override - String get tocTemplatesInternal => '内部'; + String get wsCollapsedTitle => '折叠标题'; @override - String get tocTemplateUnnamed => '未命名'; + String get wsTerm => '术语'; @override - String get tocTemplateNew => '新建模板...'; + String get wsListLayout => '列表布局'; @override - String get tocTemplateName => '名称:'; + String get wsSyncGroup => '同步组'; @override - String get tocTemplateExtension => '扩展名:'; + String get wsTabKey => '选项卡键'; @override - String get tocTemplateLoadError => '无法加载模板。现有模板未更改。'; + String get wsSwitcherLabel => '切换器标签'; @override - String get tocTemplateSaveError => '无法保存模板。您的编辑内容仍保留在此处。'; + String get wsNavigationElements => '导航元素'; @override - String get tocTemplateConflict => '模板已在其他窗口中更改。请取消并重新打开编辑器。'; + String get wsNavigationDepth => '导航深度'; @override - String get tocTemplateDuplicateName => '已存在相同名称和扩展名的模板。'; + String get wsAddItem => '添加项目'; @override - String get tocTemplateFormat => '格式:'; + String get wsStep => '步骤'; @override - String get tocTemplateReset => '重置'; + String get wsPropertyInvalid => '属性值无效'; @override - String get tocTemplateRetry => '重试'; + String get wsFilter => '筛选器'; @override - String tocRemoveElementsDialog(int count) { - return '移除 $count 个目录元素'; - } + String get wsIdentifier => '元素 ID'; @override - String get tocRemoveElementsRetainFiles => '不会删除与这些目录元素关联的源文件。'; + String get wsTldr => '摘要 (TLDR)'; @override - String get tocOk => '确定'; + String get wsFullLayout => '全宽'; @override - String get tocSelectExistingTopic => '选择要添加到当前实例的主题'; + String get wsMediumLayout => '中等 (1:2)'; @override - String get tocGroupName => '组名'; + String get wsNarrowLayout => '窄术语列'; @override - String get tocMoveLineUp => '上移行'; + String get wsTitle => '标题'; @override - String get tocMoveLineDown => '下移行'; + String get wsSwitcherKey => '切换键'; @override - String get tocEmptyMdTopic => '空 MD 主题'; + String get nextcloudServerSettings => '服务器 Notes 设置'; @override - String get tocEmptyXmlTopic => '空 XML 主题'; + String get nextcloudNotesPath => '笔记文件夹路径'; @override - String get tocCopySpecial => '特殊复制'; + String get nextcloudFileSuffix => '新笔记的文件后缀'; @override - String tocCopyFileName(String name) { - return '主题文件名 \'$name\''; - } + String get nextcloudServerSettingsExplanation => + '这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。'; @override - String get tocCopyFilePath => '主题文件路径'; + String get nextcloudSettingsSaved => '服务器设置已保存'; @override - String tocCopyTopicTitle(String title) { - return '主题标题 \'$title\''; - } + String get nextcloudSettingsNormalized => '已保存服务器规范化后的值'; @override - String tocCopyElementId(String id) { - return '目录元素 ID \'$id\''; + String get nextcloudPendingUpload => '附件等待上传'; + + @override + String nextcloudServerCheck(String time) { + return '上次服务器检查:$time'; } @override - String get tocCopyFileNameUnavailable => '主题文件名'; + String get nextcloudSettingsLoading => '正在加载服务器设置'; @override - String get tocCopyTopicTitleUnavailable => '主题标题'; + String get nextcloudSettingsSaving => '正在保存服务器设置'; @override - String get tocCopyElementIdUnavailable => '目录元素 ID'; + String get nextcloudPreviousLocalEdit => '先前的本地编辑'; @override - String get tocEditTitleAction => '编辑标题...'; + String get nextcloudUsePreviousEdit => '使用先前编辑'; @override - String get tocEditTitleDialog => '编辑标题'; + String get quickOpen => '快速打开'; @override - String get tocRemoveElementAction => '移除目录元素...'; + String get notesAll => '所有笔记'; @override - String get tocSetHomePage => '设为主页'; + String get notesUncategorized => '未分类'; @override - String tocGoToElement(String fileName) { - return '转到“$fileName”中的目录元素'; - } + String get notesRecovery => '恢复'; @override - String get tocSynchronize => '同步目录与编辑器'; + String get notesRecentlyEdited => '最近编辑'; @override - String get tocEmptyGroupAction => '空组'; + String get notesOldestEdited => '最早编辑'; @override - String get tocNewEmptyGroup => '新建空组'; + String get notesTitleAscending => '标题升序'; @override - String get tocGroupAction => '分组'; + String get notesTitleDescending => '标题降序'; @override - String get tocNewGroup => '新建组'; + String get notesMove => '移至分类'; @override - String get tocSortChildren => '按字母顺序排列子主题'; + String get notesRemoveFavorite => '取消收藏'; @override - String get tocLinkTopicFiles => '将主题文件链接到目录...'; + String get notesOffline => '设为离线可用'; @override - String get addLocalMarkdownFiles => '添加本地 Markdown 文件'; + String get notesOfflineRemove => '移除离线保留要求'; @override - String get tocTitleField => '目录标题:'; + String get notesIndexing => '正在索引笔记…'; @override - String get tocTopicTitleField => '主题标题:'; + String get notesSearchHelp => '词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。'; @override - String get tocAdvancedSettings => '高级设置'; + String get notesSourceLocation => '源文件位置'; @override - String tocInstanceTitleField(String instanceId) { - return '“$instanceId”的标题:'; - } + String get notesRecoveryHelp => '仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。'; @override - String get tocOnlyTitleField => '仅用于目录的标题:'; + String get notesImport => '导入笔记'; @override - String get tocInstanceTitleExplanation => '仅用于当前实例。默认继承主题标题。'; + String get notesExport => '导出笔记'; @override - String get tocOnlyTitleExplanation => - '仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见'; + String get notesImportDistinct => '导入为独立笔记'; @override - String get tocTitleDocumentationLink => '此处'; + String get notesAvailableOffline => '离线可用'; @override - String get tocFindUsages => '查找'; + String get notesIncomplete => '不完整'; @override - String get tocRefactorMenu => '重构'; + String get notesSelected => '已选笔记'; @override - String get tocSafeDelete => '安全删除'; + String get notesNoHistory => '没有保留的版本。历史可能已禁用、过期或清除。'; @override - String get tocDuplicate => '创建副本'; + String get notesTextAvailable => '文本已在本地可用'; @override - String get tocDuplicateFilename => '主题文件名:'; + String get notesLocalVersion => '本地工作版本'; @override - String get tocDuplicateDialog => '创建主题副本'; + String get notesAttachments => '附件'; @override - String get pdfTitlePage => '标题页'; + String get notesSkipped => '已跳过'; @override - String get pdfIncludeTitlePage => '包含标题页'; + String get notesFailed => '失败'; @override - String get pdfTitlePageNote => '此页不显示页眉、页脚或页码。'; + String get notesCompleted => '已完成'; @override - String get pdfTitlePageTitle => '标题'; + String get notesImportNew => '开始新导入'; @override - String get pdfTitlePageSubtitle => '副标题'; + String get notesImportResume => '继续中断的导入'; @override - String get pdfTitlePageAuthor => '作者'; + String get notesImportAlreadyImported => '已在此操作中导入'; @override - String get pdfTitlePageOrganization => '组织'; + String get references => '引用'; @override - String get pdfTitlePageVersion => '版本'; + String get referenceIncoming => '传入'; @override - String get pdfTitlePageDate => '日期'; + String get referenceOutgoing => '传出'; @override - String get pdfTitlePageTitleRequired => '请输入标题页的标题。'; + String get referenceChooseTarget => '选择链接目标'; @override - String get pdfTocSectionNote => '从单独一页开始,可以跨多页。'; + String get referenceWholeDocument => '整个文档'; @override - String get appTitle => 'BusyMark'; + String get referenceSourceLocation => '源位置'; @override - String get appSubtitle => 'Markdown 文件和兼容 Writerside 的文档项目编辑器。'; + String get referenceTargetLocation => '目标位置'; @override - String get aboutBusyMark => '关于 BusyMark'; + String get referenceResolved => '已解析'; @override - String get aboutTagline => 'Markdown 和 Writerside 编辑器'; + String get referenceMissing => '缺失'; @override - String get aboutLicenseLabel => '许可证'; + String get referenceAmbiguous => '不明确'; @override - String get aboutLicenseName => 'Apache License 2.0'; + String get referenceUnavailable => '当前上下文中不可用'; @override - String get aboutWebsite => '网站'; + String get referenceUnsupported => '不支持'; @override - String get aboutSourceCode => '源代码'; + String get referenceNoPortableId => '此目标没有经过验证的可移植笔记网址。'; @override - String get reportIssue => '报告问题'; + String get wsIncludeLines => '包含行'; @override - String get feedbackCategory => '类别'; + String get wsIncludeSymbol => '包含符号'; @override - String get feedbackChooseCategory => '选择类别'; + String get wsEmphasizeLines => '突出显示已显示的行'; @override - String get feedbackCategoryProblem => '问题或错误'; + String get wsPropertiesReadOnly => '此视图中的属性为只读。'; @override - String get feedbackCategoryFeature => '功能请求'; + String get wsPropertiesProtected => '此元素受保护。请编辑其源代码。'; @override - String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; + String get wsPropertiesConflict => '文档或元素已更改。请还原草稿并重新选择。'; @override - String get feedbackCategoryUsability => '可用性问题'; + String get wsPropertiesUnresolved => '目前无法解析此引用。'; @override - String get feedbackCategoryOther => '其他'; + String get wsUseDefault => '使用默认值'; @override - String get feedbackSubject => '主题'; + String get wsPropertiesSelection => '请选择单个元素。'; @override - String get feedbackMessage => '详细留言'; + String get wsImageDisplay => '图像显示'; @override - String get feedbackReplyEmail => '回复邮箱(可选)'; + String get wsImageThumbnail => '可点击缩略图'; @override - String get feedbackIncludeTechnicalDetails => '包含技术详细信息'; + String get wsEnabled => '已启用'; @override - String get feedbackTechnicalDetailsDisclosure => - '启用后,此选项仅会添加您的 Linux 操作系统版本和 BusyMark 应用语言。不会附加日志、文件、账户数据或其他诊断信息。'; + String get wsDisabled => '已禁用'; @override - String get feedbackSubmit => '提交'; + String get wsWidth => '宽度'; @override - String get feedbackSubmitting => '正在提交…'; + String get wsHeight => '高度'; @override - String get feedbackCategoryRequired => '请选择类别。'; + String get wsPropertiesInactive => '未启用发布折叠时,此属性无效。'; @override - String get feedbackSubjectLength => '主题必须包含 3 到 120 个字符。'; + String get wsNavigationAll => '所有有标题的元素'; @override - String get feedbackMessageLength => '信息必须包含 10 到 5,000 个字符。'; + String get wsNavigationNone => '无导航'; @override - String get feedbackReplyEmailInvalid => '请输入有效的邮箱地址,或将此字段留空。'; + String get wsNavigationCustom => '自定义导航'; @override - String get feedbackConnectionFailure => 'BusyMark 无法连接。请检查网络连接后重试。'; + String get wsTitleOverride => '覆盖标题'; @override - String get feedbackTimeoutFailure => '请求超时。请重试。'; + String get wsCompactLayout => '紧凑'; @override - String get feedbackRateLimitedFailure => '此连接发送的报告过多。请稍候再试。'; + String get wsWideLayout => '宽'; @override - String get feedbackRejectedFailure => '服务器拒绝了此报告。请检查表单字段后重试。'; + String get wsCodeSourceConflict => + '设置 src 前,请移走或删除此代码块中编写的代码。Writerside 不接受带有源引用的非空代码块。'; @override - String get feedbackServerFailure => '服务器无法接收此报告。请稍后重试。'; + String get wsInherited => '从列表继承'; @override - String feedbackSuccess(String id) { - return '反馈已发送。参考 ID:$id'; - } + String get wsExternalImageSize => 'Writerside 无法可靠地调整外部托管图片的尺寸。可以查看和移除已有尺寸。'; @override - String get advanced => '高级'; + String get wsPropertiesNotSerialized => '此更改未写入源文本。草稿已保留。'; @override - String get addToGit => '添加到 Git'; + String get wsListItem => '列表项'; @override - String get appearance => '外观'; + String get wsSourceFile => '源文件'; @override - String get apply => '应用'; + String get wsLinkTarget => '链接目标'; @override - String get back => '返回'; + String get wsPreviewImage => '预览图像'; @override - String get bottomLeft => '左下'; + String get wsIncludedElement => '包含的元素'; @override - String get bottomRight => '右下'; + String get wsHelpModule => '帮助模块'; @override - String get cancel => '取消'; + String get wsActionId => '操作 ID'; @override - String get choose => '选择'; + String get wsKeymapHelpInstance => '键位映射帮助实例'; @override - String get chooseLocation => '选择位置'; + String get wsShowInSource => '在源代码中显示'; @override - String get copy => '复制'; + String get wsOriginElement => '元素覆盖值'; @override - String get copyPlainText => '复制为纯文本'; + String get wsOriginAncestor => '父元素'; @override - String get clipboardCopyFailed => '无法将所选内容复制到剪贴板。'; + String get wsOriginModule => '模块默认值'; @override - String get copyName => '复制名称'; + String get wsOriginBuiltIn => '内置默认值'; @override - String get copyFileName => '复制文件名'; + String get wsOriginUnresolved => '未解析'; @override - String get copyPath => '复制路径'; + String wsEffectiveProperty(String value, String origin) { + return '有效值:$value — $origin'; + } @override - String get create => '创建'; + String get wsElementSelector => '元素'; @override - String get creating => '正在创建…'; + String wsGeneratedAnchorValue(String anchor) { + return '生成值:$anchor'; + } @override - String get cut => '剪切'; + String wsDefaultProperty(String value) { + return '默认值:$value'; + } @override - String get promoteSection => '提升节级别'; + String wsDefaultPropertyOrigin(String value, String origin) { + return '默认值:$value · $origin'; + } @override - String get demoteSection => '降低节级别'; + String wsResolvedProperty(String value) { + return '解析值:$value'; + } @override - String get moveSectionUp => '上移节'; + String get wsPropertyUnresolved => '未解析'; @override - String get moveSectionDown => '下移节'; + String get wsModule => '模块'; @override - String get confirmDeleteSectionTitle => '删除此节?'; + String get wsParent => '父元素'; @override - String confirmDeleteSectionMessage(String name) { - return '删除“$name”及其节中的全部内容?此操作无法撤销。'; - } + String get wsListType => '列表类型'; @override - String get darkTheme => '深色'; + String get wsStartingNumber => '起始编号'; @override - String get delete => '删除'; + String get wsListSorting => '排序'; @override - String get discard => '放弃'; + String get wsLetteredList => '字母列表'; @override - String get editor => '编辑器'; + String get wsCheckboxList => '复选框列表'; @override - String get file => '文件'; + String get wsMarkerlessList => '无标记列表'; @override - String get fileHistory => '文件历史记录'; + String get wsAscending => '升序'; @override - String get folder => '文件夹'; + String get wsDescending => '降序'; @override - String get insert => '插入'; + String get wsNoSorting => '不排序'; @override - String get keyboardShortcuts => '键盘快捷键'; + String get wsAsWritten => '按原文'; @override - String get commandPalette => '命令面板'; + String get wsSentenceCase => '句首大写'; @override - String get commandPaletteHint => '输入命令'; + String get wsTitleCase => '标题大写'; @override - String get commandPaletteEmpty => '没有匹配的命令'; + String get wsLowerCase => '小写'; @override - String get commandUnavailableInContext => '当前编辑器上下文中无法使用此命令'; + String get wsUpperCase => '大写'; @override - String get lightTheme => '浅色'; + String get wsNativeMarkers => 'Markdown 标记'; @override - String get mainMenu => '主菜单'; + String wsListMarkerStart(String number) { + return '起始标记:$number'; + } @override - String get fullScreen => '全屏'; + String wsListLevel(String level) { + return '层级 $level'; + } @override - String get markdown => 'Markdown'; + String get wsModuleCapsIgnored => + '固定版本的构建器会忽略模块的 <caps> 规则。请使用本地 caps 属性或 default-property。'; @override - String get open => '打开'; + String get wsInsertElements => '插入元素'; @override - String get openInFiles => '在文件中打开'; + String get wsAdmonitionsAndQuotes => '提示和引用'; @override - String get pathActions => '路径操作'; + String get wsSemanticText => '语义文本'; @override - String get outline => '大纲'; + String get wsPropertiesUnavailable => '所检查的元素不可用。请检查一个元素以继续。'; +} + +/// The translations for Chinese, as used in China (`zh_CN`). +class AppLocalizationsZhCn extends AppLocalizationsZh { + AppLocalizationsZhCn() : super('zh_CN'); @override - String get overwrite => '覆盖'; + String get wsModuleCapsNotPublished => '模块的大小写规则不会发布。'; @override - String get paste => '粘贴'; + String get wsTitleOverridesAction => '实例与目录标题…'; @override - String get reading => '阅读'; + String get wsHeadingNotPublished => '此构建器不会发布此属性。'; @override - String get removeFromRecent => '从最近使用记录中移除'; + String get wsMarkdownInstanceTitleLimitation => + '固定版本构建器不发布 Markdown 主题的实例标题覆盖。保留现有标记,请在源码中查看。'; @override - String get recent => '最近使用'; + String get wsTopicIdentity => '主题 ID'; @override - String get redo => '重做'; + String get wsElementAnchor => '元素锚点'; @override - String get save => '保存'; + String get wsHeadingText => '标题文本'; @override - String get search => '搜索'; + String get wsDefaultTitle => '默认标题'; @override - String get selectAll => '全选'; + String get wsTopicNavigation => '主题导航'; @override - String get settings => '设置'; + String get wsHeadingOptions => '标题属性'; @override - String get source => '源代码'; + String get wsDocumentOptions => '文档设置'; @override - String get split => '拆分'; + String get wsSettingsPresent => '存在已编写设置'; @override - String get systemTheme => '系统'; + String get wsTitleOverrideExplanation => '覆盖发布的标题;标题文本保持不变。'; @override - String get theme => '主题'; + String get wsIgnoreVariables => '将变量视为字面文本'; @override - String get appLanguage => '语言'; + String get wsIgnoreVariablesExplanation => '启用时,不替换类似变量的文本。'; @override - String get systemLanguage => '系统'; + String get wsInstanceSelection => '帮助实例'; @override - String get languageEnglish => 'English'; + String get wsInstanceExplanation => '通过名称、组、列表、排除项或表达式选择帮助输出。'; @override - String get languageGerman => 'Deutsch'; + String get wsFilterExplanation => '按条件筛选可复用内容;这不会选择帮助实例。'; @override - String get languageDutch => 'Nederlands'; + String get wsContextHelp => '上下文帮助 ID'; @override - String get languageTurkish => 'Türkçe'; + String get wsCapitalization => '大小写'; @override - String get languageItalian => 'Italiano'; + String get wsLibraryTopic => '库主题'; @override - String get languageNorwegian => 'Norsk bokmål'; + String get wsLastModified => '最后修改'; @override - String get languageFrench => 'Français'; + String get wsMissingTopicHeading => '尚未编写主题 H1。请在源码中添加。'; @override - String get languageRussian => 'Русский'; + String get wsMultipleTopicHeadings => '第一个 H1 决定主题标题,后续 H1 是独立标题。'; @override - String get languageUkrainian => 'Українська'; + String get wsTopicIdentityMismatch => '根 id 与文件名派生的主题 ID 不同。请重命名主题文件或查看源码。'; @override - String get languagePolish => 'Polski'; + String get wsUnsupportedProperties => '保留不受支持的属性。请在源码中查看。'; @override - String get languageSpanish => 'Español'; + String get wsGeneratedAnchor => '自动生成的锚点;输入值可覆盖。'; @override - String get languagePortuguese => 'Português'; + String get wsHeadingBuilderLimitation => + '固定版本构建器不发布 H1 属性。属性属于标题,而非 XML 根元素。'; @override - String get languageArabic => 'العربية'; + String get closeOtherTabs => '关闭其他标签页'; @override - String get languagePersian => 'فارسی'; + String get tocTopicFromTemplate => '从模板创建主题...'; @override - String get languageHindi => 'हिन्दी'; + String get tocSaveAsTemplate => '保存为模板'; @override - String get languageIndonesian => 'Bahasa Indonesia'; + String get tocTemplateDialog => '从模板创建主题'; @override - String get languageEstonian => 'Eesti'; + String get tocTemplatesEditor => '文件和代码模板'; @override - String get languageVietnamese => 'Tiếng Việt'; + String get tocEditTemplates => '编辑模板...'; @override - String get languageJapanese => '日本語'; + String get tocCreateCustomTemplate => '创建自定义模板...'; @override - String get languageKorean => '한국어'; + String get tocTemplateCreated => '已创建模板'; @override - String get languageSimplifiedChinese => '简体中文'; + String get tocTemplateDefault => '默认'; @override - String get toggleSidebar => '侧边栏面板'; + String get tocTemplateCustom => '自定义'; @override - String get topLeft => '左上'; + String get tocTemplateFilename => '文件名:'; @override - String get topRight => '右上'; + String get tocTemplateMarkdown => 'Markdown (.md)'; @override - String get undo => '撤销'; + String get tocTemplateXml => 'XML (.topic)'; @override - String get validate => '验证'; + String get tocTemplatesInternal => '内部'; @override - String get validation => '验证'; + String get tocTemplateUnnamed => '未命名'; @override - String get viewMode => '视图模式'; + String get tocTemplateNew => '新建模板...'; @override - String get welcome => '欢迎'; + String get tocTemplateName => '名称:'; @override - String get writerside => 'Writerside'; + String get tocTemplateExtension => '扩展名:'; @override - String get xml => 'XML'; + String get tocTemplateLoadError => '无法加载模板。现有模板未更改。'; @override - String get fileTypeMarkdown => 'Markdown'; + String get tocTemplateSaveError => '无法保存模板。您的编辑内容仍保留在此处。'; @override - String get fileTypeImages => '图像'; + String get tocTemplateConflict => '模板已在其他窗口中更改。请取消并重新打开编辑器。'; @override - String get openMarkdownFile => '打开 Markdown 文件'; + String get tocTemplateDuplicateName => '已存在相同名称和扩展名的模板。'; @override - String get markdownFileExtensions => '.md 或 .markdown'; + String get tocTemplateFormat => '格式:'; @override - String get openFolderOrWritersideProject => '打开文件夹或 Writerside 项目'; + String get tocTemplateReset => '重置'; @override - String get markdownFolderOrWritersideProject => - 'Markdown 文件夹或兼容 Writerside 的项目'; + String get tocTemplateRetry => '重试'; @override - String get noOpenFile => '没有打开的文件'; + String tocRemoveElementsDialog(int count) { + return '移除 $count 个目录元素'; + } @override - String get shortcutDeleteTreeItemDescription => '删除选中的文件项,或从目录中移除选中的主题'; + String get tocRemoveElementsRetainFiles => '不会删除与这些目录元素关联的源文件。'; @override - String get shortcutGroupGeneral => '常规'; + String get tocOk => '确定'; @override - String get shortcutNewDocument => '创建'; + String get tocSelectExistingTopic => '选择要添加到当前实例的主题'; @override - String get shortcutNewDocumentDescription => '创建 Markdown 文件或 Writerside 项目'; + String get tocGroupName => '组名'; @override - String get shortcutOpenDescription => '打开 Markdown 文件、文件夹或 Writerside 项目'; + String get tocMoveLineUp => '上移行'; @override - String get shortcutSaveDescription => '保存当前文档'; + String get tocMoveLineDown => '下移行'; @override - String get shortcutSearchDescription => '搜索当前工作区'; + String get tocEmptyMdTopic => '空 MD 主题'; @override - String get shortcutKeyboardShortcutsDescription => '显示此键盘快捷键参考'; + String get tocEmptyXmlTopic => '空 XML 主题'; @override - String get shortcutSyntaxReferenceDescription => '打开语法参考'; + String get tocCopySpecial => '特殊复制'; @override - String get shortcutSettingsDescription => '打开 BusyMark 设置'; + String tocCopyFileName(String name) { + return '主题文件名 \'$name\''; + } @override - String get shortcutNextTab => '下一个标签页'; + String get tocCopyFilePath => '主题文件路径'; @override - String get shortcutNextTabDescription => '移至下一个打开的标签页'; + String tocCopyTopicTitle(String title) { + return '主题标题 \'$title\''; + } @override - String get shortcutPreviousTab => '上一个标签页'; + String tocCopyElementId(String id) { + return '目录元素 ID \'$id\''; + } @override - String get shortcutPreviousTabDescription => '移至上一个打开的标签页'; + String get tocCopyFileNameUnavailable => '主题文件名'; @override - String get shortcutCloseTab => '关闭标签页'; + String get tocCopyTopicTitleUnavailable => '主题标题'; @override - String get shortcutCloseTabDescription => '关闭当前标签页'; + String get tocCopyElementIdUnavailable => '目录元素 ID'; @override - String get shortcutCloseAllTabs => '关闭所有标签页'; + String get tocEditTitleAction => '编辑标题...'; @override - String get shortcutCloseAllTabsDescription => '关闭所有打开的标签页'; + String get tocEditTitleDialog => '编辑标题'; @override - String get shortcutGroupTextEditing => '文本编辑'; + String get tocRemoveElementAction => '移除目录元素...'; @override - String get shortcutSelectAllDescription => '在源代码模式下选择全部文本;在编辑器模式下按两次以选择所有块'; + String get tocSetHomePage => '设为主页'; @override - String get shortcutCutDescription => '剪切选中的文本'; + String tocGoToElement(String fileName) { + return '转到“$fileName”中的目录元素'; + } @override - String get shortcutCopyDescription => '复制选中的文本'; + String get tocSynchronize => '同步目录与编辑器'; @override - String get shortcutPasteDescription => '从剪贴板粘贴'; + String get tocEmptyGroupAction => '空组'; @override - String get shortcutPastePlainTextDescription => '粘贴剪贴板中的无格式文本'; + String get tocNewEmptyGroup => '新建空组'; @override - String get shortcutUndoDescription => '撤销上次编辑'; + String get tocGroupAction => '分组'; @override - String get shortcutRedoDescription => '重做上次撤销的编辑'; + String get tocNewGroup => '新建组'; @override - String get shortcutInsertIndentation => '插入缩进'; + String get tocSortChildren => '按字母顺序排列子主题'; @override - String get shortcutInsertIndentationDescription => '在光标处插入缩进'; + String get tocLinkTopicFiles => '将主题文件链接到目录...'; @override - String get shortcutOutdentSource => '减少源代码缩进'; + String get addLocalMarkdownFiles => '添加本地 Markdown 文件'; @override - String get shortcutOutdentSourceDescription => '在源代码模式下减少一级缩进'; + String get tocTitleField => '目录标题:'; @override - String get shortcutEscape => '关闭搜索或清除块选择'; + String get tocTopicTitleField => '主题标题:'; @override - String get shortcutEscapeDescription => '关闭工作区搜索,或在编辑器模式下清除块选择'; + String get tocAdvancedSettings => '高级设置'; @override - String get shortcutGroupFormatting => '格式'; + String tocInstanceTitleField(String instanceId) { + return '“$instanceId”的标题:'; + } @override - String get shortcutBoldDescription => '切换选中文本的粗体'; + String get tocOnlyTitleField => '仅用于目录的标题:'; @override - String get shortcutItalicDescription => '切换选中文本的斜体'; + String get tocInstanceTitleExplanation => '仅用于当前实例。默认继承主题标题。'; @override - String get shortcutUnderlineDescription => '切换选中文本的下划线'; + String get tocOnlyTitleExplanation => + '仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见'; @override - String get shortcutLinkDescription => '插入或编辑链接'; + String get tocTitleDocumentationLink => '此处'; @override - String get shortcutInlineCodeDescription => '切换选中文本的行内代码格式'; + String get tocFindUsages => '查找'; @override - String get shortcutStrikethroughDescription => '切换选中文本的删除线'; + String get tocRefactorMenu => '重构'; @override - String get shortcutGroupBlocks => '块'; + String get tocSafeDelete => '安全删除'; @override - String get shortcutParagraphDescription => '将当前块设为段落'; + String get tocDuplicate => '创建副本'; @override - String get shortcutHeading1Description => '将当前块设为标题 1'; + String get tocDuplicateFilename => '主题文件名:'; @override - String get shortcutHeading2Description => '将当前块设为标题 2'; + String get tocDuplicateDialog => '创建主题副本'; @override - String get shortcutHeading3Description => '将当前块设为标题 3'; + String get pdfTitlePage => '标题页'; @override - String get shortcutHeading4Description => '将当前块设为标题 4'; + String get pdfIncludeTitlePage => '包含标题页'; @override - String get shortcutHeading5Description => '将当前块设为标题 5'; + String get pdfTitlePageNote => '此页不显示页眉、页脚或页码。'; @override - String get shortcutHeading6Description => '将当前块设为标题 6'; + String get pdfTitlePageTitle => '标题'; @override - String get shortcutGroupLists => '列表'; + String get pdfTitlePageSubtitle => '副标题'; @override - String get numberedList => '编号列表'; + String get pdfTitlePageAuthor => '作者'; @override - String get shortcutNumberedListDescription => '切换编号列表格式'; + String get pdfTitlePageOrganization => '组织'; @override - String get bulletedList => '项目符号列表'; + String get pdfTitlePageVersion => '版本'; @override - String get shortcutBulletedListDescription => '切换项目符号列表格式'; + String get pdfTitlePageDate => '日期'; @override - String get checklist => '检查清单'; + String get pdfTitlePageTitleRequired => '请输入标题页的标题。'; @override - String get shortcutChecklistDescription => '切换检查清单格式'; + String get pdfTocSectionNote => '从单独一页开始,可以跨多页。'; @override - String get shortcutGroupSidebar => '侧边栏'; + String get appTitle => 'BusyMark'; @override - String get sidebarViewMenu => '侧边栏视图'; + String get appSubtitle => 'Markdown 文件和兼容 Writerside 的文档项目编辑器。'; @override - String get createMarkdownFile => '创建 Markdown 文件'; + String get aboutBusyMark => '关于 BusyMark'; @override - String get createMarkdownFileDescription => '开始创建未保存的本地 Markdown 文档'; + String get aboutTagline => 'Markdown 和 Writerside 编辑器'; @override - String get createWritersideProject => '创建 Writerside 项目'; + String get aboutLicenseLabel => '许可证'; @override - String get createWritersideProjectDescription => '开始创建本地兼容 Writerside 的项目'; + String get aboutLicenseName => 'Apache License 2.0'; @override - String get defaultProjectName => '文档'; + String get aboutWebsite => '网站'; @override - String get defaultInstanceName => '用户指南'; + String get aboutSourceCode => '源代码'; @override - String get defaultStartTopicTitle => '开始使用'; + String get reportIssue => '报告问题'; @override - String get projectName => '项目名称'; + String get feedbackCategory => '类别'; @override - String get directoryName => '目录名称'; + String get feedbackChooseCategory => '选择类别'; @override - String get instanceName => '实例名称'; + String get feedbackCategoryProblem => '问题或错误'; @override - String get instanceId => '实例 ID'; + String get feedbackCategoryFeature => '功能请求'; @override - String get startTopicTitle => '起始主题标题'; + String get feedbackCategoryPrivacySecurity => '隐私或安全问题'; @override - String get location => '位置'; + String get feedbackCategoryUsability => '可用性问题'; @override - String get projectNameRequired => '项目名称为必填项。'; + String get feedbackCategoryOther => '其他'; @override - String get directoryNameRequired => '目录名称为必填项。'; + String get feedbackSubject => '主题'; @override - String get useSingleSafeDirectoryName => '使用单一且安全的目录名称。'; + String get feedbackMessage => '详细留言'; @override - String get useLowercaseIdentifier => '使用仅包含小写字母、数字、下划线或连字符的小写标识符。'; + String get feedbackReplyEmail => '回复邮箱(可选)'; @override - String get startTopicTitleRequired => '起始主题标题为必填项。'; + String get feedbackIncludeTechnicalDetails => '包含技术详细信息'; @override - String get createWritersideProjectFailed => '无法创建 Writerside 项目。'; + String get feedbackTechnicalDetailsDisclosure => + '启用后,此选项仅会添加您的 Linux 操作系统版本和 BusyMark 应用语言。不会附加日志、文件、账户数据或其他诊断信息。'; @override - String get settingsTitle => 'BusyMark 设置'; + String get feedbackSubmit => '提交'; @override - String get autoSave => '自动保存'; + String get feedbackSubmitting => '正在提交…'; @override - String get autoSaveDescription => '在短暂空闲后自动保存文件更改。'; + String get feedbackCategoryRequired => '请选择类别。'; @override - String get wordWrap => '自动换行'; + String get feedbackSubjectLength => '主题必须包含 3 到 120 个字符。'; @override - String get wordWrapDescription => '将长行换行以适应编辑器宽度,而不是水平滚动。'; + String get feedbackMessageLength => '信息必须包含 10 到 5,000 个字符。'; @override - String get editorFontSize => '编辑器字体大小'; + String get feedbackReplyEmailInvalid => '请输入有效的邮箱地址,或将此字段留空。'; @override - String get validateOnEdit => '编辑时验证'; + String get feedbackConnectionFailure => 'BusyMark 无法连接。请检查网络连接后重试。'; @override - String get clearRecentWorkspaces => '清除最近使用的工作区'; + String get feedbackTimeoutFailure => '请求超时。请重试。'; @override - String get editingButtonsPosition => '编辑按钮位置'; + String get feedbackRateLimitedFailure => '此连接发送的报告过多。请稍候再试。'; @override - String get editingButtonsPositionDescription => '选择浮动 WYSIWYG 编辑按钮的显示位置。'; + String get feedbackRejectedFailure => '服务器拒绝了此报告。请检查表单字段后重试。'; @override - String get editingButtonsDirection => '编辑按钮方向'; + String get feedbackServerFailure => '服务器无法接收此报告。请稍后重试。'; @override - String get editingButtonsDirectionDescription => '选择水平或垂直排列浮动 WYSIWYG 编辑按钮。'; + String feedbackSuccess(String id) { + return '反馈已发送。参考 ID:$id'; + } @override - String get horizontal => '水平'; + String get advanced => '高级'; @override - String get vertical => '垂直'; + String get addToGit => '添加到 Git'; @override - String get privacy => '隐私'; + String get appearance => '外观'; @override - String get allowRemoteImages => '加载远程图像'; + String get apply => '应用'; @override - String get allowRemoteImagesDescription => - '允许 Markdown 预览和编辑器从 http 和 https URL 加载图像。'; + String get back => '返回'; @override - String get clearRemoteImagePermissions => '清除远程图像权限'; + String get bottomLeft => '左下'; @override - String get clearRemoteImagePermissionsDescription => '忘记已获准加载远程图像的工作区。'; + String get bottomRight => '右下'; @override - String get clearGitWorkspaceTrust => '清除受信任的 Git 工作区'; + String get cancel => '取消'; @override - String get clearGitWorkspaceTrustDescription => '为之前受信任的工作区启用 Git 功能前先进行询问。'; + String get choose => '选择'; @override - String get settingsEditingButtonsSectionTitle => '编辑按钮'; + String get chooseLocation => '选择位置'; @override - String get spellingDictionaries => '拼写词典'; + String get copy => '复制'; @override - String get spellingDictionariesDescription => '安装和管理拼写词典'; + String get copyPlainText => '复制为纯文本'; @override - String get settingsDictionariesSectionTitle => '词典'; + String get clipboardCopyFailed => '无法将所选内容复制到剪贴板。'; @override - String get availableSpellingDictionaries => '可用词典'; + String get copyName => '复制名称'; @override - String get customSpellingDictionaries => '自定义词典'; + String get copyFileName => '复制文件名'; @override - String get spellingDictionaryProblems => '词典问题'; + String get copyPath => '复制路径'; @override - String get settingsWindowSectionTitle => '窗口'; + String get create => '创建'; @override - String get settingsReopenWorkspaceOnStartupTitle => '启动时重新打开上次的工作区'; + String get creating => '正在创建…'; @override - String get settingsReopenWorkspaceOnStartupDescription => - 'BusyMark 启动时打开上次会话中的工作区和标签页。'; + String get cut => '剪切'; @override - String get settingsConfirmCloseWithUnsavedChangesTitle => '有未保存更改时关闭前进行确认'; + String get promoteSection => '提升节级别'; @override - String get settingsConfirmCloseWithUnsavedChangesDescription => - '文档有未保存更改时,关闭 BusyMark 前先进行询问。'; + String get demoteSection => '降低节级别'; @override - String get closeUnsavedChangesTitle => '未保存的更改'; + String get moveSectionUp => '上移节'; @override - String get closeUnsavedChangesSingleMessage => - '此文档有未保存的更改。关闭 BusyMark 前保存更改?'; + String get moveSectionDown => '下移节'; @override - String closeUnsavedChangesMultipleMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '有 $count 个文档包含未保存的更改。关闭 BusyMark 前保存更改?', - one: '有 1 个文档包含未保存的更改。关闭 BusyMark 前保存更改?', - zero: '关闭 BusyMark 前保存更改?', - ); - return '$_temp0'; - } + String get confirmDeleteSectionTitle => '删除此节?'; @override - String get closeUnsavedChangesCancel => '取消'; + String confirmDeleteSectionMessage(String name) { + return '删除“$name”及其节中的全部内容?此操作无法撤销。'; + } @override - String get closeUnsavedChangesDiscard => '放弃'; + String get darkTheme => '深色'; @override - String get closeUnsavedChangesSave => '保存'; + String get delete => '删除'; @override - String get currentFile => '当前文件'; + String get discard => '放弃'; @override - String get unsavedChanges => '未保存的更改'; + String get editor => '编辑器'; @override - String unsavedChangesMessage(String fileName) { - return '$fileName 中有未保存的更改。继续前保存这些更改?'; - } + String get file => '文件'; @override - String unsavedChangesMultipleMessage(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '有 $count 个文档包含未保存的更改。继续前保存它们?', - one: '有 1 个文档包含未保存的更改。继续前保存它?', - ); - return '$_temp0'; - } + String get fileHistory => '文件历史记录'; @override - String get fileChangedOnDisk => '文件已在磁盘上更改'; + String get folder => '文件夹'; @override - String get fileChangedOnDiskMessage => '自打开此文件后,它已在磁盘上发生更改。覆盖它?'; + String get insert => '插入'; @override - String get untitledMarkdownFileName => '未命名.md'; + String get keyboardShortcuts => '键盘快捷键'; @override - String get unorderedList => '无序列表'; + String get commandPalette => '命令面板'; @override - String get orderedList => '有序列表'; + String get commandPaletteHint => '输入命令'; @override - String get taskList => '任务列表'; + String get commandPaletteEmpty => '没有匹配的命令'; @override - String get toggleTaskChecked => '切换任务选中状态'; + String get commandUnavailableInContext => '当前编辑器上下文中无法使用此命令'; @override - String get indentListItem => '增加列表项缩进'; + String get lightTheme => '浅色'; @override - String get outdentListItem => '减少列表项缩进'; + String get mainMenu => '主菜单'; @override - String get blockquote => '引用块'; + String get fullScreen => '全屏'; @override - String get codeBlock => '代码块'; + String get markdown => 'Markdown'; @override - String get codeBlockLanguage => '代码块语言'; + String get open => '打开'; @override - String get image => '图像'; + String get openInFiles => '在文件中打开'; @override - String get video => '视频'; + String get pathActions => '路径操作'; @override - String get openVideo => '播放视频'; + String get outline => '大纲'; @override - String get pauseVideo => '暂停视频'; + String get overwrite => '覆盖'; @override - String get videoUnavailable => '视频不可用'; + String get paste => '粘贴'; @override - String get videoPreview => '视频预览'; + String get reading => '阅读'; @override - String get diagnosticWritersideVideoMissingSource => '视频缺少 src 属性。'; + String get removeFromRecent => '从最近使用记录中移除'; @override - String diagnosticWritersideVideoUnsupportedSource(String source) { - return '不支持的视频源:$source'; - } + String get recent => '最近使用'; @override - String diagnosticWritersideVideoMissingFile(String source) { - return '视频文件不存在:$source'; - } + String get redo => '重做'; @override - String diagnosticWritersideVideoMissingPreview(String preview) { - return '视频预览图像不存在:$preview'; - } + String get save => '保存'; @override - String get inlineImage => '行内图像'; + String get search => '搜索'; @override - String get table => '表格'; + String get selectAll => '全选'; @override - String get htmlBlock => 'HTML 块'; + String get settings => '设置'; @override - String get htmlContentDefault => 'HTML 内容'; + String get source => '源代码'; @override - String get shortcutHtmlBlockDescription => '插入或编辑 HTML 块'; + String get split => '拆分'; @override - String get renderedHtml => '已渲染的 HTML'; + String get systemTheme => '系统'; @override - String get editHtml => '编辑 HTML'; + String get theme => '主题'; @override - String get htmlSource => 'HTML 源代码'; + String get appLanguage => '语言'; @override - String get thematicBreak => '分隔线'; + String get systemLanguage => '系统'; @override - String get bold => '粗体'; + String get languageEnglish => 'English'; @override - String get italic => '斜体'; + String get languageGerman => 'Deutsch'; @override - String get underline => '下划线'; + String get languageDutch => 'Nederlands'; @override - String get strikethrough => '删除线'; + String get languageTurkish => 'Türkçe'; @override - String get inlineCode => '行内代码'; + String get languageItalian => 'Italiano'; @override - String get link => '链接'; + String get languageNorwegian => 'Norsk bokmål'; @override - String get hardLineBreak => '硬换行'; + String get languageFrench => 'Français'; @override - String get insertBlankLine => '插入空行'; + String get languageRussian => 'Русский'; @override - String get textStyle => '文本样式'; + String get languageUkrainian => 'Українська'; @override - String get paragraph => '段落'; + String get languagePolish => 'Polski'; @override - String get heading1 => '标题 1'; + String get languageSpanish => 'Español'; @override - String get heading2 => '标题 2'; + String get languagePortuguese => 'Português'; @override - String get heading3 => '标题 3'; + String get languageArabic => 'العربية'; @override - String get heading4 => '标题 4'; + String get languagePersian => 'فارسی'; @override - String get heading5 => '标题 5'; + String get languageHindi => 'हिन्दी'; @override - String get heading6 => '标题 6'; + String get languageIndonesian => 'Bahasa Indonesia'; @override - String headingLevelAbbreviation(int level) { - return 'H$level'; - } + String get languageEstonian => 'Eesti'; @override - String get deleteTable => '删除表格'; + String get languageVietnamese => 'Tiếng Việt'; @override - String tableColumnNumber(int columnNumber) { - return '列 $columnNumber'; - } + String get languageJapanese => '日本語'; @override - String get insertColumnLeft => '在左侧插入列'; + String get languageKorean => '한국어'; @override - String get insertColumnRight => '在右侧插入列'; + String get languageSimplifiedChinese => '简体中文'; @override - String get deleteColumn => '删除列'; + String get toggleSidebar => '侧边栏面板'; @override - String get tableAlignmentUnspecified => '对齐方式:未指定'; + String get topLeft => '左上'; @override - String get tableAlignmentLeft => '对齐方式:左对齐'; + String get topRight => '右上'; @override - String get tableAlignmentCenter => '对齐方式:居中'; + String get undo => '撤销'; @override - String get tableAlignmentRight => '对齐方式:右对齐'; + String get validate => '验证'; @override - String tableRowNumber(int rowNumber) { - return '行 $rowNumber'; - } + String get validation => '验证'; @override - String get insertRowAbove => '在上方插入行'; + String get viewMode => '视图模式'; @override - String get insertRowBelow => '在下方插入行'; + String get welcome => '欢迎'; @override - String get deleteRow => '删除行'; + String get writerside => 'Writerside'; @override - String get tableHeaderHint => '标题'; + String get xml => 'XML'; @override - String get tableCellHint => '单元格'; + String get fileTypeMarkdown => 'Markdown'; @override - String get language => '语言'; + String get fileTypeImages => '图像'; @override - String get hideEditingButtons => '隐藏编辑按钮'; + String get openMarkdownFile => '打开 Markdown 文件'; @override - String get showEditingButtons => '显示编辑按钮'; + String get markdownFileExtensions => '.md 或 .markdown'; @override - String get altText => '替代文本'; + String get openFolderOrWritersideProject => '打开文件夹或 Writerside 项目'; @override - String get editorPlaceholderText => '文本'; + String get markdownFolderOrWritersideProject => + 'Markdown 文件夹或兼容 Writerside 的项目'; @override - String get editorPlaceholderCode => '代码'; + String get noOpenFile => '没有打开的文件'; @override - String get editorPlaceholderAltText => '替代文本'; + String get shortcutDeleteTreeItemDescription => '删除选中的文件项,或从目录中移除选中的主题'; @override - String get describeTheImage => '描述图像'; + String get shortcutGroupGeneral => '常规'; @override - String get columns => '列'; + String get shortcutNewDocument => '创建'; @override - String get rows => '行'; + String get shortcutNewDocumentDescription => '创建 Markdown 文件或 Writerside 项目'; @override - String tableHeaderNumber(int columnNumber) { - return '标题 $columnNumber'; - } + String get shortcutOpenDescription => '打开 Markdown 文件、文件夹或 Writerside 项目'; @override - String get tableCellDefault => '单元格'; + String get shortcutSaveDescription => '保存当前文档'; @override - String get noImageSource => '没有图像源'; + String get shortcutSearchDescription => '搜索当前工作区'; @override - String get remoteImageBlocked => '远程图像已阻止'; + String get shortcutKeyboardShortcutsDescription => '显示此键盘快捷键参考'; @override - String get remoteImageBlockedTooltip => '选择是否允许 BusyMark 加载远程图像。'; + String get shortcutSyntaxReferenceDescription => '打开语法参考'; @override - String get remoteImagesBlockedTitle => '远程图像已阻止'; + String get shortcutSettingsDescription => '打开 BusyMark 设置'; @override - String get remoteImagesBlockedMessage => - '本文档引用了来自互联网的图像。加载这些图像可能会向图像托管方泄露网络信息。'; + String get shortcutNextTab => '下一个标签页'; @override - String get loadRemoteImagesForWorkspace => '为此工作区加载'; + String get shortcutNextTabDescription => '移至下一个打开的标签页'; @override - String get alwaysLoadRemoteImages => '始终加载远程图像'; + String get shortcutPreviousTab => '上一个标签页'; @override - String get hideSidebar => '隐藏侧边栏面板'; + String get shortcutPreviousTabDescription => '移至上一个打开的标签页'; @override - String get showSidebar => '显示侧边栏面板'; + String get shortcutCloseTab => '关闭标签页'; @override - String get showPreview => '显示预览'; + String get shortcutCloseTabDescription => '关闭当前标签页'; @override - String get hidePreview => '隐藏预览'; + String get shortcutCloseAllTabs => '关闭所有标签页'; @override - String get workspaceKindUnsavedMarkdown => '未保存的 Markdown 文件'; + String get shortcutCloseAllTabsDescription => '关闭所有打开的标签页'; @override - String get workspaceKindSingleMarkdown => '单个 Markdown 文件'; + String get shortcutGroupTextEditing => '文本编辑'; @override - String get workspaceKindMarkdownFolder => 'Markdown 文件夹'; + String get shortcutSelectAllDescription => '在源代码模式下选择全部文本;在编辑器模式下按两次以选择所有块'; @override - String get workspaceKindWritersideModule => 'Writerside 模块'; + String get shortcutCutDescription => '剪切选中的文本'; @override - String get problems => '问题'; + String get shortcutCopyDescription => '复制选中的文本'; @override - String diagnosticCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 条诊断信息', - one: '1 条诊断信息', - zero: '没有诊断信息', - ); - return '$_temp0'; - } + String get shortcutPasteDescription => '从剪贴板粘贴'; @override - String get files => '文件'; + String get shortcutPastePlainTextDescription => '粘贴剪贴板中的无格式文本'; @override - String get toc => '目录'; + String get shortcutUndoDescription => '撤销上次编辑'; @override - String get tocActions => '目录操作'; + String get shortcutRedoDescription => '重做上次撤销的编辑'; @override - String get markdownUnsaved => 'Markdown - 未保存'; + String get shortcutInsertIndentation => '插入缩进'; @override - String workspaceDetail(String kind, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 个文件', - one: '1 个文件', - ); - return '$kind - $_temp0'; - } + String get shortcutInsertIndentationDescription => '在光标处插入缩进'; @override - String get noFiles => '没有文件'; + String get shortcutOutdentSource => '减少源代码缩进'; @override - String get newFile => '新建文件'; + String get shortcutOutdentSourceDescription => '在源代码模式下减少一级缩进'; @override - String get noWritersideToc => '没有 Writerside 目录'; + String get shortcutEscape => '关闭搜索或清除块选择'; @override - String get tocSection => '目录节'; + String get shortcutEscapeDescription => '关闭工作区搜索,或在编辑器模式下清除块选择'; @override - String get newTopic => '新建主题'; + String get shortcutGroupFormatting => '格式'; @override - String get newChildTopic => '新建子主题'; + String get shortcutBoldDescription => '切换选中文本的粗体'; @override - String get newSiblingTopic => '新建主题'; + String get shortcutItalicDescription => '切换选中文本的斜体'; @override - String get renameTopicFile => '重命名主题文件'; + String get shortcutUnderlineDescription => '切换选中文本的下划线'; @override - String get topicPlacement => '目录位置'; + String get shortcutLinkDescription => '插入或编辑链接'; @override - String get tocRoot => '位于目录根级别'; + String get shortcutInlineCodeDescription => '切换选中文本的行内代码格式'; @override - String get afterSelectedTopic => '位于选中主题之后'; + String get shortcutStrikethroughDescription => '切换选中文本的删除线'; @override - String get insideSelectedTopic => '位于选中主题内'; + String get shortcutGroupBlocks => '块'; @override - String get pasteAfterTopic => '粘贴到主题之后'; + String get shortcutParagraphDescription => '将当前块设为段落'; @override - String get pasteAsChildTopic => '粘贴为子主题'; + String get shortcutHeading1Description => '将当前块设为标题 1'; @override - String get removeFromToc => '从目录中移除'; + String get shortcutHeading2Description => '将当前块设为标题 2'; @override - String get confirmRemoveFromTocTitle => '从目录中移除?'; + String get shortcutHeading3Description => '将当前块设为标题 3'; @override - String confirmRemoveFromTocMessage(String name) { - return '从此目录中移除 $name?主题文件将被保留。'; - } + String get shortcutHeading4Description => '将当前块设为标题 4'; @override - String get confirmDeleteTopicTitle => '删除主题文件?'; + String get shortcutHeading5Description => '将当前块设为标题 5'; @override - String confirmDeleteTopicMessage(String name) { - return '删除 $name 并从每个目录中移除它?此操作无法撤销。'; - } + String get shortcutHeading6Description => '将当前块设为标题 6'; @override - String get safeDeleteTopicFile => '安全删除主题文件…'; + String get shortcutGroupLists => '列表'; @override - String get removeTocElement => '移除目录元素'; + String get numberedList => '编号列表'; @override - String get removeTocElements => '移除目录元素'; + String get shortcutNumberedListDescription => '切换编号列表格式'; @override - String get reviewUsages => '查看使用情况'; + String get bulletedList => '项目符号列表'; @override - String get deleteTopicFile => '删除主题文件'; + String get shortcutBulletedListDescription => '切换项目符号列表格式'; @override - String get removeAction => '移除'; + String get checklist => '检查清单'; @override - String topicRemovalSummary(String topic) { - return '移除目录元素“$topic”?与目录关联的源文件不会被删除。'; - } + String get shortcutChecklistDescription => '切换检查清单格式'; @override - String safeDeleteTopicSummary(String topic) { - return '删除“$topic”,并在整个 Writerside 项目中安全更新其引用。'; - } + String get shortcutGroupSidebar => '侧边栏'; @override - String childTopicsPromoted(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 个子主题将上移一级。', - one: '1 个子主题将上移一级。', - ); - return '$_temp0'; - } + String get sidebarViewMenu => '侧边栏视图'; @override - String get topicIsHomePageRemovalWarning => '此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。'; + String get createMarkdownFile => '创建 Markdown 文件'; @override - String get newHomePage => '新主页'; + String get createMarkdownFileDescription => '开始创建未保存的本地 Markdown 文档'; @override - String get chooseHomePage => '选择主页'; + String get createWritersideProject => '创建 Writerside 项目'; @override - String get noHomePageReplacement => '移除主页之前,请向此实例添加或链接另一个符合条件的主题。'; + String get createWritersideProjectDescription => '开始创建本地兼容 Writerside 的项目'; @override - String get topicIsStartPageRemovalWarning => '此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。'; + String get defaultProjectName => '文档'; @override - String topicUsagesCount(int count) { - return '找到 $count 处使用。'; - } + String get defaultInstanceName => '用户指南'; @override - String get noBreakingTopicUsages => '未找到会导致引用失效的引用。'; + String get defaultStartTopicTitle => '开始使用'; @override - String get topicUsagesFound => 'BusyMark 找到了以下对该主题的引用。'; + String get projectName => '项目名称'; @override - String get topicUsageTocElements => '目录元素'; + String get directoryName => '目录名称'; @override - String get topicUsageStartPages => '主页'; + String get instanceName => '实例名称'; @override - String get topicUsageTopicLinks => '主题链接'; + String get instanceId => '实例 ID'; @override - String get topicUsageIncludes => '包含项'; + String get startTopicTitle => '起始主题标题'; @override - String usageCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 次使用', - one: '1 次使用', - ); - return '$_temp0'; - } + String get location => '位置'; @override - String get refactoringOptions => '重构选项'; + String get projectNameRequired => '项目名称为必填项。'; @override - String get updateUsagesAutomatically => '自动更新使用情况'; + String get directoryNameRequired => '目录名称为必填项。'; @override - String get updateUsagesAutomaticallyDescription => '移除目录引用和包含项,同时保留链接文本。'; + String get useSingleSafeDirectoryName => '使用单一且安全的目录名称。'; @override - String get manualUsageUpdatesRequired => '执行此重构前,某些使用情况需要手动更改。'; + String get useLowercaseIdentifier => '使用仅包含小写字母、数字、下划线或连字符的小写标识符。'; @override - String get setRedirectTo => '设置重定向到:'; + String get startTopicTitleRequired => '起始主题标题为必填项。'; @override - String get noRedirectDescription => '不重定向旧的已发布页面。'; + String get createWritersideProjectFailed => '无法创建 Writerside 项目。'; @override - String get redirectTarget => '重定向目标'; + String get settingsTitle => 'BusyMark 设置'; @override - String get remainingUsagesBlockRemoval => '请在继续前查看并更新剩余的使用情况,或在可用时启用自动更新。'; + String get autoSave => '自动保存'; @override - String usagesOfTopic(String topic) { - return '$topic 的使用情况'; - } + String get autoSaveDescription => '在短暂空闲后自动保存文件更改。'; @override - String get noUsagesFound => '未找到使用情况'; + String get wordWrap => '自动换行'; @override - String get outsideSelectedInstance => '在选中的实例之外'; + String get wordWrapDescription => '将长行换行以适应编辑器宽度,而不是水平滚动。'; @override - String get doRefactor => '执行重构'; + String get editorFontSize => '编辑器字体大小'; @override - String get preview => '预览'; + String get validateOnEdit => '编辑时验证'; @override - String get topicRenamePreviewTitle => '重命名预览'; + String get clearRecentWorkspaces => '清除最近使用的工作区'; @override - String topicRenamePathChange(String oldPath, String newPath) { - return '路径:$oldPath → $newPath'; - } + String get editingButtonsPosition => '编辑按钮位置'; @override - String topicRenameIdChange(String oldId, String newId) { - return '主题 ID:$oldId → $newId'; - } + String get editingButtonsPositionDescription => '选择浮动 WYSIWYG 编辑按钮的显示位置。'; @override - String topicRenameUrlChange(String instance, String oldName, String newName) { - return '实例 $instance:$oldName → $newName'; - } + String get editingButtonsDirection => '编辑按钮方向'; @override - String get topicRenameAffectedFiles => '受影响的文件'; + String get editingButtonsDirectionDescription => '选择水平或垂直排列浮动 WYSIWYG 编辑按钮。'; @override - String get orphanTopicTitle => '主题文件已不再使用'; + String get horizontal => '水平'; @override - String get keepTopicFile => '保留主题文件'; + String get vertical => '垂直'; @override - String orphanTopicMessage(String topic) { - return '“$topic”在此 Writerside 项目中已不再使用。删除该文件,或保留它以供其他实例使用。'; - } + String get privacy => '隐私'; @override - String get defaultNewTopicTitle => '新主题'; + String get allowRemoteImages => '加载远程图像'; @override - String get topicTitle => '主题标题'; + String get allowRemoteImagesDescription => + '允许 Markdown 预览和编辑器从 http 和 https URL 加载图像。'; @override - String get fileName => '文件名'; + String get clearRemoteImagePermissions => '清除远程图像权限'; @override - String get topicTitleRequired => '主题标题为必填项。'; + String get clearRemoteImagePermissionsDescription => '忘记已获准加载远程图像的工作区。'; @override - String get fileNameRequired => '文件名为必填项。'; + String get clearGitWorkspaceTrust => '清除受信任的 Git 工作区'; @override - String get rename => '重命名'; + String get clearGitWorkspaceTrustDescription => '为之前受信任的工作区启用 Git 功能前先进行询问。'; @override - String get confirmDeleteFileTitle => '删除文件?'; + String get settingsEditingButtonsSectionTitle => '编辑按钮'; @override - String get confirmDeleteFolderTitle => '删除文件夹?'; + String get spellingDictionaries => '拼写词典'; @override - String confirmDeleteFileMessage(String name) { - return '删除 $name?此操作无法撤销。'; - } + String get spellingDictionariesDescription => '安装和管理拼写词典'; @override - String confirmDeleteFolderMessage(String name) { - return '删除 $name 及其中的所有文件?此操作无法撤销。'; - } + String get settingsDictionariesSectionTitle => '词典'; @override - String get useSingleSafeFileName => '使用单一且安全的文件名。'; + String get availableSpellingDictionaries => '可用词典'; @override - String useExpectedExtension(String extension) { - return '为选中的格式使用 $extension 扩展名。'; - } + String get customSpellingDictionaries => '自定义词典'; @override - String get useIdentifierCharacters => '在扩展名之前仅使用字母、数字、下划线或连字符。'; + String get spellingDictionaryProblems => '词典问题'; @override - String get topicIdAlreadyExists => '主题 ID 已存在。'; + String get settingsWindowSectionTitle => '窗口'; @override - String get createWritersideTopicFailed => '无法创建 Writerside 主题。'; + String get settingsReopenWorkspaceOnStartupTitle => '启动时重新打开上次的工作区'; @override - String get noOutline => '没有大纲'; + String get settingsReopenWorkspaceOnStartupDescription => + 'BusyMark 启动时打开上次会话中的工作区和标签页。'; @override - String expandKind(String kind) { - return '展开 $kind'; - } + String get settingsConfirmCloseWithUnsavedChangesTitle => '有未保存更改时关闭前进行确认'; @override - String collapseKind(String kind) { - return '折叠 $kind'; - } + String get settingsConfirmCloseWithUnsavedChangesDescription => + '文档有未保存更改时,关闭 BusyMark 前先进行询问。'; @override - String get foldKindSection => '节'; + String get closeUnsavedChangesTitle => '未保存的更改'; @override - String get foldKindList => '列表'; + String get closeUnsavedChangesSingleMessage => + '此文档有未保存的更改。关闭 BusyMark 前保存更改?'; @override - String get foldKindQuote => '引用'; + String closeUnsavedChangesMultipleMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '有 $count 个文档包含未保存的更改。关闭 BusyMark 前保存更改?', + one: '有 1 个文档包含未保存的更改。关闭 BusyMark 前保存更改?', + zero: '关闭 BusyMark 前保存更改?', + ); + return '$_temp0'; + } @override - String get foldKindTag => '标签'; + String get closeUnsavedChangesCancel => '取消'; @override - String get sourceSearchPreviousMatch => '上一个匹配项'; + String get closeUnsavedChangesDiscard => '放弃'; @override - String get sourceSearchNextMatch => '下一个匹配项'; + String get closeUnsavedChangesSave => '保存'; @override - String get sourceSearchCaseSensitive => '区分大小写'; + String get currentFile => '当前文件'; @override - String get sourceSearchWholeWord => '全字匹配'; + String get unsavedChanges => '未保存的更改'; @override - String get sourceSearchRegex => '正则表达式'; + String unsavedChangesMessage(String fileName) { + return '$fileName 中有未保存的更改。继续前保存这些更改?'; + } @override - String get sourceSearchReplacement => '替换为'; + String unsavedChangesMultipleMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '有 $count 个文档包含未保存的更改。继续前保存它们?', + one: '有 1 个文档包含未保存的更改。继续前保存它?', + ); + return '$_temp0'; + } @override - String get sourceSearchReplaceCurrent => '替换当前匹配项'; + String get fileChangedOnDisk => '文件已在磁盘上更改'; @override - String get sourceSearchReplaceAndFindNext => '替换并查找下一个'; + String get fileChangedOnDiskMessage => '自打开此文件后,它已在磁盘上发生更改。覆盖它?'; @override - String get sourceSearchReplaceAll => '全部替换'; + String get untitledMarkdownFileName => '未命名.md'; @override - String get workspaceReplace => '在工作区中替换'; + String get unorderedList => '无序列表'; @override - String get reviewReplacements => '查看替换内容'; + String get orderedList => '有序列表'; @override - String get applyReplacements => '应用替换'; + String get taskList => '任务列表'; @override - String get skippedFiles => '跳过的文件'; + String get toggleTaskChecked => '切换任务选中状态'; @override - String get workspaceReplaceDirtyBuffer => '未保存的编辑器内容'; + String get indentListItem => '增加列表项缩进'; @override - String get workspaceReplaceDiskContent => '磁盘上的已保存内容'; + String get outdentListItem => '减少列表项缩进'; @override - String selectFileMatches(int count) { - return '选择全部 $count 个匹配项'; - } + String get blockquote => '引用块'; @override - String workspaceReplaceApplied(int matches, int files, int skipped) { - return '已在 $files 个文件中替换 $matches 个匹配项;跳过 $skipped 个。'; - } + String get codeBlock => '代码块'; @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · 末尾有换行符'; - } + String get codeBlockLanguage => '代码块语言'; @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · 末尾无换行符'; - } + String get image => '图像'; @override - String get normalizeLineEndings => '规范化行尾'; + String get video => '视频'; @override - String get mixedLineEndingsSavePrompt => '此文档包含混合的行尾格式。请选择一种格式。'; + String get openVideo => '播放视频'; @override - String workspaceReplaceMixedLineEndings(String fileName) { - return '$fileName 使用混合的行尾格式。请在替换前选择要使用的格式。'; - } + String get pauseVideo => '暂停视频'; @override - String get workspaceReplaceIssueOversized => '已跳过过大的文件。'; + String get videoUnavailable => '视频不可用'; @override - String get workspaceReplaceIssueUnreadable => '已跳过无法读取的文件。'; + String get videoPreview => '视频预览'; @override - String get workspaceReplaceIssueInvalidUtf8 => '已跳过不是有效 UTF-8 的文件。'; + String get diagnosticWritersideVideoMissingSource => '视频缺少 src 属性。'; @override - String get workspaceReplaceIssueTruncated => '替换预览已被截断。'; + String diagnosticWritersideVideoUnsupportedSource(String source) { + return '不支持的视频源:$source'; + } @override - String get workspaceReplaceIssueFileChanged => '已跳过预览后发生变化的文件。'; + String diagnosticWritersideVideoMissingFile(String source) { + return '视频文件不存在:$source'; + } @override - String get workspaceReplaceIssueBufferChanged => '已跳过预览后发生变化的编辑器缓冲区。'; + String diagnosticWritersideVideoMissingPreview(String preview) { + return '视频预览图像不存在:$preview'; + } @override - String get workspaceReplaceIssueNormalizationRequired => - '请在替换前选择 LF 或 CRLF 规范化。'; + String get inlineImage => '行内图像'; @override - String get workspaceReplaceIssuePartialConflict => - '由于文件同时发生了变化,回滚已停止。部分替换可能仍然存在;被移出的内容已保留在下方路径中。'; + String get table => '表格'; @override - String get workspaceReplaceIssueApplyFailed => '无法提交经过审查的替换;没有文件发生更改。'; + String get htmlBlock => 'HTML 块'; @override - String externalChangesTitle(String fileName) { - return '外部更改 — $fileName'; - } + String get htmlContentDefault => 'HTML 内容'; @override - String get externalFileDeleted => '此文件已从磁盘删除。'; + String get shortcutHtmlBlockDescription => '插入或编辑 HTML 块'; @override - String get externalFileChanged => '在您有未保存编辑时,此文件已在磁盘上发生变化。'; + String get renderedHtml => '已渲染的 HTML'; @override - String recoveredDocumentReview(String fileName) { - return '已恢复 $fileName 的未保存内容。请检查后保存、另存为或放弃。'; - } + String get editHtml => '编辑 HTML'; @override - String get compare => '比较'; + String get htmlSource => 'HTML 源代码'; @override - String get reloadFromDisk => '从磁盘重新加载'; + String get thematicBreak => '分隔线'; @override - String get keepMine => '保留我的版本'; + String get bold => '粗体'; @override - String get saveAs => '另存为'; + String get italic => '斜体'; @override - String get sourceSearchInvalidRegex => '无效的正则表达式'; + String get underline => '下划线'; @override - String get sourceSearchZeroLengthUnsupported => - '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; + String get strikethrough => '删除线'; @override - String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; + String get inlineCode => '行内代码'; @override - String get workspaceSearchShowMore => '显示更多结果'; + String get link => '链接'; @override - String get workspaceSearchSkippedFiles => '搜索不完整。以下文件超过 1 MiB 或无法读取:'; + String get hardLineBreak => '硬换行'; @override - String get sourceLargeFileFeaturesPaused => '大文件:已暂停语法突出显示和折叠'; + String get insertBlankLine => '插入空行'; @override - String get nothingToRead => '没有可读取的内容'; + String get textStyle => '文本样式'; @override - String get admonition => '提示块'; + String get paragraph => '段落'; @override - String get quote => '引用'; + String get heading1 => '标题 1'; @override - String get note => '备注'; + String get heading2 => '标题 2'; @override - String get tip => '提示'; + String get heading3 => '标题 3'; @override - String get warning => '警告'; + String get heading4 => '标题 4'; @override - String get tabs => '标签页'; + String get heading5 => '标题 5'; @override - String get tab => '标签页'; + String get heading6 => '标题 6'; @override - String get procedure => '操作步骤'; + String headingLevelAbbreviation(int level) { + return 'H$level'; + } @override - String get step => '步骤'; + String get deleteTable => '删除表格'; @override - String get topic => '主题'; + String tableColumnNumber(int columnNumber) { + return '列 $columnNumber'; + } @override - String get chapter => '章节'; + String get insertColumnLeft => '在左侧插入列'; @override - String couldNotOpenTarget(String target) { - return '无法打开 $target'; - } + String get insertColumnRight => '在右侧插入列'; @override - String linkTargetNotFound(String targetPath) { - return '找不到链接目标:$targetPath'; - } + String get deleteColumn => '删除列'; @override - String get cannotOpenFileTypeInEditor => '无法在编辑器中打开此文件类型'; + String get tableAlignmentUnspecified => '对齐方式:未指定'; @override - String anchorNotFound(String anchor) { - return '找不到锚点:$anchor'; - } + String get tableAlignmentLeft => '对齐方式:左对齐'; @override - String get noProblemsFound => '未发现问题'; + String get tableAlignmentCenter => '对齐方式:居中'; @override - String get noResults => '无结果'; + String get tableAlignmentRight => '对齐方式:右对齐'; @override - String searchResultLine(String relativePath, int lineNumber) { - return '$relativePath - 第 $lineNumber 行'; + String tableRowNumber(int rowNumber) { + return '行 $rowNumber'; } @override - String get untitledResult => '未命名结果'; + String get insertRowAbove => '在上方插入行'; @override - String get documentKindMarkdownFile => 'Markdown 文件'; + String get insertRowBelow => '在下方插入行'; @override - String get documentKindWritersideMarkdownTopic => 'Writerside Markdown 主题'; + String get deleteRow => '删除行'; @override - String get documentKindWritersideXmlTopic => 'Writerside XML 主题'; + String get tableHeaderHint => '标题'; @override - String get documentKindWritersideTree => 'Writerside 树'; + String get tableCellHint => '单元格'; @override - String get documentKindConfigurationFile => '配置文件'; + String get language => '语言'; @override - String get documentKindVariablesFile => '变量文件'; + String get hideEditingButtons => '隐藏编辑按钮'; @override - String get documentKindCategoriesFile => '类别文件'; + String get showEditingButtons => '显示编辑按钮'; @override - String get documentKindResourceFile => '资源文件'; + String get altText => '替代文本'; @override - String workspaceErrorOpenFailed(String error) { - return '打开失败:$error'; - } + String get editorPlaceholderText => '文本'; @override - String workspaceErrorCreateWritersideProjectFailed(String error) { - return '无法创建 Writerside 项目:$error'; - } + String get editorPlaceholderCode => '代码'; @override - String workspaceErrorCreateWritersideTopicFailed(String error) { - return '无法创建 Writerside 主题:$error'; - } + String get editorPlaceholderAltText => '替代文本'; @override - String workspaceErrorCouldNotOpenFile(String error) { - return '无法打开文件:$error'; - } + String get describeTheImage => '描述图像'; @override - String get workspaceErrorChooseWhereToSaveMarkdown => '选择保存此 Markdown 文件的位置。'; + String get columns => '列'; @override - String get workspaceErrorSaveBlockedFileChangedOnDisk => '保存已阻止:文件已在磁盘上发生变化。'; + String get rows => '行'; @override - String workspaceErrorSaveFailed(String error) { - return '保存失败:$error'; + String tableHeaderNumber(int columnNumber) { + return '标题 $columnNumber'; } @override - String workspaceErrorFileOperationFailed(String error) { - return '文件操作失败:$error'; - } + String get tableCellDefault => '单元格'; @override - String workspaceErrorValidationFailed(String error) { - return '验证失败:$error'; - } + String get noImageSource => '没有图像源'; @override - String workspaceRecoveryRestored(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '已恢复 $count 个未保存的文档。请在保存或放弃前逐一检查。', - one: '已恢复 1 个未保存的文档。请在保存或放弃前进行检查。', - ); - return '$_temp0'; - } + String get remoteImageBlocked => '远程图像已阻止'; @override - String workspaceRecoveryDamaged(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '无法恢复 $count 条损坏的恢复记录。有效的恢复记录仍可用。', - one: '无法恢复一条损坏的恢复记录。原始恢复文件已保留以供检查。', - ); - return '$_temp0'; - } + String get remoteImageBlockedTooltip => '选择是否允许 BusyMark 加载远程图像。'; @override - String errorPathDoesNotExist(String path) { - return '路径不存在:$path'; - } + String get remoteImagesBlockedTitle => '远程图像已阻止'; @override - String errorTargetDirectoryNotEmpty(String path) { - return '目标目录已存在且不为空:$path'; - } + String get remoteImagesBlockedMessage => + '本文档引用了来自互联网的图像。加载这些图像可能会向图像托管方泄露网络信息。'; @override - String errorTargetPathNotDirectory(String path) { - return '目标路径已存在但不是目录:$path'; - } + String get loadRemoteImagesForWorkspace => '为此工作区加载'; @override - String errorGeneratedFileAlreadyExists(String path) { - return '生成的文件已存在:$path'; - } + String get alwaysLoadRemoteImages => '始终加载远程图像'; @override - String get errorParentDirectoryRequired => '必须指定父目录。'; + String get hideSidebar => '隐藏侧边栏面板'; @override - String errorParentDirectoryMissing(String path) { - return '父目录不存在:$path'; - } + String get showSidebar => '显示侧边栏面板'; @override - String errorDirectoryMissing(String path) { - return '目录不存在:$path'; - } + String get showPreview => '显示预览'; @override - String errorPathAlreadyExists(String path) { - return '路径已存在:$path'; - } + String get hidePreview => '隐藏预览'; @override - String get errorFileNameRequired => '文件名为必填项。'; + String get workspaceKindUnsavedMarkdown => '未保存的 Markdown 文件'; @override - String get errorFileNameUnsafe => '文件名必须是单一且安全的路径段。'; + String get workspaceKindSingleMarkdown => '单个 Markdown 文件'; @override - String get errorFileOperationInvalidTarget => '无法将文件夹移动到其自身中。'; + String get workspaceKindMarkdownFolder => 'Markdown 文件夹'; @override - String get errorFileOperationOutsideRoot => '文件操作必须在工作区内进行。'; + String get workspaceKindWritersideModule => 'Writerside 模块'; @override - String get errorFileOperationRoot => '无法从文件树更改工作区根目录。'; + String get problems => '问题'; @override - String get errorProjectNameRequired => '项目名称为必填项。'; + String diagnosticCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 条诊断信息', + one: '1 条诊断信息', + zero: '没有诊断信息', + ); + return '$_temp0'; + } @override - String get errorDirectoryNameRequired => '目录名称为必填项。'; + String get files => '文件'; @override - String get errorDirectoryNameUnsafe => '目录名称必须是单一且安全的路径段。'; + String get toc => '目录'; @override - String get errorInstanceIdInvalid => '实例 ID 必须以小写字母开头,且只能包含小写字母、数字、下划线和连字符。'; + String get tocActions => '目录操作'; @override - String get errorTopicFileInvalid => '主题文件名必须是没有路径分隔符的 Markdown 文件名。'; + String get markdownUnsaved => 'Markdown - 未保存'; @override - String get errorTopicTitleRequired => '主题标题为必填项。'; + String workspaceDetail(String kind, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个文件', + one: '1 个文件', + ); + return '$kind - $_temp0'; + } @override - String errorWritersideModuleRootMissing(String path) { - return 'Writerside 模块根目录不存在:$path'; - } + String get noFiles => '没有文件'; @override - String get errorWritersideModuleNotOpen => '必须打开 Writerside 模块才能创建主题。'; + String get newFile => '新建文件'; @override - String get errorWritersideInstanceTreeMissing => 'Writerside 模块没有实例树。'; + String get noWritersideToc => '没有 Writerside 目录'; @override - String errorWritersideTreeFileMissing(String path) { - return 'Writerside 树文件不存在:$path'; - } + String get tocSection => '目录节'; @override - String errorTopicIdAlreadyExists(String topicId) { - return '主题 ID \"$topicId\" 已存在于此帮助模块中。'; - } + String get newTopic => '新建主题'; @override - String errorTopicFileAlreadyExists(String path) { - return '主题文件已存在:$path'; - } + String get newChildTopic => '新建子主题'; @override - String errorReferenceTopicMissing(String topic) { - return '选中的树中不存在引用主题:$topic'; - } + String get newSiblingTopic => '新建主题'; @override - String get errorWritersideTocNodeMissing => '选中的目录项已不存在。'; + String get renameTopicFile => '重命名主题文件'; @override - String get errorWritersideTocInvalidMove => '目录项无法移动到自身或其子项中。'; + String get topicPlacement => '目录位置'; @override - String errorWritersideStartTopicDelete(String topic) { - return '无法删除主页 $topic。请先选择另一个主页。'; - } + String get tocRoot => '位于目录根级别'; @override - String get errorWritersideSafeDeleteRequired => '请对 Writerside 主题文件使用安全删除。'; + String get afterSelectedTopic => '位于选中主题之后'; @override - String get errorWritersideTopicUsageScanFailed => '无法完成主题使用情况扫描。没有文件发生更改。'; + String get insideSelectedTopic => '位于选中主题内'; @override - String get errorWritersideTopicUsagesRemain => '某些主题使用情况仍需处理。请先查看后再继续。'; + String get pasteAfterTopic => '粘贴到主题之后'; @override - String get errorWritersideRedirectInvalid => '选中的重定向目标已无效。请重新选择。'; + String get pasteAsChildTopic => '粘贴为子主题'; @override - String errorWritersideRollbackFailed(String paths) { - return '无法完全回滚主题移除。请在继续前查看这些路径:$paths'; - } + String get removeFromToc => '从目录中移除'; @override - String errorWritersideTopicRenameIncompleteProjectIndex( - String module, - String count, - String paths, - ) { - return '无法安全地重命名并更新所有项目引用,因为模块 $module 中有 $count 个主题文件尚未解析:$paths'; - } + String get confirmRemoveFromTocTitle => '从目录中移除?'; @override - String get errorWritersideTopicRenameProjectBuffersDirty => - 'Writerside 项目文件有未保存的更改。请先处理这些更改,再准备新的重命名预览。'; + String confirmRemoveFromTocMessage(String name) { + return '从此目录中移除 $name?主题文件将被保留。'; + } @override - String get errorWritersideTopicRenameProjectInventoryChanged => - 'Writerside 项目的模块集合已更改。请在重构前准备新的重命名预览。'; + String get confirmDeleteTopicTitle => '删除主题文件?'; @override - String get errorTopicsRootUnsafe => '主题根目录必须是安全的相对目录。'; + String confirmDeleteTopicMessage(String name) { + return '删除 $name 并从每个目录中移除它?此操作无法撤销。'; + } @override - String get errorTopicFileNameUnsafe => '主题文件名必须是单一且安全的路径段。'; + String get safeDeleteTopicFile => '安全删除主题文件…'; @override - String errorTopicFileExtensionMismatch(String extension) { - return '主题文件扩展名必须与选中的格式匹配($extension)。'; - } + String get removeTocElement => '移除目录元素'; @override - String get errorTopicFileNameInvalid => '主题文件名只能包含字母、数字、下划线和连字符。'; + String get removeTocElements => '移除目录元素'; @override - String errorUnknown(String code) { - return '未知错误:$code'; - } + String get reviewUsages => '查看使用情况'; @override - String diagnosticWorkspaceFileStatFailed(String error) { - return '无法读取文件元数据:$error'; - } + String get deleteTopicFile => '删除主题文件'; @override - String get diagnosticWorkspaceScanSkipped => '检测到大型工作区。为保持应用响应速度,已跳过部分文件。'; + String get removeAction => '移除'; @override - String diagnosticWorkspaceScanInspectFailed(String error) { - return '无法检查工作区项:$error'; + String topicRemovalSummary(String topic) { + return '移除目录元素“$topic”?与目录关联的源文件不会被删除。'; } @override - String get diagnosticWorkspaceFileTooLarge => '文件超过 beta 自动解析限制。'; + String safeDeleteTopicSummary(String topic) { + return '删除“$topic”,并在整个 Writerside 项目中安全更新其引用。'; + } @override - String diagnosticWorkspaceFileReadFailed(String error) { - return '无法读取 Markdown 文件:$error'; + String childTopicsPromoted(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个子主题将上移一级。', + one: '1 个子主题将上移一级。', + ); + return '$_temp0'; } @override - String get diagnosticMarkdownAttributeMalformed => 'Writerside 标题属性块格式错误。'; + String get topicIsHomePageRemovalWarning => '此主题是此实例的主页。从目录中移除它之前,请选择另一个主页。'; @override - String diagnosticMarkdownHeadingDuplicateId(String id) { - return '标题 ID“$id”重复。'; - } + String get newHomePage => '新主页'; @override - String get diagnosticWritersideTopicH1ConvertedToChapter => - '额外的顶级 H1 标题将被视为章节。'; + String get chooseHomePage => '选择主页'; @override - String get diagnosticWritersideMarkdownTopicMissingTitle => - 'Writerside Markdown 主题没有 H1 或 front matter 标题。'; + String get noHomePageReplacement => '移除主页之前,请向此实例添加或链接另一个符合条件的主题。'; @override - String get diagnosticWritersideXmlTopicMissingTitle => 'XML 主题缺少标题。'; + String get topicIsStartPageRemovalWarning => '此主题是某个实例的主页。删除其源文件之前,请指定另一个主页。'; @override - String diagnosticWritersideTopicFileMissingTitle(String fileName) { - return '主题“$fileName”缺少标题。'; + String topicUsagesCount(int count) { + return '找到 $count 处使用。'; } @override - String get diagnosticMarkdownFrontMatterMalformed => 'front matter 未闭合。'; + String get noBreakingTopicUsages => '未找到会导致引用失效的引用。'; @override - String get diagnosticMarkdownRawHtmlUnsafe => '不安全的 HTML 元素。'; + String get topicUsagesFound => 'BusyMark 找到了以下对该主题的引用。'; @override - String diagnosticMarkdownLinkUnresolvedTarget(String targetPath) { - return '链接目标不存在:$targetPath'; - } + String get topicUsageTocElements => '目录元素'; @override - String diagnosticMarkdownLinkUnresolvedAnchor(String anchor) { - return '锚点“$anchor”不存在。'; - } + String get topicUsageStartPages => '主页'; @override - String diagnosticMarkdownImageMissingAlt(String destination) { - return '图像“$destination”缺少替代文本。'; - } + String get topicUsageTopicLinks => '主题链接'; @override - String diagnosticMarkdownImageMissingFile(String destination) { - return '图像不存在:$destination'; - } + String get topicUsageIncludes => '包含项'; @override - String diagnosticInvalidXml(String message) { - return '无效的 XML:$message'; + String usageCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 次使用', + one: '1 次使用', + ); + return '$_temp0'; } @override - String get diagnosticWritersideConfigInvalidRoot => - 'writerside.cfg 根元素必须是 <ihp>。'; + String get refactoringOptions => '重构选项'; @override - String get diagnosticWritersideConfigMissingSnippetsSrc => - 'snippets 声明缺少 src。'; + String get updateUsagesAutomatically => '自动更新使用情况'; @override - String get diagnosticWritersideConfigMissingInstanceGroupsSrc => - 'instance-groups 声明缺少 src。'; + String get updateUsagesAutomaticallyDescription => '移除目录引用和包含项,同时保留链接文本。'; @override - String diagnosticWritersideConfigInvalidKeymapsMode(String mode) { - return '不支持的 keymaps 模式:$mode'; - } + String get manualUsageUpdatesRequired => '执行此重构前,某些使用情况需要手动更改。'; @override - String get diagnosticWritersideConfigMissingInstanceSrc => - 'instance 声明缺少 src。'; + String get setRedirectTo => '设置重定向到:'; @override - String get diagnosticWritersideConfigMissingInstance => - 'writerside.cfg 未注册实例。'; + String get noRedirectDescription => '不重定向旧的已发布页面。'; @override - String get diagnosticWritersideTreeInvalidRoot => - '.tree 根元素必须是 <instance-profile>。'; + String get redirectTarget => '重定向目标'; @override - String get diagnosticWritersideTreeMissingId => '实例配置文件缺少 id。'; + String get remainingUsagesBlockRemoval => '请在继续前查看并更新剩余的使用情况,或在可用时启用自动更新。'; @override - String diagnosticWritersideTreeIdMismatch(String id) { - return '树文件名(不含扩展名)与实例 ID“$id”不匹配。'; + String usagesOfTopic(String topic) { + return '$topic 的使用情况'; } @override - String get diagnosticWritersideTreeMissingStartPage => '非库实例缺少 start-page。'; + String get noUsagesFound => '未找到使用情况'; @override - String diagnosticWritersideStartPageMissing(String startPage) { - return '起始页“$startPage”不存在。'; - } + String get outsideSelectedInstance => '在选中的实例之外'; @override - String diagnosticWritersideTreeDuplicateTopic(String topic) { - return '主题“$topic”在此实例目录中出现多次。'; - } + String get doRefactor => '执行重构'; @override - String get diagnosticWritersideVariableMalformedDeclaration => - '变量声明必须包含名称和值。'; + String get preview => '预览'; @override - String diagnosticWritersideVariableDuplicateName(String name) { - return '变量“$name”被声明了多次。'; - } + String get topicRenamePreviewTitle => '重命名预览'; @override - String get diagnosticWritersideCategoryMissingId => '类别缺少 id。'; + String topicRenamePathChange(String oldPath, String newPath) { + return '路径:$oldPath → $newPath'; + } @override - String diagnosticWritersideCategoryDuplicateId(String id) { - return '类别“$id”被声明了多次。'; + String topicRenameIdChange(String oldId, String newId) { + return '主题 ID:$oldId → $newId'; } @override - String diagnosticWritersideCategoryDuplicateOrder(String order) { - return '类别顺序“$order”被声明了多次。'; + String topicRenameUrlChange(String instance, String oldName, String newName) { + return '实例 $instance:$oldName → $newName'; } @override - String get diagnosticWritersideTopicInvalidRoot => '.topic 根元素必须是 <topic>。'; + String get topicRenameAffectedFiles => '受影响的文件'; @override - String get diagnosticWritersideTopicMissingRootId => 'XML 主题缺少根 id。'; + String get orphanTopicTitle => '主题文件已不再使用'; @override - String diagnosticWritersideTopicRootIdMismatch(String id, String expectedId) { - return 'XML 主题根 ID“$id”必须与文件名“$expectedId”匹配。'; - } + String get keepTopicFile => '保留主题文件'; @override - String diagnosticWritersideTopicDuplicateElementId(String elementId) { - return '元素 ID“$elementId”出现多次。'; + String orphanTopicMessage(String topic) { + return '“$topic”在此 Writerside 项目中已不再使用。删除该文件,或保留它以供其他实例使用。'; } @override - String get diagnosticWritersideTopicAnchorMissingHref => '<a> 缺少 href。'; + String get defaultNewTopicTitle => '新主题'; @override - String get diagnosticWritersideConfigMissing => - 'Writerside 模式需要 writerside.cfg。'; + String get topicTitle => '主题标题'; @override - String diagnosticWritersideConfigMissingBuildConfigDirectory( - String relativePath, - ) { - return '配置的构建配置目录不存在:$relativePath'; - } + String get fileName => '文件名'; @override - String diagnosticWritersideConfigMissingApiSpecificationsDirectory( - String relativePath, - ) { - return '配置的 API 规范目录不存在:$relativePath'; - } + String get topicTitleRequired => '主题标题为必填项。'; @override - String diagnosticWritersideConfigMissingSnippetsDirectory( - String relativePath, - ) { - return '配置的 snippets 目录不存在:$relativePath'; - } + String get fileNameRequired => '文件名为必填项。'; @override - String diagnosticWritersideConfigMissingVarsFile(String relativePath) { - return '配置的变量文件不存在:$relativePath'; - } + String get rename => '重命名'; @override - String diagnosticWritersideConfigMissingCategoriesFile(String relativePath) { - return '配置的类别文件不存在:$relativePath'; - } + String get confirmDeleteFileTitle => '删除文件?'; @override - String diagnosticWritersideConfigMissingInstanceGroupsFile( - String relativePath, - ) { - return '配置的实例组文件不存在:$relativePath'; - } + String get confirmDeleteFolderTitle => '删除文件夹?'; @override - String diagnosticWritersideConfigMissingInstanceTree(String source) { - return '已注册的实例树“$source”不存在。'; + String confirmDeleteFileMessage(String name) { + return '删除 $name?此操作无法撤销。'; } @override - String diagnosticWritersideTopicReadFailed(String error) { - return '无法读取主题文件:$error'; + String confirmDeleteFolderMessage(String name) { + return '删除 $name 及其中的所有文件?此操作无法撤销。'; } @override - String diagnosticWritersideDefaultTopicsDirectoryMissing( - String relativePath, - ) { - return '默认主题目录不存在:$relativePath'; - } + String get useSingleSafeFileName => '使用单一且安全的文件名。'; @override - String diagnosticWritersideTopicsDirectoryMissing(String relativePath) { - return '配置的主题目录不存在:$relativePath'; + String useExpectedExtension(String extension) { + return '为选中的格式使用 $extension 扩展名。'; } @override - String diagnosticWritersideImagesDirectoryMissing(String relativePath) { - return '配置的图像目录不存在:$relativePath'; - } + String get useIdentifierCharacters => '在扩展名之前仅使用字母、数字、下划线或连字符。'; @override - String diagnosticWritersideTopicDuplicateId(String id) { - return '元素 ID“$id”出现多次。'; - } + String get topicIdAlreadyExists => '主题 ID 已存在。'; @override - String diagnosticWritersideWebFileNameInvalid( - String instanceId, - String webFileName, - String topic, - ) { - return '实例 \"$instanceId\" 为主题 \"$topic\" 发布了无效的 Web 文件名 \"$webFileName\"。'; + String get createWritersideTopicFailed => '无法创建 Writerside 主题。'; + + @override + String get noOutline => '没有大纲'; + + @override + String expandKind(String kind) { + return '展开 $kind'; } @override - String diagnosticWritersideWebFileNameCollision( - String instanceId, - String webFileName, - String firstTopic, - String secondTopic, - ) { - return '实例 \"$instanceId\" 将 \"$firstTopic\" 和 \"$secondTopic\" 都发布为 \"$webFileName\"。'; + String collapseKind(String kind) { + return '折叠 $kind'; } @override - String diagnosticWritersideTreeMissingTopic(String topic) { - return '目录引用了缺失的主题“$topic”。'; + String get foldKindSection => '节'; + + @override + String get foldKindList => '列表'; + + @override + String get foldKindQuote => '引用'; + + @override + String get foldKindTag => '标签'; + + @override + String get sourceSearchPreviousMatch => '上一个匹配项'; + + @override + String get sourceSearchNextMatch => '下一个匹配项'; + + @override + String get sourceSearchCaseSensitive => '区分大小写'; + + @override + String get sourceSearchWholeWord => '全字匹配'; + + @override + String get sourceSearchRegex => '正则表达式'; + + @override + String get sourceSearchReplacement => '替换为'; + + @override + String get sourceSearchReplaceCurrent => '替换当前匹配项'; + + @override + String get sourceSearchReplaceAndFindNext => '替换并查找下一个'; + + @override + String get sourceSearchReplaceAll => '全部替换'; + + @override + String get workspaceReplace => '在工作区中替换'; + + @override + String get reviewReplacements => '查看替换内容'; + + @override + String get applyReplacements => '应用替换'; + + @override + String get skippedFiles => '跳过的文件'; + + @override + String get workspaceReplaceDirtyBuffer => '未保存的编辑器内容'; + + @override + String get workspaceReplaceDiskContent => '磁盘上的已保存内容'; + + @override + String selectFileMatches(int count) { + return '选择全部 $count 个匹配项'; } @override - String diagnosticWritersideTreeInvalidHref(String href) { - return '外部 href“$href”无效。'; + String workspaceReplaceApplied(int matches, int files, int skipped) { + return '已在 $files 个文件中替换 $matches 个匹配项;跳过 $skipped 个。'; } @override - String diagnosticWritersideVariableUnresolved(String name) { - return '变量“%$name%”未声明。'; + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · 末尾有换行符'; } @override - String diagnosticWritersideTopicLinkUnresolved(String destination) { - return '主题链接“$destination”无法解析。'; + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · 末尾无换行符'; } @override - String diagnosticWritersideAnchorUnresolved( - String anchor, - String targetName, - ) { - return '“$targetName”中不存在锚点“$anchor”。'; + String get normalizeLineEndings => '规范化行尾'; + + @override + String get mixedLineEndingsSavePrompt => '此文档包含混合的行尾格式。请选择一种格式。'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName 使用混合的行尾格式。请在替换前选择要使用的格式。'; } @override - String get diagnosticWritersideIncludeMissingFrom => '<include> 缺少 from。'; + String get workspaceReplaceIssueOversized => '已跳过过大的文件。'; + + @override + String get workspaceReplaceIssueUnreadable => '已跳过无法读取的文件。'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => '已跳过不是有效 UTF-8 的文件。'; + + @override + String get workspaceReplaceIssueTruncated => '替换预览已被截断。'; + + @override + String get workspaceReplaceIssueFileChanged => '已跳过预览后发生变化的文件。'; + + @override + String get workspaceReplaceIssueBufferChanged => '已跳过预览后发生变化的编辑器缓冲区。'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + '请在替换前选择 LF 或 CRLF 规范化。'; + + @override + String get workspaceReplaceIssuePartialConflict => + '由于文件同时发生了变化,回滚已停止。部分替换可能仍然存在;被移出的内容已保留在下方路径中。'; + + @override + String get workspaceReplaceIssueApplyFailed => '无法提交经过审查的替换;没有文件发生更改。'; + + @override + String externalChangesTitle(String fileName) { + return '外部更改 — $fileName'; + } + + @override + String get externalFileDeleted => '此文件已从磁盘删除。'; + + @override + String get externalFileChanged => '在您有未保存编辑时,此文件已在磁盘上发生变化。'; + + @override + String recoveredDocumentReview(String fileName) { + return '已恢复 $fileName 的未保存内容。请检查后保存、另存为或放弃。'; + } + + @override + String get compare => '比较'; + + @override + String get reloadFromDisk => '从磁盘重新加载'; + + @override + String get keepMine => '保留我的版本'; + + @override + String get saveAs => '另存为'; + + @override + String get sourceSearchInvalidRegex => '无效的正则表达式'; + + @override + String get sourceSearchZeroLengthUnsupported => + '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; + + @override + String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; + + @override + String get workspaceSearchShowMore => '显示更多结果'; + + @override + String get workspaceSearchSkippedFiles => '搜索不完整。以下文件超过 1 MiB 或无法读取:'; + + @override + String get sourceLargeFileFeaturesPaused => '大文件:已暂停语法突出显示和折叠'; + + @override + String get nothingToRead => '没有可读取的内容'; + + @override + String get admonition => '提示块'; + + @override + String get quote => '引用'; + + @override + String get note => '备注'; + + @override + String get tip => '提示'; + + @override + String get warning => '警告'; + + @override + String get tabs => '标签页'; + + @override + String get tab => '标签页'; + + @override + String get procedure => '操作步骤'; + + @override + String get step => '步骤'; + + @override + String get topic => '主题'; + + @override + String get chapter => '章节'; + + @override + String couldNotOpenTarget(String target) { + return '无法打开 $target'; + } + + @override + String linkTargetNotFound(String targetPath) { + return '找不到链接目标:$targetPath'; + } + + @override + String get cannotOpenFileTypeInEditor => '无法在编辑器中打开此文件类型'; + + @override + String anchorNotFound(String anchor) { + return '找不到锚点:$anchor'; + } + + @override + String get noProblemsFound => '未发现问题'; + + @override + String get noResults => '无结果'; + + @override + String searchResultLine(String relativePath, int lineNumber) { + return '$relativePath - 第 $lineNumber 行'; + } + + @override + String get untitledResult => '未命名结果'; + + @override + String get documentKindMarkdownFile => 'Markdown 文件'; + + @override + String get documentKindWritersideMarkdownTopic => 'Writerside Markdown 主题'; + + @override + String get documentKindWritersideXmlTopic => 'Writerside XML 主题'; + + @override + String get documentKindWritersideTree => 'Writerside 树'; + + @override + String get documentKindConfigurationFile => '配置文件'; + + @override + String get documentKindVariablesFile => '变量文件'; + + @override + String get documentKindCategoriesFile => '类别文件'; + + @override + String get documentKindResourceFile => '资源文件'; + + @override + String workspaceErrorOpenFailed(String error) { + return '打开失败:$error'; + } + + @override + String workspaceErrorCreateWritersideProjectFailed(String error) { + return '无法创建 Writerside 项目:$error'; + } + + @override + String workspaceErrorCreateWritersideTopicFailed(String error) { + return '无法创建 Writerside 主题:$error'; + } + + @override + String workspaceErrorCouldNotOpenFile(String error) { + return '无法打开文件:$error'; + } + + @override + String get workspaceErrorChooseWhereToSaveMarkdown => '选择保存此 Markdown 文件的位置。'; + + @override + String get workspaceErrorSaveBlockedFileChangedOnDisk => '保存已阻止:文件已在磁盘上发生变化。'; + + @override + String workspaceErrorSaveFailed(String error) { + return '保存失败:$error'; + } + + @override + String workspaceErrorFileOperationFailed(String error) { + return '文件操作失败:$error'; + } + + @override + String workspaceErrorValidationFailed(String error) { + return '验证失败:$error'; + } + + @override + String workspaceRecoveryRestored(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '已恢复 $count 个未保存的文档。请在保存或放弃前逐一检查。', + one: '已恢复 1 个未保存的文档。请在保存或放弃前进行检查。', + ); + return '$_temp0'; + } + + @override + String workspaceRecoveryDamaged(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '无法恢复 $count 条损坏的恢复记录。有效的恢复记录仍可用。', + one: '无法恢复一条损坏的恢复记录。原始恢复文件已保留以供检查。', + ); + return '$_temp0'; + } + + @override + String errorPathDoesNotExist(String path) { + return '路径不存在:$path'; + } + + @override + String errorTargetDirectoryNotEmpty(String path) { + return '目标目录已存在且不为空:$path'; + } + + @override + String errorTargetPathNotDirectory(String path) { + return '目标路径已存在但不是目录:$path'; + } + + @override + String errorGeneratedFileAlreadyExists(String path) { + return '生成的文件已存在:$path'; + } + + @override + String get errorParentDirectoryRequired => '必须指定父目录。'; + + @override + String errorParentDirectoryMissing(String path) { + return '父目录不存在:$path'; + } + + @override + String errorDirectoryMissing(String path) { + return '目录不存在:$path'; + } + + @override + String errorPathAlreadyExists(String path) { + return '路径已存在:$path'; + } + + @override + String get errorFileNameRequired => '文件名为必填项。'; + + @override + String get errorFileNameUnsafe => '文件名必须是单一且安全的路径段。'; + + @override + String get errorFileOperationInvalidTarget => '无法将文件夹移动到其自身中。'; + + @override + String get errorFileOperationOutsideRoot => '文件操作必须在工作区内进行。'; + + @override + String get errorFileOperationRoot => '无法从文件树更改工作区根目录。'; + + @override + String get errorProjectNameRequired => '项目名称为必填项。'; + + @override + String get errorDirectoryNameRequired => '目录名称为必填项。'; + + @override + String get errorDirectoryNameUnsafe => '目录名称必须是单一且安全的路径段。'; + + @override + String get errorInstanceIdInvalid => '实例 ID 必须以小写字母开头,且只能包含小写字母、数字、下划线和连字符。'; + + @override + String get errorTopicFileInvalid => '主题文件名必须是没有路径分隔符的 Markdown 文件名。'; + + @override + String get errorTopicTitleRequired => '主题标题为必填项。'; + + @override + String errorWritersideModuleRootMissing(String path) { + return 'Writerside 模块根目录不存在:$path'; + } + + @override + String get errorWritersideModuleNotOpen => '必须打开 Writerside 模块才能创建主题。'; + + @override + String get errorWritersideInstanceTreeMissing => 'Writerside 模块没有实例树。'; + + @override + String errorWritersideTreeFileMissing(String path) { + return 'Writerside 树文件不存在:$path'; + } + + @override + String errorTopicIdAlreadyExists(String topicId) { + return '主题 ID \"$topicId\" 已存在于此帮助模块中。'; + } + + @override + String errorTopicFileAlreadyExists(String path) { + return '主题文件已存在:$path'; + } + + @override + String errorReferenceTopicMissing(String topic) { + return '选中的树中不存在引用主题:$topic'; + } + + @override + String get errorWritersideTocNodeMissing => '选中的目录项已不存在。'; + + @override + String get errorWritersideTocInvalidMove => '目录项无法移动到自身或其子项中。'; + + @override + String errorWritersideStartTopicDelete(String topic) { + return '无法删除主页 $topic。请先选择另一个主页。'; + } + + @override + String get errorWritersideSafeDeleteRequired => '请对 Writerside 主题文件使用安全删除。'; + + @override + String get errorWritersideTopicUsageScanFailed => '无法完成主题使用情况扫描。没有文件发生更改。'; + + @override + String get errorWritersideTopicUsagesRemain => '某些主题使用情况仍需处理。请先查看后再继续。'; + + @override + String get errorWritersideRedirectInvalid => '选中的重定向目标已无效。请重新选择。'; + + @override + String errorWritersideRollbackFailed(String paths) { + return '无法完全回滚主题移除。请在继续前查看这些路径:$paths'; + } + + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return '无法安全地重命名并更新所有项目引用,因为模块 $module 中有 $count 个主题文件尚未解析:$paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside 项目文件有未保存的更改。请先处理这些更改,再准备新的重命名预览。'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Writerside 项目的模块集合已更改。请在重构前准备新的重命名预览。'; + + @override + String get errorTopicsRootUnsafe => '主题根目录必须是安全的相对目录。'; + + @override + String get errorTopicFileNameUnsafe => '主题文件名必须是单一且安全的路径段。'; + + @override + String errorTopicFileExtensionMismatch(String extension) { + return '主题文件扩展名必须与选中的格式匹配($extension)。'; + } + + @override + String get errorTopicFileNameInvalid => '主题文件名只能包含字母、数字、下划线和连字符。'; + + @override + String errorUnknown(String code) { + return '未知错误:$code'; + } + + @override + String diagnosticWorkspaceFileStatFailed(String error) { + return '无法读取文件元数据:$error'; + } + + @override + String get diagnosticWorkspaceScanSkipped => '检测到大型工作区。为保持应用响应速度,已跳过部分文件。'; + + @override + String diagnosticWorkspaceScanInspectFailed(String error) { + return '无法检查工作区项:$error'; + } + + @override + String get diagnosticWorkspaceFileTooLarge => '文件超过 beta 自动解析限制。'; + + @override + String diagnosticWorkspaceFileReadFailed(String error) { + return '无法读取 Markdown 文件:$error'; + } + + @override + String get diagnosticMarkdownAttributeMalformed => 'Writerside 标题属性块格式错误。'; + + @override + String diagnosticMarkdownHeadingDuplicateId(String id) { + return '标题 ID“$id”重复。'; + } + + @override + String get diagnosticWritersideTopicH1ConvertedToChapter => + '额外的顶级 H1 标题将被视为章节。'; + + @override + String get diagnosticWritersideMarkdownTopicMissingTitle => + 'Writerside Markdown 主题没有 H1 或 front matter 标题。'; + + @override + String get diagnosticWritersideXmlTopicMissingTitle => 'XML 主题缺少标题。'; + + @override + String diagnosticWritersideTopicFileMissingTitle(String fileName) { + return '主题“$fileName”缺少标题。'; + } + + @override + String get diagnosticMarkdownFrontMatterMalformed => 'front matter 未闭合。'; + + @override + String get diagnosticMarkdownRawHtmlUnsafe => '不安全的 HTML 元素。'; + + @override + String diagnosticMarkdownLinkUnresolvedTarget(String targetPath) { + return '链接目标不存在:$targetPath'; + } + + @override + String diagnosticMarkdownLinkUnresolvedAnchor(String anchor) { + return '锚点“$anchor”不存在。'; + } + + @override + String diagnosticMarkdownImageMissingAlt(String destination) { + return '图像“$destination”缺少替代文本。'; + } + + @override + String diagnosticMarkdownImageMissingFile(String destination) { + return '图像不存在:$destination'; + } + + @override + String diagnosticInvalidXml(String message) { + return '无效的 XML:$message'; + } + + @override + String get diagnosticWritersideConfigInvalidRoot => + 'writerside.cfg 根元素必须是 <ihp>。'; + + @override + String get diagnosticWritersideConfigMissingSnippetsSrc => + 'snippets 声明缺少 src。'; + + @override + String get diagnosticWritersideConfigMissingInstanceGroupsSrc => + 'instance-groups 声明缺少 src。'; + + @override + String diagnosticWritersideConfigInvalidKeymapsMode(String mode) { + return '不支持的 keymaps 模式:$mode'; + } + + @override + String get diagnosticWritersideConfigMissingInstanceSrc => + 'instance 声明缺少 src。'; + + @override + String get diagnosticWritersideConfigMissingInstance => + 'writerside.cfg 未注册实例。'; + + @override + String get diagnosticWritersideTreeInvalidRoot => + '.tree 根元素必须是 <instance-profile>。'; + + @override + String get diagnosticWritersideTreeMissingId => '实例配置文件缺少 id。'; + + @override + String diagnosticWritersideTreeIdMismatch(String id) { + return '树文件名(不含扩展名)与实例 ID“$id”不匹配。'; + } + + @override + String get diagnosticWritersideTreeMissingStartPage => '非库实例缺少 start-page。'; + + @override + String diagnosticWritersideStartPageMissing(String startPage) { + return '起始页“$startPage”不存在。'; + } + + @override + String diagnosticWritersideTreeDuplicateTopic(String topic) { + return '主题“$topic”在此实例目录中出现多次。'; + } + + @override + String get diagnosticWritersideVariableMalformedDeclaration => + '变量声明必须包含名称和值。'; + + @override + String diagnosticWritersideVariableDuplicateName(String name) { + return '变量“$name”被声明了多次。'; + } + + @override + String get diagnosticWritersideCategoryMissingId => '类别缺少 id。'; + + @override + String diagnosticWritersideCategoryDuplicateId(String id) { + return '类别“$id”被声明了多次。'; + } + + @override + String diagnosticWritersideCategoryDuplicateOrder(String order) { + return '类别顺序“$order”被声明了多次。'; + } + + @override + String get diagnosticWritersideTopicInvalidRoot => '.topic 根元素必须是 <topic>。'; + + @override + String get diagnosticWritersideTopicMissingRootId => 'XML 主题缺少根 id。'; + + @override + String diagnosticWritersideTopicRootIdMismatch(String id, String expectedId) { + return 'XML 主题根 ID“$id”必须与文件名“$expectedId”匹配。'; + } + + @override + String diagnosticWritersideTopicDuplicateElementId(String elementId) { + return '元素 ID“$elementId”出现多次。'; + } + + @override + String get diagnosticWritersideTopicAnchorMissingHref => '<a> 缺少 href。'; + + @override + String get diagnosticWritersideConfigMissing => + 'Writerside 模式需要 writerside.cfg。'; + + @override + String diagnosticWritersideConfigMissingBuildConfigDirectory( + String relativePath, + ) { + return '配置的构建配置目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingApiSpecificationsDirectory( + String relativePath, + ) { + return '配置的 API 规范目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingSnippetsDirectory( + String relativePath, + ) { + return '配置的 snippets 目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingVarsFile(String relativePath) { + return '配置的变量文件不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingCategoriesFile(String relativePath) { + return '配置的类别文件不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingInstanceGroupsFile( + String relativePath, + ) { + return '配置的实例组文件不存在:$relativePath'; + } + + @override + String diagnosticWritersideConfigMissingInstanceTree(String source) { + return '已注册的实例树“$source”不存在。'; + } + + @override + String diagnosticWritersideTopicReadFailed(String error) { + return '无法读取主题文件:$error'; + } + + @override + String diagnosticWritersideDefaultTopicsDirectoryMissing( + String relativePath, + ) { + return '默认主题目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideTopicsDirectoryMissing(String relativePath) { + return '配置的主题目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideImagesDirectoryMissing(String relativePath) { + return '配置的图像目录不存在:$relativePath'; + } + + @override + String diagnosticWritersideTopicDuplicateId(String id) { + return '元素 ID“$id”出现多次。'; + } + + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return '实例 \"$instanceId\" 为主题 \"$topic\" 发布了无效的 Web 文件名 \"$webFileName\"。'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return '实例 \"$instanceId\" 将 \"$firstTopic\" 和 \"$secondTopic\" 都发布为 \"$webFileName\"。'; + } + + @override + String diagnosticWritersideTreeMissingTopic(String topic) { + return '目录引用了缺失的主题“$topic”。'; + } + + @override + String diagnosticWritersideTreeInvalidHref(String href) { + return '外部 href“$href”无效。'; + } + + @override + String diagnosticWritersideVariableUnresolved(String name) { + return '变量“%$name%”未声明。'; + } + + @override + String diagnosticWritersideTopicLinkUnresolved(String destination) { + return '主题链接“$destination”无法解析。'; + } + + @override + String diagnosticWritersideAnchorUnresolved( + String anchor, + String targetName, + ) { + return '“$targetName”中不存在锚点“$anchor”。'; + } + + @override + String get diagnosticWritersideIncludeMissingFrom => '<include> 缺少 from。'; + + @override + String diagnosticWritersideIncludeSourceMissing(String from) { + return '包含源“$from”不存在。'; + } + + @override + String diagnosticWritersideIncludeElementMissing( + String elementId, + String from, + ) { + return '“$from”中不存在包含元素“$elementId”。'; + } + + @override + String diagnosticWritersideCategoryUnresolved(String ref) { + return 'seealso 类别“$ref”未声明。'; + } + + @override + String diagnosticWritersideTopicAmbiguousReference(String reference) { + return '主题引用“$reference”不明确。'; + } + + @override + String diagnosticUnknown(String code) { + return '未知诊断信息:$code'; + } + + @override + String get close => '关闭'; + + @override + String get git => 'Git'; + + @override + String get gitDiff => 'Git 差异'; + + @override + String get gitShowDiff => '显示差异'; + + @override + String gitDiffHunkRange(String oldRange, String newRange) { + return '旧 $oldRange → 新 $newRange'; + } + + @override + String get gitDiffNoLines => '无行'; + + @override + String get gitUnavailableTitle => 'Git 不可用'; + + @override + String gitUnavailableMessage(String reason) { + String _temp0 = intl.Intl.selectLogic(reason, { + 'other': '安装 Git,或配置 BusyMark 以使用可用的 Git 可执行文件。$reason', + }); + return '$_temp0'; + } + + @override + String get gitTrustRequiredTitle => '信任此工作区以使用 Git?'; + + @override + String get gitTrustRequiredMessage => + 'Git 存储库可以通过 hook、过滤器和其他配置运行程序。在 BusyMark 读取存储库数据或启用 Git 操作前,请先信任此工作区。'; + + @override + String get gitTrustWorkspace => '信任工作区'; + + @override + String get gitNotRepositoryTitle => '不是 Git 存储库'; + + @override + String get gitNotRepositoryMessage => '此工作区不在 Git 存储库中。'; + + @override + String get gitInitializeRepository => '初始化存储库'; + + @override + String get gitDetachedHead => '分离的 HEAD'; + + @override + String gitDetachedHeadAt(String commit) { + return '分离于 $commit'; + } + + @override + String get gitNoUpstream => '没有上游分支'; + + @override + String gitAheadCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个未推送的提交', + one: '1 个未推送的提交', + ); + return '$_temp0'; + } + + @override + String gitBehindCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个待拉取的提交', + one: '1 个待拉取的提交', + ); + return '$_temp0'; + } + + @override + String get gitClean => '干净'; + + @override + String get gitConflicts => '冲突'; + + @override + String get gitChanges => '更改'; + + @override + String get gitStaged => '已暂存'; + + @override + String get gitUnstaged => '未暂存'; + + @override + String get gitHistory => '历史记录'; + + @override + String get gitBranches => '分支'; + + @override + String get gitActions => 'Git 操作'; + + @override + String get gitPull => 'Pull'; + + @override + String get gitFetch => 'Fetch'; + + @override + String get gitPush => 'Push'; + + @override + String get gitCommit => 'Commit'; + + @override + String get gitSelectForCommit => '暂存文件'; + + @override + String get gitRemoveFromCommit => '取消暂存文件'; + + @override + String get gitDiscard => '还原'; + + @override + String get gitOpenFile => '打开文件'; + + @override + String get gitMarkResolved => '标记为已解决'; + + @override + String get gitUntracked => '未跟踪'; + + @override + String get gitCommitMessage => '提交消息'; + + @override + String get gitCommitSelectedFiles => '选中的文件'; + + @override + String get gitCommitNoSelectedFiles => '提交前至少暂存一个文件。'; + + @override + String gitStagedFileCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 个已暂存文件', + one: '1 个已暂存文件', + ); + return '$_temp0'; + } + + @override + String get gitOutsideWorkspace => '工作区之外'; + + @override + String get gitCommitMessageRequired => '请输入提交消息。'; + + @override + String get gitCreateBranch => '创建分支'; + + @override + String get gitNewBranch => '新分支'; + + @override + String get gitBranchName => '分支名称'; + + @override + String get gitSwitchBranch => '切换'; + + @override + String get gitNoChanges => '没有更改'; + + @override + String get gitNoHistory => '没有历史记录'; + + @override + String get gitNoBranches => '没有分支'; + + @override + String get gitNoDiff => '没有可显示的差异'; + + @override + String get gitBinaryFile => '二进制文件。BusyMark 不渲染二进制补丁。'; + + @override + String gitBinaryFileInfo(int size) { + return '二进制文件($size 字节)。BusyMark 不渲染二进制补丁。'; + } + + @override + String get gitUnsavedChangesBanner => '保存前不会包含未保存的编辑器更改。'; + + @override + String get gitConfirmDiscardTitle => '放弃 Git 更改?'; + + @override + String gitConfirmDiscardTracked(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '选中的已跟踪文件中的所有已暂存和未暂存更改都将还原到 HEAD。', + one: '选中的已跟踪文件中的所有已暂存和未暂存更改都将还原到 HEAD。', + ); + return '$_temp0'; + } + + @override + String gitConfirmDiscardUntracked(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '选中的未跟踪文件将被删除。', + one: '选中的未跟踪文件将被删除。', + ); + return '$_temp0'; + } + + @override + String gitConfirmDiscardMixed(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '将根据其 Git 状态还原或删除选中的文件。', + one: '将根据其 Git 状态还原或删除选中的文件。', + ); + return '$_temp0'; + } + + @override + String gitConfirmSwitchBranchTitle(String branch) { + return '切换到 $branch?'; + } + + @override + String get gitConfirmSwitchBranchMessage => 'Git 切换分支后,BusyMark 将从磁盘重新加载工作区。'; + + @override + String get gitConfirmPushSetUpstreamTitle => '设置上游分支?'; + + @override + String gitConfirmPushSetUpstreamMessage(String branch) { + return '此分支没有上游分支。当只配置了一个远程仓库时,BusyMark 可以推送 $branch 并设置其上游分支。'; + } + + @override + String get gitProjectHistory => '项目历史记录'; + + @override + String get gitFileHistory => '文件历史记录'; + + @override + String get gitFileHistoryRequiresOpenFile => '文件历史记录需要打开的 Markdown 文件。'; + + @override + String get gitLoadMore => '加载更多'; + + @override + String get gitChangesInCommit => '此提交中的更改'; + + @override + String get gitCompareWithCurrent => '与当前版本比较'; + + @override + String get gitRestoreVersion => '还原此版本'; + + @override + String get gitConfirmRestoreTitle => '还原此文件版本?'; + + @override + String get gitConfirmRestoreMessage => + 'BusyMark 将使用选中的已提交版本替换当前工作树中的文件。还原后的文件将保持未暂存状态。'; + + @override + String get gitCommitActions => '提交操作'; + + @override + String get gitResetCurrentBranchToHere => '将当前分支重置到此处…'; + + @override + String gitResetCurrentBranchTitle(String branch, String commit) { + return '将 $branch 重置到 $commit?'; + } + + @override + String gitResetCurrentBranchMessage(String branch, String commit) { + return '这会将分支 $branch 移动到提交 $commit。请选择 Git 更新索引和工作树的方式。'; + } + + @override + String get gitReset => '重置'; + + @override + String get gitResetModeSoft => '软'; + + @override + String get gitResetModeSoftDescription => + '仅移动分支。保持索引和工作树不变;与选中提交之间的差异仍会保持暂存状态。'; + + @override + String get gitResetModeMixed => '混合'; + + @override + String get gitResetModeMixedDescription => '移动分支并重置索引。保持工作树不变,使差异处于未暂存状态。'; + + @override + String get gitResetModeHard => '硬'; + + @override + String get gitResetModeHardDescription => + '移动分支并重置索引和工作树。已跟踪的更改将被放弃;造成阻碍的未跟踪文件可能会被删除。'; + + @override + String get gitResetModeKeep => '保留'; + + @override + String get gitResetModeKeepDescription => + '移动分支并重置已跟踪文件,同时保留本地更改。如果这些更改与重置冲突,Git 将中止操作。'; + + @override + String gitAdditionsDeletions(int additions, int deletions) { + return '+$additions -$deletions'; + } + + @override + String get fileActions => '文件操作'; + + @override + String get actions => '操作'; + + @override + String get gitStatusAdded => '已添加'; + + @override + String get gitStatusDeleted => '已删除'; + + @override + String get gitStatusRenamed => '已重命名'; + + @override + String get gitStatusCopied => '已复制'; + + @override + String get gitStatusUntracked => '未跟踪'; + + @override + String get gitStatusConflicted => '有冲突'; + + @override + String get gitStatusIgnored => '已忽略'; + + @override + String get gitStatusTypeChanged => '类型已更改'; + + @override + String get gitStatusModified => '已修改'; + + @override + String get gitStatusUnknown => '未知'; + + @override + String get gitErrorUnavailable => 'Git 不可用。'; + + @override + String get gitErrorNotRepository => '此工作区不是 Git 存储库。'; + + @override + String get gitErrorUnsafePath => 'BusyMark 阻止了不安全的 Git 路径。'; + + @override + String get gitErrorInvalidBranchName => '请输入有效的分支名称。'; + + @override + String get gitErrorNoRemote => '未配置 Git 远程仓库。'; + + @override + String get gitErrorNoUpstream => '未配置上游分支。'; + + @override + String get gitErrorMultipleRemotes => '配置了多个远程仓库。请在此 BusyMark 版本之外选择上游分支。'; + + @override + String get gitErrorDirtyWorkspace => '切换分支前,请保存或放弃 BusyMark 编辑器中的更改。'; + + @override + String get gitErrorResetDirtyWorkspace => '重置当前分支前,请保存或放弃 BusyMark 编辑器中的更改。'; + + @override + String get gitErrorRestoreStagedFile => '还原历史版本前,请取消暂存此文件。'; + + @override + String get gitErrorResetDetachedHead => '重置前请先检出一个分支。'; + + @override + String get gitErrorDiverged => '分支已发生分歧。请在此 BusyMark 版本之外解决合并或变基问题。'; + + @override + String get gitErrorAuthorIdentity => 'Git 需要作者姓名和邮箱地址才能提交。'; + + @override + String get gitAuthorIdentityTitle => 'Git 作者身份'; + + @override + String get gitAuthorIdentityMessage => + '请输入 Git 应记录在提交中的身份信息。BusyMark 将保存它并重试此次提交。'; + + @override + String get gitAuthorName => '姓名'; + + @override + String get gitAuthorEmail => 'Email'; + + @override + String get gitAuthorIdentityGlobal => '用于所有存储库'; + + @override + String get gitAuthorIdentityGlobalDescription => + '作为 Snap 安装时,此设置适用于在 BusyMark 中打开的存储库。'; + + @override + String get gitSaveIdentityAndCommit => '保存并提交'; + + @override + String get gitErrorAuthentication => 'Git 身份验证失败。'; + + @override + String get gitErrorNetwork => 'Git 网络操作失败。'; + + @override + String get gitErrorConflict => 'Git 报告存在未解决的冲突。'; + + @override + String get gitErrorCommandFailed => 'Git 命令失败。'; + + @override + String get syntaxReference => '语法参考'; + + @override + String get syntaxReferenceMarkdownBlocks => 'Markdown 块'; + + @override + String get syntaxReferenceMarkdownBlocksDescription => + 'Markdown 源代码和预览中支持的块结构。'; + + @override + String get syntaxReferenceInlineFormatting => '行内 Markdown'; + + @override + String get syntaxReferenceInlineFormattingDescription => + '可出现在段落、列表项和表格单元格中的格式。'; + + @override + String get syntaxReferenceRawHtmlBlocks => '原始 HTML 块'; + + @override + String get syntaxReferenceRawHtmlBlocksDescription => + '通过 BusyMark 预览组件渲染的安全块级 HTML 标签。'; + + @override + String get syntaxReferenceRawHtmlInline => '原始 HTML 行内标签'; + + @override + String get syntaxReferenceRawHtmlInlineDescription => + '安全的行内 HTML 标签,渲染时不会显示字面标签。'; + + @override + String get syntaxReferenceHeadings => '标题'; + + @override + String get syntaxReferenceParagraphs => '段落'; + + @override + String get syntaxReferenceLists => '列表'; + + @override + String get syntaxReferenceHtmlContainers => '容器'; + + @override + String get syntaxReferenceHtmlTextBlocks => '文本块'; + + @override + String get syntaxReferenceHtmlFigures => '图和图像'; + + @override + String get syntaxReferenceHtmlPreformatted => '预格式化代码'; + + @override + String get syntaxReferenceHtmlDisclosure => '折叠块'; + + @override + String get syntaxReferenceHtmlDescriptionLists => '描述列表'; + + @override + String get syntaxReferenceHtmlFormattingTags => '格式标签'; + + @override + String get syntaxReferenceHtmlInlineCodeTags => '行内代码标签'; + + @override + String get syntaxReferenceHtmlNeutralInlineTags => '语义文本标签'; + + @override + String get syntaxReferenceSanitizedPreviewDescription => + '允许的 HTML 会转换为 BusyMark 预览块,而不是在浏览器中渲染。'; + + @override + String get syntaxReferenceSourcePreservedDescription => + '未经编辑的原始 HTML 会按源文本原样保存。'; + + @override + String get syntaxReferenceMarkdownInsideHtmlDescription => + '原始 HTML 内的 Markdown 标记会按字面文本渲染。'; + + @override + String get syntaxReferenceBlockedContentDescription => + '脚本、样式、框架、表单、SVG、MathML、事件和不安全属性都会被阻止。'; + + @override + String get syntaxReferenceSafeUrlsDescription => + '链接允许 http、https、mailto、tel、相对路径和片段 URL;不安全的 scheme 会被阻止。'; + + @override + String get syntaxReferenceCategory => '类别'; + + @override + String get syntaxReferenceCategoryHtml => 'HTML'; + + @override + String get syntaxReferenceCategoryDiagramsAndApi => '图表和 API'; + + @override + String get syntaxReferenceCategoryMathematics => '数学'; + + @override + String get syntaxReferenceExample => '示例'; + + @override + String get syntaxReferenceIdentifiers => '标识符和别名'; @override - String diagnosticWritersideIncludeSourceMissing(String from) { - return '包含源“$from”不存在。'; - } + String get syntaxReferenceScope => '适用范围'; @override - String diagnosticWritersideIncludeElementMissing( - String elementId, - String from, - ) { - return '“$from”中不存在包含元素“$elementId”。'; - } + String get syntaxReferenceLimitation => 'BusyMark 限制'; @override - String diagnosticWritersideCategoryUnresolved(String ref) { - return 'seealso 类别“$ref”未声明。'; - } + String get syntaxReferenceOfficialDocumentation => '官方文档'; @override - String diagnosticWritersideTopicAmbiguousReference(String reference) { - return '主题引用“$reference”不明确。'; - } + String get syntaxReferenceScopeWritersideMarkdown => '仅 Writerside Markdown'; @override - String diagnosticUnknown(String code) { - return '未知诊断信息:$code'; - } + String get syntaxReferenceScopeWritersideMarkdownAndXml => + '仅 Writerside Markdown 和 Writerside XML'; @override - String get close => '关闭'; + String get syntaxReferenceMarkdownDescription => + 'BusyMark 可以编写和预览的核心 Markdown 形式。'; @override - String get git => 'Git'; + String get syntaxReferenceParagraphExample => '一段文本。'; @override - String get gitDiff => 'Git 差异'; + String get syntaxReferenceTableLimitation => + '表格使用 GitHub Flavored Markdown 的竖线语法。'; @override - String get gitShowDiff => '显示差异'; + String get syntaxReferenceHardBreakIdentifiers => '行尾两个空格、\\、<br>'; @override - String gitDiffHunkRange(String oldRange, String newRange) { - return '旧 $oldRange → 新 $newRange'; - } + String get syntaxReferenceHtmlDescription => + 'BusyMark 在 Markdown 源码中仅接受有限且安全的原始 HTML 子集。'; @override - String get gitDiffNoLines => '无行'; + String get syntaxReferenceDiagramsDescription => + '带围栏的 Mermaid、PlantUML、D2 和 OpenAPI 块可在 Markdown 源码中使用。围栏标识符不区分大小写,BusyMark 会保留原始拼写。'; @override - String get gitUnavailableTitle => 'Git 不可用'; + String get syntaxReferenceMermaid => 'Mermaid'; @override - String gitUnavailableMessage(String reason) { - String _temp0 = intl.Intl.selectLogic(reason, { - 'other': '安装 Git,或配置 BusyMark 以使用可用的 Git 可执行文件。$reason', - }); - return '$_temp0'; - } + String get syntaxReferencePlantUml => 'PlantUML'; @override - String get gitTrustRequiredTitle => '信任此工作区以使用 Git?'; + String get syntaxReferenceD2 => 'D2'; @override - String get gitTrustRequiredMessage => - 'Git 存储库可以通过 hook、过滤器和其他配置运行程序。在 BusyMark 读取存储库数据或启用 Git 操作前,请先信任此工作区。'; + String get syntaxReferenceOpenApi => 'OpenAPI'; @override - String get gitTrustWorkspace => '信任工作区'; + String get syntaxReferenceOpenApiLimitation => + '请使用带围栏的 YAML 或 JSON 内容。BusyMark 不会将任意完整的 YAML 或 JSON 文档视为 OpenAPI 参考。'; @override - String get gitNotRepositoryTitle => '不是 Git 存储库'; + String get syntaxReferenceSemanticDiagramBlocks => '图表的语义代码块'; @override - String get gitNotRepositoryMessage => '此工作区不在 Git 存储库中。'; + String get syntaxReferenceSemanticDiagramLimitation => + '语义 code-block 和 src 形式仅支持 Mermaid、PlantUML 和 D2,不支持 OpenAPI,且只能在 Writerside 项目中使用。'; @override - String get gitInitializeRepository => '初始化存储库'; + String get syntaxReferenceReferencedDiagramSource => '引用的图表源'; @override - String get gitDetachedHead => '分离的 HEAD'; + String get syntaxReferenceReferencedDiagramLimitation => + '路径必须是相对路径并位于已打开的 Writerside 项目内;围栏加 src 的形式仅适用于 Writerside Markdown。'; @override - String gitDetachedHeadAt(String commit) { - return '分离于 $commit'; - } + String get syntaxReferenceMathematicsDescription => + 'BusyMark 支持 TeX 表达式,但不支持完整的 TeX 或 LaTeX 文档。'; @override - String get gitNoUpstream => '没有上游分支'; + String get syntaxReferenceInlineMath => '行内数学公式'; @override - String gitAheadCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 个未推送的提交', - one: '1 个未推送的提交', - ); - return '$_temp0'; - } + String get syntaxReferenceGithubMath => '使用美元符号和反引号的 GitHub 数学公式'; @override - String gitBehindCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 个待拉取的提交', - one: '1 个待拉取的提交', - ); - return '$_temp0'; - } + String get syntaxReferenceDisplayMath => '显示数学公式'; @override - String get gitClean => '干净'; + String get syntaxReferenceMathFence => 'math 围栏'; @override - String get gitConflicts => '冲突'; + String get syntaxReferenceTexFence => 'tex 围栏'; @override - String get gitChanges => '更改'; + String get syntaxReferenceMathDelimitersLimitation => + 'BusyMark 不会将 \\(...\\) 或 \\[...\\] 识别为 Markdown 数学分隔符。'; @override - String get gitStaged => '已暂存'; + String get syntaxReferenceTexFenceLimitation => + '在 Writerside 模式之外,tex 围栏仍是普通代码块。'; @override - String get gitUnstaged => '未暂存'; + String get syntaxReferenceWritersideMathElement => 'Writerside math 元素'; @override - String get gitHistory => '历史记录'; + String get syntaxReferenceWritersideMathElementLimitation => + 'math 元素是语义 Writerside 语法,不是允许的原始 HTML MathML。'; @override - String get gitBranches => '分支'; + String get syntaxReferenceSemanticTexBlock => '语义 TeX 代码块'; @override - String get gitActions => 'Git 操作'; + String get syntaxReferenceWritersideDescription => + '这些特定扩展仅在已打开的 Writerside 项目中解析。'; @override - String get gitPull => 'Pull'; + String get syntaxReferenceAdmonitionBlockquote => '提示块引用'; @override - String get gitFetch => 'Fetch'; + String get syntaxReferenceAdmonitionLimitation => + '普通块引用在 Writerside Markdown 中是提示;在普通 Markdown 中仍是普通引用。'; @override - String get gitPush => 'Push'; + String get syntaxReferenceSemanticAdmonitions => '语义提示'; @override - String get gitCommit => 'Commit'; + String get syntaxReferenceSemanticMarkupLimitation => + '普通 Markdown 不会解析这些 Writerside 语义元素。'; @override - String get gitSelectForCommit => '暂存文件'; + String get syntaxReferenceCollapsibleHeading => '可折叠标题'; @override - String get gitRemoveFromCommit => '取消暂存文件'; + String get syntaxReferenceCollapsibleCode => '可折叠代码围栏'; @override - String get gitDiscard => '还原'; + String get syntaxReferenceSemanticCollapsibles => '语义可折叠内容'; @override - String get gitOpenFile => '打开文件'; + String get syntaxReferenceSemanticCollapsiblesLimitation => + 'BusyMark 支持可折叠的 chapter、procedure、code-block 和定义列表形式,但不支持完整的 Writerside 目录。'; @override - String get gitMarkResolved => '标记为已解决'; + String get syntaxReferenceSemanticCodeBlocks => '数学和图表的语义代码块'; @override - String get gitUntracked => '未跟踪'; + String get syntaxReferenceVideo => 'Writerside 视频'; @override - String get gitCommitMessage => '提交消息'; + String get syntaxReferenceVideoLimitation => + '本地视频使用本地 preview-src 图像;托管视频源必须是受支持的 YouTube 或 Vimeo HTTPS URL。'; @override - String get gitCommitSelectedFiles => '选中的文件'; + String get exportAsPdf => '导出为 PDF'; @override - String get gitCommitNoSelectedFiles => '提交前至少暂存一个文件。'; + String get pdfExportDescription => '选择页面布局,生成精美且自包含的 PDF。'; @override - String gitStagedFileCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 个已暂存文件', - one: '1 个已暂存文件', - ); - return '$_temp0'; - } + String get pdfRemoteImagesNote => '导出过程中不会下载远程图像。有可用的本地图像时将包含它们。'; @override - String get gitOutsideWorkspace => '工作区之外'; + String get pdfPageSize => '页面大小'; @override - String get gitCommitMessageRequired => '请输入提交消息。'; + String get pdfPageSizeA4 => 'A4'; @override - String get gitCreateBranch => '创建分支'; + String get pdfPageSizeLetter => 'Letter'; @override - String get gitNewBranch => '新分支'; + String get pdfOrientation => '方向'; @override - String get gitBranchName => '分支名称'; + String get pdfPortrait => '纵向'; @override - String get gitSwitchBranch => '切换'; + String get pdfLandscape => '横向'; @override - String get gitNoChanges => '没有更改'; + String get pdfMargins => '页边距'; @override - String get gitNoHistory => '没有历史记录'; + String get pdfMarginNarrow => '窄'; @override - String get gitNoBranches => '没有分支'; + String get pdfMarginNormal => '普通'; @override - String get gitNoDiff => '没有可显示的差异'; + String get pdfMarginWide => '宽'; @override - String get gitBinaryFile => '二进制文件。BusyMark 不渲染二进制补丁。'; + String get pdfIncludePageNumbers => '包含页码'; @override - String gitBinaryFileInfo(int size) { - return '二进制文件($size 字节)。BusyMark 不渲染二进制补丁。'; - } + String get export => '导出'; @override - String get gitUnsavedChangesBanner => '保存前不会包含未保存的编辑器更改。'; + String get exportFormat => '输出格式'; @override - String get gitConfirmDiscardTitle => '放弃 Git 更改?'; + String get exportingPdf => '正在导出 PDF…'; @override - String gitConfirmDiscardTracked(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '选中的已跟踪文件中的所有已暂存和未暂存更改都将还原到 HEAD。', - one: '选中的已跟踪文件中的所有已暂存和未暂存更改都将还原到 HEAD。', - ); - return '$_temp0'; - } + String get fileTypePdf => 'PDF 文档'; @override - String gitConfirmDiscardUntracked(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '选中的未跟踪文件将被删除。', - one: '选中的未跟踪文件将被删除。', - ); - return '$_temp0'; + String pdfExported(String fileName) { + return '已导出 $fileName。'; } @override - String gitConfirmDiscardMixed(int count) { + String pdfExportedWithWarnings(String fileName, int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '将根据其 Git 状态还原或删除选中的文件。', - one: '将根据其 Git 状态还原或删除选中的文件。', + other: '$count 条警告', + one: '1 条警告', ); - return '$_temp0'; - } - - @override - String gitConfirmSwitchBranchTitle(String branch) { - return '切换到 $branch?'; + return '$fileName 已导出,包含 $_temp0。'; } @override - String get gitConfirmSwitchBranchMessage => 'Git 切换分支后,BusyMark 将从磁盘重新加载工作区。'; + String get pdfExportUnavailable => '缺少 PDF 导出组件。请重新安装 BusyMark 后重试。'; @override - String get gitConfirmPushSetUpstreamTitle => '设置上游分支?'; + String get pdfExportTimedOut => 'PDF 导出耗时过长,已停止。'; @override - String gitConfirmPushSetUpstreamMessage(String branch) { - return '此分支没有上游分支。当只配置了一个远程仓库时,BusyMark 可以推送 $branch 并设置其上游分支。'; - } + String get pdfExportFailed => 'BusyMark 无法将此文档导出为 PDF。'; @override - String get gitProjectHistory => '项目历史记录'; + String get visualizationRendering => '正在渲染…'; @override - String get gitFileHistory => '文件历史记录'; + String get visualizationStale => '正在显示上次有效的渲染结果'; @override - String get gitFileHistoryRequiresOpenFile => '文件历史记录需要打开的 Markdown 文件。'; + String get visualizationShowSource => '显示源代码'; @override - String get gitLoadMore => '加载更多'; + String get visualizationShowRender => '显示渲染结果'; @override - String get gitChangesInCommit => '此提交中的更改'; + String get visualizationFitWidth => '适应宽度'; @override - String get gitCompareWithCurrent => '与当前版本比较'; + String get visualizationSaveImage => '保存图像'; @override - String get gitRestoreVersion => '还原此版本'; + String get visualizationCopyImage => '复制图像'; @override - String get gitConfirmRestoreTitle => '还原此文件版本?'; + String get visualizationImageCopied => '图像已复制'; @override - String get gitConfirmRestoreMessage => - 'BusyMark 将使用选中的已提交版本替换当前工作树中的文件。还原后的文件将保持未暂存状态。'; + String get visualizationOpenApiReference => '打开 API 参考'; @override - String get gitCommitActions => '提交操作'; + String get visualizationValid => '有效'; @override - String get gitResetCurrentBranchToHere => '将当前分支重置到此处…'; + String get visualizationInvalid => '无效'; @override - String gitResetCurrentBranchTitle(String branch, String commit) { - return '将 $branch 重置到 $commit?'; - } + String get visualizationServers => '服务器'; @override - String gitResetCurrentBranchMessage(String branch, String commit) { - return '这会将分支 $branch 移动到提交 $commit。请选择 Git 更新索引和工作树的方式。'; - } + String get visualizationPaths => '路径'; @override - String get gitReset => '重置'; + String get visualizationOperations => '操作'; @override - String get gitResetModeSoft => '软'; + String get visualizationTags => '标签'; @override - String get gitResetModeSoftDescription => - '仅移动分支。保持索引和工作树不变;与选中提交之间的差异仍会保持暂存状态。'; + String get visualizationNoOperations => '没有匹配的操作'; @override - String get gitResetModeMixed => '混合'; + String get visualizationSearchOperations => '搜索操作'; @override - String get gitResetModeMixedDescription => '移动分支并重置索引。保持工作树不变,使差异处于未暂存状态。'; + String get visualizationRenderFailed => '无法渲染此可视化内容。'; @override - String get gitResetModeHard => '硬'; + String get visualizationRetry => '重试'; @override - String get gitResetModeHardDescription => - '移动分支并重置索引和工作树。已跟踪的更改将被放弃;造成阻碍的未跟踪文件可能会被删除。'; + String visualizationSaved(String fileName) { + return '已保存 $fileName'; + } @override - String get gitResetModeKeep => '保留'; + String get shortcutExportPdfDescription => '将当前文档或 Writerside 模块导出为 PDF。'; @override - String get gitResetModeKeepDescription => - '移动分支并重置已跟踪文件,同时保留本地更改。如果这些更改与重置冲突,Git 将中止操作。'; + String get instances => '实例'; @override - String gitAdditionsDeletions(int additions, int deletions) { - return '+$additions -$deletions'; - } + String get newInstance => '新建实例'; @override - String get fileActions => '文件操作'; + String get newTocLibrary => '新建目录库'; @override - String get actions => '操作'; + String get editInstance => '编辑实例'; @override - String get gitStatusAdded => '已添加'; + String get openTocFile => '打开目录文件'; @override - String get gitStatusDeleted => '已删除'; + String get createInstance => '创建实例'; @override - String get gitStatusRenamed => '已重命名'; + String get createTocLibrary => '创建目录库'; @override - String get gitStatusCopied => '已复制'; + String get instanceContent => '内容'; @override - String get gitStatusUntracked => '未跟踪'; + String get instanceContentSource => '创建来源'; @override - String get gitStatusConflicted => '有冲突'; + String get emptyInstance => '空实例'; @override - String get gitStatusIgnored => '已忽略'; + String get markdownFiles => '本地 Markdown 文件'; @override - String get gitStatusTypeChanged => '类型已更改'; + String get chooseMarkdownFolder => '选择 Markdown 文件夹'; @override - String get gitStatusModified => '已修改'; + String get errorWritersideInstanceImportSourceRequired => + '选择包含 Markdown 文件的文件夹。'; @override - String get gitStatusUnknown => '未知'; + String get instanceAppearance => '外观'; @override - String get gitErrorUnavailable => 'Git 不可用。'; + String get instanceColor => '图标颜色'; @override - String get gitErrorNotRepository => '此工作区不是 Git 存储库。'; + String get instanceVersion => '版本'; @override - String get gitErrorUnsafePath => 'BusyMark 阻止了不安全的 Git 路径。'; + String instanceVersionInherited(String version) { + return '此字段为空时,项目版本为 $version。'; + } @override - String get gitErrorInvalidBranchName => '请输入有效的分支名称。'; + String get instanceWebPath => 'Web 路径'; @override - String get gitErrorNoRemote => '未配置 Git 远程仓库。'; + String get instanceStatus => '状态'; @override - String get gitErrorNoUpstream => '未配置上游分支。'; + String get instanceStatusRelease => '发布'; @override - String get gitErrorMultipleRemotes => '配置了多个远程仓库。请在此 BusyMark 版本之外选择上游分支。'; + String get instanceStatusEap => '抢先体验'; @override - String get gitErrorDirtyWorkspace => '切换分支前,请保存或放弃 BusyMark 编辑器中的更改。'; + String get instanceStatusDeprecated => '已弃用'; @override - String get gitErrorResetDirtyWorkspace => '重置当前分支前,请保存或放弃 BusyMark 编辑器中的更改。'; + String get allowSearchEngineIndexing => '允许搜索引擎建立索引'; @override - String get gitErrorRestoreStagedFile => '还原历史版本前,请取消暂存此文件。'; + String get allowSearchEngineIndexingDescription => '允许外部搜索引擎为此输出建立索引。'; @override - String get gitErrorResetDetachedHead => '重置前请先检出一个分支。'; + String get offlineArtifact => '离线构件'; @override - String get gitErrorDiverged => '分支已发生分歧。请在此 BusyMark 版本之外解决合并或变基问题。'; + String get offlineArtifactDescription => '捆绑资源,使构建的文档可以自包含。'; @override - String get gitErrorAuthorIdentity => 'Git 需要作者姓名和邮箱地址才能提交。'; + String get instanceOutputSettings => '输出设置'; @override - String get gitAuthorIdentityTitle => 'Git 作者身份'; + String get markdownImportSource => 'Markdown 源'; @override - String get gitAuthorIdentityMessage => - '请输入 Git 应记录在提交中的身份信息。BusyMark 将保存它并重试此次提交。'; + String get markdownImportFiles => 'Markdown 文件'; @override - String get gitAuthorName => '姓名'; + String get selectNone => '全部取消选择'; @override - String get gitAuthorEmail => 'Email'; + String markdownFilesFound(int count) { + return '找到 $count 个 Markdown 文件'; + } @override - String get gitAuthorIdentityGlobal => '用于所有存储库'; + String get noMarkdownFilesFound => '在此目录中未找到 Markdown 文件。'; @override - String get gitAuthorIdentityGlobalDescription => - '作为 Snap 安装时,此设置适用于在 BusyMark 中打开的存储库。'; + String get copyReferencedMedia => '复制引用的媒体'; @override - String get gitSaveIdentityAndCommit => '保存并提交'; + String get copyReferencedMediaDescription => '复制选中文件引用的本地图像和视频,同时保留相对路径。'; @override - String get gitErrorAuthentication => 'Git 身份验证失败。'; + String get instanceIdRenameWarningTitle => '重命名实例 ID?'; @override - String get gitErrorNetwork => 'Git 网络操作失败。'; + String instanceIdRenameWarning(String oldId, String newId) { + return 'BusyMark 将重命名 .tree 文件,并将 Writerside 项目引用从“$oldId”更新为“$newId”。发布脚本不会更改,必须单独更新。'; + } @override - String get gitErrorConflict => 'Git 报告存在未解决的冲突。'; + String get renameAndUpdateReferences => '重命名并更新引用'; @override - String get gitErrorCommandFailed => 'Git 命令失败。'; + String get tocLibraryDescription => '目录库用于存储可复用的节,不会生成自己的输出。'; @override - String get syntaxReference => '语法参考'; + String get defaultTocLibraryName => '共享目录'; @override - String get syntaxReferenceMarkdownBlocks => 'Markdown 块'; + String get instanceColorAutomatic => '自动'; @override - String get syntaxReferenceMarkdownBlocksDescription => - 'Markdown 源代码和预览中支持的块结构。'; + String get instanceColorBlue => '蓝色'; @override - String get syntaxReferenceInlineFormatting => '行内 Markdown'; + String get instanceColorGreen => '绿色'; @override - String get syntaxReferenceInlineFormattingDescription => - '可出现在段落、列表项和表格单元格中的格式。'; + String get instanceColorOrange => '橙色'; @override - String get syntaxReferenceRawHtmlBlocks => '原始 HTML 块'; + String get instanceColorPurple => '紫色'; @override - String get syntaxReferenceRawHtmlBlocksDescription => - '通过 BusyMark 预览组件渲染的安全块级 HTML 标签。'; + String get instanceColorRed => '红色'; @override - String get syntaxReferenceRawHtmlInline => '原始 HTML 行内标签'; + String get instanceColorTeal => '青绿色'; @override - String get syntaxReferenceRawHtmlInlineDescription => - '安全的行内 HTML 标签,渲染时不会显示字面标签。'; + String get instanceColorYellow => '黄色'; @override - String get syntaxReferenceHeadings => '标题'; + String get errorWritersideInstanceNameRequired => '请输入实例名称。'; @override - String get syntaxReferenceParagraphs => '段落'; + String errorWritersideInstanceIdExists(String id) { + return '已存在 ID 为“$id”的实例。'; + } @override - String get syntaxReferenceLists => '列表'; + String errorWritersideInstanceTreeExists(String path) { + return '实例树已存在:$path'; + } @override - String get syntaxReferenceHtmlContainers => '容器'; + String errorWritersideInstanceImportSourceMissing(String path) { + return 'Markdown 源目录不存在:$path'; + } @override - String get syntaxReferenceHtmlTextBlocks => '文本块'; + String get errorWritersideInstanceImportSelectionRequired => + '请选择至少一个 Markdown 文件进行导入。'; @override - String get syntaxReferenceHtmlFigures => '图和图像'; + String errorWritersideInstanceImportFileInvalid(String path) { + return '选中的源中有一个不是可读取的 Markdown 文件:$path'; + } @override - String get syntaxReferenceHtmlPreformatted => '预格式化代码'; + String errorWritersideInstanceImportTargetExists(String path) { + return '导入将覆盖现有项目文件:$path'; + } @override - String get syntaxReferenceHtmlDisclosure => '折叠块'; + String get errorWritersideInstanceFilesChanged => '实例文件已在磁盘上发生变化。请查看后重试。'; @override - String get syntaxReferenceHtmlDescriptionLists => '描述列表'; + String errorWritersideInstanceRollbackFailed(String paths) { + return 'BusyMark 无法完全回滚实例更改。请在继续前查看这些文件:$paths'; + } @override - String get syntaxReferenceHtmlFormattingTags => '格式标签'; + String get errorWritersideInstanceLibraryImport => '目录库无法导入 Markdown 主题。'; @override - String get syntaxReferenceHtmlInlineCodeTags => '行内代码标签'; + String get errorWritersideInstanceWebPathInvalid => 'Web 路径必须为单行文本。'; @override - String get syntaxReferenceHtmlNeutralInlineTags => '语义文本标签'; + String get errorWritersideInstanceConfigurationInvalid => + 'Writerside 实例配置无效。请修正其诊断信息后重试。'; @override - String get syntaxReferenceSanitizedPreviewDescription => - '允许的 HTML 会转换为 BusyMark 预览块,而不是在浏览器中渲染。'; + String get errorWritersideInstanceTemporaryFile => 'BusyMark 无法安全暂存实例更改。'; @override - String get syntaxReferenceSourcePreservedDescription => - '未经编辑的原始 HTML 会按源文本原样保存。'; + String diagnosticWritersideTreeInvalidStatus(String status) { + return '未知的实例状态“$status”。请使用 release、eap 或 deprecated。'; + } @override - String get syntaxReferenceMarkdownInsideHtmlDescription => - '原始 HTML 内的 Markdown 标记会按字面文本渲染。'; + String diagnosticWritersideDuplicateInstanceId(String id) { + return '实例 ID“$id”被多个树文件使用。'; + } @override - String get syntaxReferenceBlockedContentDescription => - '脚本、样式、框架、表单、SVG、MathML、事件和不安全属性都会被阻止。'; + String get diagnosticWritersideBuildProfilesInvalidRoot => + 'buildprofiles.xml 必须包含 <buildprofiles> 根元素。'; @override - String get syntaxReferenceSafeUrlsDescription => - '链接允许 http、https、mailto、tel、相对路径和片段 URL;不安全的 scheme 会被阻止。'; + String diagnosticWritersideBuildProfilesInvalidBoolean( + String name, + String value, + ) { + return '$name 值“$value”必须为 true 或 false。'; + } @override - String get syntaxReferenceCategory => '类别'; + String get diagnosticWritersideBuildProfileMissingInstance => + '<build-profile> 元素必须指定实例 ID。'; @override - String get syntaxReferenceCategoryHtml => 'HTML'; + String get diagnosticWritersideTreeInvalidInclude => + '树中的 <include> 必须同时指定 from 和 element-id。'; @override - String get syntaxReferenceCategoryDiagramsAndApi => '图表和 API'; + String get diagnosticWritersideTreeMissingSnippetId => + '树中的 <snippet> 必须指定 id。'; @override - String get syntaxReferenceCategoryMathematics => '数学'; + String get diagnosticWritersideTreeInvalidCrossInstanceReference => + '跨实例目录引用必须同时指定 ref 和 in。'; @override - String get syntaxReferenceExample => '示例'; + String get diagnosticWritersideTreeConflictingTargets => + '目录元素不能同时指向多个主题、引用、链接或重定向。'; @override - String get syntaxReferenceIdentifiers => '标识符和别名'; + String diagnosticWritersideTreeDuplicateElementId(String id) { + return '树元素 ID“$id”被声明了多次。'; + } @override - String get syntaxReferenceScope => '适用范围'; + String get diagnosticWritersideInstanceGroupsInvalidRoot => + '实例组文件必须包含 <instance-groups> 根元素。'; @override - String get syntaxReferenceLimitation => 'BusyMark 限制'; + String get diagnosticWritersideInstanceGroupInvalid => '实例组必须指定非空 ID 和实例列表。'; @override - String get syntaxReferenceOfficialDocumentation => '官方文档'; + String diagnosticWritersideInstanceGroupDuplicateId(String id) { + return '实例组 ID“$id”被声明了多次。'; + } @override - String get syntaxReferenceScopeWritersideMarkdown => '仅 Writerside Markdown'; + String diagnosticWritersideExternalTreeInclude( + String source, + String id, + String origin, + ) { + return '目录包含项“$source#$id”属于外部模块“$origin”,无法在此工作区中展开。'; + } @override - String get syntaxReferenceScopeWritersideMarkdownAndXml => - '仅 Writerside Markdown 和 Writerside XML'; + String diagnosticWritersideTreeIncludeElementMissing( + String source, + String id, + ) { + return '已注册树“$source”中不存在树元素“$id”。'; + } @override - String get syntaxReferenceMarkdownDescription => - 'BusyMark 可以编写和预览的核心 Markdown 形式。'; + String diagnosticWritersideTreeCircularInclude(String source, String id) { + return '树包含项“$source#$id”创建了循环。'; + } @override - String get syntaxReferenceParagraphExample => '一段文本。'; + String diagnosticWritersideUnknownInstanceGroup(String group) { + return '实例条件引用了未知组“@$group”。'; + } @override - String get syntaxReferenceTableLimitation => - '表格使用 GitHub Flavored Markdown 的竖线语法。'; + String diagnosticWritersideReferenceInstanceMissing(String instance) { + return '跨实例引用指向未知实例“$instance”。'; + } + + @override + String diagnosticWritersideReferenceTopicMissing( + String topic, + String instance, + ) { + return '主题“$topic”不在被引用的实例“$instance”中。'; + } @override - String get syntaxReferenceHardBreakIdentifiers => '行尾两个空格、\\、<br>'; + String get download => '下载'; @override - String get syntaxReferenceHtmlDescription => - 'BusyMark 在 Markdown 源码中仅接受有限且安全的原始 HTML 子集。'; + String get exportWritersideAsPdf => '将 Writerside 导出为 PDF'; @override - String get syntaxReferenceDiagramsDescription => - '带围栏的 Mermaid、PlantUML、D2 和 OpenAPI 块可在 Markdown 源码中使用。围栏标识符不区分大小写,BusyMark 会保留原始拼写。'; + String get writersidePdfContent => '导出内容'; @override - String get syntaxReferenceMermaid => 'Mermaid'; + String get writersidePdfPage => '页面'; @override - String get syntaxReferencePlantUml => 'PlantUML'; + String get exportingWritersidePdf => '正在导出 Writerside PDF…'; @override - String get syntaxReferenceD2 => 'D2'; + String get ai => 'AI'; @override - String get syntaxReferenceOpenApi => 'OpenAPI'; + String get aiLocalOllama => '本地 Ollama'; @override - String get syntaxReferenceOpenApiLimitation => - '请使用带围栏的 YAML 或 JSON 内容。BusyMark 不会将任意完整的 YAML 或 JSON 文档视为 OpenAPI 参考。'; + String get aiDisabled => '已禁用'; @override - String get syntaxReferenceSemanticDiagramBlocks => '图表的语义代码块'; + String get aiExplicitEditingDescription => + 'AI 编辑需要明确发起。BusyMark 只会将所选提供商对应的显示上下文发送出去,且不会在未经审核的情况下应用建议。'; @override - String get syntaxReferenceSemanticDiagramLimitation => - '语义 code-block 和 src 形式仅支持 Mermaid、PlantUML 和 D2,不支持 OpenAPI,且只能在 Writerside 项目中使用。'; + String get aiProvider => 'AI 提供商'; @override - String get syntaxReferenceReferencedDiagramSource => '引用的图表源'; + String get aiDefaultProvider => '默认提供商'; @override - String get syntaxReferenceReferencedDiagramLimitation => - '路径必须是相对路径并位于已打开的 Writerside 项目内;围栏加 src 的形式仅适用于 Writerside Markdown。'; + String get aiConfigureProvider => '配置提供商'; @override - String get syntaxReferenceMathematicsDescription => - 'BusyMark 支持 TeX 表达式,但不支持完整的 TeX 或 LaTeX 文档。'; + String get aiChooseProvider => '选择 AI 提供商'; @override - String get syntaxReferenceInlineMath => '行内数学公式'; + String get aiOllamaEndpoint => 'Ollama 端点'; @override - String get syntaxReferenceGithubMath => '使用美元符号和反引号的 GitHub 数学公式'; + String get aiOllamaModel => 'Ollama 模型'; @override - String get syntaxReferenceDisplayMath => '显示数学公式'; + String get aiTestConnection => '测试连接'; @override - String get syntaxReferenceMathFence => 'math 围栏'; + String get aiTestingConnection => '正在测试…'; @override - String get syntaxReferenceTexFence => 'tex 围栏'; + String aiConnectionReady(int count) { + return '已连接。找到 $count 个已安装模型。'; + } @override - String get syntaxReferenceMathDelimitersLimitation => - 'BusyMark 不会将 \\(...\\) 或 \\[...\\] 识别为 Markdown 数学分隔符。'; + String get aiNoModels => '未选择模型。'; @override - String get syntaxReferenceTexFenceLimitation => - '在 Writerside 模式之外,tex 围栏仍是普通代码块。'; + String get aiConnectionFailed => 'BusyMark 无法验证 AI 文本生成。'; @override - String get syntaxReferenceWritersideMathElement => 'Writerside math 元素'; + String get aiConfigureFirst => '请先在设置 → AI 中启用 AI 提供商并验证模型。'; @override - String get syntaxReferenceWritersideMathElementLimitation => - 'math 元素是语义 Writerside 语法,不是允许的原始 HTML MathML。'; + String get aiEditWithAi => '使用 AI 编辑'; @override - String get syntaxReferenceSemanticTexBlock => '语义 TeX 代码块'; + String get aiRefineWithAi => '使用 AI 优化'; @override - String get syntaxReferenceWritersideDescription => - '这些特定扩展仅在已打开的 Writerside 项目中解析。'; + String get aiInstruction => '指令'; @override - String get syntaxReferenceAdmonitionBlockquote => '提示块引用'; + String get aiChangeTarget => '可更改内容'; @override - String get syntaxReferenceAdmonitionLimitation => - '普通块引用在 Writerside Markdown 中是提示;在普通 Markdown 中仍是普通引用。'; + String get aiSharedContext => '与 AI 共享的上下文'; @override - String get syntaxReferenceSemanticAdmonitions => '语义提示'; + String get aiTargetSelection => '选中的内容'; @override - String get syntaxReferenceSemanticMarkupLimitation => - '普通 Markdown 不会解析这些 Writerside 语义元素。'; + String get aiTargetInsertAfterBlock => '插入到当前块之后'; @override - String get syntaxReferenceCollapsibleHeading => '可折叠标题'; + String get aiTargetCurrentBlock => '当前块'; @override - String get syntaxReferenceCollapsibleCode => '可折叠代码围栏'; + String get aiTargetCurrentSection => '当前节'; @override - String get syntaxReferenceSemanticCollapsibles => '语义可折叠内容'; + String get aiTargetCompleteDocument => '完整文档'; @override - String get syntaxReferenceSemanticCollapsiblesLimitation => - 'BusyMark 支持可折叠的 chapter、procedure、code-block 和定义列表形式,但不支持完整的 Writerside 目录。'; + String get aiContextNone => '没有文档上下文'; @override - String get syntaxReferenceSemanticCodeBlocks => '数学和图表的语义代码块'; + String get aiContextSelection => '选中的内容'; @override - String get syntaxReferenceVideo => 'Writerside 视频'; + String get aiContextCurrentBlock => '当前块'; @override - String get syntaxReferenceVideoLimitation => - '本地视频使用本地 preview-src 图像;托管视频源必须是受支持的 YouTube 或 Vimeo HTTPS URL。'; + String get aiContextCurrentSection => '当前节'; @override - String get exportAsPdf => '导出为 PDF'; + String get aiContextCompleteDocument => '完整文档'; @override - String get pdfExportDescription => '选择页面布局,生成精美且自包含的 PDF。'; + String get aiGenerating => '正在生成建议…'; @override - String get pdfRemoteImagesNote => '导出过程中不会下载远程图像。有可用的本地图像时将包含它们。'; + String get aiProposal => 'AI 建议'; @override - String get pdfPageSize => '页面大小'; + String get aiGenerateProposal => '生成建议'; @override - String get pdfPageSizeA4 => 'A4'; + String aiContextDisclosure(int count) { + return '所选提供商将接收显示上下文中的 $count 个字符。'; + } @override - String get pdfPageSizeLetter => 'Letter'; + String get aiOriginal => '原始内容'; @override - String get pdfOrientation => '方向'; + String get aiSuggested => '建议内容'; @override - String get pdfPortrait => '纵向'; + String get aiApplyProposal => '应用建议'; @override - String get pdfLandscape => '横向'; + String aiTokenUsage(int input, int output) { + return '输入 token:$input · 输出 token:$output'; + } @override - String get pdfMargins => '页边距'; + String get aiStaleProposal => '生成此建议期间文档发生了变化。请重新执行操作。'; @override - String get pdfMarginNarrow => '窄'; + String get gitAiStagedChangesChanged => '生成此提交消息期间,已暂存的更改发生了变化。请重新执行操作。'; @override - String get pdfMarginNormal => '普通'; + String get aiViewContext => '查看已发送的上下文'; @override - String get pdfMarginWide => '宽'; + String get aiReviewExactContent => '查看确切内容'; @override - String get pdfIncludePageNumbers => '包含页码'; + String get aiContentToChange => '要更改的内容'; @override - String get export => '导出'; + String get aiContentSentToAi => '发送给 AI 的内容'; @override - String get exportFormat => '输出格式'; + String get aiApiKey => 'API 密钥'; @override - String get exportingPdf => '正在导出 PDF…'; + String get aiApiKeyStoredHint => '密钥已存储在系统凭据存储中'; @override - String get fileTypePdf => 'PDF 文档'; + String get aiApiKeyEnterHint => '输入提供商 API 密钥'; @override - String pdfExported(String fileName) { - return '已导出 $fileName。'; - } + String get aiReplaceApiKey => '替换 API 密钥'; @override - String pdfExportedWithWarnings(String fileName, int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count 条警告', - one: '1 条警告', - ); - return '$fileName 已导出,包含 $_temp0。'; - } + String get aiSaveApiKey => '安全保存 API 密钥'; @override - String get pdfExportUnavailable => '缺少 PDF 导出组件。请重新安装 BusyMark 后重试。'; + String get aiRemoveApiKey => '移除已保存的 API 密钥'; @override - String get pdfExportTimedOut => 'PDF 导出耗时过长,已停止。'; + String get aiCredentialSaved => 'API 密钥已保存到系统凭据存储中。'; @override - String get pdfExportFailed => 'BusyMark 无法将此文档导出为 PDF。'; + String get aiCredentialRemoved => '已移除保存的 API 密钥。'; @override - String get visualizationRendering => '正在渲染…'; + String get aiModelRouting => '模型路由'; @override - String get visualizationStale => '正在显示上次有效的渲染结果'; + String get aiAutomaticRouting => '按任务自动选择'; @override - String get visualizationShowSource => '显示源代码'; + String get aiFixedModelRouting => '使用选中的模型'; @override - String get visualizationShowRender => '显示渲染结果'; + String get aiPreferredModel => '首选模型'; @override - String get visualizationFitWidth => '适应宽度'; + String get aiModel => '模型'; @override - String get visualizationSaveImage => '保存图像'; + String aiUsageThisMonth(int requests, int input, int output) { + return '$requests 个请求 · $input 个输入 token · $output 个输出 token'; + } @override - String get visualizationCopyImage => '复制图像'; + String aiCloudConsentTitle(String provider) { + return '将内容发送给 $provider?'; + } @override - String get visualizationImageCopied => '图像已复制'; + String aiCloudConsentEnable(String provider) { + return '启用 $provider'; + } @override - String get visualizationOpenApiReference => '打开 API 参考'; + String get aiCloudConsentMessage => + '只会发送每个 AI 审核对话框中显示的内容。请求不保存状态,建议需要审核,API 密钥存储在 Linux 系统凭据存储中。'; @override - String get visualizationValid => '有效'; + String aiCloudConsentRequired(String provider) { + return '请先在设置 → AI 中确认与 $provider 共享数据。'; + } @override - String get visualizationInvalid => '无效'; + String aiGenerationVerified(String model, int count) { + return '已通过 $model 验证生成能力。有 $count 个兼容模型可用。'; + } @override - String get visualizationServers => '服务器'; + String get aiColdStartObserved => '检测到本地模型冷启动。'; @override - String get visualizationPaths => '路径'; + String get aiNoCompatibleModels => '没有可用的兼容文本生成模型。'; @override - String get visualizationOperations => '操作'; + String get aiEnableProvider => '请先启用 AI 提供商。'; @override - String get visualizationTags => '标签'; + String get aiDraftCommitMessage => '起草提交消息'; @override - String get visualizationNoOperations => '没有匹配的操作'; + String get aiDrafting => '正在起草…'; @override - String get visualizationSearchOperations => '搜索操作'; + String get aiDraftWithAi => '使用 AI 起草'; @override - String get visualizationRenderFailed => '无法渲染此可视化内容。'; + String diagnosticMarkdownHeadingSkippedLevel(int level, int previousLevel) { + return '标题级别 $level 跟在级别 $previousLevel 之后;请检查节的嵌套结构。'; + } @override - String get visualizationRetry => '重试'; + String get diagnosticMarkdownLinkEmptyText => '链接文本为空;请提供能描述其用途的可访问名称。'; @override - String visualizationSaved(String fileName) { - return '已保存 $fileName'; + String diagnosticMarkdownLinkReviewText(String text) { + return '请检查链接文本“$text”是否在上下文中描述了其用途。'; } @override - String get shortcutExportPdfDescription => '将当前文档或 Writerside 模块导出为 PDF。'; + String get diagnosticMarkdownTableEmptyHeader => '表头单元格必须标识其列;请补全每个空表头。'; @override - String get instances => '实例'; + String get mathRenderFailed => '无法渲染数学表达式。'; @override - String get newInstance => '新建实例'; + String get inlineMath => '行内数学'; @override - String get newTocLibrary => '新建目录库'; + String get displayMath => '显示数学'; @override - String get editInstance => '编辑实例'; + String get goToDeclaration => '转到声明'; @override - String get openTocFile => '打开目录文件'; + String get findUsages => '查找用法'; @override - String get createInstance => '创建实例'; + String get cannotRenameSymbol => '无法安全地重命名此符号。请检查名称并刷新引用,然后重试。'; @override - String get createTocLibrary => '创建目录库'; + String get renameTopicFileInstead => '主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。'; @override - String get instanceContent => '内容'; + String get keyboardLayout => '键盘布局'; @override - String get instanceContentSource => '创建来源'; + String get diagnosticWritersideUnsupported => '不支持或格式错误的内容将显示为源代码。'; @override - String get emptyInstance => '空实例'; + String diagnosticWritersideSource(String reference, String reason) { + return '无法解析源“$reference”:$reason'; + } @override - String get markdownFiles => '本地 Markdown 文件'; + String diagnosticWritersideLink(String destination) { + return '此实例中无法使用链接目标:$destination'; + } @override - String get chooseMarkdownFolder => '选择 Markdown 文件夹'; + String diagnosticWritersideSchema( + String element, + String attribute, + String reason, + ) { + return '无效的 Writerside 标记:$element,$attribute。$reason'; + } @override - String get errorWritersideInstanceImportSourceRequired => - '选择包含 Markdown 文件的文件夹。'; + String get diagnosticWritersideLlmsTxt => + '请使用 <llms-txt>true</llms-txt> 或 <llms-txt>false</llms-txt>;不再支持 single-file。'; @override - String get instanceAppearance => '外观'; + String diagnosticWritersideReference(String kind, String reference) { + return '无法解析 $kind:$reference'; + } @override - String get instanceColor => '图标颜色'; + String get exportAsHtml => '导出为 HTML…'; @override - String get instanceVersion => '版本'; + String get fileTypeHtml => 'HTML 文档'; @override - String instanceVersionInherited(String version) { - return '此字段为空时,项目版本为 $version。'; - } + String get exportingHtml => '正在导出 HTML…'; @override - String get instanceWebPath => 'Web 路径'; + String get htmlExported => 'HTML 导出完成'; @override - String get instanceStatus => '状态'; + String get htmlExportFailed => 'HTML 导出失败'; @override - String get instanceStatusRelease => '发布'; + String get htmlKeepAssetsTogether => '复制导出结果时,请将 HTML 文件及其配套资源文件夹放在一起。'; @override - String get instanceStatusEap => '抢先体验'; + String get htmlInstanceDescription => '将一个实例导出为相互链接的离线页面。请选择专用输出文件夹的名称。'; @override - String get instanceStatusDeprecated => '已弃用'; + String get htmlInstance => '实例'; @override - String get allowSearchEngineIndexing => '允许搜索引擎建立索引'; + String get htmlOwnedDirectoryOnly => + '只能替换由 BusyMark HTML 导出创建的文件夹。已修改或无关的文件将被保留。'; @override - String get allowSearchEngineIndexingDescription => '允许外部搜索引擎为此输出建立索引。'; + String get showInFolder => '在文件夹中显示'; @override - String get offlineArtifact => '离线构件'; + String sortTableColumn(String column) { + return '排序 $column'; + } @override - String get offlineArtifactDescription => '捆绑资源,使构建的文档可以自包含。'; + String get exportReset => '恢复默认值'; @override - String get instanceOutputSettings => '输出设置'; + String get exportToc => '包含目录'; @override - String get markdownImportSource => 'Markdown 源'; + String get exportTocDepth => '目录深度'; @override - String get markdownImportFiles => 'Markdown 文件'; + String get exportNumberHeadings => '为标题编号'; @override - String get selectNone => '全部取消选择'; + String get exportLegal => 'US Legal'; @override - String markdownFilesFound(int count) { - return '找到 $count 个 Markdown 文件'; - } + String get exportCustom => '自定义'; @override - String get noMarkdownFilesFound => '在此目录中未找到 Markdown 文件。'; + String get exportPageWidth => '页面宽度(mm)'; @override - String get copyReferencedMedia => '复制引用的媒体'; + String get exportPageHeight => '页面高度(mm)'; @override - String get copyReferencedMediaDescription => '复制选中文件引用的本地图像和视频,同时保留相对路径。'; + String get exportMarginTop => '上边距(mm)'; @override - String get instanceIdRenameWarningTitle => '重命名实例 ID?'; + String get exportMarginRight => '右边距(mm)'; @override - String instanceIdRenameWarning(String oldId, String newId) { - return 'BusyMark 将重命名 .tree 文件,并将 Writerside 项目引用从“$oldId”更新为“$newId”。发布脚本不会更改,必须单独更新。'; - } + String get exportMarginBottom => '下边距(mm)'; @override - String get renameAndUpdateReferences => '重命名并更新引用'; + String get exportMarginLeft => '左边距(mm)'; @override - String get tocLibraryDescription => '目录库用于存储可复用的节,不会生成自己的输出。'; + String get exportTypography => '字体'; @override - String get defaultTocLibraryName => '共享目录'; + String get exportSans => '无衬线'; @override - String get instanceColorAutomatic => '自动'; + String get exportSerif => '衬线'; @override - String get instanceColorBlue => '蓝色'; + String get exportBodySize => '正文字号'; @override - String get instanceColorGreen => '绿色'; + String get exportCodeSize => '代码字号'; @override - String get instanceColorOrange => '橙色'; + String get exportRunningText => '页眉和页脚'; @override - String get instanceColorPurple => '紫色'; + String get exportHeader => '页眉'; @override - String get instanceColorRed => '红色'; + String get exportFooter => '页脚'; @override - String get instanceColorTeal => '青绿色'; + String get exportNone => '无'; @override - String get instanceColorYellow => '黄色'; + String get exportDocumentTitle => '文档标题'; @override - String get errorWritersideInstanceNameRequired => '请输入实例名称。'; + String get exportBottomLeft => '左下'; @override - String errorWritersideInstanceIdExists(String id) { - return '已存在 ID 为“$id”的实例。'; - } + String get exportBottomCenter => '底部居中'; @override - String errorWritersideInstanceTreeExists(String path) { - return '实例树已存在:$path'; - } + String get exportBottomRight => '右下'; @override - String errorWritersideInstanceImportSourceMissing(String path) { - return 'Markdown 源目录不存在:$path'; - } + String get exportFirstPage => '在首页显示页眉和页脚'; @override - String get errorWritersideInstanceImportSelectionRequired => - '请选择至少一个 Markdown 文件进行导入。'; + String get exportAccent => '强调色/链接颜色'; @override - String errorWritersideInstanceImportFileInvalid(String path) { - return '选中的源中有一个不是可读取的 Markdown 文件:$path'; - } + String get exportTheme => '主题'; @override - String errorWritersideInstanceImportTargetExists(String path) { - return '导入将覆盖现有项目文件:$path'; - } + String get exportAutomatic => '自动'; @override - String get errorWritersideInstanceFilesChanged => '实例文件已在磁盘上发生变化。请查看后重试。'; + String get exportLayout => '布局'; @override - String errorWritersideInstanceRollbackFailed(String paths) { - return 'BusyMark 无法完全回滚实例更改。请在继续前查看这些文件:$paths'; - } + String get exportContentWidth => '内容最大宽度'; @override - String get errorWritersideInstanceLibraryImport => '目录库无法导入 Markdown 主题。'; + String get exportOutput => '输出'; @override - String get errorWritersideInstanceWebPathInvalid => 'Web 路径必须为单行文本。'; + String get exportPackaging => '打包方式'; @override - String get errorWritersideInstanceConfigurationInvalid => - 'Writerside 实例配置无效。请修正其诊断信息后重试。'; + String get exportSingleFile => '单个 HTML 文件'; @override - String get errorWritersideInstanceTemporaryFile => 'BusyMark 无法安全暂存实例更改。'; + String get exportAssetsDirectory => 'HTML + 资源目录'; @override - String diagnosticWritersideTreeInvalidStatus(String status) { - return '未知的实例状态“$status”。请使用 release、eap 或 deprecated。'; - } + String get exportCustomCss => '自定义样式表…'; @override - String diagnosticWritersideDuplicateInstanceId(String id) { - return '实例 ID“$id”被多个树文件使用。'; - } + String get exportRemoveCss => '移除自定义样式表'; @override - String get diagnosticWritersideBuildProfilesInvalidRoot => - 'buildprofiles.xml 必须包含 <buildprofiles> 根元素。'; + String get exportCssNote => + '可选用 UTF-8 CSS 文件(最大 256 KiB)。不接受导入、资源 URL、可执行或转义 CSS。样式在 BusyMark 样式表之后应用。不会获取远程资源。'; @override - String diagnosticWritersideBuildProfilesInvalidBoolean( - String name, - String value, - ) { - return '$name 值“$value”必须为 true 或 false。'; - } + String get exportSitePackagingNote => + 'Writerside 保留独立且相互链接的主题页面。单文件模式将资源嵌入每个页面。'; @override - String get diagnosticWritersideBuildProfileMissingInstance => - '<build-profile> 元素必须指定实例 ID。'; + String get exportInvalidGeometry => '扣除边距后,请至少保留 20 mm 的内容宽度和高度。'; @override - String get diagnosticWritersideTreeInvalidInclude => - '树中的 <include> 必须同时指定 from 和 element-id。'; + String get exportInvalidColor => '请输入 #RRGGBB 格式的颜色。'; @override - String get diagnosticWritersideTreeMissingSnippetId => - '树中的 <snippet> 必须指定 id。'; + String get exportInvalidCss => + '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; @override - String get diagnosticWritersideTreeInvalidCrossInstanceReference => - '跨实例目录引用必须同时指定 ref 和 in。'; + String get clipboardHistory => '剪贴板历史'; @override - String get diagnosticWritersideTreeConflictingTargets => - '目录元素不能同时指向多个主题、引用、链接或重定向。'; + String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; @override - String diagnosticWritersideTreeDuplicateElementId(String id) { - return '树元素 ID“$id”被声明了多次。'; - } + String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; @override - String get diagnosticWritersideInstanceGroupsInvalidRoot => - '实例组文件必须包含 <instance-groups> 根元素。'; + String get clipboardCurrent => '当前剪贴板'; @override - String get diagnosticWritersideInstanceGroupInvalid => '实例组必须指定非空 ID 和实例列表。'; + String get clipboardCurrentExternal => '来自其他应用程序的当前剪贴板'; @override - String diagnosticWritersideInstanceGroupDuplicateId(String id) { - return '实例组 ID“$id”被声明了多次。'; + String clipboardDestination(String name) { + return '粘贴到 $name'; } @override - String diagnosticWritersideExternalTreeInclude( - String source, - String id, - String origin, - ) { - return '目录包含项“$source#$id”属于外部模块“$origin”,无法在此工作区中展开。'; - } + String get clipboardSessionOnly => '仅存储在本次 BusyMark 会话中。'; @override - String diagnosticWritersideTreeIncludeElementMissing( - String source, - String id, - ) { - return '已注册树“$source”中不存在树元素“$id”。'; - } + String get clipboardNoItems => '剪贴板历史为空。'; @override - String diagnosticWritersideTreeCircularInclude(String source, String id) { - return '树包含项“$source#$id”创建了循环。'; - } + String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; @override - String diagnosticWritersideUnknownInstanceGroup(String group) { - return '实例条件引用了未知组“@$group”。'; - } + String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; @override - String diagnosticWritersideReferenceInstanceMissing(String instance) { - return '跨实例引用指向未知实例“$instance”。'; - } + String get clipboardUnavailable => '当前剪贴板内容不可用或不受支持。'; @override - String diagnosticWritersideReferenceTopicMissing( - String topic, - String instance, - ) { - return '主题“$topic”不在被引用的实例“$instance”中。'; - } + String get clipboardTooLarge => '此剪贴板内容过大,无法保留。'; @override - String get download => '下载'; + String get clipboardPastePlainText => '作为纯文本粘贴'; @override - String get exportWritersideAsPdf => '将 Writerside 导出为 PDF'; + String get clipboardClearAll => '清除剪贴板历史'; @override - String get writersidePdfContent => '导出内容'; + String get clipboardEntryText => '文本'; @override - String get writersidePdfPage => '页面'; + String get clipboardEntryRichText => '丰富文本'; @override - String get exportingWritersidePdf => '正在导出 Writerside PDF…'; + String get clipboardEntryImage => '图片'; @override - String get ai => 'AI'; + String clipboardOrigin(String name) { + return '复制自 $name'; + } @override - String get aiLocalOllama => '本地 Ollama'; + String get localHistory => '本地历史'; @override - String get aiDisabled => '已禁用'; + String get localHistoryEllipsis => '本地历史…'; @override - String get aiExplicitEditingDescription => - 'AI 编辑需要明确发起。BusyMark 只会将所选提供商对应的显示上下文发送出去,且不会在未经审核的情况下应用建议。'; + String get localHistoryDescription => '显示当前文档的已保存和自动本地修订。'; @override - String get aiProvider => 'AI 提供商'; + String get findLocalHistoryEllipsis => '在本地历史中查找…'; @override - String get aiDefaultProvider => '默认提供商'; + String get findLocalHistoryDescription => '搜索本地历史文档和修订内容。'; @override - String get aiConfigureProvider => '配置提供商'; + String get localHistoryNoDocuments => '尚无文档具有本地历史。'; @override - String get aiChooseProvider => '选择 AI 提供商'; + String get localHistoryNoRevisions => '此文档没有本地历史修订。'; @override - String get aiOllamaEndpoint => 'Ollama 端点'; + String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; @override - String get aiOllamaModel => 'Ollama 模型'; + String get localHistorySearchHint => '搜索本地历史记录'; @override - String get aiTestConnection => '测试连接'; + String get localHistorySelectedRevision => '已选择的修订'; @override - String get aiTestingConnection => '正在测试…'; + String get localHistoryCurrentEditor => '当前编辑器内容'; @override - String aiConnectionReady(int count) { - return '已连接。找到 $count 个已安装模型。'; - } + String get localHistoryCurrentDisk => '磁盘上的当前内容'; @override - String get aiNoModels => '未选择模型。'; + String get localHistoryMissingFile => '原始文件丢失。'; @override - String get aiConnectionFailed => 'BusyMark 无法验证 AI 文本生成。'; + String get localHistoryRestoreRevision => '恢复版本'; @override - String get aiConfigureFirst => '请先在设置 → AI 中启用 AI 提供商并验证模型。'; + String get localHistoryRestoreOriginalLocation => '恢复到原始位置'; @override - String get aiEditWithAi => '使用 AI 编辑'; + String get localHistoryRestoreNewLocation => '恢复到新位置…'; @override - String get aiRefineWithAi => '使用 AI 优化'; + String get localHistoryRestoreChange => '恢复更改'; @override - String get aiInstruction => '指令'; + String get localHistoryRestoreUnavailable => '无法恢复此更改,因为比较已过时。'; @override - String get aiChangeTarget => '可更改内容'; + String get localHistoryComparisonSimplified => '此大型比较已简化。 无法恢复单个更改。'; @override - String get aiSharedContext => '与 AI 共享的上下文'; + String localHistoryOlderChange(String text) { + return '较旧的版本更改: $text'; + } @override - String get aiTargetSelection => '选中的内容'; + String localHistoryCurrentChange(String text) { + return '当前内容更改: $text'; + } @override - String get aiTargetInsertAfterBlock => '插入到当前块之后'; + String get localHistoryDeleted => '已删除'; @override - String get aiTargetCurrentBlock => '当前块'; + String get localHistoryUntitled => '未命名'; @override - String get aiTargetCurrentSection => '当前节'; + String get localHistoryClearDocument => '清除此文档的历史'; @override - String get aiTargetCompleteDocument => '完整文档'; + String get localHistoryClearAll => '清除所有本地历史'; @override - String get aiContextNone => '没有文档上下文'; + String get localHistoryClearDocumentTitle => '清除文档历史?'; @override - String get aiContextSelection => '选中的内容'; + String get localHistoryClearAllTitle => '清除所有本地历史?'; @override - String get aiContextCurrentBlock => '当前块'; + String get localHistoryClearConfirmation => '这将永久删除存储的修订。 此操作无法撤销。'; @override - String get aiContextCurrentSection => '当前节'; + String get localHistoryWarningIndexRebuilt => '本地历史索引已损坏并从完整修订中重建。'; @override - String get aiContextCompleteDocument => '完整文档'; + String localHistoryWarningUnsupportedFormat(String version) { + return '此本地历史存储使用不受支持的格式 $version。'; + } @override - String get aiGenerating => '正在生成建议…'; + String localHistoryWarningUnavailable(String detail) { + return '本地历史不可用: $detail'; + } @override - String get aiProposal => 'AI 建议'; + String get localHistoryWarningRecordingDisabled => + '录音已禁用或此路径已排除。 在恢复之前重新启用录音。'; @override - String get aiGenerateProposal => '生成建议'; + String localHistoryWarningPathChange(String detail) { + return '无法记录本地历史路径更改: $detail'; + } @override - String aiContextDisclosure(int count) { - return '所选提供商将接收显示上下文中的 $count 个字符。'; + String localHistoryWarningDeletedPath(String detail) { + return '本地历史记录无法保留已删除的路径: $detail'; } @override - String get aiOriginal => '原始内容'; + String get localHistoryWarningRevisionMissing => '所选版本丢失或损坏。'; @override - String get aiSuggested => '建议内容'; + String localHistoryWarningRevisionRead(String detail) { + return '无法读取所选版本: $detail'; + } @override - String get aiApplyProposal => '应用建议'; + String localHistoryWarningCapture(String detail) { + return '本地历史记录无法捕获此版本: $detail'; + } @override - String aiTokenUsage(int input, int output) { - return '输入 token:$input · 输出 token:$output'; + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason・$timestamp'; } @override - String get aiStaleProposal => '生成此建议期间文档发生了变化。请重新执行操作。'; + String get localHistoryReasonBaseline => '基线'; @override - String get gitAiStagedChangesChanged => '生成此提交消息期间,已暂存的更改发生了变化。请重新执行操作。'; + String get localHistoryReasonSaved => '已保存'; @override - String get aiViewContext => '查看已发送的上下文'; + String get localHistoryReasonAutomaticCheckpoint => '自动检查点'; @override - String get aiReviewExactContent => '查看确切内容'; + String get localHistoryReasonBeforeReload => '在重新加载之前'; @override - String get aiContentToChange => '要更改的内容'; + String get localHistoryReasonBeforeDiscard => '在丢弃之前'; @override - String get aiContentSentToAi => '发送给 AI 的内容'; + String get localHistoryReasonBeforeRestore => '恢复前'; @override - String get aiApiKey => 'API 密钥'; + String get localHistoryReasonBeforeDelete => '删除前'; @override - String get aiApiKeyStoredHint => '密钥已存储在系统凭据存储中'; + String get localHistoryReasonExternalChange => '外部修改'; @override - String get aiApiKeyEnterHint => '输入提供商 API 密钥'; + String get settingsHistory => '历史记录'; @override - String get aiReplaceApiKey => '替换 API 密钥'; + String get settingsClipboardHistoryTitle => '收集剪贴板历史'; @override - String get aiSaveApiKey => '安全保存 API 密钥'; + String get settingsClipboardHistoryDescription => + '只保留本次 BusyMark 会话中已复制并成功粘贴的内容。'; @override - String get aiRemoveApiKey => '移除已保存的 API 密钥'; + String get settingsLocalHistoryTitle => '记录本地历史'; @override - String get aiCredentialSaved => 'API 密钥已保存到系统凭据存储中。'; + String get settingsLocalHistoryDescription => '将文档修订存储在此设备上,以便恢复和比较。'; @override - String get aiCredentialRemoved => '已移除保存的 API 密钥。'; + String get settingsHistoryCheckpoint => '自动检查点间隔'; @override - String get aiModelRouting => '模型路由'; + String get settingsHistoryRetention => '保留期限'; @override - String get aiAutomaticRouting => '按任务自动选择'; + String get settingsHistoryStorage => '最大存储'; @override - String get aiFixedModelRouting => '使用选中的模型'; + String get settingsHistoryExcludedPaths => '排除路径'; @override - String get aiPreferredModel => '首选模型'; + String get settingsHistoryExcludedPathsHint => '每行一个绝对路径'; @override - String get aiModel => '模型'; + String settingsSecondsValue(int value) { + return '$value 秒'; + } @override - String aiUsageThisMonth(int requests, int input, int output) { - return '$requests 个请求 · $input 个输入 token · $output 个输出 token'; + String settingsDaysValue(int value) { + return '$value 天'; } @override - String aiCloudConsentTitle(String provider) { - return '将内容发送给 $provider?'; + String settingsMebibytesValue(int value) { + return '$value MiB'; } @override - String aiCloudConsentEnable(String provider) { - return '启用 $provider'; - } + String get spelling => '拼写检查'; @override - String get aiCloudConsentMessage => - '只会发送每个 AI 审核对话框中显示的内容。请求不保存状态,建议需要审核,API 密钥存储在 Linux 系统凭据存储中。'; + String get automaticSpelling => '自动检查拼写'; @override - String aiCloudConsentRequired(String provider) { - return '请先在设置 → AI 中确认与 $provider 共享数据。'; - } + String get defaultSpellingLanguage => '默认拼写语言'; @override - String aiGenerationVerified(String model, int count) { - return '已通过 $model 验证生成能力。有 $count 个兼容模型可用。'; - } + String get projectSpellingLanguage => '项目拼写检查语言'; @override - String get aiColdStartObserved => '检测到本地模型冷启动。'; + String get chooseSpellingLanguage => '选择拼写语言'; @override - String get aiNoCompatibleModels => '没有可用的兼容文本生成模型。'; + String get spellingLanguageUnsetStatus => '语言…'; @override - String get aiEnableProvider => '请先启用 AI 提供商。'; + String get spellingOffStatus => '拼写检查已关闭'; @override - String get aiDraftCommitMessage => '起草提交消息'; + String get checkSpelling => '检查拼写'; @override - String get aiDrafting => '正在起草…'; + String get ignoreSpellingOnce => '忽略一次'; @override - String get aiDraftWithAi => '使用 AI 起草'; + String get ignoreSpellingDocument => '在此文档中全部忽略'; @override - String diagnosticMarkdownHeadingSkippedLevel(int level, int previousLevel) { - return '标题级别 $level 跟在级别 $previousLevel 之后;请检查节的嵌套结构。'; - } + String get addPersonalSpellingWord => '添加到个人词典'; @override - String get diagnosticMarkdownLinkEmptyText => '链接文本为空;请提供能描述其用途的可访问名称。'; + String get addProjectSpellingWord => '添加到项目词典'; @override - String diagnosticMarkdownLinkReviewText(String text) { - return '请检查链接文本“$text”是否在上下文中描述了其用途。'; - } + String get inheritSpellingLanguage => '使用项目或应用语言'; @override - String get diagnosticMarkdownTableEmptyHeader => '表头单元格必须标识其列;请补全每个空表头。'; + String get disableDocumentSpelling => '不检查拼写'; @override - String get mathRenderFailed => '无法渲染数学表达式。'; + String get importSpellingDictionary => '导入词典'; @override - String get inlineMath => '行内数学'; + String get personalSpellingDictionary => '个人词典'; @override - String get displayMath => '显示数学'; + String get projectSpellingDictionary => '项目词典'; @override - String get goToDeclaration => '转到声明'; + String get noSpellingErrors => '没有拼写错误'; @override - String get findUsages => '查找用法'; + String get noSpellingSuggestions => '没有拼写建议'; @override - String get cannotRenameSymbol => '无法安全地重命名此符号。请检查名称并刷新引用,然后重试。'; + String get spellingWordCheckFailed => '无法检查此单词'; @override - String get renameTopicFileInstead => '主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。'; + String get spellingCheckFailed => '拼写检查失败'; @override - String get keyboardLayout => '键盘布局'; + String get spellingSettingsLoadFailed => '无法加载拼写检查设置'; @override - String get diagnosticWritersideUnsupported => '不支持或格式错误的内容将显示为源代码。'; + String get retrySpellingSettings => '重试加载拼写检查设置'; @override - String diagnosticWritersideSource(String reference, String reason) { - return '无法解析源“$reference”:$reason'; - } + String get spellingCheckIncomplete => '拼写检查未完成'; @override - String diagnosticWritersideLink(String destination) { - return '此实例中无法使用链接目标:$destination'; - } + String get spellingDictionaryNotInstalled => '尚未安装词典'; @override - String diagnosticWritersideSchema( - String element, - String attribute, - String reason, - ) { - return '无效的 Writerside 标记:$element,$attribute。$reason'; + String spellingDictionaryNotInstalledForLanguage(String dictionary) { + return '尚未安装 $dictionary 词典'; } @override - String get diagnosticWritersideLlmsTxt => - '请使用 <llms-txt>true</llms-txt> 或 <llms-txt>false</llms-txt>;不再支持 single-file。'; + String spellingDictionaryNotInstalledWithSize(String size) { + return '未安装 · $size'; + } @override - String diagnosticWritersideReference(String kind, String reference) { - return '无法解析 $kind:$reference'; - } + String get spellingDictionaryInstalled => '已安装'; @override - String get exportAsHtml => '导出为 HTML…'; + String get installSpellingDictionary => '安装词典'; @override - String get fileTypeHtml => 'HTML 文档'; + String get removeSpellingDictionary => '移除词典'; @override - String get exportingHtml => '正在导出 HTML…'; + String get cancelSpellingDictionaryInstall => '取消下载词典'; @override - String get htmlExported => 'HTML 导出完成'; + String get retrySpellingDictionaryInstall => '重试安装词典'; @override - String get htmlExportFailed => 'HTML 导出失败'; + String spellingDictionaryDownloading(String size) { + return '正在下载 $size…'; + } @override - String get htmlKeepAssetsTogether => '复制导出结果时,请将 HTML 文件及其配套资源文件夹放在一起。'; + String get spellingDictionaryValidating => '正在验证词典…'; @override - String get htmlInstanceDescription => '将一个实例导出为相互链接的离线页面。请选择专用输出文件夹的名称。'; + String get spellingDictionaryInstallFailed => '词典安装失败 — 选择以重试'; @override - String get htmlInstance => '实例'; + String spellingDictionaryInstallPrompt(String dictionary, String size) { + return '要安装 $dictionary($size)吗?安装后,在移除前都可离线使用。'; + } @override - String get htmlOwnedDirectoryOnly => - '只能替换由 BusyMark HTML 导出创建的文件夹。已修改或无关的文件将被保留。'; + String get spellingDictionaryUnavailable => '词典不可用'; @override - String get showInFolder => '在文件夹中显示'; + String get spellingChecking => '正在检查拼写…'; @override - String sortTableColumn(String column) { - return '排序 $column'; + String exportInvalidRange(String field, String minimum, String maximum) { + return '$field:请输入 $minimum 到 $maximum 之间的数字。'; } @override - String get exportReset => '恢复默认值'; + String get spellingDictionaryRecoveryConflict => '拼写词典已被外部更改。已保留恢复副本。'; @override - String get exportToc => '包含目录'; + String get windowMinimize => '最小化'; @override - String get exportTocDepth => '目录深度'; + String get windowMaximize => '最大化'; @override - String get exportNumberHeadings => '为标题编号'; + String get windowRestore => '还原'; @override - String get exportLegal => 'US Legal'; + String get nextcloudNotes => 'Nextcloud Notes'; @override - String get exportCustom => '自定义'; + String get nextcloudConnect => '连接'; @override - String get exportPageWidth => '页面宽度(mm)'; + String get nextcloudReconnect => '重新连接'; @override - String get exportPageHeight => '页面高度(mm)'; + String get nextcloudDisconnect => '断开连接'; @override - String get exportMarginTop => '上边距(mm)'; + String get nextcloudServer => '服务器地址'; @override - String get exportMarginRight => '右边距(mm)'; + String get nextcloudSignInBrowser => '请在浏览器中完成登录。'; @override - String get exportMarginBottom => '下边距(mm)'; + String get nextcloudFavorites => '收藏'; @override - String get exportMarginLeft => '左边距(mm)'; + String get nextcloudNewNote => '新建笔记'; @override - String get exportTypography => '字体'; + String get nextcloudSavedLocally => '已保存到本地 · 等待同步'; @override - String get exportSans => '无衬线'; + String get nextcloudSyncing => '正在同步'; @override - String get exportSerif => '衬线'; + String get nextcloudSynced => '已同步'; @override - String get exportBodySize => '正文字号'; + String get nextcloudOffline => '离线'; @override - String get exportCodeSize => '代码字号'; + String get nextcloudLocked => '被服务器锁定'; @override - String get exportRunningText => '页眉和页脚'; + String get nextcloudReconnectRequired => '需要重新连接'; + + @override + String get nextcloudCreationUncertain => '创建结果不确定'; + + @override + String get nextcloudReadOnly => '只读'; + + @override + String get nextcloudTakeRemote => '使用服务器版本'; @override - String get exportHeader => '页眉'; + String get nextcloudMerge => '合并'; @override - String get exportFooter => '页脚'; + String get nextcloudDeleteNote => '删除笔记?'; @override - String get exportNone => '无'; + String get nextcloudWorkspaceReplaceUnavailable => + 'Nextcloud Notes不支持工作区整体替换。请在每条笔记中替换。'; @override - String get exportDocumentTitle => '文档标题'; + String get nextcloudSaveLocalCopy => '保存本地副本…'; @override - String get exportBottomLeft => '左下'; + String get nextcloudDisconnectWarning => + '移除此账户将删除此设备上缓存的笔记和待同步的更改。请先同步,将工作保留在服务器上。'; @override - String get exportBottomCenter => '底部居中'; + String get nextcloudAttachmentRetryWarning => '如果上次上传成功,重试可能会在服务器上留下未使用的附件。'; @override - String get exportBottomRight => '右下'; + String get nextcloudAttachmentReference => '服务器上的附件引用'; @override - String get exportFirstPage => '在首页显示页眉和页脚'; + String get nextcloudAddAttachment => '添加附件…'; @override - String get exportAccent => '强调色/链接颜色'; + String get nextcloudDeleteAttachmentWarning => + '删除此附件后,笔记中指向它的链接将不可用。保留的数据可通过本地历史记录恢复。'; @override - String get exportTheme => '主题'; + String get nextcloudChooseConflictingAttributes => '为每个冲突属性选择一个值。'; @override - String get exportAutomatic => '自动'; + String get nextcloudUseServerNote => '使用此服务器笔记'; @override - String get exportLayout => '布局'; + String get nextcloudUseServerNoteExplanation => + '将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。'; @override - String get exportContentWidth => '内容最大宽度'; + String get nextcloudExactCandidate => '与请求完全匹配;身份尚未确认。'; @override - String get exportOutput => '输出'; + String get nextcloudPossibleCandidate => '可能与规范化后的请求匹配;身份尚未确认。'; @override - String get exportPackaging => '打包方式'; + String get nextcloudSelectCandidate => '选择要检查的服务器笔记'; @override - String get exportSingleFile => '单个 HTML 文件'; + String get nextcloudCreateSeparate => '创建独立笔记'; @override - String get exportAssetsDirectory => 'HTML + 资源目录'; + String get nextcloudCreateSeparateWarning => + '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; @override - String get exportCustomCss => '自定义样式表…'; + String get wsSemanticFormats => '语义格式'; @override - String get exportRemoveCss => '移除自定义样式表'; + String get wsUiControl => '界面控件'; @override - String get exportCssNote => - '可选用 UTF-8 CSS 文件(最大 256 KiB)。不接受导入、资源 URL、可执行或转义 CSS。样式在 BusyMark 样式表之后应用。不会获取远程资源。'; + String get wsFilePath => '文件 / 路径'; @override - String get exportSitePackagingNote => - 'Writerside 保留独立且相互链接的主题页面。单文件模式将资源嵌入每个页面。'; + String get wsUiPath => '界面路径'; @override - String get exportInvalidGeometry => '扣除边距后,请至少保留 20 mm 的内容宽度和高度。'; + String get wsDefinitionList => '定义列表'; @override - String get exportInvalidColor => '请输入 #RRGGBB 格式的颜色。'; + String get wsIncludeContent => '包含可复用内容'; @override - String get exportInvalidCss => - '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; + String get wsVariableReference => '变量引用'; @override - String get clipboardHistory => '剪贴板历史'; + String get wsConvertList => '将列表转换为过程'; @override - String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; + String get wsElementProperties => '元素属性'; @override - String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; + String get wsTopicProperties => '主题属性'; @override - String get clipboardCurrent => '当前剪贴板'; + String get wsCollapsible => '可折叠'; @override - String get clipboardCurrentExternal => '来自其他应用程序的当前剪贴板'; + String get wsPublishedState => '发布时的初始状态'; @override - String clipboardDestination(String name) { - return '粘贴到 $name'; - } + String get wsExpanded => '展开'; @override - String get clipboardSessionOnly => '仅存储在本次 BusyMark 会话中。'; + String get wsCollapsed => '折叠'; @override - String get clipboardNoItems => '剪贴板历史为空。'; + String get wsCollapsedTitle => '折叠标题'; @override - String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; + String get wsTerm => '术语'; @override - String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; + String get wsListLayout => '列表布局'; @override - String get clipboardUnavailable => '当前剪贴板内容不可用或不受支持。'; + String get wsSyncGroup => '同步组'; @override - String get clipboardTooLarge => '此剪贴板内容过大,无法保留。'; + String get wsTabKey => '选项卡键'; @override - String get clipboardPastePlainText => '作为纯文本粘贴'; + String get wsSwitcherLabel => '切换器标签'; @override - String get clipboardClearAll => '清除剪贴板历史'; + String get wsNavigationElements => '导航元素'; @override - String get clipboardEntryText => '文本'; + String get wsNavigationDepth => '导航深度'; @override - String get clipboardEntryRichText => '丰富文本'; + String get wsAddItem => '添加项目'; @override - String get clipboardEntryImage => '图片'; + String get wsStep => '步骤'; @override - String clipboardOrigin(String name) { - return '复制自 $name'; - } + String get wsPropertyInvalid => '属性值无效'; @override - String get localHistory => '本地历史'; + String get wsFilter => '筛选器'; @override - String get localHistoryEllipsis => '本地历史…'; + String get wsIdentifier => '元素 ID'; @override - String get localHistoryDescription => '显示当前文档的已保存和自动本地修订。'; + String get wsTldr => '摘要 (TLDR)'; @override - String get findLocalHistoryEllipsis => '在本地历史中查找…'; + String get wsFullLayout => '全宽'; @override - String get findLocalHistoryDescription => '搜索本地历史文档和修订内容。'; + String get wsMediumLayout => '中等 (1:2)'; @override - String get localHistoryNoDocuments => '尚无文档具有本地历史。'; + String get wsNarrowLayout => '窄术语列'; @override - String get localHistoryNoRevisions => '此文档没有本地历史修订。'; + String get wsTitle => '标题'; @override - String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; + String get wsSwitcherKey => '切换键'; @override - String get localHistorySearchHint => '搜索本地历史记录'; + String get nextcloudServerSettings => '服务器 Notes 设置'; @override - String get localHistorySelectedRevision => '已选择的修订'; + String get nextcloudNotesPath => '笔记文件夹路径'; @override - String get localHistoryCurrentEditor => '当前编辑器内容'; + String get nextcloudFileSuffix => '新笔记的文件后缀'; @override - String get localHistoryCurrentDisk => '磁盘上的当前内容'; + String get nextcloudServerSettingsExplanation => + '这些服务器 Notes 设置会影响其他客户端。更改路径不会移动文件;更改后缀不会重命名现有笔记。'; @override - String get localHistoryMissingFile => '原始文件丢失。'; + String get nextcloudSettingsSaved => '服务器设置已保存'; @override - String get localHistoryRestoreRevision => '恢复版本'; + String get nextcloudSettingsNormalized => '已保存服务器规范化后的值'; @override - String get localHistoryRestoreOriginalLocation => '恢复到原始位置'; + String get nextcloudPendingUpload => '附件等待上传'; @override - String get localHistoryRestoreNewLocation => '恢复到新位置…'; + String nextcloudServerCheck(String time) { + return '上次服务器检查:$time'; + } @override - String get localHistoryRestoreChange => '恢复更改'; + String get nextcloudSettingsLoading => '正在加载服务器设置'; @override - String get localHistoryRestoreUnavailable => '无法恢复此更改,因为比较已过时。'; + String get nextcloudSettingsSaving => '正在保存服务器设置'; @override - String get localHistoryComparisonSimplified => '此大型比较已简化。 无法恢复单个更改。'; + String get nextcloudPreviousLocalEdit => '先前的本地编辑'; @override - String localHistoryOlderChange(String text) { - return '较旧的版本更改: $text'; - } + String get nextcloudUsePreviousEdit => '使用先前编辑'; @override - String localHistoryCurrentChange(String text) { - return '当前内容更改: $text'; - } + String get quickOpen => '快速打开'; @override - String get localHistoryDeleted => '已删除'; + String get notesAll => '所有笔记'; @override - String get localHistoryUntitled => '未命名'; + String get notesUncategorized => '未分类'; @override - String get localHistoryClearDocument => '清除此文档的历史'; + String get notesRecovery => '恢复'; @override - String get localHistoryClearAll => '清除所有本地历史'; + String get notesRecentlyEdited => '最近编辑'; @override - String get localHistoryClearDocumentTitle => '清除文档历史?'; + String get notesOldestEdited => '最早编辑'; @override - String get localHistoryClearAllTitle => '清除所有本地历史?'; + String get notesTitleAscending => '标题升序'; @override - String get localHistoryClearConfirmation => '这将永久删除存储的修订。 此操作无法撤销。'; + String get notesTitleDescending => '标题降序'; @override - String get localHistoryWarningIndexRebuilt => '本地历史索引已损坏并从完整修订中重建。'; + String get notesMove => '移至分类'; @override - String localHistoryWarningUnsupportedFormat(String version) { - return '此本地历史存储使用不受支持的格式 $version。'; - } + String get notesRemoveFavorite => '取消收藏'; @override - String localHistoryWarningUnavailable(String detail) { - return '本地历史不可用: $detail'; - } + String get notesOffline => '设为离线可用'; @override - String get localHistoryWarningRecordingDisabled => - '录音已禁用或此路径已排除。 在恢复之前重新启用录音。'; + String get notesOfflineRemove => '移除离线保留要求'; @override - String localHistoryWarningPathChange(String detail) { - return '无法记录本地历史路径更改: $detail'; - } + String get notesIndexing => '正在索引笔记…'; @override - String localHistoryWarningDeletedPath(String detail) { - return '本地历史记录无法保留已删除的路径: $detail'; - } + String get notesSearchHelp => '词项使用“与”组合。可使用\"引号短语\"、title: 和 category:。'; @override - String get localHistoryWarningRevisionMissing => '所选版本丢失或损坏。'; + String get notesSourceLocation => '源文件位置'; @override - String localHistoryWarningRevisionRead(String detail) { - return '无法读取所选版本: $detail'; - } + String get notesRecoveryHelp => '仅包括保留的本地笔记和本地历史。无法访问服务器回收站或历史。'; @override - String localHistoryWarningCapture(String detail) { - return '本地历史记录无法捕获此版本: $detail'; - } + String get notesImport => '导入笔记'; @override - String localHistoryRevisionAt(String reason, String timestamp) { - return '$reason・$timestamp'; - } + String get notesExport => '导出笔记'; @override - String get localHistoryReasonBaseline => '基线'; + String get notesImportDistinct => '导入为独立笔记'; @override - String get localHistoryReasonSaved => '已保存'; + String get notesAvailableOffline => '离线可用'; @override - String get localHistoryReasonAutomaticCheckpoint => '自动检查点'; + String get notesIncomplete => '不完整'; @override - String get localHistoryReasonBeforeReload => '在重新加载之前'; + String get notesSelected => '已选笔记'; @override - String get localHistoryReasonBeforeDiscard => '在丢弃之前'; + String get notesNoHistory => '没有保留的版本。历史可能已禁用、过期或清除。'; @override - String get localHistoryReasonBeforeRestore => '恢复前'; + String get notesTextAvailable => '文本已在本地可用'; @override - String get localHistoryReasonBeforeDelete => '删除前'; + String get notesLocalVersion => '本地工作版本'; @override - String get localHistoryReasonExternalChange => '外部修改'; + String get notesAttachments => '附件'; @override - String get settingsHistory => '历史记录'; + String get notesSkipped => '已跳过'; @override - String get settingsClipboardHistoryTitle => '收集剪贴板历史'; + String get notesFailed => '失败'; @override - String get settingsClipboardHistoryDescription => - '只保留本次 BusyMark 会话中已复制并成功粘贴的内容。'; + String get notesCompleted => '已完成'; @override - String get settingsLocalHistoryTitle => '记录本地历史'; + String get notesImportNew => '开始新导入'; @override - String get settingsLocalHistoryDescription => '将文档修订存储在此设备上,以便恢复和比较。'; + String get notesImportResume => '继续中断的导入'; @override - String get settingsHistoryCheckpoint => '自动检查点间隔'; + String get notesImportAlreadyImported => '已在此操作中导入'; @override - String get settingsHistoryRetention => '保留期限'; + String get references => '引用'; @override - String get settingsHistoryStorage => '最大存储'; + String get referenceIncoming => '传入'; @override - String get settingsHistoryExcludedPaths => '排除路径'; + String get referenceOutgoing => '传出'; @override - String get settingsHistoryExcludedPathsHint => '每行一个绝对路径'; + String get referenceChooseTarget => '选择链接目标'; @override - String settingsSecondsValue(int value) { - return '$value 秒'; - } + String get referenceWholeDocument => '整个文档'; @override - String settingsDaysValue(int value) { - return '$value 天'; - } + String get referenceSourceLocation => '源位置'; @override - String settingsMebibytesValue(int value) { - return '$value MiB'; - } + String get referenceTargetLocation => '目标位置'; @override - String get spelling => '拼写检查'; + String get referenceResolved => '已解析'; @override - String get automaticSpelling => '自动检查拼写'; + String get referenceMissing => '缺失'; @override - String get defaultSpellingLanguage => '默认拼写语言'; + String get referenceAmbiguous => '不明确'; @override - String get projectSpellingLanguage => '项目拼写检查语言'; + String get referenceUnavailable => '当前上下文中不可用'; @override - String get chooseSpellingLanguage => '选择拼写语言'; + String get referenceUnsupported => '不支持'; @override - String get spellingLanguageUnsetStatus => '语言…'; + String get referenceNoPortableId => '此目标没有经过验证的可移植笔记网址。'; @override - String get spellingOffStatus => '拼写检查已关闭'; + String get wsIncludeLines => '包含行'; @override - String get checkSpelling => '检查拼写'; + String get wsIncludeSymbol => '包含符号'; @override - String get ignoreSpellingOnce => '忽略一次'; + String get wsEmphasizeLines => '突出显示已显示的行'; @override - String get ignoreSpellingDocument => '在此文档中全部忽略'; + String get wsPropertiesReadOnly => '此视图中的属性为只读。'; @override - String get addPersonalSpellingWord => '添加到个人词典'; + String get wsPropertiesProtected => '此元素受保护。请编辑其源代码。'; @override - String get addProjectSpellingWord => '添加到项目词典'; + String get wsPropertiesConflict => '文档或元素已更改。请还原草稿并重新选择。'; @override - String get inheritSpellingLanguage => '使用项目或应用语言'; + String get wsPropertiesUnresolved => '目前无法解析此引用。'; @override - String get disableDocumentSpelling => '不检查拼写'; + String get wsUseDefault => '使用默认值'; @override - String get importSpellingDictionary => '导入词典'; + String get wsPropertiesSelection => '请选择单个元素。'; @override - String get personalSpellingDictionary => '个人词典'; + String get wsImageDisplay => '图像显示'; @override - String get projectSpellingDictionary => '项目词典'; + String get wsImageThumbnail => '可点击缩略图'; @override - String get noSpellingErrors => '没有拼写错误'; + String get wsEnabled => '已启用'; @override - String get noSpellingSuggestions => '没有拼写建议'; + String get wsDisabled => '已禁用'; @override - String get spellingWordCheckFailed => '无法检查此单词'; + String get wsWidth => '宽度'; @override - String get spellingCheckFailed => '拼写检查失败'; + String get wsHeight => '高度'; @override - String get spellingSettingsLoadFailed => '无法加载拼写检查设置'; + String get wsPropertiesInactive => '未启用发布折叠时,此属性无效。'; @override - String get retrySpellingSettings => '重试加载拼写检查设置'; + String get wsNavigationAll => '所有有标题的元素'; @override - String get spellingCheckIncomplete => '拼写检查未完成'; + String get wsNavigationNone => '无导航'; @override - String get spellingDictionaryNotInstalled => '尚未安装词典'; + String get wsNavigationCustom => '自定义导航'; @override - String spellingDictionaryNotInstalledForLanguage(String dictionary) { - return '尚未安装 $dictionary 词典'; - } + String get wsTitleOverride => '覆盖标题'; @override - String spellingDictionaryNotInstalledWithSize(String size) { - return '未安装 · $size'; - } + String get wsCompactLayout => '紧凑'; @override - String get spellingDictionaryInstalled => '已安装'; + String get wsWideLayout => '宽'; @override - String get installSpellingDictionary => '安装词典'; + String get wsCodeSourceConflict => + '设置 src 前,请移走或删除此代码块中编写的代码。Writerside 不接受带有源引用的非空代码块。'; @override - String get removeSpellingDictionary => '移除词典'; + String get wsInherited => '从列表继承'; @override - String get cancelSpellingDictionaryInstall => '取消下载词典'; + String get wsExternalImageSize => 'Writerside 无法可靠地调整外部托管图片的尺寸。可以查看和移除已有尺寸。'; @override - String get retrySpellingDictionaryInstall => '重试安装词典'; + String get wsPropertiesNotSerialized => '此更改未写入源文本。草稿已保留。'; @override - String spellingDictionaryDownloading(String size) { - return '正在下载 $size…'; - } + String get wsListItem => '列表项'; @override - String get spellingDictionaryValidating => '正在验证词典…'; + String get wsSourceFile => '源文件'; @override - String get spellingDictionaryInstallFailed => '词典安装失败 — 选择以重试'; + String get wsLinkTarget => '链接目标'; @override - String spellingDictionaryInstallPrompt(String dictionary, String size) { - return '要安装 $dictionary($size)吗?安装后,在移除前都可离线使用。'; - } + String get wsPreviewImage => '预览图像'; @override - String get spellingDictionaryUnavailable => '词典不可用'; + String get wsIncludedElement => '包含的元素'; @override - String get spellingChecking => '正在检查拼写…'; + String get wsHelpModule => '帮助模块'; @override - String exportInvalidRange(String field, String minimum, String maximum) { - return '$field:请输入 $minimum 到 $maximum 之间的数字。'; - } + String get wsActionId => '操作 ID'; @override - String get spellingDictionaryRecoveryConflict => '拼写词典已被外部更改。已保留恢复副本。'; + String get wsKeymapHelpInstance => '键位映射帮助实例'; @override - String get windowMinimize => '最小化'; + String get wsShowInSource => '在源代码中显示'; @override - String get windowMaximize => '最大化'; + String get wsOriginElement => '元素覆盖值'; @override - String get windowRestore => '还原'; + String get wsOriginAncestor => '父元素'; @override - String get nextcloudNotes => 'Nextcloud Notes'; + String get wsOriginModule => '模块默认值'; @override - String get nextcloudConnect => '连接'; + String get wsOriginBuiltIn => '内置默认值'; @override - String get nextcloudReconnect => '重新连接'; + String get wsOriginUnresolved => '未解析'; @override - String get nextcloudDisconnect => '断开连接'; + String wsEffectiveProperty(String value, String origin) { + return '有效值:$value — $origin'; + } @override - String get nextcloudServer => '服务器地址'; + String get wsElementSelector => '元素'; @override - String get nextcloudSignInBrowser => '请在浏览器中完成登录。'; + String wsGeneratedAnchorValue(String anchor) { + return '生成值:$anchor'; + } @override - String get nextcloudFavorites => '收藏'; + String wsDefaultProperty(String value) { + return '默认值:$value'; + } @override - String get nextcloudNewNote => '新建笔记'; + String wsDefaultPropertyOrigin(String value, String origin) { + return '默认值:$value · $origin'; + } @override - String get nextcloudSavedLocally => '已保存到本地 · 等待同步'; + String wsResolvedProperty(String value) { + return '解析值:$value'; + } @override - String get nextcloudSyncing => '正在同步'; + String get wsPropertyUnresolved => '未解析'; @override - String get nextcloudSynced => '已同步'; + String get wsModule => '模块'; @override - String get nextcloudOffline => '离线'; + String get wsParent => '父元素'; @override - String get nextcloudLocked => '被服务器锁定'; + String get wsListType => '列表类型'; @override - String get nextcloudReconnectRequired => '需要重新连接'; + String get wsStartingNumber => '起始编号'; @override - String get nextcloudCreationUncertain => '创建结果不确定'; + String get wsListSorting => '排序'; @override - String get nextcloudReadOnly => '只读'; + String get wsLetteredList => '字母列表'; @override - String get nextcloudTakeRemote => '使用服务器版本'; + String get wsCheckboxList => '复选框列表'; @override - String get nextcloudMerge => '合并'; + String get wsMarkerlessList => '无标记列表'; @override - String get nextcloudDeleteNote => '删除笔记?'; + String get wsAscending => '升序'; @override - String get nextcloudWorkspaceReplaceUnavailable => - 'Nextcloud Notes不支持工作区整体替换。请在每条笔记中替换。'; + String get wsDescending => '降序'; @override - String get nextcloudSaveLocalCopy => '保存本地副本…'; + String get wsNoSorting => '不排序'; @override - String get nextcloudDisconnectWarning => - '移除此账户将删除此设备上缓存的笔记和待同步的更改。请先同步,将工作保留在服务器上。'; + String get wsAsWritten => '按原文'; @override - String get nextcloudAttachmentRetryWarning => '如果上次上传成功,重试可能会在服务器上留下未使用的附件。'; + String get wsSentenceCase => '句首大写'; @override - String get nextcloudAttachmentReference => '服务器上的附件引用'; + String get wsTitleCase => '标题大写'; @override - String get nextcloudAddAttachment => '添加附件…'; + String get wsLowerCase => '小写'; @override - String get nextcloudDeleteAttachmentWarning => - '删除此附件后,笔记中指向它的链接将不可用。保留的数据可通过本地历史记录恢复。'; + String get wsUpperCase => '大写'; @override - String get nextcloudChooseConflictingAttributes => '为每个冲突属性选择一个值。'; + String get wsNativeMarkers => 'Markdown 标记'; @override - String get nextcloudUseServerNote => '使用此服务器笔记'; + String wsListMarkerStart(String number) { + return '起始标记:$number'; + } @override - String get nextcloudUseServerNoteExplanation => - '将此草稿关联到选定的服务器笔记,然后同步保留的本地更改。请求匹配并不能确认笔记由谁创建。'; + String wsListLevel(String level) { + return '层级 $level'; + } @override - String get nextcloudExactCandidate => '与请求完全匹配;身份尚未确认。'; + String get wsModuleCapsIgnored => + '固定版本的构建器会忽略模块的 <caps> 规则。请使用本地 caps 属性或 default-property。'; @override - String get nextcloudPossibleCandidate => '可能与规范化后的请求匹配;身份尚未确认。'; + String get wsInsertElements => '插入元素'; @override - String get nextcloudSelectCandidate => '选择要检查的服务器笔记'; + String get wsAdmonitionsAndQuotes => '提示和引用'; @override - String get nextcloudCreateSeparate => '创建独立笔记'; + String get wsSemanticText => '语义文本'; @override - String get nextcloudCreateSeparateWarning => - '首次请求可能已在服务器上创建笔记。此操作将使用保留的本地工作创建独立笔记。不会删除任何疑似重复的笔记。'; + String get wsPropertiesUnavailable => '所检查的元素不可用。请检查一个元素以继续。'; } diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index ac974a9b..eb14e97a 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.6.1'; +const busyMarkAppVersion = '0.6.2'; diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 95436a29..14f3c282 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -79,6 +79,8 @@ class AppSettings { required this.themeModePreference, required this.localeTag, required this.sidebarVisible, + this.elementPropertiesVisible = false, + this.elementPropertiesWidth = 320, required this.previewVisible, required this.documentViewMode, required this.editorFontSize, @@ -172,6 +174,15 @@ class AppSettings { defaults.themeModePreference, ), localeTag: normalizeBusyMarkLocaleTag(json['localeTag']?.toString()), + elementPropertiesVisible: + json['elementPropertiesVisible'] as bool? ?? + defaults.elementPropertiesVisible, + elementPropertiesWidth: + (json['elementPropertiesWidth'] as num?)?.toDouble().clamp( + 260, + 600, + ) ?? + defaults.elementPropertiesWidth, sidebarVisible: json['sidebarVisible'] as bool? ?? defaults.sidebarVisible, previewVisible: documentViewMode != DocumentViewModePreference.source, @@ -283,6 +294,8 @@ class AppSettings { final BusyMarkThemeModePreference themeModePreference; final String? localeTag; final bool sidebarVisible; + final bool elementPropertiesVisible; + final double elementPropertiesWidth; final bool previewVisible; final DocumentViewModePreference documentViewMode; final double editorFontSize; @@ -326,6 +339,8 @@ class AppSettings { 'themeModePreference': themeModePreference.name, 'localeTag': localeTag, 'sidebarVisible': sidebarVisible, + 'elementPropertiesVisible': elementPropertiesVisible, + 'elementPropertiesWidth': elementPropertiesWidth, 'previewVisible': previewVisible, 'documentViewMode': documentViewMode.name, 'editorFontSize': editorFontSize, @@ -408,6 +423,8 @@ class AppSettings { BusyMarkThemeModePreference? themeModePreference, Object? localeTag = _unset, bool? sidebarVisible, + bool? elementPropertiesVisible, + double? elementPropertiesWidth, bool? previewVisible, DocumentViewModePreference? documentViewMode, double? editorFontSize, @@ -449,6 +466,10 @@ class AppSettings { ? this.localeTag : normalizeBusyMarkLocaleTag(localeTag as String?), sidebarVisible: sidebarVisible ?? this.sidebarVisible, + elementPropertiesVisible: + elementPropertiesVisible ?? this.elementPropertiesVisible, + elementPropertiesWidth: + elementPropertiesWidth ?? this.elementPropertiesWidth, previewVisible: previewVisible ?? this.previewVisible, documentViewMode: documentViewMode ?? this.documentViewMode, editorFontSize: editorFontSize ?? this.editorFontSize, @@ -579,6 +600,16 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { } class AppSettingsController extends Notifier<AppSettings> { + bool Function()? processPendingProperties; + Future<void> setElementPropertiesVisible(bool value) { + if (!value && processPendingProperties?.call() == false) { + return Future.value(); + } + return _mutate((s) => s.copyWith(elementPropertiesVisible: value)); + } + + Future<void> setElementPropertiesWidth(double value) => + _mutate((s) => s.copyWith(elementPropertiesWidth: value.clamp(260, 600))); late LocalSettingsStore _store; Future<void>? _lastSave; final _pendingInitialMutations = <AppSettings Function(AppSettings)>[]; @@ -653,6 +684,7 @@ class AppSettingsController extends Notifier<AppSettings> { } Future<void> setDocumentViewMode(DocumentViewModePreference mode) { + if (processPendingProperties?.call() == false) return Future.value(); return _mutate( (settings) => settings.copyWith( documentViewMode: mode, @@ -942,6 +974,9 @@ class AppSettingsController extends Notifier<AppSettings> { } on Object { loaded = AppSettings.defaults(); } + // Visibility belongs to this run; retain the preferred inspector width. + // Explicit requests made during hydration are applied below. + loaded = loaded.copyWith(elementPropertiesVisible: false); final hadPendingInitialMutations = _pendingInitialMutations.isNotEmpty; for (final mutation in _pendingInitialMutations) { loaded = mutation(loaded); diff --git a/lib/src/app/app_shortcuts.dart b/lib/src/app/app_shortcuts.dart deleted file mode 100644 index 2acdc1c3..00000000 --- a/lib/src/app/app_shortcuts.dart +++ /dev/null @@ -1 +0,0 @@ -export 'busymark_shortcuts.dart'; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 59fcd1d2..d4e8c5b0 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -1,3 +1,4 @@ +import '../references/reference_controller.dart'; import 'dart:async'; import 'package:file_selector/file_selector.dart'; @@ -27,6 +28,7 @@ import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; import 'command_palette.dart'; +import 'quick_open.dart'; import 'command_registry.dart'; import 'app_theme.dart'; import 'busymark_dialogs.dart'; @@ -51,6 +53,7 @@ final busyMarkCommandRegistryProvider = Provider<BusyMarkCommandRegistry>(( BusyMarkCommandIds.search: const _OpenSearchIntent(), BusyMarkCommandIds.keyboardShortcuts: const _KeyboardShortcutsIntent(), BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), + BusyMarkCommandIds.quickOpen: const _QuickOpenIntent(), BusyMarkCommandIds.syntaxReference: const _SyntaxReferenceIntent(), BusyMarkCommandIds.settings: const _SettingsIntent(), BusyMarkCommandIds.nextTab: const _NextTabIntent(), @@ -76,6 +79,12 @@ final busyMarkCommandRegistryProvider = Provider<BusyMarkCommandRegistry>(( }; return BusyMarkCommandCatalog.create( executions: { + BusyMarkCommandIds.elementProperties: () => ref + .read(workspaceControllerProvider.notifier) + .toggleElementProperties + ?.call(), + BusyMarkCommandIds.references: () => + ref.read(referencePanelRequestProvider.notifier).request(), for (final entry in commandIntents.entries) entry.key: () { final target = rootNavigatorKey.currentContext; @@ -85,6 +94,8 @@ final busyMarkCommandRegistryProvider = Provider<BusyMarkCommandRegistry>(( }, }, enabled: { + BusyMarkCommandIds.quickOpen: () => + ref.read(workspaceControllerProvider).workspace != null, BusyMarkCommandIds.save: () => ref.read(workspaceControllerProvider).workspace != null, BusyMarkCommandIds.export: () => @@ -374,6 +385,13 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), + _QuickOpenIntent: CallbackAction<_QuickOpenIntent>( + onInvoke: (_) { + final target = rootNavigatorKey.currentContext; + if (target != null) unawaited(showQuickOpen(target, ref)); + return null; + }, + ), _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( onInvoke: (intent) { if (ref.read(workspaceControllerProvider).workspace != @@ -911,11 +929,27 @@ class _BusyMarkWindowLifecycle extends ConsumerStatefulWidget { class _BusyMarkWindowLifecycleState extends ConsumerState<_BusyMarkWindowLifecycle> { late final WindowControlService _windowControlService; + late final AppLifecycleListener _notesLifecycle; + late final GoRouter _notesRouter; @override void initState() { super.initState(); _windowControlService = ref.read(windowControlServiceProvider); + _notesRouter = ref.read(appRouterProvider); + _notesRouter.routeInformationProvider.addListener(_notesRouteChanged); + _notesLifecycle = AppLifecycleListener( + onStateChange: (state) { + final controller = ref.read(workspaceControllerProvider.notifier); + controller.setNotesApplicationVisible( + state == AppLifecycleState.resumed || + state == AppLifecycleState.inactive, + ); + if (state == AppLifecycleState.resumed) { + controller.refreshNotesOnFocus(); + } + }, + ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; @@ -927,6 +961,8 @@ class _BusyMarkWindowLifecycleState @override void dispose() { + _notesRouter.routeInformationProvider.removeListener(_notesRouteChanged); + _notesLifecycle.dispose(); _windowControlService.unregisterCloseHandler(); super.dispose(); } @@ -936,6 +972,17 @@ class _BusyMarkWindowLifecycleState return widget.child; } + void _notesRouteChanged() { + if (!mounted) return; + ref + .read(workspaceControllerProvider.notifier) + .setNotesWorkspaceActive( + _notesRouter.routeInformationProvider.value.uri.path.startsWith( + '/workspace', + ), + ); + } + Future<void> _handleWindowClose() async { final context = rootNavigatorKey.currentContext ?? this.context; if (!context.mounted) { @@ -1207,3 +1254,7 @@ class _DocumentViewModeIntent extends Intent { final DocumentViewModePreference mode; } + +class _QuickOpenIntent extends Intent { + const _QuickOpenIntent(); +} diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index edfafcd2..4cc14af5 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1209,6 +1209,7 @@ class BusyMarkHeaderPopupMenuButton<T> extends StatefulWidget { required this.icon, required this.itemBuilder, required this.onSelected, + this.onCanceled, this.transparent = true, this.elevated = false, this.shortcut, @@ -1224,6 +1225,7 @@ class BusyMarkHeaderPopupMenuButton<T> extends StatefulWidget { final FutureOr<List<PopupMenuEntry<T>>> Function(BuildContext context) itemBuilder; final ValueChanged<T> onSelected; + final VoidCallback? onCanceled; final bool transparent; /// Uses the theme's physical button elevation. @@ -1284,6 +1286,10 @@ class _BusyMarkHeaderPopupMenuButtonState<T> return; } setState(() => _loading = true); + // A menu response belongs to the context in which it was opened. A + // rebuilt caller may now represent another buffer or element. + final onSelected = widget.onSelected; + final onCanceled = widget.onCanceled; try { final items = await widget.itemBuilder(context); if (!mounted || items.isEmpty) { @@ -1313,8 +1319,12 @@ class _BusyMarkHeaderPopupMenuButtonState<T> }); } } - if (mounted && !session.dismissed && selection != null) { - widget.onSelected(selection); + if (mounted && !session.dismissed) { + if (selection != null) { + onSelected(selection); + } else { + onCanceled?.call(); + } } } finally { if (mounted) { diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 61ca9be9..00b13ac8 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -15,6 +15,7 @@ enum BusyMarkMainMenuAction { settings, keyboardShortcuts, commandPalette, + quickOpen, syntaxReference, reportIssue, aboutBusyMark, @@ -74,6 +75,12 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkCommandIds.keyboardShortcuts, ).shortcut?.label, ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.quickOpen, + label: command(BusyMarkCommandIds.quickOpen).label(context), + icon: BusyMarkGlyphs.search, + shortcut: command(BusyMarkCommandIds.quickOpen).shortcut?.label, + ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.commandPalette, label: command(BusyMarkCommandIds.commandPalette).label(context), @@ -104,6 +111,7 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, BusyMarkMainMenuAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, + BusyMarkMainMenuAction.quickOpen => BusyMarkCommandIds.quickOpen, BusyMarkMainMenuAction.commandPalette => BusyMarkCommandIds.commandPalette, BusyMarkMainMenuAction.syntaxReference => diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index f95852b2..6409c3be 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -28,6 +28,7 @@ enum BusyMarkAppShortcutAction { search, keyboardShortcuts, commandPalette, + quickOpen, syntaxReference, settings, nextTab, @@ -48,6 +49,14 @@ abstract final class BusyMarkAppShortcuts { static const backLabel = 'Alt+Left'; static const searchLabel = 'Ctrl+F'; static const keyboardShortcutsLabel = 'Ctrl+Alt+K'; + static const quickOpenLabel = 'Ctrl+P'; + static const quickOpenGtkAccelerator = '<Primary>p'; + static const quickOpen = BusyMarkShortcutDefinition( + label: quickOpenLabel, + activator: SingleActivator(LogicalKeyboardKey.keyP, control: true), + gtkAccelerator: quickOpenGtkAccelerator, + ); + static const commandPaletteLabel = 'Ctrl+Shift+P'; static const syntaxReferenceLabel = 'Ctrl+Alt+M'; static const settingsLabel = 'Ctrl+Alt+S'; @@ -198,6 +207,7 @@ abstract final class BusyMarkAppShortcuts { BusyMarkAppShortcutAction.search: search, BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette: commandPalette, + BusyMarkAppShortcutAction.quickOpen: quickOpen, BusyMarkAppShortcutAction.syntaxReference: syntaxReference, BusyMarkAppShortcutAction.settings: settings, BusyMarkAppShortcutAction.nextTab: nextTab, diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index ffeae413..2fa9cc70 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -251,6 +251,7 @@ abstract final class BusyMarkCommandIds { static const back = 'navigation.back'; static const search = 'search.find'; static const keyboardShortcuts = 'help.keyboardShortcuts'; + static const quickOpen = 'workspace.quickOpen'; static const commandPalette = 'view.commandPalette'; static const syntaxReference = 'help.syntaxReference'; static const settings = 'application.settings'; @@ -259,6 +260,7 @@ abstract final class BusyMarkCommandIds { static const closeTab = 'tabs.close'; static const closeAllTabs = 'tabs.closeAll'; static const toggleSidebar = 'view.toggleSidebar'; + static const elementProperties = 'view.elementProperties'; static const viewEditor = 'view.editor'; static const viewSource = 'view.source'; static const viewReading = 'view.reading'; @@ -276,6 +278,7 @@ abstract final class BusyMarkCommandIds { static const textEscape = 'text.escape'; static const editorRefineWithAi = 'editor.refineWithAi'; static const checkSpelling = 'spelling.check'; + static const references = 'sidebar.references'; static const sidebarFiles = 'sidebar.files'; static const sidebarToc = 'sidebar.toc'; static const sidebarOutline = 'sidebar.outline'; @@ -330,6 +333,18 @@ abstract final class BusyMarkCommandCatalog { } final commands = <BusyMarkCommand>[ + command( + id: BusyMarkCommandIds.elementProperties, + label: (context) => context.l10n.wsElementProperties, + category: (context) => context.l10n.viewMode, + scope: BusyMarkCommandScope.documentView, + ), + command( + id: BusyMarkCommandIds.references, + label: (context) => context.l10n.references, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.sidebar, + ), for (final action in BusyMarkAppShortcutAction.values) command( id: _appId(action), @@ -459,6 +474,7 @@ abstract final class BusyMarkCommandCatalog { BusyMarkAppShortcutAction.fullScreen => BusyMarkCommandIds.fullScreen, BusyMarkAppShortcutAction.back => BusyMarkCommandIds.back, BusyMarkAppShortcutAction.search => BusyMarkCommandIds.search, + BusyMarkAppShortcutAction.quickOpen => BusyMarkCommandIds.quickOpen, BusyMarkAppShortcutAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette => @@ -484,6 +500,7 @@ abstract final class BusyMarkCommandCatalog { BusyMarkAppShortcutAction.fullScreen => context.l10n.fullScreen, BusyMarkAppShortcutAction.back => context.l10n.back, BusyMarkAppShortcutAction.search => context.l10n.search, + BusyMarkAppShortcutAction.quickOpen => context.l10n.quickOpen, BusyMarkAppShortcutAction.keyboardShortcuts => context.l10n.keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette => context.l10n.commandPalette, diff --git a/lib/src/app/quick_open.dart b/lib/src/app/quick_open.dart new file mode 100644 index 00000000..345e365d --- /dev/null +++ b/lib/src/app/quick_open.dart @@ -0,0 +1,245 @@ +import 'dart:isolate'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/domain/notes_search.dart'; +import '../search/workspace_search_scope.dart'; +import '../workspace/workspace_controller.dart'; +import 'busymark_design.dart'; +import 'busymark_dialogs.dart'; +import 'localization.dart'; + +class QuickOpenDocument { + const QuickOpenDocument({ + required this.title, + required this.path, + this.localId, + this.filePath, + }); + final String title, path; + final String? localId, filePath; + String get identity => localId ?? filePath!; +} + +List<QuickOpenDocument> rankQuickOpen( + List<QuickOpenDocument> documents, + String query, { + int limit = 100, +}) { + final normalized = normalizeNotesSearch(query.trim()); + final ranked = <({QuickOpenDocument document, String title, int rank})>[]; + for (final document in documents) { + final title = normalizeNotesSearch(document.title); + final rank = normalized.isEmpty || title == normalized + ? 0 + : title.startsWith(normalized) + ? 1 + : title.contains(normalized) + ? 2 + : normalizeNotesSearch(document.path).contains(normalized) + ? 3 + : 4; + if (rank < 4) ranked.add((document: document, title: title, rank: rank)); + } + ranked.sort((a, b) { + final rank = a.rank.compareTo(b.rank); + if (rank != 0) return rank; + final title = a.title.compareTo(b.title); + if (title != 0) return title; + final path = a.document.path.compareTo(b.document.path); + return path != 0 + ? path + : a.document.identity.compareTo(b.document.identity); + }); + return ranked.take(limit).map((r) => r.document).toList(); +} + +Future<void> showQuickOpen(BuildContext context, WidgetRef ref) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null) return; + final previous = FocusManager.instance.primaryFocus; + final documents = <QuickOpenDocument>[]; + if (workspace.isRemote) { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + documents.addAll( + repository.notes + .where( + (n) => + n.accountId == workspace.nextcloudAccountId && + !isNotesRecovery(n), + ) + .map( + (n) => QuickOpenDocument( + title: n.title, + path: n.category, + localId: n.localId, + ), + ), + ); + } else { + documents.addAll( + workspace.files + .where(isSearchableWorkspaceDocument) + .map( + (f) => QuickOpenDocument( + title: p.basenameWithoutExtension(f.relativePath), + path: f.relativePath, + filePath: f.absolutePath, + ), + ), + ); + } + if (!context.mounted) return; + final result = await showBusyMarkModalDialog<QuickOpenDocument>( + context, + builder: (_) => QuickOpenDialog(documents: documents), + ); + if (!ref.context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + if (result == null) { + if (previous?.context?.mounted == true) previous!.requestFocus(); + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + if (result.localId != null) { + await controller.openNextcloudNote(result.localId!); + } else { + await controller.openActiveFile(result.filePath!); + } +} + +class QuickOpenDialog extends StatefulWidget { + const QuickOpenDialog({super.key, required this.documents}); + final List<QuickOpenDocument> documents; + @override + State<QuickOpenDialog> createState() => _QuickOpenDialogState(); +} + +class _QuickOpenDialogState extends State<QuickOpenDialog> { + late final FocusNode _focus = FocusNode(onKeyEvent: _key); + final _scroll = ScrollController(); + List<QuickOpenDocument> _results = []; + int _selected = 0, _generation = 0; + bool _loading = false; + @override + void initState() { + super.initState(); + _search(''); + } + + Future<void> _search(String query) async { + final generation = ++_generation; + setState(() => _loading = true); + final documents = widget.documents; + final results = await _rankQuickOpenAsync(documents, query); + if (!mounted || generation != _generation) return; + setState(() { + _results = results; + _selected = 0; + _loading = false; + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + } + + KeyEventResult _key(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + _open(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_results.isNotEmpty) { + setState( + () => _selected = + (_selected + + (event.logicalKey == LogicalKeyboardKey.arrowDown + ? 1 + : -1)) + .clamp(0, _results.length - 1), + ); + if (_scroll.hasClients) { + _scroll.animateTo( + (_selected * 64.0).clamp(0, _scroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _open() { + if (!_loading && _results.isNotEmpty) { + Navigator.pop(context, _results[_selected]); + } + } + + @override + void dispose() { + _generation++; + _focus.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.quickOpen, + maxWidth: 640, + children: [ + TextField( + autofocus: true, + focusNode: _focus, + decoration: InputDecoration(labelText: context.l10n.quickOpen), + onChanged: _search, + ), + if (_loading) const LinearProgressIndicator(), + SizedBox( + height: 340, + child: _results.isEmpty + ? Center(child: Text(context.l10n.noResults)) + : ListView.builder( + controller: _scroll, + itemCount: _results.length, + itemExtent: 64, + itemBuilder: (context, index) { + final document = _results[index]; + return ListTile( + selected: index == _selected, + title: Text( + document.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + document.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.pop(context, document), + ); + }, + ), + ), + ], + ); +} + +Future<List<QuickOpenDocument>> _rankQuickOpenAsync( + List<QuickOpenDocument> documents, + String query, +) => Isolate.run(() => rankQuickOpen(documents, query)); diff --git a/lib/src/assets/document_media_context.dart b/lib/src/assets/document_media_context.dart index 5fbaa903..26fa0138 100644 --- a/lib/src/assets/document_media_context.dart +++ b/lib/src/assets/document_media_context.dart @@ -1,50 +1,6 @@ -import 'dart:convert'; -import 'dart:io'; - import 'package:flutter/widgets.dart'; - -Future<String> loadDocumentMediaText( - DocumentMediaContext media, - String reference, { - int maximumBytes = 4 * 1024 * 1024, -}) async { - final path = await media.resolve(reference); - if (path == null) { - throw const FileSystemException('This note attachment is unavailable.'); - } - final file = File(path); - if (await file.length() > maximumBytes) { - throw const FileSystemException( - 'This note attachment exceeds the text limit.', - ); - } - final bytes = await file.readAsBytes(); - if (bytes.length > maximumBytes) { - throw const FileSystemException( - 'This note attachment exceeds the text limit.', - ); - } - return utf8.decode(bytes); -} - -/// Access to media belongs to the document provider, never to authored paths. -/// A remote context deliberately has no local-filesystem fallback. -class DocumentMediaContext { - static final unavailable = DocumentMediaContext( - identity: 'remote-unavailable', - resolve: (_) async => null, - resolveCached: (_) => null, - ); - const DocumentMediaContext({ - required this.identity, - required this.resolve, - required this.resolveCached, - }); - - final String identity; - final Future<String?> Function(String reference) resolve; - final String? Function(String reference) resolveCached; -} +import 'document_media_resolver.dart'; +export 'document_media_resolver.dart'; class DocumentReadOnlyScope extends InheritedWidget { const DocumentReadOnlyScope({ diff --git a/lib/src/assets/document_media_resolver.dart b/lib/src/assets/document_media_resolver.dart new file mode 100644 index 00000000..58f78218 --- /dev/null +++ b/lib/src/assets/document_media_resolver.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:io'; + +Future<String> loadDocumentMediaText( + DocumentMediaContext media, + String reference, { + int maximumBytes = 4 * 1024 * 1024, +}) async { + final path = await media.resolve(reference); + if (path == null) { + throw const FileSystemException('This note attachment is unavailable.'); + } + final file = File(path); + if (await file.length() > maximumBytes) { + throw const FileSystemException( + 'This note attachment exceeds the text limit.', + ); + } + final bytes = await file.readAsBytes(); + if (bytes.length > maximumBytes) { + throw const FileSystemException( + 'This note attachment exceeds the text limit.', + ); + } + return utf8.decode(bytes); +} + +/// Access to media belongs to the document provider, never to authored paths. +/// A remote context deliberately has no local-filesystem fallback. +class DocumentMediaContext { + static final unavailable = DocumentMediaContext( + identity: 'remote-unavailable', + resolve: (_) async => null, + resolveCached: (_) => null, + ); + const DocumentMediaContext({ + required this.identity, + required this.resolve, + required this.resolveCached, + }); + + final String identity; + final Future<String?> Function(String reference) resolve; + final String? Function(String reference) resolveCached; +} diff --git a/lib/src/core/linux_file_identity.dart b/lib/src/core/linux_file_identity.dart new file mode 100644 index 00000000..7083948f --- /dev/null +++ b/lib/src/core/linux_file_identity.dart @@ -0,0 +1,55 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +typedef _StatxNative = + Int32 Function(Int32, Pointer<Utf8>, Int32, Uint32, Pointer<Uint8>); +typedef _StatxDart = int Function(int, Pointer<Utf8>, int, int, Pointer<Uint8>); + +/// Durable identity across rename, including birth time when supported. The +/// fixed 256-byte Linux statx ABI comes from linux/uapi/linux/stat.h; unlike +/// struct stat, its offsets do not vary between supported Linux architectures. +final class LinuxFileIdentity { + LinuxFileIdentity._(); + static final _StatxDart? _statx = _load(); + static _StatxDart? _load() { + if (!Platform.isLinux) return null; + try { + return DynamicLibrary.open( + 'libc.so.6', + ).lookupFunction<_StatxNative, _StatxDart>('statx'); + } on ArgumentError { + return null; + } + } + + /// Unsupported identity inspection returns null. Recovery must fail closed + /// rather than infer identity from a pathname or matching document bytes. + static String? read(String path) { + final statx = _statx; + if (statx == null) return null; + final nativePath = path.toNativeUtf8(); + final buffer = calloc<Uint8>(256); + try { + // AT_FDCWD, AT_SYMLINK_NOFOLLOW, STATX_BASIC_STATS | STATX_BTIME. + if (statx(-100, nativePath, 0x100, 0xfff, buffer) != 0) { + throw FileSystemException('Cannot inspect filesystem identity', path); + } + final data = ByteData.sublistView(buffer.asTypedList(256)); + final mask = data.getUint32(0, Endian.host); + if (mask & 0x100 == 0) return null; // STATX_INO must be real. + final major = data.getUint32(0x88, Endian.host); + final minor = data.getUint32(0x8c, Endian.host); + final inode = data.getUint64(0x20, Endian.host); + final birth = mask & 0x800 == 0 + ? '' + : ':${data.getInt64(0x50, Endian.host)}:${data.getUint32(0x58, Endian.host)}'; + return '$major:$minor:$inode$birth'; + } finally { + calloc.free(buffer); + malloc.free(nativePath); + } + } +} diff --git a/lib/src/editor/editor_shortcuts.dart b/lib/src/editor/editor_shortcuts.dart deleted file mode 100644 index 7898545c..00000000 --- a/lib/src/editor/editor_shortcuts.dart +++ /dev/null @@ -1 +0,0 @@ -export '../app/busymark_shortcuts.dart'; diff --git a/lib/src/editor/inline_semantics.dart b/lib/src/editor/inline_semantics.dart index 4d471afb..5ffb1fa3 100644 --- a/lib/src/editor/inline_semantics.dart +++ b/lib/src/editor/inline_semantics.dart @@ -11,6 +11,8 @@ bool busyMarkSameInlineSemantics(BusyInline left, BusyInline right) => ); bool busyMarkIsInheritedInlineContext(BusyInlineKind kind) => + busyMarkIsSemanticInline(kind) || + kind == BusyInlineKind.writersideVariable || kind == BusyInlineKind.strong || kind == BusyInlineKind.emphasis || kind == BusyInlineKind.underline || diff --git a/lib/src/editor/link_destination.dart b/lib/src/editor/link_destination.dart new file mode 100644 index 00000000..29346c83 --- /dev/null +++ b/lib/src/editor/link_destination.dart @@ -0,0 +1,6 @@ +/// An ordinary link destination with optional Writerside semantic context. +class LinkDestination { + const LinkDestination(this.href, {this.origin}); + final String href; + final String? origin; +} diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index ba2f4429..1821b038 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -364,18 +364,23 @@ abstract final class SourceCommands { static TextEditingValue insertLink( TextEditingValue value, { required String labelPlaceholder, + String destination = 'url', + String? origin, }) { final selection = _normalizedSelection(value); final selected = selection.textInside(value.text); final label = selected.isEmpty ? labelPlaceholder : selected; - final replacement = '[$label](url)'; + final attrs = origin == null + ? '' + : '{origin="${origin.replaceAll('&', '&').replaceAll('"', '"')}"}'; + final replacement = '[$label]($destination)$attrs'; final urlStart = selection.start + label.length + 3; return _replaceSelection( value, replacement, selection: TextSelection( baseOffset: urlStart, - extentOffset: urlStart + 3, + extentOffset: urlStart + destination.length, ), ); } diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index d8034b65..5f1141af 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,3 +1,5 @@ +import '../link_destination.dart'; +import '../wysiwyg/writerside_editing_adapter.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; import 'dart:io'; @@ -112,6 +114,7 @@ class BusyMarkSourceEditor extends StatefulWidget { this.documentFormat, this.markdownMode, this.onAssetSaveRequired, + this.onChooseLink, this.spellingAnnotations = const [], this.onCheckSpelling, this.readSpellingMenuItems, @@ -156,6 +159,7 @@ class BusyMarkSourceEditor extends StatefulWidget { final SourceDocumentFormat? documentFormat; final MarkdownMode? markdownMode; final VoidCallback? onAssetSaveRequired; + final Future<LinkDestination?> Function()? onChooseLink; final List<SpellingAnnotation> spellingAnnotations; final VoidCallback? onCheckSpelling; final BusyMarkEditorSpellingMenuReader? readSpellingMenuItems; @@ -2612,6 +2616,10 @@ class BusyMarkSourceEditorState extends State<BusyMarkSourceEditor> { } void _applyInlineCommand(SourceInlineCommand command) { + if (command == SourceInlineCommand.link && widget.onChooseLink != null) { + unawaited(_chooseLink()); + return; + } _applyFullEditingValue( SourceCommands.applyInlineCommand( _fullEditingValue(), @@ -2623,6 +2631,53 @@ class BusyMarkSourceEditorState extends State<BusyMarkSourceEditor> { ); } + Future<void> _chooseLink() async { + if (DocumentReadOnlyScope.of(context)) return; + final target = _fullEditingValue(); + final id = widget.documentId; + final revision = widget.editRevision; + final path = widget.filePath; + final destination = await widget.onChooseLink!(); + if (!mounted || + widget.documentId != id || + widget.filePath != path || + widget.editRevision != revision || + _fullEditingValue() != target || + destination == null || + DocumentReadOnlyScope.of(context) || + destination.href.trim().isEmpty) { + return; + } + if (_documentFormat == SourceDocumentFormat.writersideXmlTopic) { + final result = const WritersideEditingAdapter().insertSourceLink( + filePath: path ?? '', + source: target.text, + start: target.selection.start, + end: target.selection.end, + destination: destination.href.trim(), + origin: destination.origin, + placeholder: context.l10n.editorPlaceholderText, + ); + if (result != null) { + _applyFullEditingValue( + TextEditingValue( + text: result, + selection: TextSelection.collapsed(offset: target.selection.start), + ), + ); + } + return; + } + _applyFullEditingValue( + SourceCommands.insertLink( + target, + labelPlaceholder: context.l10n.editorPlaceholderText, + destination: destination.href.trim(), + origin: destination.origin, + ), + ); + } + void _applyBlockCommand(SourceBlockCommand command) { _applyFullEditingValue( SourceCommands.applyBlockCommand(_fullEditingValue(), command), diff --git a/lib/src/editor/source/source_folding.dart b/lib/src/editor/source/source_folding.dart deleted file mode 100644 index cfdb7c1c..00000000 --- a/lib/src/editor/source/source_folding.dart +++ /dev/null @@ -1 +0,0 @@ -export '../source_folding.dart'; diff --git a/lib/src/editor/source/source_highlighting.dart b/lib/src/editor/source/source_highlighting.dart deleted file mode 100644 index bfdc79c3..00000000 --- a/lib/src/editor/source/source_highlighting.dart +++ /dev/null @@ -1 +0,0 @@ -export '../source_highlighter.dart'; diff --git a/lib/src/editor/wysiwyg/writerside_editing_adapter.dart b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart new file mode 100644 index 00000000..506b6f33 --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart @@ -0,0 +1,1287 @@ +import 'dart:convert'; +import '../../markdown/markdown_source_map.dart'; +import 'wysiwyg_inline_controller.dart'; +import 'package:xml/xml.dart'; +import '../../core/source_span.dart'; +import '../../markdown/busymark_document.dart'; +import '../../markdown/busymark_markdown_serializer.dart'; +import '../../markdown/markdown_model.dart'; +import '../../writerside/writerside_document.dart'; +import '../../writerside/writerside_document_parser.dart'; +import '../../writerside/writerside_document_serializer.dart'; +import '../../writerside/writerside_schema.dart'; + +/// An unresolved editing projection with bindings back to the authored tree. +/// Unlike the preview renderer, this never substitutes references or invents +/// titles. Unsupported nodes stay atomic and keep their original source. +class WritersideEditingAdapter { + const WritersideEditingAdapter(); + + static Iterable< + ({ + BusyInlineStyleRange range, + int begin, + int end, + int localBegin, + int localEnd, + bool xmlSource, + }) + > + authoredInlineRanges( + BusyDocument document, + String source, + BusyBlock block, + ) sync* { + if (block.sourceSpan == null) return; + if (document.isXmlTopic && document.authoredXml?.source != source) return; + final raw = + block.attributes['busymark-native-inline-source'] ?? + block.rawSource ?? + ''; + if (block.children.isNotEmpty && + !block.attributes.containsKey('element') && + block.attributes.containsKey('busymark-native-source-map') && + !block.attributes.containsKey('busymark-native-inline-source')) { + return; + } + if (!document.isXmlTopic && block.attributes.containsKey('element')) { + // Semantic XML islands retain bindings relative to their own authored + // fragment. Use that same adapter to map text occurrences, including + // variables, then translate back into the containing Markdown source. + const prefix = '<topic id="inspector" title="Inspector">'; + final wrapped = '$prefix$raw</topic>'; + final xml = const WritersideEditingAdapter().parseXml( + filePath: document.filePath, + source: wrapped, + ); + final element = xml?.blocks.first.children + .where((b) => !b.isSourceOnly) + .singleOrNull; + if (xml != null && element?.plainText == block.plainText) { + final references = busyInlineReferenceRanges(block.inlines); + for (final entry in authoredInlineRanges(xml, wrapped, element!)) { + final owner = references + .where( + (r) => + r.kind == entry.range.kind && + r.start == entry.range.start && + r.end == entry.range.end && + r.referenceIndex == entry.range.referenceIndex, + ) + .firstOrNull; + if (owner != null) { + final begin = markdownProjectedSourceOffset( + block, + entry.begin - prefix.length, + inline: true, + ); + final end = markdownProjectedSourceOffset( + block, + entry.end - prefix.length, + inline: true, + ); + if (begin == null || end == null) continue; + yield ( + range: owner, + begin: begin, + end: end, + localBegin: entry.begin - prefix.length, + localEnd: entry.end - prefix.length, + xmlSource: entry.xmlSource, + ); + } + } + } + return; + } + final mapped = + !document.isXmlTopic && !block.attributes.containsKey('element') + ? const MarkdownSourceMapper() + .createInlineParserContext( + documentSource: source, + mode: document.mode, + mapWritersideReferences: true, + ) + .parseMapped(raw) + : null; + final mappedReferences = <BusyInline>[]; + void collect(List<BusyInline> inlines, bool inheritedIgnore) { + for (final inline in inlines) { + final ignore = switch (inline.attributes['ignore-vars']) { + 'true' => true, + 'false' => false, + _ => inheritedIgnore, + }; + if (inline.kind != BusyInlineKind.writersideVariable || !ignore) { + if (mapped!.ranges.containsKey(inline)) mappedReferences.add(inline); + } + collect(inline.children, ignore); + } + } + + if (mapped != null) { + collect(mapped.inlines, block.attributes['ignore-vars'] == 'true'); + } + final references = busyInlineReferenceRanges(block.inlines); + final xmlVariables = document.isXmlTopic + ? const WritersideEditingAdapter() + .xmlVariableSourceRanges(document, block) + .toList() + : const <({int start, int end, int sourceStart, int sourceEnd})>[]; + for (final range in references) { + if (!{ + BusyInlineKind.link, + BusyInlineKind.image, + BusyInlineKind.writersideVariable, + BusyInlineKind.writersideShortcut, + }.contains(range.kind)) { + continue; + } + final binding = range.attributes['xml-source-binding']?.split(':'); + int? begin; + int? end; + int? localBegin; + int? localEnd; + var xmlSource = false; + if (document.isXmlTopic && binding?.length == 2) { + xmlSource = true; + begin = int.tryParse(binding![0]); + end = int.tryParse(binding[1]); + localBegin = begin == null + ? null + : begin - block.sourceSpan!.startOffset; + localEnd = end == null ? null : end - block.sourceSpan!.startOffset; + } else if (mapped != null) { + final peers = references.where((r) => r.kind == range.kind).toList(); + final ordinal = peers.indexOf(range); + final candidates = + mappedReferences.where((e) => e.kind == range.kind).toList()..sort( + (a, b) => + mapped.ranges[a]!.start.compareTo(mapped.ranges[b]!.start), + ); + if (ordinal >= 0 && candidates.length == peers.length) { + final positioned = mapped.ranges[candidates[ordinal]]!; + final attributes = RegExp( + r'^\{(?:\s*[\w:.-]+\s*=\s*"[^"]*"\s*)+\}', + ).firstMatch(raw.substring(positioned.end)); + xmlSource = positioned.rawHtmlFragment != null; + localBegin = positioned.start; + localEnd = positioned.end + (attributes?.end ?? 0); + begin = markdownProjectedSourceOffset( + block, + positioned.start, + inline: true, + ); + end = markdownProjectedSourceOffset( + block, + positioned.end + (attributes?.end ?? 0), + inline: true, + ); + } + } else if (document.isXmlTopic && + range.kind == BusyInlineKind.writersideVariable) { + final occurrence = xmlVariables + .where((v) => v.start == range.start && v.end == range.end) + .singleOrNull; + if (occurrence != null) { + begin = occurrence.sourceStart; + end = occurrence.sourceEnd; + localBegin = begin - block.sourceSpan!.startOffset; + localEnd = end - block.sourceSpan!.startOffset; + } + } + if (begin != null && + end != null && + localBegin != null && + localEnd != null) { + yield ( + range: range, + begin: begin, + end: end, + localBegin: localBegin, + localEnd: localEnd, + xmlSource: xmlSource, + ); + } + } + } + + static bool isNativeHeading(BusyDocument document, BusyBlock block) => + !document.isXmlTopic && + block.kind == BusyBlockKind.heading && + !block.attributes.containsKey('element'); + + /// The existing document interpretation uses the first top-level H1 as the + /// topic heading. Subsequent H1s retain their own heading/anchor ownership. + static BusyBlock? topicHeading(BusyDocument document) => document.blocks + .where( + (b) => isNativeHeading(document, b) && b.attributes['level'] == '1', + ) + .firstOrNull; + + static String propertyTag(BusyDocument document, BusyBlock block) => + isNativeHeading(document, block) ? 'chapter' : tagForBlock(block); + + static Iterable<String> propertyAttributes( + BusyDocument document, + BusyBlock block, + ) => WritersideSchema.attributesFor(propertyTag(document, block)).where( + (name) => + !(topicHeading(document)?.id == block.id && + !{'id', 'title'}.contains(name)), + ); + + /// Reuse the authored semantic parser to protect declarations that an opaque + /// editing block cannot safely expose as one unscoped navigation owner. + static bool topicNavigationEditable(BusyDocument document) { + final semantic = + document.authoredXml ?? + const WritersideDocumentParser().parseMarkdown( + filePath: document.filePath, + source: document.source ?? '', + markdown: document, + ); + final declarations = semantic.elements + .where((e) => e.name == 'show-structure') + .toList(); + final topLevel = document.isXmlTopic + ? semantic.rootElement?.children + .whereType<WritersideElementNode>() + .where((e) => e.name == 'show-structure') + .length ?? + 0 + : semantic.nodes + .whereType<WritersideElementNode>() + .where((e) => e.name == 'show-structure') + .length; + return declarations.length < 2 && + declarations.length == topLevel && + !declarations.any( + (e) => e.attributes.keys.any( + {'instance', 'filter', 'switcher-key'}.contains, + ), + ); + } + + /// Source authoring shares the semantic XML serializer and namespace rules. + String? insertSourceLink({ + required String filePath, + required String source, + required int start, + required int end, + required String destination, + required String placeholder, + String? origin, + }) { + final document = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!document.isWellFormed || + start < 0 || + end < start || + end > source.length) { + return null; + } + final parent = document + .walk() + .whereType<WritersideElementNode>() + .where((n) => n.span.startOffset < start && n.span.endOffset > end) + .lastOrNull; + if (parent == null || + !{ + 'p', + 'title', + 'b', + 'i', + 'em', + 'strong', + 'u', + 's', + 'td', + 'li', + }.contains(parent.name)) { + return null; + } + final openingEnd = source.indexOf('>', parent.span.startOffset); + final closingStart = source.lastIndexOf('</', parent.span.endOffset); + if (start <= openingEnd || end > closingStart) return null; + final prefix = parent.qualifiedName.contains(':') + ? '${parent.qualifiedName.split(':').first}:' + : ''; + final inner = source.substring(start, end); + final link = elementNode( + 'a', + {'href': destination, if (origin != null) 'origin': origin}, + [ + if (inner.isEmpty) + ...inlineNodes( + BusyInline(kind: BusyInlineKind.text, text: placeholder), + filePath, + ) + else + rawNode(inner, filePath), + ], + filePath, + namespacePrefix: prefix, + ); + final fragment = const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: filePath, + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: [link], + ), + ); + final candidate = source.replaceRange(start, end, fragment); + final checked = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: candidate, + ); + if (!checked.isWellFormed) return null; + final authored = checked.elements + .where((e) => e.name == 'a' && e.span.startOffset == start) + .firstOrNull; + return authored?.attributes['href'] == destination ? candidate : null; + } + + static const containers = { + 'topic', + 'chapter', + 'procedure', + 'step', + 'tabs', + 'tab', + 'deflist', + 'def', + 'tldr', + 'list', + 'li', + 'tip', + 'note', + 'warning', + 'quote', + }; + static const titleElements = {'topic', 'chapter', 'procedure', 'tab', 'def'}; + static const referenceLeaves = {'img', 'video', 'include', 'show-structure'}; + + BusyDocument? parseXml({required String filePath, required String source}) { + final authored = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!authored.isWellFormed || + authored.rootElement?.name != 'topic' || + authored.nodes.whereType<WritersideElementNode>().length != 1) { + return null; + } + var index = 0; + return BusyDocument( + filePath: filePath, + mode: MarkdownMode.writersideMarkdown, + source: source, + authoredXml: authored, + title: authored.rootElement?.attributes['title'], + blocks: [ + for (final node in authored.nodes) + blockFromNode(node, () => 'xml-${index++}'), + ], + ); + } + + BusyBlock? parseMarkdownElement(String source, String Function() nextId) { + final doc = const WritersideDocumentParser().parseMarkdown( + filePath: '', + source: source, + markdown: BusyDocument( + filePath: '', + mode: MarkdownMode.writersideMarkdown, + blocks: [ + BusyBlock( + id: 'semantic', + kind: BusyBlockKind.writersideRawXml, + rawSource: source, + sourceSpan: SourceSpan.entireFile('', source), + ), + ], + ), + ); + final element = doc.rootElement; + if (element == null || + (!containers.contains(element.name) && + !{'include', 'video', 'show-structure'}.contains(element.name)) || + element.name == 'topic') { + return null; + } + return blockFromNode(element, nextId, markdown: true); + } + + BusyBlock blockFromNode( + WritersideDocumentNode node, + String Function() nextId, { + bool markdown = false, + int level = 1, + String? listType, + }) { + final id = nextId(); + if (node is WritersideMarkdownBlockNode) { + BusyBlock reidentify(BusyBlock block) => block.copyWith( + id: nextId(), + children: block.children.map(reidentify).toList(), + ); + return reidentify(node.block); + } + final binding = '${node.span.startOffset}:${node.span.endOffset}'; + if (node is! WritersideElementNode) { + if (node is WritersideTextNode && node.text.trim().isNotEmpty) { + return BusyBlock( + id: id, + kind: BusyBlockKind.paragraph, + inlines: textInlines(node.text), + rawSource: node.rawSource, + sourceSpan: node.span, + attributes: { + busyMarkXmlBindingAttribute: binding, + 'element': '#text', + }, + ); + } + return BusyBlock( + id: id, + kind: BusyBlockKind.writersideRawXml, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: true, + isSourceOnly: true, + attributes: {busyMarkXmlBindingAttribute: binding}, + ); + } + final tag = node.name; + if (WritersideSchema.requiredAttributesFor(tag).any( + (a) => + (!node.attributes.containsKey(a) && + !(a == 'title' && + node.children.whereType<WritersideElementNode>().any( + (child) => child.name == 'title', + ))) || + a != 'title' && (node.attributes[a] ?? '').trim().isEmpty, + )) { + return BusyBlock( + id: id, + kind: BusyBlockKind.writersideRawXml, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: true, + attributes: { + ...node.attributes, + 'element': tag, + busyMarkXmlBindingAttribute: binding, + }, + ); + } + final container = containers.contains(tag); + final editable = + container || + { + 'p', + 'code-block', + 'video', + 'include', + 'img', + 'show-structure', + }.contains(tag); + final title = titleElements.contains(tag); + final attributes = <String, String>{ + ...node.attributes, + 'element': tag, + if (!node.attributes.containsKey('title') && + titleElements.contains(tag) && + node.children.whereType<WritersideElementNode>().any( + (child) => child.name == 'title', + )) + 'busymark-title-label': node.children + .whereType<WritersideElementNode>() + .firstWhere((child) => child.name == 'title') + .plainText, + if (node.qualifiedName != tag) + 'busymark-xml-element-prefix': node.qualifiedName.substring( + 0, + node.qualifiedName.length - tag.length, + ), + if (referenceLeaves.contains(tag) && node.children.isNotEmpty) + 'busymark-xml-leaf-content': node.children + .map((n) => n.rawSource) + .join(), + busyMarkXmlBindingAttribute: binding, + if (container) busyMarkWritersideContainerAttribute: 'true', + if (tag == 'topic' || tag == 'chapter') 'level': '$level', + if (tag == 'topic' || tag == 'chapter') + 'generatedId': node.attributes.containsKey('id') ? 'false' : 'true', + if (tag == 'code-block') ...{ + 'language': node.attributes['lang'] ?? '', + busyMarkWritersideCodeBlockSourceFormAttribute: + busyMarkWritersideCodeBlockElementSourceForm, + }, + if (tag == 'li') 'marker': listType == 'decimal' ? '1.' : '-', + if (tag == 'li' && listType == 'checkbox') + 'task': node.attributes['checked'] ?? 'false', + if (busyAdmonitionStyleFromName(tag) != null) ...{ + 'style': tag, + busyMarkWritersideAdmonitionAttribute: 'true', + busyMarkWritersideAdmonitionSourceFormAttribute: 'element', + }, + if (node.attributes.keys.any( + (key) => + key.startsWith('busymark-') || _projectionAttributes.contains(key), + )) + 'busymark-xml-source-attributes': jsonEncode(node.attributes), + }; + final kind = switch (tag) { + 'topic' || 'chapter' => BusyBlockKind.heading, + 'p' => BusyBlockKind.paragraph, + 'code-block' => BusyBlockKind.codeBlock, + 'tip' || + 'note' || + 'warning' || + 'quote' => BusyBlockKind.writersideAdmonition, + 'tabs' || 'tab' => BusyBlockKind.writersideTabs, + 'procedure' => BusyBlockKind.writersideProcedure, + 'li' => + listType == 'decimal' + ? BusyBlockKind.orderedListItem + : listType == 'checkbox' + ? BusyBlockKind.taskListItem + : BusyBlockKind.unorderedListItem, + 'video' => BusyBlockKind.video, + 'img' => BusyBlockKind.image, + _ => BusyBlockKind.writersideRawXml, + }; + return BusyBlock( + id: id, + kind: kind, + attributes: attributes, + rawSource: node.rawSource, + sourceSpan: node.span, + preserveRaw: + !editable || + (tag == 'p' && + inlinesFromNodes(node.children).any((i) => _protectedInline(i))), + isSourceOnly: tag == 'show-structure' && !markdown, + inlines: title + ? [ + BusyInline( + kind: BusyInlineKind.text, + text: node.attributes['title'] ?? '', + ), + ] + : tag == 'img' + ? [ + BusyInline( + kind: BusyInlineKind.image, + text: node.attributes['alt'] ?? '', + destination: node.attributes['src'], + attributes: node.attributes, + ), + ] + : container || !editable + ? const [] + : tag == 'code-block' + ? [BusyInline(kind: BusyInlineKind.text, text: node.plainText)] + : inlinesFromNodes(node.children), + children: container + ? [ + for (final child in node.children) + blockFromNode( + child, + nextId, + markdown: markdown, + level: tag == 'topic' || tag == 'chapter' ? level + 1 : level, + listType: tag == 'list' ? node.attributes['type'] : listType, + ), + ] + : const [], + ); + } + + List<BusyInline> textInlines( + String text, { + WritersideTextNode? authoredText, + Map<BusyInline, ({int start, int end})>? sourceBindings, + }) { + final result = <BusyInline>[]; + final positions = authoredText == null || sourceBindings == null + ? null + : _authoredTextPositions(authoredText); + var end = 0; + for (final match in RegExp(r'%([\w.-]+)%').allMatches(text)) { + if (match.start > end) { + result.add( + BusyInline( + kind: BusyInlineKind.text, + text: text.substring(end, match.start), + ), + ); + } + final variable = BusyInline( + kind: BusyInlineKind.writersideVariable, + text: match.group(1)!, + attributes: {'reference': match.group(1)!}, + ); + result.add(variable); + if (positions != null) { + sourceBindings![variable] = ( + start: positions[match.start].start, + end: positions[match.end - 1].end, + ); + } + end = match.end; + } + if (end < text.length) { + result.add( + BusyInline(kind: BusyInlineKind.text, text: text.substring(end)), + ); + } + return result; + } + + List<BusyInline> inlinesFromNodes( + List<WritersideDocumentNode> nodes, { + Map<BusyInline, ({int start, int end})>? sourceBindings, + }) => [ + for (final node in nodes) + if (node is WritersideTextNode) + ...textInlines( + node.text, + authoredText: node, + sourceBindings: sourceBindings, + ) + else if (node is WritersideRawNode) + BusyInline( + kind: BusyInlineKind.html, + text: '', + attributes: {'xml-raw': node.rawSource}, + ) + else if (node is WritersideElementNode) + _inline(node, sourceBindings: sourceBindings), + ]; + + BusyInline _inline( + WritersideElementNode node, { + Map<BusyInline, ({int start, int end})>? sourceBindings, + }) { + final kind = switch (node.name) { + 'control' => BusyInlineKind.writersideControl, + 'path' => BusyInlineKind.writersidePath, + 'ui-path' => BusyInlineKind.writersideUiPath, + 'shortcut' => BusyInlineKind.writersideShortcut, + 'b' || 'strong' => BusyInlineKind.strong, + 'emphasis' || 'i' => BusyInlineKind.emphasis, + 'code' => BusyInlineKind.code, + 'u' => BusyInlineKind.underline, + 's' => BusyInlineKind.strikethrough, + 'br' => BusyInlineKind.hardBreak, + 'a' => BusyInlineKind.link, + 'img' => BusyInlineKind.image, + _ => BusyInlineKind.html, + }; + final inline = BusyInline( + kind: kind, + text: kind == BusyInlineKind.image + ? node.attributes['alt'] ?? '' + : kind == BusyInlineKind.hardBreak + ? '\n' + : node.plainText, + destination: node.attributes['href'] ?? node.attributes['src'], + children: kind == BusyInlineKind.html + ? const [] + : inlinesFromNodes(node.children, sourceBindings: sourceBindings), + attributes: { + ...node.attributes, + 'xml-name': node.qualifiedName, + 'xml-source-binding': '${node.span.startOffset}:${node.span.endOffset}', + if (kind == BusyInlineKind.html) 'xml-raw': node.rawSource, + }, + ); + return inline.copyWith( + attributes: { + ...inline.attributes, + 'xml-original-source': node.rawSource, + 'xml-original-signature': _inlineSignature(inline), + }, + ); + } + + // Bind tokens within their actual XML text node, including entity and CDATA + // spelling. A binding is emitted only when the decoded text agrees with the + // existing authored parser. Images, breaks and comments are separate nodes. + static List<({int start, int end})>? _authoredTextPositions( + WritersideTextNode node, + ) { + final cdata = node.rawSource.startsWith('<![CDATA['); + final raw = cdata + ? node.rawSource.substring(9, node.rawSource.length - 3) + : node.rawSource; + final base = node.span.startOffset + (cdata ? 9 : 0); + final decoded = StringBuffer(); + final positions = <({int start, int end})>[]; + for (var i = 0; i < raw.length;) { + var end = i + 1; + var value = raw.substring(i, end); + if (!cdata && raw[i] == '&') { + final closing = raw.indexOf(';', i + 1); + if (closing < 0) return null; + end = closing + 1; + try { + value = XmlDocumentFragment.parse(raw.substring(i, end)).innerText; + } on XmlParserException { + return null; + } + } + decoded.write(value); + for (var j = 0; j < value.length; j++) { + positions.add((start: base + i, end: base + end)); + } + i = end; + } + return decoded.toString() == node.text ? positions : null; + } + + /// Variable bindings in the editor's inline coordinate system. Reusing the + /// editing projection makes image alt text, breaks, wrappers and atomic + /// elements advance the position exactly as they do in the editor. + Iterable<({int start, int end, int sourceStart, int sourceEnd})> + xmlVariableSourceRanges(BusyDocument document, BusyBlock block) sync* { + final authored = document.authoredXml; + final binding = block.attributes[busyMarkXmlBindingAttribute]; + if (authored == null || binding == null) return; + final node = authored + .walk() + .where((n) => '${n.span.startOffset}:${n.span.endOffset}' == binding) + .firstOrNull; + if (node == null) return; + final bindings = Map<BusyInline, ({int start, int end})>.identity(); + final projected = node is WritersideTextNode + ? textInlines(node.text, authoredText: node, sourceBindings: bindings) + : node is WritersideElementNode + ? inlinesFromNodes(node.children, sourceBindings: bindings) + : const <BusyInline>[]; + if (projected.map((i) => i.plainText).join() != block.plainText) return; + var offset = 0; + Iterable<({int start, int end, int sourceStart, int sourceEnd})> visit( + BusyInline inline, + ) sync* { + final position = bindings[inline]; + if (position != null) { + yield ( + start: offset, + end: offset + inline.plainText.length, + sourceStart: position.start, + sourceEnd: position.end, + ); + } + if (inline.children.isNotEmpty) { + for (final child in inline.children) { + yield* visit(child); + } + } else { + offset += inline.plainText.length; + } + } + + for (final inline in projected) { + yield* visit(inline); + } + } + + static bool _protectedInline(BusyInline inline) => + (inline.attributes.containsKey('xml-raw') && + inline.plainText.isNotEmpty) || + inline.children.any(_protectedInline); + + static String _inlineSignature(BusyInline inline) { + final keys = + inline.attributes.keys + .where((k) => !k.startsWith('xml-') || k == 'xml-name') + .toList() + ..sort(); + return jsonEncode([ + inline.kind.name, + inline.text, + inline.destination, + {for (final k in keys) k: inline.attributes[k]}, + [for (final child in inline.children) _inlineSignature(child)], + ]); + } + + String serializeXml(BusyDocument document) { + final authored = document.authoredXml!; + final bindings = <String, WritersideDocumentNode>{ + for (final node in authored.walk()) + '${node.span.startOffset}:${node.span.endOffset}': node, + }; + WritersideDocumentNode build(BusyBlock block, String namespacePrefix) { + final original = bindings[block.attributes[busyMarkXmlBindingAttribute]]; + if (block.preserveRaw || (!hasDirtyContent(block) && original != null)) { + return original ?? rawNode(block.rawSource ?? '', document.filePath); + } + final tag = block.attributes['element']; + if (tag == '#text') { + return WritersideTextNode( + text: block.plainText, + span: original?.span ?? SourceSpan.entireFile(document.filePath, ''), + rawSource: '', + isModified: true, + ); + } + final name = tagForBlock(block); + // New descendants belong to their actual parent's namespace. Its + // qualified prefix is already bound by the preserved declarations, + // including local aliases, default namespaces and prefix rebindings. + final childPrefix = original is WritersideElementNode + ? _namespacePrefix(original.qualifiedName) + : namespacePrefix; + final attributes = sourceAttributes(block); + if (titleElements.contains(name) && + (block.attributes.containsKey('title') || + block.plainText.isNotEmpty)) { + attributes['title'] = block.plainText; + } + if (name == 'code-block') { + if (block.attributes['language']?.isNotEmpty == true) { + attributes['lang'] = block.attributes['language']!; + } + } + if (name == 'img') { + final image = block.inlines + .where((i) => i.kind == BusyInlineKind.image) + .firstOrNull; + if (image != null) { + attributes['src'] = image.destination ?? attributes['src'] ?? ''; + if (attributes.containsKey('alt') || image.text.isNotEmpty) { + attributes['alt'] = image.text; + } + } + } + final leafContent = block.attributes['busymark-xml-leaf-content']; + final children = referenceLeaves.contains(name) + ? original is WritersideElementNode + ? original.children + : leafContent != null + ? [rawNode(leafContent, document.filePath)] + : const <WritersideDocumentNode>[] + : busyMarkIsWritersideContainer(block) || + containers.contains(name) && block.children.isNotEmpty + ? block.children.map((child) => build(child, childPrefix)).toList() + : [ + for (final inline in block.inlines) + ...inlineNodes( + inline, + document.filePath, + namespacePrefix: childPrefix, + ), + ]; + return elementNode( + name, + attributes, + children, + document.filePath, + original: original is WritersideElementNode ? original : null, + namespacePrefix: namespacePrefix, + ); + } + + final next = authored.copyWith( + nodes: document.blocks.map((block) => build(block, '')).toList(), + ); + return const WritersideDocumentSerializer().serialize(next); + } + + /// Reparse the committed source to refresh every span and raw subtree, while + /// retaining the live IDs used by selection, outline and transaction targets. + BusyDocument rebaseXml(BusyDocument document, String source) { + final parsed = parseXml(filePath: document.filePath, source: source); + if (parsed == null) { + throw const FormatException('Invalid authored XML edit'); + } + List<BusyBlock> retain(List<BusyBlock> old, List<BusyBlock> fresh) { + final oldVisible = old.where((b) => !b.isSourceOnly).toList(); + var index = 0; + return [ + for (final block in fresh) + if (block.isSourceOnly) + block.copyWith(id: 'xml-raw-${block.id}') + else if (index < oldVisible.length) + (() { + final previous = oldVisible[index++]; + return block.copyWith( + id: previous.id, + children: retain(previous.children, block.children), + ); + })() + else + block, + ]; + } + + return parsed.copyWith(blocks: retain(document.blocks, parsed.blocks)); + } + + /// Maps an editor boundary to its authored XML text, including entities and + /// title attributes. Comments and markup never participate in text matching. + int? xmlTextSourceOffset( + BusyDocument document, + BusyBlock block, + int textOffset, { + required bool endBoundary, + }) { + final authored = document.authoredXml; + final binding = block.attributes[busyMarkXmlBindingAttribute]; + if (authored == null || binding == null) return null; + final node = authored + .walk() + .where((n) => '${n.span.startOffset}:${n.span.endOffset}' == binding) + .firstOrNull; + if (node == null) return null; + final ranges = <({int start, int end})>[]; + void append( + String raw, + int start, { + bool literal = false, + bool variables = false, + }) { + for (var i = 0; i < raw.length;) { + if (variables && raw[i] == '%') { + final match = RegExp(r'^%[\w.-]+%').firstMatch(raw.substring(i)); + if (match != null) { + for (var j = 1; j < match.end - 1; j++) { + ranges.add((start: start + i + j, end: start + i + j + 1)); + } + i += match.end; + continue; + } + } + if (!literal && raw[i] == '&') { + final end = raw.indexOf(';', i + 1); + if (end >= 0) { + final decoded = XmlDocumentFragment.parse( + raw.substring(i, end + 1), + ).innerText; + for (var j = 0; j < decoded.length; j++) { + ranges.add((start: start + i, end: start + end + 1)); + } + i = end + 1; + continue; + } + } + ranges.add((start: start + i, end: start + i + 1)); + i++; + } + } + + if (node is WritersideElementNode && titleElements.contains(node.name)) { + final span = node.attributeSpans['title']; + if (span == null) return node.span.startOffset; + append( + authored.source.substring(span.startOffset, span.endOffset), + span.startOffset, + ); + } else { + for (final text in node.walk().whereType<WritersideTextNode>()) { + final cdata = text.rawSource.startsWith('<![CDATA['); + append( + cdata + ? text.rawSource.substring(9, text.rawSource.length - 3) + : text.rawSource, + text.span.startOffset + (cdata ? 9 : 0), + literal: cdata, + variables: block.kind != BusyBlockKind.codeBlock, + ); + } + } + if (ranges.isEmpty) return node.span.startOffset; + final offset = textOffset.clamp(0, ranges.length); + return offset == ranges.length + ? ranges.last.end + : endBoundary && offset > 0 + ? ranges[offset - 1].end + : ranges[offset].start; + } + + static bool hasDirtyContent(BusyBlock block) => + block.dirty || block.children.any(hasDirtyContent); + + /// XML has one marker style per list. Materializing a mixed native owner + /// would turn ordinary items into tasks or lose task editing semantics. + static bool nativeListHasMixedMarkers(BusyBlock block) => + block.attributes['busymark-list-form'] == 'markdown' && + block.children.any((b) => b.kind == BusyBlockKind.taskListItem) && + block.children.any((b) => b.kind != BusyBlockKind.taskListItem) || + block.children.any(nativeListHasMixedMarkers); + static const _projectionAttributes = { + 'element', + 'htmlTag', + 'htmlAttributes', + 'editorBlockId', + 'level', + 'language', + 'marker', + 'task', + 'ordered', + 'style', + 'generatedId', + 'sourceFormat', + busyMarkWritersideAdmonitionAttribute, + busyMarkWritersideAdmonitionSourceFormAttribute, + busyMarkWritersideCodeBlockSourceFormAttribute, + busyMarkPreserveTextWhitespaceAttribute, + busyMarkTransientTrailingParagraphAttribute, + busyMarkPreserveEmptyParagraphAttribute, + }; + static bool isProjectionAttribute(String key, BusyBlockKind kind) => + key.startsWith('busymark-') || + _projectionAttributes.contains(key) && + (key != 'style' || + kind == BusyBlockKind.writersideAdmonition || + kind == BusyBlockKind.blockquote); + static Map<String, String> sourceAttributes(BusyBlock block) => { + // Some authored attributes share names with the existing Markdown editing + // metadata. Keep their original values independently of the projection. + if (block.attributes['busymark-xml-source-attributes'] ?? + block.attributes['busymark-writerside-source-attributes'] + case final source?) + for (final entry in (jsonDecode(source) as Map<String, dynamic>).entries) + if (isProjectionAttribute(entry.key, block.kind)) + entry.key: entry.value as String, + for (final entry in block.attributes.entries) + if (!isProjectionAttribute(entry.key, block.kind)) entry.key: entry.value, + }; + static String tagForBlock(BusyBlock block) => switch (block.kind) { + BusyBlockKind.paragraph => 'p', + BusyBlockKind.heading => + block.attributes['element'] == 'topic' ? 'topic' : 'chapter', + BusyBlockKind.codeBlock => 'code-block', + BusyBlockKind.image => 'img', + BusyBlockKind.table => 'table', + BusyBlockKind.video => 'video', + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => 'li', + BusyBlockKind.blockquote || + BusyBlockKind.writersideAdmonition => block.attributes['style'] ?? 'quote', + _ => block.attributes['element'] ?? 'p', + }; + + static WritersideRawNode rawNode(String source, String path) => + WritersideRawNode( + span: SourceSpan.entireFile(path, source), + rawSource: source, + ); + static String _namespacePrefix(String qualifiedName) => + qualifiedName.contains(':') ? '${qualifiedName.split(':').first}:' : ''; + static WritersideElementNode elementNode( + String name, + Map<String, String> attributes, + List<WritersideDocumentNode> children, + String path, { + WritersideElementNode? original, + String namespacePrefix = '', + String? qualifiedName, + }) { + final prefix = original?.qualifiedName.contains(':') == true + ? '${original!.qualifiedName.split(':').first}:' + : original == null + ? namespacePrefix + : ''; + return WritersideGenericElementNode( + name: name, + qualifiedName: qualifiedName ?? '$prefix$name', + attributes: attributes, + children: children, + schemaKnown: WritersideSchema.isKnownElement(name), + qualifiedAttributes: original?.qualifiedAttributes ?? const [], + attributeSpans: original?.attributeSpans ?? const {}, + span: original?.span ?? SourceSpan.entireFile(path, ''), + rawSource: original?.rawSource ?? (children.isEmpty ? '<$name/>' : ''), + isModified: true, + ); + } + + static List<WritersideDocumentNode> inlineNodes( + BusyInline inline, + String path, { + String namespacePrefix = '', + }) { + final source = inline.attributes['xml-original-source']; + if (source != null && + inline.attributes['xml-original-signature'] == + _inlineSignature(inline)) { + return [rawNode(source, path)]; + } + if (inline.attributes['xml-raw'] case final raw?) { + return [rawNode(raw, path)]; + } + if (inline.kind == BusyInlineKind.hardBreak) { + return [ + elementNode( + 'br', + const {}, + const [], + path, + namespacePrefix: namespacePrefix, + ), + ]; + } + if (inline.kind == BusyInlineKind.text || + inline.kind == BusyInlineKind.softBreak || + inline.kind == BusyInlineKind.writersideVariable) { + final text = inline.kind == BusyInlineKind.writersideVariable + ? '%${inline.attributes['reference'] ?? inline.text}%' + : inline.text; + if (text.isEmpty) return const []; + return [ + WritersideTextNode( + text: text, + span: SourceSpan.entireFile(path, ''), + rawSource: '', + isModified: true, + ), + ]; + } + final name = + busyMarkSemanticInlineTag(inline.kind) ?? + switch (inline.kind) { + BusyInlineKind.strong => 'b', + BusyInlineKind.emphasis => 'emphasis', + BusyInlineKind.underline => 'u', + BusyInlineKind.strikethrough => 's', + BusyInlineKind.code => 'code', + BusyInlineKind.link => 'a', + BusyInlineKind.image => 'img', + _ => inline.attributes['xml-name'] ?? 'emphasis', + }; + final attrs = { + for (final e in inline.attributes.entries) + if (!e.key.startsWith('xml-') && + !e.key.startsWith('busymark-') && + e.key != 'reference') + e.key: e.value, + if (inline.kind == BusyInlineKind.link && inline.destination != null) + 'href': inline.destination!, + if (inline.kind == BusyInlineKind.image && inline.destination != null) + 'src': inline.destination!, + if (inline.kind == BusyInlineKind.image && + (inline.text.isNotEmpty || inline.attributes.containsKey('alt'))) + 'alt': inline.text, + }; + final qualifiedName = inline.attributes['xml-name']; + final childPrefix = qualifiedName != null + ? _namespacePrefix(qualifiedName) + : namespacePrefix; + return [ + elementNode( + name.split(':').last, + attrs, + inline.kind == BusyInlineKind.image + ? const [] + : inline.children.isEmpty + ? [ + WritersideTextNode( + text: inline.text, + span: SourceSpan.entireFile(path, ''), + rawSource: '', + isModified: true, + ), + ] + : [ + for (final child in inline.children) + ...inlineNodes(child, path, namespacePrefix: childPrefix), + ], + path, + namespacePrefix: namespacePrefix, + qualifiedName: inline.attributes['xml-name'], + ), + ]; + } + + static String serializeInlineElement(BusyInline inline) => + const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: '', + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: inlineNodes(inline, ''), + ), + ); + + String serializeMarkdownElement(BusyBlock block) { + if (!hasDirtyContent(block) && block.rawSource != null) { + return block.rawSource!; + } + final name = block.attributes['element'] ?? tagForBlock(block); + final qualifiedName = + '${block.attributes['busymark-xml-element-prefix'] ?? ''}$name'; + final attrs = sourceAttributes(block); + if (titleElements.contains(name) && + (block.attributes.containsKey('title') || block.plainText.isNotEmpty)) { + attrs['title'] = block.plainText; + } + final attributes = attrs.entries + .map((e) => ' ${e.key}="${escape(e.value)}"') + .join(); + if (!busyMarkIsWritersideContainer(block)) { + if ({'p', 'code-block'}.contains(name)) { + return const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: '', + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: [ + elementNode(name, attrs, [ + for (final inline in block.inlines) ...inlineNodes(inline, ''), + ], ''), + ], + ), + ); + } + final content = block.attributes['busymark-xml-leaf-content']; + return content == null + ? '<$qualifiedName$attributes/>' + : '<$qualifiedName$attributes>$content</$qualifiedName>'; + } + final content = block.children + .map( + (child) => child.rawSource != null && !hasDirtyContent(child) + ? child.rawSource! + : child.attributes.containsKey('busymark-property-edits') + ? const BusyMarkMarkdownSerializer().serializeBlock(child) + : child.isSourceOnly + ? child.rawSource ?? '' + : busyMarkIsWritersideContainer(child) + ? serializeMarkdownElement(child) + : child.kind == BusyBlockKind.paragraph && + (name == 'tldr' || child.attributes['element'] == 'p') + ? const WritersideDocumentSerializer().serialize( + WritersideDocument( + filePath: '', + source: '', + format: WritersideDocumentFormat.xmlTopic, + nodes: [ + elementNode('p', sourceAttributes(child), [ + for (final inline in child.inlines) + ...inlineNodes(inline, ''), + ], ''), + ], + ), + ) + : const BusyMarkMarkdownSerializer().serializeBlock(child), + ) + .join('\n\n'); + return '<$qualifiedName$attributes>\n\n$content\n\n</$qualifiedName>'; + } + + static String escape(String value) => value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/lib/src/editor/wysiwyg/writerside_inspector_target.dart b/lib/src/editor/wysiwyg/writerside_inspector_target.dart new file mode 100644 index 00000000..c318d38c --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_inspector_target.dart @@ -0,0 +1,536 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import '../../markdown/busymark_document.dart'; +import '../../markdown/markdown_source_map.dart'; +import '../../markdown/writerside_authoring_syntax.dart'; +import 'wysiwyg_document_controller.dart'; +import 'wysiwyg_inline_controller.dart'; +import 'writerside_editing_adapter.dart'; + +/// A source-generation-bound identity. Parser IDs are only locators within +/// this snapshot, never durable identities across reparsing or replacement. +class WritersideInspectorTarget { + const WritersideInspectorTarget({ + required this.bufferId, + required this.revision, + required this.source, + required this.authoredSource, + required this.document, + required this.block, + required this.path, + this.inline, + this.multiple = false, + this.logicalTopic = false, + this.navigationEditable = true, + this.unavailable = false, + }); + final String bufferId; + final int revision; + final String source; + final String authoredSource; + final BusyDocument document; + final BusyBlock? block; + final List<BusyBlock> path; + final BusyInlineStyleRange? inline; + final bool multiple; + final bool logicalTopic; + final bool navigationEditable; + + /// Once identity has been lost, ordinary revisions cannot revive this owner. + /// Explicit inspection or a previously captured history owner must replace it. + final bool unavailable; + + WritersideInspectorTarget markUnavailable() => WritersideInspectorTarget( + bufferId: bufferId, + revision: revision, + source: source, + authoredSource: authoredSource, + document: document, + block: block, + path: path, + inline: inline, + multiple: multiple, + logicalTopic: logicalTopic, + navigationEditable: navigationEditable, + unavailable: true, + ); + List<int> get address => _address(includeSourceOnly: true); + + /// Serialization can add XML whitespace nodes. They are not authored + /// property targets and must not shift the edited element's ordinal. + List<int> get semanticAddress => _address(includeSourceOnly: false); + + List<int> _address({required bool includeSourceOnly}) { + List<int>? find(List<BusyBlock> blocks) { + if (!includeSourceOnly) { + blocks = blocks.where((b) => !b.isSourceOnly).toList(); + } + for (var i = 0; i < blocks.length; i++) { + if (blocks[i].id == block?.id) return [i]; + final nested = find(blocks[i].children); + if (nested != null) return [i, ...nested]; + } + return null; + } + + return find(document.blocks) ?? []; + } + + bool hasSameAuthoredState(BusyDocument other) { + bool authored(BusyBlock b) => + !b.isGenerated && + b.attributes[busyMarkTransientTrailingParagraphAttribute] != 'true' && + (!b.isSourceOnly || b.attributes['element'] == 'show-structure'); + Object inlineState(BusyInline inline) => [ + inline.kind, + inline.text, + inline.destination, + { + for (final a in inline.attributes.entries) + if (!a.key.startsWith('busymark-') && + !{ + 'xml-source-binding', + 'xml-original-source', + 'xml-original-signature', + }.contains(a.key)) + a.key: a.value, + }, + inline.children.map(inlineState).toList(), + ]; + Object blockState(BusyBlock block) => [ + WritersideEditingAdapter.tagForBlock(block), + WritersideEditingAdapter.sourceAttributes(block), + if (block.kind == BusyBlockKind.codeBlock) block.attributes['language'], + if (block.kind == BusyBlockKind.blockquote || + block.kind == BusyBlockKind.writersideAdmonition) + block.attributes['style'], + block.inlines.map(inlineState).toList(), + block.children.where(authored).map(blockState).toList(), + if (block.isSourceProtected || block.preserveRaw) block.rawSource, + ]; + Object state(BusyDocument d) => [ + d.frontMatter, + d.blocks.where(authored).map(blockState).toList(), + ]; + bool equal(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + Iterable.generate(a.length).every((i) => equal(a[i], b[i])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.keys.every((k) => b.containsKey(k) && equal(a[k], b[k])); + } + return a == b; + } + + return equal(state(document), state(other)); + } + + bool get topic => + logicalTopic || + block?.attributes['element'] == 'topic' || + block != null && + WritersideEditingAdapter.topicHeading(document)?.id == block!.id; + bool matches(String? id, int? generation, String? text) => + !unavailable && + id == bufferId && + generation == revision && + source == text && + authoredSource == source; + + bool sameElement(WritersideInspectorTarget? current) => + current != null && + bufferId == current.bufferId && + revision == current.revision && + source == current.source && + unavailable == current.unavailable && + topic == current.topic && + multiple == current.multiple && + listEquals(address, current.address) && + inline?.kind == current.inline?.kind && + inline?.start == current.inline?.start && + inline?.end == current.inline?.end && + inline?.referenceIndex == current.inline?.referenceIndex; + + /// Revalidate an off-caret selection after a property-only model refresh. + /// Ordinals alone are insufficient: removal, replacement or changed content + /// must not bind the old inspected element to another authored node. + WritersideInspectorTarget? refreshUnchangedElement({ + required int revision, + required String source, + required BusyDocument document, + }) { + if (unavailable || + document.filePath != this.document.filePath || + document.isXmlTopic != this.document.isXmlTopic || + document.mode != this.document.mode) { + return null; + } + if (logicalTopic) { + final refreshed = fromTopic( + bufferId: bufferId, + revision: revision, + source: source, + document: document, + ); + return refreshed.matches(bufferId, revision, source) ? refreshed : null; + } + final before = block; + if (before == null || document.source != source && !document.isXmlTopic) { + return null; + } + final span = before.sourceSpan; + if (span == null || + span.startOffset < 0 || + span.endOffset > this.source.length) { + return null; + } + + // Locate the retained authored interval through the actual source change. + // Neither parser IDs nor sibling ordinals establish cross-revision identity. + var prefix = 0; + while (prefix < this.source.length && + prefix < source.length && + this.source[prefix] == source[prefix]) { + prefix++; + } + var suffix = 0; + while (suffix < this.source.length - prefix && + suffix < source.length - prefix && + this.source[this.source.length - suffix - 1] == + source[source.length - suffix - 1]) { + suffix++; + } + final oldEnd = this.source.length - suffix; + final delta = source.length - this.source.length; + int? boundary(int offset, {bool end = false}) { + if (end && offset == prefix && prefix == oldEnd) return offset + delta; + if (offset <= prefix) return offset; + if (offset >= oldEnd) return offset + delta; + return null; + } + + final changedInside = + prefix >= span.startOffset && oldEnd <= span.endOffset; + final start = boundary(span.startOffset); + final stop = boundary(span.endOffset, end: true); + if (start == null) return null; + // Complete replacement is ambiguous even when a new node has the same tag. + if (prefix <= span.startOffset && + oldEnd >= span.endOffset && + this.source != source) { + return null; + } + Iterable<BusyBlock> walk(List<BusyBlock> blocks) sync* { + for (final b in blocks) { + yield b; + yield* walk(b.children); + } + } + + if (before.rawSource != null) { + bool sameOccurrence(BusyBlock b) => + b.kind == before.kind && b.rawSource == before.rawSource; + final oldCopies = walk(this.document.blocks).where(sameOccurrence).length; + final newCopies = walk(document.blocks).where(sameOccurrence).length; + if (oldCopies != newCopies && + (oldCopies > 1 || newCopies > 1) && + (this.source.substring(prefix, oldEnd).trim().isEmpty || + source + .substring(prefix, source.length - suffix) + .trim() + .isEmpty)) { + return null; + } + } + bool metadataTail(BusyBlock candidate) { + if (document.isXmlTopic || + before.plainText != candidate.plainText || + !(before.attributes.containsKey( + 'busymark-writerside-source-attributes', + ) || + candidate.attributes.containsKey( + 'busymark-writerside-source-attributes', + ))) { + return false; + } + int? coreEnd(BusyBlock owner) { + if (!owner.attributes.containsKey( + 'busymark-writerside-source-attributes', + )) { + return owner.sourceSpan?.endOffset; + } + final raw = owner.rawSource; + if (raw == null) return null; + final token = WritersideAttributeSyntax().pattern + .allMatches(raw) + .where((m) => raw.substring(m.end).trim().isEmpty) + .lastOrNull; + return token == null + ? null + : markdownProjectedSourceOffset(owner, token.start); + } + + final oldCoreEnd = coreEnd(before); + final newCoreEnd = coreEnd(candidate); + if (oldCoreEnd == null || + newCoreEnd == null || + prefix < oldCoreEnd || + prefix < newCoreEnd || + this.source.substring(span.startOffset, oldCoreEnd).trimRight() != + source + .substring(candidate.sourceSpan!.startOffset, newCoreEnd) + .trimRight()) { + return false; + } + final end = candidate.sourceSpan!.endOffset; + // Attribute-line attachment can change whether a trailing newline belongs + // to the block span. The retained body and all changed bytes must still + // belong to this parser-confirmed owner's tail, with only whitespace past it. + final newEnd = source.length - suffix; + return (oldEnd <= span.endOffset || + this.source.substring(span.endOffset, oldEnd).trim().isEmpty) && + (newEnd <= end || source.substring(end, newEnd).trim().isEmpty); + } + + final candidates = walk(document.blocks) + .where( + (b) => + (b.sourceSpan?.startOffset == start || + prefix == oldEnd && + prefix == span.startOffset && + b.sourceSpan?.startOffset == start + delta && + stop != null && + source.substring(start + delta, stop) == + this.source.substring( + span.startOffset, + span.endOffset, + )) && + (b.sourceSpan?.endOffset == stop || + metadataTail(b) || + // An inserted sibling can start exactly after a closed XML + // element. Its original interval is still intact; do not + // extend that owner's boundary into the new sibling. + prefix == oldEnd && + prefix == span.endOffset && + b.sourceSpan?.endOffset == span.endOffset && + source.substring(start, span.endOffset) == + this.source.substring( + span.startOffset, + span.endOffset, + )) && + b.kind == before.kind && + WritersideEditingAdapter.tagForBlock(b) == + WritersideEditingAdapter.tagForBlock(before), + ) + .toList(); + if (candidates.length != 1) return null; + final candidate = candidates.single; + if (!changedInside && + !metadataTail(candidate) && + this.source != source && + this.source.substring(span.startOffset, span.endOffset) != + source.substring(start, stop ?? candidate.sourceSpan!.endOffset)) { + return null; + } + // Protected fragments are never rebound through a content change. + if ((before.isSourceProtected || before.preserveRaw) && + before.rawSource != candidate.rawSource) { + return null; + } + BusyInlineStyleRange? refreshedInline; + if (inline != null) { + final occurrence = + WritersideEditingAdapter.authoredInlineRanges( + this.document, + this.source, + before, + ) + .where( + (e) => + e.range.kind == inline!.kind && + e.range.start == inline!.start && + e.range.end == inline!.end && + e.range.referenceIndex == inline!.referenceIndex, + ) + .singleOrNull; + if (occurrence == null) return null; + final begin = boundary(occurrence.begin); + final end = boundary(occurrence.end, end: true); + if (begin == null || + end == null || + prefix <= occurrence.begin && + oldEnd >= occurrence.end && + this.source != source) { + return null; + } + refreshedInline = + WritersideEditingAdapter.authoredInlineRanges( + document, + source, + candidate, + ) + .where( + (e) => + e.begin == begin && + e.end == end && + e.range.kind == inline!.kind, + ) + .singleOrNull + ?.range; + if (refreshedInline == null) return null; + } + final refreshed = fromSelection( + bufferId: bufferId, + revision: revision, + source: source, + document: document, + blockId: candidate.id, + inline: refreshedInline, + multiple: multiple, + ); + return refreshed.matches(bufferId, revision, source) ? refreshed : null; + } + + static WritersideInspectorTarget fromTopic({ + required String bufferId, + required int revision, + required String source, + required BusyDocument document, + }) { + final root = document.isXmlTopic + ? document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull + : WritersideEditingAdapter.topicHeading(document); + final selected = fromSelection( + bufferId: bufferId, + revision: revision, + source: source, + document: document, + blockId: root?.id, + ); + return WritersideInspectorTarget( + bufferId: bufferId, + revision: revision, + source: source, + authoredSource: selected.authoredSource, + document: document, + block: selected.block, + path: selected.path, + logicalTopic: true, + navigationEditable: selected.navigationEditable, + ); + } + + static WritersideInspectorTarget fromSelection({ + required String bufferId, + required int revision, + required String source, + required BusyDocument document, + String? blockId, + BusyInlineStyleRange? inline, + bool multiple = false, + }) { + final model = BusyMarkWysiwygDocumentController(document: document); + try { + final block = blockId == null ? null : model.blockById(blockId); + return WritersideInspectorTarget( + bufferId: bufferId, + revision: revision, + source: source, + authoredSource: model.markdown, + document: document, + block: block, + path: block == null ? const [] : model.elementPath(block.id), + inline: inline, + multiple: multiple, + navigationEditable: WritersideEditingAdapter.topicNavigationEditable( + document, + ), + ); + } finally { + model.dispose(); + } + } + + /// The inspected occurrence's markup, independent of the editing caret. + int? get sourceLocation { + if (block == null) return topic ? 0 : null; + final selected = inline; + if (selected == null) return block!.sourceSpan?.startOffset; + return WritersideEditingAdapter.authoredInlineRanges( + document, + source, + block!, + ) + .where( + (entry) => + entry.range.kind == selected.kind && + entry.range.start == selected.start && + entry.range.end == selected.end && + (selected.referenceIndex == null || + entry.range.referenceIndex == selected.referenceIndex), + ) + .firstOrNull + ?.begin; + } + + static WritersideInspectorTarget fromSource({ + required String bufferId, + required int revision, + required String source, + required BusyDocument document, + required TextSelection selection, + }) { + document = busyMarkWysiwygBindMarkdownReferences(document); + List<BusyBlock> locate(int offset) { + List<BusyBlock> find(List<BusyBlock> blocks) { + for (final block in blocks) { + final span = block.sourceSpan; + if (span == null || + offset < span.startOffset || + offset >= span.endOffset) { + continue; + } + return [block, ...find(block.children)]; + } + return []; + } + + return find(document.blocks); + } + + final start = selection.start.clamp(0, source.length); + final first = locate(start); + final last = selection.isCollapsed + ? first + : locate((selection.end - 1).clamp(0, source.length)); + final multiple = first.lastOrNull?.id != last.lastOrNull?.id; + BusyInlineStyleRange? inline; + final block = first.lastOrNull; + if (!multiple && block != null && block.sourceSpan != null) { + for (final entry in WritersideEditingAdapter.authoredInlineRanges( + document, + source, + block, + )) { + if (start >= entry.begin && + start < entry.end && + selection.end <= entry.end) { + inline = entry.range; + } + } + } + return fromSelection( + bufferId: bufferId, + revision: revision, + source: source, + document: document, + blockId: multiple ? null : first.lastOrNull?.id, + inline: inline, + multiple: multiple, + ); + } +} diff --git a/lib/src/editor/wysiwyg/writerside_properties.dart b/lib/src/editor/wysiwyg/writerside_properties.dart new file mode 100644 index 00000000..460d66ec --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_properties.dart @@ -0,0 +1,2035 @@ +import 'dart:convert'; +import 'package:path/path.dart' as p; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:yaru/yaru.dart'; +import 'writerside_property_session.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../app/command_registry.dart'; +import '../../markdown/busymark_document.dart'; +import '../../writerside/writerside_schema.dart'; +import '../../writerside/writerside_effective_properties.dart'; +import 'writerside_editing_adapter.dart'; +import 'wysiwyg_inline_controller.dart'; + +String writersideElementLabel( + BuildContext context, + BusyBlock block, [ + BusyDocument? document, + WritersideEffectiveProperties? resolver, +]) => switch (block.attributes['element']) { + 'topic' => context.l10n.topic, + 'chapter' => context.l10n.chapter, + 'procedure' => context.l10n.procedure, + 'step' => context.l10n.wsStep, + 'tabs' => context.l10n.tabs, + 'tab' => context.l10n.tab, + 'deflist' => context.l10n.wsDefinitionList, + 'def' => context.l10n.wsTerm, + 'list' => switch ((resolver ?? const WritersideEffectiveProperties()) + .resolve('list', 'type', block.attributes) + .value) { + 'decimal' => context.l10n.orderedList, + 'alpha-lower' => context.l10n.wsLetteredList, + 'checkbox' => context.l10n.wsCheckboxList, + 'none' => context.l10n.wsMarkerlessList, + _ => context.l10n.unorderedList, + }, + 'li' => context.l10n.wsListItem, + 'tldr' => context.l10n.wsTldr, + 'img' => context.l10n.image, + 'table' => context.l10n.table, + 'include' => context.l10n.wsIncludeContent, + 'video' => context.l10n.video, + _ when block.isSourceProtected || block.preserveRaw => + block.attributes['element'] ?? context.l10n.source, + _ => switch (block.kind) { + BusyBlockKind.heading => + (document == null + ? block.attributes['level'] == '1' + : WritersideEditingAdapter.topicHeading(document)?.id == block.id) + ? context.l10n.topic + : context.l10n.chapter, + BusyBlockKind.image => context.l10n.image, + BusyBlockKind.table => context.l10n.table, + BusyBlockKind.codeBlock => context.l10n.codeBlock, + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => context.l10n.wsListItem, + BusyBlockKind.blockquote || + BusyBlockKind.writersideAdmonition => context.l10n.admonition, + _ => context.l10n.paragraph, + }, +}; + +/// Subordinate, non-modal authoring properties. Targets are supplied by the +/// workspace, never inferred from whichever property entry happens to have focus. +String _elementExcerpt(BusyBlock block) { + if (block.plainText.trim().isNotEmpty) { + return block.plainText.split('\n').first; + } + for (final child in block.children) { + final excerpt = _elementExcerpt(child); + if (excerpt.isNotEmpty) return excerpt; + } + return ''; +} + +class BusyMarkWritersideProperties extends StatelessWidget { + const BusyMarkWritersideProperties({ + super.key, + required this.document, + required this.path, + required this.target, + required this.topicSelected, + required this.onTargetSelected, + required this.onClose, + required this.onProperty, + required this.onHeadingText, + required this.onTopicProperty, + required this.onManageItem, + this.effectiveProperties = const WritersideEffectiveProperties(), + this.documentIdentity, + this.session, + this.readOnly = false, + this.onSourceLocation, + this.onChooseInclude, + this.onChooseCodeSource, + this.inlineTarget, + this.onInlineProperty, + this.videoDraft = false, + this.onInsertVideo, + this.onNavigation, + this.onRenameTopic, + this.onEditTitles, + this.onUndo, + this.onRedo, + this.hasFileIdentity = true, + this.navigationEditable = true, + }); + final WritersideEffectiveProperties effectiveProperties; + final String? documentIdentity; + final WritersidePropertySession? session; + final bool readOnly; + final VoidCallback? onSourceLocation; + final VoidCallback? onChooseInclude; + final Future<String?> Function()? onChooseCodeSource; + final BusyDocument document; + final List<BusyBlock> path; + final BusyBlock? target; + final bool topicSelected; + final ValueChanged<String> onTargetSelected; + final VoidCallback onClose; + final bool Function(String, String?) onProperty; + final bool Function(String) onHeadingText; + final bool Function(String, String) onTopicProperty; + final void Function(String? itemId, int? direction, bool remove) onManageItem; + final BusyInlineStyleRange? inlineTarget; + final bool Function(String, String)? onInlineProperty; + final bool videoDraft; + final bool Function(String)? onInsertVideo; + final bool Function(bool present, String forElements, String depth)? + onNavigation; + final VoidCallback? onRenameTopic; + final VoidCallback? onEditTitles; + final VoidCallback? onUndo; + final VoidCallback? onRedo; + final bool hasFileIdentity; + final bool navigationEditable; + + @override + Widget build(BuildContext context) { + final l = context.l10n; + Iterable<Widget> propertyUnits(Iterable<Widget> fields) sync* { + for (final field in fields) { + if (field is SizedBox && field.width == 0 && field.height == 0) { + continue; + } + yield SizedBox(width: double.infinity, child: field); + } + } + + final block = target; + final inlineTag = switch (inlineTarget?.kind) { + BusyInlineKind.image => 'img', + BusyInlineKind.link => 'a', + BusyInlineKind.writersideShortcut => 'shortcut', + _ => null, + }; + final tag = + inlineTag ?? + (block == null + ? '' + : WritersideEditingAdapter.propertyTag(document, block)); + final nativeHeading = + block != null && + WritersideEditingAdapter.isNativeHeading(document, block); + final attrs = + inlineTarget?.attributes ?? + block?.attributes ?? + const <String, String>{}; + final nativeOwner = path + .where( + (b) => + b.attributes['busymark-list-form'] == 'markdown' && + b.children.any((child) => child.id == block?.id), + ) + .lastOrNull; + final unsafeItemConversion = + inlineTarget == null && + nativeOwner != null && + WritersideEditingAdapter.nativeListHasMixedMarkers(nativeOwner); + final immutable = + readOnly || + unsafeItemConversion || + block?.isSourceProtected == true || + block?.preserveRaw == true; + final offered = <String>{}; + String attributeLabel(String attribute, String label) { + final name = switch (attribute) { + 'language' => 'lang', + 'busymark-ignored-href' => 'href', + _ => attribute, + }; + return name == label || attribute == 'reference' + ? label + : '$label ($name)'; + } + + Widget resetOverride(String attribute) => BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.windowClose, + tooltip: l.wsUseDefault, + onPressed: immutable + ? null + : () { + if (!(session?.flush() ?? true)) return; + if (inlineTarget != null) { + onInlineProperty?.call(attribute, ''); + } else { + onProperty(attribute, null); + } + }, + ); + Widget entry( + String attribute, + String label, { + String? value, + bool topic = false, + bool inline = false, + bool enabled = true, + Future<String?> Function()? chooseFile, + String? supportingText, + }) { + final name = attribute == 'language' ? 'lang' : attribute; + offered.add(name); + final eligible = topic + ? attribute == 'switcher-label' + : inlineTarget != null + ? attribute == 'reference' || + attribute == 'busymark-ignored-href' || + WritersideSchema.attributesFor(tag).contains(name) + : block != null && + WritersideEditingAdapter.propertyAttributes( + document, + block, + ).contains(name); + if (!eligible) return const SizedBox.shrink(); + final field = _PropertyTextEntry( + key: ValueKey( + 'writerside-property-${document.filePath}-${topic + ? 'document' + : nativeHeading + ? 'heading-${block.id}' + : 'element-${block?.id}'}-$attribute', + ), + session: session, + readOnly: immutable || !enabled, + label: attributeLabel(attribute, label), + chooseFile: chooseFile, + supportingText: supportingText, + help: [ + if (topicSelected && nativeHeading && !topic) + l.wsHeadingBuilderLimitation, + if (switch (name) { + 'id' when nativeHeading => l.wsGeneratedAnchor, + 'title' when nativeHeading => l.wsTitleOverrideExplanation, + 'filter' => l.wsFilterExplanation, + 'instance' => l.wsInstanceExplanation, + 'ignore-vars' => l.wsIgnoreVariablesExplanation, + _ => null, + } + case final explanation?) + explanation, + ].join('\n'), + value: value ?? attrs[attribute] ?? '', + apply: (value) => inline || inlineTarget != null + ? onInlineProperty?.call(attribute, value) ?? false + : topic + ? onTopicProperty(attribute, value) + : onProperty( + attribute, + value.isEmpty && attribute != 'alt' ? null : value, + ), + ); + return topicSelected && nativeHeading && !topic + ? _PropertyUnitNotice(message: l.wsHeadingNotPublished, child: field) + : field; + } + + final externalImage = + tag == 'img' && + RegExp( + r'^(?:https?:)?//', + ).hasMatch(inlineTarget?.destination ?? attrs['src'] ?? ''); + Widget dimension(String attribute, String label) { + if (externalImage && attrs[attribute]?.isNotEmpty != true) { + offered.add(attribute); + return const SizedBox.shrink(); + } + return Row( + children: [ + Expanded(child: entry(attribute, label, enabled: !externalImage)), + if (externalImage && attrs[attribute]?.isNotEmpty == true) + resetOverride(attribute), + ], + ); + } + + final parentList = path + .where((b) => b.attributes['element'] == 'deflist' && b.id != block?.id) + .lastOrNull; + final listAttributes = parentList == null + ? null + : effectiveProperties.defaultsFor('deflist', parentList.attributes); + final collapse = effectiveProperties.resolve( + tag, + 'collapsible', + attrs, + definitionList: listAttributes, + ); + Widget enumRow(String attribute, String label, {bool active = true}) { + offered.add(attribute); + if (!WritersideSchema.attributesFor(tag).contains(attribute)) { + return const SizedBox.shrink(); + } + final property = effectiveProperties.resolve( + tag, + attribute, + attrs, + definitionList: listAttributes, + ); + final value = attrs[attribute] ?? ''; + if (!active && value.isEmpty) return const SizedBox.shrink(); + final allowed = WritersideSchema.valuesFor(tag, attribute).toList(); + final values = [ + '', + ...allowed, + if (value.isNotEmpty && !allowed.contains(value)) value, + ]; + String valueLabel(String v) => switch (v) { + 'expanded' => l.wsExpanded, + 'collapsed' => l.wsCollapsed, + 'inherited' => l.wsInherited, + 'bullet' => l.unorderedList, + 'decimal' => l.orderedList, + 'alpha-lower' => l.wsLetteredList, + 'checkbox' => l.wsCheckboxList, + 'none' when attribute == 'type' => l.wsMarkerlessList, + 'none' when attribute == 'sorted' => l.wsNoSorting, + 'asc' => l.wsAscending, + 'desc' => l.wsDescending, + 'aswritten' => l.wsAsWritten, + 'sentence' => l.wsSentenceCase, + 'title' => l.wsTitleCase, + 'lower' => l.wsLowerCase, + 'upper' => l.wsUpperCase, + 'true' => l.wsEnabled, + 'false' => l.wsDisabled, + 'full' => l.wsFullLayout, + 'medium' => l.wsMediumLayout, + 'narrow' => l.wsNarrowLayout, + 'compact' => l.wsCompactLayout, + 'wide' => l.wsWideLayout, + _ => v, + }; + final origin = switch (property.origin) { + WritersidePropertyOrigin.element => l.wsOriginElement, + WritersidePropertyOrigin.ancestor => l.wsOriginAncestor, + WritersidePropertyOrigin.module => l.wsOriginModule, + WritersidePropertyOrigin.markdownMarker => l.wsNativeMarkers, + WritersidePropertyOrigin.builtIn => l.wsOriginBuiltIn, + WritersidePropertyOrigin.unresolved => l.wsOriginUnresolved, + }; + final resolved = property.value; + final summary = resolved == null + ? l.wsPropertyUnresolved + : value.isEmpty + ? property.origin == WritersidePropertyOrigin.module + ? l.wsDefaultPropertyOrigin(valueLabel(resolved), l.wsModule) + : property.origin == WritersidePropertyOrigin.ancestor + ? l.wsDefaultPropertyOrigin(valueLabel(resolved), l.wsParent) + : l.wsDefaultProperty(valueLabel(resolved)) + : resolved != value + ? l.wsResolvedProperty(valueLabel(resolved)) + : null; + final provenance = l.wsEffectiveProperty( + resolved == null ? l.wsPropertyUnresolved : valueLabel(resolved), + origin, + ); + final help = [ + provenance, + if (property.limitation == 'module-caps-ignored') l.wsModuleCapsIgnored, + if (attribute == 'ignore-vars') l.wsIgnoreVariablesExplanation, + if (topicSelected && nativeHeading) l.wsHeadingBuilderLimitation, + ].join('\n'); + final subtitle = [ + if (summary != null) summary, + if (property.limitation == 'module-caps-ignored') + l.wsModuleCapsNotPublished, + if (!active) l.wsPropertiesInactive, + ].join('\n'); + final field = _PropertyContextHelp( + help: help, + mergeSemantics: active, + child: Row( + children: [ + Expanded( + child: _ResponsivePropertyCombo( + row: BusyMarkComboRow<String>( + enabled: !immutable && active, + subtitle: subtitle.isEmpty ? null : subtitle, + tooltip: help, + title: attributeLabel(attribute, label), + values: values, + selected: value, + labelFor: (v) => v.isEmpty ? l.wsUseDefault : valueLabel(v), + onSelected: (v) { + if (!immutable && (active || v.isEmpty)) { + if (inlineTarget != null) { + onInlineProperty?.call(attribute, v); + } else { + onProperty(attribute, v.isEmpty ? null : v); + } + } + }, + width: 125, + ), + ), + ), + if (!active && value.isNotEmpty) resetOverride(attribute), + ], + ), + ); + return topicSelected && nativeHeading + ? _PropertyUnitNotice(message: l.wsHeadingNotPublished, child: field) + : field; + } + + Widget stateRow() => enumRow( + 'default-state', + l.wsPublishedState, + active: collapse.value == 'true', + ); + + final ancestors = path + .where( + (b) => + busyMarkIsWritersideContainer(b) || + b.kind == BusyBlockKind.heading || + { + BusyBlockKind.unorderedListItem, + BusyBlockKind.orderedListItem, + BusyBlockKind.taskListItem, + }.contains(b.kind) || + b.id == block?.id, + ) + .where( + (b) => + !(b.attributes['element'] == 'topic' || + WritersideEditingAdapter.topicHeading(document)?.id == b.id), + ) + .toList(); + final values = [ + '@topic', + ...{for (final b in ancestors) b.id}, + ]; + final selected = topicSelected ? '@topic' : block?.id ?? '@topic'; + if (!values.contains(selected)) values.add(selected); + final identity = [ + if (attrs['id']?.isNotEmpty == true) attrs['id']!, + if (inlineTarget != null && block != null) + block.plainText.substring(inlineTarget!.start, inlineTarget!.end) + else if (block != null) + block.attributes['title'] ?? + block.attributes['busymark-title-label'] ?? + (block.kind == BusyBlockKind.codeBlock + ? block.attributes['src'] ?? '' + : block.plainText.split('\n').first), + ].where((v) => v.isNotEmpty).join(' · '); + final hasEditableTitle = + !immutable && + inlineTarget == null && + block != null && + (block.kind == BusyBlockKind.heading || + WritersideEditingAdapter.titleElements.contains(tag)); + final rows = <Widget>[]; + final notices = <Widget>[]; + final actions = <Widget>[]; + if (videoDraft) { + rows.add( + _PropertyTextEntry( + key: const ValueKey('writerside-video-source'), + commitOnBlur: false, + session: session, + readOnly: immutable, + label: attributeLabel('src', l.source), + value: '', + apply: (v) => onInsertVideo?.call(v) ?? false, + ), + ); + } else if (inlineTarget != null) { + final selectedInline = inlineTarget!; + switch (selectedInline.kind) { + case BusyInlineKind.link: + rows.add( + entry('href', l.wsLinkTarget, value: selectedInline.destination), + ); + if (attrs['busymark-ignored-href'] case final ignored?) { + rows.add( + entry( + 'busymark-ignored-href', + 'href', + value: ignored, + enabled: false, + ), + ); + notices.add(_PropertyNotice(l.wsPropertiesProtected)); + } + rows.add(entry('origin', l.wsHelpModule)); + case BusyInlineKind.image: + rows.add(entry('src', l.source, value: selectedInline.destination)); + rows.add( + entry( + 'alt', + l.altText, + value: + attrs['alt'] ?? + block!.plainText.substring( + selectedInline.start, + selectedInline.end, + ), + ), + ); + if (externalImage) { + notices.add(_PropertyNotice(l.wsExternalImageSize)); + } + rows.add(dimension('width', l.wsWidth)); + rows.add(dimension('height', l.wsHeight)); + rows.add(enumRow('style', l.wsImageDisplay)); + rows.add(enumRow('thumbnail', l.wsImageThumbnail)); + rows.add(entry('preview-src', l.wsPreviewImage)); + case BusyInlineKind.writersideVariable: + rows.add( + entry( + 'reference', + l.wsVariableReference, + value: + attrs['reference'] ?? + block!.plainText.substring( + selectedInline.start, + selectedInline.end, + ), + ), + ); + case BusyInlineKind.writersideShortcut: + rows.add(entry('key', l.wsActionId)); + rows.add(entry('from-keymap-of', l.wsKeymapHelpInstance)); + rows.add(entry('force-layout', l.keyboardLayout)); + default: + break; + } + } else if (topicSelected) { + if (document.isXmlTopic) offered.add('id'); + if (block != null) { + rows.add( + nativeHeading + ? _PropertyTextEntry( + key: ValueKey( + 'writerside-heading-title-${document.filePath}-${block.id}', + ), + session: session, + readOnly: immutable, + label: l.wsHeadingText, + value: block.plainText, + apply: onHeadingText, + ) + : entry('title', l.wsDefaultTitle), + ); + } else if (!document.isXmlTopic) { + notices.add(_PropertyNotice(l.wsMissingTopicHeading)); + } + final identity = hasFileIdentity && document.filePath.isNotEmpty + ? p.basenameWithoutExtension(document.filePath) + : null; + rows.add( + BusyMarkActionRow( + title: l.wsTopicIdentity, + subtitleWidget: SelectableText( + identity ?? l.wsPropertiesUnresolved, + key: const ValueKey('writerside-topic-id'), + ), + trailing: onRenameTopic != null && identity != null + ? BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.edit, + tooltip: l.renameTopicFile, + onPressed: immutable + ? null + : () { + if (session?.transition() ?? true) onRenameTopic!(); + }, + ) + : null, + ), + ); + if (document.isXmlTopic && identity != null && attrs['id'] != identity) { + notices.add( + _PropertyNotice( + '${l.wsTopicIdentityMismatch} id="${attrs["id"] ?? ""}" → $identity', + ), + ); + } + if (!document.isXmlTopic && + document.blocks + .where( + (b) => + WritersideEditingAdapter.isNativeHeading(document, b) && + b.attributes['level'] == '1', + ) + .length > + 1) { + notices.add(_PropertyNotice(l.wsMultipleTopicHeadings)); + } + if (onEditTitles != null) { + actions.add( + BusyMarkActionRow( + title: l.wsTitleOverridesAction, + onTap: immutable + ? null + : () { + if (session?.transition() ?? true) onEditTitles!(); + }, + ), + ); + } + } else if (block != null) { + if (tag == 'list') { + rows.add(enumRow('type', l.wsListType)); + final type = effectiveProperties.resolve(tag, 'type', attrs).value; + final numbered = type == 'decimal' || type == 'alpha-lower'; + final startDefault = effectiveProperties.resolve(tag, 'start', attrs); + final startSummary = + startDefault.origin == WritersidePropertyOrigin.module + ? l.wsDefaultPropertyOrigin( + startDefault.value ?? l.wsPropertyUnresolved, + l.wsModule, + ) + : attrs['busymark-native-list-start'] == null + ? l.wsDefaultProperty(startDefault.value ?? l.wsPropertyUnresolved) + : l.wsListMarkerStart(attrs['busymark-native-list-start']!); + if (numbered || attrs.containsKey('start')) { + rows.add( + Row( + children: [ + Expanded( + child: entry( + 'start', + l.wsStartingNumber, + enabled: numbered, + supportingText: startSummary, + ), + ), + if (!numbered && attrs.containsKey('start')) + resetOverride('start'), + ], + ), + ); + } else { + offered.add('start'); + } + rows.add(entry('columns', l.columns)); + rows.add(enumRow('sorted', l.wsListSorting)); + } + if (block.kind == BusyBlockKind.heading) { + if (!document.isXmlTopic && !block.attributes.containsKey('element')) { + rows.add( + _PropertyTextEntry( + key: ValueKey('writerside-heading-title-${block.id}'), + session: session, + readOnly: immutable, + label: l.wsHeadingText, + value: block.plainText, + apply: onHeadingText, + ), + ); + } else { + rows.add(entry('title', l.wsDefaultTitle)); + } + } else if (WritersideEditingAdapter.titleElements.contains(tag)) { + rows.add( + entry( + 'title', + tag == 'def' ? l.wsTerm : l.wsTitle, + value: block.plainText, + ), + ); + } + if (WritersideSchema.attributesFor(tag).contains('id')) { + rows.add( + entry( + 'id', + l.wsElementAnchor, + value: attrs['generatedId'] == 'true' ? '' : attrs['id'], + supportingText: attrs['generatedId'] == 'true' + ? l.wsGeneratedAnchorValue(attrs['id'] ?? '') + : null, + ), + ); + } + if ({'chapter', 'code-block', 'procedure', 'deflist'}.contains(tag)) { + rows.add(enumRow('collapsible', l.wsCollapsible)); + rows.add(stateRow()); + } + if (tag == 'def') rows.add(stateRow()); + if (tag == 'code-block') { + rows.add(entry('language', l.language)); + rows.add(entry('src', l.wsSourceFile, chooseFile: onChooseCodeSource)); + if (collapse.value == 'true' || + attrs['collapsed-title']?.isNotEmpty == true) { + rows.add( + Row( + children: [ + Expanded( + child: entry( + 'collapsed-title', + l.wsCollapsedTitle, + enabled: collapse.value == 'true', + ), + ), + if (collapse.value != 'true' && + attrs['collapsed-title']?.isNotEmpty == true) + resetOverride('collapsed-title'), + ], + ), + ); + } + rows.add(entry('include-lines', l.wsIncludeLines)); + if (attrs['src']?.isNotEmpty == true || + attrs['include-symbol']?.isNotEmpty == true) { + rows.add( + Row( + children: [ + Expanded( + child: entry( + 'include-symbol', + l.wsIncludeSymbol, + enabled: attrs['src']?.isNotEmpty == true, + ), + ), + if (attrs['src']?.isNotEmpty != true) + resetOverride('include-symbol'), + ], + ), + ); + } else { + offered.add('include-symbol'); + } + rows.add(entry('emphasize-lines', l.wsEmphasizeLines)); + } + if (tag == 'img') { + rows.add(entry('src', l.source)); + rows.add(entry('alt', l.altText)); + if (externalImage) notices.add(_PropertyNotice(l.wsExternalImageSize)); + rows.add(dimension('width', l.wsWidth)); + rows.add(dimension('height', l.wsHeight)); + rows.add(enumRow('style', l.wsImageDisplay)); + rows.add(enumRow('thumbnail', l.wsImageThumbnail)); + rows.add(entry('preview-src', l.wsPreviewImage)); + } + if (tag == 'deflist') { + rows.add(enumRow('type', l.wsListLayout)); + } + if (tag == 'tabs') rows.add(entry('group', l.wsSyncGroup)); + if (tag == 'tab') rows.add(entry('group-key', l.wsTabKey)); + if (busyAdmonitionStyleFromName(tag) != null || + block.kind == BusyBlockKind.blockquote && + attrs[busyMarkWritersideAdmonitionAttribute] == 'true') { + final value = + busyAdmonitionStyleFromName(attrs['style'] ?? tag) ?? + BusyAdmonitionStyle.tip; + rows.add( + BusyMarkComboRow<BusyAdmonitionStyle>( + title: l.admonition, + values: BusyAdmonitionStyle.values, + selected: value, + labelFor: (v) => switch (v) { + BusyAdmonitionStyle.tip => l.tip, + BusyAdmonitionStyle.note => l.note, + BusyAdmonitionStyle.warning => l.warning, + BusyAdmonitionStyle.quote => l.quote, + }, + onSelected: (v) => onProperty('admonition-type', v.name), + width: 120, + ), + ); + } + if (tag == 'include') { + if (onChooseInclude != null) { + actions.add( + BusyMarkActionRow( + title: l.wsIncludeContent, + onTap: immutable + ? null + : () { + if (session?.transition() ?? true) onChooseInclude!(); + }, + ), + ); + } + rows.add(entry('from', l.topic)); + rows.add(entry('element-id', l.wsIncludedElement)); + rows.add(entry('origin', l.wsHelpModule)); + rows.add(entry('use-filter', l.wsFilter)); + } + if (tag == 'video') { + rows.add(entry('src', l.source)); + rows.add(entry('preview-src', l.wsPreviewImage)); + } + if (WritersideSchema.attributesFor(tag).contains('switcher-key') && + !{'topic', 'chapter', 'p', 'list'}.contains(tag)) { + rows.add(entry('switcher-key', l.wsSwitcherKey)); + } + if (inlineTarget case final inline?) { + if (inline.kind == BusyInlineKind.writersideVariable) { + rows.add( + entry( + 'reference', + l.wsVariableReference, + value: + inline.attributes['reference'] ?? + block.plainText.substring(inline.start, inline.end), + inline: true, + ), + ); + } else if (inline.kind == BusyInlineKind.writersideShortcut) { + rows.add( + entry( + 'key', + l.wsActionId, + value: inline.attributes['key'] ?? '', + inline: true, + ), + ); + rows.add( + entry( + 'from-keymap-of', + l.wsKeymapHelpInstance, + value: inline.attributes['from-keymap-of'] ?? '', + inline: true, + ), + ); + rows.add( + entry( + 'force-layout', + l.keyboardLayout, + value: inline.attributes['force-layout'] ?? '', + inline: true, + ), + ); + } + } + if ({'tabs', 'procedure', 'deflist'}.contains(tag) && + !(tag == 'tabs' && + block.children.any((b) => b.kind == BusyBlockKind.codeBlock) && + !block.children.any((b) => b.attributes['element'] == 'tab'))) { + rows.add( + BusyMarkActionRow( + title: l.wsAddItem, + leading: const Icon(BusyMarkGlyphs.add), + onTap: immutable ? null : () => onManageItem(null, null, false), + ), + ); + final itemTag = switch (tag) { + 'tabs' => 'tab', + 'procedure' => 'step', + _ => 'def', + }; + for (final item in block.children.where( + (b) => b.attributes['element'] == itemTag, + )) { + rows.add( + BusyMarkActionRow( + title: item.plainText.isEmpty + ? writersideElementLabel(context, item) + : item.plainText, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.upArrow, + tooltip: l.moveSectionUp, + onPressed: immutable + ? null + : () => onManageItem(item.id, -1, false), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.downArrow, + tooltip: l.moveSectionDown, + onPressed: immutable + ? null + : () => onManageItem(item.id, 1, false), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.delete, + tooltip: l.removeAction, + onPressed: + !immutable && + block.children + .where( + (b) => b.attributes['element'] == itemTag, + ) + .length > + 1 + ? () => onManageItem(item.id, null, true) + : null, + ), + ], + ), + ), + ); + } + } + } + final advanced = <Widget>[]; + final documentSettings = <Widget>[]; + final advancedNotices = <Widget>[]; + final advancedAttributes = <String>{}; + final documentAdvancedAttributes = <String>{}; + final secondary = <Widget>[]; + if (topicSelected && inlineTarget == null) { + final root = document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + final declarations = (root?.children ?? document.blocks) + .where((b) => b.attributes['element'] == 'show-structure') + .toList(); + secondary.add( + _PropertySection( + key: const ValueKey('writerside-topic-navigation'), + storageIdentity: + 'writerside-navigation-${documentIdentity ?? document.filePath}', + title: l.wsTopicNavigation, + session: session, + children: [ + if (!navigationEditable) _PropertyNotice(l.wsPropertiesProtected), + _TopicNavigation( + key: ValueKey('navigation-${document.filePath}'), + session: session, + declarations: declarations, + readOnly: immutable || !navigationEditable, + apply: + onNavigation ?? + (present, forElements, depth) => onTopicProperty( + '@navigation', + !present + ? 'default' + : forElements == 'none' + ? 'none' + : 'all', + ), + ), + ], + ), + ); + documentAdvancedAttributes.add('switcher-label'); + documentSettings.add( + entry( + 'switcher-label', + l.wsSwitcherLabel, + value: + root?.attributes['switcher-label'] ?? + document.frontMatter['switcher-label'] ?? + '', + topic: true, + ), + ); + if (nativeHeading) { + advancedAttributes.add('id'); + advanced.add( + entry( + 'id', + l.wsElementAnchor, + value: attrs['generatedId'] == 'true' ? '' : attrs['id'], + supportingText: attrs['generatedId'] == 'true' + ? l.wsGeneratedAnchorValue(attrs['id'] ?? '') + : null, + ), + ); + } + } + String fieldLabel(String name) => switch (name) { + 'filter' => l.wsFilter, + 'instance' => l.wsInstanceSelection, + 'help-id' => l.wsContextHelp, + 'ignore-vars' => l.wsIgnoreVariables, + 'caps' => l.wsCapitalization, + 'switcher-key' => l.wsSwitcherKey, + 'is-library' => l.wsLibraryTopic, + 'last-modified' => l.wsLastModified, + _ => name, + }; + if (block != null && + !videoDraft && + (inlineTarget == null || inlineTag != null)) { + if (nativeHeading && (!topicSelected || attrs.containsKey('title'))) { + advancedAttributes.add('title'); + advanced.add(entry('title', l.wsTitleOverride)); + } + final properties = + (inlineTag == null + ? WritersideEditingAdapter.propertyAttributes(document, block) + : WritersideSchema.attributesFor(tag)) + .where( + (a) => + !offered.contains(a) && + !{'title', 'level'}.contains(a) && + !({'deflist', 'list'}.contains(tag) && + a == 'style' && + !attrs.containsKey(a)), + ) + .toList() + ..sort(); + for (final name in properties) { + final allowed = WritersideSchema.valuesFor(tag, name); + final targetRows = topicSelected && document.isXmlTopic + ? documentSettings + : advanced; + (topicSelected && document.isXmlTopic + ? documentAdvancedAttributes + : advancedAttributes) + .add(name); + targetRows.add( + allowed.isEmpty + ? entry(name, fieldLabel(name)) + : enumRow( + name, + fieldLabel(name), + active: !(tag == 'def' && name == 'collapsible'), + ), + ); + } + final sourceAttributes = inlineTarget == null + ? WritersideEditingAdapter.sourceAttributes(block) + : { + for (final entry in inlineTarget!.attributes.entries) + if (!entry.key.startsWith('xml-') && + !entry.key.startsWith('busymark-')) + entry.key: entry.value, + }; + final unsupported = sourceAttributes.keys + .where( + (a) => + !a.startsWith('xmlns') && + !(inlineTag == null + ? WritersideEditingAdapter.propertyAttributes( + document, + block, + ) + : WritersideSchema.attributesFor(tag)) + .contains(a), + ) + .toList(); + if (unsupported.isNotEmpty) { + advancedNotices.add( + _PropertyNotice( + '${l.wsUnsupportedProperties} ${unsupported.join(', ')}', + ), + ); + } + } + final authored = + inlineTarget?.attributes ?? + (block == null + ? const <String, String>{} + : WritersideEditingAdapter.sourceAttributes(block)); + final authoredAdvanced = advancedAttributes.any( + (name) => + authored.containsKey(name) && + !(nativeHeading && name == 'id' && attrs['generatedId'] == 'true'), + ); + final authoredDocument = + topicSelected && + documentAdvancedAttributes.any( + (name) => document.isXmlTopic + ? authored.containsKey(name) + : document.frontMatter.containsKey(name), + ); + final hasAdvancedSettings = + authoredAdvanced || authoredDocument || advancedNotices.isNotEmpty; + final commands = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; + return Shortcuts( + shortcuts: { + if (!readOnly && onUndo != null) + commands['text.undo']!.shortcut!.activator: + const _PropertyHistoryIntent(false), + if (!readOnly && onRedo != null) + commands['text.redo']!.shortcut!.activator: + const _PropertyHistoryIntent(true), + }, + child: Actions( + actions: { + _PropertyHistoryIntent: CallbackAction<_PropertyHistoryIntent>( + onInvoke: (intent) { + if (session?.flush() ?? true) { + (intent.redo ? onRedo : onUndo)?.call(); + } + return null; + }, + ), + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: BusyMarkSurfaceColors.of(context).panel, + ), + child: FocusTraversalGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 12, end: 4), + child: Row( + children: [ + Expanded( + child: Text( + l.wsElementProperties, + style: busyMarkSectionHeaderStyle(context), + overflow: TextOverflow.ellipsis, + ), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.windowClose, + tooltip: l.close, + onPressed: () { + if (videoDraft) session?.revertAll(); + if (session?.transition() ?? true) onClose(); + }, + ), + ], + ), + ), + if (session != null) + ListenableBuilder( + listenable: session!, + builder: (context, _) { + final reason = session!.feedback; + if (reason == null || session!.hasPending) { + return const SizedBox.shrink(); + } + final message = switch (reason) { + WritersidePropertyRejection.stale => + l.wsPropertiesConflict, + WritersidePropertyRejection.readOnly => + l.wsPropertiesReadOnly, + WritersidePropertyRejection.unresolved => + l.wsPropertiesUnresolved, + _ => l.wsPropertyInvalid, + }; + return Padding( + padding: const EdgeInsets.all(12), + child: Text( + session!.detail ?? message, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + }, + ), + if (readOnly || + unsafeItemConversion || + block?.isSourceProtected == true || + block?.preserveRaw == true) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + readOnly + ? l.wsPropertiesReadOnly + : l.wsPropertiesProtected, + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Tooltip( + message: block == null + ? l.wsElementSelector + : '${writersideElementLabel(context, block, document)} · ${block.attributes['id'] ?? ''} · ${block.attributes['title'] ?? block.attributes['busymark-title-label'] ?? block.plainText}', + child: BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow<String>( + title: l.wsElementSelector, + values: values, + selected: selected, + labelFor: (id) => id == '@topic' + ? l.wsTopicProperties + : (() { + final b = path + .where((b) => b.id == id) + .firstOrNull; + return inlineTarget != null && + id == block?.id + ? switch (inlineTarget!.kind) { + BusyInlineKind.image => l.image, + BusyInlineKind.link => l.link, + BusyInlineKind + .writersideVariable => + l.wsVariableReference, + _ => l.keyboardShortcuts, + } + : b == null + ? l.wsElementProperties + : b.id == block?.id + ? writersideElementLabel( + context, + b, + document, + effectiveProperties, + ) + : [ + writersideElementLabel( + context, + b, + document, + effectiveProperties, + ), + b.attributes['id'] ?? + b.attributes['title'] ?? + b.attributes['busymark-title-label'] ?? + _elementExcerpt(b), + if (b.attributes['element'] == + 'list') + l.wsListLevel( + '${path.takeWhile((p) => p.id != b.id).where((p) => p.attributes['element'] == 'list').length + 1}', + ), + ].where((v) => v.isNotEmpty).join(' · '); + })(), + onSelected: (value) { + if (session?.transition() ?? true) { + onTargetSelected(value); + } + }, + width: 160, + ), + ], + ), + ), + if (identity.isNotEmpty && + !topicSelected && + !hasEditableTitle) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 72, + ), + child: Semantics( + excludeSemantics: true, + label: + inlineTarget == null && + tag == 'p' && + block != null + ? '${attrs['id'] ?? ''} ${block.plainText}' + : identity, + child: Text( + identity, + maxLines: 2, + overflow: TextOverflow.ellipsis, + key: const ValueKey( + 'writerside-properties-identity', + ), + ), + ), + ), + ), + ), + if (rows.isNotEmpty) + AbsorbPointer( + absorbing: + readOnly || + block?.isSourceProtected == true || + block?.preserveRaw == true, + child: BusyMarkGroupedList( + filled: true, + children: propertyUnits(rows).toList(), + ), + ), + ...notices, + if (actions.isNotEmpty) + BusyMarkGroupedList(filled: true, children: actions), + ...secondary, + if (advanced.isNotEmpty || + documentSettings.isNotEmpty || + advancedNotices.isNotEmpty) + _PropertySection( + key: const ValueKey('writerside-advanced'), + storageIdentity: + 'writerside-advanced-${documentIdentity ?? document.filePath}', + session: session, + title: l.advanced, + subtitle: hasAdvancedSettings + ? l.wsSettingsPresent + : null, + children: [ + if (documentSettings.isNotEmpty) + BusyMarkGroupedList( + title: topicSelected + ? l.wsDocumentOptions + : null, + padding: + const EdgeInsetsDirectional.symmetric( + horizontal: 16, + ), + filled: true, + children: propertyUnits( + documentSettings, + ).toList(), + ), + if (advanced.isNotEmpty) + BusyMarkGroupedList( + title: topicSelected && nativeHeading + ? l.wsHeadingOptions + : null, + padding: + const EdgeInsetsDirectional.symmetric( + horizontal: 16, + ), + filled: true, + children: propertyUnits(advanced).toList(), + ), + ...advancedNotices, + ], + ), + if (onSourceLocation != null) + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 16, + 12, + 16, + 12, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: BusyMarkDialogButton( + label: l.wsShowInSource, + icon: BusyMarkGlyphs.sourceView, + onPressed: () { + if (session?.flush() ?? true) { + onSourceLocation!(); + } + }, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _PropertyHistoryIntent extends Intent { + const _PropertyHistoryIntent(this.redo); + final bool redo; +} + +/// Disclosure state is presentation only. Hiding a pending field is a guarded +/// transition, just like closing the inspector or changing its target. +class _PropertySection extends StatefulWidget { + const _PropertySection({ + super.key, + required this.title, + required this.children, + this.subtitle, + this.session, + required this.storageIdentity, + }); + final String title; + final String? subtitle; + final List<Widget> children; + final WritersidePropertySession? session; + final String storageIdentity; + @override + State<_PropertySection> createState() => _PropertySectionState(); +} + +class _PropertySectionState extends State<_PropertySection> { + bool expanded = false; + bool initialized = false; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!initialized) { + initialized = true; + expanded = + PageStorage.maybeOf( + context, + )?.readState(context, identifier: widget.storageIdentity) == + true; + } + } + + @override + void didUpdateWidget(covariant _PropertySection oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.storageIdentity != widget.storageIdentity) { + expanded = + PageStorage.maybeOf( + context, + )?.readState(context, identifier: widget.storageIdentity) == + true; + } + } + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: BusyMarkSpacing.sm), + Semantics( + expanded: expanded, + button: true, + child: TextButton( + key: ValueKey('writerside-disclosure-${widget.title}'), + onPressed: () { + if (expanded && !(widget.session?.flush() ?? true)) return; + setState(() => expanded = !expanded); + PageStorage.maybeOf(context)?.writeState( + context, + expanded, + identifier: widget.storageIdentity, + ); + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleSmall, + ), + if (widget.subtitle != null) + Text( + widget.subtitle!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Icon( + expanded ? BusyMarkGlyphs.upArrow : BusyMarkGlyphs.downArrow, + ), + ], + ), + ), + ), + if (expanded) ...widget.children, + ], + ); +} + +/// Notices are outside divided field groups. Field-specific information stays +/// with its control, so separators describe properties rather than prose. +class _PropertyNotice extends StatelessWidget { + const _PropertyNotice(this.message); + final String message; + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 8, 16, 8), + child: Text(message, style: Theme.of(context).textTheme.bodySmall), + ); +} + +class _PropertyUnitNotice extends StatelessWidget { + const _PropertyUnitNotice({required this.message, required this.child}); + final String message; + final Widget child; + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [child, _PropertyNotice(message)], + ); +} + +/// Keep native menu behavior while letting a constrained property unit place +/// its selected value below the label. This affects only inspector composition. +class _ResponsivePropertyCombo extends StatelessWidget { + const _ResponsivePropertyCombo({required this.row}); + final BusyMarkComboRow<String> row; + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final style = Theme.of(context).textTheme.bodyMedium; + if (constraints.hasBoundedWidth && + constraints.maxWidth >= 320 && + MediaQuery.textScalerOf(context).scale(14) <= 15.4) { + return row; + } + double width(String text) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final result = painter.width; + painter.dispose(); + return result; + } + + if (!constraints.hasBoundedWidth || + width(row.title) + width(row.labelFor(row.selected)) + 64 <= + constraints.maxWidth) { + return row; + } + return BusyMarkMenuButton<int>( + tooltip: row.tooltip ?? row.title, + enabled: row.enabled, + items: [ + for (var i = 0; i < row.values.length; i++) + BusyMarkPopupMenuItem<int>( + value: i, + label: row.labelFor(row.values[i]), + checked: row.values[i] == row.selected, + trailingCheck: true, + ), + ], + onSelected: (i) { + if (row.values[i] != row.selected) row.onSelected(row.values[i]); + }, + triggerBuilder: (context, trigger) => Semantics( + container: true, + button: true, + enabled: row.enabled, + expanded: trigger.isOpen, + label: row.title, + value: row.labelFor(row.selected), + hint: row.tooltip, + child: YaruListTile.square( + title: Text(row.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: BusyMarkSpacing.xs), + Row( + children: [ + Expanded( + child: Text(row.labelFor(row.selected), style: style), + ), + trigger.anchor( + child: const Icon( + BusyMarkGlyphs.downArrow, + size: BusyMarkSizes.iconSm, + ), + ), + ], + ), + if (row.subtitle != null) + Padding( + padding: const EdgeInsets.only(top: BusyMarkSpacing.xs), + child: Text( + row.subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + focusNode: trigger.focusNode, + enabled: row.enabled, + onTap: trigger.onPressed, + hoverColor: busyMarkRowHoverColor(context), + ), + ), + ); + }, + ); +} + +class _TopicNavigation extends StatefulWidget { + const _TopicNavigation({ + super.key, + required this.declarations, + required this.readOnly, + required this.apply, + this.session, + }); + final List<BusyBlock> declarations; + final bool readOnly; + final bool Function(bool, String, String) apply; + final WritersidePropertySession? session; + @override + State<_TopicNavigation> createState() => _TopicNavigationState(); +} + +class _TopicNavigationState extends State<_TopicNavigation> { + late final _local = WritersidePropertySession(); + WritersidePropertySession get session => widget.session ?? _local; + late WritersidePropertyDraft draft; + final elements = TextEditingController(); + final depth = TextEditingController(); + final focus = FocusNode(); + final elementsFocus = FocusNode(); + final depthFocus = FocusNode(); + bool custom = false; + bool get protected => + widget.declarations.length > 1 || + widget.declarations.any( + (b) => b.attributes.keys.any( + {'instance', 'filter', 'switcher-key'}.contains, + ), + ); + Map<String, dynamic> get authored => widget.declarations.isEmpty + ? {'present': false, 'for': '', 'depth': ''} + : { + 'present': true, + 'for': widget.declarations.first.attributes['for'] ?? '', + 'depth': widget.declarations.first.attributes['depth'] ?? '', + }; + void bind() { + final apply = widget.apply; + draft = session.bind('${widget.key}-show-structure', jsonEncode(authored), ( + value, + ) { + final values = jsonDecode(value) as Map<String, dynamic>; + return apply( + values['present'] as bool, + values['for'] as String, + values['depth'] as String, + ); + }); + sync(); + } + + void sync() { + final values = jsonDecode(draft.text) as Map<String, dynamic>; + final forText = values['for'] == 'none' ? '' : values['for'] as String; + if (elements.text != forText) elements.text = forText; + if (depth.text != values['depth']) depth.text = values['depth'] as String; + } + + @override + void initState() { + super.initState(); + bind(); + focus.addListener(() { + if (!focus.hasFocus) session.commit(draft); + }); + session.addListener(refresh); + } + + void refresh() { + if (mounted) { + sync(); + setState(() {}); + } + } + + @override + void didUpdateWidget(covariant _TopicNavigation old) { + super.didUpdateWidget(old); + bind(); + } + + @override + void dispose() { + session.removeListener(refresh); + elements.dispose(); + depth.dispose(); + focus.dispose(); + elementsFocus.dispose(); + depthFocus.dispose(); + _local.dispose(); + super.dispose(); + } + + void edit() => session.edit( + draft, + jsonEncode({'present': true, 'for': elements.text, 'depth': depth.text}), + ); + @override + Widget build(BuildContext context) { + final l = context.l10n; + final values = jsonDecode(draft.text) as Map<String, dynamic>; + final selected = custom + ? 'custom' + : values['present'] != true + ? 'default' + : values['for'] == 'none' + ? 'none' + : values['for'] == '' && values['depth'] == '' + ? 'all' + : 'custom'; + return Focus( + focusNode: focus, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): _RevertPropertyIntent(), + }, + child: Actions( + actions: { + _RevertPropertyIntent: CallbackAction<_RevertPropertyIntent>( + onInvoke: (_) { + session.revert(draft); + custom = false; + return null; + }, + ), + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (protected) _PropertyNotice(l.wsPropertiesProtected), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow<String>( + title: l.wsNavigationElements, + tooltip: l.wsNavigationElements, + values: const ['default', 'all', 'none', 'custom'], + selected: selected, + enabled: !widget.readOnly && !protected, + labelFor: (v) => switch (v) { + 'default' => l.wsUseDefault, + 'all' => l.wsNavigationAll, + 'none' => l.wsNavigationNone, + _ => l.wsNavigationCustom, + }, + onSelected: (v) { + if (!session.flush()) return; + if (v == 'custom') { + // Enter editing without inventing a published depth or element list. + setState(() => custom = true); + } else { + session.edit( + draft, + jsonEncode({ + 'present': v != 'default', + 'for': v == 'none' ? 'none' : '', + 'depth': '', + }), + ); + if (session.commit(draft)) { + setState(() => custom = false); + } + } + }, + width: 155, + ), + if (selected == 'custom') ...[ + BusyMarkGroupedTextEntry( + label: '${l.wsNavigationElements} (for)', + controller: elements, + focusNode: elementsFocus, + readOnly: widget.readOnly || protected, + onChanged: (_) => edit(), + onSubmitted: (_) { + session.commit(draft); + if (mounted) elementsFocus.requestFocus(); + }, + errorText: draft.error == null + ? null + : draft.detail ?? l.wsPropertyInvalid, + ), + BusyMarkGroupedTextEntry( + label: '${l.wsNavigationDepth} (depth)', + controller: depth, + focusNode: depthFocus, + readOnly: widget.readOnly || protected, + onChanged: (_) => edit(), + onSubmitted: (_) { + session.commit(draft); + if (mounted) depthFocus.requestFocus(); + }, + ), + ], + ], + ), + ], + ), + ), + ), + ); + } +} + +class _PropertyTextEntry extends StatefulWidget { + const _PropertyTextEntry({ + super.key, + required this.label, + required this.value, + required this.apply, + this.session, + this.readOnly = false, + this.commitOnBlur = true, + this.chooseFile, + this.supportingText, + this.help, + }); + final String label, value; + final bool Function(String) apply; + final WritersidePropertySession? session; + final bool readOnly; + final bool commitOnBlur; + final Future<String?> Function()? chooseFile; + final String? supportingText; + final String? help; + @override + State<_PropertyTextEntry> createState() => _PropertyTextEntryState(); +} + +class _PropertyTextEntryState extends State<_PropertyTextEntry> { + late final _localSession = WritersidePropertySession(); + WritersidePropertySession get _session => widget.session ?? _localSession; + late WritersidePropertyDraft _draft; + late final TextEditingController _controller; + final _focus = FocusNode(); + String get _key => widget.key.toString(); + var _choosing = false; + + Future<void> _chooseFile() async { + if (_choosing || !_session.flush()) return; + final draft = _draft; + setState(() => _choosing = true); + try { + // A successful flush can reparse the same element. Capture its refreshed + // transaction after the workspace has rebuilt, before opening the dialog. + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !identical(draft, _draft) || widget.readOnly) return; + final apply = draft.apply; + final chooser = widget.chooseFile; + if (chooser == null) return; + final selected = await chooser(); + if (!mounted || + selected == null || + !identical(draft, _draft) || + widget.readOnly) { + return; + } + // Keep the authoritative target captured before opening the chooser. + // A presentation rebuild may rebind this field while the dialog is open. + draft.apply = apply; + _session.edit(draft, selected); + _session.commit(draft); + } on Exception { + if (mounted && identical(draft, _draft)) { + _session.reject(WritersidePropertyRejection.unresolved); + } + } finally { + if (mounted) setState(() => _choosing = false); + } + } + + @override + void initState() { + super.initState(); + _draft = _session.bind(_key, widget.value, widget.apply); + _controller = TextEditingController(text: _draft.text) + ..addListener(_controllerChanged); + _session.addListener(_refresh); + _focus.addListener(_focusChanged); + } + + void _refresh() { + if (!mounted) return; + if (_controller.text != _draft.text) _controller.text = _draft.text; + setState(() {}); + } + + void _controllerChanged() { + if (_controller.text != _draft.text) { + _session.edit(_draft, _controller.text); + if (mounted) setState(() {}); + } + } + + void _focusChanged() { + if (!_focus.hasFocus && widget.commitOnBlur) _session.commit(_draft); + } + + @override + void didUpdateWidget(covariant _PropertyTextEntry old) { + super.didUpdateWidget(old); + _draft = _session.bind(_key, widget.value, widget.apply); + if (_controller.text != _draft.text) _controller.text = _draft.text; + } + + @override + void dispose() { + _session.removeListener(_refresh); + _controller.removeListener(_controllerChanged); + _controller.dispose(); + _focus.removeListener(_focusChanged); + _focus.dispose(); + _localSession.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = context.l10n; + final error = + _draft.detail ?? + switch (_draft.error) { + WritersidePropertyRejection.invalid => l.wsPropertyInvalid, + WritersidePropertyRejection.readOnly => l.wsPropertiesReadOnly, + WritersidePropertyRejection.stale => l.wsPropertiesConflict, + WritersidePropertyRejection.unresolved => l.wsPropertiesUnresolved, + null => null, + }; + final field = Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): _RevertPropertyIntent(), + }, + child: Actions( + actions: { + _RevertPropertyIntent: CallbackAction<_RevertPropertyIntent>( + onInvoke: (_) { + _session.revert(_draft); + return null; + }, + ), + }, + child: BusyMarkGroupedTextEntry( + readOnly: widget.readOnly, + label: widget.label, + controller: _controller, + focusNode: _focus, + errorText: error, + trailing: widget.chooseFile == null + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.folderOpen, + tooltip: l.open, + onPressed: widget.readOnly || _choosing + ? null + : _chooseFile, + ), + if (_draft.text.isNotEmpty) + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.windowClose, + tooltip: l.removeAction, + onPressed: widget.readOnly || _choosing + ? null + : () { + _session.edit(_draft, ''); + _session.commit(_draft); + }, + ), + ], + ), + onSubmitted: (_) { + _session.commit(_draft); + if (mounted) _focus.requestFocus(); + }, + onChanged: (text) { + _session.edit(_draft, text); + if (mounted) setState(() {}); + }, + ), + ), + ); + final unit = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + field, + if (widget.supportingText != null && _controller.text.isEmpty) + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 8), + child: SelectableText( + widget.supportingText!, + key: widget.label.endsWith('(id)') + ? const ValueKey('writerside-generated-anchor') + : ValueKey('writerside-property-support-${widget.label}'), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + return widget.help == null + ? unit + : _PropertyContextHelp(help: widget.help!, child: unit); + } +} + +/// Existing tooltip treatment, available from the keyboard and in semantics. +class _PropertyContextHelp extends StatefulWidget { + const _PropertyContextHelp({ + required this.help, + required this.child, + this.mergeSemantics = false, + }); + final String help; + final Widget child; + final bool mergeSemantics; + @override + State<_PropertyContextHelp> createState() => _PropertyContextHelpState(); +} + +class _PropertyContextHelpState extends State<_PropertyContextHelp> { + final _tooltip = GlobalKey<TooltipState>(); + @override + Widget build(BuildContext context) => CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.f1): () => + _tooltip.currentState?.ensureTooltipVisible(), + }, + child: Tooltip( + key: _tooltip, + message: widget.help, + excludeFromSemantics: true, + child: widget.mergeSemantics + ? MergeSemantics( + child: Semantics(hint: widget.help, child: widget.child), + ) + : Semantics(hint: widget.help, child: widget.child), + ), + ); +} + +class _RevertPropertyIntent extends Intent { + const _RevertPropertyIntent(); +} diff --git a/lib/src/editor/wysiwyg/writerside_property_session.dart b/lib/src/editor/wysiwyg/writerside_property_session.dart new file mode 100644 index 00000000..08b75486 --- /dev/null +++ b/lib/src/editor/wysiwyg/writerside_property_session.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; + +enum WritersidePropertyRejection { invalid, readOnly, stale, unresolved } + +/// Pending text belongs to a captured target, independently of field widgets. +/// Transitions explicitly flush this session; disposal never publishes edits. +class WritersidePropertySession extends ChangeNotifier { + final Map<String, WritersidePropertyDraft> _drafts = {}; + WritersidePropertyRejection rejection = WritersidePropertyRejection.invalid; + String? detail; + WritersidePropertyRejection? feedback; + bool _processing = false; + + bool get hasPending => _drafts.values.any((d) => d.pending); + Iterable<WritersidePropertyDraft> get drafts => _drafts.values; + + WritersidePropertyDraft bind( + String key, + String value, + bool Function(String) apply, + ) { + final draft = _drafts.putIfAbsent( + key, + () => WritersidePropertyDraft(value, apply), + ); + if (!draft.pending) { + draft.committed = value; + draft.text = value; + draft.apply = apply; + draft.error = null; + draft.detail = null; + } + return draft; + } + + void edit(WritersidePropertyDraft draft, String text) { + draft.text = text; + draft.error = null; + draft.detail = null; + } + + void revert(WritersidePropertyDraft draft) { + draft.text = draft.committed; + draft.error = null; + draft.detail = null; + feedback = null; + notifyListeners(); + } + + void revertAll() { + for (final draft in _drafts.values) { + draft.text = draft.committed; + draft.error = null; + draft.detail = null; + } + notifyListeners(); + } + + bool reject(WritersidePropertyRejection reason, [String? explanation]) { + rejection = reason; + detail = explanation; + feedback = reason; + notifyListeners(); + return false; + } + + bool commit(WritersidePropertyDraft draft) { + if (!draft.pending) return true; + rejection = WritersidePropertyRejection.invalid; + detail = null; + if (!draft.apply(draft.text)) { + draft.error = rejection; + draft.detail = detail; + notifyListeners(); + return false; + } + draft.committed = draft.text; + draft.error = null; + draft.detail = null; + feedback = null; + notifyListeners(); + return true; + } + + bool flush() { + if (_processing) return true; + _processing = true; + try { + for (final draft in _drafts.values.toList()) { + if (!commit(draft)) return false; + } + return true; + } finally { + _processing = false; + } + } + + /// Only a successfully processed transition can retire old field bindings. + bool transition() { + if (!flush()) return false; + _drafts.clear(); + feedback = null; + return true; + } +} + +class WritersidePropertyDraft { + WritersidePropertyDraft(this.committed, this.apply) : text = committed; + String committed; + String text; + bool Function(String) apply; + WritersidePropertyRejection? error; + String? detail; + bool get pending => text != committed; +} diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 12055be3..8f46fe69 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -457,6 +457,20 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { borderEffect: block.attributes['border-effect'] ?? 'none', ); } + if (!block.preserveRaw && block.attributes['element'] == 'include') { + final from = block.attributes['from'] ?? ''; + final elementId = block.attributes['element-id'] ?? ''; + return BusyMarkGroupedList( + children: [ + BusyMarkActionRow( + title: context.l10n.wsIncludeContent, + subtitle: elementId.isEmpty ? from : '$from#$elementId', + tooltip: context.l10n.wsElementProperties, + onTap: onFocused, + ), + ], + ); + } if (_isRenderedHtmlBlock) { return _RenderedHtmlBlockEditor( block: block, diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index 53e788f2..67384cc2 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import 'package:path/path.dart' as p; import '../../markdown/busymark_document.dart'; @@ -265,6 +267,14 @@ class WysiwygClipboardFragment { ); BusyBlock block(BusyBlock value) { final rebasedAttributes = attributes(value.attributes); + final rebasedInlines = [for (final child in value.inlines) inline(child)]; + bool referencesChanged(BusyInline before, BusyInline after) => + before.destination != after.destination || + !mapEquals(before.attributes, after.attributes) || + [ + for (var i = 0; i < before.children.length; i++) + referencesChanged(before.children[i], after.children[i]), + ].any((v) => v); final rawSource = value.kind == BusyBlockKind.video ? _rebaseVideoRawSource( value.rawSource, @@ -275,7 +285,7 @@ class WysiwygClipboardFragment { return BusyBlock( id: value.id, kind: value.kind, - inlines: [for (final child in value.inlines) inline(child)], + inlines: rebasedInlines, children: [for (final child in value.children) block(child)], attributes: rebasedAttributes, rawSource: rawSource, @@ -283,7 +293,13 @@ class WysiwygClipboardFragment { isSourceOnly: value.isSourceOnly, isGenerated: value.isGenerated, isSourceProtected: value.isSourceProtected, - dirty: true, + dirty: + value.dirty || + !mapEquals(value.attributes, rebasedAttributes) || + [ + for (var i = 0; i < value.inlines.length; i++) + referencesChanged(value.inlines[i], rebasedInlines[i]), + ].any((v) => v), ); } @@ -363,6 +379,7 @@ Map<String, Object?> _encodeBlock(BusyBlock block) => { 'sourceOnly': block.isSourceOnly, 'generated': block.isGenerated, 'sourceProtected': block.isSourceProtected, + 'dirty': block.dirty, }; bool _boundedJsonDepth(String source) { @@ -389,12 +406,20 @@ bool _boundedJsonDepth(String source) { return true; } -BusyBlock _destinationSerializationBlock(BusyBlock block) => BusyBlock( +BusyBlock _destinationSerializationBlock( + BusyBlock block, { + bool authoredStructure = false, +}) => BusyBlock( id: block.id, kind: block.kind, inlines: block.inlines, children: [ - for (final child in block.children) _destinationSerializationBlock(child), + for (final child in block.children) + _destinationSerializationBlock( + child, + authoredStructure: + authoredStructure || busyMarkIsWritersideContainer(block), + ), ], attributes: block.attributes, rawSource: block.rawSource, @@ -402,7 +427,9 @@ BusyBlock _destinationSerializationBlock(BusyBlock block) => BusyBlock( isSourceOnly: block.isSourceOnly, isGenerated: block.isGenerated, isSourceProtected: false, - dirty: true, + dirty: authoredStructure || busyMarkIsWritersideContainer(block) + ? block.dirty + : true, ); class _FragmentReader { @@ -484,7 +511,7 @@ class _FragmentReader { isSourceOnly: boolean(data['sourceOnly']), isGenerated: boolean(data['generated']), isSourceProtected: boolean(data['sourceProtected']), - dirty: true, + dirty: data['dirty'] == null || boolean(data['dirty']), ); } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_commands.dart b/lib/src/editor/wysiwyg/wysiwyg_commands.dart index 2edb3cf9..fb49efd1 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_commands.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_commands.dart @@ -24,6 +24,42 @@ enum BusyWysiwygInlineCommand { strikethrough, code, link, + uiControl, + filePath, + uiPath, + shortcut, +} + +enum BusyWritersideInsertCommand { + procedure, + tabs, + definitionList, + include, + variable, + video, + tldr, + convertListToProcedure, +} + +/// Distinct command categories share one menu without enum/string coercion. +sealed class BusyWritersideToolbarCommand { + const BusyWritersideToolbarCommand(); +} + +final class BusyWritersideToolbarInsert extends BusyWritersideToolbarCommand { + const BusyWritersideToolbarInsert(this.command); + final BusyWritersideInsertCommand command; +} + +final class BusyWritersideToolbarAdmonition + extends BusyWritersideToolbarCommand { + const BusyWritersideToolbarAdmonition(this.style); + final BusyAdmonitionStyle style; +} + +final class BusyWritersideToolbarSemantic extends BusyWritersideToolbarCommand { + const BusyWritersideToolbarSemantic(this.command); + final BusyWysiwygInlineCommand command; } /// Whether [block] can safely be replaced by a generic block command. @@ -41,6 +77,7 @@ bool busyMarkWysiwygCanApplyBlockCommand( return true; } if (block.preserveRaw || + busyMarkIsWritersideContainer(block) || block.isSourceOnly || block.isGenerated || block.isSourceProtected) { @@ -144,5 +181,9 @@ BusyInlineKind inlineKindForCommand(BusyWysiwygInlineCommand command) { BusyWysiwygInlineCommand.strikethrough => BusyInlineKind.strikethrough, BusyWysiwygInlineCommand.code => BusyInlineKind.code, BusyWysiwygInlineCommand.link => BusyInlineKind.link, + BusyWysiwygInlineCommand.uiControl => BusyInlineKind.writersideControl, + BusyWysiwygInlineCommand.filePath => BusyInlineKind.writersidePath, + BusyWysiwygInlineCommand.uiPath => BusyInlineKind.writersideUiPath, + BusyWysiwygInlineCommand.shortcut => BusyInlineKind.writersideShortcut, }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 177ef9e0..1da726b6 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -1,16 +1,28 @@ +import '../../writerside/writerside_property_validation.dart'; +import 'dart:convert'; +import '../../core/source_span.dart'; import 'package:flutter/foundation.dart'; import '../../core/path_utils.dart'; import '../../markdown/busymark_document.dart'; +import '../../markdown/document_outline.dart'; import '../../markdown/busymark_markdown_serializer.dart'; import '../../markdown/markdown_model.dart'; +import '../../markdown/markdown_source_map.dart'; import '../../markdown/markdown_parser.dart'; +import '../../markdown/markdown_front_matter.dart'; import '../../markdown/math_syntax.dart'; import '../../markdown/raw_html_adapter.dart'; import '../../spellcheck/spelling_replacement.dart'; import '../inline_semantics.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_inline_controller.dart'; +import 'writerside_editing_adapter.dart'; +import '../../writerside/writerside_schema.dart'; +import '../../writerside/writerside_project.dart'; +import '../../writerside/writerside_document_parser.dart'; +import '../../writerside/writerside_document_serializer.dart'; +import '../../writerside/writerside_document.dart'; BusyBlock busyMarkWysiwygImmutableBlockSnapshot(BusyBlock block) { BusyInline snapshotInline(BusyInline inline) { @@ -52,7 +64,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyMarkWysiwygDocumentController({ required BusyDocument document, BusyMarkMarkdownSerializer serializer = const BusyMarkMarkdownSerializer(), - }) : _document = _ensureEditableDocument(document), + }) : _document = _ensureEditableDocument( + busyMarkWysiwygBindMarkdownReferences(document), + ), _serializer = serializer; BusyDocument _document; @@ -61,7 +75,143 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyDocument get document => _document; - String get markdown => _serializer.serialize(_document); + String get markdown => _document.isXmlTopic + ? const WritersideEditingAdapter().serializeXml(_document) + : _serializer.serialize(_document); + + void rebaseCommittedSource(String source) { + if (_document.isXmlTopic) { + _document = const WritersideEditingAdapter().rebaseXml(_document, source); + } else if (_document.isWriterside) { + // Every Writerside editing projection needs current authored positions, + // including a topic containing only headings and paragraphs. List + // ownership reparsing remains limited to the structural cases below. + final current = _document; + bool needsOwnership(List<BusyBlock> blocks, BusyBlock? owner) { + for (final b in blocks) { + if (_isListItemKind(b.kind) && + !b.attributes.containsKey('element') && + owner?.attributes['busymark-list-form'] != 'markdown') { + return true; + } + if (b.attributes['busymark-list-form'] == 'markdown' && + (b.children.any((c) => !_isListItemKind(c.kind)) || + b.attributes['busymark-native-members'] != + jsonEncode(b.children.map((c) => c.id).toList()))) { + return true; + } + if (b.dirty && + b.attributes.containsKey('element') && + b.attributes['busymark-list-form'] != 'markdown' && + (b.rawSource == null || + b.rawSource!.trimLeft().startsWith('<'))) { + return true; + } + if (needsOwnership(b.children, b)) return true; + } + return false; + } + + final hasListItems = _flattenBlocks( + current.blocks, + ).any((b) => _isListItemKind(b.kind)); + var rebased = hasListItems && needsOwnership(current.blocks, null) + ? _reparseNativeListOwnership(source) + : const MarkdownParser().rebindSourceMetadata(current, source); + final generatedAnchors = { + for (final heading in rebased.outline) + if (heading.editorBlockId != null) heading.editorBlockId!: heading.id, + }; + BusyBlock commit(BusyBlock block) { + final attrs = {...block.attributes} + ..remove('busymark-property-edits') + ..remove('busymark-property-line-ending'); + if (block.kind == BusyBlockKind.heading && + !attrs.containsKey('element') && + attrs['generatedId'] == 'true' && + generatedAnchors.containsKey(block.id)) { + attrs['id'] = generatedAnchors[block.id]!; + } + if (block.attributes.containsKey( + 'busymark-writerside-source-attributes', + ) || + WritersideEditingAdapter.sourceAttributes(block).isNotEmpty) { + attrs['busymark-writerside-source-attributes'] = jsonEncode( + WritersideEditingAdapter.sourceAttributes(block), + ); + } + return block.copyWith( + attributes: attrs, + dirty: false, + children: block.children.map(commit).toList(), + ); + } + + rebased = rebased.copyWith(blocks: rebased.blocks.map(commit).toList()); + _document = busyMarkWysiwygBindMarkdownReferences(rebased); + } + } + + BusyDocument _reparseNativeListOwnership(String source) { + final parsed = busyMarkWysiwygBindMarkdownReferences( + const MarkdownParser() + .parse( + filePath: _document.filePath, + source: source, + mode: _document.mode, + validateLocalReferences: false, + ) + .busyDocument, + ); + bool member(BusyBlock b) => + !b.isSourceOnly && + b.kind != BusyBlockKind.frontMatter && + b.attributes['busymark-list-form'] != 'markdown'; + final before = _flattenBlocks(_document.blocks).where(member).toList(); + final after = _flattenBlocks(parsed.blocks).where(member).toList(); + // Reparse our own transaction, retaining editing identities only when the + // complete authored member sequence agrees. Replacement buffers do not use + // this reconciliation and pending property sessions stay revision-bound. + if (before.length != after.length || + Iterable.generate(before.length).any( + (i) => + (before[i].kind != after[i].kind && + !(_isListItemKind(before[i].kind) && + _isListItemKind(after[i].kind))) || + WritersideEditingAdapter.tagForBlock(before[i]) != + WritersideEditingAdapter.tagForBlock(after[i]) || + before[i].plainText != after[i].plainText, + )) { + return parsed; + } + final identities = { + for (var i = 0; i < before.length; i++) after[i].id: before[i].id, + }; + final owners = { + for (final b in _flattenBlocks(_document.blocks)) + if (b.attributes['busymark-list-form'] == 'markdown') + jsonEncode(b.children.map((c) => c.id).toList()): b.id, + }; + BusyBlock rekey(BusyBlock b) { + final children = b.children.map(rekey).toList(); + final native = b.attributes['busymark-list-form'] == 'markdown'; + final signature = jsonEncode(children.map((c) => c.id).toList()); + final id = native + ? owners[signature] ?? _nextGeneratedBlockId('list') + : identities[b.id] ?? b.id; + return b.copyWith( + id: id, + children: children, + attributes: { + ...b.attributes, + if (b.attributes.containsKey('busymark-native-members')) + 'busymark-native-members': signature, + }, + ); + } + + return parsed.copyWith(blocks: parsed.blocks.map(rekey).toList()); + } @override void notifyListeners() { @@ -70,13 +220,1556 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void replaceDocument(BusyDocument document) { - _document = _ensureEditableDocument(document); + _document = _ensureEditableDocument( + busyMarkWysiwygBindMarkdownReferences(document), + ); + notifyListeners(); + } + + String blockText(String blockId) { + final block = blockById(blockId); + return block == null ? '' : busyMarkWysiwygEditableText(block); + } + + List<BusyBlock> elementPath(String blockId) { + List<BusyBlock>? find(List<BusyBlock> blocks) { + for (final block in blocks) { + if (block.id == blockId) return [block]; + final path = find(block.children); + if (path != null) return [block, ...path]; + } + return null; + } + + final path = find(_document.blocks) ?? []; + if (!_document.isXmlTopic && path.isNotEmpty) { + final rootIndex = _document.blocks.indexWhere( + (b) => b.id == path.first.id, + ); + final headings = <BusyBlock>[]; + for (final candidate in _document.blocks.take(rootIndex + 1)) { + if (candidate.kind != BusyBlockKind.heading) continue; + final level = int.tryParse(candidate.attributes['level'] ?? '') ?? 1; + headings.removeWhere( + (h) => (int.tryParse(h.attributes['level'] ?? '') ?? 1) >= level, + ); + if (candidate.id != blockId) headings.add(candidate); + } + return [...headings, ...path]; + } + return path; + } + + List<String> availableVariableReferences( + WritersideProjectIndex index, + String moduleId, + String blockId, + ) { + final target = blockById(blockId); + if (target == null) { + return const []; + } + final names = <String>{ + for (final s in index.symbols) + if (s.moduleId == moduleId && + s.kind == WritersideSymbolKind.variable && + s.scopeSpan == null) + s.name, + }; + // Read local declarations from authored parents, avoiding stale index + // offsets after structural edits. Markdown root declarations share topic + // scope, while a declaration inside a tab/chapter remains in that parent. + final scopes = [ + if (!_document.isXmlTopic) _document.blocks, + for (final parent in elementPath(blockId)) parent.children, + ]; + for (final children in scopes) { + for (final child in children) { + final name = child.attributes['name']; + if (child.attributes['element'] == 'var' && name != null) { + names.add(name); + } + } + } + return names.toList()..sort(); + } + + bool canInsertWriterside( + BusyWritersideInsertCommand command, + String? blockId, + ) { + if (!_document.isWriterside || blockId == null) return false; + final target = blockById(blockId); + if (target == null || + target.preserveRaw || + target.isSourceProtected || + target.isGenerated) { + return false; + } + if (command == BusyWritersideInsertCommand.variable) { + return !busyMarkIsWritersideContainer(target) && + target.kind == BusyBlockKind.paragraph; + } + if (command == BusyWritersideInsertCommand.convertListToProcedure) { + return _listToProcedureSelection(blockId) != null; + } + final parent = _insertionParent(target); + final tag = switch (command) { + BusyWritersideInsertCommand.procedure => 'procedure', + BusyWritersideInsertCommand.tabs => 'tabs', + BusyWritersideInsertCommand.definitionList => 'deflist', + BusyWritersideInsertCommand.include => 'include', + BusyWritersideInsertCommand.video => 'video', + BusyWritersideInsertCommand.tldr => 'tldr', + _ => '', + }; + final parentTag = parent == null + ? 'topic' + : parent.attributes['element'] ?? 'chapter'; + if (!WritersideSchema.childElementNames(parentTag).contains(tag)) { + return false; + } + if (command == BusyWritersideInsertCommand.tldr) { + if (!{'topic', 'chapter'}.contains(parentTag)) return false; + final siblings = parent?.children ?? _document.blocks; + if (_document.isXmlTopic || + parent != null && busyMarkIsWritersideContainer(parent)) { + if (siblings.any((b) => b.attributes['element'] == 'tldr')) { + return false; + } + } else if (_document.blocks.any( + (b) => + b.attributes['element'] == 'tldr' && + elementPath(b.id) + .where((p) => p.kind == BusyBlockKind.heading) + .lastOrNull + ?.id == + parent?.id, + )) { + return false; + } + } + return true; + } + + BusyBlock? _insertionParent(BusyBlock target) { + if (!_document.isXmlTopic && target.kind == BusyBlockKind.heading) { + return target; + } + if (busyMarkIsWritersideContainer(target) && + !{ + 'tabs', + 'deflist', + 'list', + 'procedure', + }.contains(target.attributes['element'])) { + return target; + } + final path = elementPath(target.id); + for (final parent in path.reversed.skip(1)) { + if (busyMarkIsWritersideContainer(parent)) return parent; + if (!_document.isXmlTopic && parent.kind == BusyBlockKind.heading) { + return parent; + } + } + return null; + } + + BusyBlock _newWritersideElement( + String tag, { + String title = '', + Map<String, String> attributes = const {}, + List<BusyBlock> children = const [], + }) { + final container = WritersideEditingAdapter.containers.contains(tag); + return BusyBlock( + id: _nextGeneratedBlockId(tag), + kind: switch (tag) { + 'procedure' => BusyBlockKind.writersideProcedure, + 'tabs' || 'tab' => BusyBlockKind.writersideTabs, + 'video' => BusyBlockKind.video, + 'p' => BusyBlockKind.paragraph, + _ => BusyBlockKind.writersideRawXml, + }, + inlines: WritersideEditingAdapter.titleElements.contains(tag) + ? [BusyInline(kind: BusyInlineKind.text, text: title)] + : const [], + children: children, + dirty: true, + attributes: { + 'element': tag, + ...attributes, + if (container) busyMarkWritersideContainerAttribute: 'true', + if (WritersideEditingAdapter.titleElements.contains(tag)) + 'title': title, + }, + ); + } + + String? insertWriterside( + BusyWritersideInsertCommand command, + String blockId, { + required String title, + Map<String, String> attributes = const {}, + }) { + if (!canInsertWriterside(command, blockId)) return null; + if (command == BusyWritersideInsertCommand.convertListToProcedure) { + return convertListToProcedure(blockId, title: title); + } + final tag = switch (command) { + BusyWritersideInsertCommand.procedure => 'procedure', + BusyWritersideInsertCommand.tabs => 'tabs', + BusyWritersideInsertCommand.definitionList => 'deflist', + BusyWritersideInsertCommand.include => 'include', + BusyWritersideInsertCommand.video => 'video', + BusyWritersideInsertCommand.tldr => 'tldr', + _ => '', + }; + if (tag.isEmpty) return null; + for (final required in WritersideSchema.requiredAttributesFor(tag)) { + if ((attributes[required] ?? '').trim().isEmpty) return null; + } + final itemTag = switch (tag) { + 'procedure' => 'step', + 'tabs' => 'tab', + 'deflist' => 'def', + _ => null, + }; + final inserted = _newWritersideElement( + tag, + title: title, + attributes: attributes, + children: itemTag == null + ? tag == 'tldr' + ? [_newWritersideElement('p')] + : [] + : [ + _newWritersideElement( + itemTag, + title: title, + children: [_newWritersideElement('p')], + ), + ], + ); + final target = blockById(blockId)!; + final parent = _insertionParent(target); + if (parent != null && busyMarkIsWritersideContainer(parent)) { + _replaceBlock(parent.id, (block) { + final children = [...block.children]; + var index = children.indexWhere((b) => b.id == target.id); + if (tag == 'tldr') { + index = children.indexWhere( + (b) => !b.isSourceOnly && b.attributes['element'] != 'title', + ); + children.insert(index < 0 ? children.length : index, inserted); + } else { + children.insert(index < 0 ? children.length : index + 1, inserted); + } + return block.copyWith(children: children, dirty: true); + }); + } else { + final anchor = tag == 'tldr' && parent != null ? parent.id : blockId; + _document = _document.copyWith( + blocks: _insertBlocksAfter(_document.blocks, anchor, [inserted]), + ); + notifyListeners(); + } + return inserted.id; + } + + ({BusyBlock? list, List<BusyBlock> items, BusyBlockKind kind})? + _listToProcedureSelection(String blockId) { + final target = elementPath( + blockId, + ).where((b) => _isListItemKind(b.kind)).lastOrNull; + if (target == null || target.kind == BusyBlockKind.taskListItem) { + return null; + } + final path = elementPath(target.id); + final list = path + .where((b) => b.attributes['element'] == 'list') + .lastOrNull; + final siblings = + list?.children ?? + (path.length > 1 && busyMarkIsWritersideContainer(path[path.length - 2]) + ? path[path.length - 2].children + : _document.blocks); + final index = siblings.indexWhere((b) => b.id == target.id); + if (index < 0) return null; + var start = index, end = index + 1; + while (start > 0 && siblings[start - 1].kind == target.kind) { + start--; + } + while (end < siblings.length && siblings[end].kind == target.kind) { + end++; + } + final items = list == null + ? siblings.sublist(start, end) + : siblings.where((b) => !b.isSourceOnly).toList(); + if (items.any( + (b) => + !_isListItemKind(b.kind) || + b.preserveRaw || + b.isSourceProtected || + WritersideEditingAdapter.sourceAttributes(b).keys.any( + (a) => !WritersideSchema.attributesFor('step').contains(a), + ), + )) { + return null; + } + if (list != null && + WritersideEditingAdapter.sourceAttributes(list).keys.any( + (a) => + a != 'type' && + !WritersideSchema.attributesFor('procedure').contains(a), + )) { + return null; + } + final parentPath = elementPath(list?.id ?? target.id); + final parent = parentPath.length > 1 + ? parentPath[parentPath.length - 2] + : null; + final parentTag = parent == null + ? 'topic' + : parent.kind == BusyBlockKind.heading && !_document.isXmlTopic + ? 'chapter' + : WritersideEditingAdapter.tagForBlock(parent); + if (!WritersideSchema.childElementNames(parentTag).contains('procedure')) { + return null; + } + return (list: list, items: items, kind: target.kind); + } + + String? convertListToProcedure(String blockId, {required String title}) { + final selection = _listToProcedureSelection(blockId); + if (selection == null) return null; + final list = selection.list; + final items = selection.items; + final steps = [ + for (final item in items) + _newWritersideElement( + 'step', + attributes: WritersideEditingAdapter.sourceAttributes(item), + children: [ + if (item.inlines.isNotEmpty) + BusyBlock( + id: _nextGeneratedBlockId('p'), + kind: BusyBlockKind.paragraph, + inlines: item.inlines, + dirty: true, + ), + ...item.children, + ], + ), + ]; + final procedure = _newWritersideElement( + 'procedure', + title: title, + attributes: { + if (list != null) ...{ + for (final e in WritersideEditingAdapter.sourceAttributes( + list, + ).entries) + if (WritersideSchema.attributesFor('procedure').contains(e.key) && + e.key != 'type') + e.key: e.value, + }, + 'type': selection.kind == BusyBlockKind.orderedListItem + ? 'steps' + : 'choices', + }, + children: list == null + ? steps + : [ + for (final child in list.children) + if (child.isSourceOnly) child else steps[items.indexOf(child)], + ], + ); + if (list != null) { + _document = _document.copyWith( + blocks: _replaceBlockWithMany(_document.blocks, list.id, [procedure]), + ); + } else { + var blocks = _document.blocks; + for (final item in items) { + blocks = _replaceBlockWithMany( + blocks, + item.id, + item == items.first ? [procedure] : [], + ); + } + _document = _document.copyWith(blocks: blocks); + } + notifyListeners(); + return procedure.id; + } + + bool updateWritersideProperty( + String blockId, + String attribute, + String? value, + ) { + final block = blockById(blockId); + if (!_document.isWriterside || + block == null || + block.isSourceProtected || + block.preserveRaw) { + return false; + } + final tag = WritersideEditingAdapter.propertyTag(_document, block); + final name = attribute == 'language' ? 'lang' : attribute; + if (!(block.kind == BusyBlockKind.heading && + !_document.isXmlTopic && + {'id', 'title'}.contains(attribute) && + (value == null || value.isEmpty)) && + validateWritersideProperty( + tag, + attribute, + value, + attributes: block.attributes, + applicableAttributes: WritersideEditingAdapter.propertyAttributes( + _document, + block, + ), + hasTitleChild: block.children.any( + (b) => b.attributes['element'] == 'title', + ), + inlineCode: block.kind == BusyBlockKind.codeBlock + ? block.plainText + : null, + ) != + null) { + return false; + } + if (name == 'id' && + value != null && + value.isNotEmpty && + _flatten(_document.blocks).any( + (b) => + b.id != blockId && + b.attributes['id'] == value && + b.attributes['generatedId'] != 'true', + )) { + return false; + } + if (block.kind == BusyBlockKind.heading && + !_document.isXmlTopic && + WritersideEditingAdapter.topicHeading(_document)?.id == block.id && + {'collapsible', 'default-state'}.contains(name)) { + return false; + } + if (!WritersideEditingAdapter.propertyAttributes( + _document, + block, + ).contains(name) || + tag == 'topic' && name == 'id') { + return false; + } + if (value != null && + value.isNotEmpty && + WritersideSchema.invalidAttributeValue(tag, name, value) != null) { + return false; + } + if ((value == null || value.isEmpty && name != 'alt') && + WritersideSchema.requiredAttributesFor(tag).contains(name) && + !(name == 'title' && + block.children.any((b) => b.attributes['element'] == 'title')) && + !(block.kind == BusyBlockKind.heading && + !_document.isXmlTopic && + {'id', 'title'}.contains(name))) { + return false; + } + if (block.attributes[attribute] == value || + (value == null || value.isEmpty && attribute != 'alt') && + !block.attributes.containsKey(attribute)) { + return true; + } + // Markdown's list attribute line applies to the list, never to a li. + // An item override therefore requires the existing semantic XML form. + if (!_document.isXmlTopic && + _isListItemKind(block.kind) && + !block.attributes.containsKey('element') && + !_materializeNativeList(blockId)) { + return false; + } + final imageRange = block.kind == BusyBlockKind.image + ? busyInlineReferenceRanges( + block.inlines, + ).where((r) => r.kind == BusyInlineKind.image).firstOrNull + : null; + final imageSource = imageRange == null + ? null + : _patchNativeReferenceProperty(block, imageRange, attribute, value); + _replaceBlock(blockId, (block) { + final attrs = {...block.attributes}; + if (!_document.isXmlTopic && + block.rawSource != null && + (!block.dirty || + block.attributes.containsKey('busymark-property-edits')) && + (block.attributes.containsKey('element') || + { + BusyBlockKind.paragraph, + BusyBlockKind.heading, + BusyBlockKind.codeBlock, + BusyBlockKind.table, + BusyBlockKind.image, + BusyBlockKind.blockquote, + }.contains(block.kind))) { + final old = block.attributes['busymark-property-edits']; + attrs['busymark-property-line-ending'] = + (_document.source?.contains('\r\n') ?? false) ? '\r\n' : '\n'; + attrs['busymark-property-edits'] = imageSource != null + ? old ?? '{}' + : jsonEncode({ + if (old != null) ...(jsonDecode(old) as Map<String, dynamic>), + attribute: value, + }); + } + if (value == null || value.isEmpty && attribute != 'alt') { + attrs.remove(attribute); + } else { + attrs[attribute] = value; + } + if (attribute == 'checked' && block.kind == BusyBlockKind.taskListItem) { + attrs['task'] = value ?? 'false'; + } + if (attribute == 'language' && + (attrs['element'] == 'code-block' || attrs.containsKey('lang'))) { + if (value == null || value.isEmpty) { + attrs.remove('lang'); + } else { + attrs['lang'] = value; + } + } + if (attribute == 'id' && block.kind == BusyBlockKind.heading) { + attrs['generatedId'] = 'false'; + } + return block.copyWith( + rawSource: imageSource ?? block.rawSource, + attributes: attrs, + dirty: true, + inlines: + block.kind == BusyBlockKind.image && + WritersideSchema.attributesFor('img').contains(attribute) + ? block.inlines + .map((inline) => _imageProperty(inline, attribute, value)) + .toList() + : attribute == 'title' && busyMarkIsWritersideContainer(block) + ? [BusyInline(kind: BusyInlineKind.text, text: value ?? '')] + : block.inlines, + ); + }); + if (tag == 'def' && attribute != 'title' && !_document.isXmlTopic) { + final list = elementPath( + blockId, + ).where((b) => b.attributes['element'] == 'deflist').lastOrNull; + if (list?.attributes['busymark-definition-form'] == 'markdown') { + _replaceBlock( + list!.id, + (b) => b.copyWith( + attributes: {...b.attributes}..remove('busymark-definition-form'), + dirty: true, + ), + ); + } + } + return true; + } + + bool _materializeNativeList(String blockId) { + final path = elementPath(blockId); + // Markdown chapter ancestry is structural navigation, not child ownership. + final parent = path + .where((b) => b.children.any((child) => child.id == blockId)) + .lastOrNull; + final nativeList = parent?.attributes['busymark-list-form'] == 'markdown'; + if (nativeList && + WritersideEditingAdapter.nativeListHasMixedMarkers(parent!)) { + return false; + } + final siblings = parent?.children ?? _document.blocks; + final index = siblings.indexWhere((b) => b.id == blockId); + if (index < 0) return false; + var start = index, end = index + 1; + if (nativeList) { + // The parser's owner includes mixed task/non-task and loose items. + start = 0; + end = siblings.length; + } else { + // Adjacency cannot establish an authored list boundary. + return false; + } + final items = siblings.sublist(start, end); + bool convertible(BusyBlock b) => + !b.isSourceProtected && + !b.preserveRaw && + (_isListItemKind(b.kind) || + b.kind == BusyBlockKind.paragraph || + b.kind == BusyBlockKind.codeBlock || + busyMarkIsWritersideContainer(b)) && + (b.attributes['busymark-list-form'] == 'markdown' || + !b.children.any((child) => _isListItemKind(child.kind))) && + b.children.every(convertible); + if (!items.every(convertible)) return false; + + Map<String, String> listAttributes( + List<BusyBlock> members, [ + Map<String, String> authored = const {}, + ]) { + final first = members.first; + final markerStart = RegExp( + r'^([0-9]+)[.)]$', + ).firstMatch(first.attributes['marker'] ?? '')?.group(1); + return { + ...authored, + 'type': + authored['type'] ?? + (first.kind == BusyBlockKind.orderedListItem + ? 'decimal' + : first.kind == BusyBlockKind.taskListItem + ? 'checkbox' + : 'bullet'), + // An explicit Writerside start overrides Markdown marker numbering. + // Infer only an absent override, separately for each list level. + if (!authored.containsKey('start') && + first.kind == BusyBlockKind.orderedListItem && + markerStart != null && + markerStart != '1') + 'start': markerStart, + }; + } + + BusyBlock convert(BusyBlock b) { + if (b.attributes['busymark-list-form'] == 'markdown') { + return _newWritersideElement( + 'list', + attributes: listAttributes( + b.children, + WritersideEditingAdapter.sourceAttributes(b), + ), + children: b.children.map(convert).toList(), + ).copyWith(id: b.id); + } + if (busyMarkIsWritersideContainer(b)) return b; + final children = <BusyBlock>[]; + if (_isListItemKind(b.kind) && b.inlines.isNotEmpty) { + children.add( + BusyBlock( + id: _nextGeneratedBlockId('p'), + kind: BusyBlockKind.paragraph, + inlines: b.inlines, + attributes: const {'element': 'p'}, + dirty: true, + ), + ); + } + children.addAll(b.children.map(convert)); + return BusyBlock( + id: b.id, + kind: b.kind, + inlines: _isListItemKind(b.kind) ? const [] : b.inlines, + children: children, + dirty: true, + attributes: { + ...b.attributes, + if (b.kind == BusyBlockKind.taskListItem && + b.attributes['task'] == 'true') + 'checked': 'true', + 'element': WritersideEditingAdapter.tagForBlock(b), + if (_isListItemKind(b.kind)) + busyMarkWritersideContainerAttribute: 'true', + if (b.kind == BusyBlockKind.codeBlock && + b.attributes['language'] != null) + 'lang': b.attributes['language']!, + }, + ); + } + + final source = _document.source; + final first = items.first.sourceSpan, last = items.last.sourceSpan; + final span = source == null || first == null || last == null + ? null + : SourceSpan.fromOffsets( + filePath: _document.filePath, + source: source, + startOffset: first.startOffset, + endOffset: last.endOffset, + ); + final list = _newWritersideElement( + 'list', + attributes: listAttributes( + items, + nativeList + ? WritersideEditingAdapter.sourceAttributes(parent!) + : const {}, + ), + children: items.map(convert).toList(), + ).copyWith(sourceSpan: nativeList ? parent!.sourceSpan : span); + if (nativeList) { + _document = _document.copyWith( + blocks: _replaceInBlocks( + _document.blocks, + parent!.id, + (_) => list.copyWith(id: parent.id), + ), + ); + return true; + } + final replacement = [...siblings.take(start), list, ...siblings.skip(end)]; + _document = _document.copyWith( + blocks: parent == null + ? replacement + : _replaceInBlocks( + _document.blocks, + parent.id, + (b) => b.copyWith(children: replacement, dirty: true), + ), + ); + return true; + } + + BusyInline _imageProperty(BusyInline inline, String name, String? value) { + if (inline.kind == BusyInlineKind.image) { + final attributes = { + ...inline.attributes, + if (!_document.isXmlTopic) 'busymark-writerside-attributes': 'true', + }; + if (value == null || value.isEmpty && name != 'alt') { + attributes.remove(name); + } else { + attributes[name] = value; + } + return inline.copyWith( + text: name == 'alt' ? value ?? '' : inline.text, + destination: name == 'src' ? value ?? '' : inline.destination, + attributes: attributes, + ); + } + return inline.copyWith( + children: inline.children + .map((i) => _imageProperty(i, name, value)) + .toList(), + ); + } + + /// Reference tuples are validated together and published as one model edit. + bool updateIncludeReference( + String blockId, { + required String from, + required String elementId, + String? origin, + }) { + final block = blockById(blockId); + if (!_document.isWriterside || + block == null || + block.attributes['element'] != 'include' || + block.isSourceProtected || + block.preserveRaw || + from.trim().isEmpty || + elementId.trim().isEmpty) { + return false; + } + final attrs = {...block.attributes, 'from': from, 'element-id': elementId} + ..remove('origin'); + if (origin?.isNotEmpty == true) attrs['origin'] = origin!; + _replaceBlock(blockId, (b) => b.copyWith(attributes: attrs, dirty: true)); + return true; + } + + bool resetTopicNavigation() { + if (!_document.isWriterside || + !WritersideEditingAdapter.topicNavigationEditable(_document)) { + return false; + } + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + final nodes = (root?.children ?? _document.blocks) + .where((b) => b.attributes['element'] == 'show-structure') + .toList(); + if (nodes.isEmpty) return true; + if (nodes.length != 1 || + nodes.single.attributes.keys.any( + {'instance', 'filter', 'switcher-key'}.contains, + )) { + return false; + } + if (root != null) { + _replaceBlock( + root.id, + (b) => b.copyWith( + children: b.children.where((c) => c.id != nodes.single.id).toList(), + dirty: true, + ), + ); + } else { + // A removed block cannot mark itself dirty. The Markdown serializer's + // unchanged-source fast path would otherwise retain the declaration. + // Locate its authored span in the current serialized generation, then + // reparse through the existing adapter to keep all following locations + // and inline bindings in the same coordinate system. + final source = markdown; + final parsed = const MarkdownParser() + .parse( + filePath: _document.filePath, + source: source, + mode: _document.mode, + validateLocalReferences: false, + ) + .busyDocument; + final declaration = parsed.blocks + .where((b) => b.attributes['element'] == 'show-structure') + .singleOrNull; + final span = declaration?.sourceSpan; + if (span == null) return false; + final next = source.replaceRange(span.startOffset, span.endOffset, ''); + if (!_publishMarkdownNavigationSource(next, removedId: nodes.single.id)) { + return false; + } + notifyListeners(); + } + return true; + } + + bool manageWritersideItem( + String containerId, { + String? itemId, + int? direction, + bool remove = false, + required String title, + }) { + final group = blockById(containerId); + final tag = switch (group?.attributes['element']) { + 'tabs' => 'tab', + 'procedure' => 'step', + 'deflist' => 'def', + _ => null, + }; + if (group == null || + tag == null || + tag == 'tab' && + group.children.any((b) => b.kind == BusyBlockKind.codeBlock) && + !group.children.any((b) => b.attributes['element'] == 'tab') || + group.preserveRaw || + group.isSourceProtected) { + return false; + } + final children = [...group.children]; + if (itemId == null) { + children.add( + _newWritersideElement( + tag, + title: title, + children: [_newWritersideElement('p')], + ), + ); + } else { + final index = children.indexWhere( + (b) => b.id == itemId && b.attributes['element'] == tag, + ); + if (index < 0) return false; + if (remove) { + if (children.where((b) => b.attributes['element'] == tag).length < 2) { + return false; + } + children.removeAt(index); + } else { + final items = children + .where((b) => b.attributes['element'] == tag) + .toList(); + final ordinal = items.indexWhere((b) => b.id == itemId); + final next = ordinal + (direction ?? 0); + if (next < 0 || next >= items.length || next == ordinal) return false; + final other = children.indexOf(items[next]); + children[index] = items[next]; + children[other] = items[ordinal]; + } + } + _replaceBlock( + containerId, + (b) => b.copyWith(children: children, dirty: true), + ); + return true; + } + + void insertVariableReference( + String blockId, + String name, + int start, + int end, + ) { + final block = blockById(blockId); + if (block == null || + !canInsertWriterside(BusyWritersideInsertCommand.variable, blockId)) { + return; + } + final value = BusyInline( + kind: BusyInlineKind.writersideVariable, + text: name, + attributes: {'reference': name}, + ); + final before = busyMarkWysiwygClipboardInlineSlice(block.inlines, 0, start); + final after = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + end, + block.plainText.length, + ); + _replaceBlock( + blockId, + (b) => b.copyWith(inlines: [...before, value, ...after], dirty: true), + ); + } + + bool updateTopicSwitcherLabel(String value) { + if (_document.isXmlTopic) { + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + return root != null && + updateWritersideProperty(root.id, 'switcher-label', value); + } + if (!_document.isWriterside) { + return false; + } + if (_document.frontMatter['switcher-label'] == value || + value.isEmpty && !_document.frontMatter.containsKey('switcher-label')) { + return true; + } + final raw = _document.rawFrontMatter; + final newline = _document.source?.contains('\r\n') == true ? '\r\n' : '\n'; + final line = 'switcher-label: ${encodeWritersideSwitcherLabel(value)}'; + final labelPattern = RegExp(r'^switcher-label:[^\r\n]*', multiLine: true); + final nextRaw = value.isEmpty + ? (raw ?? '').replaceFirst( + RegExp(r'^switcher-label:[^\r\n]*(?:\r?\n|$)', multiLine: true), + '', + ) + : raw == null + ? '---$newline$line$newline---$newline' + : labelPattern.hasMatch(raw) + ? raw.replaceFirst(labelPattern, line) + : raw.replaceFirstMapped( + RegExp(r'^---[ \t]*\r?\n'), + (m) => '${m[0]}$line$newline', + ); + final source = markdown; + final nextSource = raw == null + ? '$nextRaw$newline$source' + : source.replaceFirst(raw, nextRaw); + _document = _document.copyWith( + source: nextSource, + rawFrontMatter: nextRaw, + frontMatter: { + for (final entry in _document.frontMatter.entries) + if (entry.key != 'switcher-label') entry.key: entry.value, + if (value.isNotEmpty) 'switcher-label': value, + }, + ); + notifyListeners(); + return true; + } + + bool updateTopicNavigation(String attribute, String value) { + if (!{'for', 'depth'}.contains(attribute)) return false; + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + final existing = (root?.children ?? _document.blocks) + .where((b) => b.attributes['element'] == 'show-structure') + .firstOrNull; + return setTopicNavigation( + present: true, + forElements: attribute == 'for' + ? value + : existing?.attributes['for'] ?? '', + depth: attribute == 'depth' ? value : existing?.attributes['depth'] ?? '', + ); + } + + /// Publish a complete unscoped declaration as one document transaction. + /// Absence, an empty declaration and an explicit configuration are distinct. + bool setTopicNavigation({ + required bool present, + String forElements = '', + String depth = '', + }) { + if (!_document.isWriterside || + !WritersideEditingAdapter.topicNavigationEditable(_document) || + validateWritersideProperty('show-structure', 'depth', depth) != null || + validateWritersideProperty('show-structure', 'for', forElements) != + null) { + return false; + } + final root = _document.blocks + .where((b) => b.attributes['element'] == 'topic') + .firstOrNull; + final siblings = root?.children ?? _document.blocks; + final declarations = siblings + .where((b) => b.attributes['element'] == 'show-structure') + .toList(); + if (declarations.length > 1 || + declarations.any( + (b) => b.attributes.keys.any( + {'instance', 'filter', 'switcher-key'}.contains, + ), + )) { + return false; + } + final existing = declarations.firstOrNull; + if (!present) return resetTopicNavigation(); + final attrs = {...?existing?.attributes} + ..remove('for') + ..remove('depth'); + if (forElements.isNotEmpty) attrs['for'] = forElements; + if (depth.isNotEmpty) attrs['depth'] = depth; + if (existing != null) { + if (mapEquals(attrs, existing.attributes)) return true; + _replaceBlock( + existing.id, + (b) => b.copyWith(attributes: attrs, dirty: true), + ); + return true; + } + final node = _newWritersideElement( + 'show-structure', + attributes: attrs, + ).copyWith(isSourceOnly: _document.isXmlTopic); + if (root != null) { + _replaceBlock( + root.id, + (b) => b.copyWith(children: [node, ...b.children], dirty: true), + ); + } else { + final source = markdown; + final parsed = const MarkdownParser() + .parse( + filePath: _document.filePath, + source: source, + mode: _document.mode, + validateLocalReferences: false, + ) + .busyDocument; + final heading = WritersideEditingAdapter.topicHeading(parsed); + final offset = + heading?.sourceSpan?.endOffset ?? parsed.rawFrontMatter?.length ?? 0; + final newline = source.contains('\r\n') ? '\r\n' : '\n'; + final fragment = _serializer.serializeBlock(node); + final next = source.replaceRange( + offset, + offset, + '${offset == 0 ? '' : '$newline$newline'}$fragment${offset == source.length ? newline : ''}', + ); + if (!_publishMarkdownNavigationSource(next, inserted: node)) return false; + notifyListeners(); + } + return true; + } + + /// Navigation changes one known top-level owner. Rebase locations through + /// the existing parser while retaining the editor identities of every other + /// block, including its nested children. Reject a changed structural shape. + bool _publishMarkdownNavigationSource( + String source, { + String? removedId, + BusyBlock? inserted, + }) { + final parsed = _ensureEditableDocument( + const MarkdownParser() + .parse( + filePath: _document.filePath, + source: source, + mode: _document.mode, + validateLocalReferences: false, + ) + .busyDocument, + ); + final previous = _document.blocks + .where( + (b) => + b.id != removedId && b.attributes['element'] != 'show-structure', + ) + .toList(); + var index = 0; + BusyBlock? retain(BusyBlock fresh, BusyBlock old) { + if (fresh.kind != old.kind || + fresh.plainText != old.plainText || + fresh.children.length != old.children.length) { + return null; + } + final children = <BusyBlock>[]; + for (var i = 0; i < fresh.children.length; i++) { + final child = retain(fresh.children[i], old.children[i]); + if (child == null) return null; + children.add(child); + } + return fresh.copyWith( + id: old.id, + children: children, + attributes: { + ...fresh.attributes, + if (fresh.attributes.containsKey('editorBlockId')) + 'editorBlockId': old.id, + }, + ); + } + + final blocks = <BusyBlock>[]; + for (final block in parsed.blocks) { + if (block.attributes['element'] == 'show-structure') { + if (inserted == null) return false; + blocks.add(block.copyWith(id: inserted.id)); + } else { + if (index >= previous.length) return false; + final retained = retain(block, previous[index++]); + if (retained == null) return false; + blocks.add(retained); + } + } + if (index != previous.length) return false; + _document = busyMarkWysiwygBindMarkdownReferences( + parsed.copyWith(blocks: blocks), + ); + return true; + } + + bool updateInlineReference( + String blockId, + BusyInlineStyleRange target, + String attribute, + String value, + ) { + final block = blockById(blockId); + if (block == null || block.preserveRaw) { + return false; + } + final ranges = busyInlineReferenceRanges(block.inlines); + final index = ranges.indexWhere( + (r) => + r.start == target.start && + r.end == target.end && + r.kind == target.kind && + (target.referenceIndex == null || + r.referenceIndex == target.referenceIndex) && + mapEquals(r.attributes, target.attributes), + ); + if (index < 0) return false; + final range = ranges[index]; + if (range.kind == BusyInlineKind.writersideVariable) { + if (value.trim().isEmpty) { + return false; + } + final before = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + 0, + range.start, + ); + final after = busyMarkWysiwygClipboardInlineSlice( + block.inlines, + range.end, + block.plainText.length, + ); + final inlines = [ + ...before, + BusyInline( + kind: range.kind, + text: value, + attributes: {...range.attributes, 'reference': value}, + ), + ...after, + ]; + if (block.attributes.containsKey('busymark-native-source-map')) { + return _publishMappedNativeReference( + block, + range, + attribute, + value, + inlines, + ); + } + _replaceBlock(blockId, (b) => b.copyWith(inlines: inlines, dirty: true)); + return true; + } + final tag = switch (range.kind) { + BusyInlineKind.link => 'a', + BusyInlineKind.image => 'img', + BusyInlineKind.writersideShortcut => 'shortcut', + _ => null, + }; + if (tag == null || + validateWritersideProperty( + tag, + attribute, + value, + attributes: range.attributes, + ) != + null || + value.isNotEmpty && + WritersideSchema.invalidAttributeValue(tag, attribute, value) != + null || + attribute == 'key' && value.isEmpty && range.start == range.end) { + return false; + } + if (range.attributes[attribute] == value || + value.isEmpty && + attribute != 'alt' && + !range.attributes.containsKey(attribute)) { + return true; + } + final patchedSource = + block.attributes.containsKey('busymark-native-source-map') + ? null + : _patchNativeReferenceProperty(block, range, attribute, value); + var offset = 0; + var replaced = false; + var referenceIndex = 0; + BusyInline replace(BusyInline inline) { + final start = offset; + final ordinal = + { + BusyInlineKind.link, + BusyInlineKind.image, + BusyInlineKind.writersideVariable, + BusyInlineKind.writersideShortcut, + }.contains(inline.kind) + ? referenceIndex++ + : null; + if (!replaced && + inline.kind == range.kind && + ordinal == range.referenceIndex && + start == range.start && + start + inline.plainText.length == range.end && + mapEquals(inline.attributes, range.attributes)) { + offset += inline.plainText.length; + replaced = true; + final attributes = { + ...inline.attributes, + if (!_document.isXmlTopic) 'busymark-writerside-attributes': 'true', + }; + if (attribute == 'href') attributes.remove('busymark-ignored-href'); + if (value.isEmpty && attribute != 'alt') { + attributes.remove(attribute); + } else { + attributes[attribute] = value; + } + return inline.copyWith( + attributes: attributes, + destination: {'src', 'href'}.contains(attribute) + ? value + : inline.destination, + text: attribute == 'alt' ? value : inline.text, + ); + } + if (inline.children.isNotEmpty) { + return inline.copyWith(children: inline.children.map(replace).toList()); + } + offset += inline.plainText.length; + return inline; + } + + final inlines = block.inlines.map(replace).toList(); + if (!replaced) return false; + if (block.attributes.containsKey('busymark-native-source-map')) { + return _publishMappedNativeReference( + block, + range, + attribute, + value, + inlines, + ); + } + _replaceBlock( + blockId, + (b) => b.copyWith( + inlines: inlines, + dirty: true, + rawSource: patchedSource ?? b.rawSource, + attributes: { + ...b.attributes, + if (patchedSource != null) + 'busymark-property-edits': + b.attributes['busymark-property-edits'] ?? '{}', + }, + ), + ); + return true; + } + + /// A property edit in transformed list content must publish its mapped span, + /// not serialize the containing loose list or XML island. Reuse the parser + /// and our existing transaction rebind to retain the actual member identities. + bool _publishMappedNativeReference( + BusyBlock block, + BusyInlineStyleRange range, + String attribute, + String value, + List<BusyInline> inlines, + ) { + final source = _document.source; + if (source == null || source != markdown) return false; + final occurrence = + WritersideEditingAdapter.authoredInlineRanges(_document, source, block) + .where( + (e) => + e.range.kind == range.kind && + e.range.start == range.start && + e.range.end == range.end && + e.range.referenceIndex == range.referenceIndex, + ) + .singleOrNull; + if (occurrence == null) return false; + var begin = occurrence.begin; + var end = occurrence.end; + String replacement; + if (range.kind == BusyInlineKind.writersideVariable) { + replacement = '%$value%'; + } else if (block.attributes.containsKey('element') || + occurrence.xmlSource || + range.kind == BusyInlineKind.writersideShortcut) { + final normalized = + block.attributes['busymark-native-inline-source'] ?? block.rawSource; + if (normalized == null || + occurrence.localBegin < 0 || + occurrence.localEnd > normalized.length) { + return false; + } + final raw = normalized.substring( + occurrence.localBegin, + occurrence.localEnd, + ); + final parsed = const WritersideDocumentParser().parseXml( + filePath: '', + source: raw, + ); + final element = parsed.nodes.singleOrNull; + if (!parsed.isWellFormed || element is! WritersideElementNode) { + return false; + } + final attributes = {...element.attributes}; + if (value.isEmpty && attribute != 'alt') { + attributes.remove(attribute); + } else { + attributes[attribute] = value; + } + final changed = const WritersideDocumentSerializer().serialize( + parsed.copyWith(nodes: [element.copyWith(attributes: attributes)]), + ); + if (changed == raw) return true; + // Adding a physical line inside a transformed fragment would require a + // new Markdown prefix with no authored mapping. Reject before staging. + if (value.contains('\n') || value.contains('\r')) return false; + var first = 0; + while (first < raw.length && + first < changed.length && + raw[first] == changed[first]) { + first++; + } + var suffix = 0; + while (suffix < raw.length - first && + suffix < changed.length - first && + raw[raw.length - suffix - 1] == + changed[changed.length - suffix - 1]) { + suffix++; + } + // Removing an attribute must retain its separating newlines (and their + // Markdown prefixes). Narrow deletion to the authored name/value tokens. + if (value.isEmpty && + attribute != 'alt' && + raw.substring(first, raw.length - suffix).contains('\n')) { + while (first < raw.length - suffix && + RegExp(r'\s').hasMatch(raw[first])) { + first++; + } + } + final localStart = occurrence.localBegin + first; + final localStop = occurrence.localBegin + raw.length - suffix; + final mappedStart = markdownProjectedSourceOffset( + block, + localStart, + inline: true, + ); + final mappedStop = markdownProjectedSourceOffset( + block, + localStop, + inline: true, + ); + if (mappedStart == null || mappedStop == null) return false; + // A lexical replacement cannot consume removed Markdown prefixes. Check + // every interior boundary as well as both ends before publication. + for (var i = localStart; i <= localStop; i++) { + if (markdownProjectedSourceOffset(block, i, inline: true) != + mappedStart + i - localStart) { + return false; + } + } + begin = mappedStart; + end = mappedStop; + replacement = changed.substring( + first.clamp(0, changed.length - suffix), + changed.length - suffix, + ); + } else { + final raw = + block.attributes['busymark-native-inline-source'] ?? block.rawSource; + if (raw == null) return false; + final changed = _patchNativeReferenceProperty( + block.copyWith(rawSource: raw), + range, + attribute, + value, + ); + if (changed == null) return false; + var first = 0; + while (first < raw.length && + first < changed.length && + raw[first] == changed[first]) { + first++; + } + var suffix = 0; + while (suffix < raw.length - first && + suffix < changed.length - first && + raw[raw.length - suffix - 1] == + changed[changed.length - suffix - 1]) { + suffix++; + } + final start = markdownProjectedSourceOffset(block, first, inline: true); + final stop = markdownProjectedSourceOffset( + block, + raw.length - suffix, + inline: true, + ); + if (start == null || stop == null) return false; + begin = start; + end = stop; + replacement = changed.substring(first, changed.length - suffix); + } + if (begin < 0 || end < begin || end > source.length) return false; + final nextSource = source.replaceRange(begin, end, replacement); + final previous = _document; + _document = _document.copyWith( + blocks: _replaceInBlocks( + _document.blocks, + block.id, + (b) => b.copyWith(inlines: inlines), + ), + ); + final rebound = _reparseNativeListOwnership(nextSource); + final retained = _flattenBlocks( + rebound.blocks, + ).where((b) => b.id == block.id).singleOrNull; + if (retained == null || + retained.plainText != inlines.map((i) => i.plainText).join()) { + _document = previous; + return false; + } + _document = busyMarkWysiwygBindMarkdownReferences(rebound); notifyListeners(); + return true; } - String blockText(String blockId) { - final block = blockById(blockId); - return block == null ? '' : busyMarkWysiwygEditableText(block); + // Use the established positioned parser to preserve the occurrence, link + // wrapper, reference definition and surrounding formatting. Writerside's + // native attribute syntax permits local image src/alt overrides. Link href + // overrides are ignored by the builder, so change only that native link. + String? _patchNativeReferenceProperty( + BusyBlock block, + BusyInlineStyleRange range, + String attribute, + String? value, + ) { + if (_document.mode != MarkdownMode.writersideMarkdown || + _document.isXmlTopic || + block.attributes.containsKey('element') || + block.rawSource == null || + block.dirty && + !block.attributes.containsKey('busymark-property-edits') || + !{BusyInlineKind.image, BusyInlineKind.link}.contains(range.kind)) { + return null; + } + final raw = block.rawSource!; + final mapped = const MarkdownSourceMapper() + .createInlineParserContext( + documentSource: _document.source ?? markdown, + mode: _document.mode, + ) + .parseMapped(raw); + final peers = busyInlineReferenceRanges( + block.inlines, + ).where((r) => r.kind == range.kind).toList(); + final candidates = + mapped.ranges.entries.where((e) => e.key.kind == range.kind).toList() + ..sort((a, b) => a.value.start.compareTo(b.value.start)); + final ordinal = peers.indexWhere( + (r) => + r.start == range.start && + r.end == range.end && + r.referenceIndex == range.referenceIndex, + ); + if (ordinal < 0 || candidates.length != peers.length) return null; + final positioned = candidates[ordinal].value; + final end = positioned.end; + if (range.kind == BusyInlineKind.link && + attribute == 'href' && + value != null) { + final destination = value + .replaceAll('\\', '\\\\') + .replaceAll('(', '\\(') + .replaceAll(')', '\\)') + .replaceAll(' ', '%20') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); + final labelEnd = positioned.labelEnd; + final labelStart = positioned.labelStart; + if (labelEnd == null || labelStart == null) return null; + var title = ''; + if (positioned.titleStart != null && positioned.titleEnd != null) { + var start = positioned.titleStart! - 1; + while (start > labelEnd + 2 && RegExp(r'\s').hasMatch(raw[start - 1])) { + start--; + } + title = raw.substring(start, positioned.titleEnd! + 1); + } else if (range.attributes['title']?.isNotEmpty == true) { + final original = range.attributes['title']!.replaceAll('"', '"'); + title = ' "$original"'; + } + final label = positioned.isAutolink + ? '[${raw.substring(labelStart, labelEnd)}]' + : raw.substring(positioned.start, labelEnd + 1); + final cleaned = + _patchNativeReferenceProperty(block, range, 'href', null) ?? raw; + return cleaned.replaceRange( + positioned.start, + end, + '$label($destination$title)', + ); + } + final suffix = RegExp( + r'^\{(?:\s*[\w:.-]+\s*=\s*"[^"]*"\s*)+\}', + ).firstMatch(raw.substring(end)); + var attributes = suffix == null + ? '' + : suffix[0]!.substring(1, suffix.end - 1); + final pattern = RegExp( + '(?:^|\\s+)${RegExp.escape(attribute)}\\s*=\\s*"[^"]*"', + ); + final escaped = (value ?? '') + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + final replacement = value == null || value.isEmpty && attribute != 'alt' + ? '' + : '$attribute="$escaped"'; + if (pattern.hasMatch(attributes)) { + attributes = attributes.replaceFirstMapped( + pattern, + (m) => + '${m[0]!.startsWith(' ') && replacement.isNotEmpty ? ' ' : ''}$replacement', + ); + } else if (replacement.isNotEmpty) { + attributes = '${attributes.trimRight()} $replacement'; + } + final ending = attributes.trim().isEmpty ? '' : '{${attributes.trim()}}'; + return raw.replaceRange(end, end + (suffix?.end ?? 0), ending); } String _blockStructurePrefix(BusyBlock block) { @@ -108,6 +1801,20 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (current == null) { return null; } + List<BusyBlock> fieldBlocks(BusyDocument parsed) { + final blocks = parsed.blocks + .where((b) => b.kind != BusyBlockKind.frontMatter && !b.isSourceOnly) + .toList(); + // The source fragment's first parser-owned list represents the existing + // item's owner, which remains in the destination tree. Replace members + // of that owner, rather than nesting an artificial copy inside the item. + if (_isListItemKind(current.kind) && + blocks.firstOrNull?.attributes['busymark-list-form'] == 'markdown') { + return [...blocks.first.children, ...blocks.skip(1)]; + } + return blocks; + } + final structuralPrefix = _blockStructurePrefix(current); final parsed = const MarkdownParser().parse( filePath: _document.filePath, @@ -115,12 +1822,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { mode: _document.mode, validateLocalReferences: false, ); - final parsedBlocks = parsed.busyDocument.blocks - .where( - (block) => - block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, - ) - .toList(growable: false); + final parsedBlocks = fieldBlocks(parsed.busyDocument); List<int>? descendantOwnerPath; if (replacementScope == BusyWysiwygReplacementScope.fieldContent && current.children.isNotEmpty) { @@ -134,12 +1836,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { mode: _document.mode, validateLocalReferences: false, ); - final anchoredBlocks = anchored.busyDocument.blocks - .where( - (block) => - block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, - ) - .toList(growable: false); + final anchoredBlocks = fieldBlocks(anchored.busyDocument); descendantOwnerPath = _blockPathContainingText( anchoredBlocks, ownerMarker, @@ -200,12 +1897,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { mode: _document.mode, validateLocalReferences: false, ); - final markedBlocks = marked.busyDocument.blocks - .where( - (block) => - block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, - ) - .toList(growable: false); + final markedBlocks = fieldBlocks(marked.busyDocument); final located = _blockPathContainingText(markedBlocks, marker); if (located != null) { destinationPath = located.path; @@ -439,6 +2131,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } String? insertDisplayMathAfter(String blockId, {String expression = 'x'}) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -868,6 +2561,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (allowListExit && _isListItemKind(block.kind) && normalizedText.trim().isEmpty) { + if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); } @@ -951,7 +2645,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { void applyBlockCommand(String blockId, BusyWysiwygBlockCommand command) { final block = blockById(blockId); - if (block == null || !busyMarkWysiwygCanApplyBlockCommand(block, command)) { + if (block == null || !canApplyBlockCommand(block, command)) { + return; + } + if (_document.isXmlTopic) { + _replaceBlock(blockId, (b) => _xmlBlockWithCommand(b, command)); return; } if (command == BusyWysiwygBlockCommand.thematicBreak) { @@ -985,8 +2683,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final eligibleIds = ids.where((id) { final block = blockById(id); - return block != null && - busyMarkWysiwygCanApplyBlockCommand(block, command); + return block != null && canApplyBlockCommand(block, command); }).toList(); if (eligibleIds.isEmpty) { return; @@ -996,7 +2693,17 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return; } final idSet = eligibleIds.toSet(); + if (!_isListItemKind(blockKindForCommand(command)) && + _flattenBlocks(_document.blocks).any( + (b) => + b.attributes['busymark-list-form'] == 'markdown' && + WritersideEditingAdapter.sourceAttributes(b).isNotEmpty && + b.children.every((child) => idSet.contains(child.id)), + )) { + return; + } final replaced = _replaceBlocksByIds(_document.blocks, idSet, (block) { + if (_document.isXmlTopic) return _xmlBlockWithCommand(block, command); return _blockWithCommand( block, command, @@ -1011,6 +2718,90 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { notifyListeners(); } + bool canApplyBlockCommand(BusyBlock block, BusyWysiwygBlockCommand command) { + if (!busyMarkWysiwygCanApplyBlockCommand(block, command)) return false; + if (!_isListItemKind(blockKindForCommand(command)) && + _wouldDiscardNativeListOwner(block.id)) { + return false; + } + if (!_document.isXmlTopic) return true; + final tag = switch (blockKindForCommand(command)) { + BusyBlockKind.heading => 'chapter', + BusyBlockKind.codeBlock => 'code-block', + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => 'list', + BusyBlockKind.blockquote => 'quote', + BusyBlockKind.paragraph => 'p', + BusyBlockKind.image => 'img', + _ => '', + }; + final parent = elementPath(block.id).reversed.skip(1).firstOrNull; + if (parent == null || + !WritersideSchema.childElementNames( + parent.attributes['element'] ?? '', + ).contains(tag)) { + return false; + } + if (tag == 'chapter' && + block.inlines.any( + (i) => + i.kind != BusyInlineKind.text && + i.kind != BusyInlineKind.softBreak, + )) { + return false; + } + return true; + } + + BusyBlock _xmlBlockWithCommand( + BusyBlock block, + BusyWysiwygBlockCommand command, + ) { + final kind = blockKindForCommand(command); + if (_isListItemKind(kind)) { + return _newWritersideElement( + 'list', + attributes: { + 'type': kind == BusyBlockKind.orderedListItem + ? 'decimal' + : kind == BusyBlockKind.taskListItem + ? 'checkbox' + : 'bullet', + }, + children: [ + _newWritersideElement('li', children: [block]).copyWith( + kind: kind, + attributes: { + 'element': 'li', + busyMarkWritersideContainerAttribute: 'true', + 'marker': kind == BusyBlockKind.orderedListItem ? '1.' : '-', + }, + ), + ], + ); + } + if (kind == BusyBlockKind.blockquote) { + return _xmlAdmonitionStyle(block, BusyAdmonitionStyle.quote); + } + final attrs = { + ...block.attributes, + 'element': kind == BusyBlockKind.heading + ? 'chapter' + : kind == BusyBlockKind.codeBlock + ? 'code-block' + : 'p', + }..remove(busyMarkWritersideContainerAttribute); + if (kind == BusyBlockKind.heading) { + attrs.addAll({ + busyMarkWritersideContainerAttribute: 'true', + 'title': block.plainText, + 'level': '2', + }); + } + return block.copyWith(kind: kind, attributes: attrs, dirty: true); + } + void applyAdmonitionStyle(String blockId, BusyAdmonitionStyle style) { final block = blockById(blockId); if (block == null || !busyMarkWysiwygCanApplyAdmonitionStyle(block)) { @@ -1020,16 +2811,46 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { blocks: _replaceInBlocks( _document.blocks, blockId, - (block) => _blockWithAdmonitionStyle( - block, - style, - nextId: () => _nextGeneratedBlockId('paragraph'), - ), + (block) => _document.isXmlTopic + ? _xmlAdmonitionStyle(block, style) + : _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); } + BusyBlock _xmlAdmonitionStyle(BusyBlock block, BusyAdmonitionStyle style) => + block.copyWith( + kind: BusyBlockKind.writersideAdmonition, + attributes: { + ...block.attributes, + 'element': style.name, + 'style': style.name, + busyMarkWritersideContainerAttribute: 'true', + busyMarkWritersideAdmonitionAttribute: 'true', + busyMarkWritersideAdmonitionSourceFormAttribute: 'element', + }, + inlines: const [], + children: busyMarkIsWritersideContainer(block) + ? block.children + : [ + if (block.inlines.isNotEmpty) + BusyBlock( + id: _nextGeneratedBlockId('p'), + kind: BusyBlockKind.paragraph, + inlines: block.inlines, + dirty: true, + ), + ...block.children, + ], + dirty: true, + preserveRaw: false, + ); + void applyAdmonitionStyleToBlocks( Iterable<String> blockIds, BusyAdmonitionStyle style, @@ -1047,11 +2868,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { blocks: _replaceBlocksByIds( _document.blocks, ids, - (block) => _blockWithAdmonitionStyle( - block, - style, - nextId: () => _nextGeneratedBlockId('paragraph'), - ), + (block) => _document.isXmlTopic + ? _xmlAdmonitionStyle(block, style) + : _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); @@ -1087,7 +2910,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { attributes: {'src': trimmedSource, 'alt': trimmedAlt}, ), ], - attributes: {...block.attributes, 'src': trimmedSource}, + attributes: { + ...block.attributes, + 'src': trimmedSource, + if (_document.isXmlTopic) ...{'element': 'img', 'alt': trimmedAlt}, + }, dirty: true, ), ); @@ -1101,6 +2928,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String alt, required String fallbackAltText, }) { + if (_document.isXmlTopic && + blockById(blockId)?.kind != BusyBlockKind.paragraph) { + return; + } final trimmedSource = source.trim(); if (trimmedSource.isEmpty) { return; @@ -1178,6 +3009,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String Function(int columnNumber) headerTextForColumn, required String cellText, }) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -1205,6 +3037,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } String? insertRawHtmlBlockAfter(String blockId, String rawSource) { + if (_document.isXmlTopic) return null; if (blockById(blockId) == null) { return null; } @@ -1622,6 +3455,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { void applyCodeBlockLanguage(String blockId, String language) { final trimmedLanguage = language.trim(); + if (_document.isWriterside && + blockById(blockId)?.kind == BusyBlockKind.codeBlock) { + updateWritersideProperty(blockId, 'language', trimmedLanguage); + return; + } _replaceBlock(blockId, (block) { final attributes = {...block.attributes} ..remove('level') @@ -1653,7 +3491,18 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final checked = block.attributes['task'] == 'true'; return block.copyWith( - attributes: {...block.attributes, 'task': '${!checked}'}, + attributes: { + ...block.attributes, + 'task': '${!checked}', + if (block.attributes['element'] == 'li') 'checked': '${!checked}', + if (block.attributes['element'] == 'li' && + block.attributes['busymark-property-edits'] != null) + 'busymark-property-edits': jsonEncode({ + ...(jsonDecode(block.attributes['busymark-property-edits']!) + as Map<String, dynamic>), + 'checked': '${!checked}', + }), + }, dirty: true, ); }), @@ -1663,6 +3512,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { bool indentListItems(Iterable<String> blockIds) { final ids = blockIds.toSet(); + if (ids.any( + (id) => elementPath( + id, + ).any((b) => b.attributes['busymark-list-form'] == 'markdown'), + )) { + return _indentNativeListItems(ids); + } if (ids.isEmpty) { return false; } @@ -1712,6 +3568,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { bool outdentListItems(Iterable<String> blockIds) { final ids = blockIds.toSet(); + if (ids.any( + (id) => elementPath( + id, + ).any((b) => b.attributes['busymark-list-form'] == 'markdown'), + )) { + return _outdentNativeListItems(ids); + } if (ids.isEmpty) { return false; } @@ -1764,6 +3627,155 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return true; } + bool _indentNativeListItems(Set<String> ids) { + var changed = false; + List<BusyBlock> visit(List<BusyBlock> blocks) => [ + for (final block in blocks) + (() { + if (block.isSourceProtected) return block; + var children = visit(block.children); + if (block.attributes['busymark-list-form'] == 'markdown') { + final members = <BusyBlock>[]; + for (final item in children) { + if (!ids.contains(item.id) || + item.isSourceProtected || + members.isEmpty || + !_isListItemKind(item.kind) || + members.last.isSourceProtected) { + members.add(item); + continue; + } + final parent = members.removeLast(); + final last = parent.children.lastOrNull; + final sameList = + last?.attributes['busymark-list-form'] == 'markdown' && + last!.children.firstOrNull?.attributes['ordered'] == + item.attributes['ordered']; + if (sameList && last.isSourceProtected) { + members.addAll([parent, item]); + continue; + } + final nestedMembers = sameList ? last.children : <BusyBlock>[]; + final nested = _numberIndentedListItem( + item.copyWith(dirty: true), + nestedMembers, + ); + final owner = sameList + ? last.copyWith( + children: [...nestedMembers, nested], + dirty: true, + ) + : BusyBlock( + id: _nextGeneratedBlockId('list-indent'), + kind: BusyBlockKind.writersideRawXml, + children: [nested], + attributes: { + 'element': 'list', + busyMarkWritersideContainerAttribute: 'true', + 'busymark-list-form': 'markdown', + 'busymark-native-list-type': + item.attributes['ordered'] == 'true' + ? 'decimal' + : 'bullet', + 'busymark-native-list-start': '1', + }, + dirty: true, + ); + members.add( + parent.copyWith( + children: [ + ...parent.children.take( + parent.children.length - (sameList ? 1 : 0), + ), + owner, + ], + dirty: true, + ), + ); + changed = true; + } + children = members; + } + return listEquals(children, block.children) + ? block + : block.copyWith(children: children, dirty: true); + })(), + ]; + final blocks = visit(_document.blocks); + if (!changed) return false; + _document = _document.copyWith( + blocks: _normalizeOrderedListMarkers(blocks), + ); + notifyListeners(); + return true; + } + + bool _outdentNativeListItems(Set<String> ids) { + // Work on a staged tree so any protected owner rejects the entire action. + var blocks = _document.blocks; + var changed = false; + final selected = _flattenBlocks( + blocks, + ).where((b) => ids.contains(b.id)).toList(); + for (final id in selected.reversed.map((b) => b.id)) { + final path = _actualElementPath(blocks, id); + if (path.length < 4) continue; + final owner = path[path.length - 2]; + final parent = path[path.length - 3]; + final outer = path[path.length - 4]; + if (owner.attributes['busymark-list-form'] != 'markdown' || + !_isListItemKind(parent.kind) || + outer.attributes['busymark-list-form'] != 'markdown') { + continue; + } + if (path.any((b) => b.isSourceProtected)) return false; + final kept = owner.children.where((b) => b.id != id).toList(); + // An empty owner cannot retain authored list properties in native source. + if (kept.isEmpty && + WritersideEditingAdapter.sourceAttributes(owner).isNotEmpty) { + return false; + } + final item = path.last.copyWith(dirty: true); + final updatedParent = parent.copyWith( + children: [ + for (final child in parent.children) + if (child.id != owner.id) + child + else if (kept.isNotEmpty) + owner.copyWith(children: kept, dirty: true), + ], + dirty: true, + ); + blocks = _replaceInBlocks( + blocks, + outer.id, + (b) => b.copyWith( + children: [ + for (final child in b.children) + if (child.id != parent.id) child else ...[updatedParent, item], + ], + dirty: true, + ), + ); + changed = true; + } + if (!changed) return false; + _document = _document.copyWith( + blocks: _normalizeOrderedListMarkers(blocks), + ); + notifyListeners(); + return true; + } + + List<BusyBlock> _actualElementPath(List<BusyBlock> blocks, String id) { + for (final b in blocks) { + if (b.id == id) return [b]; + final path = _actualElementPath(b.children, id); + if (path.isNotEmpty) return [b, ...path]; + } + return []; + } + String? splitBlockAt(String blockId, int offset) => _splitBlockAt(blockId, offset); @@ -1833,6 +3845,18 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (block == null) { return null; } + if (_document.isXmlTopic && + busyMarkIsWritersideContainer(block) && + WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + )) { + final paragraph = _newWritersideElement('p'); + _replaceBlock( + blockId, + (b) => b.copyWith(children: [...b.children, paragraph], dirty: true), + ); + return BusyWysiwygTextSplitResult(blockId: paragraph.id, offset: 0); + } if (block.kind == BusyBlockKind.codeBlock) { final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); @@ -1858,6 +3882,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } if (_isListItemKind(block.kind) && block.plainText.trim().isEmpty) { + if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); } @@ -1909,7 +3934,22 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } BusyWysiwygTextSplitResult? applyBackspaceAtStart(String blockId) { - final blocks = _document.blocks; + final path = elementPath(blockId); + final owner = path.length < 2 ? null : path[path.length - 2]; + final native = owner?.attributes['busymark-list-form'] == 'markdown'; + final blocks = native ? owner!.children : _document.blocks; + void publish(List<BusyBlock> members) { + _document = _document.copyWith( + blocks: native + ? _replaceInBlocks( + _document.blocks, + owner!.id, + (b) => b.copyWith(children: members, dirty: true), + ) + : members, + ); + } + final index = blocks.indexWhere((block) => block.id == blockId); if (index <= 0) { return null; @@ -1942,13 +3982,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { previous.copyWith(inlines: mergedInlines, children: mergedChildren), dirty: true, ); - _document = _document.copyWith( - blocks: [ - ...blocks.take(index - 1), - updatedPrevious, - ...blocks.skip(index + 1), - ], - ); + publish([ + ...blocks.take(index - 1), + updatedPrevious, + ...blocks.skip(index + 1), + ]); notifyListeners(); return BusyWysiwygTextSplitResult( blockId: previous.id, @@ -1963,9 +4001,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { attributes: previous.attributes, dirty: true, ); - _document = _document.copyWith( - blocks: [...blocks.take(index - 1), merged, ...blocks.skip(index + 1)], - ); + publish([...blocks.take(index - 1), merged, ...blocks.skip(index + 1)]); notifyListeners(); return BusyWysiwygTextSplitResult( blockId: merged.id, @@ -1973,6 +4009,14 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } + bool _wouldDiscardNativeListOwner(String blockId) { + final path = elementPath(blockId); + final owner = path.length < 2 ? null : path[path.length - 2]; + return owner?.attributes['busymark-list-form'] == 'markdown' && + owner!.children.length == 1 && + WritersideEditingAdapter.sourceAttributes(owner).isNotEmpty; + } + BusyWysiwygTextSplitResult? deleteTextSelection({ required String firstBlockId, required int firstStartOffset, @@ -2118,6 +4162,57 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (block == null || block.preserveRaw) { return null; } + if (_document.isXmlTopic) { + if (block.kind != BusyBlockKind.paragraph) return null; + bool supportedInline(BusyInline inline) => + inline.kind != BusyInlineKind.math && + (inline.kind != BusyInlineKind.html || + inline.attributes.containsKey('xml-raw')) && + inline.children.every(supportedInline); + bool supportedBlock(BusyBlock block) { + if (block.preserveRaw || block.isSourceProtected) { + final raw = block.rawSource?.trimLeft(); + return raw != null && + (raw.startsWith('<') || block.isSourceOnly && raw.isEmpty); + } + if ({ + BusyBlockKind.table, + BusyBlockKind.math, + BusyBlockKind.htmlBlock, + }.contains(block.kind) || + !busyMarkIsWritersideContainer(block) && + { + BusyBlockKind.heading, + BusyBlockKind.orderedListItem, + BusyBlockKind.unorderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind)) { + return false; + } + return block.inlines.every(supportedInline) && + block.children.every(supportedBlock); + } + + if (blocks.any( + (b) => !supportedBlock(busyMarkWysiwygClipboardBlock(b)), + )) { + return null; + } + for (final styled in blocks) { + final command = switch (styled.completeBlock?.attributes['element']) { + 'procedure' => BusyWritersideInsertCommand.procedure, + 'tabs' => BusyWritersideInsertCommand.tabs, + 'deflist' => BusyWritersideInsertCommand.definitionList, + 'tldr' => BusyWritersideInsertCommand.tldr, + 'include' => BusyWritersideInsertCommand.include, + 'video' => BusyWritersideInsertCommand.video, + _ => null, + }; + if (command != null && !canInsertWriterside(command, blockId)) { + return null; + } + } + } final text = block.plainText; final start = selectionStart.clamp(0, text.length).toInt(); final end = selectionEnd.clamp(start, text.length).toInt(); @@ -2641,7 +4736,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { int selectionStart, int selectionEnd, { String? destination, + Map<String, String> linkAttributes = const {}, }) { + if (_document.isXmlTopic && + blockById(blockId)?.kind != BusyBlockKind.paragraph) { + return; + } if (selectionStart == selectionEnd) { return; } @@ -2655,6 +4755,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { start, end, destination: destination, + linkAttributes: linkAttributes, ), ); } @@ -2670,6 +4771,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } _document = _document.copyWith( blocks: _replaceBlocksByIds(_document.blocks, idSet, (block) { + if (_document.isXmlTopic && block.kind != BusyBlockKind.paragraph) { + return block; + } final length = block.plainText.length; if (length == 0) { return block; @@ -2688,7 +4792,21 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { void _replaceBlock(String blockId, BusyBlock Function(BusyBlock) replace) { _document = _document.copyWith( - blocks: _replaceInBlocks(_document.blocks, blockId, replace), + blocks: _replaceInBlocks(_document.blocks, blockId, (block) { + final next = replace(block); + if (next.attributes['busymark-property-edits'] != null && + next.rawSource == block.rawSource && + next.attributes['busymark-property-edits'] == + block.attributes['busymark-property-edits'] && + (!identical(next.inlines, block.inlines) || + !identical(next.children, block.children) || + next.kind != block.kind)) { + return next.copyWith( + attributes: {...next.attributes}..remove('busymark-property-edits'), + ); + } + return next; + }), ); notifyListeners(); } @@ -2732,7 +4850,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } return [ ...blocks.take(index), - block.copyWith(children: children), + block.copyWith( + children: children, + attributes: {...block.attributes}..remove('busymark-property-edits'), + ), ...blocks.skip(index + 1), ]; } @@ -2786,7 +4907,8 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { continue; } changed = true; - if (block.kind == BusyBlockKind.blockquote && + if ((block.kind == BusyBlockKind.blockquote || + block.attributes['busymark-list-form'] == 'markdown') && block.children.isNotEmpty && children.isEmpty && block.inlines.isEmpty) { @@ -2901,7 +5023,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { children: List.unmodifiable([ for (final child in block.children) _cloneClipboardBlock(child), ]), - attributes: Map.unmodifiable(block.attributes), + attributes: Map.unmodifiable( + {...block.attributes}..remove(busyMarkXmlBindingAttribute), + ), rawSource: block.rawSource, preserveRaw: block.preserveRaw, isSourceOnly: block.isSourceOnly, @@ -4298,6 +6422,7 @@ BusyBlock _blockWithAdmonitionStyle( ..['style'] = style.name; if (semanticElement) { attributes['element'] = style.name; + if (block.attributes['id'] case final id?) attributes['id'] = id; } else { attributes.remove('element'); } @@ -4452,6 +6577,7 @@ BusyBlock _blockWithInlineCommand( int selectionStart, int selectionEnd, { String? destination, + Map<String, String> linkAttributes = const {}, }) { final text = block.plainText; final safeStart = selectionStart.clamp(0, text.length).toInt(); @@ -4477,6 +6603,9 @@ BusyBlock _blockWithInlineCommand( start: safeStart, end: safeEnd, kind: inlineKind, + attributes: command == BusyWysiwygInlineCommand.link + ? linkAttributes + : const {}, destination: command == BusyWysiwygInlineCommand.link ? destination : null, @@ -4583,7 +6712,11 @@ bool _isTypingInlineKind(BusyInlineKind kind) { BusyInlineKind.emphasis || BusyInlineKind.underline || BusyInlineKind.strikethrough || - BusyInlineKind.code => true, + BusyInlineKind.code || + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => true, _ => false, }; } @@ -4620,7 +6753,156 @@ String _incrementOrderedMarker(String? marker, int offset) { return '${value + offset}$suffix'; } +// The authored Markdown parser retains interpolation tokens for the preview +// resolver. Bind those tokens to the editor's reference representation on +// loading, without changing source, dirty flags, or the preview projection. +BusyDocument busyMarkWysiwygBindMarkdownReferences(BusyDocument document) { + if (document.mode != MarkdownMode.writersideMarkdown || document.isXmlTopic) { + return document; + } + bool ignores(Map<String, String> attributes, bool inherited) => + switch (attributes['ignore-vars']) { + 'true' => true, + 'false' => false, + _ => inherited, + }; + List<BusyInline> bind(List<BusyInline> inlines, bool inherited) { + final result = <BusyInline>[]; + for (final inline in inlines) { + final ignore = ignores(inline.attributes, inherited); + if (ignore || + { + BusyInlineKind.code, + BusyInlineKind.math, + BusyInlineKind.html, + BusyInlineKind.writersideVariable, + }.contains(inline.kind)) { + result.add(inline); + } else if (inline.kind == BusyInlineKind.text) { + final parts = const WritersideEditingAdapter().textInlines(inline.text); + if (!parts.any((p) => p.kind == BusyInlineKind.writersideVariable)) { + result.add(inline); + } else { + result.addAll( + parts.map( + (part) => part.kind == BusyInlineKind.writersideVariable + ? part.copyWith( + attributes: {...inline.attributes, ...part.attributes}, + ) + : inline.copyWith(text: part.text), + ), + ); + } + } else if (inline.children.isNotEmpty) { + final children = bind(inline.children, ignore); + result.add( + listEquals(children, inline.children) + ? inline + : inline.copyWith(children: children), + ); + } else { + result.add(inline); + } + } + return result; + } + + BusyBlock visit(BusyBlock block, bool inherited) { + if (block.isSourceProtected || block.preserveRaw || block.isSourceOnly) { + return block; + } + final ignore = ignores(block.attributes, inherited); + final inlines = + { + BusyBlockKind.paragraph, + BusyBlockKind.heading, + BusyBlockKind.unorderedListItem, + BusyBlockKind.orderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind) && + !WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + ) + ? bind(block.inlines, ignore) + : block.inlines; + final children = block.children.map((b) => visit(b, ignore)).toList(); + if (listEquals(inlines, block.inlines) && + listEquals(children, block.children)) { + return block; + } + return block.copyWith( + inlines: inlines, + children: children, + dirty: block.dirty, + ); + } + + final blocks = document.blocks.map((b) => visit(b, false)).toList(); + return listEquals(blocks, document.blocks) + ? document + : document.copyWith(blocks: blocks); +} + BusyDocument _ensureEditableDocument(BusyDocument document) { + if (document.isXmlTopic) return document; + if (document.isWriterside) { + // Split only an existing parser-owned container when an explicit editing + // operation promotes a member to ordinary content. Never join neighbors + // merely because their kinds happen to agree. + List<BusyBlock> owners(List<BusyBlock> blocks) { + final result = <BusyBlock>[]; + for (final block in blocks) { + final children = owners(block.children); + if (block.attributes['busymark-list-form'] != 'markdown' || + children.every((b) => _isListItemKind(b.kind))) { + result.add( + listEquals(children, block.children) + ? block + : block.copyWith(children: children), + ); + continue; + } + var first = true; + final run = <BusyBlock>[]; + void flush() { + if (run.isEmpty) return; + result.add( + BusyBlock( + id: first ? block.id : 'list-part-${run.first.id}', + kind: block.kind, + children: [...run], + attributes: first + ? block.attributes + : const { + 'element': 'list', + busyMarkWritersideContainerAttribute: 'true', + 'busymark-list-form': 'markdown', + }, + dirty: true, + ), + ); + run.clear(); + first = false; + } + + for (final child in children) { + if (_isListItemKind(child.kind)) { + run.add(child); + } else { + flush(); + result.add(child); + } + } + flush(); + } + return result; + } + + final normalized = owners(document.blocks); + if (!listEquals(normalized, document.blocks)) { + document = document.copyWith(blocks: normalized); + } + } // Commit source-form changes before notifying listeners, within the edit's // existing transaction. Include dirty imported fragments as well as newly // reconstructed prose; untouched source-backed blocks retain their source. @@ -4894,7 +7176,11 @@ bool _canMergeInlineStyles(BusyInline left, BusyInline right) { BusyInlineKind.emphasis || BusyInlineKind.underline || BusyInlineKind.strikethrough || - BusyInlineKind.link => true, + BusyInlineKind.link || + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => true, _ => false, }; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 5b81f6d4..e18c682b 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,8 +1,13 @@ +import '../../writerside/writerside_effective_properties.dart'; +import '../../writerside/writerside_model.dart'; +import '../link_destination.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; +import 'package:crypto/crypto.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -57,6 +62,12 @@ import 'wysiwyg_document_controller.dart'; import 'wysiwyg_inline_controller.dart'; import 'wysiwyg_session_state.dart'; import 'wysiwyg_toolbar.dart'; +import 'writerside_editing_adapter.dart'; +import 'writerside_inspector_target.dart'; +import 'writerside_properties.dart'; +import 'writerside_property_session.dart'; +import '../../writerside/writerside_project.dart'; +import '../../writerside/writerside_source_loader.dart'; typedef BusyMarkWysiwygSourceChanged = void Function(String filePath, String source); @@ -93,8 +104,15 @@ class BusyMarkWysiwygEditor extends StatefulWidget { required this.document, required this.onSourceChanged, this.documentId, + this.writersideProjectIndex, + this.writersideModuleId, + this.writersideSettings = const WritersideSettingsConfig(), + this.resolvedWritersideProperties = const {}, this.initialSessionState = const WysiwygEditorSessionState(), this.onSessionChanged, + this.onInspectElement, + this.onPropertiesRequested, + this.onVideoPropertiesRequested, this.onTransactionalSourceChanged, this.onSpellingSourceChanged, this.contentRevision, @@ -113,6 +131,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.clipboardInsertionRegistry, this.onClipboardCaptured, this.onAssetSaveRequired, + this.onChooseLink, this.allowRemoteImages = false, this.onRemoteImageBlocked, this.toolbarPlacement = EditorToolbarPlacement.topLeft, @@ -136,9 +155,26 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.onMathDiagnostic, }); + /// A revision refresh preserves the restored caret and lets the workspace + /// retain an explicitly inspected ancestor without rebinding pending drafts. + final void Function( + BusyDocument document, + String? blockId, + BusyInlineStyleRange? inline, + bool explicit, + bool multiple, { + bool revisionRefresh, + })? + onInspectElement; + final VoidCallback? onPropertiesRequested; + final void Function(BusyDocument, String)? onVideoPropertiesRequested; final BusyDocument document; final BusyMarkWysiwygSourceChanged onSourceChanged; final String? documentId; + final WritersideSettingsConfig writersideSettings; + final Map<int, Map<String, String>> resolvedWritersideProperties; + final WritersideProjectIndex? writersideProjectIndex; + final String? writersideModuleId; final WysiwygEditorSessionState initialSessionState; final BusyMarkWysiwygSessionChanged? onSessionChanged; final BusyMarkWysiwygTransactionalSourceChanged? onTransactionalSourceChanged; @@ -159,6 +195,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final BusyMarkClipboardInsertionRegistry? clipboardInsertionRegistry; final ValueChanged<BusyMarkClipboardCapture>? onClipboardCaptured; final VoidCallback? onAssetSaveRequired; + final Future<LinkDestination?> Function(String initial)? onChooseLink; final bool allowRemoteImages; final VoidCallback? onRemoteImageBlocked; final EditorToolbarPlacement toolbarPlacement; @@ -210,6 +247,45 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { bool _hasReportedVisibleHeading = false; late final FocusNode _selectionFocusNode; String? _activeBlockId; + String? _propertiesTargetId; + BusyInlineStyleRange? _propertiesInlineTarget; + String? _videoDraftTarget; + bool _propertiesVisible = false; + bool _propertiesExplicitlyOpened = false; + final _propertySession = WritersidePropertySession(); + String? _lastInspectorSelection; + bool _inspectorRestorePending = false; + final _propertyHoverViewportKey = GlobalKey(); + final _propertyHoverEntries = + < + String, + ({ + BusyBlock block, + BusyBlock hovered, + int depth, + int generation, + int? revision, + TextDirection direction, + TextDirection ambientDirection, + RenderBox anchor, + }) + >{}; + ({ + BusyBlock block, + BusyBlock hovered, + int depth, + int generation, + int? revision, + TextDirection direction, + TextDirection ambientDirection, + RenderBox anchor, + })? + _propertyHoverCapture; + String? _propertyHoverScope; + bool _propertyMarginHovered = false; + BusyDocument? _propertiesSnapshot; + String? _propertiesSnapshotTarget; + bool _topicPropertiesSelected = false; String? _activeCellId; final _collapsibleExpansion = <String, bool>{}; int _undoGroupSequence = 0; @@ -235,6 +311,8 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { var _toolbarVisible = true; var _documentGeneration = 0; bool _sessionReportScheduled = false; + String? _sessionSource; + String? _sessionSourceSignature; double? _viewportHeight; late final _WysiwygClipboardInsertionTarget _historyInsertionTarget; _PendingTextFocus? _pendingTextFocus; @@ -537,9 +615,18 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { _listenForDroppedAssets(); } final oldDocumentId = oldWidget.documentId ?? oldWidget.document.filePath; - final fileChanged = oldDocumentId != _documentId; + final fileChanged = + oldDocumentId != _documentId || + oldWidget.document.isXmlTopic != widget.document.isXmlTopic || + oldWidget.document.mode != widget.document.mode; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { + // Pending property text belongs to the current authored element. An + // external replacement can reuse a projection ID for a different node. + // Recreate the panel entries rather than retargeting their drafts. + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; _releaseSelectionDrag(rebuild: false); _pendingTextFocus = null; _hasReportedVisibleHeading = false; @@ -567,6 +654,9 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { _scheduleHeadingScroll(); _scheduleSearchScroll(); } + if (oldWidget.contentRevision != widget.contentRevision) { + _scheduleSessionReport(); + } } @override @@ -595,11 +685,16 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { for (final focusNode in _tableCellFocusNodes.values) { focusNode.dispose(); } + _propertySession.dispose(); _selectionFocusNode.dispose(); super.dispose(); } void _resetPerDocumentState() { + _lastInspectorSelection = null; + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; for (final controller in _textControllers.values) { controller.dispose(); } @@ -691,6 +786,8 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); + final authoringGuard = _captureDialogTarget(); + final authoringBlockId = _activeBlockId; final commandRegistry = BusyMarkCommandRegistryScope.maybeOf(context) ?? BusyMarkCommandCatalog.metadata; @@ -842,7 +939,18 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ? constraints.maxHeight : null; final documentLayout = _documentLayout; - return Stack( + final showProperties = + _documentController.document.isWriterside && + _propertiesVisible && + (constraints.maxWidth >= 900 || + _propertiesExplicitlyOpened) && + widget.onInspectElement == null; + final paneWidth = showProperties && constraints.maxWidth >= 900 + ? 300.0 + : 0.0; + const paneHeight = 0.0; + final editor = Stack( + key: _propertyHoverViewportKey, children: [ Positioned.fill( child: Listener( @@ -879,6 +987,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ), ), ), + if (_propertyHoverControl() case final control?) control, if (_documentSelection case final selection? when _selectionPointer == null && _documentController @@ -909,50 +1018,102 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { visible: _toolbarVisible, maxWidth: math.max( 0, - constraints.maxWidth - BusyMarkSpacing.lg, + constraints.maxWidth - paneWidth - BusyMarkSpacing.lg, ), maxHeight: math.max( 0, - constraints.maxHeight - BusyMarkSpacing.lg, + constraints.maxHeight - paneHeight - BusyMarkSpacing.lg, ), onToggle: () => setState(() => _toolbarVisible = !_toolbarVisible), onPlacementChanged: widget.onToolbarPlacementChanged, onDirectionChanged: widget.onToolbarDirectionChanged, child: BusyMarkWysiwygToolbar( + onMenuCanceled: () { + if (_isDialogTargetCurrent(authoringGuard)) { + _restoreEditingFocusAfterFrame(); + } + }, axis: widget.toolbarDirection._axis, alignEnd: _toolbarAlignedEnd( widget.toolbarPlacement, widget.toolbarDirection, ), onBlockCommand: _applyToolbarBlockCommand, - isBlockCommandEnabled: _canApplyBlockCommand, - onAdmonitionCommand: (style) => _applyToolbarCommand( - () => _applyAdmonitionCommand(style), + onWritersideInsert: (command) => unawaited( + _applyWritersideInsert( + command, + authoringGuard, + authoringBlockId, + ), ), + isWritersideInsertEnabled: (command) => + _documentController.canInsertWriterside( + command, + authoringBlockId, + ) && + (!{ + BusyWritersideInsertCommand.include, + BusyWritersideInsertCommand.variable, + }.contains(command) || + widget.writersideProjectIndex != null), + isBlockCommandEnabled: _canApplyBlockCommand, + onAdmonitionCommand: (style) { + if (!_isDialogTargetCurrent(authoringGuard)) return; + _applyToolbarCommand( + () => _applyAdmonitionCommand(style), + ); + }, admonitionCommandsEnabled: _canApplyAdmonitionCommand(), inlineCommandsEnabled: _hasInlineCommandTarget, + semanticCommandsEnabled: _hasSemanticCommandTarget, lineBreakCommandsEnabled: _activeCellId == null, admonitionsEnabled: - _documentController.document.mode == - MarkdownMode.writersideMarkdown, - onInlineCommand: (command) => _applyToolbarCommand( - () => _applyInlineCommand(command), - ), + _documentController.document.isWriterside, + onInlineCommand: (command) { + if (busyMarkIsSemanticInline( + inlineKindForCommand(command), + ) && + !_isDialogTargetCurrent(authoringGuard)) { + return; + } + if (busyMarkIsSemanticInline( + inlineKindForCommand(command), + ) && + _selectedTextRanges().isEmpty && + _activeTextTarget() + ?.controller + .selection + .isCollapsed == + true) { + unawaited(_insertSemanticText(command)); + } else { + _applyToolbarCommand( + () => _applyInlineCommand(command), + ); + } + }, onLinkCommand: () => _applyAsyncToolbarCommand(_applyLinkCommand), - onInlineMathCommand: () => - _applyToolbarCommand(_applyInlineMathCommand), - onDisplayMathCommand: () => - _applyToolbarCommand(_applyDisplayMathCommand), + onInlineMathCommand: + _documentController.document.isXmlTopic + ? null + : () => _applyToolbarCommand(_applyInlineMathCommand), + onDisplayMathCommand: + _documentController.document.isXmlTopic + ? null + : () => + _applyToolbarCommand(_applyDisplayMathCommand), onImageCommand: () => _applyAsyncToolbarCommand(_applyImageCommand), onInlineImageCommand: () => _applyAsyncToolbarCommand(_applyInlineImageCommand), - onTableCommand: () => - _applyAsyncToolbarCommand(_applyTableCommand), - onHtmlCommand: () => - _applyAsyncToolbarCommand(_applyHtmlCommand), + onTableCommand: _documentController.document.isXmlTopic + ? null + : () => _applyAsyncToolbarCommand(_applyTableCommand), + onHtmlCommand: _documentController.document.isXmlTopic + ? null + : () => _applyAsyncToolbarCommand(_applyHtmlCommand), onIndentCommand: () => _applyToolbarCommand(_applyIndentCommand), onOutdentCommand: () => @@ -967,6 +1128,27 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ), ], ); + if (!showProperties) return editor; + final panel = _writersidePropertiesPanel(); + return paneWidth > 0 + ? Row( + children: [ + Expanded(child: editor), + SizedBox(width: paneWidth, child: panel), + ], + ) + : Stack( + children: [ + Positioned.fill(child: editor), + PositionedDirectional( + top: 0, + bottom: 0, + end: 0, + width: math.min(300, constraints.maxWidth), + child: Material(elevation: 8, child: panel), + ), + ], + ); }, ), ), @@ -974,12 +1156,707 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ); } - BusyMarkDocumentLayoutSpec get _documentLayout => - widget.documentLayout ?? - BusyMarkDocumentLayoutSpec.standalone.withEditingToolbar( - placement: widget.toolbarPlacement, - direction: widget.toolbarDirection, + Widget _writersidePropertiesPanel() { + if (!_propertySession.hasPending) { + final previous = _propertiesSnapshot; + final currentDocument = _documentController.document; + if (previous != null && + previous.source != currentDocument.source && + _propertiesSnapshotTarget == _propertiesTargetId && + _propertiesTargetId != null) { + final captured = WritersideInspectorTarget.fromSelection( + bufferId: _documentId, + revision: 0, + source: previous.source ?? '', + document: previous, + blockId: _propertiesTargetId, + inline: _propertiesInlineTarget, + ); + final refreshed = captured.refreshUnchangedElement( + revision: _documentGeneration, + source: _documentController.markdown, + document: currentDocument, + ); + _propertiesTargetId = refreshed?.block?.id; + _propertiesInlineTarget = refreshed?.inline; + } + _propertiesSnapshot = _documentController.document; + _propertiesSnapshotTarget = _propertiesTargetId; + } + final document = _propertiesSnapshot ?? _documentController.document; + final snapshot = BusyMarkWysiwygDocumentController(document: document); + final inspected = _topicPropertiesSelected + ? document.blocks + .where( + (b) => + b.attributes['element'] == 'topic' || + b.kind == BusyBlockKind.heading && + b.attributes['level'] == '1' && + !b.attributes.containsKey('element'), + ) + .firstOrNull + ?.id + : _propertiesSnapshotTarget; + final path = inspected == null + ? const <BusyBlock>[] + : snapshot.elementPath(inspected); + final target = inspected == null ? null : snapshot.blockById(inspected); + snapshot.dispose(); + final guard = _captureDialogTarget(); + final id = _documentId; + bool current() => + id == _documentId && + _isDialogTargetCurrent(guard) && + (target == null || + identical(_documentController.blockById(target.id), target)); + bool commit(bool Function() mutate) { + if (!current()) { + return _propertySession.reject(WritersidePropertyRejection.stale); + } + if (DocumentReadOnlyScope.of(context)) { + return _propertySession.reject(WritersidePropertyRejection.readOnly); + } + final before = _historySnapshot(); + final previous = _documentController.markdown; + if (!mutate()) return false; + final oldInline = _propertiesInlineTarget; + if (oldInline != null && target != null) { + final updated = _documentController.blockById(target.id); + _propertiesInlineTarget = updated == null + ? null + : busyInlineReferenceRanges(updated.inlines) + .where( + (r) => + r.kind == oldInline.kind && + r.referenceIndex == oldInline.referenceIndex, + ) + .singleOrNull; + } + if (_documentController.markdown == previous) return true; + _recordUndoSnapshot(before); + _emitMarkdown(); + return true; + } + + final inlineTarget = _propertiesInlineTarget; + return BusyMarkWritersideProperties( + key: ValueKey('writerside-properties-$_documentId'), + documentIdentity: _documentId, + effectiveProperties: WritersideEffectiveProperties( + settings: widget.writersideSettings, + ), + session: _propertySession, + onUndo: () { + if (widget.useExternalUndoHistory) { + widget.onUndo?.call(); + } else { + _undoEditorChange(); + } + }, + onRedo: () { + if (widget.useExternalUndoHistory) { + widget.onRedo?.call(); + } else { + _redoEditorChange(); + } + }, + onChooseCodeSource: () async { + final guard = _captureDialogTarget(); + final module = widget + .writersideProjectIndex + ?.modulesById[widget.writersideModuleId]; + final root = + module?.rootPath ?? widget.writersideRoot ?? widget.workspaceRoot; + if (root == null) { + _propertySession.reject(WritersidePropertyRejection.unresolved); + return null; + } + final selected = await openFile( + initialDirectory: module?.config.snippetsDir == null + ? p.dirname(document.filePath) + : p.join(root, module!.config.snippetsDir!), + confirmButtonText: context.l10n.open, + ); + if (!_isDialogTargetCurrent(guard) || selected == null) return null; + final reference = await const WritersideSourceLoader().referenceForFile( + selectedPath: selected.path, + documentPath: document.filePath, + workspaceRoot: root, + snippetsDirectory: module?.config.snippetsDir, + ); + if (!_isDialogTargetCurrent(guard)) return null; + if (reference == null) { + _propertySession.reject(WritersidePropertyRejection.unresolved); + } + return reference; + }, + document: document, + path: path, + target: target, + topicSelected: _topicPropertiesSelected, + onClose: () => setState(() { + _propertiesVisible = false; + _videoDraftTarget = null; + }), + onTargetSelected: (value) => setState(() { + _topicPropertiesSelected = value == '@topic'; + _propertiesTargetId = _topicPropertiesSelected ? null : value; + _propertiesInlineTarget = null; + _videoDraftTarget = null; + }), + onProperty: (name, value) => commit(() { + if (target == null) return false; + if (name == 'admonition-type') { + final style = busyAdmonitionStyleFromName(value); + if (style == null || + !busyMarkWysiwygCanApplyAdmonitionStyle(target)) { + return false; + } + _documentController.applyAdmonitionStyle(target.id, style); + return true; + } + return _documentController.updateWritersideProperty( + target.id, + name, + value, + ); + }), + onHeadingText: (value) => commit(() { + if (target == null || + value.trim().isEmpty || + target.plainText == value) { + return false; + } + _documentController.updateBlockText(target.id, value); + return true; + }), + onNavigation: (present, forElements, depth) => commit( + () => _documentController.setTopicNavigation( + present: present, + forElements: forElements, + depth: depth, + ), + ), + onTopicProperty: (name, value) => commit( + () => name == '@navigation' + ? value == 'default' + ? _documentController.resetTopicNavigation() + : value == 'none' + ? _documentController.updateTopicNavigation('for', 'none') + : _documentController.resetTopicNavigation() && + _documentController.updateTopicNavigation('for', '') + : name == 'switcher-label' + ? _documentController.updateTopicSwitcherLabel(value) + : _documentController.updateTopicNavigation(name, value), + ), + onManageItem: (itemId, direction, remove) => commit( + () => + target != null && + _documentController.manageWritersideItem( + target.id, + itemId: itemId, + direction: direction, + remove: remove, + title: target.attributes['element'] == 'tabs' + ? context.l10n.tab + : context.l10n.wsTerm, + ), + ), + inlineTarget: inlineTarget, + onInlineProperty: (name, value) => commit( + () => + target != null && + inlineTarget != null && + _documentController.updateInlineReference( + target.id, + inlineTarget, + name, + value, + ), + ), + videoDraft: _videoDraftTarget != null, + onInsertVideo: (value) => commit(() { + final draft = _videoDraftTarget; + if (draft == null || + !value.trim().isNotEmpty || + !_documentController.canInsertWriterside( + BusyWritersideInsertCommand.video, + draft, + )) { + return false; + } + final inserted = _documentController.insertWriterside( + BusyWritersideInsertCommand.video, + draft, + title: context.l10n.video, + attributes: {'src': value.trim()}, + ); + if (inserted == null) return false; + _videoDraftTarget = null; + _propertiesTargetId = inserted; + return true; + }), + ); + } + + Future<void> _applyWritersideInsert( + BusyWritersideInsertCommand command, + _WysiwygDialogTarget guard, + String? blockId, + ) async { + if (!_isDialogTargetCurrent(guard) || + !_documentController.canInsertWriterside(command, blockId)) { + return; + } + final target = _documentController.blockById(blockId!)!; + final capturedId = _documentId; + final capturedSource = _documentController.markdown; + final selection = _textControllers[blockId]?.selection; + final index = widget.writersideProjectIndex; + final moduleId = widget.writersideModuleId; + bool current() => + capturedId == _documentId && + _isDialogTargetCurrent(guard) && + identical(widget.writersideProjectIndex, index) && + widget.writersideModuleId == moduleId && + _documentController.markdown == capturedSource && + identical(_documentController.blockById(blockId), target); + var attributes = <String, String>{}; + if (command == BusyWritersideInsertCommand.video) { + setState(() { + _videoDraftTarget = blockId; + _propertiesVisible = true; + _propertiesExplicitlyOpened = true; + _topicPropertiesSelected = false; + _propertiesTargetId = blockId; + }); + widget.onVideoPropertiesRequested?.call( + _documentController.document, + blockId, ); + _restoreEditingFocusAfterFrame(); + return; + } + if (command == BusyWritersideInsertCommand.include) { + if (index == null || moduleId == null) return; + final symbols = index.symbols + .where( + (s) => + s.moduleId == moduleId && + (s.kind == WritersideSymbolKind.element || + s.kind == WritersideSymbolKind.snippet) && + s.filePath != _documentController.document.filePath && + index.modulesById[moduleId]?.topics + .where((t) => t.filePath == s.filePath) + .firstOrNull + ?.document + .elementById(s.name) + ?.name != + 'topic', + ) + .toList(); + final files = {for (final s in symbols) s.filePath}.toList()..sort(); + final source = await showBusyMarkMenu<String>( + context: context, + focusFirst: true, + items: [ + for (final file in files) + BusyMarkPopupMenuItem(value: file, label: p.basename(file)), + ], + ); + if (source == null || !current() || !mounted) return; + final candidates = symbols.where((s) => s.filePath == source).toList(); + final element = await showBusyMarkMenu<WritersideSymbol>( + context: context, + focusFirst: true, + items: [ + for (final candidate in candidates) + BusyMarkPopupMenuItem(value: candidate, label: candidate.name), + ], + ); + if (element == null || !current()) return; + final topic = index.modulesById[moduleId]?.topics + .where((t) => t.filePath == source) + .firstOrNull; + if (topic == null) return; + attributes = {'from': topic.fileName, 'element-id': element.name}; + } else if (command == BusyWritersideInsertCommand.variable) { + if (index == null || moduleId == null) return; + final names = _documentController.availableVariableReferences( + index, + moduleId, + blockId, + ); + final name = await showBusyMarkMenu<String>( + context: context, + focusFirst: true, + items: [ + for (final name in names) + BusyMarkPopupMenuItem(value: name, label: name), + ], + ); + if (name == null || !current()) return; + final before = _historySnapshot(); + _documentController.insertVariableReference( + blockId, + name, + selection?.isValid == true ? selection!.start : target.plainText.length, + selection?.isValid == true ? selection!.end : target.plainText.length, + ); + _recordUndoSnapshot(before); + _emitMarkdown(); + _restoreEditingFocusAfterFrame(); + return; + } + if (!current() || !mounted) return; + final before = _historySnapshot(); + final inserted = _documentController.insertWriterside( + command, + blockId, + title: busyMarkWritersideInsertLabel(context, command), + attributes: attributes, + ); + if (inserted == null) return; + _recordUndoSnapshot(before); + _emitMarkdown(); + setState(() { + _propertiesVisible = true; + _propertiesExplicitlyOpened = true; + _topicPropertiesSelected = false; + _propertiesTargetId = inserted; + }); + final block = _documentController.blockById(inserted); + final content = block == null + ? null + : _firstEditableBlockIn(_editorRenderEntries(block.children)); + if (content != null) { + _focusBlockAfterFrame(content.id, offset: 0); + } else { + _restoreEditingFocusAfterFrame(); + } + } + + Map<String, String> _publishedProperties(BusyBlock block) { + final resolver = WritersideEffectiveProperties( + settings: widget.writersideSettings, + resolvedValues: + widget.resolvedWritersideProperties[block.sourceSpan?.startOffset] ?? + const {}, + ); + final tag = WritersideEditingAdapter.tagForBlock(block); + final list = _documentController + .elementPath(block.id) + .where((b) => b.attributes['element'] == 'deflist' && b.id != block.id) + .lastOrNull; + final parent = list == null + ? null + : resolver.defaultsFor('deflist', list.attributes); + return { + ...block.attributes, + 'busymark-effective-collapsible': + resolver + .resolve( + tag, + 'collapsible', + block.attributes, + definitionList: parent, + ) + .value ?? + '', + 'busymark-effective-default-state': + resolver + .resolve( + tag, + 'default-state', + block.attributes, + definitionList: parent, + ) + .value ?? + '', + }; + } + + Widget _buildStructuredEntry( + BuildContext context, + _EditorRenderEntry entry, { + required BusyMarkDocumentLayoutSpec documentLayout, + required Set<String> selectedBlockIds, + required Map<String, BusyMarkWysiwygSelectionRange> + selectionRangesByBlockId, + }) { + final block = entry.block; + final tag = block.attributes['element']; + final title = WritersideEditingAdapter.titleElements.contains(tag); + final published = _publishedProperties(block); + final collapsible = + tag != 'deflist' && busyMarkWritersideIsCollapsible(published); + final expanded = + !collapsible || + (_collapsibleExpansion[block.id] ?? + busyMarkWritersideInitiallyExpanded(published)); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _propertyHoverRegion( + block, + Row( + children: [ + if (collapsible) + BusyMarkHeaderIconButton( + icon: expanded + ? BusyMarkGlyphs.downArrow + : BusyMarkGlyphs.upArrow, + tooltip: expanded + ? context.l10n.collapseKind( + writersideElementLabel( + context, + block, + _documentController.document, + ), + ) + : context.l10n.expandKind( + writersideElementLabel( + context, + block, + _documentController.document, + ), + ), + onPressed: () => setState( + () => _collapsibleExpansion[block.id] = !expanded, + ), + ), + Expanded( + child: title + ? _buildEditableBlockField( + block, + first: false, + listRunEnd: false, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ) + : Text( + writersideElementLabel( + context, + block, + _documentController.document, + ), + style: busyMarkSectionHeaderStyle(context), + ), + ), + ], + ), + scope: 'header', + ), + if (expanded) + Padding( + padding: EdgeInsetsDirectional.only( + start: tag == 'topic' || tag == 'chapter' + ? 0 + : BusyMarkSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child + in entry.children ?? const <_EditorRenderEntry>[]) + _buildRenderEntry( + context, + child, + documentLayout: documentLayout, + applyDocumentFrame: false, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ), + ], + ), + ), + ], + ); + } + + BusyMarkDocumentLayoutSpec get _documentLayout { + final base = + widget.documentLayout ?? + BusyMarkDocumentLayoutSpec.standalone.withEditingToolbar( + placement: widget.toolbarPlacement, + direction: widget.toolbarDirection, + ); + if (!_documentController.document.isWriterside) return base; + // Reserve a stable margin. Pointer entry never changes content width. + return BusyMarkDocumentLayoutSpec( + minimumInsets: base.minimumInsets.copyWith( + left: base.minimumInsets.left + BusyMarkSizes.iconButton, + right: base.minimumInsets.right + BusyMarkSizes.iconButton, + ), + maxContentWidth: base.maxContentWidth, + ); + } + + Widget _propertyHoverRegion( + BusyBlock block, + Widget child, { + String scope = 'block', + }) { + if (!_documentController.document.isWriterside) return child; + final token = '${block.id}:$scope'; + return Builder( + builder: (regionContext) { + void updateHover(PointerEvent _) { + final previous = _propertyHoverEntries[token]; + final anchor = regionContext.findRenderObject(); + final ambientDirection = Directionality.of(regionContext); + if (identical(previous?.hovered, block) && + identical(previous?.anchor, anchor) && + previous?.ambientDirection == ambientDirection && + previous?.generation == _documentGeneration && + previous?.revision == widget.contentRevision) { + return; + } + if (anchor is! RenderBox || !anchor.attached) return; + final path = _documentController.elementPath(block.id); + final parent = path.length < 2 ? null : path[path.length - 2]; + final inspected = + block.kind == BusyBlockKind.paragraph && + parent?.attributes['element'] == 'li' && + parent!.children + .where((b) => !b.isSourceOnly) + .firstOrNull + ?.id == + block.id + ? parent + : block; + _propertyHoverEntries[token] = ( + block: inspected, + hovered: block, + ambientDirection: ambientDirection, + anchor: anchor, + depth: inspected.id == block.id ? path.length : path.length - 1, + generation: _documentGeneration, + revision: widget.contentRevision, + direction: busyMarkWysiwygBlockTextDirection( + block, + fallback: Directionality.of(context), + ), + ); + final active = _propertyHoverEntries.entries + .reduce((a, b) => a.value.depth > b.value.depth ? a : b) + .key; + if (_propertyHoverScope != active || + previous?.ambientDirection != ambientDirection || + !identical(previous?.anchor, anchor) || + previous?.generation != _documentGeneration || + previous?.revision != widget.contentRevision || + !identical(previous?.block, inspected)) { + setState(() => _propertyHoverScope = active); + } + } + + return MouseRegion( + onEnter: updateHover, + onHover: updateHover, + onExit: (_) { + _propertyHoverEntries.remove(token); + _schedulePropertyHoverExit(); + }, + child: child, + ); + }, + ); + } + + void _schedulePropertyHoverExit() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _propertyMarginHovered) return; + final active = _propertyHoverEntries.isEmpty + ? null + : _propertyHoverEntries.entries + .reduce((a, b) => a.value.depth > b.value.depth ? a : b) + .key; + if (_propertyHoverScope != active) { + setState(() => _propertyHoverScope = active); + } + }); + } + + Widget? _propertyHoverControl() { + final scope = _propertyHoverScope; + if (scope == null) return null; + // Retain the captured occurrence while crossing into its margin button. + final capture = _propertyHoverEntries[scope] ?? _propertyHoverCapture; + if (capture == null || + capture.generation != _documentGeneration || + capture.revision != widget.contentRevision) { + return null; + } + _propertyHoverCapture = capture; + final anchor = capture.anchor; + final viewport = _propertyHoverViewportKey.currentContext + ?.findRenderObject(); + if (!anchor.attached || + viewport is! RenderBox || + !viewport.hasSize || + !anchor.hasSize) { + return null; + } + final origin = viewport.globalToLocal(anchor.localToGlobal(Offset.zero)); + final size = BusyMarkSizes.iconButton; + final left = capture.direction == TextDirection.rtl + ? origin.dx + anchor.size.width + : origin.dx - size; + final top = origin.dy + .clamp( + math.min( + _documentLayout.minimumInsets.top, + math.max(0, viewport.size.height - size), + ), + math.max(0, viewport.size.height - size), + ) + .toDouble(); + return Positioned( + left: left.clamp(0, math.max(0, viewport.size.width - size)).toDouble(), + top: top, + width: size, + height: size, + child: MouseRegion( + onEnter: (_) => _propertyMarginHovered = true, + onExit: (_) { + _propertyMarginHovered = false; + _schedulePropertyHoverExit(); + }, + child: BusyMarkHeaderIconButton( + key: ValueKey('writerside-inspect-${capture.block.id}'), + icon: BusyMarkGlyphs.settings, + tooltip: context.l10n.wsElementProperties, + onPressed: () { + if (capture.generation != _documentGeneration || + capture.revision != widget.contentRevision || + !identical( + _documentController.blockById(capture.block.id), + capture.block, + )) { + return; + } + if (widget.onInspectElement == null && + !_propertySession.transition()) { + return; + } + setState(() { + _propertiesTargetId = capture.block.id; + _propertiesInlineTarget = null; + _propertiesVisible = widget.onInspectElement == null; + _propertiesExplicitlyOpened = true; + _topicPropertiesSelected = false; + }); + _publishInspectorTarget( + explicit: true, + multiple: false, + inspectInline: false, + ); + }, + ), + ), + ); + } Widget _buildRenderEntry( BuildContext context, @@ -996,7 +1873,15 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { block, fallback: Directionality.of(context), ); - final content = entry.collapsible + final content = busyMarkIsWritersideContainer(block) + ? _buildStructuredEntry( + context, + entry, + documentLayout: documentLayout, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: selectionRangesByBlockId, + ) + : entry.collapsible ? _buildCollapsibleEntry( context, entry, @@ -1093,10 +1978,12 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ); return BusyMarkDocumentCollapsible( key: ValueKey('wysiwyg-collapsible-${block.id}'), - initiallyExpanded: busyMarkWritersideInitiallyExpanded(block.attributes), + initiallyExpanded: busyMarkWritersideInitiallyExpanded( + _publishedProperties(block), + ), expanded: _collapsibleExpansion[block.id] ?? - busyMarkWritersideInitiallyExpanded(block.attributes), + busyMarkWritersideInitiallyExpanded(_publishedProperties(block)), onExpansionChanged: (expanded) { setState(() => _collapsibleExpansion[block.id] = expanded); }, @@ -1108,7 +1995,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ? BusyMarkInsets.documentHeadingBlock.copyWith(top: 0) : BusyMarkInsets.documentHeadingBlock) : const EdgeInsets.symmetric(vertical: BusyMarkSpacing.xs), - header: header, + header: _propertyHoverRegion(block, header, scope: 'header'), child: expandedChild, ); } @@ -1212,106 +2099,115 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { selectionRangesByBlockId, }) { final documentFilePath = _documentController.document.filePath; - return BusyMarkWysiwygBlockField( - key: _blockKeyFor(block.id), - block: block, - first: first, - listRunEnd: listRunEnd, - documentFilePath: documentFilePath, - workspaceRoot: widget.workspaceRoot, - writersideRoot: widget.writersideRoot, - imagesDir: widget.imagesDir, - allowRemoteImages: widget.allowRemoteImages, - onRemoteImageBlocked: widget.onRemoteImageBlocked, - onMathDiagnostic: widget.onMathDiagnostic, - controller: _textControllerFor(block), - undoController: _textUndoControllerFor(block), - focusNode: _focusNodeFor(block), - selected: selectedBlockIds.contains(block.id), - selectionRange: selectionRangesByBlockId[block.id], - documentSelectionActive: _hasBlockSelection, - // The originating field must stop its local drag once the document owns - // it; otherwise EditableText refocuses and scrolls that field's caret. - documentSelectionDragging: - _hasBlockSelection && _pointerSelectionAnchor != null, - onPointerDown: (event) => _handleBlockPointerDown(block.id, event), - onPointerMove: _handleBlockPointerMove, - onPointerUp: _handleBlockPointerUp, - onPointerCancel: _handleBlockPointerCancel, - onFocused: () => _handleBlockFocused(block.id), - onCut: _cutCurrentSelection, - onCopy: _copyCurrentSelection, - onPaste: () => - unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.normal)), - onPastePlainText: () => - unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.plainText)), - readPasteAvailability: _readContextMenuPasteAvailability, - onCopyPlainText: _copyCurrentSelectionAsPlainText, - onRefineWithAi: widget.onAiEdit == null - ? null - : () => unawaited(_runAiEdit(blockId: block.id)), - onChanged: (value) => - _handleBlockTextChanged(documentFilePath, block.id, value), - onTableCellChanged: (cellId, value) => _handleTableCellTextChanged( - documentFilePath, - block.id, - cellId, - value, - ), - onTableCellSourceChanged: (cellId, value) => - _handleTableCellSourceChanged( - documentFilePath, - block.id, - cellId, - value, - ), - tableCellController: _tableCellControllerFor, - tableCellUndoController: _textUndoControllerFor, - tableCellFocusNode: (cell) => _tableCellFocusNodeFor(block.id, cell), - tableCellKey: _tableCellKeyFor, - spellingRanges: _spellingRangesForBlock(block.id), - spellingEditableKey: _spellingEditableKeyFor(block.id), - tableCellSpellingRanges: _spellingRangesForCell, - tableCellSpellingEditableKey: _tableCellSpellingEditableKeyFor, - spellingMenuReader: widget.readSpellingMenuItems == null - ? null - : (offset) => widget.readSpellingMenuItems!( - SpellingRichBlockTarget( - blockId: block.id, - documentGeneration: _documentGeneration, - ), - offset, + return _propertyHoverRegion( + block, + BusyMarkWysiwygBlockField( + key: _blockKeyFor(block.id), + block: block, + first: first, + listRunEnd: listRunEnd, + documentFilePath: documentFilePath, + workspaceRoot: widget.workspaceRoot, + writersideRoot: widget.writersideRoot, + imagesDir: widget.imagesDir, + allowRemoteImages: widget.allowRemoteImages, + onRemoteImageBlocked: widget.onRemoteImageBlocked, + onMathDiagnostic: widget.onMathDiagnostic, + controller: _textControllerFor(block), + undoController: _textUndoControllerFor(block), + focusNode: _focusNodeFor(block), + selected: selectedBlockIds.contains(block.id), + selectionRange: selectionRangesByBlockId[block.id], + documentSelectionActive: _hasBlockSelection, + // The originating field must stop its local drag once the document owns + // it; otherwise EditableText refocuses and scrolls that field's caret. + documentSelectionDragging: + _hasBlockSelection && _pointerSelectionAnchor != null, + onPointerDown: (event) => _handleBlockPointerDown(block.id, event), + onPointerMove: _handleBlockPointerMove, + onPointerUp: _handleBlockPointerUp, + onPointerCancel: _handleBlockPointerCancel, + onFocused: () => _handleBlockFocused(block.id), + onCut: _cutCurrentSelection, + onCopy: _copyCurrentSelection, + onPaste: () => + unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.normal)), + onPastePlainText: () => + unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.plainText)), + readPasteAvailability: _readContextMenuPasteAvailability, + onCopyPlainText: _copyCurrentSelectionAsPlainText, + onRefineWithAi: widget.onAiEdit == null + ? null + : () => unawaited(_runAiEdit(blockId: block.id)), + onChanged: (value) => + _handleBlockTextChanged(documentFilePath, block.id, value), + onTableCellChanged: (cellId, value) => _handleTableCellTextChanged( + documentFilePath, + block.id, + cellId, + value, + ), + onTableCellSourceChanged: (cellId, value) => + _handleTableCellSourceChanged( + documentFilePath, + block.id, + cellId, + value, ), - tableCellSpellingMenuReader: widget.readSpellingMenuItems == null - ? null - : (cellId) => - (offset) => widget.readSpellingMenuItems!( - SpellingRichTableCellTarget( - tableBlockId: block.id, - cellId: cellId, - documentGeneration: _documentGeneration, - ), - offset, + tableCellController: _tableCellControllerFor, + tableCellUndoController: _textUndoControllerFor, + tableCellFocusNode: (cell) => _tableCellFocusNodeFor(block.id, cell), + tableCellKey: _tableCellKeyFor, + spellingRanges: _spellingRangesForBlock(block.id), + spellingEditableKey: _spellingEditableKeyFor(block.id), + tableCellSpellingRanges: _spellingRangesForCell, + tableCellSpellingEditableKey: _tableCellSpellingEditableKeyFor, + spellingMenuReader: widget.readSpellingMenuItems == null + ? null + : (offset) => widget.readSpellingMenuItems!( + SpellingRichBlockTarget( + blockId: block.id, + documentGeneration: _documentGeneration, ), - onCheckSpelling: widget.onCheckSpelling, - onTableCellFocused: (cellId) => _handleTableCellFocused(block.id, cellId), - onTableRowInserted: (rowIndex, {required after}) => - _handleTableRowInserted(block.id, rowIndex, after: after), - onTableRowDeleted: (rowIndex) => - _handleTableRowDeleted(block.id, rowIndex), - onTableColumnInserted: (columnIndex, {required after}) => - _handleTableColumnInserted(block.id, columnIndex, after: after), - onTableColumnDeleted: (columnIndex) => - _handleTableColumnDeleted(block.id, columnIndex), - onTableColumnAlignmentChanged: (columnIndex, alignment) => - _handleTableColumnAlignmentChanged(block.id, columnIndex, alignment), - onTableDeleted: () => _handleTableDeleted(block.id), - onImageEditRequested: () => - unawaited(_handleImageBlockEditRequested(block.id)), - onHtmlEditRequested: () => - unawaited(_handleHtmlBlockEditRequested(block.id)), - onTaskChanged: (checked) => _handleTaskCheckedChanged(block.id, checked), - editRevision: widget.visualizationRevision + _documentGeneration, + offset, + ), + tableCellSpellingMenuReader: widget.readSpellingMenuItems == null + ? null + : (cellId) => + (offset) => widget.readSpellingMenuItems!( + SpellingRichTableCellTarget( + tableBlockId: block.id, + cellId: cellId, + documentGeneration: _documentGeneration, + ), + offset, + ), + onCheckSpelling: widget.onCheckSpelling, + onTableCellFocused: (cellId) => + _handleTableCellFocused(block.id, cellId), + onTableRowInserted: (rowIndex, {required after}) => + _handleTableRowInserted(block.id, rowIndex, after: after), + onTableRowDeleted: (rowIndex) => + _handleTableRowDeleted(block.id, rowIndex), + onTableColumnInserted: (columnIndex, {required after}) => + _handleTableColumnInserted(block.id, columnIndex, after: after), + onTableColumnDeleted: (columnIndex) => + _handleTableColumnDeleted(block.id, columnIndex), + onTableColumnAlignmentChanged: (columnIndex, alignment) => + _handleTableColumnAlignmentChanged( + block.id, + columnIndex, + alignment, + ), + onTableDeleted: () => _handleTableDeleted(block.id), + onImageEditRequested: () => + unawaited(_handleImageBlockEditRequested(block.id)), + onHtmlEditRequested: () => + unawaited(_handleHtmlBlockEditRequested(block.id)), + onTaskChanged: (checked) => + _handleTaskCheckedChanged(block.id, checked), + editRevision: widget.visualizationRevision + _documentGeneration, + ), ); } @@ -1414,7 +2310,11 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { void _handleDocumentControllerChanged() { _documentGeneration++; + _propertyHoverEntries.clear(); + _propertyHoverScope = null; + _propertyMarginHovered = false; _syncBlockControllers(); + _scheduleSessionReport(); } _WysiwygDialogTarget _captureDialogTarget() { @@ -1475,6 +2375,13 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { String targetId, TextEditingController controller, ) { + if (_documentController.document.isWriterside && + _activeBlockId == targetId && + mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } if (_cellSelectAllTarget == targetId) _cellSelectAllTarget = null; _scheduleSessionReport(); final continuous = _continuousTextEdit; @@ -1578,6 +2485,10 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { if (block.kind == BusyBlockKind.frontMatter || block.isSourceOnly) { continue; } + if (block.attributes['busymark-list-form'] == 'markdown') { + entries.addAll(_editableBlockEntries(block.children, depth)); + continue; + } if (_isStructuralBlockquote(block)) { entries.addAll(_editableBlockEntries(block.children, depth)); continue; @@ -1604,6 +2515,48 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { var index = 0; while (index < visibleBlocks.length) { final block = visibleBlocks[index]; + if (block.attributes['busymark-list-form'] == 'markdown') { + entries.addAll(_editorRenderEntries(block.children, depth)); + index++; + continue; + } + if (busyMarkIsWritersideContainer(block)) { + if ({'topic', 'chapter'}.contains(block.attributes['element']) && + !busyMarkWritersideIsCollapsible(_publishedProperties(block))) { + entries.add( + _EditorRenderEntry.blockquote( + block: block, + depth: depth, + children: const [], + ), + ); + entries.addAll(_editorRenderEntries(block.children, depth)); + index++; + continue; + } + entries.add( + _EditorRenderEntry.blockquote( + block: block, + depth: depth, + collapsible: + block.attributes['element'] != 'deflist' && + (busyMarkWritersideIsCollapsible(_publishedProperties(block)) || + block.attributes['element'] == 'def' && + _documentController + .elementPath(block.id) + .any( + (b) => + b.attributes['element'] == 'deflist' && + busyMarkWritersideIsCollapsible( + _publishedProperties(b), + ), + )), + children: _editorRenderEntries(block.children), + ), + ); + index++; + continue; + } if (_isWritersideCollapsibleHeading(block)) { final level = int.tryParse(block.attributes['level'] ?? '') ?? 6; var end = index + 1; @@ -1671,14 +2624,14 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { return _documentController.document.mode == MarkdownMode.writersideMarkdown && block.kind == BusyBlockKind.heading && - busyMarkWritersideIsCollapsible(block.attributes); + busyMarkWritersideIsCollapsible(_publishedProperties(block)); } bool _isWritersideCollapsibleLeaf(BusyBlock block) { return _documentController.document.mode == MarkdownMode.writersideMarkdown && block.kind != BusyBlockKind.heading && - busyMarkWritersideIsCollapsible(block.attributes); + busyMarkWritersideIsCollapsible(_publishedProperties(block)); } bool _isStructuralBlockquote(BusyBlock block) => @@ -1690,7 +2643,9 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { if (block.isSourceProtected) { return false; } - return _isListItemBlock(block) || block.kind == BusyBlockKind.blockquote; + return busyMarkIsWritersideContainer(block) || + _isListItemBlock(block) || + block.kind == BusyBlockKind.blockquote; } bool _isListItemBlock(BusyBlock block) => switch (block.kind) { @@ -1700,23 +2655,23 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { _ => false, }; - void _setActiveBlock(String blockId) { + void _setActiveBlock(String blockId, {bool selectElement = false}) { final changed = _activeBlockId != blockId || _activeCellId != null; _activeBlockId = blockId; _activeCellId = null; - if (changed && mounted) { - setState(() {}); - } + if (changed && mounted) setState(() {}); _scheduleSessionReport(); } - void _setActiveTableCell(String tableBlockId, String cellId) { + void _setActiveTableCell( + String tableBlockId, + String cellId, { + bool selectElement = false, + }) { final changed = _activeBlockId != tableBlockId || _activeCellId != cellId; _activeBlockId = tableBlockId; _activeCellId = cellId; - if (changed && mounted) { - setState(() {}); - } + if (changed && mounted) setState(() {}); _scheduleSessionReport(); } @@ -1727,13 +2682,13 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { _preserveSelectionFocusCallbacks--; - _setActiveBlock(tableBlockId); + _setActiveBlock(tableBlockId, selectElement: true); return; } _cellSelectAllTarget = null; _clearBlockSelection(); _collapseFieldSelections(exceptBlockId: cellId); - _setActiveTableCell(tableBlockId, cellId); + _setActiveTableCell(tableBlockId, cellId, selectElement: true); } KeyEventResult _handleDocumentSelectionKeyEvent( @@ -1868,12 +2823,28 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } void _restoreEditorSnapshot(BusyDocument snapshot) { + final activeId = _activeBlockId; + final activePath = _documentController.elementPath(activeId ?? ''); + final selection = _textControllers[activeId]?.selection; _clearBlockSelection(); _documentController.replaceDocument(snapshot); + // A structural operation can move text into a new child. When Undo removes + // that child, recover the closest surviving owner from this actual model + // path rather than moving the caret to the document's first heading. + _activeBlockId = _documentController.blockById(activeId ?? '') != null + ? activeId + : activePath.reversed + .where((b) => _documentController.blockById(b.id) != null) + .firstOrNull + ?.id; _emitMarkdown(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _focusActiveOrFirstBlock(); + _focusActiveOrFirstBlock( + initialSelectionOffset: selection?.isValid == true + ? selection!.extentOffset + : null, + ); } }); } @@ -1885,12 +2856,12 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { _preserveSelectionFocusCallbacks--; - _setActiveBlock(blockId); + _setActiveBlock(blockId, selectElement: true); return; } _clearBlockSelection(); _collapseInactiveFieldSelections(blockId); - _setActiveBlock(blockId); + _setActiveBlock(blockId, selectElement: true); } void _handleBlockTextChanged( @@ -2230,20 +3201,76 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { .where((block) => !busyMarkWysiwygBlockContainsMath(block)) .firstOrNull; if (firstPlainBlock == null) { + _inspectorRestorePending = false; + _scheduleSessionReport(); return; } _activeBlockId = firstPlainBlock.id; _focusActiveOrFirstBlock(initialSelectionOffset: 0); + _inspectorRestorePending = false; + _scheduleSessionReport(); }); } void _scheduleSessionRestore(WysiwygEditorSessionState session) { + _inspectorRestorePending = true; if (session.activeBlockId == null && session.anchorBlockId == null && session.viewportBlockId == null) { _scheduleInitialFocus(); return; } + final signature = _writersideSessionSourceSignature(); + if (signature != null && session.sourceSignature == signature) { + Iterable<BusyBlock> walk(List<BusyBlock> blocks) sync* { + for (final block in blocks) { + yield block; + yield* walk(block.children); + } + } + + String? locate(String? id, {bool caret = false}) { + final binding = session.sourceBindings[id]; + if (binding == null) return id; + final found = walk(_documentController.document.blocks) + .where( + (b) => + b.sourceSpan?.startOffset == binding.start && + b.sourceSpan?.endOffset == binding.end && + b.kind.name == binding.kind && + WritersideEditingAdapter.tagForBlock(b) == binding.tag, + ) + .singleOrNull; + // A newly transformed native quote has one inline field in the editing + // model. Reparsing the exact same source gives that text its paragraph + // child. Restore the caret to this sole authored text owner, while + // keeping the viewport bound to the quote container. + if (caret && + found != null && + _isStructuralBlockquote(found) && + found.children.length == 1) { + final child = found.children.single; + if (child.kind == BusyBlockKind.paragraph && + child.children.isEmpty && + !child.isSourceProtected && + !child.preserveRaw) { + return child.id; + } + } + return found?.id; + } + + session = WysiwygEditorSessionState( + activeBlockId: locate(session.activeBlockId, caret: true), + activeCellId: locate(session.activeCellId), + anchorBlockId: locate(session.anchorBlockId, caret: true), + anchorOffset: session.anchorOffset, + extentBlockId: locate(session.extentBlockId, caret: true), + extentOffset: session.extentOffset, + viewportBlockId: locate(session.viewportBlockId), + viewportAlignment: session.viewportAlignment, + ); + } _initialFocusScheduled = true; final generation = _documentGeneration; final documentId = _documentId; @@ -2348,22 +3375,90 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { if (mounted) { setState(() {}); } + _inspectorRestorePending = false; + _scheduleSessionReport(); }); } void _scheduleSessionReport() { - if (_sessionReportScheduled || widget.onSessionChanged == null) { + if (_sessionReportScheduled || + widget.onSessionChanged == null && widget.onInspectElement == null) { return; } _sessionReportScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _sessionReportScheduled = false; - if (mounted) { + if (mounted && !_inspectorRestorePending) { _reportSessionNow(); + _publishInspectorTarget(); } }); } + void _publishInspectorTarget({ + bool explicit = false, + bool? multiple, + bool inspectInline = true, + }) { + if (_inspectorRestorePending) return; + // Session reporting still tracks the caret. Inspection notifications only + // select explicitly or refresh the pinned owner after a model change. + final signature = + '$_documentId|$_documentGeneration|${widget.contentRevision}'; + if (!explicit && _lastInspectorSelection == signature) return; + if (!explicit) _lastInspectorSelection = signature; + final revisionRefresh = !explicit; + final targetId = explicit ? _propertiesTargetId : _activeBlockId; + final block = targetId == null + ? null + : _documentController.blockById(targetId); + BusyInlineStyleRange? inline; + if (inspectInline && block != null && targetId == _activeBlockId) { + final selection = _textControllers[targetId]?.selection; + if (selection?.isValid == true) { + inline = busyInlineReferenceRanges(block.inlines) + .where( + (r) => + { + BusyInlineKind.link, + BusyInlineKind.image, + BusyInlineKind.writersideVariable, + BusyInlineKind.writersideShortcut, + }.contains(r.kind) && + selection!.start >= r.start && + selection.end <= r.end, + ) + .firstOrNull; + } + } + if (explicit) _propertiesInlineTarget = inline; + widget.onInspectElement?.call( + _documentController.document, + targetId, + inline, + explicit, + multiple ?? _hasBlockSelection, + revisionRefresh: revisionRefresh, + ); + if (explicit) widget.onPropertiesRequested?.call(); + } + + void restoreEditingFocus() => _restoreEditingFocusAfterFrame(); + + void inspectCurrentSelection() { + if (!_documentController.document.isWriterside) return; + if (widget.onInspectElement == null && !_propertySession.transition()) { + return; + } + setState(() { + _propertiesTargetId = _activeBlockId; + _propertiesVisible = widget.onInspectElement == null; + _propertiesExplicitlyOpened = true; + _topicPropertiesSelected = false; + }); + _publishInspectorTarget(explicit: true); + } + void _reportSessionNow({ BusyMarkWysiwygSessionChanged? callback, String? documentId, @@ -2386,6 +3481,18 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { report(targetDocumentId, session); } + String? _writersideSessionSourceSignature() { + final source = _documentController.document.source; + if (!_documentController.document.isWriterside || source == null) { + return null; + } + if (_sessionSource != source) { + _sessionSource = source; + _sessionSourceSignature = sha256.convert(utf8.encode(source)).toString(); + } + return _sessionSourceSignature; + } + WysiwygEditorSessionState _captureSessionState() { String? anchorBlockId; String? extentBlockId; @@ -2409,6 +3516,15 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { extentOffset = selection.extentOffset; } } + final pending = _pendingTextFocus; + if (_documentController.document.isXmlTopic && + anchorBlockId == null && + pending != null && + pending.targetId == (_activeCellId ?? _activeBlockId) && + _documentController.blockById(pending.targetId) != null) { + anchorBlockId = extentBlockId = pending.targetId; + anchorOffset = extentOffset = pending.offset; + } String? viewportBlockId; var viewportAlignment = 0.0; final positions = @@ -2433,6 +3549,25 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { extentOffset: extentOffset, viewportBlockId: viewportBlockId, viewportAlignment: viewportAlignment, + sourceSignature: _writersideSessionSourceSignature(), + sourceBindings: { + if (_documentController.document.isWriterside) + for (final id in { + _activeBlockId, + _activeCellId, + anchorBlockId, + extentBlockId, + viewportBlockId, + }) + if (_documentController.blockById(id ?? '') case final block? + when block.sourceSpan != null) + id!: ( + start: block.sourceSpan!.startOffset, + end: block.sourceSpan!.endOffset, + kind: block.kind.name, + tag: WritersideEditingAdapter.tagForBlock(block), + ), + }, ); } @@ -2572,14 +3707,19 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { final candidates = <_WysiwygSourceTarget>[]; BusyDocument sourceDocument; try { - sourceDocument = const MarkdownParser() - .parse( - filePath: _documentController.document.filePath, - source: source, - mode: _documentController.document.mode, - validateLocalReferences: false, - ) - .busyDocument; + sourceDocument = _documentController.document.isXmlTopic + ? const WritersideEditingAdapter().parseXml( + filePath: _documentController.document.filePath, + source: source, + )! + : const MarkdownParser() + .parse( + filePath: _documentController.document.filePath, + source: source, + mode: _documentController.document.mode, + validateLocalReferences: false, + ) + .busyDocument; } on Object { sourceDocument = _documentController.document; } @@ -2902,8 +4042,14 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { if (blocks.isEmpty) { return; } + final content = + activeBlock == null || blocks.any((block) => block.id == activeBlock.id) + ? null + : _firstEditableBlockIn(_editorRenderEntries(activeBlock.children)); final blockId = blocks.any((block) => block.id == _activeBlockId) ? _activeBlockId! + : content != null && blocks.any((block) => block.id == content.id) + ? content.id : blocks.first.id; final focusNode = _focusNodes[blockId]; final controller = _textControllers[blockId]; @@ -4004,10 +5150,33 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { bool _isFocusableTextBlock(BusyBlock block) { return !block.preserveRaw && + (!busyMarkIsWritersideContainer(block) || + WritersideEditingAdapter.titleElements.contains( + block.attributes['element'], + )) && + !{ + 'include', + 'video', + 'show-structure', + }.contains(block.attributes['element']) && block.kind != BusyBlockKind.thematicBreak && block.kind != BusyBlockKind.table; } + bool get _hasSemanticCommandTarget { + final target = _activeTextTarget(); + return _hasInlineCommandTarget && + _documentController.document.isWriterside && + !DocumentReadOnlyScope.of(context) && + target != null && + !target.block.isSourceProtected && + !target.block.preserveRaw && + !{ + BusyBlockKind.codeBlock, + BusyBlockKind.math, + }.contains(target.block.kind); + } + bool get _hasInlineCommandTarget { if (_activeCellId != null) { return _documentController.blockById(_activeCellId!) != null; @@ -4016,7 +5185,10 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { final block = blockId == null ? null : _documentController.blockById(blockId); - return block != null && _isFocusableTextBlock(block); + return block != null && + _isFocusableTextBlock(block) && + (!_documentController.document.isXmlTopic || + block.kind == BusyBlockKind.paragraph); } ({ @@ -4071,7 +5243,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { : [if (active != null) active]; return targets.isNotEmpty && targets.every( - (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + (block) => _documentController.canApplyBlockCommand(block, command), ); } @@ -4116,8 +5288,9 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } void _restoreEditingFocusAfterFrame() { + final documentId = _documentId; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || _documentId != documentId) { return; } if (_documentSelection != null) { @@ -4154,7 +5327,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { : [if (activeBlock != null) activeBlock]; if (commandTargets.isEmpty || !commandTargets.every( - (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + (block) => _documentController.canApplyBlockCommand(block, command), )) { return; } @@ -4202,6 +5375,29 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { final activeBlock = _activeBlockId == null ? null : _documentController.blockById(_activeBlockId!); + final selection = activeBlock == null + ? null + : _textControllers[activeBlock.id]?.selection; + void focusContent() { + if (!_documentController.document.isXmlTopic) return; + final transformed = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + if (transformed?.kind != BusyBlockKind.writersideAdmonition) return; + // XML transforms replace the paragraph's field with a container. Its + // original text now belongs to the child paragraph, so restore focus + // there through the established structural-edit helper. + final content = _firstEditableBlockIn( + _editorRenderEntries(transformed!.children), + ); + if (content != null) { + _focusTextTargetAfterFrame( + content.id, + offset: selection?.isValid == true ? selection!.extentOffset : 0, + ); + } + } + final targets = selectedBlocks.isNotEmpty ? selectedBlocks : [if (activeBlock != null) activeBlock]; @@ -4218,7 +5414,9 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { style, ); _clearBlockSelection(); + focusContent(); _emitMarkdown(); + if (_documentController.document.isXmlTopic) _reportSessionNow(); return; } final blockId = _activeBlockId; @@ -4230,7 +5428,9 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { _documentController.admonitionTargetId(blockId), style, ); + focusContent(); _emitMarkdown(); + if (_documentController.document.isXmlTopic) _reportSessionNow(); } void _applyInlineCommand(BusyWysiwygInlineCommand command) { @@ -4257,7 +5457,11 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { final controller = target.controller; final selection = controller.selection; if (selection.isCollapsed) { - _togglePendingInlineKind(blockId, inlineKindForCommand(command)); + if (busyMarkIsSemanticInline(inlineKindForCommand(command))) { + unawaited(_insertSemanticText(command)); + } else { + _togglePendingInlineKind(blockId, inlineKindForCommand(command)); + } return; } _recordUndoSnapshot(); @@ -4270,6 +5474,85 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { _emitMarkdown(); } + Future<void> _insertSemanticText(BusyWysiwygInlineCommand command) async { + final target = _activeTextTarget(); + if (target == null || + !_documentController.document.isWriterside || + DocumentReadOnlyScope.of(context)) { + return; + } + final guard = _captureDialogTarget(); + final revision = widget.contentRevision; + final documentId = _documentId; + final selection = target.controller.selection; + final original = target.controller.text; + if (!selection.isValid || !selection.isCollapsed) return; + var text = ''; + final inserted = await _showEditorDialog<String>( + context, + builder: (context) => StatefulBuilder( + builder: (context, update) => BusyMarkModalEditorScaffold( + title: busyMarkSemanticInlineLabel(context, command), + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.insert, + onCancel: () => Navigator.pop(context), + onSave: text.trim().isEmpty + ? null + : () => Navigator.pop(context, text), + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('wysiwyg-semantic-text'), + label: context.l10n.wsSemanticText, + initialValue: '', + autofocus: true, + onChanged: (value) => update(() => text = value), + onSubmitted: (value) { + if (value.trim().isNotEmpty) Navigator.pop(context, value); + }, + ), + ], + ), + ], + ), + ), + ); + if (inserted == null || inserted.trim().isEmpty) { + if (_isDialogTargetCurrent(guard)) _restoreEditingFocusAfterFrame(); + return; + } + if (!mounted) return; + if (!_isDialogTargetCurrent(guard) || + revision != widget.contentRevision || + documentId != _documentId || + DocumentReadOnlyScope.of(context) || + _activeTextTarget()?.targetId != target.targetId || + target.controller.text != original || + target.controller.selection != selection) { + return; + } + // The existing text mutation and semantic wrapping serialize and escape the + // element for both document formats. One snapshot covers the whole insert. + _recordUndoSnapshot(); + _documentController.updateBlockText( + target.targetId, + original.replaceRange(selection.start, selection.end, inserted), + ); + _documentController.applyInlineCommand( + target.targetId, + command, + selection.start, + selection.start + inserted.length, + ); + target.controller.selection = TextSelection.collapsed( + offset: selection.start + inserted.length, + ); + _emitMarkdown(); + _restoreEditingFocusAfterFrame(); + } + void _applyEditorShortcutAction(BusyMarkEditorShortcutAction action) { switch (action) { case BusyMarkEditorShortcutAction.refineWithAi: @@ -4513,7 +5796,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ); if (!_isDialogTargetCurrent(target) || destination == null || - destination.trim().isEmpty) { + destination.href.trim().isEmpty) { return; } _recordUndoSnapshot(); @@ -4523,7 +5806,10 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { BusyWysiwygInlineCommand.link, range.start, range.end, - destination: destination.trim(), + destination: destination.href.trim(), + linkAttributes: destination.origin == null + ? const {} + : {'origin': destination.origin!}, ); } _clearBlockSelection(); @@ -4569,7 +5855,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ); if (!_isDialogTargetCurrent(dialogTarget) || destination == null || - destination.trim().isEmpty) { + destination.href.trim().isEmpty) { return; } _recordUndoSnapshot(); @@ -4578,7 +5864,10 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { BusyWysiwygInlineCommand.link, rangeToApply.start, rangeToApply.end, - destination: destination.trim(), + destination: destination.href.trim(), + linkAttributes: destination.origin == null + ? const {} + : {'origin': destination.origin!}, ); _emitMarkdown(); } @@ -4627,6 +5916,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } void _applyInlineMathCommand() { + if (_documentController.document.isXmlTopic) return; final target = _activeTextTarget(); if (target == null) { return; @@ -4681,6 +5971,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } void _applyDisplayMathCommand() { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5335,6 +6626,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } Future<void> _applyTableCommand() async { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5382,6 +6674,7 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } Future<void> _applyHtmlCommand() async { + if (_documentController.document.isXmlTopic) return; final blockId = _activeBlockId; if (blockId == null) { return; @@ -5528,6 +6821,18 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { return; } final firstBlock = _documentController.blockById(blockIds.first); + if (_documentController.document.isWriterside && + blockIds.length == 1 && + firstBlock?.kind == BusyBlockKind.codeBlock) { + setState(() { + _propertiesVisible = widget.onInspectElement == null; + _propertiesExplicitlyOpened = true; + _propertiesTargetId = firstBlock!.id; + _topicPropertiesSelected = false; + }); + _publishInspectorTarget(explicit: true, multiple: false); + return; + } final target = _captureDialogTarget(); final language = await _showCodeLanguageDialog( context, @@ -5573,19 +6878,22 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { ); } - Future<String?> _showLinkDialog( + Future<LinkDestination?> _showLinkDialog( BuildContext context, { String initialDestination = '', }) { + if (widget.onChooseLink != null) { + return widget.onChooseLink!(initialDestination); + } var destination = initialDestination; - return _showEditorDialog<String>( + return _showEditorDialog<LinkDestination>( context, builder: (context) => BusyMarkModalEditorScaffold( title: context.l10n.link, cancelLabel: context.l10n.cancel, saveLabel: context.l10n.apply, onCancel: () => Navigator.pop(context), - onSave: () => Navigator.pop(context, destination), + onSave: () => Navigator.pop(context, LinkDestination(destination)), children: [ BusyMarkGroupedList( filled: true, @@ -5602,7 +6910,8 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, ), hintText: 'https://example.com', - onSubmitted: (value) => Navigator.pop(context, value), + onSubmitted: (value) => + Navigator.pop(context, LinkDestination(value)), ), ], ), @@ -6045,6 +7354,8 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { } _internalChange = true; final markdown = _documentController.markdown; + _documentController.rebaseCommittedSource(markdown); + _lastInspectorSelection = null; widget.onDocumentChanged?.call( _documentController.document.copyWith(source: markdown), ); @@ -6147,14 +7458,16 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { return null; } final liveBlocks = _editableBlocks(_documentController.document.blocks); - final parsedDocument = const MarkdownParser() - .parse( - filePath: _documentController.document.filePath, - source: source, - mode: _documentController.document.mode, - validateLocalReferences: false, - ) - .busyDocument; + final parsedDocument = busyMarkWysiwygBindMarkdownReferences( + const MarkdownParser() + .parse( + filePath: _documentController.document.filePath, + source: source, + mode: _documentController.document.mode, + validateLocalReferences: false, + ) + .busyDocument, + ); final parsedBlocks = _editableBlocks(parsedDocument.blocks); final ranges = _currentSelectionRanges(); if (ranges.isEmpty) { @@ -6297,6 +7610,14 @@ class BusyMarkWysiwygEditorState extends State<BusyMarkWysiwygEditor> { required bool endBoundary, String? visibleText, }) { + if (_documentController.document.isXmlTopic) { + return const WritersideEditingAdapter().xmlTextSourceOffset( + _documentController.document, + block, + visibleOffset, + endBoundary: endBoundary, + ); + } final span = sourceSpan ?? block.sourceSpan; if (span == null || span.startOffset < 0 || @@ -8231,8 +9552,8 @@ class _EditorRenderEntry { required this.block, required this.depth, required this.children, - }) : listRunEnd = false, - collapsible = false; + this.collapsible = false, + }) : listRunEnd = false; const _EditorRenderEntry.collapsible({ required this.block, diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index e297ec9c..f40973c9 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import '../../app/busymark_design.dart'; import '../../markdown/busymark_document.dart'; @@ -150,6 +151,7 @@ class BusyInlineStyleRange { required this.kind, this.destination, this.attributes = const {}, + this.referenceIndex, }); final int start; @@ -157,6 +159,10 @@ class BusyInlineStyleRange { final BusyInlineKind kind; final String? destination; final Map<String, String> attributes; + + /// Owning reference's ordinal within this block snapshot. Empty references + /// can share text boundaries, so offsets alone are not their identity. + final int? referenceIndex; } class BusyInlineSemanticAnchor { @@ -166,6 +172,41 @@ class BusyInlineSemanticAnchor { final BusyInline inline; } +/// One range per owning reference, independent of formatting in its caption. +/// Styling still uses [busyInlineStyleRanges]'s individual text segments. +/// References without a label retain their editable source position. +List<BusyInlineStyleRange> busyInlineReferenceRanges(List<BusyInline> inlines) { + final ranges = <BusyInlineStyleRange>[]; + var offset = 0; + void visit(BusyInline inline) { + if ({ + BusyInlineKind.link, + BusyInlineKind.image, + BusyInlineKind.writersideVariable, + BusyInlineKind.writersideShortcut, + }.contains(inline.kind)) { + ranges.add( + BusyInlineStyleRange( + start: offset, + end: offset + inline.plainText.length, + kind: inline.kind, + destination: inline.destination, + attributes: inline.attributes, + referenceIndex: ranges.length, + ), + ); + } + if (inline.children.isNotEmpty) { + inline.children.forEach(visit); + } else { + offset += inline.plainText.length; + } + } + + inlines.forEach(visit); + return ranges; +} + List<BusyInlineSemanticAnchor> busyInlineSemanticAnchors( List<BusyInline> inlines, ) { @@ -304,7 +345,10 @@ class BusyMarkWysiwygTextController extends TextEditingController { BusyInlineKind.strikethrough => baseStyle.copyWith( decoration: TextDecoration.lineThrough, ), - BusyInlineKind.code => baseStyle.copyWith( + BusyInlineKind.code || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => baseStyle.copyWith( fontFamily: BusyMarkTypography.monoFontFamily, backgroundColor: colors.control, ), @@ -312,6 +356,9 @@ class BusyMarkWysiwygTextController extends TextEditingController { color: colorScheme.primary, decoration: TextDecoration.underline, ), + BusyInlineKind.writersideControl => baseStyle.copyWith( + fontWeight: FontWeight.w600, + ), BusyInlineKind.image => baseStyle.copyWith( color: colorScheme.primary, fontStyle: FontStyle.italic, @@ -446,15 +493,17 @@ class _InheritedInlineStyle { } bool _styledKind(BusyInlineKind kind) { - return { - BusyInlineKind.strong, - BusyInlineKind.emphasis, - BusyInlineKind.underline, - BusyInlineKind.strikethrough, - BusyInlineKind.code, - BusyInlineKind.link, - BusyInlineKind.image, - }.contains(kind); + return busyMarkIsSemanticInline(kind) || + kind == BusyInlineKind.writersideVariable || + { + BusyInlineKind.strong, + BusyInlineKind.emphasis, + BusyInlineKind.underline, + BusyInlineKind.strikethrough, + BusyInlineKind.code, + BusyInlineKind.link, + BusyInlineKind.image, + }.contains(kind); } bool _inlineStyleRangesEqual( @@ -473,7 +522,8 @@ bool _inlineStyleRangesEqual( if (left.start != right.start || left.end != right.end || left.kind != right.kind || - left.destination != right.destination) { + left.destination != right.destination || + !mapEquals(left.attributes, right.attributes)) { return false; } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart index 642f4cfc..8a468983 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart @@ -8,6 +8,8 @@ class WysiwygEditorSessionState { this.extentOffset = 0, this.viewportBlockId, this.viewportAlignment = 0, + this.sourceSignature, + this.sourceBindings = const {}, }); final String? activeBlockId; @@ -19,6 +21,12 @@ class WysiwygEditorSessionState { final String? viewportBlockId; final double viewportAlignment; + // Writerside authoring IDs can differ after an exact saved source is reparsed. + // Bind caret/viewport locations to that representation, never to an ordinal. + final String? sourceSignature; + final Map<String, ({int start, int end, String kind, String tag})> + sourceBindings; + Map<String, Object?> toJson() => { 'activeBlockId': activeBlockId, 'activeCellId': activeCellId, @@ -28,6 +36,17 @@ class WysiwygEditorSessionState { 'extentOffset': extentOffset, 'viewportBlockId': viewportBlockId, 'viewportAlignment': viewportAlignment, + if (sourceSignature != null) 'sourceSignature': sourceSignature, + if (sourceBindings.isNotEmpty) + 'sourceBindings': { + for (final entry in sourceBindings.entries) + entry.key: { + 'start': entry.value.start, + 'end': entry.value.end, + 'kind': entry.value.kind, + 'tag': entry.value.tag, + }, + }, }; factory WysiwygEditorSessionState.fromJson(Map<String, Object?> json) { @@ -40,6 +59,24 @@ class WysiwygEditorSessionState { extentOffset: (json['extentOffset'] as num?)?.toInt() ?? 0, viewportBlockId: json['viewportBlockId']?.toString(), viewportAlignment: (json['viewportAlignment'] as num?)?.toDouble() ?? 0, + sourceSignature: json['sourceSignature'] as String?, + sourceBindings: { + if (json['sourceBindings'] case final Map bindings) + for (final entry in bindings.entries) + if (entry.key is String) + if (entry.value case { + 'start': final num start, + 'end': final num end, + 'kind': final String kind, + 'tag': final String tag, + }) + entry.key as String: ( + start: start.toInt(), + end: end.toInt(), + kind: kind, + tag: tag, + ), + }, ); } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 554b3d7c..4ee4dfcd 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -7,11 +7,40 @@ import '../../app/localization.dart'; import '../../markdown/busymark_document.dart'; import 'wysiwyg_commands.dart'; +String busyMarkWritersideInsertLabel( + BuildContext context, + BusyWritersideInsertCommand command, +) => switch (command) { + BusyWritersideInsertCommand.procedure => context.l10n.procedure, + BusyWritersideInsertCommand.tabs => context.l10n.tabs, + BusyWritersideInsertCommand.definitionList => context.l10n.wsDefinitionList, + BusyWritersideInsertCommand.include => context.l10n.wsIncludeContent, + BusyWritersideInsertCommand.variable => context.l10n.wsVariableReference, + BusyWritersideInsertCommand.video => context.l10n.video, + BusyWritersideInsertCommand.tldr => context.l10n.wsTldr, + BusyWritersideInsertCommand.convertListToProcedure => + context.l10n.wsConvertList, +}; + +String busyMarkSemanticInlineLabel( + BuildContext context, + BusyWysiwygInlineCommand command, +) => switch (command) { + BusyWysiwygInlineCommand.uiControl => context.l10n.wsUiControl, + BusyWysiwygInlineCommand.filePath => context.l10n.wsFilePath, + BusyWysiwygInlineCommand.uiPath => context.l10n.wsUiPath, + BusyWysiwygInlineCommand.shortcut => context.l10n.keyboardShortcuts, + _ => throw ArgumentError.value(command), +}; + class BusyMarkWysiwygToolbar extends StatelessWidget { const BusyMarkWysiwygToolbar({ super.key, required this.onBlockCommand, this.onAdmonitionCommand, + this.onWritersideInsert, + this.isWritersideInsertEnabled, + this.onMenuCanceled, required this.onInlineCommand, required this.onLinkCommand, required this.onInlineMathCommand, @@ -28,6 +57,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { this.isBlockCommandEnabled, this.admonitionCommandsEnabled = true, this.inlineCommandsEnabled = true, + this.semanticCommandsEnabled = true, this.lineBreakCommandsEnabled = true, this.admonitionsEnabled = false, this.alignEnd = false, @@ -36,14 +66,17 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final ValueChanged<BusyWysiwygBlockCommand> onBlockCommand; final ValueChanged<BusyAdmonitionStyle>? onAdmonitionCommand; + final ValueChanged<BusyWritersideInsertCommand>? onWritersideInsert; + final bool Function(BusyWritersideInsertCommand)? isWritersideInsertEnabled; + final VoidCallback? onMenuCanceled; final ValueChanged<BusyWysiwygInlineCommand> onInlineCommand; final VoidCallback onLinkCommand; - final VoidCallback onInlineMathCommand; - final VoidCallback onDisplayMathCommand; + final VoidCallback? onInlineMathCommand; + final VoidCallback? onDisplayMathCommand; final VoidCallback onImageCommand; final VoidCallback onInlineImageCommand; - final VoidCallback onTableCommand; - final VoidCallback onHtmlCommand; + final VoidCallback? onTableCommand; + final VoidCallback? onHtmlCommand; final VoidCallback onIndentCommand; final VoidCallback onOutdentCommand; final VoidCallback onToggleTaskCommand; @@ -52,6 +85,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final bool Function(BusyWysiwygBlockCommand command)? isBlockCommandEnabled; final bool admonitionCommandsEnabled; final bool inlineCommandsEnabled; + final bool semanticCommandsEnabled; final bool lineBreakCommandsEnabled; final bool admonitionsEnabled; final bool alignEnd; @@ -63,7 +97,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { return SingleChildScrollView( scrollDirection: axis, reverse: alignEnd, - clipBehavior: Clip.none, + clipBehavior: Clip.hardEdge, hitTestBehavior: HitTestBehavior.deferToChild, padding: axis == Axis.horizontal ? const EdgeInsets.symmetric( @@ -158,9 +192,8 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { : null, ), ], + if (admonitionsEnabled) [_writersideMenu(context)], [ - if (admonitionsEnabled && onAdmonitionCommand != null) - _admonitionMenu(context), _button( context, tooltip: context.l10n.blockquote, @@ -286,6 +319,75 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { return isBlockCommandEnabled?.call(command) ?? true; } + Widget _writersideMenu(BuildContext context) => + BusyMarkHeaderPopupMenuButton<BusyWritersideToolbarCommand>( + onCanceled: onMenuCanceled, + key: const ValueKey('wysiwyg-writerside'), + tooltip: context.l10n.writerside, + icon: BusyMarkGlyphs.insertObject, + transparent: false, + elevated: true, + foregroundColor: BusyMarkLinuxPalette.white, + backgroundColor: _editorToolbarButtonBackground(context), + itemBuilder: (context) => [ + if (onWritersideInsert != null) + BusyMarkSubmenuItem( + label: context.l10n.wsInsertElements, + items: [ + for (final command in BusyWritersideInsertCommand.values) + BusyMarkPopupMenuItem( + value: BusyWritersideToolbarInsert(command), + label: busyMarkWritersideInsertLabel(context, command), + enabled: isWritersideInsertEnabled?.call(command) ?? true, + ), + ], + ), + if (onAdmonitionCommand != null) + BusyMarkSubmenuItem( + label: context.l10n.wsAdmonitionsAndQuotes, + items: [ + for (final style in BusyAdmonitionStyle.values) + BusyMarkPopupMenuItem( + value: BusyWritersideToolbarAdmonition(style), + label: switch (style) { + BusyAdmonitionStyle.tip => context.l10n.tip, + BusyAdmonitionStyle.note => context.l10n.note, + BusyAdmonitionStyle.warning => context.l10n.warning, + BusyAdmonitionStyle.quote => context.l10n.quote, + }, + enabled: admonitionCommandsEnabled, + ), + ], + ), + BusyMarkSubmenuItem( + label: context.l10n.wsSemanticText, + items: [ + for (final command in const [ + BusyWysiwygInlineCommand.uiControl, + BusyWysiwygInlineCommand.filePath, + BusyWysiwygInlineCommand.uiPath, + BusyWysiwygInlineCommand.shortcut, + ]) + BusyMarkPopupMenuItem( + value: BusyWritersideToolbarSemantic(command), + enabled: inlineCommandsEnabled && semanticCommandsEnabled, + label: busyMarkSemanticInlineLabel(context, command), + ), + ], + ), + ], + onSelected: (selection) { + switch (selection) { + case BusyWritersideToolbarInsert(:final command): + onWritersideInsert!(command); + case BusyWritersideToolbarAdmonition(:final style): + onAdmonitionCommand!(style); + case BusyWritersideToolbarSemantic(:final command): + onInlineCommand(command); + } + }, + ); + List<Widget> _groups(Axis axis, List<List<Widget>> groups) { final widgets = <Widget>[]; for (final (groupIndex, group) @@ -306,6 +408,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { Widget _blockStyleMenu(BuildContext context) { return BusyMarkHeaderPopupMenuButton<BusyWysiwygBlockCommand>( + onCanceled: onMenuCanceled, tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, shortcut: BusyMarkEditorShortcutLabels.textStyle, @@ -368,52 +471,16 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { ); } - Widget _admonitionMenu(BuildContext context) { - return BusyMarkHeaderPopupMenuButton<BusyAdmonitionStyle>( - tooltip: context.l10n.admonition, - icon: BusyMarkGlyphs.info, - transparent: false, - elevated: true, - foregroundColor: BusyMarkLinuxPalette.white, - backgroundColor: _editorToolbarButtonBackground(context), - itemBuilder: (context) => [ - BusyMarkPopupMenuItem( - value: BusyAdmonitionStyle.tip, - label: context.l10n.tip, - icon: BusyMarkGlyphs.tip, - enabled: admonitionCommandsEnabled, - ), - BusyMarkPopupMenuItem( - value: BusyAdmonitionStyle.note, - label: context.l10n.note, - icon: BusyMarkGlyphs.info, - enabled: admonitionCommandsEnabled, - ), - BusyMarkPopupMenuItem( - value: BusyAdmonitionStyle.warning, - label: context.l10n.warning, - icon: BusyMarkGlyphs.warning, - enabled: admonitionCommandsEnabled, - ), - BusyMarkPopupMenuItem( - value: BusyAdmonitionStyle.quote, - label: context.l10n.quote, - icon: BusyMarkGlyphs.blockquote, - enabled: admonitionCommandsEnabled, - ), - ], - onSelected: onAdmonitionCommand!, - ); - } - Widget _button( BuildContext context, { required String tooltip, required IconData icon, required VoidCallback? onPressed, String? shortcut, + Key? key, }) { return BusyMarkHeaderIconButton( + key: key, tooltip: tooltip, icon: icon, onPressed: onPressed, diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart index e0cc378b..48423adf 100644 --- a/lib/src/export/html_document_writer.dart +++ b/lib/src/export/html_document_writer.dart @@ -866,6 +866,11 @@ class HtmlDocumentWriter { ); case BusyInlineKind.strikethrough: node = _el('del', children: nodes); + case BusyInlineKind.writersideControl: + node = _el('strong', children: nodes); + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.code: var text = value.text; if (a['shortcut-layouts'] case final layouts?) { diff --git a/lib/src/export/markdown_copy_export_service.dart b/lib/src/export/markdown_copy_export_service.dart index fdaca800..30206568 100644 --- a/lib/src/export/markdown_copy_export_service.dart +++ b/lib/src/export/markdown_copy_export_service.dart @@ -5,7 +5,7 @@ import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:uuid/uuid.dart'; -import '../assets/document_media_context.dart'; +import '../assets/document_media_resolver.dart'; import '../core/atomic_file_writer.dart'; import '../nextcloud_notes/data/notes_attachment_references.dart'; @@ -20,6 +20,8 @@ class MarkdownCopyExportService { required String destinationPath, required DocumentMediaContext media, bool overwrite = false, + void Function(String reference)? onMissing, + bool Function()? isCancelled, }) async { final occurrences = await scanNotesAttachmentReferences(source); final references = occurrences.map((r) => r.reference).toSet(); @@ -36,6 +38,9 @@ class MarkdownCopyExportService { final assets = Directory(p.join(staging.path, 'assets')); await assets.create(); for (final reference in references) { + if (isCancelled?.call() == true) { + throw const FileSystemException('Export cancelled.'); + } final uri = Uri.tryParse(reference); if (uri?.scheme == 'https' || uri?.scheme == 'http' || @@ -44,6 +49,13 @@ class MarkdownCopyExportService { } final path = await media.resolve(reference); if (path == null) { + if (onMissing != null && + (occurrences.any((r) => r.reference == reference && r.image) || + reference.startsWith('busymark-attachment:') || + RegExp(r'^\.attachments\.\d+/').hasMatch(reference))) { + onMissing(reference); + continue; + } if (reference.startsWith('busymark-attachment:') || RegExp(r'^\.attachments\.\d+/').hasMatch(reference)) { throw const FileSystemException( @@ -59,11 +71,11 @@ class MarkdownCopyExportService { 'The note attachments exceed the export size limit.', ); } - final bytes = await file.readAsBytes(); - totalBytes += bytes.length; + totalBytes += size; + final digest = await sha256.bind(file.openRead()).first; final extension = p.extension(path).toLowerCase(); - final name = '${sha256.convert(bytes)}$extension'; - await File(p.join(assets.path, name)).writeAsBytes(bytes, flush: true); + final name = '$digest$extension'; + await file.copy(p.join(assets.path, name)); final replacement = Uri(pathSegments: [folderName, name]).toString(); replacements[reference] = replacement; } @@ -76,6 +88,9 @@ class MarkdownCopyExportService { await assets.rename(published.path); ownsPublished = true; } + if (isCancelled?.call() == true) { + throw const FileSystemException('Export cancelled.'); + } await const AtomicFileWriter().writeBytes( destination, utf8.encode(exported), diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index 070c7262..34c7a718 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -463,6 +463,10 @@ class MarkdownExportMapper { text: inline.text, children: children, ), + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut || BusyInlineKind.code => MarkdownExportInline( kind: MarkdownExportInlineKind.code, text: inline.text, diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 209ba72e..2cfc54ac 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -1,6 +1,28 @@ import '../core/diagnostic.dart'; import '../core/source_span.dart'; import 'markdown_model.dart'; +import '../writerside/writerside_document.dart'; + +const busyMarkXmlBindingAttribute = 'busymark-xml-binding'; +const busyMarkWritersideContainerAttribute = 'busymark-writerside-container'; + +bool busyMarkIsWritersideContainer(BusyBlock block) => + block.attributes[busyMarkWritersideContainerAttribute] == 'true'; + +bool busyMarkIsSemanticInline(BusyInlineKind kind) => const { + BusyInlineKind.writersideControl, + BusyInlineKind.writersidePath, + BusyInlineKind.writersideUiPath, + BusyInlineKind.writersideShortcut, +}.contains(kind); + +String? busyMarkSemanticInlineTag(BusyInlineKind kind) => switch (kind) { + BusyInlineKind.writersideControl => 'control', + BusyInlineKind.writersidePath => 'path', + BusyInlineKind.writersideUiPath => 'ui-path', + BusyInlineKind.writersideShortcut => 'shortcut', + _ => null, +}; /// Marks an empty WYSIWYG paragraph that must remain a source blank line. const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph'; @@ -33,14 +55,16 @@ const busyMarkWritersideCodeBlockSourceFormAttribute = const busyMarkWritersideCodeBlockElementSourceForm = 'element'; bool busyMarkWritersideIsCollapsible(Map<String, String> attributes) { - return attributes[busyMarkWritersideCollapsibleAttribute] + return (attributes['busymark-effective-collapsible'] ?? + attributes[busyMarkWritersideCollapsibleAttribute]) ?.trim() .toLowerCase() == 'true'; } bool busyMarkWritersideInitiallyExpanded(Map<String, String> attributes) { - return attributes[busyMarkWritersideDefaultStateAttribute] + return (attributes['busymark-effective-default-state'] ?? + attributes[busyMarkWritersideDefaultStateAttribute]) ?.trim() .toLowerCase() == 'expanded'; @@ -85,6 +109,7 @@ class BusyDocument { this.frontMatter = const {}, this.rawFrontMatter, this.source, + this.authoredXml, }); final String filePath; @@ -96,6 +121,12 @@ class BusyDocument { final String? rawFrontMatter; final String? source; + /// The unresolved authored XML tree. Presentation resolution never owns saves. + final WritersideDocument? authoredXml; + bool get isXmlTopic => authoredXml != null; + bool get isWriterside => + isXmlTopic || mode == MarkdownMode.writersideMarkdown; + BusyDocument copyWith({ String? title, List<BusyBlock>? blocks, @@ -103,6 +134,7 @@ class BusyDocument { Map<String, String>? frontMatter, String? rawFrontMatter, String? source, + WritersideDocument? authoredXml, }) { return BusyDocument( filePath: filePath, @@ -113,6 +145,7 @@ class BusyDocument { frontMatter: frontMatter ?? this.frontMatter, rawFrontMatter: rawFrontMatter ?? this.rawFrontMatter, source: source ?? this.source, + authoredXml: authoredXml ?? this.authoredXml, ); } } @@ -215,6 +248,10 @@ enum BusyInlineKind { hardBreak, html, writersideVariable, + writersideControl, + writersidePath, + writersideUiPath, + writersideShortcut, unknown, } diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index f20b55f2..eeb5bdf4 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -1,6 +1,14 @@ import '../core/source_span.dart'; +import '../writerside/writerside_schema.dart'; +import '../writerside/writerside_document.dart'; +import '../writerside/writerside_document_parser.dart'; +import '../writerside/writerside_document_serializer.dart'; +import 'dart:convert'; import 'busymark_document.dart'; +import '../editor/wysiwyg/writerside_editing_adapter.dart'; import 'math_syntax.dart'; +import 'markdown_model.dart'; +import 'markdown_list_source.dart'; class BusyMarkSerializedInlineFragment { const BusyMarkSerializedInlineFragment({ @@ -257,16 +265,72 @@ class BusyMarkMarkdownSerializer { block.rawSource != null) { return block.rawSource!; } + if (block.attributes['busymark-property-edits'] != null && + block.rawSource != null && + !(block.attributes['busymark-list-form'] == 'markdown' && + block.children.any(_hasDirtyContent))) { + final preserved = _propertyOnlySource(block); + if (preserved != null) return preserved; + } + if (block.kind != BusyBlockKind.math && + {'p', 'code-block', 'img'}.contains(block.attributes['element']) && + block.rawSource?.trimLeft().startsWith('<') == true && + !busyMarkIsWritersideContainer(block)) { + return const WritersideEditingAdapter().serializeMarkdownElement(block); + } + if (busyMarkIsWritersideContainer(block)) { + if (block.attributes['busymark-list-form'] == 'markdown') { + final attributes = WritersideEditingAdapter.sourceAttributes(block); + final content = StringBuffer(); + for (final (i, child) in block.children.indexed) { + content.write(serializeBlock(child)); + if (i + 1 < block.children.length) { + final gap = child.attributes['busymark-native-trailing-gap']; + content.write(gap?.contains('\n') == true ? gap : '\n'); + } + } + return attributes.isEmpty + ? content.toString() + : '$content\n\n{${attributes.entries.map((a) => '${a.key}="${_attribute(a.value)}"').join(' ')}}'; + } + if (block.attributes['busymark-definition-form'] == 'markdown') { + final attrs = WritersideEditingAdapter.sourceAttributes(block); + final prefix = attrs.isEmpty + ? '' + : '{${attrs.entries.map((e) => '${e.key}="${WritersideEditingAdapter.escape(e.value)}"').join(' ')}}\n'; + return prefix + + block.children + .map( + (item) => + '${item.plainText}\n: ${item.children.map(serializeBlock).join('\n\n').replaceAll('\n', '\n ')}', + ) + .join('\n\n'); + } + return const WritersideEditingAdapter().serializeMarkdownElement(block); + } + if ({ + 'include', + 'video', + 'show-structure', + }.contains(block.attributes['element'])) { + return const WritersideEditingAdapter().serializeMarkdownElement(block); + } return switch (block.kind) { BusyBlockKind.heading => _heading(block), - BusyBlockKind.paragraph => _inlineMarkdown( - block.inlines, - atBlockStart: true, - readableHardBreakRuns: true, + BusyBlockKind.paragraph => _withBlockAttributes( + block, + _inlineMarkdown( + block.inlines, + atBlockStart: true, + readableHardBreakRuns: true, + ), ), BusyBlockKind.math => _mathBlock(block), BusyBlockKind.codeBlock => _codeBlock(block), - BusyBlockKind.unorderedListItem => _listItem(block, '-'), + BusyBlockKind.unorderedListItem => _listItem( + block, + block.attributes['marker'] ?? '-', + ), BusyBlockKind.orderedListItem => _listItem( block, block.attributes['marker'] ?? '1.', @@ -275,14 +339,14 @@ class BusyMarkMarkdownSerializer { block, block.attributes['ordered'] == 'true' ? block.attributes['marker'] ?? '1.' - : '-', + : block.attributes['marker'] ?? '-', contentPrefix: '[${block.attributes['task'] == 'true' ? 'x' : ' '}]', ), BusyBlockKind.blockquote => _blockquote(block), BusyBlockKind.thematicBreak => '---', BusyBlockKind.image => _image(block), BusyBlockKind.video => block.rawSource ?? _inlineMarkdown(block.inlines), - BusyBlockKind.table => _table(block), + BusyBlockKind.table => _withBlockAttributes(block, _table(block)), BusyBlockKind.writersideAdmonition => _writersideAdmonition(block), BusyBlockKind.writersideTabs || BusyBlockKind.writersideProcedure || @@ -294,11 +358,116 @@ class BusyMarkMarkdownSerializer { }; } + /// Publish native list attributes at their authored boundaries. Ancestor + /// dirtiness alone must not serialize a loose list or its nested items. + String? _nativeListPropertySource(BusyDocument document) { + final source = document.source; + if (source == null) return null; + final roots = document.blocks.where(_isSourceBackedBlock).toList(); + if (roots.any((b) => b.sourceSpan == null) || + _sourceOutsideSpansHasContent( + source, + roots.map((b) => b.sourceSpan!).toList(), + )) { + return null; + } + final edits = <({int start, int end, String value, int depth})>[]; + var found = false; + int? offset(BusyBlock block, int local) { + final encoded = block.attributes['busymark-native-source-map']; + if (encoded == null) return null; + final lines = jsonDecode(encoded) as List<dynamic>; + final segment = lines.where((p) => (p[0] as int) <= local).lastOrNull; + return segment == null + ? null + : (segment[1] as int) + local - (segment[0] as int); + } + + bool visit(BusyBlock block, int depth) { + if (!_hasDirtyContent(block)) return true; + final properties = + block.attributes['busymark-list-form'] == 'markdown' && + block.attributes.containsKey('busymark-property-edits'); + if (block.dirty || properties) { + final members = block.attributes['busymark-native-members']; + if (members == null || + members != jsonEncode(block.children.map((b) => b.id).toList()) || + block.attributes['busymark-native-inline-state'] != + nativeMarkdownInlineState(block.inlines) || + !properties && + block.attributes['busymark-native-attribute-state'] != + nativeMarkdownAttributeState(block.attributes)) { + return false; + } + } + if (properties) { + found = true; + final old = block.rawSource; + final changed = old == null ? null : _propertyOnlySource(block); + if (old == null || changed == null) return false; + var start = 0; + while (start < old.length && + start < changed.length && + old.codeUnitAt(start) == changed.codeUnitAt(start)) { + start++; + } + var suffix = 0; + while (suffix < old.length - start && + suffix < changed.length - start && + old.codeUnitAt(old.length - suffix - 1) == + changed.codeUnitAt(changed.length - suffix - 1)) { + suffix++; + } + final first = offset(block, start); + final last = offset(block, old.length - suffix); + if (first == null || + last == null || + first > last || + last > source.length) { + return false; + } + final base = block.sourceSpan?.startOffset; + if (base == null) return false; + final prefix = source.substring( + source.lastIndexOf('\n', base == 0 ? 0 : base - 1) + 1, + base, + ); + var value = changed.substring(start, changed.length - suffix); + if (RegExp(r'^[ \t>]*$').hasMatch(prefix) && prefix.isNotEmpty) { + value = value.replaceAllMapped( + prefix.contains('>') ? RegExp(r'\n') : RegExp(r'\n(?=[^\r\n])'), + (_) => '\n$prefix', + ); + } + edits.add((start: first, end: last, value: value, depth: depth)); + } + return block.children.every((b) => visit(b, depth + 1)); + } + + if (!document.blocks.every((b) => visit(b, 0)) || !found) return null; + edits.sort((a, b) { + final position = a.start.compareTo(b.start); + return position != 0 ? position : b.depth.compareTo(a.depth); + }); + final result = StringBuffer(); + var cursor = 0; + for (final edit in edits) { + if (edit.start < cursor) return null; + result + ..write(source.substring(cursor, edit.start)) + ..write(edit.value); + cursor = edit.end; + } + return '$result${source.substring(cursor)}'; + } + String? _serializeByPatchingSource(BusyDocument document) { final source = document.source; if (source == null) { return null; } + final listProperties = _nativeListPropertySource(document); + if (listProperties != null) return listProperties; final dirtyBlocks = [ for (final block in document.blocks) if (_isSourceBackedBlock(block) && @@ -392,13 +561,24 @@ class BusyMarkMarkdownSerializer { final text = _inlineMarkdown(block.inlines); final id = block.attributes['id']; final generated = block.attributes['generatedId'] != 'false'; + final authored = WritersideEditingAdapter.sourceAttributes(block); final attributes = <String>[ if (id != null && id.isNotEmpty && !generated) 'id="${_attribute(id)}"', - if (busyMarkWritersideIsCollapsible(block.attributes)) - 'collapsible="true"', + if (block.attributes[busyMarkWritersideCollapsibleAttribute] + case final value?) + 'collapsible="${_attribute(value)}"', if (block.attributes[busyMarkWritersideDefaultStateAttribute] case final state? when state.trim().isNotEmpty) 'default-state="${_attribute(state)}"', + for (final entry in authored.entries) + if (!{ + 'id', + busyMarkWritersideCollapsibleAttribute, + busyMarkWritersideDefaultStateAttribute, + busyMarkPreserveTextWhitespaceAttribute, + }.contains(entry.key) && + !entry.key.startsWith('busymark-')) + '${entry.key}="${_attribute(entry.value)}"', ]; final suffix = attributes.isEmpty ? '' : ' {${attributes.join(' ')}}'; return '${'#' * level} $text$suffix'; @@ -428,38 +608,164 @@ class BusyMarkMarkdownSerializer { final fence = delimiter * _delimiterLength(text, delimiter, minimum: 3); final infoSeparator = language.startsWith(delimiter) ? ' ' : ''; final source = '$fence$infoSeparator$language\n$text\n$fence'; - final hasSource = block.attributes['src']?.trim().isNotEmpty ?? false; - if (!busyMarkWritersideIsCollapsible(block.attributes) && !hasSource) { - return source; + return _withBlockAttributes(block, source); + } + + String? _propertyOnlySource(BusyBlock block) { + var source = block.rawSource!.trimRight(); + final edits = + jsonDecode(block.attributes['busymark-property-edits']!) + as Map<String, dynamic>; + if (edits.isEmpty) return block.rawSource!; + if (block.attributes.containsKey('element') && + source.trimLeft().startsWith('<')) { + final authored = const WritersideDocumentParser().parseMarkdown( + filePath: '', + source: block.rawSource!, + markdown: BusyDocument( + filePath: '', + mode: MarkdownMode.writersideMarkdown, + blocks: [ + BusyBlock( + id: 'property-source', + kind: BusyBlockKind.writersideRawXml, + rawSource: block.rawSource, + sourceSpan: SourceSpan.entireFile('', block.rawSource!), + ), + ], + ), + ); + final original = authored.nodes + .whereType<WritersideElementNode>() + .firstOrNull; + if (original == null) return null; + final attributes = {...original.attributes}; + for (final edit in edits.entries) { + final name = edit.key == 'language' ? 'lang' : edit.key; + if (edit.value == null || edit.value == '') { + attributes.remove(name); + } else { + attributes[name] = edit.value as String; + } + } + return const WritersideDocumentSerializer().serialize( + authored.copyWith( + nodes: [ + for (final node in authored.nodes) + identical(node, original) + ? original.copyWith(attributes: attributes) + : node, + ], + ), + ); } - final attributes = <String>[ - 'collapsible="true"', - if (block.attributes[busyMarkWritersideCollapsedTitleAttribute] - case final title? when title.trim().isNotEmpty) - 'collapsed-title="${_attribute(title)}"', - if (block.attributes[busyMarkWritersideDefaultStateAttribute] - case final state? when state.trim().isNotEmpty) - 'default-state="${_attribute(state)}"', - if (block.attributes['src'] case final source? - when source.trim().isNotEmpty) - 'src="${_attribute(source)}"', - ]; - return '$source\n{${attributes.join(' ')}}'; + final authored = block.attributes['busymark-writerside-source-attributes']; + final hasLanguageOverride = + block.kind == BusyBlockKind.codeBlock && + authored != null && + (jsonDecode(authored) as Map<String, dynamic>).containsKey('lang'); + if (block.kind == BusyBlockKind.codeBlock && + edits.containsKey('language') && + !hasLanguageOverride) { + final opening = RegExp(r'^(\s*)(`{3,}|~{3,})[^\r\n]*').firstMatch(source); + final language = edits['language'] as String? ?? ''; + if (opening == null || + language.contains('`') || + language.contains('\n')) { + return null; + } + source = source.replaceRange( + 0, + opening.end, + '${opening[1]}${opening[2]}$language', + ); + } + final suffix = authored == null + ? null + : RegExp( + r'\{(?:\s*[\w:.-]+\s*=\s*"[^"]*"\s*)+\}\s*$', + ).firstMatch(source); + var attributes = suffix == null + ? '' + : source.substring(suffix.start + 1, source.lastIndexOf('}')); + for (final edit in edits.entries) { + if (edit.key == 'language' && !hasLanguageOverride) continue; + final name = edit.key == 'language' ? 'lang' : edit.key; + final pattern = RegExp( + '(?:^|\\s+)${RegExp.escape(name)}\\s*=\\s*"[^"]*"', + ); + final value = edit.value as String?; + final replacement = value == null || value.isEmpty + ? '' + : '$name="${_attribute(value)}"'; + if (pattern.hasMatch(attributes)) { + attributes = attributes.replaceFirstMapped( + pattern, + (m) => + '${m[0]!.startsWith(' ') && replacement.isNotEmpty ? ' ' : ''}$replacement', + ); + } else if (replacement.isNotEmpty) { + attributes = '${attributes.trimRight()} $replacement'; + } + } + final ending = attributes.trim().isEmpty ? '' : '{${attributes.trim()}}'; + if (suffix != null) { + if (block.kind == BusyBlockKind.blockquote || + block.attributes['busymark-list-form'] == 'markdown') { + final content = source.substring(0, suffix.start).trimRight(); + final newline = + block.attributes['busymark-property-line-ending'] ?? '\n'; + return ending.isEmpty ? content : '$content$newline$newline$ending'; + } + return source + .replaceRange(suffix.start, source.length, ending) + .trimRight(); + } + if (ending.isEmpty) return source; + return '$source${block.kind == BusyBlockKind.image + ? '' + : block.kind == BusyBlockKind.blockquote || block.attributes['busymark-list-form'] == 'markdown' + ? '${block.attributes['busymark-property-line-ending'] ?? '\n'}${block.attributes['busymark-property-line-ending'] ?? '\n'}' + : block.kind == BusyBlockKind.heading + ? ' ' + : block.attributes['busymark-property-line-ending'] ?? '\n'}$ending'; } - bool _writersideCodeBlockXmlAttribute(String key) => !{ - 'element', - 'lang', - 'language', - 'editorBlockId', - busyMarkWritersideCodeBlockSourceFormAttribute, - }.contains(key); + String _withBlockAttributes(BusyBlock block, String source) { + final authored = block.attributes['busymark-writerside-source-attributes']; + final original = authored == null + ? const <String, dynamic>{} + : jsonDecode(authored) as Map<String, dynamic>; + final tag = WritersideEditingAdapter.tagForBlock(block); + final attributes = WritersideEditingAdapter.sourceAttributes(block) + ..removeWhere( + (k, v) => + !WritersideSchema.preservedAttributesFor(tag).contains(k) && + !original.containsKey(k), + ); + if (attributes.isEmpty) return source; + return '$source\n{${attributes.entries.map((e) => '${e.key}="${_attribute(e.value)}"').join(' ')}}'; + } + + bool _writersideCodeBlockXmlAttribute(String key) => + !{ + 'element', + 'lang', + 'language', + 'editorBlockId', + busyMarkWritersideCodeBlockSourceFormAttribute, + }.contains(key) && + !key.startsWith('busymark-'); String _xmlAttribute(String value) => busyMarkEncodeXmlMathText( value, ).replaceAll('"', '"').replaceAll("'", '''); - String _attribute(String value) => value.replaceAll('"', '"'); + String _attribute(String value) => value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); String _mathBlock(BusyBlock block) { final expression = @@ -609,25 +915,64 @@ class BusyMarkMarkdownSerializer { final style = busyAdmonitionStyleFromName(block.attributes['style']) ?? BusyAdmonitionStyle.tip; - if (style == BusyAdmonitionStyle.tip) { - return quote; + final attributes = WritersideEditingAdapter.sourceAttributes(block); + attributes.remove('style'); + if (style != BusyAdmonitionStyle.tip) attributes['style'] = style.name; + if (attributes.isEmpty) return quote; + final attribute = + '{${attributes.entries.map((e) => '${e.key}="${_attribute(e.value)}"').join(' ')}}'; + final separator = attributes.keys.any((name) => name != 'style') + ? '\n\n' + : '\n'; + return quote.isEmpty ? attribute : '$quote$separator$attribute'; + } + + String _withInlineReferenceAttributes(BusyInline inline, String source) { + if (inline.attributes['busymark-writerside-attributes'] != 'true') { + return source; } - final attribute = '{style="${style.name}"}'; - return quote.isEmpty ? attribute : '$quote\n$attribute'; + final tag = inline.kind == BusyInlineKind.image + ? 'img' + : inline.kind == BusyInlineKind.link + ? 'a' + : null; + if (tag == null) return source; + final attrs = inline.attributes.entries.where( + (entry) => + !{'src', 'href', 'alt', 'title'}.contains(entry.key) && + WritersideSchema.preservedAttributesFor(tag).contains(entry.key), + ); + final attributes = [ + ...attrs, + if (inline.attributes['busymark-ignored-href'] case final href?) + MapEntry('href', href), + ].map((e) => '${e.key}="${_attribute(e.value)}"').join(' '); + return attributes.isEmpty ? source : '$source{$attributes}'; } String _image(BusyBlock block) { - final imageInline = block.inlines - .where((inline) => inline.kind == BusyInlineKind.image) - .firstOrNull; - final alt = imageInline?.text ?? block.plainText; - final src = block.attributes['src'] ?? imageInline?.destination ?? ''; - final attributes = {...block.attributes} - ..removeWhere((key, value) => {'src', 'alt', 'title'}.contains(key)); - final attrText = attributes.isEmpty - ? '' - : '{ ${attributes.entries.map((entry) => '${entry.key}="${entry.value}"').join(' ')} }'; - return '![${_escapeInlineText(alt)}]($src)$attrText'; + final original = _inlineMarkdown(block.inlines); + final attributes = WritersideEditingAdapter.sourceAttributes(block) + ..removeWhere( + (key, value) => {'src', 'alt', 'title', 'href'}.contains(key), + ); + final inlineAttributes = <String, String>{}; + void collect(BusyInline inline) { + if (inline.kind == BusyInlineKind.image && + inline.attributes['busymark-writerside-attributes'] == 'true') { + inlineAttributes.addAll(inline.attributes); + } + for (final child in inline.children) { + collect(child); + } + } + + for (final inline in block.inlines) { + collect(inline); + } + attributes.removeWhere((key, value) => inlineAttributes[key] == value); + if (attributes.isEmpty) return original; + return '$original{${attributes.entries.map((e) => '${e.key}="${_attribute(e.value)}"').join(' ')}}'; } String _table(BusyBlock block) { @@ -712,6 +1057,14 @@ class BusyMarkMarkdownSerializer { .replaceAll('>', '>'); } + String _semanticInlineOpening(BusyInline inline) { + final attributes = inline.attributes.entries + .where((e) => !e.key.startsWith('xml-')) + .map((e) => ' ${e.key}="${_escapeXmlAttribute(e.value)}"') + .join(); + return '<${busyMarkSemanticInlineTag(inline.kind)}$attributes>'; + } + _InlineSerialization _inlineMarkdownAtTextOffsets( List<BusyInline> inlines, { required Set<int> textOffsets, @@ -1014,7 +1367,7 @@ class BusyMarkMarkdownSerializer { : _escapeInlineText(inline.text, atBlockStart: atBlockStart) : childResult!.source; final delimiter = delimiterOverrides[inline.kind]; - final source = switch (inline.kind) { + final nativeSource = switch (inline.kind) { BusyInlineKind.text => rawText ? inline.text @@ -1047,8 +1400,21 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.softBreak => ' ', BusyInlineKind.hardBreak => ' \n', BusyInlineKind.writersideVariable => '%${inline.text}%', + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => + tableCell + ? WritersideEditingAdapter.serializeInlineElement( + inline, + ).replaceAll('|', '|') + : WritersideEditingAdapter.serializeInlineElement(inline), BusyInlineKind.html || BusyInlineKind.unknown => inline.text, }; + final source = + {'a', 'img'}.contains(inline.attributes['xml-name']?.split(':').last) + ? WritersideEditingAdapter.serializeInlineElement(inline) + : _withInlineReferenceAttributes(inline, nativeSource); final escapedTextOffsets = rawText ? {for (final target in targets) target: target} : inline.kind == BusyInlineKind.text @@ -1067,8 +1433,38 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.strong || BusyInlineKind.strikethrough => 2, BusyInlineKind.underline => 3, BusyInlineKind.emphasis => 1, + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => _semanticInlineOpening( + inline, + ).length, _ => 0, }; + // Semantic children are serialized as XML, including their formatting. + // Use the existing XML text/source mapping for offsets inside that markup. + const semanticPrefix = '<topic id="offset" title="Offset"><p>'; + final semanticDocument = + targets.any((t) => t > 0 && t < length) && + busyMarkIsSemanticInline(inline.kind) && + inline.children.any((child) => child.kind != BusyInlineKind.text) + ? const WritersideEditingAdapter().parseXml( + filePath: '', + source: '$semanticPrefix$source</p></topic>', + ) + : null; + int semanticOffset(int target) => semanticDocument == null + ? openingLength + + WritersideEditingAdapter.escape( + inline.plainText.substring(0, target), + ).replaceAll('|', tableCell ? '|' : '|').length + : const WritersideEditingAdapter().xmlTextSourceOffset( + semanticDocument, + semanticDocument.blocks.first.children.single, + target, + endBoundary: false, + )! - + semanticPrefix.length; final sourceOffsets = <int, int>{}; for (final target in targets) { sourceOffsets[target] = target == 0 @@ -1082,6 +1478,10 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.underline || BusyInlineKind.strikethrough => openingLength + (childResult?.sourceOffsets[target] ?? 0), + BusyInlineKind.writersideControl || + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut => semanticOffset(target), BusyInlineKind.link => linkOpeningLength + (childResult?.sourceOffsets[target] ?? @@ -1272,6 +1672,11 @@ class BusyMarkMarkdownSerializer { final unit = value.codeUnitAt(index); if (index >= literalPrefixLength && (_inlineSyntaxCharacters.contains(unit) || + (unit == 0x7b && + RegExp( + r'''\{\s*[\w:.-]+\s*=\s*(?:"[^"\n]*"|'[^'\n]*')''', + ).matchAsPrefix(value, index) != + null) || (escapeTrailingBang && unit == 0x21 && index == value.length - 1) || @@ -1407,6 +1812,11 @@ class BusyMarkMarkdownSerializer { final unit = value.codeUnitAt(index); if (index >= literalPrefixLength && (_inlineSyntaxCharacters.contains(unit) || + (unit == 0x7b && + RegExp( + r'''\{\s*[\w:.-]+\s*=\s*(?:"[^"\n]*"|'[^'\n]*')''', + ).matchAsPrefix(value, index) != + null) || (escapeTrailingBang && unit == 0x21 && index == value.length - 1) || @@ -1571,7 +1981,3 @@ class _HardBreakRunSerialization { class _InlineTraversalMetrics { int visitedNodes = 0; } - -extension _FirstOrNull<T> on Iterable<T> { - T? get firstOrNull => isEmpty ? null : first; -} diff --git a/lib/src/markdown/busymark_preview_builder.dart b/lib/src/markdown/busymark_preview_builder.dart deleted file mode 100644 index cf7fa31d..00000000 --- a/lib/src/markdown/busymark_preview_builder.dart +++ /dev/null @@ -1 +0,0 @@ -export 'preview_model.dart' show BusyMarkPreviewBuilder; diff --git a/lib/src/markdown/document_outline.dart b/lib/src/markdown/document_outline.dart index f50242b8..9e26daa4 100644 --- a/lib/src/markdown/document_outline.dart +++ b/lib/src/markdown/document_outline.dart @@ -46,7 +46,14 @@ extension BusyDocumentOutline on BusyDocument { final headings = <DocumentOutlineHeading>[]; final generatedIdOccurrences = <String, int>{}; - for (final block in blocks) { + Iterable<BusyBlock> xmlHeadings(Iterable<BusyBlock> candidates) sync* { + for (final block in candidates) { + if (block.kind == BusyBlockKind.heading) yield block; + yield* xmlHeadings(block.children); + } + } + + for (final block in isXmlTopic ? xmlHeadings(blocks) : blocks) { if (block.kind != BusyBlockKind.heading) { continue; } diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 3462755c..16748153 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:convert'; import 'package:markdown/markdown.dart' as md; import 'package:xml/xml.dart'; @@ -13,7 +14,9 @@ import 'math_syntax.dart'; import 'raw_html_adapter.dart'; import 'raw_html_policy.dart'; import 'writerside_variable_syntax.dart'; +import 'writerside_authoring_syntax.dart'; import '../writerside/writerside_schema.dart'; +import '../editor/wysiwyg/writerside_editing_adapter.dart'; const _rawHtmlAdapter = RawHtmlAdapter(); @@ -63,8 +66,10 @@ class MarkdownAstAdapter { String? title, bool preserveHtmlSemantics = false, }) { - final frontMatter = _extractFrontMatter(source); - final imageAttributes = _imageAttributeBlocks(source); + final frontMatter = _extractFrontMatter( + source, + writerside: mode == MarkdownMode.writersideMarkdown, + ); final markdownSource = frontMatter == null ? source : source.substring(frontMatter.endOffset).trimLeft(); @@ -88,7 +93,7 @@ class MarkdownAstAdapter { filePath: filePath, mode: mode, title: title, - blocks: _applyImageAttributes(blocks, imageAttributes), + blocks: blocks, frontMatter: frontMatter?.values ?? const {}, rawFrontMatter: frontMatter?.raw, source: source, @@ -262,26 +267,6 @@ class MarkdownAstAdapter { : blocks; } - List<BusyBlock> _applyImageAttributes( - List<BusyBlock> blocks, - List<Map<String, String>> imageAttributes, - ) { - var imageIndex = 0; - BusyBlock visit(BusyBlock block) { - final children = block.children.map(visit).toList(); - if (block.kind == BusyBlockKind.image && - imageIndex < imageAttributes.length) { - return block.copyWith( - children: children, - attributes: {...block.attributes, ...imageAttributes[imageIndex++]}, - ); - } - return children.isEmpty ? block : block.copyWith(children: children); - } - - return blocks.map(visit).toList(); - } - List<BusyBlock> _blocksFromNode( md.Node node, { required String Function() nextId, @@ -392,7 +377,16 @@ class MarkdownAstAdapter { 'level': '$level', 'id': anchorId, 'generatedId': '${attrId == null}', - ...trailingAttributes, + for (final entry in trailingAttributes.entries) + if (!WritersideEditingAdapter.isProjectionAttribute( + entry.key, + BusyBlockKind.heading, + )) + entry.key: entry.value, + if (trailingAttributes.isNotEmpty) + 'busymark-writerside-source-attributes': jsonEncode( + trailingAttributes, + ), }, ), ]; @@ -409,16 +403,35 @@ class MarkdownAstAdapter { if (writerside != null) { return [writerside]; } - final inlines = _inlinesFromNodes(children); + final parsedInlines = _inlinesFromNodes(children); + final inlines = mode == MarkdownMode.writersideMarkdown + ? _attachReferenceAttributes(parsedInlines) + : parsedInlines; final imageBlock = _imageBlockFromParagraph(node, inlines, nextId); if (imageBlock != null) { return [imageBlock]; } + final paragraphProperties = mode == MarkdownMode.writersideMarkdown + ? _paragraphAttributeInline(inlines) + : (attributes: const <String, String>{}, inlines: inlines); return [ BusyBlock( id: nextId(), kind: BusyBlockKind.paragraph, - inlines: _normalizeSoftBreaks(inlines), + inlines: _normalizeSoftBreaks(paragraphProperties.inlines), + attributes: paragraphProperties.attributes.isNotEmpty + ? { + for (final entry in paragraphProperties.attributes.entries) + if (!WritersideEditingAdapter.isProjectionAttribute( + entry.key, + BusyBlockKind.paragraph, + )) + entry.key: entry.value, + 'busymark-writerside-source-attributes': jsonEncode( + paragraphProperties.attributes, + ), + } + : const {}, ), ]; } @@ -500,11 +513,17 @@ class MarkdownAstAdapter { ]; final attributes = <String, String>{}; if (mode == MarkdownMode.writersideMarkdown) { + blocks = _attachWritersideCodeAttributes(blocks); final admonition = _writersideBlockquoteAdmonition(blocks); blocks = admonition.blocks; attributes.addAll({ busyMarkWritersideAdmonitionAttribute: 'true', busyMarkWritersideAdmonitionSourceFormAttribute: 'blockquote', + ...admonition.attributes, + if (admonition.attributes.isNotEmpty) + 'busymark-writerside-source-attributes': jsonEncode( + admonition.attributes, + ), 'style': admonition.style.name, }); } @@ -528,7 +547,7 @@ class MarkdownAstAdapter { } if (tag == 'table') { - final tableRows = _tableRows(node, nextId); + final tableRows = _tableRows(node, nextId, mode); return [ if (_tableCaption(node, nextId) case final caption?) caption, BusyBlock(id: nextId(), kind: BusyBlockKind.table, children: tableRows), @@ -553,7 +572,36 @@ class MarkdownAstAdapter { ]; } - if (mode == MarkdownMode.writersideMarkdown && _writersideBlockTag(tag)) { + if (mode == MarkdownMode.writersideMarkdown && + (_writersideBlockTag(tag) || + WritersideEditingAdapter.containers.contains(tag))) { + if (WritersideEditingAdapter.containers.contains(tag) && + !_writersideAdmonitionTag(tag)) { + final titled = WritersideEditingAdapter.titleElements.contains(tag); + return [ + BusyBlock( + id: nextId(), + kind: _writersideKind(tag), + inlines: titled + ? [ + BusyInline( + kind: BusyInlineKind.text, + text: node.attributes['title'] ?? '', + ), + ] + : const [], + children: [ + for (final child in children) + ..._blocksFromNode(child, nextId: nextId, mode: mode), + ], + attributes: { + ...node.attributes, + 'element': tag, + busyMarkWritersideContainerAttribute: 'true', + }, + ), + ]; + } if (tag == 'code-block') { return [ _writersideCodeBlock( @@ -620,6 +668,57 @@ class MarkdownAstAdapter { ]; } + List<BusyInline> _attachReferenceAttributes(List<BusyInline> inlines) { + final values = [ + for (final inline in inlines) + inline.children.isEmpty + ? inline + : inline.copyWith( + children: _attachReferenceAttributes(inline.children), + ), + ]; + final result = <BusyInline>[]; + for (var index = 0; index < values.length; index++) { + var inline = values[index]; + if ({BusyInlineKind.image, BusyInlineKind.link}.contains(inline.kind) && + index + 1 < values.length) { + final next = values[index + 1]; + final attributes = _attributeTokenAttributes(next); + final tag = inline.kind == BusyInlineKind.image ? 'img' : 'a'; + if (attributes.isNotEmpty && + attributes.keys.every( + WritersideSchema.preservedAttributesFor(tag).contains, + )) { + inline = inline.copyWith( + destination: inline.kind == BusyInlineKind.image + ? attributes['src'] ?? inline.destination + : inline.destination, + text: inline.kind == BusyInlineKind.image + ? attributes['alt'] ?? inline.text + : inline.text, + attributes: { + ...inline.attributes, + ...attributes, + if (inline.kind == BusyInlineKind.link) ...{ + if (attributes['href'] != null) + 'busymark-ignored-href': attributes['href']!, + if (inline.destination != null) 'href': inline.destination!, + }, + 'busymark-writerside-attributes': 'true', + }, + ); + // Ownership consumes the whole lexical token. Reference metadata + // cannot subsequently make the reference itself a paragraph token. + index++; + } + } + if (inline.kind != BusyInlineKind.text || inline.text.isNotEmpty) { + result.add(inline); + } + } + return result; + } + List<BusyBlock> _listBlocksFromNode( md.Element node, { required bool ordered, @@ -641,6 +740,17 @@ class MarkdownAstAdapter { final inlineNodes = <md.Node>[]; var hasPrimaryContent = false; for (final child in contentChildren) { + if (mode == MarkdownMode.writersideMarkdown && child is md.Text) { + final semantic = _writersideBlockFromText( + child.text, + nextId: nextId, + allowVideo: true, + ); + if (semantic != null) { + nestedBlocks.add(semantic); + continue; + } + } final blockChild = child is md.Element && (child.tag == 'ul' || @@ -674,14 +784,35 @@ class MarkdownAstAdapter { ? BusyBlockKind.orderedListItem : BusyBlockKind.unorderedListItem : BusyBlockKind.taskListItem, - inlines: _normalizeSoftBreaks(_inlinesFromNodes(inlineNodes)), - children: nestedBlocks, + inlines: _normalizeSoftBreaks( + mode == MarkdownMode.writersideMarkdown + ? _attachReferenceAttributes(_inlinesFromNodes(inlineNodes)) + : _inlinesFromNodes(inlineNodes), + ), + children: mode == MarkdownMode.writersideMarkdown + ? _attachWritersideCodeAttributes(nestedBlocks) + : nestedBlocks, attributes: attributes, ), ); number++; } - return result; + return mode == MarkdownMode.writersideMarkdown && result.isNotEmpty + ? [ + BusyBlock( + id: 'list-${result.first.id}', + kind: BusyBlockKind.writersideRawXml, + children: result, + attributes: { + 'element': 'list', + busyMarkWritersideContainerAttribute: 'true', + 'busymark-list-form': 'markdown', + 'busymark-native-list-type': ordered ? 'decimal' : 'bullet', + 'busymark-native-list-start': node.attributes['start'] ?? '1', + }, + ), + ] + : result; } /// Runs the ordinary semantic converter with optional source annotations. @@ -821,6 +952,18 @@ class MarkdownAstAdapter { int? mappingEnd, Iterable<String> ignoredPositionMarkers = const [], }) { + if (node is md.Element && node.tag == writersideAttributeTokenTag) { + return [ + BusyInline( + kind: BusyInlineKind.text, + text: node.textContent, + attributes: { + writersideAttributeTokenTag: + '${node.attributes[busyMarkSourceMappingStartAttribute]}:${node.attributes[busyMarkSourceMappingEndAttribute]}', + }, + ), + ]; + } if (node is md.Element && node.tag == busyMarkLiteralLessThanTag) { return [BusyInline(kind: BusyInlineKind.text, text: node.textContent)]; } @@ -928,6 +1071,19 @@ class MarkdownAstAdapter { ); final text = node.textContent; final inlines = switch (tag) { + 'control' || 'path' || 'ui-path' || 'shortcut' => [ + BusyInline( + kind: switch (tag) { + 'control' => BusyInlineKind.writersideControl, + 'path' => BusyInlineKind.writersidePath, + 'ui-path' => BusyInlineKind.writersideUiPath, + _ => BusyInlineKind.writersideShortcut, + }, + text: text, + children: children, + attributes: node.attributes, + ), + ], busyMarkMathInlineTag => [ BusyInline( kind: BusyInlineKind.math, @@ -1104,7 +1260,9 @@ class MarkdownAstAdapter { return null; } final imageInline = images.single; - final plain = paragraph.textContent.trim(); + // Inline attribute overrides can change the authored image's alt text. + // Use the adapted inlines, rather than the pre-override Markdown AST label. + final plain = inlines.map((inline) => inline.plainText).join().trim(); final attributeText = plain.replaceFirst(imageInline.text, '').trim(); if (inlines.length > 2 || (attributeText.isNotEmpty && !attributeText.startsWith('{'))) { @@ -1144,7 +1302,11 @@ class MarkdownAstAdapter { } } - List<BusyBlock> _tableRows(md.Element table, String Function() nextId) { + List<BusyBlock> _tableRows( + md.Element table, + String Function() nextId, + MarkdownMode mode, + ) { final rows = <BusyBlock>[]; void addRow(md.Element row, {required bool sectionHeader}) { @@ -1168,7 +1330,11 @@ class MarkdownAstAdapter { BusyBlock( id: nextId(), kind: BusyBlockKind.paragraph, - inlines: _inlinesFromNodes(cell.children ?? const []), + inlines: mode == MarkdownMode.writersideMarkdown + ? _attachReferenceAttributes( + _inlinesFromNodes(cell.children ?? const []), + ) + : _inlinesFromNodes(cell.children ?? const []), attributes: _tableCellAttributes(cell), ), ], @@ -1223,6 +1389,19 @@ class MarkdownAstAdapter { required String Function() nextId, required bool allowVideo, }) { + final semantic = const WritersideEditingAdapter().parseMarkdownElement( + value, + nextId, + ); + if (semantic != null && + !{ + 'tip', + 'note', + 'warning', + 'quote', + }.contains(semantic.attributes['element'])) { + return semantic; + } try { final fragment = XmlDocumentFragment.parse(value.trim()); final elements = fragment.children.whereType<XmlElement>().toList(); @@ -1411,7 +1590,58 @@ class MarkdownAstAdapter { if (stripped == text) { return inlines; } - return [BusyInline(kind: BusyInlineKind.text, text: stripped)]; + return _takeInlinePrefix(inlines, stripped.length); + } + + ({Map<String, String> attributes, List<BusyInline> inlines}) + _paragraphAttributeInline(List<BusyInline> inlines) { + var index = inlines.length - 1; + while (index >= 0 && + inlines[index].kind == BusyInlineKind.text && + inlines[index].text.trim().isEmpty) { + index--; + } + if (index < 1) { + return (attributes: const {}, inlines: inlines); + } + final attributes = _attributeTokenAttributes(inlines[index]); + if (attributes.isEmpty) return (attributes: const {}, inlines: inlines); + final content = inlines.take(index).toList(); + if (content.last.kind == BusyInlineKind.text) { + final last = content.removeLast(); + if (last.text.trimRight().isNotEmpty) { + content.add(last.copyWith(text: last.text.trimRight())); + } + } + return (attributes: attributes, inlines: content); + } + + Map<String, String> _attributeTokenAttributes(BusyInline inline) { + if (inline.kind != BusyInlineKind.text || inline.children.isNotEmpty) { + return const {}; + } + final range = inline.attributes[writersideAttributeTokenTag]?.split(':'); + if (range == null || range.length != 2) return const {}; + final start = int.tryParse(range.first), end = int.tryParse(range.last); + // Only the inline grammar emits this range. Reject consumed/changed tokens + // and metadata without a complete, matching lexical source range. + if (start == null || + end == null || + start < 0 || + end - start != inline.text.length || + end <= start) { + return const {}; + } + return _standaloneAttributeBlock(inline.text); + } + + Map<String, String> _standaloneAttributeInline(List<BusyInline> inlines) { + final content = inlines + .where((i) => i.kind != BusyInlineKind.text || i.text.trim().isNotEmpty) + .toList(); + return content.length == 1 + ? _attributeTokenAttributes(content.single) + : const {}; } bool? _taskChecked(List<md.Node> children) { @@ -1456,15 +1686,61 @@ class MarkdownAstAdapter { var index = 0; while (index < blocks.length) { final block = blocks[index]; - if (block.kind == BusyBlockKind.codeBlock && index + 1 < blocks.length) { + if (block.attributes['busymark-list-form'] == 'markdown' && + index + 1 < blocks.length) { final attributeBlock = blocks[index + 1]; final attributes = attributeBlock.kind == BusyBlockKind.paragraph - ? _standaloneAttributeBlock(attributeBlock.plainText) + ? _standaloneAttributeInline(attributeBlock.inlines) : const <String, String>{}; - if (busyMarkWritersideIsCollapsible(attributes) || - (attributes['src']?.trim().isNotEmpty ?? false)) { + if (attributes.isNotEmpty) { result.add( - block.copyWith(attributes: {...block.attributes, ...attributes}), + block.copyWith( + attributes: { + ...block.attributes, + for (final a in attributes.entries) + if (!WritersideEditingAdapter.isProjectionAttribute( + a.key, + block.kind, + )) + a.key: a.value, + 'busymark-writerside-source-attributes': jsonEncode(attributes), + }, + ), + ); + index += 2; + continue; + } + } + if ({ + BusyBlockKind.codeBlock, + BusyBlockKind.table, + BusyBlockKind.blockquote, + }.contains(block.kind) && + index + 1 < blocks.length) { + final attributeBlock = blocks[index + 1]; + final attributes = attributeBlock.kind == BusyBlockKind.paragraph + ? _standaloneAttributeInline(attributeBlock.inlines) + : const <String, String>{}; + if (attributes.isNotEmpty) { + result.add( + block.copyWith( + attributes: { + ...block.attributes, + for (final entry in attributes.entries) + if (!WritersideEditingAdapter.isProjectionAttribute( + entry.key, + block.kind, + )) + entry.key: entry.value, + if (block.kind == BusyBlockKind.codeBlock && + attributes['lang'] != null) + 'language': attributes['lang']!, + 'busymark-writerside-source-attributes': jsonEncode(attributes), + if (block.kind == BusyBlockKind.blockquote) + 'style': + attributes['style'] ?? block.attributes['style'] ?? 'tip', + }, + ), ); index += 2; continue; @@ -1501,7 +1777,14 @@ class MarkdownAstAdapter { for (final match in RegExp( r'([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*"([^"]*)"', ).allMatches(raw)) { - attributes[match.group(1)!] = match.group(2)!; + final encoded = match.group(2)!; + try { + attributes[match.group(1)!] = XmlDocumentFragment.parse( + encoded, + ).innerText; + } on XmlParserException { + attributes[match.group(1)!] = encoded; + } } return attributes; } @@ -1629,17 +1912,9 @@ class MarkdownAstAdapter { }); } - List<Map<String, String>> _imageAttributeBlocks(String source) { - return [ - for (final match in RegExp( - r'!\[[^\]]*\]\([^)]+\)\s*(\{[^}]+\})', - ).allMatches(source)) - _parseAttributeBlock(match.group(1)!), - ]; - } - bool _writersideBlockTag(String tag) { - return WritersideSchema.isMarkdownSemanticBlock(tag); + return !{'control', 'path', 'ui-path', 'shortcut'}.contains(tag) && + WritersideSchema.isMarkdownSemanticBlock(tag); } bool _editableWritersideTag(String tag) { @@ -1663,33 +1938,39 @@ class MarkdownAstAdapter { }; } - ({BusyAdmonitionStyle style, List<BusyBlock> blocks}) + ({ + BusyAdmonitionStyle style, + List<BusyBlock> blocks, + Map<String, String> attributes, + }) _writersideBlockquoteAdmonition(List<BusyBlock> blocks) { - for (var index = blocks.length - 1; index >= 0; index -= 1) { - final block = blocks[index]; - if (block.kind != BusyBlockKind.paragraph || block.inlines.isEmpty) { - continue; - } - final match = RegExp(r'\s*\{([^{}]*)\}\s*$').firstMatch(block.plainText); - if (match == null) { - break; - } - final style = busyAdmonitionStyleFromName( - _parseAttributePairs(match.group(1)!)['style'], - ); - if (style == null) { - break; - } - final cleaned = _takeInlinePrefix(block.inlines, match.start); - final updated = [...blocks]; - if (cleaned.isEmpty && block.children.isEmpty) { - updated.removeAt(index); - } else { - updated[index] = block.copyWith(inlines: cleaned); + if (blocks.isNotEmpty && blocks.last.kind == BusyBlockKind.paragraph) { + final last = blocks.last; + final recorded = last.attributes['busymark-writerside-source-attributes']; + final tokenAttributes = _standaloneAttributeInline(last.inlines); + final token = tokenAttributes.isNotEmpty; + final attributes = recorded != null + ? Map<String, String>.from(jsonDecode(recorded) as Map) + : token + ? tokenAttributes + : <String, String>{}; + if (attributes.isNotEmpty) { + final content = [...blocks.take(blocks.length - 1)]; + if (!token) content.add(last.copyWith(attributes: const {})); + return ( + style: + busyAdmonitionStyleFromName(attributes['style']) ?? + BusyAdmonitionStyle.tip, + blocks: content, + attributes: attributes, + ); } - return (style: style, blocks: updated); } - return (style: BusyAdmonitionStyle.tip, blocks: blocks); + return ( + style: BusyAdmonitionStyle.tip, + blocks: blocks, + attributes: const {}, + ); } List<BusyInline> _takeInlinePrefix(List<BusyInline> inlines, int length) { @@ -1744,7 +2025,7 @@ class MarkdownAstAdapter { return result; } - _FrontMatter? _extractFrontMatter(String source) { + _FrontMatter? _extractFrontMatter(String source, {required bool writerside}) { final closing = frontMatterClosing(source); if (closing == null) { return null; @@ -1758,9 +2039,11 @@ class MarkdownAstAdapter { r'^\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$', ).firstMatch(line); if (match != null) { - values[match.group(1)!] = match - .group(2)! - .replaceAll(RegExp(r'''^["']|["']$'''), ''); + final key = match.group(1)!; + final scalar = match.group(2)!; + values[key] = writerside && key == 'switcher-label' + ? decodeWritersideSwitcherLabel(scalar) + : scalar.replaceAll(RegExp(r'''^["']|["']$'''), ''); } } return _FrontMatter(raw: raw, endOffset: end, values: values); @@ -1806,6 +2089,39 @@ class MarkdownAstAdapter { continue; } + if (mode == MarkdownMode.writersideMarkdown && + RegExp( + r'^ {0,3}(?:[-+*](?:\s|$)|\d+[.)](?:\s|$)|>)', + ).hasMatch(line)) { + // Keep parser-owned list/quote fragments together. Extracting an XML + // island here would promote its children out of the containing item + // and discard the de-indentation mapping before semantic adaptation. + final remaining = [ + for (final line in lines.skip(index)) md.Line(line.line), + ]; + final parser = md.BlockParser( + remaining, + busyMarkMarkdownDocument(mode), + ); + final syntax = parser.blockSyntaxes + .where( + (syntax) => + (syntax is md.ListSyntax || syntax is md.BlockquoteSyntax) && + syntax.canParse(parser), + ) + .firstOrNull; + if (syntax != null) { + syntax.parse(parser); + final consumed = parser.isDone + ? remaining.length + : remaining.indexWhere((line) => identical(line, parser.current)); + if (consumed > 0) { + index += consumed; + continue; + } + } + } + if (mode == MarkdownMode.writersideMarkdown && RegExp( r'^\s{0,3}<math(?:\s|/?>)', diff --git a/lib/src/markdown/markdown_front_matter.dart b/lib/src/markdown/markdown_front_matter.dart index 40dcf6ad..0f929a1b 100644 --- a/lib/src/markdown/markdown_front_matter.dart +++ b/lib/src/markdown/markdown_front_matter.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; +import 'package:xml/xml.dart'; + +import 'math_syntax.dart'; + /// Supported metadata delimiters occupy an entire line. Horizontal whitespace /// and CRLF are accepted, but prefixes such as `---example` are ordinary text. final _delimiter = RegExp(r'^---[ \t]*\r?$', multiLine: true); @@ -17,3 +22,37 @@ int frontMatterEndOffset(String source) { ? closing.end + 1 : closing.end; } + +/// The pinned builder inserts this literal into an XML attribute. YAML/JSON +/// quoting does not escape its quotes; XML entities preserve the actual label. +String encodeWritersideSwitcherLabel(String value) { + return busyMarkEncodeXmlMathText(value) + .replaceAll('"', '"') + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .replaceAll('\t', ' ') + .replaceAllMapped(RegExp(r'^ +| +$'), (m) => ' ' * m[0]!.length); +} + +/// Decode only the topic switcher scalar; other metadata and the authored +/// front-matter source retain their existing handling. +String decodeWritersideSwitcherLabel(String scalar) { + if (scalar.length < 2) return scalar; + if (scalar.startsWith("'") && scalar.endsWith("'")) { + return scalar.substring(1, scalar.length - 1).replaceAll("''", "'"); + } + if (scalar.startsWith('"') && scalar.endsWith('"')) { + try { + return jsonDecode(scalar) as String; + } on FormatException { + // Keep the existing display behavior for YAML escape forms outside the + // scalar subset emitted by the authoring control. Source stays intact. + return scalar.substring(1, scalar.length - 1); + } + } + try { + return XmlDocumentFragment.parse(scalar).innerText; + } on XmlParserException { + return scalar; + } +} diff --git a/lib/src/markdown/markdown_list_source.dart b/lib/src/markdown/markdown_list_source.dart new file mode 100644 index 00000000..21c623fe --- /dev/null +++ b/lib/src/markdown/markdown_list_source.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import '../core/source_span.dart'; +import 'busymark_document.dart'; + +String nativeMarkdownInlineState(List<BusyInline> inlines) => jsonEncode([ + for (final i in inlines) + { + 'kind': i.kind.name, + 'text': i.text, + 'destination': i.destination, + 'attributes': { + for (final a in i.attributes.entries) + if (!a.key.startsWith('busymark-') && + !a.key.startsWith('data-busymark-')) + a.key: a.value, + }, + 'children': nativeMarkdownInlineState(i.children), + }, +]); + +String nativeMarkdownAttributeState(Map<String, String> attributes) { + final keys = + attributes.keys + .where( + (k) => + !k.startsWith('busymark-') && !k.startsWith('data-busymark-'), + ) + .toList() + ..sort(); + return jsonEncode({for (final key in keys) key: attributes[key]}); +} + +/// Positions members inside a list already bounded by the supported parser. +/// Marker scans only locate those members; they never decide list ownership. +BusyBlock bindNativeMarkdownListSource({ + required BusyBlock list, + required String source, + required SourceSpan span, + required List<({int start, int end, bool sourceOnly})> Function(String) scan, +}) { + final marker = RegExp(r'^([ \t]*)([-+*]|\d+[.)])([ \t]+|$)'); + int columns(String whitespace) { + var n = 0; + for (final c in whitespace.codeUnits) { + n += c == 9 ? 4 - n % 4 : 1; + } + return n; + } + + BusyBlock bind(BusyBlock block, String text, List<int> offsets) { + final trimmed = text.trimRight(); + final located = SourceSpan.fromOffsets( + filePath: span.filePath, + source: source, + startOffset: offsets.first, + endOffset: offsets[trimmed.length], + ); + final attributes = {...block.attributes}; + if ({ + BusyBlockKind.unorderedListItem, + BusyBlockKind.orderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind)) { + final opening = marker.firstMatch(text); + if (opening != null) attributes['marker'] = opening[2]!; + } + var children = block.children; + if (block.attributes['busymark-list-form'] == 'markdown') { + var end = trimmed.length; + if (block.attributes.containsKey( + 'busymark-writerside-source-attributes', + )) { + final attribute = RegExp( + r'\r?\n[ \t]*\r?\n[ \t]*\{[^\r\n]*\}[ \t]*$', + ).firstMatch(trimmed); + if (attribute != null) end = attribute.start; + } + final starts = <int>[]; + var cursor = 0; + int? contentIndent; + for (final line in text.substring(0, end).split(_lineBoundaries)) { + final m = marker.firstMatch(line); + if (m != null && + (contentIndent == null || columns(m[1]!) < contentIndent)) { + starts.add(cursor); + final spaces = columns(m[3]!); + contentIndent = + columns(m[1]!) + + m[2]!.length + + (spaces > 4 ? 1 : spaces.clamp(1, 4)); + } + cursor += line.length; + } + // A mismatch must not attach the next item or an adjacent list by guess. + if (starts.length == children.length) { + children = [ + for (var i = 0; i < starts.length; i++) + bind( + children[i], + text.substring( + starts[i], + i + 1 < starts.length ? starts[i + 1] : end, + ), + offsets.sublist( + starts[i], + (i + 1 < starts.length ? starts[i + 1] : end) + 1, + ), + ), + ]; + } + } else if (block.kind == BusyBlockKind.blockquote && children.isNotEmpty) { + final body = StringBuffer(); + final bodyOffsets = <int>[]; + var cursor = 0; + for (final line in text.split(_lineBoundaries)) { + final prefix = RegExp(r'^ {0,3}>[ \t]?').firstMatch(line); + final removed = prefix?.end ?? 0; + body.write(line.substring(removed)); + for (var j = removed; j < line.length; j++) { + bodyOffsets.add(offsets[cursor + j]); + } + cursor += line.length; + } + bodyOffsets.add(offsets[text.length]); + final bodyText = body.toString(); + final chunks = scan(bodyText).where((c) => !c.sourceOnly).toList(); + if (chunks.length == children.length) { + children = [ + for (var i = 0; i < children.length; i++) + bind( + children[i], + bodyText.substring(chunks[i].start, chunks[i].end), + bodyOffsets.sublist(chunks[i].start, chunks[i].end + 1), + ), + ]; + } + } else if (marker.hasMatch(text) && + { + BusyBlockKind.unorderedListItem, + BusyBlockKind.orderedListItem, + BusyBlockKind.taskListItem, + }.contains(block.kind)) { + final opening = marker.firstMatch(text)!; + final indent = + columns(opening[1]!) + + opening[2]!.length + + columns(opening[3]!).clamp(1, 4); + final body = StringBuffer(); + final bodyOffsets = <int>[]; + var cursor = 0; + for (final (index, line) in text.split(_lineBoundaries).indexed) { + var removed = index == 0 ? opening.end : 0; + var width = 0; + while (index != 0 && + removed < line.length && + width < indent && + (line.codeUnitAt(removed) == 32 || line.codeUnitAt(removed) == 9)) { + width += line.codeUnitAt(removed) == 9 ? 4 - width % 4 : 1; + removed++; + } + // The task checkbox is part of the primary text projection. + if (index == 0 && block.kind == BusyBlockKind.taskListItem) { + final check = RegExp( + r'^\[[xX ]\][ \t]+', + ).firstMatch(line.substring(removed)); + if (check != null) removed += check.end; + } + body.write(line.substring(removed)); + for (var j = removed; j < line.length; j++) { + bodyOffsets.add(offsets[cursor + j]); + } + cursor += line.length; + } + bodyOffsets.add(offsets[text.length]); + final bodyText = body.toString(); + final chunks = scan(bodyText).where((c) => !c.sourceOnly).toList(); + final primary = block.inlines.isEmpty ? 0 : 1; + if (chunks.length == primary + children.length) { + if (primary == 1) { + final chunk = chunks.first; + attributes['busymark-native-inline-source'] = bodyText.substring( + chunk.start, + chunk.end, + ); + attributes['busymark-native-inline-source-map'] = jsonEncode([ + for (var i = chunk.start; i <= chunk.end; i++) + if (i == chunk.start || bodyOffsets[i] != bodyOffsets[i - 1] + 1) + [i - chunk.start, bodyOffsets[i]], + ]); + } + children = [ + for (var i = 0; i < children.length; i++) + bind( + children[i], + bodyText.substring( + chunks[i + primary].start, + chunks[i + primary].end, + ), + bodyOffsets.sublist( + chunks[i + primary].start, + chunks[i + primary].end + 1, + ), + ), + ]; + } + } else if (block.attributes.containsKey('element') && + block.sourceSpan != null && + children.isNotEmpty) { + // XML islands were parsed in the de-indented fragment. Bind each child + // through that fragment's map before exposing it to document navigation. + final leading = text.length - text.trimLeft().length; + final rootStart = block.sourceSpan!.startOffset - leading; + children = [ + for (final child in children) + if (child.sourceSpan case final childSpan?) + if (childSpan.startOffset - rootStart >= 0 && + childSpan.endOffset - rootStart <= text.length) + bind( + child, + text.substring( + childSpan.startOffset - rootStart, + childSpan.endOffset - rootStart, + ), + offsets.sublist( + childSpan.startOffset - rootStart, + childSpan.endOffset - rootStart + 1, + ), + ) + else + child + else + child, + ]; + } + return block.copyWith( + rawSource: trimmed, + sourceSpan: located, + children: children, + attributes: { + ...attributes, + 'busymark-native-inline-state': nativeMarkdownInlineState( + block.inlines, + ), + 'busymark-native-attribute-state': nativeMarkdownAttributeState( + attributes, + ), + 'busymark-native-trailing-gap': text.substring(trimmed.length), + 'busymark-native-members': jsonEncode( + block.children.map((b) => b.id).toList(), + ), + 'busymark-native-source-map': jsonEncode([ + for (var i = 0; i <= trimmed.length; i++) + if (i == 0 || offsets[i] != offsets[i - 1] + 1) [i, offsets[i]], + ]), + }, + ); + } + + final raw = source.substring(span.startOffset, span.endOffset); + return bind( + list, + raw, + List.generate(raw.length + 1, (i) => span.startOffset + i), + ); +} + +final _lineBoundaries = RegExp(r'(?<=\n)|(?<=\r)(?!\n)'); diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 09de5320..8c0f87a4 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -13,6 +13,7 @@ import 'busymark_document.dart'; import 'authored_html_diagnostics.dart'; import 'markdown_ast_adapter.dart'; import 'markdown_fence.dart'; +import 'markdown_list_source.dart'; import 'markdown_front_matter.dart'; import 'markdown_model.dart'; import 'math_syntax.dart'; @@ -28,6 +29,14 @@ const int _backgroundParseThresholdBytes = 64 * 1024; class MarkdownParser { const MarkdownParser({this.preserveHtmlSemantics = false}); + /// Rebind an already edited projection to its emitted source. This reuses + /// the ordinary positioned scanner; it does not infer lists from adjacency. + BusyDocument rebindSourceMetadata(BusyDocument document, String source) => + _withScannedSourceMetadata( + document.copyWith(source: source), + document.diagnostics, + ); + /// Keeps cross-block reference context for the HTML export projection. The /// editor and source serializer retain their existing projection by default. final bool preserveHtmlSemantics; @@ -482,14 +491,14 @@ class MarkdownParser { BusyDocument document, List<Diagnostic> diagnostics, ) { - final sourceChunks = document.source == null + var sourceChunks = document.source == null ? const <_ScannedBlockSource>[] : _scannedBlockSources( document.filePath, document.source!, mode: document.mode, ); - final contentBlocks = document.blocks + var contentBlocks = document.blocks .where( (block) => block.kind != BusyBlockKind.frontMatter && !block.isGenerated, @@ -537,6 +546,7 @@ class MarkdownParser { headingSourceIndex < headingSourceChunks.length ? headingSourceChunks[headingSourceIndex++] : nextVerbatimChunk(block), + fullSource: document.source, ), ]; if (sourceChunks.any((chunk) => chunk.protectEdits)) { @@ -590,7 +600,11 @@ class MarkdownParser { if (chunk.sourceOnly) _sourceOnlyBlock(chunk) else - _blockWithScannedSourceMetadata(contentBlocks[blockIndex++], chunk), + _blockWithScannedSourceMetadata( + contentBlocks[blockIndex++], + chunk, + fullSource: document.source, + ), ...generatedBlocks, ], ); @@ -758,6 +772,10 @@ class MarkdownParser { case BusyInlineKind.softBreak: case BusyInlineKind.hardBreak: case BusyInlineKind.html: + case BusyInlineKind.writersideControl: + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.writersideVariable: case BusyInlineKind.unknown: break; @@ -786,13 +804,87 @@ class MarkdownParser { BusyBlock _blockWithScannedSourceMetadata( BusyBlock block, - _ScannedBlockSource? sourceChunk, - ) { + _ScannedBlockSource? sourceChunk, { + String? fullSource, + }) { + if ((block.attributes['busymark-list-form'] == 'markdown' || + block.kind == BusyBlockKind.blockquote && + block.children.any( + (b) => b.attributes['busymark-list-form'] == 'markdown', + )) && + sourceChunk != null && + fullSource != null) { + final positioned = bindNativeMarkdownListSource( + list: block, + source: fullSource, + span: sourceChunk.span, + scan: (source) => [ + for (final c in _scannedBlockSources( + '', + source, + mode: MarkdownMode.writersideMarkdown, + )) + ( + start: c.span.startOffset, + end: c.span.endOffset, + sourceOnly: c.sourceOnly, + ), + ], + ); + return sourceChunk.protectEdits + ? _sourceProtectedTree(positioned) + : positioned; + } var updated = block; if (updated.kind != BusyBlockKind.frontMatter && sourceChunk != null) { + // Semantic fragments are parsed independently. Their descendants carry + // fragment-relative spans; bind them to the containing Markdown source. + BusyBlock positionChild(BusyBlock child) { + final span = child.sourceSpan; + final base = sourceChunk.span; + final previousBase = block.sourceSpan; + final rebound = + span != null && + previousBase != null && + span.filePath == base.filePath && + fullSource != null + ? SourceSpan.fromOffsets( + filePath: base.filePath, + source: fullSource, + startOffset: + base.startOffset + + span.startOffset - + previousBase.startOffset, + endOffset: + base.startOffset + + span.endOffset - + previousBase.startOffset, + ) + : span; + return child.copyWith( + sourceSpan: span == null || span.filePath.isNotEmpty + ? rebound + : SourceSpan( + filePath: base.filePath, + startOffset: base.startOffset + span.startOffset, + endOffset: base.startOffset + span.endOffset, + startLine: base.startLine + span.startLine - 1, + endLine: base.startLine + span.endLine - 1, + startColumn: + span.startColumn + + (span.startLine == 1 ? base.startColumn - 1 : 0), + endColumn: + span.endColumn + + (span.endLine == 1 ? base.startColumn - 1 : 0), + ), + children: child.children.map(positionChild).toList(), + ); + } + updated = updated.copyWith( rawSource: sourceChunk.rawSource, sourceSpan: sourceChunk.span, + children: updated.children.map(positionChild).toList(), ); if (sourceChunk.protectEdits) { updated = _sourceProtectedTree(updated); @@ -942,6 +1034,37 @@ class MarkdownParser { } final listIndent = _listItemIndent(indexedLines[index].line); + if (listIndent != null && mode == MarkdownMode.writersideMarkdown) { + final markdownLines = [ + for (final line in indexedLines.skip(index)) + md.Line(_withoutCarriageReturn(line.line)), + ]; + final parser = md.BlockParser(markdownLines, _markdownDocument()); + final syntax = parser.blockSyntaxes + .whereType<md.ListSyntax>() + .where((syntax) => syntax.canParse(parser)) + .firstOrNull; + if (syntax != null) { + syntax.parse(parser); + final consumed = _consumedLineCount(parser, markdownLines); + if (consumed > 0) { + index += consumed; + var attributes = index; + while (attributes < indexedLines.length && + indexedLines[attributes].trimmed.isEmpty) { + attributes++; + } + if (attributes < indexedLines.length && + _isWritersideCodeAttributeLine( + indexedLines[attributes].trimmed, + )) { + index = attributes + 1; + } + addChunk(startIndex, index); + continue; + } + } + } if (listIndent != null) { index += 1; while (index < indexedLines.length) { @@ -983,6 +1106,19 @@ class MarkdownParser { } index += 1; } + if (mode == MarkdownMode.writersideMarkdown && startsWithBlockquote) { + var attributeIndex = index; + while (attributeIndex < indexedLines.length && + indexedLines[attributeIndex].trimmed.isEmpty) { + attributeIndex++; + } + if (attributeIndex < indexedLines.length && + _isWritersideCodeAttributeLine( + indexedLines[attributeIndex].trimmed, + )) { + index = attributeIndex + 1; + } + } addChunk(startIndex, index); } return chunks; @@ -990,7 +1126,7 @@ class MarkdownParser { bool _isWritersideCodeAttributeLine(String line) { return RegExp( - r'^\{[^{}]*(?:\bcollapsible\s*=\s*"true"|\bsrc\s*=\s*"[^"]+")[^{}]*\}\s*$', + r'^\{\s*[A-Za-z_:][-A-Za-z0-9_:.]*\s*=\s*"[^\r\n]*"[^{}]*\}\s*$', caseSensitive: false, ).hasMatch(line); } diff --git a/lib/src/markdown/markdown_source_map.dart b/lib/src/markdown/markdown_source_map.dart index 17a70094..d15a09ed 100644 --- a/lib/src/markdown/markdown_source_map.dart +++ b/lib/src/markdown/markdown_source_map.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:convert'; import 'package:markdown/markdown.dart' as md; @@ -7,9 +8,54 @@ import 'markdown_ast_adapter.dart'; import 'markdown_model.dart'; import 'markdown_source_annotation.dart'; import 'math_syntax.dart'; +import 'writerside_authoring_syntax.dart'; +import 'writerside_variable_syntax.dart'; export 'markdown_source_annotation.dart'; +/// Translate a boundary in a projected fragment back to authored source. +/// Native list and quote projections remove prefixes independently per line. +int? markdownProjectedSourceOffset( + BusyBlock block, + int local, { + bool inline = false, +}) { + final raw = inline + ? block.attributes['busymark-native-inline-source'] ?? block.rawSource + : block.rawSource; + if (raw == null || local < 0 || local > raw.length) return null; + final encoded = inline + ? block.attributes['busymark-native-inline-source-map'] ?? + block.attributes['busymark-native-source-map'] + : block.attributes['busymark-native-source-map']; + if (encoded == null) { + final start = block.sourceSpan?.startOffset; + return start == null ? null : start + local; + } + try { + final segments = jsonDecode(encoded) as List<dynamic>; + int? result; + var previous = -1; + for (final segment in segments) { + if (segment is! List || + segment.length != 2 || + segment[0] is! int || + segment[1] is! int) { + return null; + } + final start = segment[0] as int; + if (start <= previous || start < 0 || segment[1] < 0) return null; + previous = start; + if (start <= local) result = (segment[1] as int) + local - start; + } + return result; + } on FormatException { + return null; + } on TypeError { + return null; + } +} + void _setSourceMappingAttributes( md.Element element, { required int start, @@ -415,6 +461,73 @@ class _SourceMappingCodeSyntax extends md.CodeSyntax { } } +// Inspector-only mappings. The authored grammar and its serialization stay +// unchanged; delegate semantic elements to the existing Writerside syntax. +class _SourceMappingWritersideParser extends md.InlineParser { + _SourceMappingWritersideParser(md.InlineParser parser) + : super(parser.source, parser.document) { + pos = parser.pos; + start = parser.start; + } + + final nodes = <md.Node>[]; + + @override + void addNode(md.Node node) => nodes.add(node); +} + +class _SourceMappingWritersideSemanticSyntax + extends WritersideSemanticInlineSyntax { + @override + bool onMatch(md.InlineParser parser, Match match) { + final recording = _SourceMappingWritersideParser(parser); + if (!super.onMatch(recording, match)) return false; + final end = recording.pos + match[0]!.length; + final element = recording.nodes.single as md.Element; + void translate(md.Node node) { + if (node is! md.Element) return; + for (final name in [ + busyMarkSourceMappingStartAttribute, + busyMarkSourceMappingEndAttribute, + busyMarkSourceMappingLabelStartAttribute, + busyMarkSourceMappingLabelEndAttribute, + busyMarkSourceMappingTitleStartAttribute, + busyMarkSourceMappingTitleEndAttribute, + busyMarkSourceMappingLineBreakOffsetAttribute, + ]) { + final offset = int.tryParse(node.attributes[name] ?? ''); + if (offset != null) node.attributes[name] = '${match.end + offset}'; + } + node.children?.forEach(translate); + } + + element.children?.forEach(translate); + _setSourceMappingAttributes( + element, + start: match.start, + end: end, + opening: match[0], + closing: match[0]!.endsWith('/>') ? '' : '</${match[1]}>', + ); + parser.addNode(element); + parser.consume(end - match.end); + return true; + } +} + +class _SourceMappingWritersideVariableSyntax extends md.InlineSyntax { + _SourceMappingWritersideVariableSyntax() : super(r'%([\w.-]+)%'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final element = md.Element.text('var', match[1]!); + element.attributes['reference'] = match[1]!; + _setSourceMappingAttributes(element, start: match.start, end: match.end); + parser.addNode(element); + return true; + } +} + List<md.InlineSyntax> _sourceMappingInlineSyntaxes( _SourceMappingEmailAutolinkSyntax emailSyntax, ) { @@ -445,6 +558,8 @@ class BusyMarkInlineParserContext { final md.Document _document; final md.Document _mappingDocument; final Map<String, md.LinkReference> _referenceDefinitions; + Map<String, md.LinkReference> get referenceDefinitions => + _referenceDefinitions; final _SourceMappingEmailAutolinkSyntax _emailSyntax; int parseInvocations = 0; @@ -1358,6 +1473,7 @@ class MarkdownSourceMapper { BusyMarkInlineParserContext createInlineParserContext({ required String documentSource, required MarkdownMode mode, + bool mapWritersideReferences = false, }) { final referenceDocument = busyMarkMarkdownDocument(mode) ..parse(documentSource); @@ -1366,7 +1482,15 @@ class MarkdownSourceMapper { final emailSyntax = _SourceMappingEmailAutolinkSyntax(); final mappingDocument = busyMarkMarkdownDocument( mode, - leadingInlineSyntaxes: _sourceMappingInlineSyntaxes(emailSyntax), + leadingInlineSyntaxes: [ + ..._sourceMappingInlineSyntaxes(emailSyntax), + if (mapWritersideReferences && + mode == MarkdownMode.writersideMarkdown) ...[ + WritersideLiteralPercentSyntax(), + _SourceMappingWritersideSemanticSyntax(), + _SourceMappingWritersideVariableSyntax(), + ], + ], )..linkReferences.addAll(referenceDocument.linkReferences); return BusyMarkInlineParserContext._( document: document, diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index 2c71e493..ccecf740 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -4,6 +4,7 @@ import 'busymark_document.dart'; import 'markdown_model.dart'; import 'markdown_source_annotation.dart'; import 'writerside_variable_syntax.dart'; +import 'writerside_authoring_syntax.dart'; const busyMarkLiteralLessThanTag = 'busymark-literal-less-than'; @@ -36,9 +37,18 @@ md.Document busyMarkMarkdownDocument( Iterable<md.InlineSyntax> leadingInlineSyntaxes = const [], }) { return md.Document( - blockSyntaxes: const [BusyDisplayMathSyntax()], + blockSyntaxes: [ + const BusyDisplayMathSyntax(), + if (mode == MarkdownMode.writersideMarkdown) + const WritersideSemanticParagraphSyntax(), + if (mode == MarkdownMode.writersideMarkdown) + const WritersideDefinitionListSyntax(), + ], inlineSyntaxes: [ ...leadingInlineSyntaxes, + if (mode == MarkdownMode.writersideMarkdown) + WritersideSemanticInlineSyntax(), + if (mode == MarkdownMode.writersideMarkdown) WritersideAttributeSyntax(), _LiteralLessThanSyntax(), if (mode == MarkdownMode.writersideMarkdown) WritersideLiteralPercentSyntax(), diff --git a/lib/src/markdown/preview_model.dart b/lib/src/markdown/preview_model.dart index 5de727ea..857d2768 100644 --- a/lib/src/markdown/preview_model.dart +++ b/lib/src/markdown/preview_model.dart @@ -189,6 +189,13 @@ class BusyMarkPreviewBuilder { } PreviewBlock _block(BusyBlock block, String path) { + if (busyMarkIsWritersideContainer(block) && + block.attributes['element'] == 'chapter' && + block.rawSource != null && + !block.dirty) { + final semantic = _writersideSemanticBlock(block, path); + if (semantic != null) return _withSourceSpan(semantic, block.sourceSpan); + } final preview = switch (block.kind) { BusyBlockKind.heading => PreviewBlock( kind: PreviewBlockKind.heading, @@ -197,7 +204,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: { ...block.attributes, @@ -211,7 +218,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -244,7 +251,7 @@ class BusyMarkPreviewBuilder { inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -271,7 +278,7 @@ class BusyMarkPreviewBuilder { text: '', children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -281,7 +288,7 @@ class BusyMarkPreviewBuilder { text: _plainText(block.inlines), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -303,7 +310,7 @@ class BusyMarkPreviewBuilder { text: block.children.map((child) => child.plainText).join('\n'), children: [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ], attributes: block.attributes, ), @@ -427,7 +434,7 @@ class BusyMarkPreviewBuilder { )?.kind; final children = [ for (final (index, child) in block.children.indexed) - _block(child, '$path.b$index'), + if (!child.isSourceOnly) _block(child, '$path.b$index'), ]; return switch (semanticKind) { WritersideSemanticKind.definitionList => PreviewBlock( @@ -566,6 +573,10 @@ class BusyMarkPreviewBuilder { BusyInlineKind.emphasis => PreviewInlineKind.emphasis, BusyInlineKind.underline => PreviewInlineKind.underline, BusyInlineKind.strikethrough => PreviewInlineKind.strikethrough, + BusyInlineKind.writersideControl => PreviewInlineKind.strong, + BusyInlineKind.writersidePath || + BusyInlineKind.writersideUiPath || + BusyInlineKind.writersideShortcut || BusyInlineKind.code => PreviewInlineKind.code, BusyInlineKind.link => PreviewInlineKind.link, BusyInlineKind.image => PreviewInlineKind.image, diff --git a/lib/src/markdown/writerside_authoring_syntax.dart b/lib/src/markdown/writerside_authoring_syntax.dart new file mode 100644 index 00000000..41151362 --- /dev/null +++ b/lib/src/markdown/writerside_authoring_syntax.dart @@ -0,0 +1,149 @@ +import 'package:markdown/markdown.dart' as md; +import 'package:xml/xml.dart'; +import 'markdown_source_annotation.dart'; + +// Lexical tokens have a separate identity from an attributed reference's +// busymark-writerside-attributes serialization flag. +const writersideAttributeTokenTag = 'busymark-writerside-attribute-token'; + +/// Attribute ownership is resolved on parsed siblings. Code spans and escapes +/// are consumed by the ordinary inline grammar before this syntax can match. +class WritersideAttributeSyntax extends md.InlineSyntax { + WritersideAttributeSyntax() + : super( + r'''\{\s*(?:[\w:.-]+\s*=\s*(?:"[^"\n]*"|'[^'\n]*'|[^\s{}]+)\s*)+\}''', + startCharacter: 0x7b, + ); + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode( + md.Element.text(writersideAttributeTokenTag, match[0]!) + ..attributes.addAll({ + busyMarkSourceMappingStartAttribute: '${match.start}', + busyMarkSourceMappingEndAttribute: '${match.end}', + }), + ); + return true; + } +} + +/// Writerside semantic spans participate in the existing inline parser, so +/// Markdown formatting and links inside a span retain their normal behavior. +class WritersideSemanticInlineSyntax extends md.InlineSyntax { + WritersideSemanticInlineSyntax() + : super( + r'<(control|path|ui-path|shortcut)\b[^>]*(?:/>|>)', + startCharacter: 0x3c, + ); + @override + bool onMatch(md.InlineParser parser, Match match) { + final tag = match.group(1)!; + var end = match.end; + var content = ''; + if (!match.group(0)!.endsWith('/>')) { + final closing = '</$tag>'; + final close = parser.source.indexOf(closing, end); + if (close < 0) return false; + content = parser.source.substring(end, close); + end = close + closing.length; + } + try { + final opening = match.group(0)!; + final element = XmlDocumentFragment.parse( + opening.endsWith('/>') + ? opening + : opening.replaceFirst(RegExp(r'>$'), '/>'), + ).children.whereType<XmlElement>().single; + parser.addNode( + md.Element(tag, parser.document.parseInline(content)) + ..attributes.addAll({ + for (final a in element.attributes) a.name.qualified: a.value, + }), + ); + parser.consume(end - match.end); + return true; + } on Object { + return false; + } + } +} + +class WritersideSemanticParagraphSyntax extends md.BlockSyntax { + const WritersideSemanticParagraphSyntax(); + @override + RegExp get pattern => RegExp(r'^ {0,3}<(?:control|path|ui-path|shortcut)\b'); + @override + md.Node parse(md.BlockParser parser) { + final lines = <String>[]; + while (!parser.isDone && parser.current.content.trim().isNotEmpty) { + lines.add(parser.current.content); + parser.advance(); + } + return md.Element('p', parser.document.parseInline(lines.join('\n'))); + } +} + +/// The documented term / colon form. Multiple definitions and indented body +/// lines stay in their original pair instead of being flattened into prose. +class WritersideDefinitionListSyntax extends md.BlockSyntax { + const WritersideDefinitionListSyntax(); + @override + RegExp get pattern => RegExp(r'^\S'); + @override + bool canParse(md.BlockParser parser) { + if (parser.isDone) return false; + final offset = _attributes(parser.current.content) == null ? 1 : 2; + return parser.peek(offset) != null && + RegExp(r'^:\s+').hasMatch(parser.peek(offset)!.content); + } + + @override + md.Node parse(md.BlockParser parser) { + final attributes = + _attributes(parser.current.content) ?? const <String, String>{}; + if (attributes.isNotEmpty) parser.advance(); + final definitions = <md.Node>[]; + while (canParse(parser)) { + final term = parser.current.content; + parser.advance(); + final body = <String>[ + parser.current.content.replaceFirst(RegExp(r'^:\s+'), ''), + ]; + parser.advance(); + while (!parser.isDone && + RegExp(r'^ {2,}\S').hasMatch(parser.current.content)) { + body.add(parser.current.content.replaceFirst(RegExp(r'^ {2,4}'), '')); + parser.advance(); + } + definitions.add( + md.Element('def', parser.document.parseLines(body)) + ..attributes['title'] = term, + ); + if (!parser.isDone && + parser.current.content.trim().isEmpty && + parser.peek(1) != null && + parser.peek(2) != null && + RegExp(r'^:\s+').hasMatch(parser.peek(2)!.content)) { + parser.advance(); + } + } + return md.Element('deflist', definitions) + ..attributes.addAll({ + ...attributes, + 'busymark-definition-form': 'markdown', + }); + } + + Map<String, String>? _attributes(String source) { + final value = source.trim(); + if (!value.startsWith('{') || !value.endsWith('}')) return null; + final result = <String, String>{}; + for (final match in RegExp( + r'''([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s}]+))''', + ).allMatches(value.substring(1, value.length - 1))) { + result[match[1]!] = match[2] ?? match[3] ?? match[4]!; + } + return result.isEmpty ? null : result; + } +} diff --git a/lib/src/nextcloud_notes/application/nextcloud_connection.dart b/lib/src/nextcloud_notes/application/nextcloud_connection.dart index caa58e90..88e6148d 100644 --- a/lib/src/nextcloud_notes/application/nextcloud_connection.dart +++ b/lib/src/nextcloud_notes/application/nextcloud_connection.dart @@ -46,30 +46,56 @@ final nextcloudNotesRepositoryProvider = FutureProvider<NotesRepository>(( final client = ref.watch(nextcloudHttpClientProvider); final secrets = ref.watch(nextcloudSecretStoreProvider); final store = await NotesStore.open(path: path); - final repository = NotesRepository( + Future<String> passwordFor(NextcloudAccount account) async { + String? password; + try { + password = await secrets.read(account.id); + } on NextcloudCredentialException { + throw const NotesException( + NotesFailureCode.authentication, + 'BusyMark could not access the desktop keyring. Unlock it and reconnect Nextcloud.', + scope: NotesRequestScope.account, + ); + } + if (password == null) { + throw const NotesException( + NotesFailureCode.authentication, + 'The Nextcloud app password is unavailable. Reconnect this account.', + scope: NotesRequestScope.account, + ); + } + return password; + } + + late final NotesRepository repository; + repository = NotesRepository( store: store, clientForAccount: (account) async { - String? password; - try { - password = await secrets.read(account.id); - } on NextcloudCredentialException { - throw const NotesException( - NotesFailureCode.authentication, - 'BusyMark could not access the desktop keyring. Unlock the keyring and reconnect Nextcloud.', - ); - } - if (password == null) { - throw const NotesException( - NotesFailureCode.authentication, - 'The Nextcloud app password is unavailable. Reconnect this account.', - ); - } + final password = await passwordFor(account); + final generation = repository.accountGeneration(account.id); + var epoch = repository.capabilityEpoch(account.id); return NotesApiClient( client: client, account: account, appPassword: password, + onApiVersions: (raw) async { + final current = repository.capabilityEpoch(account.id) == epoch; + await repository.recordApiVersions( + account.id, + generation, + epoch, + raw, + ); + if (current) epoch = repository.capabilityEpoch(account.id); + }, ); }, + fetchCapabilities: (account) async => fetchNotesCapabilities( + client: client, + server: account.server, + loginName: account.loginName, + appPassword: await passwordFor(account), + ), ); try { await repository.initialize(); @@ -226,7 +252,7 @@ class NextcloudConnectionController extends Notifier<NextcloudConnectionState> { listEtag: previous?.listEtag, lastModified: previous?.lastModified, ); - await repository.upsertAccount(account); + await repository.upsertAccount(account, reconnect: previous != null); committed = true; if (_disposed) return true; state = NextcloudConnectionState( diff --git a/lib/src/nextcloud_notes/application/notes_media.dart b/lib/src/nextcloud_notes/application/notes_media.dart index 9b7c1cd9..871fb568 100644 --- a/lib/src/nextcloud_notes/application/notes_media.dart +++ b/lib/src/nextcloud_notes/application/notes_media.dart @@ -41,6 +41,7 @@ class NextcloudDocumentMedia { final String accountId; final String localId; final _paths = <String, String>{}; + final _resolvedAt = <String, DateTime>{}; final _pending = <String, Future<String?>>{}; DocumentMediaContext? _context; int _version = -1; @@ -64,12 +65,17 @@ class NextcloudDocumentMedia { _version = version; _context = null; _paths.clear(); + _resolvedAt.clear(); _pending.clear(); } Future<String?> resolve(String reference) async { _checkVersion(); - if (_paths.containsKey(reference)) return _paths[reference]; + if (_paths.containsKey(reference) && + repository.clock().difference(_resolvedAt[reference]!) < + NotesRepository.mediaFreshness) { + return _paths[reference]; + } if (_pending.containsKey(reference)) return _pending[reference]; final future = _resolve(reference); _pending[reference] = future; @@ -81,12 +87,13 @@ class NextcloudDocumentMedia { } Future<String?> _resolve(String reference) async { - final version = _version; try { final path = await repository.resolveMedia(accountId, localId, reference); _checkVersion(); - if (version != _version) return null; - if (path != null) _paths[reference] = path; + if (path != null) { + _paths[reference] = path; + _resolvedAt[reference] = repository.clock(); + } return path; } on Object { // An unavailable remote item is represented by the renderer placeholder. diff --git a/lib/src/nextcloud_notes/application/notes_navigation.dart b/lib/src/nextcloud_notes/application/notes_navigation.dart new file mode 100644 index 00000000..62de3b09 --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_navigation.dart @@ -0,0 +1,122 @@ +import '../domain/notes_models.dart'; + +enum NotesDestination { all, favorites, uncategorized, category, recovery } + +enum NotesSort { recent, oldest, titleAscending, titleDescending } + +bool isNotesRecovery(NextcloudNote note) => + note.syncState == NoteSyncState.deletedRemotely && !note.hasPendingChanges; +bool categoryIncludes(String parent, String category) => + parent.isEmpty || parent == category || category.startsWith('$parent/'); + +/// Selection lives in stable local identity space, independently of open tabs. +class NotesNavigationController { + NotesDestination destination = NotesDestination.all; + NotesSort sort = NotesSort.recent; + String category = ''; + final collapsed = <String>{}; + final selected = <String>{}; + String? focused; + String? anchor; + List<NextcloudNote> visible = const []; + Map<String, int> categoryCounts = const {}; + List<String> categoryPaths = const []; + Map<NotesDestination, int> counts = const {}; + + void update(Iterable<NextcloudNote> notes, String accountId) { + final scoped = notes.where((n) => n.accountId == accountId).toList(); + final live = scoped.where((n) => !isNotesRecovery(n)).toList(); + final categories = <String, int>{}; + for (final note in live) { + final segments = note.category.split('/'); + for (var i = 1; i <= segments.length; i++) { + final key = segments.take(i).join('/'); + if (key.isNotEmpty) categories[key] = (categories[key] ?? 0) + 1; + } + } + if (destination == NotesDestination.category && + !categories.containsKey(category)) { + destination = NotesDestination.all; + category = ''; + } + categoryCounts = Map.unmodifiable(categories); + categoryPaths = categories.keys.toList()..sort(); + counts = { + NotesDestination.all: live.length, + NotesDestination.favorites: live.where((n) => n.favorite).length, + NotesDestination.uncategorized: live + .where((n) => n.category.isEmpty) + .length, + NotesDestination.recovery: scoped.where(isNotesRecovery).length, + }; + visible = + scoped + .where( + (n) => switch (destination) { + NotesDestination.recovery => isNotesRecovery(n), + NotesDestination.all => !isNotesRecovery(n), + NotesDestination.favorites => !isNotesRecovery(n) && n.favorite, + NotesDestination.uncategorized => + !isNotesRecovery(n) && n.category.isEmpty, + NotesDestination.category => + !isNotesRecovery(n) && categoryIncludes(category, n.category), + }, + ) + .toList() + ..sort((a, b) { + final order = switch (sort) { + NotesSort.recent => b.activityMicros.compareTo(a.activityMicros), + NotesSort.oldest => a.activityMicros.compareTo(b.activityMicros), + NotesSort.titleAscending => a.title.toLowerCase().compareTo( + b.title.toLowerCase(), + ), + NotesSort.titleDescending => b.title.toLowerCase().compareTo( + a.title.toLowerCase(), + ), + }; + return order == 0 ? a.localId.compareTo(b.localId) : order; + }); + // Hidden selections cannot accidentally become targets after a filter. + selected.retainAll(visible.map((n) => n.localId)); + if (!visible.any((n) => n.localId == focused)) focused = null; + if (!visible.any((n) => n.localId == anchor)) anchor = null; + } + + void select(String id, {bool toggle = false, bool range = false}) { + final ids = visible.map((n) => n.localId).toList(); + if (!ids.contains(id)) return; + focused = id; + if (range && anchor != null && ids.contains(anchor)) { + final a = ids.indexOf(anchor!); + final b = ids.indexOf(id); + if (!toggle) selected.clear(); + selected.addAll(ids.sublist(a < b ? a : b, (a > b ? a : b) + 1)); + } else if (toggle) { + if (!selected.remove(id)) selected.add(id); + anchor = id; + } else { + selected + ..clear() + ..add(id); + anchor = id; + } + } + + void selectAll() => selected.addAll(visible.map((n) => n.localId)); + List<String> contextTargets(String id) => selected.contains(id) + ? visible + .where((n) => selected.contains(n.localId)) + .map((n) => n.localId) + .toList() + : [id]; +} + +enum NotesBatchStatus { changed, skipped, conflicted, failed } + +class NotesBatchOutcome { + const NotesBatchOutcome(this.localId, this.title, this.status, [this.detail]); + final String localId; + final String title; + final NotesBatchStatus status; + final String? detail; +} diff --git a/lib/src/nextcloud_notes/application/notes_offline_controller.dart b/lib/src/nextcloud_notes/application/notes_offline_controller.dart new file mode 100644 index 00000000..ea2f2c83 --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_offline_controller.dart @@ -0,0 +1,259 @@ +import 'dart:async'; + +import '../domain/notes_models.dart'; +import 'notes_navigation.dart'; +import 'notes_repository.dart'; + +class NotesOfflineStatus { + const NotesOfflineStatus({ + required this.revision, + required this.textAvailable, + required this.requiredCount, + required this.availableCount, + this.missing = const [], + this.external = const [], + this.running = false, + this.paused = false, + }); + final int revision, requiredCount, availableCount; + final bool textAvailable, running, paused; + final List<String> missing, external; + bool get available => textAvailable && requiredCount == availableCount; +} + +/// Local retention requirements drive the existing repository resolver. This +/// owns neither credentials nor another download queue. Failed and cancelled +/// revisions wait for deliberate retry; completed SQLite bytes remain retained. +class NotesOfflineController { + NotesOfflineController(this.repository, this.accountId) { + _subscription = repository.changes.listen((_) => _schedule()); + } + final NotesRepository repository; + final String accountId; + late final StreamSubscription<void> _subscription; + final _changes = StreamController<void>.broadcast(); + Stream<void> get changes => _changes.stream; + List<Map<String, dynamic>> requirements = []; + final statuses = <String, NotesOfflineStatus>{}; + final _attempted = <String>{}; + final failures = <String, String>{}; + Timer? _debounce; + Future<void>? _reconciliation, _disposing; + bool _disposed = false, _running = false, _again = false; + int _generation = 0; + Future<void> initialize() async { + requirements = await repository.store.offlineRequirements(accountId); + unawaited(reconcile()); + } + + bool retained(NextcloudNote note) => + requirements.any((r) => _includes(r, note)); + bool individuallyRetained(String id) => + requirements.any((r) => r['kind'] == 'note' && r['target'] == id); + bool categoryRetained(String category) => requirements.any( + (r) => r['kind'] == 'category' && r['target'] == category, + ); + bool _includes(Map<String, dynamic> r, NextcloudNote note) => + r['kind'] == 'pause' + ? false + : r['kind'] == 'note' + ? r['target'] == note.localId + : categoryIncludes(r['target'] as String, note.category); + bool _active(NextcloudNote note) => + !requirements.any( + (r) => r['kind'] == 'pause' && r['target'] == note.localId, + ) && + requirements.any((r) => r['paused'] == 0 && _includes(r, note)); + Future<void> setRequirement( + String kind, + String target, { + bool required = true, + bool paused = false, + }) async { + await repository.setOfflineRequirement( + accountId, + kind, + target, + required: required, + paused: paused, + ); + requirements = await repository.store.offlineRequirements(accountId); + _generation++; + for (final note in repository.notes.where( + (n) => n.accountId == accountId && !_active(n), + )) { + repository.cancelMediaDownloads(note.localId); + } + if (required && !paused && kind != 'pause') { + for (final note in repository.notes.where( + (n) => + n.accountId == accountId && + (kind == 'note' + ? n.localId == target + : categoryIncludes(target, n.category)), + )) { + _attempted.removeWhere((key) => key.startsWith('${note.localId}:')); + failures.removeWhere((key, _) => key.startsWith('${note.localId}:')); + } + } + await reconcile(); + } + + Future<void> cancelNote(String id) => + setRequirement('pause', id, paused: true); + + Future<void> retry([String? noteId]) async { + for (final r in requirements.toList()) { + if (r['kind'] == 'pause' && (noteId == null || r['target'] == noteId)) { + await repository.setOfflineRequirement( + accountId, + 'pause', + r['target'] as String, + required: false, + ); + } else if (r['paused'] == 1 && + (noteId == null || r['kind'] == 'note' && r['target'] == noteId)) { + await repository.setOfflineRequirement( + accountId, + r['kind'] as String, + r['target'] as String, + ); + } + } + requirements = await repository.store.offlineRequirements(accountId); + _attempted.clear(); + failures.clear(); + await reconcile(); + } + + void _schedule() { + if (_disposed || requirements.isEmpty) return; + _debounce?.cancel(); + _debounce = Timer( + const Duration(milliseconds: 150), + () => unawaited(reconcile()), + ); + } + + Future<NotesOfflineStatus> inspect( + NextcloudNote note, { + String? source, + }) async { + note = repository.noteById(note.localId) ?? note; + final dependencies = await repository.attachmentAvailability( + note.localId, + source: source, + ); + return NotesOfflineStatus( + revision: note.revision, + textAvailable: true, + requiredCount: dependencies.required.length, + availableCount: dependencies.available.length, + missing: dependencies.required + .where((r) => !dependencies.available.contains(r)) + .toList(), + external: dependencies.external, + running: _running && _active(note), + paused: retained(note) && !_active(note), + ); + } + + Future<void> reconcile() { + if (_disposed) return Future.value(); + _debounce?.cancel(); + if (_reconciliation case final pending?) { + _again = true; + return pending; + } + return _reconciliation = _reconcile().whenComplete( + () => _reconciliation = null, + ); + } + + Future<void> _reconcile() async { + _running = true; + try { + // Coalesced callers await both the active pass and their requested pass. + do { + _again = false; + final generation = _generation; + for (final note in repository.notes.where( + (n) => n.accountId == accountId && retained(n) && !isNotesRecovery(n), + )) { + if (_disposed || repository.accountById(accountId) == null) return; + var status = await inspect(note); + if (_disposed) return; + statuses[note.localId] = status; + _emit(); + if (_active(note)) { + for (final reference in status.missing) { + if (_disposed || generation != _generation || !_active(note)) { + break; + } + final key = '${note.localId}:${note.revision}:$reference'; + if (!_attempted.add(key)) continue; + try { + final path = await repository.resolveMedia( + accountId, + note.localId, + reference, + ); + if (path == null) failures[key] = 'Attachment unavailable'; + } on Object catch (error) { + failures[key] = error.toString(); + } + if (_disposed || repository.accountById(accountId) == null) { + return; + } + final current = repository.noteById(note.localId); + if (current != null) { + status = await inspect(current); + statuses[note.localId] = status; + _emit(); + } + } + } + } + statuses.removeWhere((id, _) => repository.noteById(id) == null); + } while (_again && !_disposed); + } finally { + _running = false; + for (final entry in statuses.entries.toList()) { + final s = entry.value; + statuses[entry.key] = NotesOfflineStatus( + revision: s.revision, + textAvailable: s.textAvailable, + requiredCount: s.requiredCount, + availableCount: s.availableCount, + missing: s.missing, + external: s.external, + paused: s.paused, + ); + } + _emit(); + } + } + + void _emit() { + if (!_disposed) _changes.add(null); + } + + Future<void> dispose() => _disposing ??= _dispose(); + + Future<void> _dispose() async { + _disposed = true; + _generation++; + _debounce?.cancel(); + for (final note in repository.notes.where( + (n) => n.accountId == accountId && retained(n), + )) { + repository.cancelMediaDownloads(note.localId); + } + await _subscription.cancel(); + try { + await _reconciliation; + } finally { + await _changes.close(); + } + } +} diff --git a/lib/src/nextcloud_notes/application/notes_repository.dart b/lib/src/nextcloud_notes/application/notes_repository.dart index be8147e7..36e6b04a 100644 --- a/lib/src/nextcloud_notes/application/notes_repository.dart +++ b/lib/src/nextcloud_notes/application/notes_repository.dart @@ -10,23 +10,43 @@ import 'package:uuid/uuid.dart'; import '../data/notes_api_client.dart'; import '../data/notes_attachment_references.dart'; import '../data/notes_store.dart'; +import '../data/notes_capabilities.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_retrieval.dart'; import '../domain/notes_conflict.dart'; +import 'notes_navigation.dart'; /// Owns local durability and ordinary optimistic synchronization separately. class NotesRepository { NotesRepository({ required this.store, required Future<NotesApiClient> Function(NextcloudAccount) clientForAccount, - }) : _clientForAccount = clientForAccount; + DateTime Function()? clock, + this.fetchCapabilities, + }) : clock = clock ?? DateTime.now, + _clientForAccount = clientForAccount; final NotesStore store; + final DateTime Function() clock; + final Future<NotesCapabilities> Function(NextcloudAccount)? fetchCapabilities; + final _capabilityRequests = <String, Future<void>>{}; + final _accountGenerations = <String, int>{}; + final _verifiedInProcess = <String>{}; + final _settingsTransitions = <String>{}; + final _activeMediaNotes = <String>{}; + final _mediaFreshnessEpochs = <String, int>{}; + final _validatedMediaEpochs = <String, int>{}; + final _downloads = <String, NotesDownloadCancellation>{}; + static const mediaFreshness = Duration(minutes: 5); final Future<NotesApiClient> Function(NextcloudAccount) _clientForAccount; final _changes = StreamController<void>.broadcast(); final _accounts = <String, NextcloudAccount>{}; final _notes = <String, NextcloudNote>{}; final _listedIds = <String, Set<int>>{}; final _baseGenerations = <String, int>{}; + // Downloads invalidate editor bases; acknowledgments only invalidate older + // reads. Our own acknowledged writes must not create editor conflicts. + final _acknowledgmentGenerations = <String, int>{}; final _mediaResolutions = <String, Future<String?>>{}; final _mediaVersions = <String, int>{}; int mediaVersion(String localId) => _mediaVersions[localId] ?? 0; @@ -79,9 +99,919 @@ class NotesRepository { Stream<void> get changes => _changes.stream; NextcloudAccount? accountById(String id) => _accounts[id]; NextcloudNote? noteById(String id) => _notes[id]; + + Future<bool> confirmNoteWebRoute(String accountId) async { + _assertMutableAccount(accountId); + final account = _accounts[accountId]!; + if (account.notesWebPrettyUrls case final cached?) return cached; + final generation = accountGeneration(accountId); + final client = await _clientForAccount(account); + _assertMutableAccount(accountId); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account changed.', + ); + } + final pretty = await client.noteWebPrettyUrls(); + return _mutate(() async { + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account changed.', + ); + } + final updated = _accounts[accountId]!.copyWith( + notesWebPrettyUrls: pretty, + ); + await store.saveAccount(updated); + _accounts[accountId] = updated; + _notify(); + return pretty; + }); + } + NotesEditorBase editorBase(String id) => NotesEditorBase(_require(id).base, _baseGenerations[id] ?? 0); NotesException? accountError(String accountId) => _accountErrors[accountId]; + bool isSynchronizing(String accountId) => _syncs.containsKey(accountId); + bool _currentAccount(String id, int generation) => + !_disposed && + !_removing.contains(id) && + _accounts.containsKey(id) && + (_accountGenerations[id] ?? 0) == generation; + void _assertMutableAccount(String id) { + if (_disposed || _removing.contains(id) || !_accounts.containsKey(id)) { + throw const NotesException( + NotesFailureCode.missing, + 'The Nextcloud account is no longer available.', + scope: NotesRequestScope.account, + ); + } + if (_settingsTransitions.contains(id) || + _accounts[id]!.settingsAttempt != null) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve the Notes settings transition before editing this collection.', + scope: NotesRequestScope.settings, + ); + } + } + + bool _writeEligible(NextcloudNote note) => + note.retryCount <= 6 && + (note.retryNotBefore == null || !clock().isBefore(note.retryNotBefore!)); + bool hasRetryableWork(String id) => notes.any( + (n) => + n.accountId == id && + n.hasPendingChanges && + !_isBlocked(n) && + (n.retryCount > 0 || n.retryNotBefore?.isAfter(clock()) == true) && + n.retryCount <= 6, + ); + DateTime? writeDeadline(String id) { + final deadlines = notes + .where( + (n) => + n.accountId == id && + n.hasPendingChanges && + !_isBlocked(n) && + n.retryCount <= 6, + ) + .map((n) => n.retryNotBefore) + .whereType<DateTime>() + .toList(); + final accountDeadline = + _accounts[id]?.throttleNotBefore ?? accountError(id)?.retryNotBefore; + if (accountDeadline != null) deadlines.add(accountDeadline); + deadlines.sort(); + return deadlines.lastOrNull; + } + + Future<void> retryWrites(String id) => _mutate(() async { + final reset = notes + .where( + (n) => + n.accountId == id && + n.failureCode != null && + { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(n.failureCode) && + !_isBlocked(n), + ) + .map((n) => n.copyWith(retryCount: 0)) + .toList(); + await store.commit(notes: reset); + for (final n in reset) { + _notes[n.localId] = n; + } + }); + + Future<void> recoverNetworkWrites(String id) => _mutate(() async { + final recovered = notes + .where( + (n) => + n.accountId == id && + n.failureCode == NotesFailureCode.network && + !_isBlocked(n), + ) + .map( + (n) => n.copyWith( + retryCount: 0, + syncState: NoteSyncState.pending, + failureCode: null, + failureScope: null, + ), + ) + .toList(); + await store.commit(notes: recovered); + for (final n in recovered) { + _notes[n.localId] = n; + } + }); + + /// A deliberate corrective action; timers and ordinary Refresh cannot do this. + Future<void> retryRejectedNote(String id) => _mutate(() async { + final current = _require(id); + _assertMutableAccount(current.accountId); + if (!{ + NoteSyncState.rejected, + NoteSyncState.storageFull, + }.contains(current.syncState) || + current.creationAttempt != null || + (current.readonly && + (current.base == null || + _readonlyBlocks(current, current.base!)))) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve this operation before retrying it.', + ); + } + for (final attachment in await store.attachments(id)) { + if (attachment.state == 'rejected') { + await store.updateAttachment( + NotesAttachment( + id: attachment.id, + noteId: id, + filename: attachment.filename, + reference: attachment.reference, + remotePath: attachment.remotePath, + state: 'pending', + ), + ); + } + } + final retry = current.copyWith( + syncState: NoteSyncState.pending, + failureCode: null, + failureScope: null, + retryCount: 0, + errorMessage: null, + ); + await store.saveNote(retry); + _notes[id] = retry; + _notify(); + }); + + NotesMetadataSnapshot metadataSnapshot(String id) => + NotesMetadataSnapshot(_require(id), editorBase(id)); + + /// Three-way field merge under the same boundary that owns editor content. + Future<NextcloudNote> patchMetadata( + NotesMetadataSnapshot snapshot, { + String? title, + String? category, + bool? favorite, + }) => _mutate(() async { + final original = snapshot.note; + final current = _require(original.localId); + _assertMutableAccount(original.accountId); + if (current.accountId != original.accountId || + current.serverId != original.serverId) { + throw const NotesException( + NotesFailureCode.conflict, + 'The note identity changed; reopen its properties.', + ); + } + if (isNotesRecovery(current)) { + throw const NotesException( + NotesFailureCode.missing, + 'Recover this deleted note as a new note before editing.', + ); + } + title = title == original.title ? null : title; + category = category == original.category ? null : category; + favorite = favorite == original.favorite ? null : favorite; + if (title == null && category == null && favorite == null) return current; + if ((current.readonly || current.error) && + (title != null || category != null)) { + throw const NotesException( + NotesFailureCode.forbidden, + 'This Nextcloud note became read-only.', + ); + } + final titleDivergent = + (title != null && + current.title != original.title && + current.title != title); + final categoryDivergent = + (category != null && + current.category != original.category && + current.category != category); + final favoriteDivergent = + (favorite != null && + current.favorite != original.favorite && + current.favorite != favorite); + final divergent = titleDivergent || categoryDivergent || favoriteDivergent; + final changed = + (title != null && title != current.title) || + (category != null && category != current.category) || + (favorite != null && favorite != current.favorite); + if (!changed) return current; + final intended = NotesMetadataValues.fromNote( + current, + ).copyWith(title: title, category: category, favorite: favorite); + final previousConflict = current.metadataConflict; + final previousMerge = previousConflict == null + ? null + : NotesConflictMerge.metadata(current); + final metadataConflict = previousConflict == null + ? divergent + ? NotesMetadataConflict( + original: NotesMetadataValues.fromNote(original), + alternative: intended.copyWith( + title: titleDivergent ? current.title : null, + category: categoryDivergent ? current.category : null, + favorite: favoriteDivergent ? current.favorite : null, + ), + ) + : null + : NotesMetadataConflict( + original: previousConflict.original, + alternative: previousConflict.alternative.copyWith( + title: previousMerge!.title.conflicted ? null : intended.title, + category: previousMerge.category.conflicted + ? null + : intended.category, + favorite: previousMerge.favorite.conflicted + ? null + : intended.favorite, + ), + ); + final patched = current.copyWith( + title: title, + category: category, + favorite: favorite, + revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + metadataConflict: metadataConflict, + syncState: + { + NoteSyncState.creationUncertain, + NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, + NoteSyncState.reconnectRequired, + NoteSyncState.forbidden, + NoteSyncState.unavailable, + NoteSyncState.storageFull, + }.contains(current.syncState) + ? current.syncState + : metadataConflict != null || + current.syncState == NoteSyncState.conflict + ? NoteSyncState.conflict + : _isBlocked(current) && current.syncState != NoteSyncState.rejected + ? current.syncState + : NoteSyncState.pending, + failureCode: null, + failureScope: null, + retryCount: 0, + errorMessage: divergent + ? 'A property changed while the dialog was open. Review both values before synchronizing.' + : current.syncState == NoteSyncState.conflict + ? current.errorMessage + : null, + ); + await store.saveNote(patched); + _notes[current.localId] = patched; + _notify(); + return patched; + }); + + Future<List<NotesBatchOutcome>> patchMetadataBatch( + List<NotesMetadataSnapshot> reviewed, { + String? category, + bool? favorite, + }) async { + final outcomes = <NotesBatchOutcome>[]; + final seen = <String>{}; + for (final snapshot in reviewed) { + final original = snapshot.note; + if (!seen.add(original.localId)) continue; + try { + final before = noteById(original.localId)?.revision; + final updated = await patchMetadata( + snapshot, + category: category, + favorite: favorite, + ); + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + updated.metadataConflict != null || + updated.syncState == NoteSyncState.conflict + ? NotesBatchStatus.conflicted + : updated.revision == before + ? NotesBatchStatus.skipped + : NotesBatchStatus.changed, + ), + ); + } on NotesException catch (error) { + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + error.code == NotesFailureCode.forbidden || + error.code == NotesFailureCode.missing + ? NotesBatchStatus.skipped + : error.code == NotesFailureCode.conflict + ? NotesBatchStatus.conflicted + : NotesBatchStatus.failed, + error.message, + ), + ); + } on Object { + outcomes.add( + NotesBatchOutcome( + original.localId, + original.title, + NotesBatchStatus.failed, + 'Local transaction failed; successful items remain saved.', + ), + ); + } + } + return outcomes; + } + + Future<List<NextcloudNote>> captureLocalSnapshot( + String accountId, + Set<String> ids, + ) => _mutate(() async { + _assertMutableAccount(accountId); + final captured = <NextcloudNote>[]; + for (final id in ids) { + final note = _require(id); + if (note.accountId != accountId || isNotesRecovery(note)) { + throw const NotesException( + NotesFailureCode.conflict, + 'The export scope changed; review again.', + ); + } + captured.add(note); + } + return NotesLocalSnapshot( + captured, + mediaVersions: { + for (final note in captured) note.localId: mediaVersion(note.localId), + }, + accountGenerations: {accountId: accountGeneration(accountId)}, + ); + }); + + Future<void> setOfflineRequirement( + String accountId, + String kind, + String target, { + bool required = true, + bool paused = false, + }) => _mutate(() async { + _assertMutableAccount(accountId); + if ((kind == 'note' || kind == 'pause') && + _require(target).accountId != accountId) { + throw const NotesException( + NotesFailureCode.unsafeReference, + 'Wrong offline note account.', + ); + } + await store.setOfflineRequirement( + accountId, + kind, + target, + required: required, + paused: paused, + ); + _notify(); + }); + void cancelMediaDownloads(String localId) { + final note = _notes[localId]; + if (note == null) return; + for (final entry in _downloads.entries.where( + (e) => e.key.startsWith('${note.accountId}:$localId:'), + )) { + entry.value.cancel(); + } + } + + Future< + ({List<String> required, List<String> available, List<String> external}) + > + attachmentAvailability(String localId, {String? source}) async { + final note = _require(localId); + final known = await store.attachments(localId); + final required = <String>{}, available = <String>{}, external = <String>{}; + for (final occurrence in await scanNotesAttachmentReferences( + source ?? note.content, + )) { + final reference = occurrence.reference; + final canonical = canonicalAttachmentReference(reference); + final candidates = known + .where( + (a) => + a.reference == reference || + canonical != null && a.remotePath == canonical, + ) + .toList(); + final attachment = _liveMediaAttachment(candidates); + final pending = reference.startsWith('busymark-attachment:'); + final own = + canonical?.startsWith('.attachments.${note.serverId}/') == true; + final foreign = canonical?.startsWith('.attachments.') == true && !own; + if (candidates.isNotEmpty || + pending || + own || + occurrence.image && canonical != null && !foreign) { + required.add(reference); + if (attachment != null && + await store.attachmentSize(attachment.id) != null) { + available.add(reference); + } + } else if (occurrence.image || foreign) { + external.add(reference); + } + } + return ( + required: required.toList(), + available: available.toList(), + external: external.toList(), + ); + } + + /// An import item's association commits with its new identity and bytes. + /// Synchronization keeps the existing never-sent/uncertain publication logic. + Future<NextcloudNote> importLocal({ + required String accountId, + required String sourceKey, + required String title, + required String category, + required String content, + bool favorite = false, + int? activityMicros, + int? modified, + required Map<String, ({String filename, Uint8List bytes})> media, + }) => _mutate(() async { + _assertMutableAccount(accountId); + final existing = await store.importedNote(accountId, sourceKey); + if (existing != null) return _require(existing); + final localId = _uuid.v4(); + final attachments = <({NotesAttachment attachment, Uint8List bytes})>[]; + final replacements = <String, String>{}; + for (final entry in media.entries) { + final id = _uuid.v4(); + final attachment = NotesAttachment( + id: id, + noteId: localId, + filename: entry.value.filename, + reference: 'busymark-attachment:$accountId:$localId:$id', + ); + attachments.add((attachment: attachment, bytes: entry.value.bytes)); + replacements[entry.key] = attachment.reference; + } + final rewritten = replaceNotesAttachmentReferences( + content, + await scanNotesAttachmentReferences(content), + replacements, + ); + final note = NextcloudNote( + localId: localId, + accountId: accountId, + title: title, + category: category, + content: rewritten, + favorite: favorite, + creationNeverSent: true, + localActivityMicros: activityMicros ?? clock().microsecondsSinceEpoch, + modified: modified ?? 0, + ); + await store.createWithAttachments(note, attachments, importKey: sourceKey); + _notes[localId] = note; + _notify(); + return note; + }); + + final _capabilityEpochs = <String, int>{}; + int capabilityEpoch(String id) => _capabilityEpochs[id] ?? 0; + int accountGeneration(String id) => _accountGenerations[id] ?? 0; + Future<void> recordApiVersions( + String id, + int generation, + int epoch, + String raw, + ) => _mutate(() async { + if (!_currentAccount(id, generation) || capabilityEpoch(id) != epoch) { + return; + } + final values = raw.split(',').map((v) => v.trim()).toList(); + if (values.isEmpty || + values.any((v) => !RegExp(r'^\d+\.\d+$').hasMatch(v))) { + return; + } + final version = parseNotesApiVersions(values); + final current = _accounts[id]!; + final updated = current.copyWith( + apiVersion: version ?? current.apiVersion, + apiSupported: version != null, + ); + await store.saveAccount(updated); + _accounts[id] = updated; + _capabilityEpochs[id] = epoch + 1; + if (version == null) { + _accountErrors[id] = const NotesException( + NotesFailureCode.unsupported, + 'This server no longer advertises Notes API major 1, minor 4 or later. Local work is preserved.', + scope: NotesRequestScope.capability, + ); + } + _notify(); + }); + + Future<void> maintainCapabilities(String id, {bool force = false}) { + if (_capabilityRequests[id] case final pending?) return pending; + final account = _accounts[id]; + if (fetchCapabilities == null || + account == null || + _disposed || + _removing.contains(id)) { + return Future.value(); + } + if (!force && + _verifiedInProcess.contains(id) && + account.capabilitiesCheckedAt != null && + clock().difference(account.capabilitiesCheckedAt!) < + const Duration(hours: 1)) { + return Future.value(); + } + final generation = accountGeneration(id); + final epoch = capabilityEpoch(id) + 1; + _capabilityEpochs[id] = epoch; + final future = () async { + try { + final capabilities = await fetchCapabilities!(account); + await _mutate(() async { + if (!_currentAccount(id, generation)) return; + final current = _accounts[id]!; + final updated = current.copyWith( + appVersion: capabilities.appVersion, + apiVersion: capabilityEpoch(id) == epoch + ? capabilities.apiVersion + : current.apiVersion, + apiSupported: capabilityEpoch(id) == epoch + ? true + : current.apiSupported, + capabilitiesCheckedAt: clock(), + ); + await store.saveAccount(updated); + _accounts[id] = updated; + _verifiedInProcess.add(id); + _notify(); + }); + } on NextcloudCapabilityException catch (error) { + if (!_currentAccount(id, generation)) return; + if (error.code == NextcloudCapabilityFailure.unsupported) { + await _mutate(() async { + if (!_currentAccount(id, generation) || + capabilityEpoch(id) != epoch) { + return; + } + final current = _accounts[id]!.copyWith( + apiSupported: false, + capabilitiesCheckedAt: clock(), + ); + await store.saveAccount(current); + _accounts[id] = current; + }); + } + throw NotesException( + switch (error.code) { + NextcloudCapabilityFailure.unsupported => + NotesFailureCode.unsupported, + NextcloudCapabilityFailure.unauthorized => + NotesFailureCode.authentication, + NextcloudCapabilityFailure.malformed => + NotesFailureCode.invalidResponse, + NextcloudCapabilityFailure.network => NotesFailureCode.network, + NextcloudCapabilityFailure.rejected => NotesFailureCode.rejected, + NextcloudCapabilityFailure.forbidden => NotesFailureCode.forbidden, + NextcloudCapabilityFailure.throttled => NotesFailureCode.throttled, + }, + error.message, + scope: NotesRequestScope.capability, + statusCode: error.statusCode, + retryNotBefore: error.retryNotBefore, + ); + } + }(); + _capabilityRequests[id] = future; + return future.whenComplete(() { + if (identical(_capabilityRequests[id], future)) { + _capabilityRequests.remove(id); + } + }); + } + + Future<NotesSettings> getSettings(String id) async { + final generation = accountGeneration(id); + final account = _accounts[id]; + if (account == null) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed.', + scope: NotesRequestScope.account, + ); + } + _checkThrottle(account); + late NotesSettings settings; + try { + settings = await (await _clientForAccount(account)).getSettings(); + } on NotesException catch (error) { + await _rememberThrottle(id, error); + rethrow; + } + if (!_currentAccount(id, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed or reconnected.', + scope: NotesRequestScope.account, + ); + } + return settings; + } + + bool hasSettingsBlockers(String id) => notes.any( + (n) => + n.accountId == id && + (n.hasPendingChanges || + n.syncState == NoteSyncState.conflict || + n.syncState == NoteSyncState.creationUncertain), + ); + + Future<NotesSettings> changeSettings( + String id, + NotesSettings original, + Map<String, String> patch, + ) async { + if (patch.isEmpty) return original; + _assertMutableAccount(id); + _settingsTransitions.add(id); + final generation = accountGeneration(id); + try { + await _syncs[id]; + final fresh = await getSettings(id); + for (final field in patch.keys) { + if (fresh.toJson()[field] != original.toJson()[field] && + fresh.toJson()[field] != patch[field]) { + throw const NotesException( + NotesFailureCode.conflict, + 'Server settings changed while this form was open. Reload and review them.', + scope: NotesRequestScope.settings, + ); + } + } + final pathChange = + patch.containsKey('notesPath') && + patch['notesPath'] != fresh.notesPath; + final suffixChange = + patch.containsKey('fileSuffix') && + patch['fileSuffix'] != fresh.fileSuffix; + final attempt = NotesSettingsAttempt( + id: _uuid.v4(), + original: fresh, + patch: patch, + ); + await _mutate(() async { + if (!_currentAccount(id, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The account was removed.', + scope: NotesRequestScope.account, + ); + } + final attachments = await store.attachments(); + if ((pathChange || suffixChange) && + (hasSettingsBlockers(id) || + attachments.any( + (a) => + _notes[a.noteId]?.accountId == id && + !{'cached', 'uploaded', 'deleted'}.contains(a.state), + ))) { + throw const NotesException( + NotesFailureCode.conflict, + 'Synchronize or resolve pending notes, conflicts and attachment operations before changing the server collection.', + scope: NotesRequestScope.settings, + ); + } + final current = _accounts[id]!.copyWith(settingsAttempt: attempt); + await store.saveAccount(current); + _accounts[id] = current; + }); + NotesSettings result; + try { + result = await (await _clientForAccount( + _accounts[id]!, + )).updateSettings(patch); + } on NotesException catch (error) { + await _rememberThrottle(id, error); + if (!error.possiblyExecuted) { + await _finishSettings( + id, + generation, + attempt, + fresh, + resetCheckpoint: false, + ); + rethrow; + } + // A lost response may follow normalization/partial execution. Read first; + // if that read fails, the durable attempt fences writes across restart. + result = await getSettings(id); + if (patch.keys.every( + (key) => result.toJson()[key] == fresh.toJson()[key], + )) { + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: + result.notesPath != fresh.notesPath || + result.fileSuffix != fresh.fileSuffix, + ); + throw const NotesException( + NotesFailureCode.rejected, + 'The settings response was lost and the server still reports the previous values. Review them before saving again.', + scope: NotesRequestScope.settings, + ); + } + } + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: + pathChange || + suffixChange || + result.notesPath != fresh.notesPath || + result.fileSuffix != fresh.fileSuffix, + ); + return result; + } finally { + _settingsTransitions.remove(id); + } + } + + Future<void> _finishSettings( + String id, + int generation, + NotesSettingsAttempt attempt, + NotesSettings result, { + required bool resetCheckpoint, + }) => _mutate(() async { + if (!_currentAccount(id, generation) || + _accounts[id]!.settingsAttempt?.id != attempt.id) { + throw const NotesException( + NotesFailureCode.missing, + 'The account changed during the settings request.', + scope: NotesRequestScope.account, + ); + } + final current = _accounts[id]!.copyWith( + settingsAttempt: null, + listEtag: resetCheckpoint ? null : _accounts[id]!.listEtag, + lastModified: resetCheckpoint ? null : _accounts[id]!.lastModified, + ); + await store.saveAccount(current); + _accounts[id] = current; + _notify(); + }); + + void _checkThrottle(NextcloudAccount account) { + final deadline = account.throttleNotBefore; + if (deadline != null && clock().isBefore(deadline)) { + throw NotesException( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before the next request.', + scope: NotesRequestScope.account, + retryNotBefore: deadline, + ); + } + } + + Future<void> _rememberThrottle(String id, NotesException error) => + _mutate(() async { + final account = _accounts[id]; + if (account == null || + _disposed || + _removing.contains(id) || + error.retryNotBefore == null) { + return; + } + final previous = account.throttleNotBefore; + final deadline = + previous != null && previous.isAfter(error.retryNotBefore!) + ? previous + : error.retryNotBefore; + final updated = account.copyWith(throttleNotBefore: deadline); + await store.saveAccount(updated); + _accounts[id] = updated; + }); + Future<NotesSettings> reconcileSettings(String id) async { + final attempt = _accounts[id]?.settingsAttempt; + final generation = accountGeneration(id); + final result = await getSettings(id); + if (attempt != null) { + await _finishSettings( + id, + generation, + attempt, + result, + resetCheckpoint: true, + ); + } + return result; + } + + void setDisplayedMediaNotes(Iterable<String> ids) { + _activeMediaNotes + ..clear() + ..addAll(ids); + } + + void _invalidateMediaFreshness(String id) { + _mediaFreshnessEpochs[id] = (_mediaFreshnessEpochs[id] ?? 0) + 1; + for (final entry in _downloads.entries.where( + (e) => e.key.contains(':$id:'), + )) { + entry.value.cancel(); + } + _mediaVersions[id] = mediaVersion(id) + 1; + } + + Future<void> refreshDisplayedMedia({bool force = false}) async { + for (final id in _activeMediaNotes.toList()) { + final note = _notes[id]; + if (note == null || _disposed || _removing.contains(note.accountId)) { + continue; + } + final used = (await scanNotesAttachmentReferences(note.content)) + .map((r) => canonicalAttachmentReference(r.reference)) + .whereType<String>() + .toSet(); + final cached = (await store.attachments(id)) + .where( + (a) => + a.remotePath != null && + used.contains(a.remotePath) && + {'cached', 'uploaded'}.contains(a.state), + ) + .toList(); + if (force) { + _invalidateMediaFreshness(id); + await Future.wait( + _mediaResolutions.entries + .where((e) => e.key.startsWith('${note.accountId}:$id:')) + .map( + (e) => e.value.then<void>( + (_) {}, + onError: (Object _, StackTrace _) {}, + ), + ) + .toList(), + ); + } + for (final attachment in cached) { + await resolveMedia( + note.accountId, + id, + attachmentMarkdownReference(attachment.remotePath!), + ); + } + } + } + Future<List<NotesAttachment>> attachments(String localId) => store.attachments(localId); @@ -159,12 +1089,71 @@ class NotesRepository { 'This note is no longer available.', )); - Future<void> upsertAccount(NextcloudAccount account) async { + Future<void> upsertAccount( + NextcloudAccount account, { + bool reconnect = false, + }) async { await initialize(); _validateId(account.id); + // Finish requests using the old credential before releasing their durable + // authentication blocks. Network waits stay outside the mutation queue. + if (reconnect) await _syncs[account.id]; await _mutate(() async { - await store.saveAccount(account); - _accounts[account.id] = account; + final existing = _accounts[account.id]; + if (reconnect && + (existing == null || + existing.server != account.server || + existing.loginName != account.loginName)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Reconnect must verify the same Nextcloud account.', + scope: NotesRequestScope.account, + ); + } + final merged = existing == null + ? account + : existing.copyWith( + appVersion: account.appVersion, + apiVersion: account.apiVersion, + apiSupported: account.apiSupported, + capabilitiesCheckedAt: account.capabilitiesCheckedAt, + notesWebPrettyUrls: reconnect + ? null + : existing.notesWebPrettyUrls, + ); + final recovered = reconnect + ? notes + .where( + (note) => + note.accountId == account.id && + note.syncState == NoteSyncState.reconnectRequired && + note.creationAttempt == null, + ) + .map( + (note) => note.copyWith( + syncState: + note.metadataConflict != null || note.remote != null + ? NoteSyncState.conflict + : NoteSyncState.pending, + failureCode: null, + failureScope: null, + errorMessage: null, + retryCount: 0, + ), + ) + .toList() + : <NextcloudNote>[]; + await store.commit(accounts: [merged], notes: recovered); + _accounts[account.id] = merged; + for (final note in recovered) { + _notes[note.localId] = note; + } + if (reconnect) _accountErrors.remove(account.id); + if (reconnect || existing == null) { + _accountGenerations[account.id] = + (_accountGenerations[account.id] ?? 0) + 1; + } + _verifiedInProcess.remove(account.id); _notify(); }); } @@ -183,12 +1172,15 @@ class NotesRepository { ); } return _mutate(() async { + _assertMutableAccount(accountId); final note = NextcloudNote( + creationNeverSent: true, localId: _uuid.v4(), accountId: accountId, title: title, category: category, content: content, + localActivityMicros: clock().microsecondsSinceEpoch, ); await store.saveNote(note); _notes[note.localId] = note; @@ -208,6 +1200,7 @@ class NotesRepository { bool? favorite, }) => _mutate(() async { var current = _require(localId); + _assertMutableAccount(current.accountId); final digest = sha256.convert(utf8.encode(content)).toString(); if (editorRevision != null && (editorRevision < current.editorRevision || @@ -257,6 +1250,7 @@ class NotesRepository { NoteSyncState.conflict, NoteSyncState.creationUncertain, NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, }.contains(current.syncState); final revision = editorRevision != null && editorRevision > current.revision ? editorRevision @@ -267,6 +1261,10 @@ class NotesRepository { category: category, favorite: favorite, revision: revision, + localActivityMicros: clock().microsecondsSinceEpoch, + failureCode: null, + failureScope: null, + retryCount: 0, editorRevision: editorRevision ?? current.editorRevision, editorContentDigest: editorRevision == null ? current.editorContentDigest @@ -295,67 +1293,130 @@ class NotesRepository { return saved; }); - Future<void> synchronize(String accountId) { + Future<void> synchronize( + String accountId, { + bool allowWrites = true, + bool refreshCapabilities = false, + bool onlyFreshWrites = false, + }) { if (_disposed || _removing.contains(accountId)) return Future.value(); final existing = _syncs[accountId]; if (existing != null) return existing; - final future = _synchronize(accountId); + final future = _synchronize( + accountId, + allowWrites: allowWrites, + refreshCapabilities: refreshCapabilities, + onlyFreshWrites: onlyFreshWrites, + ); _syncs[accountId] = future; + _notify(); future.then<void>( (_) { _syncs.remove(accountId); + _notify(); }, onError: (Object _, StackTrace _) { _syncs.remove(accountId); + _notify(); }, ); return future; } - Future<void> _synchronize(String accountId) async { + Future<void> _synchronize( + String accountId, { + required bool allowWrites, + required bool refreshCapabilities, + required bool onlyFreshWrites, + }) async { await initialize(); - final account = _accounts[accountId]; - if (account == null) return; + var account = _accounts[accountId]; + if (account == null || _settingsTransitions.contains(accountId)) return; + final generation = _accountGenerations[accountId] ?? 0; + final deadline = + _accounts[accountId]?.throttleNotBefore ?? + _accountErrors[accountId]?.retryNotBefore; + if (deadline != null && clock().isBefore(deadline)) { + _accountErrors[accountId] = NotesException( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before the next server check.', + scope: NotesRequestScope.account, + retryNotBefore: deadline, + ); + _notify(); + return; + } late NotesApiClient client; try { + await maintainCapabilities(accountId, force: refreshCapabilities); + account = _accounts[accountId]; + if (account == null || + !_currentAccount(accountId, generation) || + !account.apiSupported) { + return; + } + if (account.settingsAttempt != null) { + await reconcileSettings(accountId); + account = _accounts[accountId]; + if (account == null || account.settingsAttempt != null) return; + } client = await _clientForAccount(account); final list = await client.list( forceFull: _notes.values.any( (n) => n.accountId == accountId && - n.syncState == NoteSyncState.creationUncertain, + (n.syncState == NoteSyncState.creationUncertain || + (n.syncState == NoteSyncState.deletedRemotely && + n.deletionEvidence == null)), ), ); + if (!_currentAccount(accountId, generation)) return; if (!list.notModified) { await _applyList(account, list); _listedIds[accountId] = list.ids; } - if (_accountErrors.remove(accountId) != null) _notify(); + await _mutate(() async { + if (!_currentAccount(accountId, generation)) return; + final checked = _accounts[accountId]!.copyWith( + lastServerCheck: clock(), + ); + await store.saveAccount(checked); + _accounts[accountId] = checked; + if (checked.apiSupported) _accountErrors.remove(accountId); + _notify(); + }); } on NotesException catch (error) { await _markAccountError(accountId, error); return; - } catch (_) { - await _markAccountError( - accountId, - const NotesException( - NotesFailureCode.authentication, - 'Unlock your keyring or reconnect to Nextcloud.', - ), - ); + } + if (!allowWrites || + !_currentAccount(accountId, generation) || + _accounts[accountId]?.apiSupported != true) { return; } // One pass, bounded retries through deliberate refresh, never a lock spin loop. final pendingIds = _notes.values .where( (n) => - n.accountId == accountId && n.hasPendingChanges && !_isBlocked(n), + n.accountId == accountId && + n.hasPendingChanges && + (!onlyFreshWrites || + (n.retryCount == 0 && n.failureCode == null)) && + !_isBlocked(n) && + _writeEligible(n), ) .map((n) => n.localId) .toList(); for (final id in pendingIds) { - if (_removing.contains(accountId)) return; + if (!_currentAccount(accountId, generation)) return; var note = _notes[id]; - if (note == null || _isBlocked(note)) continue; + if (note == null || + _isBlocked(note) || + !_writeEligible(note) || + (onlyFreshWrites && + (note.retryCount != 0 || note.failureCode != null))) { + continue; + } try { if (note.serverId == null) { final sent = await _markCreationSending(id); @@ -364,10 +1425,7 @@ class NotesRepository { await _acknowledge(sent, remote); } on NotesException catch (error) { // 5xx/malformed/transport failure can happen after a successful POST. - if (error.code == NotesFailureCode.network || - error.code == NotesFailureCode.server || - error.code == NotesFailureCode.invalidResponse || - error.code == NotesFailureCode.locked) { + if (error.possiblyExecuted) { await _setState( id, NoteSyncState.creationUncertain, @@ -380,7 +1438,10 @@ class NotesRepository { if (current.creationAttempt?.id != sent.creationAttempt?.id) { return; } - final rejected = current.copyWith(creationAttempt: null); + final rejected = current.copyWith( + creationAttempt: null, + creationNeverSent: true, + ); await store.saveNote(rejected); _notes[id] = rejected; }); @@ -408,6 +1469,7 @@ class NotesRepository { } bool _isBlocked(NextcloudNote note) => + note.metadataConflict != null || (note.serverId == null && note.creationAttempt != null) || note.error || (note.readonly && @@ -420,6 +1482,11 @@ class NotesRepository { NoteSyncState.creationUncertain, NoteSyncState.deletedRemotely, NoteSyncState.unavailable, + NoteSyncState.rejected, + NoteSyncState.forbidden, + NoteSyncState.reconnectRequired, + NoteSyncState.storageFull, + NoteSyncState.recoveryRequired, }.contains(note.syncState); bool _readonlyBlocks(NextcloudNote note, NoteState remote) => @@ -472,63 +1539,12 @@ class NotesRepository { ); } for (final remote in list.notes) { - final current = existing[remote.id]; - if (current == null) { - updated.add(_fromRemote(_uuid.v4(), account.id, remote)); - } else if (remote.error) { - updated.add( - current.copyWith( - readonly: true, - error: true, - remote: remote, - syncState: NoteSyncState.unavailable, - errorMessage: - 'Nextcloud could not read this note. Cached content has been preserved.', - ), - ); - } else if (current.hasPendingChanges) { - if (remote.etag != current.etag) { - updated.add( - current.copyWith( - remote: remote, - readonly: remote.readonly, - syncState: NoteSyncState.conflict, - errorMessage: - 'The note changed on Nextcloud while local changes were pending.', - ), - ); - } else if (current.readonly != remote.readonly || current.error) { - final forbidden = - _readonlyBlocks(current, remote) && - current.syncState != NoteSyncState.conflict; - updated.add( - current.copyWith( - readonly: remote.readonly, - error: false, - syncState: forbidden - ? NoteSyncState.forbidden - : current.syncState == NoteSyncState.unavailable - ? NoteSyncState.pending - : current.syncState, - errorMessage: forbidden - ? 'Nextcloud became read-only. Local changes are preserved; recover them as a new note.' - : current.syncState == NoteSyncState.unavailable - ? null - : current.errorMessage, - ), - ); - } - } else { - updated.add( - _fromRemote( - current.localId, - account.id, - remote, - revision: current.revision, - previous: current, - ), - ); - } + final reconciled = _reconcileServerNote( + account.id, + existing[remote.id], + remote, + ); + if (reconciled != null) updated.add(reconciled); } // Only list()'s fully received final chunk may supply the complete ID set. for (final note in existing.values) { @@ -536,6 +1552,7 @@ class NotesRepository { updated.add( note.copyWith( syncState: NoteSyncState.deletedRemotely, + deletionEvidence: 'completeList', errorMessage: note.hasPendingChanges ? 'This note was deleted on Nextcloud. Recover as a new note or discard local changes.' : null, @@ -543,6 +1560,26 @@ class NotesRepository { ); } } + for (final draft in _notes.values.where( + (n) => + n.accountId == account.id && + n.serverId == null && + n.syncState == NoteSyncState.deletedRemotely && + n.deletionEvidence == null && + n.ackRevision == 0 && + n.creationAttempt == null, + )) { + updated.add( + draft.copyWith( + syncState: draft.creationNeverSent + ? NoteSyncState.pending + : NoteSyncState.recoveryRequired, + errorMessage: draft.creationNeverSent + ? null + : 'This older record has no reliable creation history. Review the server notes before deliberately creating a separate note; its content and attachment bytes are retained.', + ), + ); + } // Reconnection may refresh app/capability metadata while this request is // in flight. Commit the checkpoint onto the current non-secret account. final checkpoint = (_accounts[account.id] ?? account).withCheckpoint( @@ -553,6 +1590,7 @@ class NotesRepository { _accounts[account.id] = checkpoint; for (final note in updated) { if (note.etag != _notes[note.localId]?.etag) { + _invalidateMediaFreshness(note.localId); _baseGenerations[note.localId] = (_baseGenerations[note.localId] ?? 0) + 1; } @@ -561,6 +1599,199 @@ class NotesRepository { _notify(); }); + NextcloudNote? _reconcileServerNote( + String accountId, + NextcloudNote? current, + NoteState remote, + ) { + final updated = <NextcloudNote>[]; + if (current == null) { + updated.add(_fromRemote(_uuid.v4(), accountId, remote)); + } else if (remote.error) { + updated.add( + current.copyWith( + readonly: true, + error: true, + remote: remote, + syncState: NoteSyncState.unavailable, + errorMessage: + 'Nextcloud could not read this note. Cached content has been preserved.', + ), + ); + } else if (current.hasPendingChanges) { + if (current.syncState == NoteSyncState.deletedRemotely && + current.deletionEvidence == null && + remote.etag == current.etag) { + updated.add( + current.copyWith( + syncState: NoteSyncState.pending, + errorMessage: null, + failureCode: null, + failureScope: null, + ), + ); + } else if (remote.etag != current.etag) { + updated.add( + current.copyWith( + remote: remote, + readonly: remote.readonly, + syncState: NoteSyncState.conflict, + errorMessage: + 'The note changed on Nextcloud while local changes were pending.', + ), + ); + } else if (current.readonly != remote.readonly || current.error) { + final forbidden = + _readonlyBlocks(current, remote) && + current.syncState != NoteSyncState.conflict; + updated.add( + current.copyWith( + readonly: remote.readonly, + error: false, + syncState: forbidden + ? NoteSyncState.forbidden + : current.syncState == NoteSyncState.unavailable + ? NoteSyncState.pending + : current.syncState, + errorMessage: forbidden + ? 'Nextcloud became read-only. Local changes are preserved; recover them as a new note.' + : current.syncState == NoteSyncState.unavailable + ? null + : current.errorMessage, + ), + ); + } + } else { + updated.add( + _fromRemote( + current.localId, + accountId, + remote, + revision: current.revision, + previous: current, + ), + ); + } + return updated.singleOrNull; + } + + /// Individually opening a link uses the same reconciliation as full sync, + /// without collection checkpoints or deletion inference. + Future<NextcloudNote> linkedNote(String accountId, int serverId) async { + await initialize(); + _assertMutableAccount(accountId); + final cached = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + if (cached != null) { + if (isNotesRecovery(cached)) { + throw const NotesException( + NotesFailureCode.missing, + 'This linked note was deleted.', + ); + } + return cached; + } + return refreshLinkedNote(accountId, serverId); + } + + Future<NotesQueryResult> queryNotes( + String accountId, + NotesRetrievalScope scope, { + int chunkSize = 100, + }) async { + await initialize(); + _assertMutableAccount(accountId); + final generation = _accountGenerations[accountId] ?? 0; + final client = await _clientForAccount(_accounts[accountId]!); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The queried account was removed.', + scope: NotesRequestScope.account, + ); + } + final result = await client.query(scope, chunkSize: chunkSize); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The queried account was removed.', + scope: NotesRequestScope.account, + ); + } + return result; + } + + Future<NextcloudNote> refreshLinkedNote( + String accountId, + int serverId, + ) async { + await initialize(); + _assertMutableAccount(accountId); + final account = _accounts[accountId]!; + final generation = _accountGenerations[accountId] ?? 0; + final cached = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + final baseGeneration = cached == null + ? null + : _baseGenerations[cached.localId] ?? 0; + final acknowledgmentGeneration = cached == null + ? null + : _acknowledgmentGenerations[cached.localId] ?? 0; + final client = await _clientForAccount(account); + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The linked account was removed.', + scope: NotesRequestScope.account, + ); + } + final result = await client.retrieve(serverId, cached: cached?.base); + return _mutate(() async { + if (!_currentAccount(accountId, generation)) { + throw const NotesException( + NotesFailureCode.missing, + 'The linked account was removed.', + scope: NotesRequestScope.account, + ); + } + final current = _notes.values + .where((n) => n.accountId == accountId && n.serverId == serverId) + .firstOrNull; + if (current != null && + (isNotesRecovery(current) || + cached == null || + (_baseGenerations[current.localId] ?? 0) != baseGeneration || + (_acknowledgmentGenerations[current.localId] ?? 0) != + acknowledgmentGeneration)) { + return current; + } + if (result is NotesNotModified) { + if (current == null) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'The cached linked note is no longer available.', + ); + } + // A 304 is a read observation; never an acknowledgment of local writes. + return current; + } + final remote = (result as NotesReturned).note; + final reconciled = _reconcileServerNote(accountId, current, remote); + if (reconciled == null) return current!; + await store.commit(notes: [reconciled]); + _notes[reconciled.localId] = reconciled; + if (reconciled.etag != current?.etag) { + _invalidateMediaFreshness(reconciled.localId); + _baseGenerations[reconciled.localId] = + (_baseGenerations[reconciled.localId] ?? 0) + 1; + } + _notify(); + return reconciled; + }); + } + NextcloudNote _fromRemote( String localId, String accountId, @@ -582,6 +1813,7 @@ class NotesRepository { base: remote, revision: revision, ackRevision: revision, + localActivityMicros: previous?.localActivityMicros, editorRevision: previous?.editorRevision ?? 0, editorContentDigest: previous?.editorContentDigest, syncState: remote.error ? NoteSyncState.unavailable : NoteSyncState.synced, @@ -612,6 +1844,7 @@ class NotesRepository { ); final sent = current.copyWith( creationAttempt: attempt, + creationNeverSent: false, syncState: NoteSyncState.syncing, errorMessage: null, ); @@ -633,11 +1866,17 @@ class NotesRepository { base: remote, remote: null, content: current.content, - title: current.title == wire['title'] ? remote.title : current.title, - category: current.category == wire['category'] + title: current.metadataConflict == null && current.title == wire['title'] + ? remote.title + : current.title, + category: + current.metadataConflict == null && + current.category == wire['category'] ? remote.category : current.category, - favorite: current.favorite == wire['favorite'] + favorite: + current.metadataConflict == null && + current.favorite == wire['favorite'] ? remote.favorite : current.favorite, readonly: remote.readonly, @@ -645,11 +1884,15 @@ class NotesRepository { error: false, ackRevision: attempt.revision, revision: latest && staged ? current.revision + 1 : current.revision, - syncState: latest && !staged + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : latest && !staged ? NoteSyncState.synced : NoteSyncState.pending, creationAttempt: null, - errorMessage: null, + errorMessage: current.metadataConflict != null + ? current.errorMessage + : null, ); return _readonlyBlocks(adopted, remote) ? adopted.copyWith( @@ -661,9 +1904,19 @@ class NotesRepository { } Future<NextcloudNote> _markSending(String id) => _mutate(() async { - final sent = _require( - id, - ).copyWith(syncState: NoteSyncState.syncing, errorMessage: null); + final current = _require(id); + // Revalidate inside the mutation boundary: a properties review can become + // conflicted while attachment publication or an earlier queue entry awaits. + if (_isBlocked(current)) { + throw const NotesException( + NotesFailureCode.conflict, + 'Resolve the outstanding note conflict before publishing another revision.', + ); + } + final sent = current.copyWith( + syncState: NoteSyncState.syncing, + errorMessage: null, + ); await store.saveNote(sent); _notes[id] = sent; _notify(); @@ -714,22 +1967,36 @@ class NotesRepository { base: remote, remote: null, ackRevision: sent.revision, + failureCode: null, + failureScope: null, + retryCount: 0, + retryNotBefore: null, content: latest && !staged ? remote.content : current.content, - title: latest || current.title == sent.title + title: + current.metadataConflict == null && + (latest || current.title == sent.title) ? remote.title : current.title, - category: latest || current.category == sent.category + category: + current.metadataConflict == null && + (latest || current.category == sent.category) ? remote.category : current.category, - favorite: latest ? remote.favorite : current.favorite, + favorite: latest && current.metadataConflict == null + ? remote.favorite + : current.favorite, readonly: remote.readonly, error: false, modified: remote.modified, revision: latest && staged ? current.revision + 1 : current.revision, - syncState: latest && !staged + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : latest && !staged ? NoteSyncState.synced : NoteSyncState.pending, - errorMessage: null, + errorMessage: current.metadataConflict != null + ? current.errorMessage + : null, ); if (acknowledged.hasPendingChanges && _readonlyBlocks(acknowledged, remote)) { @@ -741,6 +2008,8 @@ class NotesRepository { } await store.saveNote(acknowledged); _notes[sent.localId] = acknowledged; + _acknowledgmentGenerations[sent.localId] = + (_acknowledgmentGenerations[sent.localId] ?? 0) + 1; } _notify(); }); @@ -769,12 +2038,14 @@ class NotesRepository { NotesApiClient? client, }) async { var remote = error.remote; + final serverId = _notes[id]?.serverId; if ((error.code == NotesFailureCode.conflict || error.code == NotesFailureCode.forbidden) && remote == null && - client != null) { + client != null && + serverId != null) { try { - remote = await client.get(_require(id).serverId!); + remote = await client.get(serverId); } on NotesException { /* Preserve base/local. */ } @@ -782,24 +2053,71 @@ class NotesRepository { final state = switch (error.code) { NotesFailureCode.authentication => NoteSyncState.reconnectRequired, NotesFailureCode.forbidden => NoteSyncState.forbidden, - NotesFailureCode.missing => NoteSyncState.deletedRemotely, + NotesFailureCode.missing => + error.scope == NotesRequestScope.note + ? NoteSyncState.deletedRemotely + : NoteSyncState.rejected, + NotesFailureCode.rejected || + NotesFailureCode.invalidResponse || + NotesFailureCode.unsafeReference || + NotesFailureCode.unsupported => NoteSyncState.rejected, + NotesFailureCode.throttled => NoteSyncState.throttled, NotesFailureCode.conflict => NoteSyncState.conflict, NotesFailureCode.locked => NoteSyncState.locked, NotesFailureCode.storageFull => NoteSyncState.storageFull, NotesFailureCode.network => NoteSyncState.offline, _ => NoteSyncState.pending, }; - await _setState(id, state, error.message, remote: remote); + await _mutate(() async { + final current = _notes[id]; + if (current == null || + _disposed || + _removing.contains(current.accountId)) { + return; + } + final updated = current.copyWith( + // A retryable failure belongs to the earlier request, not to the + // disposition of newer metadata edits. Keep its retry evidence without + // replacing the review that independently blocks publication. + syncState: current.metadataConflict != null && error.retryable + ? NoteSyncState.conflict + : state, + errorMessage: current.metadataConflict != null && error.retryable + ? current.errorMessage + : error.message, + remote: remote ?? current.remote, + readonly: remote?.readonly ?? current.readonly, + failureCode: error.code, + failureScope: error.scope, + retryNotBefore: error.retryNotBefore, + retryCount: error.retryable + ? current.retryCount + 1 + : current.retryCount, + deletionEvidence: state == NoteSyncState.deletedRemotely + ? 'individualNote' + : current.deletionEvidence, + ); + await store.saveNote(updated); + _notes[id] = updated; + _notify(); + }); } Future<void> _markAccountError(String accountId, NotesException error) async { - _accountErrors[accountId] = error; - _notify(); - for (final note in notes.where( - (n) => n.accountId == accountId && n.hasPendingChanges && !_isBlocked(n), - )) { - await _recordError(note.localId, error); + if (_disposed || + _removing.contains(accountId) || + !_accounts.containsKey(accountId)) { + return; } + _accountErrors[accountId] = _accounts[accountId]!.apiSupported + ? error + : const NotesException( + NotesFailureCode.unsupported, + 'This server no longer advertises Notes API major 1, minor 4 or later. Local work is preserved.', + scope: NotesRequestScope.capability, + ); + await _rememberThrottle(accountId, error); + _notify(); } /// Recreates a remote recovery/history snapshot with its own durable staged @@ -903,6 +2221,8 @@ class NotesRepository { previousStart = occurrence.start; } final recovered = NextcloudNote( + creationNeverSent: true, + localActivityMicros: clock().microsecondsSinceEpoch, localId: localId, accountId: original.accountId, title: title ?? original.title, @@ -1123,7 +2443,9 @@ class NotesRepository { try { await recoverAsNew( localId, - creationDecision: original.serverId == null, + creationDecision: + original.serverId == null && + original.syncState == NoteSyncState.creationUncertain, ); } finally { _creationResolutions.remove(localId); @@ -1141,6 +2463,15 @@ class NotesRepository { await _resolveCreation(original, creationReview); return; } + if (original.metadataConflict != null) { + await _resolveMetadataConflict( + original, + resolution, + mergedContent: mergedContent, + metadataChoices: metadataChoices, + ); + return; + } if (original.serverId == null) { throw const NotesException( NotesFailureCode.conflict, @@ -1174,9 +2505,8 @@ class NotesRepository { 'New local edits appeared while resolving this note. Review them before applying the resolution.', ); } - if (resolution != NoteConflictResolution.takeRemote && - original.remote != null && - fresh.etag != original.remote!.etag) { + if (original.remote != null && + jsonEncode(fresh.toJson()) != jsonEncode(original.remote!.toJson())) { final changedAgain = current.copyWith( remote: fresh, readonly: fresh.readonly, @@ -1233,6 +2563,10 @@ class NotesRepository { readonly: fresh.readonly, error: false, revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + failureCode: null, + failureScope: null, + retryCount: 0, syncState: NoteSyncState.pending, errorMessage: null, ); @@ -1250,6 +2584,107 @@ class NotesRepository { }); } + Future<void> _resolveMetadataConflict( + NextcloudNote original, + NoteConflictResolution resolution, { + String? mergedContent, + required Map<NotesMergeAttribute, NotesMergeChoice> metadataChoices, + }) async { + _assertMutableAccount(original.accountId); + final generation = accountGeneration(original.accountId); + NoteState? fresh; + if (original.serverId != null) { + final client = await _clientForAccount(_accounts[original.accountId]!); + fresh = await client.get(original.serverId!); + if (fresh.error) { + throw const NotesException( + NotesFailureCode.forbidden, + 'The server note is unavailable.', + ); + } + } + await _mutate(() async { + if (!_currentAccount(original.accountId, generation)) { + throw const NotesException( + NotesFailureCode.conflict, + 'The account changed while reviewing these properties.', + ); + } + _assertMutableAccount(original.accountId); + final current = _require(original.localId); + if (current.revision != original.revision || + current.metadataConflict == null) { + throw const NotesException( + NotesFailureCode.conflict, + 'New local edits appeared. Review the conflict again.', + ); + } + final merge = NotesConflictMerge.metadata(current); + final alternative = current.metadataConflict!.alternative; + final merging = resolution == NoteConflictResolution.merge; + final previous = resolution == NoteConflictResolution.takeRemote; + final serverChanged = fresh != null && fresh.etag != current.etag; + final protectedState = { + NoteSyncState.creationUncertain, + NoteSyncState.deletedRemotely, + NoteSyncState.recoveryRequired, + NoteSyncState.reconnectRequired, + NoteSyncState.forbidden, + NoteSyncState.unavailable, + NoteSyncState.storageFull, + }.contains(current.syncState); + final resolved = current.copyWith( + content: merging ? mergedContent ?? current.content : current.content, + title: previous + ? alternative.title + : merging + ? merge.title.resolve(metadataChoices[NotesMergeAttribute.title]) + : current.title, + category: previous + ? alternative.category + : merging + ? merge.category.resolve( + metadataChoices[NotesMergeAttribute.category], + ) + : current.category, + favorite: previous + ? alternative.favorite + : merging + ? merge.favorite.resolve( + metadataChoices[NotesMergeAttribute.favorite], + ) + : current.favorite, + metadataConflict: null, + base: !serverChanged && fresh != null ? fresh : current.base, + remote: serverChanged ? fresh : null, + readonly: fresh?.readonly ?? current.readonly, + revision: current.revision + 1, + localActivityMicros: clock().microsecondsSinceEpoch, + syncState: protectedState + ? current.syncState + : current.serverId == null && current.creationAttempt != null + ? NoteSyncState.creationUncertain + : serverChanged + ? NoteSyncState.conflict + : NoteSyncState.pending, + errorMessage: protectedState + ? current.errorMessage + : serverChanged + ? 'The server also changed. Review its state against your selected properties.' + : null, + ); + if (fresh?.readonly == true && _readonlyBlocks(resolved, fresh!)) { + throw const NotesException( + NotesFailureCode.forbidden, + 'The server note is read-only. Recover protected changes as a new note.', + ); + } + await store.saveNote(resolved); + _notes[current.localId] = resolved; + _notify(); + }); + } + Future<void> _resolveCreation( NextcloudNote original, NotesCreationReview review, @@ -1445,6 +2880,7 @@ class NotesRepository { final deleted = note.copyWith( syncState: NoteSyncState.deletedRemotely, ackRevision: expectedRevision, + deletionEvidence: 'deliberate', errorMessage: note.revision > expectedRevision ? 'The remote note was deleted, but newer local edits are preserved for recovery.' : null, @@ -1458,8 +2894,9 @@ class NotesRepository { String localId, { required String filename, required Uint8List bytes, - }) async { + }) => _mutate(() async { final note = _require(localId); + _assertMutableAccount(note.accountId); if (note.readonly || note.error) { throw const NotesException( NotesFailureCode.forbidden, @@ -1485,7 +2922,7 @@ class NotesRepository { await store.saveAttachment(attachment, bytes); _notify(); return attachment; - } + }); Future<void> _publishAttachments( String localId, @@ -1498,6 +2935,7 @@ class NotesRepository { if (storedAttachment == null) continue; var attachment = storedAttachment; final note = _require(localId); + if (_isBlocked(note)) return; if (attachment.state == 'deleted') continue; if (attachment.state == 'deletePending') { try { @@ -1559,20 +2997,22 @@ class NotesRepository { reference: attachment.reference, remotePath: path, state: 'uploaded', + validatedAt: clock(), ); + await store.updateAttachment(attachment); } on NotesException catch (error) { - final uncertain = - error.code == NotesFailureCode.network || - error.code == NotesFailureCode.server || - error.code == NotesFailureCode.invalidResponse || - error.code == NotesFailureCode.locked; + final uncertain = error.possiblyExecuted; await store.updateAttachment( NotesAttachment( id: attachment.id, noteId: localId, filename: attachment.filename, reference: attachment.reference, - state: uncertain ? 'uncertain' : 'pending', + state: uncertain + ? 'uncertain' + : error.retryable + ? 'pending' + : 'rejected', ), ); rethrow; @@ -1592,7 +3032,9 @@ class NotesRepository { final updated = current.copyWith( content: content, revision: current.revision + 1, - syncState: NoteSyncState.pending, + syncState: current.metadataConflict != null + ? NoteSyncState.conflict + : NoteSyncState.pending, ); await store.updateAttachment(attachment, note: updated); (_publishedReferences[localId] ??= {})[attachment.reference] = @@ -1621,16 +3063,36 @@ class NotesRepository { }); } + Future<String?> resolveCachedMedia( + String accountId, + String localId, + String reference, + ) => _resolveMedia( + accountId, + localId, + reference, + canonicalAttachmentReference(reference), + allowDownload: false, + ); + Future<String?> _resolveMedia( String accountId, String localId, String reference, - String? canonicalReference, - ) async { + String? canonicalReference, { + bool allowDownload = true, + }) async { _validateId(accountId); _validateId(localId); final note = _notes[localId]; - if (note == null || note.accountId != accountId) return null; + if (note == null || + note.accountId != accountId || + _disposed || + _removing.contains(accountId)) { + return null; + } + final accountGeneration = this.accountGeneration(accountId); + final freshnessEpoch = _mediaFreshnessEpochs[localId] ?? 0; bool matches(NotesAttachment candidate) => candidate.reference == reference || (canonicalReference != null && @@ -1639,27 +3101,74 @@ class NotesRepository { final initialAttachment = _liveMediaAttachment(initial); if (initial.isNotEmpty && initialAttachment == null) return null; File? fetched; - if (initialAttachment == null) { - if (canonicalReference == null || note.serverId == null) return null; - final client = await _clientForAccount(_accounts[accountId]!); - fetched = await _downloadAttachment( - client, - note.serverId!, - canonicalReference, - ); + final downloadable = + canonicalReference != null && + note.serverId != null && + (initialAttachment == null || + ({'cached', 'uploaded'}.contains(initialAttachment.state) && + !(initialAttachment.state == 'uploaded' && + (note.hasPendingChanges || + !notesAttachmentReferences(note.content).any( + (r) => + canonicalAttachmentReference(r.reference) == + canonicalReference, + ))) && + !reference.startsWith('busymark-attachment:') && + !note.content.contains( + initialAttachment.reference.startsWith('busymark-attachment:') + ? initialAttachment.reference + : '\u0000', + ))); + final stale = + initialAttachment == null || + initialAttachment.validatedAt == null || + clock().difference(initialAttachment.validatedAt!) >= mediaFreshness || + (_validatedMediaEpochs[initialAttachment.id] ?? 0) < freshnessEpoch; + if (allowDownload && downloadable && stale) { + final cancellation = NotesDownloadCancellation(); + final key = '$accountId:$localId:$canonicalReference'; + _downloads[key] = cancellation; + try { + if (!_currentAccount(accountId, accountGeneration)) return null; + _checkThrottle(_accounts[accountId]!); + final client = await _clientForAccount(_accounts[accountId]!); + cancellation.check(); + if (!_currentAccount(accountId, accountGeneration)) return null; + fetched = await _downloadAttachment( + client, + note.serverId!, + canonicalReference, + cancellation: cancellation, + ); + } on NotesException catch (error) { + if (!_currentAccount(accountId, accountGeneration)) return null; + await _rememberThrottle(accountId, error); + if (initialAttachment == null) rethrow; + // Retain last usable bytes, without advancing their validation time. + } finally { + if (identical(_downloads[key], cancellation)) _downloads.remove(key); + } + } else if (initialAttachment == null) { + return null; } try { return await _mutate(() async { // Another context, publication or deletion may have committed during HTTP. // Never insert another owner for the same canonical destination or revive // a deletion tombstone. Network waits stay outside the durability queue. - if (_notes[localId]?.accountId != accountId) return null; + if (!_currentAccount(accountId, accountGeneration) || + _notes[localId]?.accountId != accountId || + _notes[localId]?.serverId != note.serverId || + _notes[localId]?.category != note.category || + (_mediaFreshnessEpochs[localId] ?? 0) != freshnessEpoch) { + return null; + } final current = (await store.attachments( localId, )).where(matches).toList(); final liveAttachment = _liveMediaAttachment(current); if (current.isNotEmpty && liveAttachment == null) return null; - final attachment = + var attachment = liveAttachment ?? NotesAttachment( id: _uuid.v4(), @@ -1669,9 +3178,24 @@ class NotesRepository { remotePath: canonicalReference, state: 'cached', ); - if (liveAttachment == null) { - if (fetched == null) return null; + if (fetched != null && + (liveAttachment == null || + {'cached', 'uploaded'}.contains(liveAttachment.state))) { + attachment = NotesAttachment( + id: attachment.id, + noteId: localId, + filename: attachment.filename, + reference: attachment.reference, + remotePath: attachment.remotePath, + state: attachment.state, + validatedAt: clock(), + ); await store.saveAttachmentFile(attachment, fetched); + _validatedMediaEpochs[attachment.id] = freshnessEpoch; + _mediaVersions[localId] = mediaVersion(localId) + 1; + _notify(); + } else if (liveAttachment == null) { + return null; } _validateId(attachment.id); final extension = p.extension(attachment.filename); @@ -1724,6 +3248,15 @@ class NotesRepository { } await partial.rename(file.path); } + // One complete materialization per attachment; content-addressed paths + // change renderer identity without accumulating superseded files. + await for (final entity in directory.list(followLinks: false)) { + if (entity is File && + entity.path != file.path && + p.basename(entity.path).startsWith('${attachment.id}-')) { + await entity.delete(); + } + } return file.path; } finally { await staging.delete(recursive: true); @@ -1737,8 +3270,9 @@ class NotesRepository { Future<File> _downloadAttachment( NotesApiClient client, int id, - String path, - ) async { + String path, { + NotesDownloadCancellation? cancellation, + }) async { final root = Directory(p.join(p.dirname(store.path), 'downloads')); if (await FileSystemEntity.isLink(root.path)) { throw const NotesException( @@ -1759,6 +3293,7 @@ class NotesRepository { id, path, destination: File(p.join(directory.path, 'complete')), + cancellation: cancellation, ); } catch (_) { await directory.delete(recursive: true); @@ -1877,6 +3412,9 @@ class NotesRepository { } Future<void> _invalidateAttachmentMedia(NotesAttachment attachment) async { + _downloads.entries + .where((e) => e.key.contains(':${attachment.noteId}:')) + .forEach((e) => e.value.cancel()); _mediaVersions[attachment.noteId] = mediaVersion(attachment.noteId) + 1; final note = _notes[attachment.noteId]; if (note == null) return; @@ -1996,6 +3534,12 @@ class NotesRepository { }) async { _validateId(accountId); _removing.add(accountId); + _accountGenerations[accountId] = (_accountGenerations[accountId] ?? 0) + 1; + for (final entry in _downloads.entries.where( + (e) => e.key.startsWith('$accountId:'), + )) { + entry.value.cancel(); + } try { await _syncs[accountId]; await _mutate(() async { @@ -2023,7 +3567,16 @@ class NotesRepository { Future<void> _dispose() async { _disposed = true; - await Future.wait(_syncs.values); + for (final cancellation in _downloads.values) { + cancellation.cancel(); + } + await Future.wait([ + ..._syncs.values, + ..._capabilityRequests.values, + ..._mediaResolutions.values.map( + (f) => f.then<void>((_) {}, onError: (Object _, StackTrace _) {}), + ), + ]); await _mutations; await _changes.close(); await store.close(); diff --git a/lib/src/nextcloud_notes/application/notes_search_controller.dart b/lib/src/nextcloud_notes/application/notes_search_controller.dart new file mode 100644 index 00000000..890bdbca --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_search_controller.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:isolate'; + +import '../data/notes_store.dart'; +import '../domain/notes_search.dart'; + +class NotesSearchOverlay { + const NotesSearchOverlay( + this.localId, + this.revision, + this.title, + this.category, + this.source, + ); + final String localId, title, category, source; + final int revision; +} + +class NotesSearchState { + const NotesSearchState({ + this.hits = const [], + this.loading = false, + this.indexing = false, + this.truncated = false, + this.error, + }); + final List<NotesSearchHit> hits; + final bool loading, indexing, truncated; + final Object? error; +} + +/// One bounded query workflow for the Notes sidebar and workspace command. +class NotesSearchController { + NotesSearchController(this.store, this.accountId, {this.recovery = false}); + final NotesStore store; + final String accountId; + final bool recovery; + final _changes = StreamController<NotesSearchState>.broadcast(); + Stream<NotesSearchState> get changes => _changes.stream; + NotesSearchState state = const NotesSearchState(); + int _generation = 0; + bool _disposed = false; + void _publish(NotesSearchState value) { + if (!_disposed) { + state = value; + _changes.add(value); + } + } + + void cancel() { + _generation++; + } + + Future<void> search( + String text, { + bool wholeWord = false, + int limit = 80, + List<NotesSearchOverlay> overlays = const [], + }) async { + final generation = ++_generation; + bool current() => !_disposed && generation == _generation; + _publish(const NotesSearchState(loading: true, indexing: true)); + try { + final query = NotesSearchQuery(text, wholeWord: wholeWord); + while (await store.indexStep() != 0) { + if (!current()) return; + } + if (!current()) return; + _publish(const NotesSearchState(loading: true)); + final hits = await _searchOverlays( + query, + recovery ? const [] : overlays, + limit + 1, + ); + var after = 0; + Map<String, dynamic>? continuation; + var more = true; + while (more && hits.length <= limit) { + if (!current()) return; + final chunk = await store.searchChunk( + accountId, + query, + after: after, + continuation: continuation, + recovery: recovery, + limit: limit + 1 - hits.length, + exclude: recovery ? const {} : overlays.map((o) => o.localId).toSet(), + ); + hits.addAll( + (chunk['hits'] as List).map( + (h) => NotesSearchHit.fromJson(Map<String, dynamic>.from(h as Map)), + ), + ); + after = chunk['after'] as int; + more = chunk['more'] as bool; + continuation = chunk['continuation'] == null + ? null + : Map<String, dynamic>.from(chunk['continuation'] as Map); + } + if (!current()) return; + hits.sort((a, b) { + final title = a.title.toLowerCase().compareTo(b.title.toLowerCase()); + if (title != 0) return title; + final id = a.localId.compareTo(b.localId); + if (id != 0) return id; + final start = (a.start ?? -1).compareTo(b.start ?? -1); + return start != 0 ? start : (a.end ?? -1).compareTo(b.end ?? -1); + }); + _publish( + NotesSearchState( + hits: List.unmodifiable(hits.take(limit)), + truncated: hits.length > limit || more, + ), + ); + } on Object catch (error) { + if (current()) _publish(NotesSearchState(error: error)); + } + } + + Future<void> dispose() async { + _disposed = true; + _generation++; + await _changes.close(); + } +} + +Future<List<NotesSearchHit>> _searchOverlays( + NotesSearchQuery query, + List<NotesSearchOverlay> overlays, + int limit, +) => Isolate.run(() { + final hits = <NotesSearchHit>[]; + for (final overlay in overlays) { + hits.addAll( + matchNotesDocument( + query: query, + localId: overlay.localId, + revision: overlay.revision, + title: overlay.title, + category: overlay.category, + source: overlay.source, + limit: limit - hits.length, + ), + ); + if (hits.length == limit) break; + } + return hits; +}); + +/// Navigation after an edit searches through bounded occurrence pages instead +/// of relocating a late result to one of the first page's unrelated ranges. +Future<NotesSearchHit?> relocateNotesSearchHit( + NotesSearchQuery query, + NotesSearchOverlay document, + int nearOffset, +) => Isolate.run(() { + NotesSearchHit? nearest; + var start = -1, end = -1; + while (true) { + final page = matchNotesDocument( + query: query, + localId: document.localId, + revision: document.revision, + title: document.title, + category: document.category, + source: document.source, + limit: 200, + afterStart: start, + afterEnd: end, + ); + for (final hit in page) { + if (nearest == null || + ((hit.start ?? 0) - nearOffset).abs() < + ((nearest.start ?? 0) - nearOffset).abs()) { + nearest = hit; + } + } + if (page.isEmpty || + page.length < 200 || + (page.last.start ?? 0) >= nearOffset) { + return nearest; + } + start = page.last.start!; + end = page.last.end!; + } +}); diff --git a/lib/src/nextcloud_notes/application/notes_settings_controller.dart b/lib/src/nextcloud_notes/application/notes_settings_controller.dart new file mode 100644 index 00000000..316d6046 --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_settings_controller.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../domain/notes_models.dart'; +import 'nextcloud_connection.dart'; + +enum NotesSettingsPhase { loading, ready, saving, saved, failed } + +class NotesSettingsState { + const NotesSettingsState({ + this.accountId, + this.original, + this.draft, + this.phase = NotesSettingsPhase.loading, + this.error, + this.normalized = false, + }); + final String? accountId; + final NotesSettings? original; + final NotesSettings? draft; + final NotesSettingsPhase phase; + final String? error; + final bool normalized; + Map<String, String> get patch => { + if (draft != null && + original != null && + draft!.notesPath != original!.notesPath) + 'notesPath': draft!.notesPath, + if (draft != null && + original != null && + draft!.fileSuffix != original!.fileSuffix) + 'fileSuffix': draft!.fileSuffix, + }; + bool get dirty => patch.isNotEmpty; + bool get busy => + phase == NotesSettingsPhase.loading || phase == NotesSettingsPhase.saving; +} + +final notesSettingsProvider = + NotifierProvider.autoDispose<NotesSettingsController, NotesSettingsState>( + NotesSettingsController.new, + ); + +class NotesSettingsController extends Notifier<NotesSettingsState> { + int _generation = 0; + bool _disposed = false; + @override + NotesSettingsState build() { + ref.onDispose(() { + _disposed = true; + _generation++; + }); + return const NotesSettingsState(); + } + + Future<void> load(String id, {bool discard = false}) async { + if (state.accountId == id && + (state.phase == NotesSettingsPhase.saving || + (state.dirty && !discard))) { + return; + } + final generation = ++_generation; + state = NotesSettingsState(accountId: id); + try { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + await repository.maintainCapabilities(id); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + final settings = await repository.reconcileSettings(id); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + state = NotesSettingsState( + accountId: id, + original: settings, + draft: settings, + phase: NotesSettingsPhase.ready, + ); + } on NotesException catch (error) { + if (_disposed || generation != _generation) return; + state = NotesSettingsState( + accountId: id, + phase: NotesSettingsPhase.failed, + error: error.message, + ); + } + } + + void edit({String? notesPath, String? fileSuffix}) { + final draft = state.draft; + if (draft == null || state.busy) return; + state = NotesSettingsState( + accountId: state.accountId, + original: state.original, + draft: NotesSettings( + notesPath: notesPath ?? draft.notesPath, + fileSuffix: fileSuffix ?? draft.fileSuffix, + ), + phase: NotesSettingsPhase.ready, + ); + } + + void cancel() { + if (state.busy) return; + state = NotesSettingsState( + accountId: state.accountId, + original: state.original, + draft: state.original, + phase: NotesSettingsPhase.ready, + ); + } + + Future<bool> save({required Future<bool> Function() preserveBuffers}) async { + final id = state.accountId; + final original = state.original; + final patch = state.patch; + if (id == null || original == null || patch.isEmpty || state.busy) { + return false; + } + final generation = ++_generation; + final draft = state.draft; + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.saving, + ); + try { + if (!await preserveBuffers()) { + if (!_disposed && generation == _generation) { + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.failed, + ); + } + return false; + } + if (_disposed || generation != _generation) return false; + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + final result = await repository.changeSettings(id, original, patch); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return false; + } + state = NotesSettingsState( + accountId: id, + original: result, + draft: result, + phase: NotesSettingsPhase.saved, + normalized: patch.entries.any((e) => result.toJson()[e.key] != e.value), + ); + // Checkpoint invalidation is durable before this authoritative read. + await repository.synchronize(id, allowWrites: false); + return true; + } on NotesException catch (error) { + if (!_disposed && generation == _generation) { + state = NotesSettingsState( + accountId: id, + original: original, + draft: draft, + phase: NotesSettingsPhase.failed, + error: error.message, + ); + } + return false; + } + } +} diff --git a/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart b/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart new file mode 100644 index 00000000..a1156f6e --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_sync_coordinator.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import '../domain/notes_models.dart'; +import 'notes_repository.dart'; + +enum NotesSyncTrigger { + opening, + localChange, + manual, + focus, + poll, + retry, + recovery, +} + +abstract interface class NotesScheduledTask { + void cancel(); +} + +class _TimerTask implements NotesScheduledTask { + _TimerTask(Duration delay, void Function() callback) + : timer = Timer(delay, callback); + final Timer timer; + @override + void cancel() => timer.cancel(); +} + +typedef NotesSchedule = NotesScheduledTask Function(Duration, void Function()); + +/// Scheduling only. The repository remains the sole synchronization engine. +class NotesSyncCoordinator { + NotesSyncCoordinator( + this.repository, { + DateTime Function()? clock, + NotesSchedule? schedule, + this.onError, + }) : clock = clock ?? DateTime.now, + schedule = schedule ?? _TimerTask.new; + final NotesRepository repository; + final DateTime Function() clock; + final NotesSchedule schedule; + final void Function(Object, StackTrace)? onError; + static const pollInterval = Duration(seconds: 60); + static const focusCoalescing = Duration(seconds: 10); + static const retryIntervals = [5, 10, 20, 40, 80, 160]; + String? _account; + bool _visible = true; + bool _disposed = false; + int _generation = 0; + Future<void>? _running; + String? _runningAccount; + bool _followup = false; + bool _manual = false; + bool _writes = false; + bool _capabilities = false; + bool _readWasOffline = false; + int _accountRetryCount = 0; + DateTime? _completed; + NotesScheduledTask? _poll; + NotesScheduledTask? _retry; + + void setActiveAccount(String? id) { + if (_disposed || id == _account) return; + _generation++; + _account = id; + _poll?.cancel(); + _poll = null; + _retry?.cancel(); + _retry = null; + _followup = false; + _manual = false; + _writes = false; + _capabilities = false; + _completed = null; + _accountRetryCount = 0; + _readWasOffline = false; + _armPoll(); + } + + void setVisible(bool visible) { + if (_disposed || visible == _visible) return; + _visible = visible; + _poll?.cancel(); + _poll = null; + if (visible) { + _background(NotesSyncTrigger.focus); + _armPoll(); + } + } + + void focused() => _background(NotesSyncTrigger.focus); + + Future<void> request(NotesSyncTrigger trigger) { + if (_disposed || _account == null) return Future.value(); + if (_running != null && _runningAccount != _account) { + final generation = _generation; + return _running!.then( + (_) => generation == _generation && !_disposed + ? request(trigger) + : Future<void>.value(), + ); + } + if ((trigger == NotesSyncTrigger.focus || + trigger == NotesSyncTrigger.poll) && + !_visible) { + return Future.value(); + } + if (trigger == NotesSyncTrigger.focus && + _completed != null && + clock().difference(_completed!) < focusCoalescing) { + return _running ?? Future.value(); + } + final writes = { + NotesSyncTrigger.opening, + NotesSyncTrigger.localChange, + NotesSyncTrigger.manual, + NotesSyncTrigger.retry, + }.contains(trigger); + _writes |= writes; + _manual |= trigger == NotesSyncTrigger.manual; + _capabilities |= { + NotesSyncTrigger.opening, + NotesSyncTrigger.manual, + NotesSyncTrigger.recovery, + }.contains(trigger); + if (_running != null) { + if (writes) { + _followup = true; + } + return _running!; + } + final id = _account!; + final generation = _generation; + _runningAccount = id; + late final Future<void> task; + task = _run(id, generation).whenComplete(() { + if (identical(_running, task)) { + _running = null; + _runningAccount = null; + } + if (_disposed) return; + if (generation == _generation) { + _armRetry(); + _armPoll(); + } + }); + _running = task; + return task; + } + + Future<void> _run(String id, int generation) async { + do { + _followup = false; + final manual = _manual; + final writes = _writes; + final capabilities = _capabilities; + _manual = false; + _writes = false; + _capabilities = false; + if (manual) { + _accountRetryCount = 0; + await repository.retryWrites(id); + } + await repository.synchronize( + id, + allowWrites: true, + onlyFreshWrites: !writes, + refreshCapabilities: capabilities, + ); + if (_disposed || + generation != _generation || + repository.accountById(id) == null) { + return; + } + final error = repository.accountError(id); + if (error?.code == NotesFailureCode.network) _readWasOffline = true; + if (error == null) { + _completed = clock(); + if (_readWasOffline) { + _readWasOffline = false; + _accountRetryCount = 0; + await repository.recoverNetworkWrites(id); + _writes = true; + _capabilities = true; + _followup = true; + } + } + await repository.refreshDisplayedMedia(force: manual); + } while (_followup && !_disposed && generation == _generation); + } + + void _armPoll() { + if (_disposed || !_visible || _account == null || _poll != null) return; + final generation = _generation; + _poll = schedule(pollInterval, () { + _poll = null; + if (_disposed || generation != _generation) return; + // Authenticated collection reads are also bounded connectivity probes. + _background( + _readWasOffline ? NotesSyncTrigger.recovery : NotesSyncTrigger.poll, + ); + _armPoll(); + }); + } + + void _armRetry() { + final id = _account; + if (_disposed || id == null || _retry != null) return; + final accountFailure = repository.accountError(id); + final accountRetry = + accountFailure?.retryable == true && + _accountRetryCount < retryIntervals.length; + final retryable = repository.hasRetryableWork(id); + if (!accountRetry && !retryable) return; + final counts = + repository.notes + .where( + (n) => + n.accountId == id && + n.failureCode != null && + n.retryCount > 0 && + n.retryCount <= 6 && + { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(n.failureCode), + ) + .map((n) => n.retryCount - 1) + .toList() + ..sort(); + final attempt = accountRetry + ? _accountRetryCount++ + : counts.firstOrNull ?? 0; + var delay = Duration(seconds: retryIntervals[attempt.clamp(0, 5)]); + final deadline = repository.writeDeadline(id); + if (deadline != null && deadline.difference(clock()) > delay) { + delay = deadline.difference(clock()); + } + final generation = _generation; + _retry = schedule(delay, () { + _retry = null; + if (!_disposed && generation == _generation) { + _background(NotesSyncTrigger.retry); + } + }); + } + + void _background(NotesSyncTrigger trigger) { + unawaited( + request(trigger).catchError((Object error, StackTrace stack) { + onError?.call(error, stack); + }), + ); + } + + void removeAccount(String id) { + if (_account == id) setActiveAccount(null); + } + + void dispose() { + _disposed = true; + _generation++; + _poll?.cancel(); + _retry?.cancel(); + _account = null; + _followup = false; + } +} diff --git a/lib/src/nextcloud_notes/application/notes_transfer_service.dart b/lib/src/nextcloud_notes/application/notes_transfer_service.dart new file mode 100644 index 00000000..200942bd --- /dev/null +++ b/lib/src/nextcloud_notes/application/notes_transfer_service.dart @@ -0,0 +1,667 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import '../../assets/asset_limits.dart'; +import '../../assets/document_media_resolver.dart'; +import '../../export/markdown_copy_export_service.dart'; +import '../data/notes_attachment_references.dart'; +import '../domain/notes_models.dart'; +import '../domain/notes_search.dart' show normalizeNotesSearch; +import 'notes_repository.dart'; + +class NotesTransferCancellation { + bool cancelled = false; + void cancel() => cancelled = true; + void check() { + if (cancelled) throw const FileSystemException('Transfer cancelled.'); + } +} + +class NotesExportResult { + const NotesExportResult(this.path, this.count, this.omissions); + final String path; + final int count; + final List<String> omissions; + bool get complete => omissions.isEmpty; +} + +class NotesImportItem { + NotesImportItem({ + required this.key, + required this.path, + required this.title, + required this.category, + required this.content, + this.favorite = false, + this.activityMicros, + this.serverModified, + this.collision = false, + this.media = const {}, + this.issues = const [], + }); + final String key, path, title, content; + String category; + final bool favorite, collision; + final int? activityMicros, serverModified; + final Map<String, ({String path, String filename, String digest})> media; + final List<String> issues; + bool selected = true; + String? importedNoteId; + bool get alreadyImported => importedNoteId != null; +} + +class NotesImportReview { + NotesImportReview(this.root, this.items, this.issues, {String? operationId}) + : operationId = operationId ?? const Uuid().v4(); + final String root, operationId; + final List<NotesImportItem> items; + final List<String> issues; +} + +class NotesImportOutcome { + const NotesImportOutcome( + this.item, { + this.noteId, + this.error, + this.alreadyImported = false, + }); + final String item; + final String? noteId, error; + final bool alreadyImported; +} + +/// Portable snapshots contain document metadata only. Every note is an +/// ordinary Markdown document; local identities, credentials and outbox are +/// deliberately absent from the versioned interchange manifest. +class NotesTransferService { + const NotesTransferService(this.repository); + final NotesRepository repository; + static const manifestName = 'busymark-notes.json'; + static const format = 'busymark-notes'; + static const version = 1; + static const maximumDocumentBytes = 4 * 1024 * 1024; + + Future<NotesExportResult> exportSnapshot({ + required List<NextcloudNote> notes, + required String destination, + required NotesTransferCancellation cancellation, + void Function(int completed, int total)? onProgress, + }) async { + final snapshot = notes is NotesLocalSnapshot + ? notes + : NotesLocalSnapshot( + List<NextcloudNote>.unmodifiable(notes), + mediaVersions: { + for (final note in notes) + note.localId: repository.mediaVersion(note.localId), + }, + accountGenerations: { + for (final note in notes) + note.accountId: repository.accountGeneration(note.accountId), + }, + ); + void checkSnapshot() { + cancellation.check(); + if (snapshot.mediaVersions.entries.any( + (e) => repository.mediaVersion(e.key) != e.value, + ) || + snapshot.accountGenerations.entries.any( + (e) => repository.accountGeneration(e.key) != e.value, + )) { + throw const FileSystemException( + 'Export media changed; capture the snapshot again.', + ); + } + } + + checkSnapshot(); + final parent = Directory(destination); + await _safeAbsolute(parent.path); + final staging = await parent.createTemp('.busymark-export-'); + final published = p.join( + parent.path, + 'BusyMark-notes-${const Uuid().v4()}', + ); + final documents = <Map<String, Object?>>[]; + final omissions = <String>[]; + final directories = <String, String>{'': ''}; + final used = <String>{}; + try { + for (final note in snapshot) { + checkSnapshot(); + var directory = ''; + var original = ''; + for (final component + in note.category.split('/').where((s) => s.isNotEmpty)) { + original = original.isEmpty ? component : '$original/$component'; + directory = directories.putIfAbsent( + original, + () => p.join( + directory, + _uniqueName(component, used, 'entry:$directory'), + ), + ); + } + final name = _uniqueName( + note.title, + used, + 'entry:$directory', + extension: '.md', + ); + final relative = p.join(directory, name); + final file = File(p.join(staging.path, relative)); + await file.parent.create(recursive: true); + final missing = <String>[]; + await const MarkdownCopyExportService().export( + source: note.content, + destinationPath: file.path, + media: DocumentMediaContext( + identity: note.localId, + resolve: (r) => + repository.resolveCachedMedia(note.accountId, note.localId, r), + resolveCached: (_) => null, + ), + onMissing: missing.add, + isCancelled: () => cancellation.cancelled, + ); + final availability = await repository.attachmentAvailability( + note.localId, + source: note.content, + ); + missing.addAll(availability.external); + for (final reference in missing) { + omissions.add('${note.title}: $reference'); + } + documents.add({ + 'file': p.split(relative).join('/'), + 'title': note.title, + 'category': note.category, + 'favorite': note.favorite, + 'activityMicros': note.activityMicros, + 'serverModified': note.modified, + 'workingVersion': true, + 'unresolvedConflict': + note.metadataConflict != null || + note.syncState == NoteSyncState.conflict || + note.syncState == NoteSyncState.deletedRemotely && + note.hasPendingChanges, + 'omissions': missing, + }); + checkSnapshot(); + onProgress?.call(documents.length, snapshot.length); + } + checkSnapshot(); + await File(p.join(staging.path, manifestName)).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'format': format, + 'version': version, + 'capturedAt': DateTime.now().toUtc().toIso8601String(), + 'documents': documents, + 'complete': omissions.isEmpty, + }), + flush: true, + ); + checkSnapshot(); + if (await FileSystemEntity.type(published, followLinks: false) != + FileSystemEntityType.notFound) { + throw const FileSystemException('Export destination already exists.'); + } + await staging.rename(published); + return NotesExportResult( + published, + documents.length, + List.unmodifiable(omissions), + ); + } finally { + if (await staging.exists()) await staging.delete(recursive: true); + } + } + + Future<NotesImportReview> review( + String source, + String accountId, { + String category = '', + NotesTransferCancellation? cancellation, + }) async { + await _safeAbsolute(source); + final type = await FileSystemEntity.type(source, followLinks: false); + final root = type == FileSystemEntityType.directory + ? p.normalize(p.absolute(source)) + : p.dirname(p.absolute(source)); + final manifest = File(p.join(root, manifestName)); + final issues = <String>[]; + final entries = <Map<String, dynamic>>[]; + if (type == FileSystemEntityType.directory && await manifest.exists()) { + await _within(root, manifest.path); + if (await manifest.length() > maximumDocumentBytes) { + throw const FormatException('Manifest is too large.'); + } + final data = + jsonDecode(await manifest.readAsString()) as Map<String, dynamic>; + if (data['format'] != format || + data['version'] != version || + data['documents'] is! List) { + throw const FormatException('Unsupported Notes snapshot manifest.'); + } + for (final item in data['documents'] as List) { + final entry = Map<String, dynamic>.from(item as Map); + if (entry['file'] is! String || + entry['title'] is! String || + entry['category'] is! String || + entry['favorite'] is! bool) { + throw const FormatException('Invalid Notes snapshot entry.'); + } + entries.add(entry); + } + } else if (type == FileSystemEntityType.file) { + if (!_markdown(source)) { + throw const FormatException('Choose Markdown files.'); + } + entries.add({ + 'file': p.basename(source), + 'title': p.basenameWithoutExtension(source), + 'category': category, + }); + } else if (type == FileSystemEntityType.directory) { + await for (final entity in Directory( + root, + ).list(recursive: true, followLinks: false)) { + cancellation?.check(); + if (entity is Link) { + issues.add( + 'Symbolic link skipped: ${p.relative(entity.path, from: root)}', + ); + continue; + } + if (entity is File && _markdown(entity.path)) { + final relative = p.relative(entity.path, from: root); + final parent = p.dirname(relative); + entries.add({ + 'file': p.split(relative).join('/'), + 'title': p.basenameWithoutExtension(relative), + 'category': [ + category, + if (parent != '.') p.split(parent).join('/'), + ].where((s) => s.isNotEmpty).join('/'), + }); + } + } + } else { + throw const FileSystemException('Import source is unavailable.'); + } + entries.sort( + (a, b) => (a['file'] as String).compareTo(b['file'] as String), + ); + final items = <NotesImportItem>[]; + final paths = <String>{}; + for (final entry in entries) { + cancellation?.check(); + if (!_markdown(entry['file'] as String)) { + throw const FormatException( + 'Snapshot entries must be Markdown documents.', + ); + } + final path = _relative(root, entry['file'] as String); + if (!paths.add(path)) { + throw const FormatException('Duplicate manifest file mapping.'); + } + await _within(root, path); + final file = File(path); + if (await file.length() > maximumDocumentBytes) { + issues.add('Document too large: ${entry['file']}'); + continue; + } + final content = await file.readAsString(); + final media = <String, ({String path, String filename, String digest})>{}; + final itemIssues = <String>[]; + for (final occurrence in await scanNotesAttachmentReferences(content)) { + final reference = occurrence.reference; + final uri = Uri.tryParse(reference); + if (reference.startsWith('#') || + uri?.scheme == 'https' || + uri?.scheme == 'http') { + if (occurrence.image) itemIssues.add('External image: $reference'); + continue; + } + if (uri?.hasScheme == true || + reference.startsWith('//') || + reference.contains('\\')) { + itemIssues.add('Unsupported reference: $reference'); + continue; + } + // Preserve unrelated document links. Import only recognized image and + // snapshot companion-media destinations, through the shared scanner. + final decoded = _localMediaDestination(reference); + if (decoded == null) { + if (occurrence.image) itemIssues.add('Unsafe reference: $reference'); + continue; + } + if (!occurrence.image && !decoded.contains('.attachments-')) continue; + try { + final asset = p.normalize( + p.join(file.parent.path, p.joinAll(decoded.split('/'))), + ); + await _within(root, asset); + final assetFile = File(asset); + if (await assetFile.length() > maximumManagedAssetBytes) { + throw const FileSystemException('Media exceeds size limit.'); + } + final digest = await sha256.bind(assetFile.openRead()).first; + media[reference] = ( + path: asset, + filename: p.basename(asset), + digest: digest.toString(), + ); + } on Object { + itemIssues.add('Missing or unsafe media: $reference'); + } + } + final targetCategory = entry['category'] as String; + final title = entry['title'] as String; + final collision = repository.notes.any( + (n) => + n.accountId == accountId && + n.title == title && + n.category == targetCategory && + n.syncState != NoteSyncState.deletedRemotely, + ); + final digest = sha256.convert(utf8.encode(content)).toString(); + // This is an item fingerprint, not an account-wide import identity. The + // durable reviewed operation namespaces it when an import is applied. + final key = sha256 + .convert(utf8.encode('$root\u0000${entry['file']}\u0000$digest')) + .toString(); + items.add( + NotesImportItem( + key: key, + path: path, + title: title, + category: targetCategory, + content: content, + favorite: entry['favorite'] as bool? ?? false, + activityMicros: entry['activityMicros'] as int?, + serverModified: entry['serverModified'] as int?, + collision: collision, + media: media, + issues: itemIssues, + ), + ); + } + return NotesImportReview(root, items, issues); + } + + String _itemKey(NotesImportReview review, NotesImportItem item) => + '${review.operationId}:${item.key}'; + + /// Restart resumes a specific reviewed operation, with the original paths, + /// fingerprints, selection and categories. A new review always gets a new ID. + Future<NotesImportReview> resumeImport( + String operationId, + String accountId, + ) async { + final operation = await repository.store.importOperation( + accountId, + operationId, + ); + if (operation == null) throw StateError('Import operation is unavailable.'); + final data = + jsonDecode(operation['review'] as String) as Map<String, dynamic>; + final review = NotesImportReview( + operation['root'] as String, + [ + for (final row in data['items'] as List) + NotesImportItem( + key: row['key'] as String, + path: row['path'] as String, + title: row['title'] as String, + category: row['category'] as String, + content: row['content'] as String, + favorite: row['favorite'] as bool, + activityMicros: row['activityMicros'] as int?, + serverModified: row['serverModified'] as int?, + collision: row['collision'] as bool, + media: { + for (final entry in (row['media'] as Map).entries) + entry.key as String: ( + path: entry.value['path'] as String, + filename: entry.value['filename'] as String, + digest: entry.value['digest'] as String, + ), + }, + issues: (row['issues'] as List).cast<String>(), + )..selected = row['selected'] as bool, + ], + (data['issues'] as List).cast<String>(), + operationId: operationId, + ); + for (final item in review.items) { + item.importedNoteId = await repository.store.importedNote( + accountId, + _itemKey(review, item), + ); + } + return review; + } + + String _reviewJson(NotesImportReview review) => jsonEncode({ + 'issues': review.issues, + 'items': [ + for (final item in review.items) + { + 'key': item.key, + 'path': item.path, + 'title': item.title, + 'category': item.category, + 'content': item.content, + 'favorite': item.favorite, + 'activityMicros': item.activityMicros, + 'serverModified': item.serverModified, + 'collision': item.collision, + 'selected': item.selected, + 'issues': item.issues, + 'media': { + for (final entry in item.media.entries) + entry.key: { + 'path': entry.value.path, + 'filename': entry.value.filename, + 'digest': entry.value.digest, + }, + }, + }, + ], + }); + + Future<List<NotesImportOutcome>> importReviewed( + NotesImportReview review, + String accountId, { + required NotesTransferCancellation cancellation, + void Function(int completed, int total)? onProgress, + }) async { + if (cancellation.cancelled) return []; + await repository.store.beginImportOperation( + accountId, + review.operationId, + review.root, + _reviewJson(review), + ); + final outcomes = <NotesImportOutcome>[]; + final selected = review.items.where((i) => i.selected).toList(); + for (final item in selected) { + if (cancellation.cancelled) break; + try { + final existing = await repository.store.importedNote( + accountId, + _itemKey(review, item), + ); + if (existing != null) { + outcomes.add( + NotesImportOutcome( + item.title, + noteId: existing, + alreadyImported: true, + ), + ); + onProgress?.call(outcomes.length, selected.length); + continue; + } + await _within(review.root, item.path); + final file = File(item.path); + if (await file.length() > maximumDocumentBytes || + await file.readAsString() != item.content) { + throw const FileSystemException('Document changed after review.'); + } + final media = <String, ({String filename, Uint8List bytes})>{}; + var total = 0; + for (final entry in item.media.entries) { + cancellation.check(); + await _within(review.root, entry.value.path); + final asset = File(entry.value.path); + final length = await asset.length(); + total += length; + if (length > maximumManagedAssetBytes || total > 256 * 1024 * 1024) { + throw const FileSystemException('Media exceeds import size limit.'); + } + final bytes = await asset.readAsBytes(); + if (sha256.convert(bytes).toString() != entry.value.digest) { + throw const FileSystemException('Media changed after review.'); + } + media[entry.key] = (filename: entry.value.filename, bytes: bytes); + } + cancellation.check(); + final note = await repository.importLocal( + accountId: accountId, + sourceKey: _itemKey(review, item), + title: item.title, + category: item.category, + content: item.content, + favorite: item.favorite, + activityMicros: item.activityMicros, + modified: item.serverModified, + media: media, + ); + item.importedNoteId = note.localId; + outcomes.add(NotesImportOutcome(item.title, noteId: note.localId)); + } on Object catch (error) { + outcomes.add(NotesImportOutcome(item.title, error: error.toString())); + } + onProgress?.call(outcomes.length, selected.length); + } + if (outcomes.length == selected.length && + outcomes.every((o) => o.error == null)) { + await repository.store.completeImportOperation( + accountId, + review.operationId, + [for (final item in selected) _itemKey(review, item)], + ); + } + return outcomes; + } + + // Local document destinations may contain dot segments. Decode once before + // resolving relative to the document, then enforce the selected-root/symlink + // boundary in _within. Remote attachment validation remains deliberately strict. + static String? _localMediaDestination(String reference) { + final uri = Uri.tryParse(reference); + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.hasQuery || + uri.hasFragment || + reference.contains('\\') || + RegExp(r'%(?![0-9a-fA-F]{2})').hasMatch(reference)) { + return null; + } + try { + final encoded = reference.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ); + final decoded = Uri.decodeComponent(encoded); + if (decoded.isEmpty || + decoded.startsWith('/') || + decoded.contains('\\') || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(decoded) || + decoded.split('/').first.contains(':')) { + return null; + } + return decoded; + } on FormatException { + return null; + } + } + + static bool _markdown(String path) => + {'.md', '.markdown'}.contains(p.extension(path).toLowerCase()); + static String _relative(String root, String relative) { + if (p.isAbsolute(relative) || + relative.contains('\\') || + relative.contains('\u0000') || + relative.split('/').any((s) => s == '..' || s.isEmpty)) { + throw const FormatException('Unsafe import path.'); + } + final path = p.normalize(p.join(root, relative)); + if (!p.isWithin(root, path)) { + throw const FormatException('Import path escapes source.'); + } + return path; + } + + static Future<void> _within(String root, String path) async { + if (!p.isWithin(root, path)) { + throw const FileSystemException('Import path escapes source.'); + } + await _safeAbsolute(path); + final resolved = await File(path).resolveSymbolicLinks(); + if (!p.isWithin(root, resolved)) { + throw const FileSystemException('Import path escapes source.'); + } + } + + static Future<void> _safeAbsolute(String path) async { + var current = p.normalize(p.absolute(path)); + while (true) { + if (await FileSystemEntity.isLink(current)) { + throw const FileSystemException( + 'Symbolic links are not allowed in transfers.', + ); + } + final parent = p.dirname(current); + if (parent == current) break; + current = parent; + } + } + + static String _uniqueName( + String input, + Set<String> used, + String scope, { + String extension = '', + }) { + var stem = input + .replaceAll(RegExp(r'[<>:"/\\|?*\x00-\x1f\x7f]'), '_') + .trim() + .replaceAll(RegExp(r'[. ]+$'), ''); + if (stem.isEmpty || + RegExp( + r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$', + caseSensitive: false, + ).hasMatch(stem)) { + stem = 'note'; + } + // Bound UTF-8 component bytes, leaving room for collision suffixes. + while (utf8.encode(stem).length > 120) { + stem = String.fromCharCodes(stem.runes.take(stem.runes.length - 1)); + } + var name = '$stem$extension'; + var suffix = 1; + while (!used.add('$scope:${normalizeNotesSearch(name)}')) { + name = '$stem-${++suffix}$extension'; + } + return name; + } +} diff --git a/lib/src/nextcloud_notes/data/notes_api_client.dart b/lib/src/nextcloud_notes/data/notes_api_client.dart index ea5ec85a..7f83d4c2 100644 --- a/lib/src/nextcloud_notes/data/notes_api_client.dart +++ b/lib/src/nextcloud_notes/data/notes_api_client.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import '../../assets/asset_limits.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_retrieval.dart'; import 'notes_attachment_references.dart'; import 'server_uri.dart'; @@ -51,7 +52,10 @@ class NotesApiClient { required this.account, required String appPassword, this.timeout = const Duration(seconds: 30), - }) : _client = client, + this.onApiVersions, + DateTime Function()? clock, + }) : clock = clock ?? DateTime.now, + _client = client, _authorization = 'Basic ${base64Encode(utf8.encode('${account.loginName}:$appPassword'))}'; @@ -59,6 +63,67 @@ class NotesApiClient { final NextcloudAccount account; final String _authorization; final Duration timeout; + final DateTime Function() clock; + final Future<void> Function(String)? onApiVersions; + + /// Web navigation configuration, observed without sending any credential. + /// This is not a Notes synchronization or collection retrieval request. + Future<bool> noteWebPrettyUrls() async { + try { + final request = http.Request( + 'GET', + nextcloudEndpoint(account.server, 'index.php/login'), + )..followRedirects = false; + final response = await _client.send(request).timeout(timeout); + if (response.statusCode != 200) { + throw const FormatException('Web route unavailable.'); + } + final bytes = <int>[]; + final input = StreamIterator(response.stream); + final bodyDeadline = Stopwatch()..start(); + try { + while (await input.moveNext().timeout(timeout - bodyDeadline.elapsed)) { + bytes.addAll(input.current); + if (bytes.length > 2 * 1024 * 1024) { + throw const FormatException('Web configuration too large.'); + } + } + } finally { + await input.cancel(); + } + final source = utf8.decode(bytes); + final config = RegExp( + r'var\s+_oc_config\s*=\s*(\{[^\n]*\})\s*;', + ).firstMatch(source); + final value = config == null + ? null + : jsonDecode(config[1]!)['modRewriteWorking']; + if (value is! bool) throw const FormatException('Web route unconfirmed.'); + return value; + } on Object { + throw const NotesException( + NotesFailureCode.network, + 'The installation’s note web route could not be verified. Manual URLs remain available.', + scope: NotesRequestScope.account, + ); + } + } + + NotesRequestScope _scope(http.BaseRequest request) => + request.url.path.contains('/attachment/') + ? NotesRequestScope.attachment + : request.url.path.endsWith('/settings') + ? NotesRequestScope.settings + : request.url.path.endsWith('/notes') + ? NotesRequestScope.collection + : NotesRequestScope.note; + Future<void> _observe(Map<String, String> headers) async { + final value = headers.entries + .where((e) => e.key.toLowerCase() == 'x-notes-api-versions') + .firstOrNull + ?.value; + if (value != null) await onApiVersions?.call(value); + } Uri _endpoint(String path, [Map<String, String>? query]) => nextcloudEndpoint( account.server, @@ -74,6 +139,7 @@ class NotesApiClient { final response = await http.Response.fromStream( await _client.send(request).timeout(timeout), ).timeout(timeout); + await _observe(response.headers); if (response.statusCode >= 300 && response.statusCode < 400 && response.statusCode != 304) { @@ -83,22 +149,25 @@ class NotesApiClient { ); } return response; - } on NotesException { - rethrow; + } on NotesException catch (error) { + throw error.inScope(_scope(request)); } on TimeoutException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'The Nextcloud request timed out. Local changes are preserved.', + scope: _scope(request), ); } on http.ClientException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'Nextcloud is unreachable. Local changes are preserved.', + scope: _scope(request), ); } on IOException { - throw const NotesException( + throw NotesException( NotesFailureCode.network, 'Nextcloud is unreachable. Local changes are preserved.', + scope: _scope(request), ); } } @@ -111,13 +180,17 @@ class NotesApiClient { return null; } - static dynamic _json(http.Response response) { + static dynamic _json( + http.Response response, { + NotesRequestScope scope = NotesRequestScope.note, + }) { try { return jsonDecode(utf8.decode(response.bodyBytes)); } on FormatException { - throw const NotesException( + throw NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned invalid JSON.', + scope: scope, ); } } @@ -133,7 +206,10 @@ class NotesApiClient { return NoteState.fromJson(json); } - static void _check(http.Response response) { + void _check( + http.Response response, { + NotesRequestScope scope = NotesRequestScope.note, + }) { final status = response.statusCode; if (status >= 200 && status < 300) return; final (code, message) = switch (status) { @@ -149,6 +225,10 @@ class NotesApiClient { NotesFailureCode.missing, 'The note or attachment no longer exists on Nextcloud.', ), + 429 => ( + NotesFailureCode.throttled, + 'Nextcloud requested a pause before retrying. Local changes are preserved.', + ), 412 => ( NotesFailureCode.conflict, 'This note changed on Nextcloud. Resolve the conflict before synchronizing.', @@ -166,7 +246,7 @@ class NotesApiClient { 'Nextcloud returned server error $status. Local changes are preserved.', ), _ => ( - NotesFailureCode.server, + NotesFailureCode.rejected, 'Nextcloud rejected this request (HTTP $status). Local changes are preserved.', ), }; @@ -178,7 +258,14 @@ class NotesApiClient { /* Fetch fresh state separately. */ } } - throw NotesException(code, message, statusCode: status, remote: remote); + throw NotesException( + code, + message, + statusCode: status, + remote: remote, + scope: scope, + retryNotBefore: retryAfter(header(response, 'Retry-After'), clock()), + ); } /// Collects every chunk before returning; interrupted lists never imply deletion. @@ -198,7 +285,7 @@ class NotesApiClient { try { query['pruneBefore'] = '${HttpDate.parse(account.lastModified!).millisecondsSinceEpoch ~/ 1000}'; - } on FormatException { + } on HttpException { // Invalid persisted checkpoint safely triggers an unpruned list. } } @@ -211,12 +298,13 @@ class NotesApiClient { if (response.statusCode == 304 && cursor == null) { return const NotesListResult(notes: [], ids: {}, notModified: true); } - _check(response); - final json = _json(response); + _check(response, scope: NotesRequestScope.collection); + final json = _json(response, scope: NotesRequestScope.collection); if (json is! List) { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an invalid notes list.', + scope: NotesRequestScope.collection, ); } for (final item in json) { @@ -226,11 +314,17 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an invalid note identifier.', + scope: NotesRequestScope.collection, ); } ids.add(item['id'] as int); if (item.containsKey('etag') || item.containsKey('content')) { - final note = NoteState.fromJson(item); + final NoteState note; + try { + note = NoteState.fromJson(item); + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.collection); + } notes[note.id] = note; } } @@ -249,21 +343,156 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud repeated a notes chunk cursor.', + scope: NotesRequestScope.collection, ); } } throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned too many notes chunks.', + scope: NotesRequestScope.collection, ); } Future<NoteState> get(int id) async { + final result = await retrieve(id); + return (result as NotesReturned).note; + } + + Future<NotesGetResult> retrieve(int id, {NoteState? cached}) async { + if (id <= 0 || (cached != null && cached.id != id)) { + throw ArgumentError( + 'The cached representation must belong to this note.', + ); + } + final request = http.Request('GET', _endpoint('v1/notes/$id')); + if (cached != null && !cached.error) { + request.headers['If-None-Match'] = '"${cached.etag}"'; + } + final response = await _send(request); + if (response.statusCode == 304) { + if (cached == null || cached.error) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned 304 without a cached server representation.', + ); + } + return NotesNotModified(cached); + } + _check(response); + final note = _note(response); + if (note.id != id) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned a different note.', + ); + } + return NotesReturned(note); + } + + Future<NotesProjection> getProjection( + int id, + NotesRetrievalScope scope, + ) async { + if (id <= 0 || scope.category != null) { + throw ArgumentError('Invalid note query.'); + } final response = await _send( - http.Request('GET', _endpoint('v1/notes/$id')), + http.Request('GET', _endpoint('v1/notes/$id', scope.query)), ); _check(response); - return _note(response); + final value = _json(response); + if (value is! Map<String, dynamic>) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected note.', + ); + } + final note = NotesProjection.fromJson(value); + if (note.id != null && note.id != id || + note.id == null && !scope.exclude.contains('id')) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned a different note.', + ); + } + return note; + } + + Future<NotesQueryResult> query( + NotesRetrievalScope scope, { + int chunkSize = 100, + }) async { + if (chunkSize <= 0) throw ArgumentError.value(chunkSize); + final notes = <int, NotesProjection>{}; + final unidentified = <NotesProjection>[]; + final cursors = <String>{}; + String? cursor; + for (var chunk = 0; chunk < 10000; chunk++) { + final response = await _send( + http.Request( + 'GET', + _endpoint('v1/notes', { + ...scope.query, + 'chunkSize': '$chunkSize', + if (cursor != null) 'chunkCursor': cursor, + }), + ), + ); + _check(response, scope: NotesRequestScope.collection); + final value = _json(response, scope: NotesRequestScope.collection); + if (value is! List) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected notes list.', + scope: NotesRequestScope.collection, + ); + } + for (final item in value) { + if (item is! Map<String, dynamic>) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected note.', + scope: NotesRequestScope.collection, + ); + } + final NotesProjection note; + try { + note = NotesProjection.fromJson(item); + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.collection); + } + if (note.id == null) { + if (!scope.exclude.contains('id')) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Missing projected note identifier.', + scope: NotesRequestScope.collection, + ); + } + unidentified.add(note); + continue; + } + if (scope.exclude.contains('id') && note.values.length == 1) continue; + // The final chunk repeats earlier IDs without their fields. + if (!notes.containsKey(note.id) || note.values.length > 1) { + notes[note.id!] = note; + } + } + cursor = header(response, 'X-Notes-Chunk-Cursor'); + if (cursor == null || cursor.isEmpty) { + return NotesQueryResult( + scope: scope, + notes: [...notes.values, ...unidentified], + ); + } + if (!cursors.add(cursor)) break; + } + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Invalid projected notes continuation.', + scope: NotesRequestScope.collection, + ); } static Map<String, Object> writable(NextcloudNote note) => { @@ -271,6 +500,10 @@ class NotesApiClient { 'title': note.title, 'category': note.category, 'favorite': note.favorite, + if (note.localActivityMicros != null || note.modified > 0) + 'modified': note.localActivityMicros == null + ? note.modified + : note.localActivityMicros! ~/ 1000000, }; static Future<Map<String, Object>> creationAttributes( @@ -286,7 +519,6 @@ class NotesApiClient { if (reference.reference.startsWith('busymark-attachment:')) reference.reference: '', }); - attributes['modified'] = DateTime.now().millisecondsSinceEpoch ~/ 1000; return attributes; } @@ -297,7 +529,7 @@ class NotesApiClient { note.creationAttempt?.wireBody ?? jsonEncode(await creationAttributes(note)); final response = await _send(request); - _check(response); + _check(response, scope: NotesRequestScope.collection); return _note(response); } @@ -348,8 +580,8 @@ class NotesApiClient { http.MultipartFile.fromBytes('file', bytes, filename: filename), ); final response = await _send(request); - _check(response); - final json = _json(response); + _check(response, scope: NotesRequestScope.attachment); + final json = _json(response, scope: NotesRequestScope.attachment); if (json is! Map || json['filename'] is! String || !isSafeAttachmentPath(json['filename'] as String) || @@ -357,6 +589,7 @@ class NotesApiClient { throw const NotesException( NotesFailureCode.invalidResponse, 'Nextcloud returned an unsafe attachment filename.', + scope: NotesRequestScope.attachment, ); } return json['filename'] as String; @@ -375,9 +608,8 @@ class NotesApiClient { 'This attachment reference is unsafe.', ); } - final decodedPath = Uri.decodeComponent(path); - if (decodedPath.startsWith('.attachments.') && - !decodedPath.startsWith('.attachments.$id/')) { + if (path.startsWith('.attachments.') && + !path.startsWith('.attachments.$id/')) { throw const NotesException( NotesFailureCode.unsafeReference, 'This attachment belongs to a different Nextcloud note.', @@ -402,13 +634,17 @@ class NotesApiClient { // Register the stream before checking status/headers so every exit cancels it. input = StreamIterator(response.stream.timeout(timeout)); cancel.check(); + await _observe(response.headers); if (response.statusCode >= 300 && response.statusCode < 400) { throw const NotesException( NotesFailureCode.authentication, 'Nextcloud redirected an authenticated attachment request. Reconnect using its canonical HTTPS URL.', ); } - _check(http.Response('', response.statusCode)); + _check( + http.Response('', response.statusCode, headers: response.headers), + scope: NotesRequestScope.attachment, + ); if (response.statusCode != 200) { throw const NotesException( NotesFailureCode.invalidResponse, @@ -491,23 +727,26 @@ class NotesApiClient { } cancel.check(); return await partial.rename(destination.path); - } on NotesException { - rethrow; + } on NotesException catch (error) { + throw error.inScope(NotesRequestScope.attachment); } on TimeoutException { throw const NotesException( NotesFailureCode.network, 'The attachment download timed out.', + scope: NotesRequestScope.attachment, ); } on http.ClientException { cancel.check(); throw const NotesException( NotesFailureCode.network, 'The attachment download was interrupted.', + scope: NotesRequestScope.attachment, ); } on IOException { throw const NotesException( NotesFailureCode.network, 'The attachment could not be downloaded safely.', + scope: NotesRequestScope.attachment, ); } finally { if (!abort.isCompleted) abort.complete(); @@ -536,61 +775,71 @@ class NotesApiClient { ..headers['Content-Type'] = 'application/json' ..body = jsonEncode({'path': path}); final response = await _send(request); - _check(response); + _check(response, scope: NotesRequestScope.attachment); } -} -bool isSafeAttachmentReference(String reference) { - if (reference.isEmpty || - reference.contains('\\') || - RegExp(r'[\x00-\x1f\x7f]').hasMatch(reference)) { - return false; - } - final uri = Uri.tryParse(reference); - if (uri == null || - uri.hasScheme || - uri.hasAuthority || - uri.hasQuery || - uri.hasFragment || - reference.startsWith('/')) { - return false; + Future<NotesSettings> getSettings() async { + final response = await _send(http.Request('GET', _endpoint('v1/settings'))); + _check(response, scope: NotesRequestScope.settings); + return NotesSettings.fromJson( + _json(response, scope: NotesRequestScope.settings), + ); } - try { - final decoded = Uri.decodeComponent(reference); - if (decoded.startsWith('/') || decoded.contains('\\')) return false; - return decoded - .split('/') - .every((part) => part.isNotEmpty && part != '.' && part != '..'); - } on FormatException { - return false; + + Future<NotesSettings> updateSettings(Map<String, String> patch) async { + if (patch.keys.any((key) => key != 'notesPath' && key != 'fileSuffix')) { + throw ArgumentError('Unknown Notes setting.'); + } + final request = http.Request('PUT', _endpoint('v1/settings')) + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(patch); + final response = await _send(request); + _check(response, scope: NotesRequestScope.settings); + return NotesSettings.fromJson( + _json(response, scope: NotesRequestScope.settings), + ); } } -/// API filenames are opaque paths: a literal # can be a valid filename. The -/// Markdown URI spelling is decoded separately before passing it to the API. +/// Decode Markdown destinations once; API paths always remain raw. +bool isSafeAttachmentReference(String reference) => + canonicalAttachmentReference(reference) != null; + bool isSafeAttachmentPath(String path) { if (path.isEmpty || path.startsWith('/') || path.contains('\\') || - RegExp(r'[\x00-\x1f\x7f]').hasMatch(path) || - Uri.tryParse(path)?.hasScheme != false) { - return false; - } - try { - final decoded = Uri.decodeComponent(path); - if (decoded.startsWith('/') || decoded.contains('\\')) return false; - return decoded - .split('/') - .every((part) => part.isNotEmpty && part != '.' && part != '..'); - } on FormatException { + RegExp(r'[\x00-\x1f\x7f]').hasMatch(path)) { return false; } + // A colon in the first segment could be a URI scheme. Percent characters are opaque. + if (path.split('/').first.contains(':')) return false; + return path + .split('/') + .every((part) => part.isNotEmpty && part != '.' && part != '..'); } String? canonicalAttachmentReference(String reference) { - if (!isSafeAttachmentReference(reference)) return null; + final uri = Uri.tryParse(reference); + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.hasQuery || + uri.hasFragment || + reference.contains('\\')) { + return null; + } + // Uri.parse tolerates stray percent signs; a destination must be valid encoding. + if (RegExp(r'%(?![0-9a-fA-F]{2})').hasMatch(reference)) return null; try { - return Uri.decodeComponent(reference); + // Escape literal Unicode before decoding exactly once. Do not use uri.path: + // Uri.parse normalizes dot segments before traversal can be validated. + final encoded = reference.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ); + final raw = Uri.decodeComponent(encoded); + return isSafeAttachmentPath(raw) ? raw : null; } on FormatException { return null; } @@ -605,9 +854,28 @@ String attachmentMarkdownReference(String path) => path ) .join('/'); -/// API 1.4 returns flat filenames on 6.0.x and scoped filenames on 6.1.x. -/// A foreign note folder or any other directory is never an upload destination. bool isNoteAttachmentPath(int id, String path) => isSafeAttachmentPath(path) && - (!Uri.decodeComponent(path).contains('/') || - Uri.decodeComponent(path).startsWith('.attachments.$id/')); + (!path.contains('/') || + (path.startsWith('.attachments.$id/') && path.split('/').length == 2)); + +/// RFC 9110: retain a server deadline, including long delays; never clamp earlier. +DateTime? retryAfter(String? value, DateTime now) { + if (value == null) return null; + final text = value.trim(); + if (RegExp(r'^\d+$').hasMatch(text)) { + final seconds = int.tryParse(text); + if (seconds == null || seconds > 8640000000000) { + return DateTime.utc(275760, 1, 1); + } + final maximum = DateTime.utc(275760, 1, 1); + if (seconds > maximum.difference(now.toUtc()).inSeconds) return maximum; + return now.add(Duration(seconds: seconds)); + } + try { + final deadline = HttpDate.parse(text); + return deadline.isAfter(now) ? deadline : now; + } on HttpException { + return null; + } +} diff --git a/lib/src/nextcloud_notes/data/notes_capabilities.dart b/lib/src/nextcloud_notes/data/notes_capabilities.dart index d441b750..29aae81b 100644 --- a/lib/src/nextcloud_notes/data/notes_capabilities.dart +++ b/lib/src/nextcloud_notes/data/notes_capabilities.dart @@ -1,20 +1,33 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'server_uri.dart'; +import 'notes_api_client.dart' show retryAfter; enum NextcloudCapabilityFailure { unsupported, unauthorized, malformed, network, + rejected, + forbidden, + throttled, } class NextcloudCapabilityException implements Exception { - const NextcloudCapabilityException(this.code, this.message); + const NextcloudCapabilityException( + this.code, + this.message, { + this.statusCode, + this.retryNotBefore, + }); final NextcloudCapabilityFailure code; final String message; + final int? statusCode; + final DateTime? retryNotBefore; @override String toString() => message; } @@ -49,29 +62,44 @@ class NotesCapabilities { } final version = notes['version']; final versions = notes['api_version']; - final minors = <int>[]; - if (versions is List) { - for (final advertised in versions) { - if (advertised is! String) continue; - final match = RegExp(r'^1\.(\d+)$').firstMatch(advertised); - final minor = match == null ? null : int.tryParse(match.group(1)!); - if (minor != null && minor >= 4) minors.add(minor); - } + if (versions is! List || + (versions.isNotEmpty && + !versions.any( + (v) => v is String && RegExp(r'^\d+\.\d+$').hasMatch(v.trim()), + ))) { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.malformed, + 'The Nextcloud server returned invalid Notes API versions.', + ); } - if (minors.isEmpty) { + final supported = parseNotesApiVersions(versions); + if (supported == null) { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.unsupported, 'This Nextcloud Notes API is unsupported. BusyMark requires Notes API major version 1 with minor version 4 or later.', ); } - minors.sort(); return NotesCapabilities( appVersion: version is String ? version : '', - apiVersion: '1.${minors.last}', + apiVersion: supported, ); } } +/// Shared OCS/header boundary. Application versions are separate evidence. +String? parseNotesApiVersions(Iterable<dynamic> versions) { + final minors = <int>[]; + for (final version in versions) { + if (version is! String) continue; + final match = RegExp(r'^1\.(\d+)$').firstMatch(version.trim()); + final minor = match == null ? null : int.tryParse(match.group(1)!); + if (minor != null && minor >= 4) minors.add(minor); + } + if (minors.isEmpty) return null; + minors.sort(); + return '1.${minors.last}'; +} + String nextcloudAuthorization(String loginName, String appPassword) => 'Basic ${base64Encode(utf8.encode('$loginName:$appPassword'))}'; @@ -102,7 +130,17 @@ Future<NotesCapabilities> fetchNotesCapabilities({ response = await http.Response.fromStream( await client.send(request).timeout(const Duration(seconds: 30)), ).timeout(const Duration(seconds: 30)); - } on Object { + } on TimeoutException { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.network, + 'The Nextcloud capabilities request timed out.', + ); + } on IOException { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.network, + 'The Nextcloud capabilities endpoint is unreachable.', + ); + } on http.ClientException { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.network, 'BusyMark could not verify the Nextcloud Notes capabilities. Check your connection and try again.', @@ -120,6 +158,32 @@ Future<NotesCapabilities> fetchNotesCapabilities({ 'The Nextcloud capabilities endpoint redirected. Reconnect using the canonical server URL.', ); } + if (response.statusCode == 429) { + final value = response.headers.entries + .where((e) => e.key.toLowerCase() == 'retry-after') + .firstOrNull + ?.value; + throw NextcloudCapabilityException( + NextcloudCapabilityFailure.throttled, + 'Nextcloud requested a pause before checking capabilities.', + statusCode: 429, + retryNotBefore: retryAfter(value, DateTime.now()), + ); + } + if (response.statusCode == 403) { + throw const NextcloudCapabilityException( + NextcloudCapabilityFailure.forbidden, + 'Nextcloud does not permit capability checks.', + statusCode: 403, + ); + } + if (response.statusCode >= 400 && response.statusCode < 500) { + throw NextcloudCapabilityException( + NextcloudCapabilityFailure.rejected, + 'The capability endpoint rejected the request (HTTP ${response.statusCode}).', + statusCode: response.statusCode, + ); + } if (response.statusCode != 200) { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.network, @@ -127,12 +191,12 @@ Future<NotesCapabilities> fetchNotesCapabilities({ ); } try { - return NotesCapabilities.fromOcsJson( - jsonDecode(response.body) as Map<String, dynamic>, - ); + final json = jsonDecode(response.body); + if (json is! Map<String, dynamic>) throw const FormatException(); + return NotesCapabilities.fromOcsJson(json); } on NextcloudCapabilityException { rethrow; - } on Object { + } on FormatException { throw const NextcloudCapabilityException( NextcloudCapabilityFailure.malformed, 'The Nextcloud server returned invalid capabilities.', diff --git a/lib/src/nextcloud_notes/data/notes_store.dart b/lib/src/nextcloud_notes/data/notes_store.dart index 93ddb9b8..0161959f 100644 --- a/lib/src/nextcloud_notes/data/notes_store.dart +++ b/lib/src/nextcloud_notes/data/notes_store.dart @@ -9,6 +9,7 @@ import 'package:sqlite3/sqlite3.dart'; import '../../assets/asset_limits.dart'; import '../domain/notes_models.dart'; +import '../domain/notes_search.dart'; /// Focused durable Notes store. All synchronous SQLite work runs in one isolate. class NotesStore { @@ -152,6 +153,7 @@ class NotesStore { NextcloudNote note, List<({NotesAttachment attachment, Uint8List bytes})> attachments, { List<NextcloudNote> additionalNotes = const [], + String? importKey, }) async { await _call('createWithAttachments', [ note.toJson(), @@ -166,6 +168,7 @@ class NotesStore { ) .toList(), additionalNotes.map((n) => n.toJson()).toList(), + importKey, ]); } @@ -198,6 +201,9 @@ class NotesStore { ) .toList(); + Future<int?> attachmentSize(String id) async => + await _call('attachmentSize', id) as int?; + Future<Uint8List?> attachmentBytes(String id) async => await _call('attachmentBytes', id) as Uint8List?; @@ -216,6 +222,80 @@ class NotesStore { await _call('removeAttachment', id); } + /// Each step is bounded so queued durable saves can run between steps. + Future<int> indexStep() async => await _call('indexStep') as int; + Future<void> rebuildIndex() async => await _call('rebuildIndex'); + Future<Map<String, dynamic>> searchChunk( + String accountId, + NotesSearchQuery query, { + int after = 0, + Map<String, dynamic>? continuation, + bool recovery = false, + int limit = 80, + Set<String> exclude = const {}, + }) async => Map<String, dynamic>.from( + await _call('searchChunk', { + 'account': accountId, + 'query': query.text, + 'wholeWord': query.wholeWord, + 'after': after, + 'continuation': continuation, + 'recovery': recovery, + 'limit': limit.clamp(1, 200), + 'exclude': exclude.toList(), + }) + as Map, + ); + Future<List<Map<String, dynamic>>> offlineRequirements( + String accountId, + ) async => (await _call('offlineRequirements', accountId) as List) + .map((v) => Map<String, dynamic>.from(v as Map)) + .toList(); + Future<void> setOfflineRequirement( + String accountId, + String kind, + String target, { + bool required = true, + bool paused = false, + }) async => await _call('setOfflineRequirement', [ + accountId, + kind, + target, + required, + paused, + ]); + Future<void> beginImportOperation( + String accountId, + String operationId, + String root, + String review, + ) async => await _call('beginImportOperation', [ + accountId, + operationId, + root, + review, + ]); + Future<List<Map<String, dynamic>>> importOperations(String accountId) async => + (await _call('importOperations', accountId) as List) + .map((r) => Map<String, dynamic>.from(r as Map)) + .toList(); + Future<Map<String, dynamic>?> importOperation( + String accountId, + String operationId, + ) async { + final row = await _call('importOperation', [accountId, operationId]); + return row == null ? null : Map<String, dynamic>.from(row as Map); + } + + Future<void> completeImportOperation( + String accountId, + String operationId, + List<String> keys, + ) async => + await _call('completeImportOperation', [accountId, operationId, keys]); + Future<String?> importedNote(String accountId, String sourceKey) async => + await _call('importedNote', [accountId, sourceKey]) as String?; + Future<void> close() => _closing ??= _close(); Future<void> _close() async { @@ -244,7 +324,7 @@ void _worker(List<dynamic> arguments) { db.execute('PRAGMA synchronous = FULL'); db.execute('PRAGMA busy_timeout = 5000'); final version = db.select('PRAGMA user_version').first.values.first as int; - if (version > 2) { + if (version > 6) { throw StateError( 'The Notes database was created by a newer BusyMark version.', ); @@ -274,10 +354,14 @@ void _worker(List<dynamic> arguments) { // v2 stores durable creation attempts in the existing versioned note JSON. // Older uncertain operations have no attempt and remain explicitly unresolved. // Prevent old clients from opening and losing this new safety metadata. - if (version < 2) db.execute('PRAGMA user_version = 2'); + // v3 fences durable retry/error provenance, edit times and settings attempts. + // v4 fences competing local metadata evidence from older clients. + // Advance only inside the recovery transaction; malformed rows roll back. + // A request interrupted by a crash has an uncertain creation/upload outcome. db.execute('BEGIN IMMEDIATE'); try { + _createWorkspaceSchema(db); for (final row in db.select('SELECT data FROM notes')) { final note = NextcloudNote.fromJson( jsonDecode(row['data'] as String) as Map<String, dynamic>, @@ -293,6 +377,7 @@ void _worker(List<dynamic> arguments) { ); } } + if (version < 6) db.execute('PRAGMA user_version = 6'); db.execute('COMMIT'); } catch (_) { db.execute('ROLLBACK'); @@ -326,6 +411,7 @@ void _writeNote(Database db, NextcloudNote note) { 'INSERT INTO notes(id,account_id,server_id,data) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET server_id=excluded.server_id,data=excluded.data', [note.localId, note.accountId, note.serverId, note.encode()], ); + _indexNote(db, note); if (note.hasPendingChanges) { db.execute( 'INSERT INTO outbox(note_id,revision,operation,data) VALUES(?,?,?,?) ON CONFLICT(note_id) DO UPDATE SET revision=excluded.revision,operation=excluded.operation,data=excluded.data', @@ -343,6 +429,201 @@ void _writeNote(Database db, NextcloudNote note) { Object? _execute(Database db, String action, dynamic argument) { switch (action) { + case 'indexStep': + final rows = db.select( + "SELECT n.data FROM notes n LEFT JOIN note_search_map s ON s.id=n.id WHERE s.id IS NULL OR s.revision!=json_extract(n.data,'\$.revision') LIMIT 32", + ); + db.execute('BEGIN IMMEDIATE'); + try { + for (final row in rows) { + _indexNote( + db, + NextcloudNote.fromJson( + jsonDecode(row['data'] as String) as Map<String, dynamic>, + ), + ); + } + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return rows.length; + case 'rebuildIndex': + db.execute('BEGIN IMMEDIATE'); + try { + db.execute('DELETE FROM note_search'); + db.execute('DELETE FROM note_search_map'); + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return null; + case 'searchChunk': + final a = argument as Map; + final query = NotesSearchQuery( + a['query'] as String, + wholeWord: a['wholeWord'] as bool, + ); + final expression = query.ftsCandidates; + final recovery = a['recovery'] == true; + final rows = db.select( + recovery + ? 'SELECT rowid AS cursor,data FROM notes WHERE account_id=? AND rowid>? ORDER BY rowid LIMIT 32' + : 'SELECT note_search.rowid AS cursor,n.data FROM note_search JOIN notes n ON n.id=note_search.id WHERE note_search.account=? AND note_search.rowid>? ' + "${expression == null ? '' : 'AND note_search MATCH ? '}ORDER BY note_search.rowid LIMIT 32", + [ + a['account'], + a['after'], + if (!recovery && expression != null) expression, + ], + ); + final hits = <Map<String, Object?>>[]; + final exclude = (a['exclude'] as List).cast<String>().toSet(); + final limit = a['limit'] as int; + var after = a['after'] as int; + Map? continuation = a['continuation'] as Map?; + for (var index = 0; index < rows.length; index++) { + final row = rows[index]; + final note = NextcloudNote.fromJson( + jsonDecode(row['data'] as String) as Map<String, dynamic>, + ); + if (continuation != null && + (continuation['id'] != note.localId || + continuation['revision'] != note.revision || + continuation['title'] != note.title || + continuation['category'] != note.category || + continuation['digest'] != notesSearchDigest(note.content))) { + throw StateError('Search continuation changed; refresh the query.'); + } + final deleted = + note.syncState == NoteSyncState.deletedRemotely && + !note.hasPendingChanges; + if (!exclude.contains(note.localId) && deleted == recovery) { + final remaining = limit - hits.length; + final page = matchNotesDocument( + query: query, + localId: note.localId, + revision: note.revision, + title: note.title, + category: note.category, + source: note.content, + limit: remaining + 1, + afterStart: continuation?['start'] as int? ?? -1, + afterEnd: continuation?['end'] as int? ?? -1, + ); + hits.addAll(page.take(remaining).map((h) => h.toJson())); + if (page.length > remaining) { + final last = page[remaining - 1]; + return { + 'hits': hits, + 'after': after, + 'more': true, + 'continuation': { + 'id': note.localId, + 'revision': note.revision, + 'title': note.title, + 'category': note.category, + 'digest': last.digest, + 'start': last.start, + 'end': last.end, + }, + }; + } + } + // Advance only after the complete document has been consumed. + after = row['cursor'] as int; + continuation = null; + if (hits.length == limit) { + return { + 'hits': hits, + 'after': after, + 'continuation': null, + 'more': index < rows.length - 1 || rows.length == 32, + }; + } + } + if (continuation != null) throw StateError('Search continuation lost.'); + return { + 'hits': hits, + 'after': after, + 'continuation': null, + 'more': rows.length == 32, + }; + case 'beginImportOperation': + final a = argument as List; + db.execute( + "INSERT INTO import_operations(account_id,id,root,review,state) VALUES(?,?,?,?,'pending') ON CONFLICT(account_id,id) DO UPDATE SET review=excluded.review WHERE import_operations.state='pending' AND import_operations.root=excluded.root", + a, + ); + return null; + case 'importOperations': + return db + .select( + "SELECT id,root,state,created_at FROM import_operations WHERE account_id=? AND state='pending' ORDER BY rowid DESC", + [argument], + ) + .map((r) => Map<String, Object?>.from(r)) + .toList(); + case 'importOperation': + final a = argument as List; + final rows = db.select( + 'SELECT id,root,review,state FROM import_operations WHERE account_id=? AND id=?', + a, + ); + return rows.isEmpty ? null : Map<String, Object?>.from(rows.single); + case 'completeImportOperation': + final a = argument as List; + db.execute('BEGIN IMMEDIATE'); + try { + for (final key in a[2] as List) { + if (db.select( + 'SELECT note_id FROM import_items WHERE account_id=? AND source_key=?', + [a[0], key], + ).isEmpty) { + throw StateError('An import item is not durable.'); + } + } + db.execute( + "UPDATE import_operations SET state='completed' WHERE account_id=? AND id=?", + [a[0], a[1]], + ); + db.execute('COMMIT'); + } catch (_) { + db.execute('ROLLBACK'); + rethrow; + } + return null; + case 'offlineRequirements': + return db + .select( + 'SELECT kind,target,paused FROM offline_requirements WHERE account_id=? ORDER BY kind,target', + [argument], + ) + .map((r) => Map<String, Object?>.from(r)) + .toList(); + case 'setOfflineRequirement': + final a = argument as List; + if (a[3] == true) { + db.execute( + 'INSERT INTO offline_requirements(account_id,kind,target,paused) VALUES(?,?,?,?) ON CONFLICT(account_id,kind,target) DO UPDATE SET paused=excluded.paused', + [a[0], a[1], a[2], a[4] == true ? 1 : 0], + ); + } else { + db.execute( + 'DELETE FROM offline_requirements WHERE account_id=? AND kind=? AND target=?', + [a[0], a[1], a[2]], + ); + } + return null; + case 'importedNote': + final a = argument as List; + final rows = db.select( + 'SELECT note_id FROM import_items WHERE account_id=? AND source_key=?', + [a[0], a[1]], + ); + return rows.isEmpty ? null : rows.single['note_id']; case 'accounts': return db .select('SELECT data FROM accounts ORDER BY id') @@ -395,7 +676,7 @@ Object? _execute(Database db, String action, dynamic argument) { throw StateError('Attachment too large.'); } db.execute( - 'INSERT INTO attachments(id,note_id,data,bytes) VALUES(?,?,?,?)', + 'INSERT INTO attachments(id,note_id,data,bytes) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data,bytes=excluded.bytes', [a[0], a[1], a[2], bytes], ); return null; @@ -442,6 +723,15 @@ Object? _execute(Database db, String action, dynamic argument) { (attachment as List).cast<Object?>(), ); } + if (a.length > 3 && a[3] != null) { + final note = NextcloudNote.fromJson( + Map<String, dynamic>.from(a[0] as Map), + ); + db.execute( + 'INSERT INTO import_items(account_id,source_key,note_id) VALUES(?,?,?)', + [note.accountId, a[3], note.localId], + ); + } db.execute('COMMIT'); } catch (_) { db.execute('ROLLBACK'); @@ -472,6 +762,12 @@ Object? _execute(Database db, String action, dynamic argument) { rethrow; } return null; + case 'attachmentSize': + final rows = db.select( + 'SELECT length(bytes) AS size FROM attachments WHERE id=?', + [argument], + ); + return rows.isEmpty ? null : rows.single['size']; case 'attachmentBytes': final rows = db.select('SELECT bytes FROM attachments WHERE id=?', [ argument, @@ -504,3 +800,81 @@ Object? _execute(Database db, String action, dynamic argument) { throw StateError('Unknown Notes storage operation.'); } } + +void _createWorkspaceSchema(Database db) { + // A runtime FTS5/trigram capability check uses the same native library as + // durable notes. A lost derived index is recreated without touching outbox. + final hadIndex = db + .select("SELECT name FROM sqlite_master WHERE name='note_search'") + .isNotEmpty; + db.execute( + 'CREATE TABLE IF NOT EXISTS note_search_map(id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE, docid INTEGER UNIQUE, revision INTEGER NOT NULL)', + ); + db.execute( + 'CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(id UNINDEXED, account UNINDEXED, revision UNINDEXED, title, category, body, tokenize="trigram")', + ); + db.execute( + 'CREATE TRIGGER IF NOT EXISTS notes_search_delete BEFORE DELETE ON notes BEGIN DELETE FROM note_search WHERE rowid=(SELECT docid FROM note_search_map WHERE id=old.id); END', + ); + if (!hadIndex || + db.select('SELECT count(*) AS n FROM note_search').single['n'] != + db + .select('SELECT count(docid) AS n FROM note_search_map') + .single['n']) { + db.execute('DELETE FROM note_search'); + db.execute('DELETE FROM note_search_map'); + } + db.execute( + "CREATE TABLE IF NOT EXISTS offline_requirements(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK(kind IN ('note','category','pause')), target TEXT NOT NULL, paused INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(account_id,kind,target))", + ); + db.execute( + 'CREATE TABLE IF NOT EXISTS import_items(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, source_key TEXT NOT NULL, note_id TEXT NOT NULL REFERENCES notes(id), PRIMARY KEY(account_id,source_key))', + ); // v6 namespaces imports by a durable reviewed operation. Existing v5 item + // associations remain intact; they cannot suppress a deliberately new import. + db.execute( + "CREATE TABLE IF NOT EXISTS import_operations(account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, id TEXT NOT NULL, root TEXT NOT NULL, review TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT(unixepoch()), state TEXT NOT NULL CHECK(state IN ('pending','completed')), PRIMARY KEY(account_id,id))", + ); +} + +void _indexNote(Database db, NextcloudNote note) { + final existing = db.select( + 'SELECT s.rowid,s.revision,s.title,s.category,s.body FROM note_search s JOIN note_search_map m ON s.rowid=m.docid WHERE m.id=?', + [note.localId], + ); + if (note.syncState == NoteSyncState.deletedRemotely && + !note.hasPendingChanges) { + if (existing.isNotEmpty) { + db.execute('DELETE FROM note_search WHERE rowid=?', [ + existing.single['rowid'], + ]); + } + db.execute( + 'INSERT INTO note_search_map(id,docid,revision) VALUES(?,NULL,?) ON CONFLICT(id) DO UPDATE SET docid=NULL,revision=excluded.revision', + [note.localId, note.revision], + ); + return; + } + final title = normalizeNotesSearch(note.title); + final category = normalizeNotesSearch(note.category); + final body = normalizeNotesSearch(note.content); + if (existing.isNotEmpty && + existing.single['revision'] == note.revision && + existing.single['title'] == title && + existing.single['category'] == category && + existing.single['body'] == body) { + return; + } + final rowid = existing.isEmpty ? null : existing.single['rowid']; + if (rowid != null) { + db.execute('DELETE FROM note_search WHERE rowid=?', [rowid]); + } + db.execute( + 'INSERT INTO note_search(rowid,id,account,revision,title,category,body) VALUES(?,?,?,?,?,?,?)', + [rowid, note.localId, note.accountId, note.revision, title, category, body], + ); + final docid = rowid ?? db.lastInsertRowId; + db.execute( + 'INSERT INTO note_search_map(id,docid,revision) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET docid=excluded.docid,revision=excluded.revision', + [note.localId, docid, note.revision], + ); +} diff --git a/lib/src/nextcloud_notes/data/server_uri.dart b/lib/src/nextcloud_notes/data/server_uri.dart index a7e12b42..e6ed8b0f 100644 --- a/lib/src/nextcloud_notes/data/server_uri.dart +++ b/lib/src/nextcloud_notes/data/server_uri.dart @@ -9,7 +9,12 @@ Uri normalizeNextcloudServer(String input) { // that normalization so a supplied installation prefix cannot move upwards. if (rawPath != null && rawPath.split('/').any((part) { - final decoded = Uri.decodeComponent(part); + final decoded = Uri.decodeComponent( + part.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (m) => Uri.encodeComponent(m[0]!), + ), + ); return decoded == '.' || decoded == '..'; })) { throw const FormatException('Enter a valid HTTPS Nextcloud server URL.'); @@ -66,6 +71,47 @@ bool nextcloudSameOrigin(Uri first, Uri second) => first.host.toLowerCase() == second.host.toLowerCase() && first.port == second.port; +/// Portable web navigation, independent of title/category and local database IDs. +Uri nextcloudNoteUrl(Uri server, int serverId, {bool pretty = false}) { + if (serverId <= 0) throw ArgumentError.value(serverId, 'serverId'); + return nextcloudEndpoint( + server, + '${pretty ? '' : 'index.php/'}apps/notes/note/$serverId', + ); +} + +/// Recognizes only this exact installation, with front-controller or pretty URLs. +int? nextcloudNoteId(Uri server, String destination) { + try { + final base = normalizeNextcloudServer(server.toString()); + final uri = Uri.tryParse(destination); + if (uri == null || + !uri.hasAuthority || + !nextcloudSameOrigin(base, uri) || + uri.userInfo.isNotEmpty || + destination.contains(RegExp(r'[\x00-\x20\\]'))) { + return null; + } + // Match the encoded path exactly: encoded separators, dot normalization, + // misleading installation prefixes and route suffixes never alias a note. + final rawPath = RegExp( + r'^https://[^/?#]+([^?#]*)', + caseSensitive: false, + ).firstMatch(destination)?.group(1); + if (rawPath == null || rawPath != uri.path) return null; + for (final route in ['index.php/apps/notes/note/', 'apps/notes/note/']) { + final prefix = '${base.path}/$route'; + if (!rawPath.startsWith(prefix)) continue; + final suffix = rawPath.substring(prefix.length); + if (!RegExp(r'^[1-9][0-9]*$').hasMatch(suffix)) return null; + return int.tryParse(suffix); + } + } on FormatException { + return null; + } + return null; +} + /// Validates a flow URL before sending its token or opening the browser. Uri nextcloudFlowEndpoint(Uri server, Object? value) { if (value is! String) { diff --git a/lib/src/nextcloud_notes/domain/notes_conflict.dart b/lib/src/nextcloud_notes/domain/notes_conflict.dart index 3432aaf6..19bfc6a5 100644 --- a/lib/src/nextcloud_notes/domain/notes_conflict.dart +++ b/lib/src/nextcloud_notes/domain/notes_conflict.dart @@ -30,6 +30,28 @@ class NotesAttributeMerge<T> { } class NotesConflictMerge { + NotesConflictMerge.metadata(NextcloudNote local) + : content = NotesAttributeMerge( + local.content, + local.content, + local.content, + ), + title = NotesAttributeMerge( + local.metadataConflict!.original.title, + local.title, + local.metadataConflict!.alternative.title, + ), + category = NotesAttributeMerge( + local.metadataConflict!.original.category, + local.category, + local.metadataConflict!.alternative.category, + ), + favorite = NotesAttributeMerge( + local.metadataConflict!.original.favorite, + local.favorite, + local.metadataConflict!.alternative.favorite, + ); + NotesConflictMerge(NextcloudNote local, NoteState remote) : content = NotesAttributeMerge( local.base?.content, diff --git a/lib/src/nextcloud_notes/domain/notes_models.dart b/lib/src/nextcloud_notes/domain/notes_models.dart index 3232e01d..3dc37ba4 100644 --- a/lib/src/nextcloud_notes/domain/notes_models.dart +++ b/lib/src/nextcloud_notes/domain/notes_models.dart @@ -1,5 +1,21 @@ +import 'dart:collection'; import 'dart:convert'; +/// Immutable document versions with fences for lazily copied attachment bytes. +/// A refresh or account removal invalidates publication, without holding the +/// durability queue while files are copied. +class NotesLocalSnapshot extends UnmodifiableListView<NextcloudNote> { + NotesLocalSnapshot( + super.notes, { + required Map<String, int> mediaVersions, + required Map<String, int> accountGenerations, + }) : mediaVersions = Map.unmodifiable(mediaVersions), + accountGenerations = Map.unmodifiable(accountGenerations); + + final Map<String, int> mediaVersions; + final Map<String, int> accountGenerations; +} + enum NoteSyncState { synced, pending, @@ -13,6 +29,9 @@ enum NoteSyncState { unavailable, creationUncertain, deletedRemotely, + rejected, + throttled, + recoveryRequired, } enum NoteConflictResolution { @@ -35,15 +54,55 @@ enum NotesFailureCode { invalidResponse, unsupported, unsafeReference, + rejected, + throttled, +} + +enum NotesRequestScope { + account, + capability, + collection, + settings, + note, + attachment, } class NotesException implements Exception { - const NotesException(this.code, this.message, {this.statusCode, this.remote}); + const NotesException( + this.code, + this.message, { + this.statusCode, + this.remote, + this.scope = NotesRequestScope.note, + this.retryNotBefore, + }); final NotesFailureCode code; final String message; final int? statusCode; final NoteState? remote; + final NotesRequestScope scope; + final DateTime? retryNotBefore; + bool get retryable => { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.locked, + NotesFailureCode.throttled, + }.contains(code); + bool get possiblyExecuted => { + NotesFailureCode.network, + NotesFailureCode.server, + NotesFailureCode.invalidResponse, + NotesFailureCode.locked, + }.contains(code); + NotesException inScope(NotesRequestScope value) => NotesException( + code, + message, + statusCode: statusCode, + remote: remote, + scope: value, + retryNotBefore: retryNotBefore, + ); @override String toString() => message; @@ -58,6 +117,12 @@ class NextcloudAccount { this.apiVersion = '1.4', this.listEtag, this.lastModified, + this.lastServerCheck, + this.capabilitiesCheckedAt, + this.apiSupported = true, + this.settingsAttempt, + this.throttleNotBefore, + this.notesWebPrettyUrls, }); final String id; @@ -67,10 +132,19 @@ class NextcloudAccount { final String apiVersion; final String? listEtag; final String? lastModified; + final DateTime? lastServerCheck; + final DateTime? capabilitiesCheckedAt; + final bool apiSupported; + final NotesSettingsAttempt? settingsAttempt; + final DateTime? throttleNotBefore; + + /// Optional, backward-compatible observation of the installation's web router. + final bool? notesWebPrettyUrls; /// DELETE was added without an API capability bump (upstream #2037). /// Unknown/malformed/prerelease versions cannot establish support. bool get supportsAttachmentDeletion { + if (!apiSupported) return false; final match = RegExp( r'^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$', ).firstMatch(appVersion); @@ -80,16 +154,44 @@ class NextcloudAccount { return parts[0]! > 6 || (parts[0] == 6 && parts[1]! >= 1); } + NextcloudAccount copyWith({ + String? appVersion, + String? apiVersion, + bool? apiSupported, + Object? listEtag = _unchanged, + Object? lastModified = _unchanged, + DateTime? lastServerCheck, + DateTime? capabilitiesCheckedAt, + Object? settingsAttempt = _unchanged, + Object? throttleNotBefore = _unchanged, + Object? notesWebPrettyUrls = _unchanged, + }) => NextcloudAccount( + id: id, + server: server, + loginName: loginName, + appVersion: appVersion ?? this.appVersion, + apiVersion: apiVersion ?? this.apiVersion, + apiSupported: apiSupported ?? this.apiSupported, + listEtag: identical(listEtag, _unchanged) + ? this.listEtag + : listEtag as String?, + lastModified: identical(lastModified, _unchanged) + ? this.lastModified + : lastModified as String?, + lastServerCheck: lastServerCheck ?? this.lastServerCheck, + capabilitiesCheckedAt: capabilitiesCheckedAt ?? this.capabilitiesCheckedAt, + settingsAttempt: identical(settingsAttempt, _unchanged) + ? this.settingsAttempt + : settingsAttempt as NotesSettingsAttempt?, + throttleNotBefore: identical(throttleNotBefore, _unchanged) + ? this.throttleNotBefore + : throttleNotBefore as DateTime?, + notesWebPrettyUrls: identical(notesWebPrettyUrls, _unchanged) + ? this.notesWebPrettyUrls + : notesWebPrettyUrls as bool?, + ); NextcloudAccount withCheckpoint(String? etag, String? modified) => - NextcloudAccount( - id: id, - server: server, - loginName: loginName, - appVersion: appVersion, - apiVersion: apiVersion, - listEtag: etag, - lastModified: modified, - ); + copyWith(listEtag: etag, lastModified: modified); Map<String, Object?> toJson() => { 'id': id, @@ -99,6 +201,12 @@ class NextcloudAccount { 'apiVersion': apiVersion, 'listEtag': listEtag, 'lastModified': lastModified, + 'lastServerCheck': lastServerCheck?.toIso8601String(), + 'capabilitiesCheckedAt': capabilitiesCheckedAt?.toIso8601String(), + 'apiSupported': apiSupported, + 'settingsAttempt': settingsAttempt?.toJson(), + 'throttleNotBefore': throttleNotBefore?.toIso8601String(), + if (notesWebPrettyUrls != null) 'notesWebPrettyUrls': notesWebPrettyUrls, }; factory NextcloudAccount.fromJson(Map<String, dynamic> json) => @@ -110,6 +218,22 @@ class NextcloudAccount { apiVersion: json['apiVersion'] as String? ?? '1.4', listEtag: json['listEtag'] as String?, lastModified: json['lastModified'] as String?, + lastServerCheck: DateTime.tryParse( + json['lastServerCheck'] as String? ?? '', + ), + capabilitiesCheckedAt: DateTime.tryParse( + json['capabilitiesCheckedAt'] as String? ?? '', + ), + apiSupported: json['apiSupported'] as bool? ?? true, + notesWebPrettyUrls: json['notesWebPrettyUrls'] as bool?, + settingsAttempt: json['settingsAttempt'] == null + ? null + : NotesSettingsAttempt.fromJson( + Map<String, dynamic>.from(json['settingsAttempt'] as Map), + ), + throttleNotBefore: DateTime.tryParse( + json['throttleNotBefore'] as String? ?? '', + ), ); } @@ -297,6 +421,67 @@ class NotesEditorBase { final int generation; } +/// Metadata values from one accepted local edit, independent of server evidence. +class NotesMetadataValues { + const NotesMetadataValues({ + required this.title, + required this.category, + required this.favorite, + }); + factory NotesMetadataValues.fromNote(NextcloudNote note) => + NotesMetadataValues( + title: note.title, + category: note.category, + favorite: note.favorite, + ); + final String title; + final String category; + final bool favorite; + NotesMetadataValues copyWith({ + String? title, + String? category, + bool? favorite, + }) => NotesMetadataValues( + title: title ?? this.title, + category: category ?? this.category, + favorite: favorite ?? this.favorite, + ); + Map<String, Object?> toJson() => { + 'title': title, + 'category': category, + 'favorite': favorite, + }; + factory NotesMetadataValues.fromJson(Map<String, dynamic> json) => + NotesMetadataValues( + title: json['title'] as String, + category: json['category'] as String, + favorite: json['favorite'] as bool, + ); +} + +/// Competing local properties edits. Neither side is an observation of Nextcloud. +class NotesMetadataConflict { + const NotesMetadataConflict({ + required this.original, + required this.alternative, + }); + final NotesMetadataValues original; + final NotesMetadataValues alternative; + Map<String, Object?> toJson() => { + 'original': original.toJson(), + 'alternative': alternative.toJson(), + }; + factory NotesMetadataConflict.fromJson(Map<String, dynamic> json) => + NotesMetadataConflict( + original: NotesMetadataValues.fromJson( + Map<String, dynamic>.from(json['original'] as Map), + ), + alternative: NotesMetadataValues.fromJson( + Map<String, dynamic>.from(json['alternative'] as Map), + ), + ); +} + class NextcloudNote { const NextcloudNote({ required this.localId, @@ -312,13 +497,21 @@ class NextcloudNote { this.etag, this.base, this.remote, + this.metadataConflict, this.creationAttempt, + this.creationNeverSent = false, this.revision = 1, this.ackRevision = 0, this.editorRevision = 0, this.editorContentDigest, this.syncState = NoteSyncState.pending, this.errorMessage, + this.localActivityMicros, + this.failureCode, + this.failureScope, + this.retryNotBefore, + this.retryCount = 0, + this.deletionEvidence, }); final String localId; @@ -334,7 +527,11 @@ class NextcloudNote { final String? etag; final NoteState? base; final NoteState? remote; + final NotesMetadataConflict? metadataConflict; final NotesCreationAttempt? creationAttempt; + + /// Positive durable evidence; missing legacy metadata is not proof. + final bool creationNeverSent; final int revision; final int ackRevision; @@ -343,8 +540,20 @@ class NextcloudNote { final String? editorContentDigest; final NoteSyncState syncState; final String? errorMessage; + final int? localActivityMicros; + final NotesFailureCode? failureCode; + final NotesRequestScope? failureScope; + final DateTime? retryNotBefore; + final int retryCount; + final String? deletionEvidence; + int get activityMicros => localActivityMicros == null + ? modified * 1000000 + : (localActivityMicros! > modified * 1000000 + ? localActivityMicros! + : modified * 1000000); bool get hasPendingChanges => + metadataConflict != null || revision > ackRevision || (serverId == null && syncState != NoteSyncState.deletedRemotely); String get identity => 'nextcloud-note:$accountId:$localId'; @@ -361,13 +570,21 @@ class NextcloudNote { Object? etag = _unchanged, Object? base = _unchanged, Object? remote = _unchanged, + Object? metadataConflict = _unchanged, Object? creationAttempt = _unchanged, + bool? creationNeverSent, int? revision, int? ackRevision, int? editorRevision, Object? editorContentDigest = _unchanged, NoteSyncState? syncState, Object? errorMessage = _unchanged, + Object? localActivityMicros = _unchanged, + Object? failureCode = _unchanged, + Object? failureScope = _unchanged, + Object? retryNotBefore = _unchanged, + int? retryCount, + Object? deletionEvidence = _unchanged, }) => NextcloudNote( localId: localId, accountId: accountId, @@ -384,6 +601,10 @@ class NextcloudNote { etag: identical(etag, _unchanged) ? this.etag : etag as String?, base: identical(base, _unchanged) ? this.base : base as NoteState?, remote: identical(remote, _unchanged) ? this.remote : remote as NoteState?, + metadataConflict: identical(metadataConflict, _unchanged) + ? this.metadataConflict + : metadataConflict as NotesMetadataConflict?, + creationNeverSent: creationNeverSent ?? this.creationNeverSent, creationAttempt: identical(creationAttempt, _unchanged) ? this.creationAttempt : creationAttempt as NotesCreationAttempt?, @@ -393,6 +614,22 @@ class NextcloudNote { editorContentDigest: identical(editorContentDigest, _unchanged) ? this.editorContentDigest : editorContentDigest as String?, + localActivityMicros: identical(localActivityMicros, _unchanged) + ? this.localActivityMicros + : localActivityMicros as int?, + failureCode: identical(failureCode, _unchanged) + ? this.failureCode + : failureCode as NotesFailureCode?, + failureScope: identical(failureScope, _unchanged) + ? this.failureScope + : failureScope as NotesRequestScope?, + retryNotBefore: identical(retryNotBefore, _unchanged) + ? this.retryNotBefore + : retryNotBefore as DateTime?, + retryCount: retryCount ?? this.retryCount, + deletionEvidence: identical(deletionEvidence, _unchanged) + ? this.deletionEvidence + : deletionEvidence as String?, syncState: syncState ?? this.syncState, errorMessage: identical(errorMessage, _unchanged) ? this.errorMessage @@ -413,13 +650,21 @@ class NextcloudNote { 'etag': etag, 'base': base?.toJson(), 'remote': remote?.toJson(), + 'metadataConflict': metadataConflict?.toJson(), 'creationAttempt': creationAttempt?.toJson(), + 'creationNeverSent': creationNeverSent, 'revision': revision, 'ackRevision': ackRevision, 'editorRevision': editorRevision, 'editorContentDigest': editorContentDigest, 'syncState': syncState.name, 'errorMessage': errorMessage, + 'localActivityMicros': localActivityMicros, + 'failureCode': failureCode?.name, + 'failureScope': failureScope?.name, + 'retryNotBefore': retryNotBefore?.toIso8601String(), + 'retryCount': retryCount, + 'deletionEvidence': deletionEvidence, }; factory NextcloudNote.fromJson(Map<String, dynamic> json) => NextcloudNote( @@ -440,6 +685,12 @@ class NextcloudNote { remote: json['remote'] == null ? null : NoteState.fromJson(Map<String, dynamic>.from(json['remote'] as Map)), + metadataConflict: json['metadataConflict'] == null + ? null + : NotesMetadataConflict.fromJson( + Map<String, dynamic>.from(json['metadataConflict'] as Map), + ), + creationNeverSent: json['creationNeverSent'] == true, creationAttempt: json['creationAttempt'] == null ? null : NotesCreationAttempt.fromJson( @@ -451,6 +702,16 @@ class NextcloudNote { editorContentDigest: json['editorContentDigest'] as String?, syncState: NoteSyncState.values.byName(json['syncState'] as String), errorMessage: json['errorMessage'] as String?, + localActivityMicros: json['localActivityMicros'] as int?, + failureCode: json['failureCode'] == null + ? null + : NotesFailureCode.values.byName(json['failureCode'] as String), + failureScope: json['failureScope'] == null + ? null + : NotesRequestScope.values.byName(json['failureScope'] as String), + retryNotBefore: DateTime.tryParse(json['retryNotBefore'] as String? ?? ''), + retryCount: json['retryCount'] as int? ?? 0, + deletionEvidence: json['deletionEvidence'] as String?, ); String encode() => jsonEncode(toJson()); @@ -464,6 +725,7 @@ class NotesAttachment { required this.reference, this.remotePath, this.state = 'pending', + this.validatedAt, }); final String id; @@ -472,6 +734,7 @@ class NotesAttachment { final String reference; final String? remotePath; final String state; + final DateTime? validatedAt; Map<String, Object?> toJson() => { 'id': id, @@ -480,6 +743,7 @@ class NotesAttachment { 'reference': reference, 'remotePath': remotePath, 'state': state, + 'validatedAt': validatedAt?.toIso8601String(), }; factory NotesAttachment.fromJson(Map<String, dynamic> json) => @@ -490,5 +754,60 @@ class NotesAttachment { reference: json['reference'] as String, remotePath: json['remotePath'] as String?, state: json['state'] as String, + validatedAt: DateTime.tryParse(json['validatedAt'] as String? ?? ''), + ); +} + +/// Immutable properties-dialog provenance; patches contain only edited fields. +class NotesMetadataSnapshot { + const NotesMetadataSnapshot(this.note, this.base); + final NextcloudNote note; + final NotesEditorBase base; +} + +class NotesSettings { + const NotesSettings({required this.notesPath, required this.fileSuffix}); + final String notesPath; + final String fileSuffix; + factory NotesSettings.fromJson(dynamic json) { + if (json is! Map || + json['notesPath'] is! String || + json['fileSuffix'] is! String) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned invalid Notes settings.', + scope: NotesRequestScope.settings, + ); + } + return NotesSettings( + notesPath: json['notesPath'] as String, + fileSuffix: json['fileSuffix'] as String, + ); + } + Map<String, String> toJson() => { + 'notesPath': notesPath, + 'fileSuffix': fileSuffix, + }; +} + +class NotesSettingsAttempt { + NotesSettingsAttempt({ + required this.id, + required this.original, + required Map<String, String> patch, + }) : patch = Map.unmodifiable(patch); + final String id; + final NotesSettings original; + final Map<String, String> patch; + Map<String, Object> toJson() => { + 'id': id, + 'original': original.toJson(), + 'patch': patch, + }; + factory NotesSettingsAttempt.fromJson(Map<String, dynamic> json) => + NotesSettingsAttempt( + id: json['id'] as String, + original: NotesSettings.fromJson(json['original']), + patch: Map<String, String>.from(json['patch'] as Map), ); } diff --git a/lib/src/nextcloud_notes/domain/notes_retrieval.dart b/lib/src/nextcloud_notes/domain/notes_retrieval.dart new file mode 100644 index 00000000..6f89eb21 --- /dev/null +++ b/lib/src/nextcloud_notes/domain/notes_retrieval.dart @@ -0,0 +1,82 @@ +import 'notes_models.dart'; + +/// A validator always belongs to a complete, cached server representation. +sealed class NotesGetResult { + const NotesGetResult(); +} + +final class NotesReturned extends NotesGetResult { + const NotesReturned(this.note); + final NoteState note; +} + +final class NotesNotModified extends NotesGetResult { + const NotesNotModified(this.cached); + final NoteState cached; +} + +/// Optional reads are deliberately separate from complete-account checkpoints. +class NotesRetrievalScope { + NotesRetrievalScope({this.category, Set<String> exclude = const {}}) + : exclude = Set.unmodifiable(exclude) { + if (exclude.any((field) => !fields.contains(field))) { + throw ArgumentError('Invalid excluded Notes field.'); + } + } + + static const fields = { + 'id', + 'etag', + 'content', + 'title', + 'category', + 'favorite', + 'readonly', + 'modified', + 'error', + }; + final String? category; + final Set<String> exclude; + Map<String, String> get query => { + if (category != null) 'category': category!, + if (exclude.isNotEmpty) 'exclude': (exclude.toList()..sort()).join(','), + }; +} + +/// Presence is retained; an omitted field has no value or fabricated default. +class NotesProjection { + NotesProjection.fromJson(Map<String, dynamic> json) + : values = Map.unmodifiable(json) { + if (json.containsKey('id') && + (json['id'] is! int || (json['id'] as int) <= 0)) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned an invalid note identifier.', + ); + } + for (final entry in json.entries) { + final valid = switch (entry.key) { + 'id' || 'modified' => entry.value is int, + 'etag' || 'content' || 'title' || 'category' => entry.value is String, + 'favorite' || 'readonly' || 'error' => entry.value is bool, + _ => true, + }; + if (!valid) { + throw const NotesException( + NotesFailureCode.invalidResponse, + 'Nextcloud returned an invalid projected note field.', + ); + } + } + } + final Map<String, dynamic> values; + int? get id => values['id'] as int?; + bool has(String field) => values.containsKey(field); +} + +class NotesQueryResult { + NotesQueryResult({required this.scope, required List<NotesProjection> notes}) + : notes = List.unmodifiable(notes); + final NotesRetrievalScope scope; + final List<NotesProjection> notes; +} diff --git a/lib/src/nextcloud_notes/domain/notes_search.dart b/lib/src/nextcloud_notes/domain/notes_search.dart new file mode 100644 index 00000000..e5b3c40b --- /dev/null +++ b/lib/src/nextcloud_notes/domain/notes_search.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; + +import 'package:characters/characters.dart'; +import 'package:crypto/crypto.dart'; +import 'package:unorm_dart/unorm_dart.dart' as unorm; + +String normalizeNotesSearch(String value) => unorm.nfc(value).toLowerCase(); + +class NotesSearchTerm { + const NotesSearchTerm(this.text, this.field); + final String text; + final String? field; + String get literal => normalizeNotesSearch(text); +} + +/// Only these two field prefixes and quotes are grammar. Everything else, +/// including SQL/FTS operators, punctuation and wildcards, is literal text. +class NotesSearchQuery { + NotesSearchQuery(this.text, {this.wholeWord = false}) { + if (text.length > 512) throw const FormatException('Search is too long.'); + if ('"'.allMatches(text).length.isOdd) { + throw const FormatException('Close the quoted phrase.'); + } + final tokens = RegExp(r'(title:|category:)?("[^"]*"|[^\s]+)'); + terms = [ + for (final token in tokens.allMatches(text)) + if (token.group(2)!.replaceAll('"', '').isNotEmpty) + NotesSearchTerm( + token.group(2)!.startsWith('"') + ? token.group(2)!.substring(1, token.group(2)!.length - 1) + : token.group(2)!, + token.group(1)?.replaceAll(':', ''), + ), + ]; + if (terms.length > 32) { + throw const FormatException('Too many search terms.'); + } + } + final String text; + final bool wholeWord; + late final List<NotesSearchTerm> terms; + String? get ftsCandidates { + final indexable = terms.where((t) => t.literal.runes.length >= 3); + if (indexable.isEmpty) return null; + return indexable + .map((t) { + final quoted = '"${t.literal.replaceAll('"', '""')}"'; + return t.field == null ? quoted : '${t.field}:$quoted'; + }) + .join(' AND '); + } +} + +class NotesSearchHit { + const NotesSearchHit({ + required this.localId, + required this.revision, + required this.digest, + required this.title, + required this.category, + required this.snippet, + required this.snippetStart, + this.start, + this.end, + this.snippetMatchStart, + this.snippetMatchEnd, + }); + final String localId; + final int revision; + final String digest; + final String title; + final String category; + final String snippet; + final int snippetStart; + final int? start; + final int? end; + final int? snippetMatchStart, snippetMatchEnd; + Map<String, Object?> toJson() => { + 'id': localId, + 'revision': revision, + 'digest': digest, + 'title': title, + 'category': category, + 'snippet': snippet, + 'snippetStart': snippetStart, + 'start': start, + 'end': end, + 'snippetMatchStart': snippetMatchStart, + 'snippetMatchEnd': snippetMatchEnd, + }; + factory NotesSearchHit.fromJson(Map<String, dynamic> json) => NotesSearchHit( + localId: json['id'] as String, + revision: json['revision'] as int, + digest: json['digest'] as String, + title: json['title'] as String, + category: json['category'] as String, + snippet: json['snippet'] as String, + snippetStart: json['snippetStart'] as int, + start: json['start'] as int?, + end: json['end'] as int?, + snippetMatchStart: json['snippetMatchStart'] as int?, + snippetMatchEnd: json['snippetMatchEnd'] as int?, + ); +} + +String notesSearchDigest(String source) => + sha256.convert(utf8.encode(source)).toString(); +final _word = RegExp(r'[\p{L}\p{M}\p{N}_]', unicode: true); + +Iterable<({int start, int end})> _ranges( + String normalized, + String term, + bool wholeWord, +) sync* { + var position = 0; + while (position <= normalized.length) { + final start = normalized.indexOf(term, position); + if (start < 0) break; + final end = start + term.length; + // Unicode identifier boundaries; punctuation in the query remains literal. + if (!wholeWord || + ((start == 0 || + !_word.hasMatch( + String.fromCharCode(normalized.runesBefore(start)), + )) && + (end == normalized.length || + !_word.hasMatch( + String.fromCharCode(normalized.runesAt(end)), + )))) { + yield (start: start, end: end); + } + position = end; + } +} + +extension on String { + int runesBefore(int offset) { + var start = offset - 1; + if (start > 0 && + codeUnitAt(start) >= 0xdc00 && + codeUnitAt(start) <= 0xdfff) { + start--; + } + return substring(start, offset).runes.first; + } + + int runesAt(int offset) => substring(offset).runes.first; +} + +/// Shared verifier for indexed durable documents, dirty overlays and stale +/// navigation. Offsets map back through grapheme clusters to authored UTF-16. +List<NotesSearchHit> matchNotesDocument({ + required NotesSearchQuery query, + required String localId, + required int revision, + required String title, + required String category, + required String source, + int limit = 80, + int afterStart = -1, + int afterEnd = -1, +}) { + if (query.terms.isEmpty || limit <= 0) return []; + final body = normalizeNotesSearch(source); + final normalizedTitle = normalizeNotesSearch(title); + final normalizedCategory = normalizeNotesSearch(category); + final iterators = <Iterator<({int start, int end})>>[]; + ({String text, int start, int end})? metadataMatch; + for (final term in query.terms) { + final contentMatches = + (term.field == null + ? _ranges(body, term.literal, query.wholeWord) + : const <({int start, int end})>[]) + .iterator; + final hasContent = contentMatches.moveNext(); + final titleMatches = term.field != 'category' + ? _ranges(normalizedTitle, term.literal, query.wholeWord) + : <({int start, int end})>[]; + final categoryMatches = term.field != 'title' + ? _ranges(normalizedCategory, term.literal, query.wholeWord) + : <({int start, int end})>[]; + if (!hasContent && titleMatches.isEmpty && categoryMatches.isEmpty) { + return []; + } + if (metadataMatch == null && titleMatches.isNotEmpty) { + metadataMatch = ( + text: title, + start: titleMatches.first.start, + end: titleMatches.first.end, + ); + } else if (metadataMatch == null && categoryMatches.isNotEmpty) { + metadataMatch = ( + text: category, + start: categoryMatches.first.start, + end: categoryMatches.first.end, + ); + } + if (hasContent) iterators.add(contentMatches); + } + final digest = notesSearchDigest(source); + if (iterators.isEmpty) { + if (afterStart >= 0) return []; + final match = metadataMatch!; + var normalizedOffset = 0, originalOffset = 0, from = 0, to = 0; + for (final cluster in match.text.characters) { + final length = normalizeNotesSearch(cluster).length; + if (match.start >= normalizedOffset && + match.start < normalizedOffset + length) { + from = originalOffset; + } + if (match.end > normalizedOffset && + match.end <= normalizedOffset + length) { + to = originalOffset + cluster.length; + } + normalizedOffset += length; + originalOffset += cluster.length; + } + return [ + NotesSearchHit( + localId: localId, + revision: revision, + digest: digest, + title: title, + category: category, + snippet: match.text, + snippetStart: 0, + snippetMatchStart: from, + snippetMatchEnd: to, + ), + ]; + } + // Merge lazy per-term streams. Bounds limit transferred hits, never the + // searchable occurrences. A range cursor also distinguishes overlapping terms. + final ranges = <({int start, int end})>[]; + final normalizedAfterStart = afterStart < 0 + ? -1 + : normalizeNotesSearch(source.substring(0, afterStart)).length; + final normalizedAfterEnd = afterEnd < 0 + ? -1 + : normalizeNotesSearch(source.substring(0, afterEnd)).length; + ({int start, int end})? previous; + while (iterators.isNotEmpty && ranges.length < limit) { + iterators.sort((a, b) { + final start = a.current.start.compareTo(b.current.start); + return start != 0 ? start : a.current.end.compareTo(b.current.end); + }); + final iterator = iterators.first; + final range = iterator.current; + if (range != previous && + (range.start > normalizedAfterStart || + range.start == normalizedAfterStart && + range.end > normalizedAfterEnd)) { + ranges.add(range); + } + previous = range; + if (!iterator.moveNext()) iterators.removeAt(0); + } + // Map only page boundaries through authored graphemes, including overlapping + // occurrences. No array proportional to document length is transferred. + final startBoundaries = ranges.map((r) => r.start).toSet().toList()..sort(); + final endBoundaries = ranges.map((r) => r.end).toSet().toList()..sort(); + final starts = <int, int>{}, ends = <int, int>{}; + var normalizedOffset = 0, originalOffset = 0, startIndex = 0, endIndex = 0; + for (final cluster in source.characters) { + final normalizedEnd = + normalizedOffset + normalizeNotesSearch(cluster).length; + while (startIndex < startBoundaries.length && + startBoundaries[startIndex] < normalizedEnd) { + starts[startBoundaries[startIndex++]] = originalOffset; + } + while (endIndex < endBoundaries.length && + endBoundaries[endIndex] <= normalizedEnd) { + ends[endBoundaries[endIndex++]] = originalOffset + cluster.length; + } + if (endIndex == endBoundaries.length) break; + normalizedOffset = normalizedEnd; + originalOffset += cluster.length; + } + final hits = <NotesSearchHit>[ + for (final range in ranges) + if (starts.containsKey(range.start) && ends.containsKey(range.end)) + _hit( + localId, + revision, + digest, + title, + category, + source, + starts[range.start]!, + ends[range.end]!, + ), + ]; + return hits; +} + +NotesSearchHit _hit( + String id, + int revision, + String digest, + String title, + String category, + String source, + int start, + int end, +) { + var snippetStart = (start - 45).clamp(0, source.length); + var snippetEnd = (end + 100).clamp(0, source.length); + // Snippets must not split surrogate pairs. + if (snippetStart > 0 && + source.codeUnitAt(snippetStart) >= 0xdc00 && + source.codeUnitAt(snippetStart) <= 0xdfff) { + snippetStart--; + } + if (snippetEnd < source.length && + source.codeUnitAt(snippetEnd) >= 0xdc00 && + source.codeUnitAt(snippetEnd) <= 0xdfff) { + snippetEnd++; + } + return NotesSearchHit( + localId: id, + revision: revision, + digest: digest, + title: title, + category: category, + snippet: source.substring(snippetStart, snippetEnd), + snippetStart: snippetStart, + start: start, + end: end, + ); +} diff --git a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart index 17070c05..a6c7822e 100644 --- a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart +++ b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart @@ -10,6 +10,8 @@ import '../../app/localization.dart'; import '../../workspace/workspace_controller.dart'; import '../../workspace/workspace_safety.dart'; import '../application/nextcloud_connection.dart'; +import '../application/notes_settings_controller.dart'; +import 'notes_sidebar.dart' show nextcloudNotesChangesProvider; class NextcloudNotesSettings extends ConsumerStatefulWidget { const NextcloudNotesSettings({super.key}); @@ -48,7 +50,13 @@ class _NextcloudNotesSettingsState Widget build(BuildContext context) { final connection = ref.watch(nextcloudConnectionProvider); final controller = ref.read(nextcloudConnectionProvider.notifier); - final account = connection.account; + ref.watch(nextcloudNotesChangesProvider); + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final account = connection.account == null + ? null + : repository == null + ? connection.account + : repository.accountById(connection.account!.id); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -115,6 +123,14 @@ class _NextcloudNotesSettingsState ), ], ), + if (account != null) ...[ + _ServerNotesSettings(accountId: account.id), + if (repository?.accountError(account.id) case final error?) + BusyMarkStatusBox( + message: error.message, + kind: BusyMarkStatusKind.error, + ), + ], if (connection.error != null) BusyMarkStatusBox( message: connection.error!, @@ -162,3 +178,141 @@ class _NextcloudNotesSettingsState } } } + +class _ServerNotesSettings extends ConsumerStatefulWidget { + const _ServerNotesSettings({required this.accountId}); + final String accountId; + @override + ConsumerState<_ServerNotesSettings> createState() => + _ServerNotesSettingsState(); +} + +class _ServerNotesSettingsState extends ConsumerState<_ServerNotesSettings> { + final _path = TextEditingController(); + final _suffix = TextEditingController(); + @override + void initState() { + super.initState(); + Future.microtask(() { + if (mounted) { + unawaited( + ref.read(notesSettingsProvider.notifier).load(widget.accountId), + ); + } + }); + } + + @override + void didUpdateWidget(_ServerNotesSettings oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.accountId != oldWidget.accountId) { + unawaited( + ref + .read(notesSettingsProvider.notifier) + .load(widget.accountId, discard: true), + ); + } + } + + @override + void dispose() { + _path.dispose(); + _suffix.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(notesSettingsProvider); + final controller = ref.read(notesSettingsProvider.notifier); + final draft = settings.draft; + if (draft != null) { + if (_path.text != draft.notesPath) _path.text = draft.notesPath; + if (_suffix.text != draft.fileSuffix) _suffix.text = draft.fileSuffix; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkGroupedList( + title: context.l10n.nextcloudServerSettings, + filled: true, + children: [ + BusyMarkGroupedTextEntry( + key: const ValueKey('notes-settings-path'), + label: context.l10n.nextcloudNotesPath, + controller: _path, + enabled: draft != null && !settings.busy, + onChanged: (value) => controller.edit(notesPath: value), + ), + BusyMarkGroupedTextEntry( + key: const ValueKey('notes-settings-suffix'), + label: context.l10n.nextcloudFileSuffix, + controller: _suffix, + enabled: draft != null && !settings.busy, + onChanged: (value) => controller.edit(fileSuffix: value), + ), + ], + ), + Text(context.l10n.nextcloudServerSettingsExplanation), + if (settings.busy) ...[ + Semantics( + liveRegion: true, + child: Text( + settings.phase == NotesSettingsPhase.loading + ? context.l10n.nextcloudSettingsLoading + : context.l10n.nextcloudSettingsSaving, + ), + ), + LinearProgressIndicator( + semanticsLabel: settings.phase == NotesSettingsPhase.loading + ? context.l10n.nextcloudSettingsLoading + : context.l10n.nextcloudSettingsSaving, + ), + ], + if (settings.error != null) + BusyMarkStatusBox( + message: settings.error!, + kind: BusyMarkStatusKind.error, + ), + if (settings.phase == NotesSettingsPhase.saved) + Text( + settings.normalized + ? context.l10n.nextcloudSettingsNormalized + : context.l10n.nextcloudSettingsSaved, + ), + if (settings.dirty) Text(context.l10n.closeUnsavedChangesTitle), + Row( + children: [ + BusyMarkPushButton.standard( + key: const ValueKey('notes-settings-save'), + onPressed: settings.dirty && !settings.busy + ? () => unawaited( + controller.save( + preserveBuffers: () async => + (await ref + .read(workspaceControllerProvider.notifier) + .saveAll()) + .succeeded, + ), + ) + : null, + child: Text(context.l10n.save), + ), + BusyMarkPushButton.standard( + onPressed: settings.dirty && !settings.busy + ? controller.cancel + : null, + child: Text(context.l10n.cancel), + ), + BusyMarkPushButton.standard( + onPressed: !settings.busy && !settings.dirty + ? () => unawaited(controller.load(widget.accountId)) + : null, + child: Text(context.l10n.visualizationRetry), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart index ec2db9b9..7cb6a4b4 100644 --- a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart +++ b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru/yaru.dart'; @@ -16,13 +17,19 @@ import '../../workspace/workspace_controller.dart'; import '../../workspace/workspace_message.dart'; import '../../workspace/workspace_safety.dart'; import '../application/nextcloud_connection.dart'; +import '../application/notes_navigation.dart'; +import 'notes_workspace_ui.dart'; import '../application/attachment_markdown.dart'; import '../domain/notes_models.dart'; import '../domain/notes_conflict.dart'; +import '../data/notes_attachment_references.dart'; -final nextcloudNotesChangesProvider = StreamProvider<void>((ref) async* { +final nextcloudNotesChangesProvider = StreamProvider<int>((ref) async* { final repository = await ref.watch(nextcloudNotesRepositoryProvider.future); - yield* repository.changes; + var generation = 0; + await for (final _ in repository.changes) { + yield ++generation; + } }); String nextcloudSyncLabel( @@ -40,9 +47,26 @@ String nextcloudSyncLabel( NoteSyncState.deletedRemotely => context.l10n.localHistoryDeleted, NoteSyncState.forbidden => context.l10n.nextcloudReadOnly, NoteSyncState.storageFull || - NoteSyncState.unavailable => context.l10n.warning, + NoteSyncState.unavailable || + NoteSyncState.rejected || + NoteSyncState.throttled || + NoteSyncState.recoveryRequired => context.l10n.warning, }; +// Legacy response transitions may have left a transient/synchronized state +// alongside durable review evidence. That evidence still takes precedence. +bool _metadataNeedsReview(NextcloudNote note) => + note.metadataConflict != null && + { + NoteSyncState.synced, + NoteSyncState.pending, + NoteSyncState.syncing, + NoteSyncState.offline, + NoteSyncState.locked, + NoteSyncState.throttled, + NoteSyncState.conflict, + }.contains(note.syncState); + class NextcloudNotesSidebar extends ConsumerStatefulWidget { const NextcloudNotesSidebar({super.key, required this.accountId}); final String accountId; @@ -52,185 +76,659 @@ class NextcloudNotesSidebar extends ConsumerStatefulWidget { } class _NextcloudNotesSidebarState extends ConsumerState<NextcloudNotesSidebar> { - String _query = ''; - bool _favorites = false; - String? _category; + final _navigation = NotesNavigationController(); + final _listFocus = FocusNode(); + final _listScroll = ScrollController(); + StreamSubscription<void>? _notesSubscription; + bool _ready = false; + + @override + void initState() { + super.initState(); + unawaited(_initializeNavigation()); + } + + @override + void didUpdateWidget(covariant NextcloudNotesSidebar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.accountId != widget.accountId) { + unawaited(_notesSubscription?.cancel()); + _ready = false; + unawaited(_initializeNavigation()); + } + } + + Future<void> _initializeNavigation() async { + final accountId = widget.accountId; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted || accountId != widget.accountId) return; + _navigation.update(repository.notes, widget.accountId); + _notesSubscription = repository.changes.listen((_) { + if (mounted) { + setState(() => _navigation.update(repository.notes, widget.accountId)); + } + }); + setState(() => _ready = true); + } + + void _refreshNavigation() { + final repository = ref.read(nextcloudNotesRepositoryProvider).value; + if (repository != null) { + _navigation.update(repository.notes, widget.accountId); + } + } + + @override + void dispose() { + unawaited(_notesSubscription?.cancel()); + _listFocus.dispose(); + _listScroll.dispose(); + super.dispose(); + } + + KeyEventResult _listKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final keyboard = HardwareKeyboard.instance; + if (keyboard.isControlPressed && + event.logicalKey == LogicalKeyboardKey.keyA) { + setState(_navigation.selectAll); + return KeyEventResult.handled; + } + final visible = _navigation.visible; + if (visible.isEmpty) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + final index = visible.indexWhere((n) => n.localId == _navigation.focused); + final next = + (index + (event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1)) + .clamp(0, visible.length - 1); + setState( + () => _navigation.select( + visible[next].localId, + range: keyboard.isShiftPressed, + toggle: keyboard.isControlPressed, + ), + ); + if (_listScroll.hasClients) { + unawaited( + _listScroll.animateTo( + (next * 100.0).clamp(0, _listScroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ), + ); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.space && + _navigation.focused != null) { + setState(() => _navigation.select(_navigation.focused!, toggle: true)); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter && + _navigation.focused != null) { + final note = visible.firstWhere((n) => n.localId == _navigation.focused); + unawaited( + isNotesRecovery(note) + ? showNotesRecovery(context, ref, note) + : ref + .read(workspaceControllerProvider.notifier) + .openNextcloudNote(note.localId), + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } @override Widget build(BuildContext context) { - ref.watch(nextcloudNotesChangesProvider); - final repository = ref.watch(nextcloudNotesRepositoryProvider); - final active = ref - .watch(workspaceControllerProvider) - .activeBuffer - ?.remoteNote - ?.localId; - return repository.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: Text(error.toString()), + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final active = ref.watch( + workspaceControllerProvider.select( + (s) => s.activeBuffer?.remoteNote?.localId, ), - data: (repository) { - final all = repository.notes - .where( - (n) => - n.accountId == widget.accountId && - !(n.syncState == NoteSyncState.deletedRemotely && - !n.hasPendingChanges), - ) - .toList(); - final categories = <String>{}; - for (final note in all) { - final pieces = note.category.split('/'); - for (var i = 1; i <= pieces.length; i++) { - final category = pieces.take(i).join('/'); - if (category.isNotEmpty) categories.add(category); - } - } - // A move/delete can remove the selected category, including the last - // category that made the filter control visible. - if (!categories.contains(_category)) _category = null; - final sortedCategories = categories.toList()..sort(); - final query = _query.toLowerCase(); - final visible = - all - .where( - (n) => - (!_favorites || n.favorite) && - (_category == null || - n.category == _category || - n.category.startsWith('$_category/')) && - (query.isEmpty || - n.title.toLowerCase().contains(query) || - n.content.toLowerCase().contains(query) || - n.category.toLowerCase().contains(query)), - ) - .toList() - ..sort((a, b) => b.modified.compareTo(a.modified)); - final accountError = repository.accountError(widget.accountId); - return Column( - children: [ - if (accountError != null) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: BusyMarkStatusBox( - message: accountError.message, - kind: BusyMarkStatusKind.warning, + ); + final offline = ref.watch(notesOfflineProvider(widget.accountId)).value; + ref.watch(notesOfflineChangesProvider(widget.accountId)); + if (repository == null || !_ready) { + return const Center(child: CircularProgressIndicator()); + } + final categories = _navigation.categoryPaths; + final categoryRows = categories + .where( + (category) => !_navigation.collapsed.any( + (parent) => category.startsWith('$parent/'), + ), + ) + .toList(); + final visible = _navigation.visible; + final accountError = repository.accountError(widget.accountId); + String destinationLabel(NotesDestination destination) => + switch (destination) { + NotesDestination.all => context.l10n.notesAll, + NotesDestination.favorites => context.l10n.nextcloudFavorites, + NotesDestination.uncategorized => context.l10n.notesUncategorized, + NotesDestination.recovery => context.l10n.notesRecovery, + NotesDestination.category => _navigation.category, + }; + return Column( + children: [ + if (accountError != null) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: BusyMarkStatusBox( + message: accountError.message, + kind: BusyMarkStatusKind.warning, + ), + ), + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Row( + children: [ + Expanded( + child: BusyMarkPushButton.standard( + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .createNextcloudNote( + title: context.l10n.nextcloudNewNote, + category: + _navigation.destination == + NotesDestination.category + ? _navigation.category + : '', + ), + ), + child: Text(context.l10n.nextcloudNewNote), ), ), - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: Row( - children: [ - Expanded( - child: BusyMarkPushButton.standard( - onPressed: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .createNextcloudNote( - title: context.l10n.nextcloudNewNote, - ), + IconButton( + tooltip: context.l10n.tocSynchronize, + icon: const Icon(BusyMarkGlyphs.refresh), + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .refreshNextcloudNotes(), + ), + ), + PopupMenuButton<String>( + tooltip: context.l10n.mainMenu, + onSelected: (action) { + if (action == 'export') { + unawaited( + showNotesExport( + context, + ref, + repository.notes + .where( + (n) => + n.accountId == widget.accountId && + !isNotesRecovery(n) && + (_navigation.destination != + NotesDestination.category || + categoryIncludes( + _navigation.category, + n.category, + )), + ) + .map((n) => n.localId) + .toList(), + ), + ); + } + if (action == 'import') { + unawaited(showNotesImport(context, ref)); + } + if (action == 'file') { + unawaited(showNotesImport(context, ref, folder: false)); + } + if (action == 'offline' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + ), + ); + } + if (action == 'remove' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + required: false, ), - child: Text(context.l10n.nextcloudNewNote), + ); + } + if (action == 'cancel' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + paused: true, + ), + ); + } + if (action == 'retry' && offline != null) { + unawaited( + offline.setRequirement( + 'category', + _navigation.destination == NotesDestination.category + ? _navigation.category + : '', + ), + ); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'export', + child: Text(context.l10n.notesExport), + ), + PopupMenuItem( + value: 'import', + child: Text( + '${context.l10n.notesImport} · ${context.l10n.folder}', ), ), - IconButton( - tooltip: context.l10n.tocSynchronize, - icon: const Icon(BusyMarkGlyphs.refresh), - onPressed: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .refreshNextcloudNotes(), + PopupMenuItem( + value: 'file', + child: Text( + '${context.l10n.notesImport} · ${context.l10n.fileTypeMarkdown}', ), ), + PopupMenuItem( + value: 'offline', + child: Text(context.l10n.notesOffline), + ), + PopupMenuItem( + value: 'remove', + child: Text(context.l10n.notesOfflineRemove), + ), + PopupMenuItem( + value: 'cancel', + child: Text(context.l10n.cancel), + ), + PopupMenuItem( + value: 'retry', + child: Text(context.l10n.visualizationRetry), + ), ], ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), + child: TextField( + readOnly: true, + decoration: InputDecoration( + hintText: context.l10n.search, + prefixIcon: const Icon(BusyMarkGlyphs.search), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - ), - child: TextField( - decoration: InputDecoration( - hintText: context.l10n.search, - prefixIcon: const Icon(BusyMarkGlyphs.search), - ), - onChanged: (value) => setState(() => _query = value), + onTap: () => unawaited( + showNotesSearch( + context, + ref, + account: widget.accountId, + recovery: _navigation.destination == NotesDestination.recovery, ), ), - CheckboxListTile( - dense: true, - title: Text(context.l10n.nextcloudFavorites), - value: _favorites, - onChanged: (value) => setState(() => _favorites = value ?? false), - ), - if (categories.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, + ), + ), + SizedBox( + height: ((categoryRows.length + 4) * 36.0).clamp(0, 240), + child: ListView.builder( + itemExtent: 36, + itemCount: categoryRows.length + 4, + itemBuilder: (context, index) { + if (index < 4) { + final destination = [ + NotesDestination.all, + NotesDestination.favorites, + NotesDestination.uncategorized, + NotesDestination.recovery, + ][index]; + return ListTile( + dense: true, + minTileHeight: 36, + minVerticalPadding: 0, + selected: _navigation.destination == destination, + title: Text(destinationLabel(destination)), + trailing: Text('${_navigation.counts[destination] ?? 0}'), + onTap: () => setState(() { + _navigation.destination = destination; + _refreshNavigation(); + }), + ); + } + final category = categoryRows[index - 4]; + return ListTile( + dense: true, + minTileHeight: 36, + minVerticalPadding: 0, + contentPadding: EdgeInsets.only( + left: 8 + 12.0 * (category.split('/').length - 1), + right: 12, ), - child: DropdownButton<String>( - isExpanded: true, - value: categories.contains(_category) ? _category : null, - hint: Text(context.l10n.syntaxReferenceCategory), - items: [ - DropdownMenuItem( - value: '', - child: Text(context.l10n.syntaxReferenceCategory), - ), - for (final category in sortedCategories) - DropdownMenuItem(value: category, child: Text(category)), - ], - onChanged: (value) => - setState(() => _category = value == '' ? null : value), + selected: + _navigation.destination == NotesDestination.category && + _navigation.category == category, + leading: IconButton( + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + padding: EdgeInsets.zero, + tooltip: category, + icon: Icon( + _navigation.collapsed.contains(category) + ? BusyMarkGlyphs.collapsedTreeArrowFor( + Directionality.of(context), + ) + : BusyMarkGlyphs.downArrow, + ), + onPressed: () => setState(() { + if (!_navigation.collapsed.remove(category)) { + _navigation.collapsed.add(category); + } + }), + ), + title: Text( + category.split('/').last, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + trailing: Text('${_navigation.categoryCounts[category]}'), + onTap: () => setState(() { + _navigation.destination = NotesDestination.category; + _navigation.category = category; + _refreshNavigation(); + }), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DropdownButton<NotesSort>( + isExpanded: true, + value: _navigation.sort, + items: [ + DropdownMenuItem( + value: NotesSort.recent, + child: Text(context.l10n.notesRecentlyEdited), + ), + DropdownMenuItem( + value: NotesSort.oldest, + child: Text(context.l10n.notesOldestEdited), ), - Expanded( - child: visible.isEmpty - ? Center(child: Text(context.l10n.noResults)) - : ListView.builder( - itemCount: visible.length, - itemBuilder: (context, index) { - final note = visible[index]; - return ListTile( + DropdownMenuItem( + value: NotesSort.titleAscending, + child: Text(context.l10n.notesTitleAscending), + ), + DropdownMenuItem( + value: NotesSort.titleDescending, + child: Text(context.l10n.notesTitleDescending), + ), + ], + onChanged: (v) => setState(() { + _navigation.sort = v!; + _refreshNavigation(); + }), + ), + ), + if (_navigation.selected.isNotEmpty && + _navigation.destination != NotesDestination.recovery) + TextButton( + onPressed: () => unawaited( + showNotesBatch( + context, + ref, + _navigation.visible + .where((n) => _navigation.selected.contains(n.localId)) + .map((n) => n.localId) + .toList(), + ), + ), + child: Text( + '${context.l10n.notesSelected} (${_navigation.selected.length})', + ), + ), + if (_navigation.destination == NotesDestination.recovery) + Padding( + padding: const EdgeInsets.all(8), + child: Text( + context.l10n.notesRecoveryHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Expanded( + child: Focus( + focusNode: _listFocus, + onKeyEvent: _listKey, + child: visible.isEmpty + ? Center(child: Text(context.l10n.noResults)) + : ListView.builder( + controller: _listScroll, + itemExtent: 100, + itemCount: visible.length, + itemBuilder: (context, index) { + final note = visible[index]; + final retained = offline?.statuses[note.localId]; + return GestureDetector( + onSecondaryTapDown: (details) => unawaited( + _contextMenu(note, details.globalPosition), + ), + child: ListTile( key: ValueKey('nextcloud-note-${note.localId}'), dense: true, - selected: active == note.localId, - title: Text(note.title), + selected: _navigation.selected.contains(note.localId), + shape: _navigation.focused == note.localId + ? Border( + left: BorderSide( + color: Theme.of( + context, + ).colorScheme.primary, + width: 2, + ), + ) + : null, + leading: Checkbox( + value: _navigation.selected.contains(note.localId), + semanticLabel: note.title, + onChanged: (_) => setState( + () => _navigation.select( + note.localId, + toggle: true, + ), + ), + ), + title: Text( + note.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), subtitle: Text( [ note.category, - nextcloudSyncLabel(context, note.syncState), + MaterialLocalizations.of(context).formatShortDate( + DateTime.fromMicrosecondsSinceEpoch( + note.activityMicros, + ), + ), + if (_metadataNeedsReview(note)) + context.l10n.gitConflicts + else if (note.syncState != NoteSyncState.synced) + nextcloudSyncLabel(context, note.syncState), + if (note.content.contains('busymark-attachment:')) + context.l10n.nextcloudPendingUpload, + if (retained != null) + '${retained.available ? context.l10n.notesAvailableOffline : context.l10n.notesIncomplete} ${retained.availableCount}/${retained.requiredCount}', ].where((s) => s.isNotEmpty).join(' · '), + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - leading: Icon( - note.favorite - ? YaruIcons.star_filled - : BusyMarkGlyphs.markdownFile, - ), - onTap: () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .openNextcloudNote(note.localId), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (active == note.localId) + Icon( + BusyMarkGlyphs.markdownFile, + size: 14, + semanticLabel: context.l10n.open, + ), + IconButton( + tooltip: note.favorite + ? context.l10n.notesRemoveFavorite + : context.l10n.nextcloudFavorites, + icon: Icon( + note.favorite + ? YaruIcons.star_filled + : YaruIcons.star, + size: 16, + ), + onPressed: () => unawaited( + ref + .read( + workspaceControllerProvider.notifier, + ) + .updateNextcloudNoteMetadata( + note.localId, + snapshot: repository.metadataSnapshot( + note.localId, + ), + favorite: !note.favorite, + ), + ), + ), + IconButton( + tooltip: context.l10n.mainMenu, + icon: const Icon( + BusyMarkGlyphs.menuVertical, + size: 16, + ), + onPressed: () => + unawaited(_contextMenu(note, null)), + ), + ], ), + onTap: () { + _listFocus.requestFocus(); + final keyboard = HardwareKeyboard.instance; + setState( + () => _navigation.select( + note.localId, + toggle: keyboard.isControlPressed, + range: keyboard.isShiftPressed, + ), + ); + if (!keyboard.isControlPressed && + !keyboard.isShiftPressed) { + unawaited( + isNotesRecovery(note) + ? showNotesRecovery(context, ref, note) + : ref + .read( + workspaceControllerProvider + .notifier, + ) + .openNextcloudNote(note.localId), + ); + } + }, onLongPress: () => unawaited(_editNote(note)), - trailing: IconButton( - tooltip: context.l10n.rename, - icon: const Icon(BusyMarkGlyphs.menuVertical), - onPressed: () => unawaited(_editNote(note)), - ), - ); - }, - ), - ), - ], + ), + ); + }, + ), + ), + ), + ], + ); + } + + Future<void> _contextMenu(NextcloudNote note, Offset? position) async { + final targets = _navigation.contextTargets(note.localId); + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final reviewed = [ + for (final id in targets) + if (repository.noteById(id) != null) repository.metadataSnapshot(id), + ]; + if (!mounted) return; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final point = + position ?? + overlay.localToGlobal( + Offset(overlay.size.width / 2, overlay.size.height / 2), ); - }, + final action = await showMenu<String>( + context: context, + position: RelativeRect.fromRect( + Rect.fromLTWH(point.dx, point.dy, 1, 1), + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + enabled: false, + child: Text( + targets.length > 1 + ? '${context.l10n.notesSelected} (${targets.length})' + : note.title, + ), + ), + if (!isNotesRecovery(note)) ...[ + PopupMenuItem( + value: 'favorite', + child: Text(context.l10n.nextcloudFavorites), + ), + PopupMenuItem( + value: 'unfavorite', + child: Text(context.l10n.notesRemoveFavorite), + ), + PopupMenuItem(value: 'move', child: Text(context.l10n.notesMove)), + ], + if (targets.length == 1) ...[ + PopupMenuItem(value: 'properties', child: Text(context.l10n.rename)), + PopupMenuItem( + value: 'offline', + child: Text(context.l10n.notesOffline), + ), + if (isNotesRecovery(note)) + PopupMenuItem( + value: 'recovery', + child: Text(context.l10n.notesRecovery), + ), + ], + ], ); + if (!mounted || action == null) return; + if ({'favorite', 'unfavorite', 'move'}.contains(action)) { + await showNotesBatch( + context, + ref, + targets, + initialAction: action, + reviewedTargets: reviewed, + ); + } + if (action == 'properties') await _editNote(note); + if (!mounted) return; + if (action == 'offline') await showNotesOffline(context, ref, note); + if (!mounted) return; + if (action == 'recovery') await showNotesRecovery(context, ref, note); } Future<void> _editNote(NextcloudNote note) async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted || repository.noteById(note.localId) == null) return; + final snapshot = repository.metadataSnapshot(note.localId); + note = snapshot.note; var title = note.title; var category = note.category; var favorite = note.favorite; @@ -303,9 +801,12 @@ class _NextcloudNotesSidebarState extends ConsumerState<NextcloudNotesSidebar> { } else if (action == 'save') { await controller.updateNextcloudNoteMetadata( note.localId, - title: note.readonly ? null : title, - category: note.readonly ? null : category, - favorite: favorite, + snapshot: snapshot, + title: note.readonly || title == snapshot.note.title ? null : title, + category: note.readonly || category == snapshot.note.category + ? null + : category, + favorite: favorite == snapshot.note.favorite ? null : favorite, ); } else if (action == 'delete') { final confirmed = await showBusyMarkModalDialog<bool>( @@ -342,10 +843,8 @@ class NextcloudNoteStatus extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { ref.watch(nextcloudNotesChangesProvider); - final note = ref - .watch(nextcloudNotesRepositoryProvider) - .value - ?.noteById(localId); + final repository = ref.watch(nextcloudNotesRepositoryProvider).value; + final note = repository?.noteById(localId); if (note == null) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.symmetric( @@ -361,6 +860,14 @@ class NextcloudNoteStatus extends ConsumerWidget { child: Text( unsaved ? context.l10n.closeUnsavedChangesTitle + : _metadataNeedsReview(note) + ? context.l10n.gitConflicts + : repository!.isSynchronizing(note.accountId) + ? context.l10n.nextcloudSyncing + : notesAttachmentReferences(note.content).any( + (r) => r.reference.startsWith('busymark-attachment:'), + ) + ? context.l10n.nextcloudPendingUpload : nextcloudSyncLabel(context, note.syncState), ), ), @@ -373,9 +880,11 @@ class NextcloudNoteStatus extends ConsumerWidget { addNextcloudFileAttachment(context, ref, note.localId), ), ), - if (note.syncState == NoteSyncState.conflict || + if (note.metadataConflict != null || + note.syncState == NoteSyncState.conflict || note.syncState == NoteSyncState.deletedRemotely || note.syncState == NoteSyncState.creationUncertain || + note.syncState == NoteSyncState.recoveryRequired || ((note.syncState == NoteSyncState.forbidden || note.syncState == NoteSyncState.unavailable) && note.hasPendingChanges)) @@ -394,8 +903,25 @@ class NextcloudNoteStatus extends ConsumerWidget { ), child: Text(context.l10n.visualizationRetry), ), + if (note.syncState == NoteSyncState.rejected || + note.syncState == NoteSyncState.storageFull) + BusyMarkPushButton.standard( + onPressed: () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .retryRejectedNextcloudNote(localId), + ), + child: Text(context.l10n.visualizationRetry), + ), ], ), + if (repository?.accountById(note.accountId)?.lastServerCheck + case final checked?) + Text( + context.l10n.nextcloudServerCheck( + '${MaterialLocalizations.of(context).formatShortDate(checked.toLocal())} ${MaterialLocalizations.of(context).formatTimeOfDay(TimeOfDay.fromDateTime(checked.toLocal()))}', + ), + ), if (note.errorMessage != null) Text( note.errorMessage!, @@ -495,8 +1021,9 @@ Future<void> showNextcloudConflict( final current = repository.noteById(note.localId); if (current == null || !context.mounted) return; note = current; + final localMetadata = note.metadataConflict != null; final remote = note.remote; - final creationCandidates = note.serverId == null + final creationCandidates = note.serverId == null && !localMetadata ? repository.uncertainCreationCandidates(note.localId) : const <NextcloudNote>[]; var creationCandidateServerId = remote?.id; @@ -509,17 +1036,23 @@ Future<void> showNextcloudConflict( .firstOrNull; } - final canOverwriteRemote = - note.serverId != null && - remote != null && - !remote.readonly && - !remote.error; + final canOverwriteRemote = localMetadata + ? !note.readonly && !note.error + : note.serverId != null && + remote != null && + !remote.readonly && + !remote.error; final deletedRemotely = note.syncState == NoteSyncState.deletedRemotely && remote == null; - final merge = remote == null ? null : NotesConflictMerge(note, remote); + final merge = localMetadata + ? NotesConflictMerge.metadata(note) + : remote == null + ? null + : NotesConflictMerge(note, remote); final canMergeRemote = canOverwriteRemote || - (note.serverId != null && + (!localMetadata && + note.serverId != null && remote != null && remote.readonly && !remote.error && @@ -540,15 +1073,21 @@ Future<void> showNextcloudConflict( context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) { - final displayedRemote = note.serverId == null + final displayedRemote = localMetadata + ? null + : note.serverId == null ? selectedCreationRemote() : remote; final comparison = compareSource( SourceComparisonInput( id: 'remote:${note.localId}', version: displayedRemote?.modified ?? 0, - label: context.l10n.nextcloudTakeRemote, - source: displayedRemote?.content ?? '', + label: localMetadata + ? context.l10n.nextcloudPreviousLocalEdit + : context.l10n.nextcloudTakeRemote, + source: localMetadata + ? note.content + : displayedRemote?.content ?? '', ), SourceComparisonInput( id: note.localId, @@ -566,12 +1105,22 @@ Future<void> showNextcloudConflict( onPressed: () => Navigator.pop(context), ), BusyMarkDialogButton( - label: note.serverId == null + label: note.serverId == null && !localMetadata ? context.l10n.nextcloudCreateSeparate : context.l10n.nextcloudNewNote, onPressed: () => Navigator.pop(context, NoteConflictResolution.saveAsNew), ), + if (localMetadata) + BusyMarkDialogButton( + label: context.l10n.nextcloudUsePreviousEdit, + onPressed: canOverwriteRemote + ? () => Navigator.pop( + context, + NoteConflictResolution.takeRemote, + ) + : null, + ), if (displayedRemote != null && !displayedRemote.error && (note.serverId != null || note.creationAttempt != null)) @@ -681,7 +1230,7 @@ Future<void> showNextcloudConflict( DropdownMenuItem( value: NotesMergeChoice.remote, child: Text( - '${context.l10n.nextcloudTakeRemote}: ${entry.value.remote}', + '${localMetadata ? context.l10n.nextcloudPreviousLocalEdit : context.l10n.nextcloudTakeRemote}: ${entry.value.remote}', ), ), ], @@ -725,7 +1274,9 @@ Future<void> showNextcloudConflict( ); if (confirmed != true) return; } - if (note.serverId == null && resolution == NoteConflictResolution.saveAsNew) { + if (note.serverId == null && + !localMetadata && + resolution == NoteConflictResolution.saveAsNew) { if (!context.mounted) return; final confirmed = await showBusyMarkModalDialog<bool>( context, diff --git a/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart new file mode 100644 index 00000000..83053aea --- /dev/null +++ b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart @@ -0,0 +1,1143 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_settings.dart'; +import '../../app/busymark_design.dart'; +import '../../app/busymark_dialogs.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../local_history/local_history_controller.dart'; +import '../../local_history/local_history_models.dart'; +import '../../workspace/workspace_controller.dart'; +import '../application/nextcloud_connection.dart'; +import '../application/notes_navigation.dart'; +import '../application/notes_repository.dart'; +import '../application/notes_offline_controller.dart'; +import '../application/notes_search_controller.dart'; +import '../application/notes_transfer_service.dart'; +import '../domain/notes_models.dart'; +import '../domain/notes_search.dart'; + +final notesOfflineProvider = FutureProvider.autoDispose + .family<NotesOfflineController, String>((ref, accountId) async { + final repository = await ref.watch( + nextcloudNotesRepositoryProvider.future, + ); + final controller = NotesOfflineController(repository, accountId); + ref.onDispose(() => unawaited(controller.dispose())); + await controller.initialize(); + return controller; + }); +final notesOfflineChangesProvider = StreamProvider.autoDispose + .family<int, String>((ref, accountId) async* { + final controller = await ref.watch( + notesOfflineProvider(accountId).future, + ); + var generation = 0; + await for (final _ in controller.changes) { + yield ++generation; + } + }); + +class NotesMatchNavigation { + const NotesMatchNavigation( + this.localId, + this.start, + this.end, + this.digest, + this.request, + ); + final String localId, digest; + final int start, end, request; +} + +final notesMatchNavigationProvider = + NotifierProvider<NotesMatchNavigationController, NotesMatchNavigation?>( + NotesMatchNavigationController.new, + ); + +class NotesMatchNavigationController extends Notifier<NotesMatchNavigation?> { + @override + NotesMatchNavigation? build() => null; + void navigate(String id, int start, int end, String digest) { + state = NotesMatchNavigation( + id, + start, + end, + digest, + (state?.request ?? 0) + 1, + ); + } +} + +Future<void> showNotesSearch( + BuildContext context, + WidgetRef ref, { + String query = '', + String? account, + bool recovery = false, +}) async { + final accountId = + account ?? + ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId; + if (accountId == null) return; + final focus = FocusManager.instance.primaryFocus; + final choice = + await showBusyMarkModalDialog< + ({NotesSearchHit hit, String query, bool wholeWord, bool source}) + >( + context, + builder: (_) => NotesSearchDialog( + accountId: accountId, + initialQuery: query, + recovery: recovery, + ), + ); + if (!context.mounted) return; + if (choice == null) { + if (focus?.context?.mounted == true) focus!.requestFocus(); + return; + } + if (ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != + accountId) { + return; + } + if (recovery) { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repository.noteById(choice.hit.localId); + if (context.mounted && + note != null && + note.accountId == accountId && + isNotesRecovery(note)) { + await showNotesRecovery(context, ref, note); + } + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + if (!await controller.openNextcloudNote(choice.hit.localId) || + !context.mounted) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer?.remoteNote?.localId != choice.hit.localId) return; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repository.noteById(choice.hit.localId); + if (note == null) return; + var hit = choice.hit; + // Even a revision collision cannot apply offsets to a different document. + if (buffer!.revision != hit.revision || + notesSearchDigest(buffer.text) != hit.digest) { + final refreshed = await relocateNotesSearchHit( + NotesSearchQuery(choice.query, wholeWord: choice.wholeWord), + NotesSearchOverlay( + note.localId, + buffer.revision, + note.title, + note.category, + buffer.text, + ), + hit.start ?? 0, + ); + if (refreshed == null || !context.mounted) return; + final current = ref.read(workspaceControllerProvider).activeBuffer; + if (current?.remoteNote?.localId != note.localId || + current!.revision != refreshed.revision || + notesSearchDigest(current.text) != refreshed.digest) { + return; + } + hit = refreshed; + } + if (hit.start == null || hit.end == null) return; + if (choice.source) { + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + } + ref + .read(notesMatchNavigationProvider.notifier) + .navigate(hit.localId, hit.start!, hit.end!, hit.digest); +} + +class NotesSearchDialog extends ConsumerStatefulWidget { + const NotesSearchDialog({ + super.key, + required this.accountId, + this.initialQuery = '', + this.recovery = false, + }); + final String accountId, initialQuery; + final bool recovery; + @override + ConsumerState<NotesSearchDialog> createState() => _NotesSearchDialogState(); +} + +class _NotesSearchDialogState extends ConsumerState<NotesSearchDialog> { + NotesSearchController? _controller; + StreamSubscription<NotesSearchState>? _results; + StreamSubscription<void>? _repositoryChanges; + Timer? _debounce; + late final _text = TextEditingController(text: widget.initialQuery); + late final _focus = FocusNode(onKeyEvent: _key); + final _scroll = ScrollController(); + NotesSearchState _state = const NotesSearchState(loading: true); + bool _wholeWord = false; + int _selected = 0, _limit = 80; + @override + void initState() { + super.initState(); + unawaited(_initialize()); + } + + Future<void> _initialize() async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!mounted) return; + _controller = NotesSearchController( + repository.store, + widget.accountId, + recovery: widget.recovery, + ); + _results = _controller!.changes.listen((state) { + if (mounted) { + setState(() { + _state = state; + _selected = 0; + }); + } + }); + _repositoryChanges = repository.changes.listen((_) => _schedule()); + _search(); + } + + void _schedule() { + _controller?.cancel(); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 150), _search); + } + + void _search() { + final repository = ref.read(nextcloudNotesRepositoryProvider).value; + if (repository == null || _controller == null) return; + final overlays = [ + for (final buffer + in ref.read(workspaceControllerProvider).documentBuffers) + if (!widget.recovery && + buffer.isDirty && + buffer.remoteNote?.accountId == widget.accountId && + repository.noteById(buffer.remoteNote!.localId) != null) + NotesSearchOverlay( + buffer.remoteNote!.localId, + buffer.revision, + repository.noteById(buffer.remoteNote!.localId)!.title, + repository.noteById(buffer.remoteNote!.localId)!.category, + buffer.text, + ), + ]; + unawaited( + _controller!.search( + _text.text, + wholeWord: _wholeWord, + limit: _limit, + overlays: overlays, + ), + ); + } + + KeyEventResult _key(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(context); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + _open(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp || + event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_state.hits.isNotEmpty) { + setState( + () => _selected = + (_selected + + (event.logicalKey == LogicalKeyboardKey.arrowDown + ? 1 + : -1)) + .clamp(0, _state.hits.length - 1), + ); + if (_scroll.hasClients) { + _scroll.animateTo( + (_selected * 90.0).clamp(0, _scroll.position.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _open({bool source = false, int? index}) { + if (_state.loading || _state.hits.isEmpty) return; + Navigator.pop(context, ( + hit: _state.hits[index ?? _selected], + query: _text.text, + wholeWord: _wholeWord, + source: source, + )); + } + + @override + void dispose() { + _debounce?.cancel(); + unawaited(_results?.cancel()); + unawaited(_repositoryChanges?.cancel()); + unawaited(_controller?.dispose()); + _text.dispose(); + _focus.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + workspaceControllerProvider.select((s) => s.documentBuffers), + (a, b) => _schedule(), + ); + return BusyMarkDialogShell( + title: widget.recovery + ? '${context.l10n.notesRecovery} · ${context.l10n.search}' + : context.l10n.search, + maxWidth: 760, + children: [ + TextField( + autofocus: true, + focusNode: _focus, + controller: _text, + decoration: InputDecoration(labelText: context.l10n.search), + onChanged: (_) { + _limit = 80; + _schedule(); + }, + ), + if (widget.recovery) Text(context.l10n.notesRecoveryHelp), + Text( + context.l10n.notesSearchHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + CheckboxListTile( + dense: true, + title: Text(context.l10n.sourceSearchWholeWord), + value: _wholeWord, + onChanged: (v) => setState(() { + _wholeWord = v ?? false; + _schedule(); + }), + ), + if (_state.loading) const LinearProgressIndicator(), + if (_state.indexing) Text(context.l10n.notesIndexing), + if (_state.error != null) + Text('${context.l10n.warning}: ${_state.error}'), + SizedBox( + height: 360, + child: _state.hits.isEmpty + ? Center( + child: Text( + _state.loading + ? context.l10n.notesIndexing + : context.l10n.noResults, + ), + ) + : ListView.builder( + key: const ValueKey('notes-search-results'), + controller: _scroll, + itemCount: _state.hits.length, + itemBuilder: (context, index) { + final hit = _state.hits[index]; + final from = + hit.snippetMatchStart ?? + (hit.start == null + ? 0 + : (hit.start! - hit.snippetStart).clamp( + 0, + hit.snippet.length, + )); + final to = + hit.snippetMatchEnd ?? + (hit.end == null + ? 0 + : (hit.end! - hit.snippetStart).clamp( + from, + hit.snippet.length, + )); + return ListTile( + key: ValueKey(( + 'notes-search-hit', + hit.localId, + hit.start, + hit.end, + )), + selected: index == _selected, + title: Text( + hit.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hit.category.isNotEmpty) + Text( + hit.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: hit.snippet + .substring(0, from) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + ), + TextSpan( + text: hit.snippet + .substring(from, to) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + style: TextStyle( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: hit.snippet + .substring(to) + .replaceAll(RegExp(r'[\r\n\t]'), ' '), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: widget.recovery || hit.start == null + ? null + : IconButton( + tooltip: context.l10n.notesSourceLocation, + icon: const Icon(BusyMarkGlyphs.code), + onPressed: () => + _open(source: true, index: index), + ), + onTap: () => _open(index: index), + ); + }, + ), + ), + if (_state.truncated) + TextButton( + onPressed: () { + _limit += 80; + _search(); + }, + child: Text(context.l10n.workspaceSearchShowMore), + ), + ], + ); + } +} + +Future<void> showNotesBatch( + BuildContext context, + WidgetRef ref, + List<String> ids, { + String? initialAction, + List<NotesMetadataSnapshot>? reviewedTargets, +}) async { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final reviewed = + reviewedTargets ?? + [ + for (final id in ids) + if (repository.noteById(id) != null) repository.metadataSnapshot(id), + ]; + if (!context.mounted || reviewed.isEmpty) return; + var action = initialAction ?? 'favorite'; + var category = ''; + final confirmed = await showBusyMarkModalDialog<bool>( + context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => BusyMarkDialogShell( + title: '${context.l10n.notesSelected} (${reviewed.length})', + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.save, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [ + SizedBox( + height: 140, + child: ListView( + children: [ + for (final snapshot in reviewed) + ListTile( + dense: true, + title: Text(snapshot.note.title), + subtitle: Text(snapshot.note.category), + ), + ], + ), + ), + DropdownButton<String>( + isExpanded: true, + value: action, + items: [ + DropdownMenuItem( + value: 'favorite', + child: Text(context.l10n.nextcloudFavorites), + ), + DropdownMenuItem( + value: 'unfavorite', + child: Text(context.l10n.notesRemoveFavorite), + ), + DropdownMenuItem( + value: 'move', + child: Text(context.l10n.notesMove), + ), + ], + onChanged: (v) => setDialogState(() => action = v!), + ), + if (action == 'move') + TextField( + decoration: InputDecoration( + labelText: context.l10n.syntaxReferenceCategory, + ), + onChanged: (v) => category = v, + ), + ], + ), + ), + ); + if (confirmed != true || !context.mounted) return; + final outcomes = await ref + .read(workspaceControllerProvider.notifier) + .batchNextcloudMetadata( + reviewed, + category: action == 'move' ? category : null, + favorite: action == 'move' ? null : action == 'favorite', + ); + if (!context.mounted) return; + await showBusyMarkModalDialog<void>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.notesSelected, + children: [ + for (final outcome in outcomes) + ListTile( + title: Text(outcome.title), + subtitle: Text( + [ + switch (outcome.status) { + NotesBatchStatus.changed => + context.l10n.nextcloudSavedLocally, + NotesBatchStatus.skipped => context.l10n.notesSkipped, + NotesBatchStatus.conflicted => context.l10n.gitConflicts, + NotesBatchStatus.failed => context.l10n.notesFailed, + }, + if (outcome.detail != null) outcome.detail!, + ].join(' · '), + ), + ), + ], + ), + ); +} + +Future<void> showNotesOffline( + BuildContext context, + WidgetRef ref, + NextcloudNote note, +) async { + final controller = await ref.read( + notesOfflineProvider(note.accountId).future, + ); + if (!context.mounted) return; + await showBusyMarkModalDialog<void>( + context, + builder: (context) => StreamBuilder<void>( + stream: controller.changes, + builder: (context, _) => FutureBuilder<NotesOfflineStatus>( + future: controller.inspect(note), + builder: (context, snapshot) { + final status = snapshot.data; + return BusyMarkDialogShell( + title: context.l10n.notesOffline, + actions: [ + BusyMarkDialogButton( + label: context.l10n.visualizationRetry, + onPressed: () => unawaited(controller.retry(note.localId)), + ), + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => unawaited(controller.cancelNote(note.localId)), + ), + BusyMarkDialogButton( + label: controller.individuallyRetained(note.localId) + ? context.l10n.notesOfflineRemove + : context.l10n.notesOffline, + onPressed: () => unawaited( + controller.setRequirement( + 'note', + note.localId, + required: !controller.individuallyRetained(note.localId), + ), + ), + ), + ], + children: [ + Text(note.title), + if (status == null) + const LinearProgressIndicator() + else ...[ + Text(context.l10n.notesTextAvailable), + Text( + '${status.available ? context.l10n.notesAvailableOffline : context.l10n.notesIncomplete} · ${status.availableCount}/${status.requiredCount}', + ), + if (status.running) const LinearProgressIndicator(), + for (final reference in status.missing) Text(reference), + for (final reference in status.external) + Text('${context.l10n.notesIncomplete}: $reference'), + for (final failure in controller.failures.entries.where( + (e) => e.key.startsWith('${note.localId}:'), + )) + Text(failure.value), + ], + ], + ); + }, + ), + ), + ); +} + +Future<void> showNotesRecovery( + BuildContext context, + WidgetRef ref, + NextcloudNote note, +) async { + await ref.read(localHistoryControllerProvider.notifier).refresh(); + if (!context.mounted) return; + await showBusyMarkModalDialog<void>( + context, + builder: (_) => NotesRecoveryDialog(note: note), + ); +} + +class NotesRecoveryDialog extends ConsumerStatefulWidget { + const NotesRecoveryDialog({super.key, required this.note}); + final NextcloudNote note; + @override + ConsumerState<NotesRecoveryDialog> createState() => + _NotesRecoveryDialogState(); +} + +class _NotesRecoveryDialogState extends ConsumerState<NotesRecoveryDialog> { + LocalHistoryDocument? _document; + LocalHistoryRevision? _revision; + late String _source = widget.note.content; + NotesOfflineStatus? _availability; + int _generation = 0; + @override + void initState() { + super.initState(); + unawaited(_inspect()); + } + + Future<void> _inspect() async { + final generation = ++_generation; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final availability = await _inspectRecoveryAvailability( + repository, + widget.note, + source: _source, + ); + if (mounted && generation == _generation) { + setState(() => _availability = availability); + } + } + + @override + Widget build(BuildContext context) { + final history = ref.watch(localHistoryControllerProvider); + _document ??= history.snapshot.documents + .where( + (d) => + d.remoteNote?.accountId == widget.note.accountId && + d.remoteNote?.localId == widget.note.localId, + ) + .firstOrNull; + final summaries = _document == null + ? <LocalHistoryRevisionSummary>[] + : history.snapshot.revisionsFor(_document!.id); + return BusyMarkDialogShell( + title: context.l10n.notesRecovery, + maxWidth: 760, + actions: [ + BusyMarkDialogButton( + label: + '${context.l10n.notesRecovery}: ${context.l10n.nextcloudNewNote}', + onPressed: _availability == null || _availability!.missing.isNotEmpty + ? null + : () async { + final controller = ref.read( + workspaceControllerProvider.notifier, + ); + final success = _revision == null || _document == null + ? await controller.recoverDeletedNextcloudNote( + widget.note.localId, + content: _source, + ) + : await controller.recoverNextcloudHistoryRevision( + document: _document!, + revision: _revision!, + ); + if (context.mounted && success) Navigator.pop(context); + }, + ), + ], + children: [ + Text(widget.note.title), + Text(context.l10n.notesRecoveryHelp), + if (summaries.isEmpty) Text(context.l10n.notesNoHistory), + if (!ref + .read(localHistoryControllerProvider.notifier) + .policy + .recordingEnabled) + Text(context.l10n.localHistoryWarningRecordingDisabled), + DropdownButton<String>( + isExpanded: true, + value: _revision?.summary.id ?? '', + items: [ + DropdownMenuItem( + value: '', + child: Text(context.l10n.notesTextAvailable), + ), + for (final summary in summaries) + DropdownMenuItem( + value: summary.id, + child: Text(summary.capturedAt.toLocal().toString()), + ), + ], + onChanged: (id) async { + if (id == '') { + setState(() { + _revision = null; + _source = widget.note.content; + _availability = null; + }); + await _inspect(); + return; + } + if (_document == null) return; + final controller = ref.read( + localHistoryControllerProvider.notifier, + ); + controller.selectDocument(_document!.id); + await controller.selectRevision(id!); + final revision = ref + .read(localHistoryControllerProvider) + .selectedRevision; + if (mounted && revision != null) { + setState(() { + _revision = revision; + _source = revision.source; + _availability = null; + }); + await _inspect(); + } + }, + ), + if (_availability == null) + const LinearProgressIndicator() + else ...[ + Text( + '${context.l10n.notesAvailableOffline}: ${_availability!.availableCount}/${_availability!.requiredCount}', + ), + for (final item in _availability!.missing) + Text('${context.l10n.notesIncomplete}: $item'), + for (final item in _availability!.external) + Text('${context.l10n.notesIncomplete}: $item'), + ], + SizedBox( + height: 260, + child: SingleChildScrollView(child: SelectableText(_source)), + ), + ], + ); + } +} + +Future<void> showNotesExport( + BuildContext context, + WidgetRef ref, + List<String> ids, +) async { + final completedLabel = context.l10n.notesCompleted; + final incompleteLabel = context.l10n.notesIncomplete; + final parent = await getDirectoryPath( + confirmButtonText: context.l10n.notesExport, + ); + if (parent == null || !context.mounted) return; + List<NextcloudNote>? snapshot; + Object? failure; + try { + snapshot = await ref + .read(workspaceControllerProvider.notifier) + .captureNextcloudExport(ids.toSet()); + if (snapshot == null) failure = incompleteLabel; + } on Object catch (error) { + failure = error; + } + if (!context.mounted) return; + if (failure != null || snapshot == null) { + await showBusyMarkModalDialog<void>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.warning, + children: [Text(context.l10n.workspaceErrorSaveFailed('$failure'))], + ), + ); + return; + } + final captured = snapshot; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!context.mounted) return; + await showBusyMarkModalDialog<void>( + context, + barrierDismissible: false, + builder: (_) => NotesTransferProgressDialog( + title: context.l10n.notesExport, + operation: (cancel, progress) async { + final result = await NotesTransferService(repository).exportSnapshot( + notes: captured, + destination: parent, + cancellation: cancel, + onProgress: progress, + ); + return [ + result.complete ? completedLabel : incompleteLabel, + '${result.count}', + result.path, + ...result.omissions, + ]; + }, + ), + ); +} + +Future<void> showNotesImport( + BuildContext context, + WidgetRef ref, { + bool folder = true, +}) async { + final accountId = ref + .read(workspaceControllerProvider) + .workspace + ?.nextcloudAccountId; + if (accountId == null) return; + final savedLocally = context.l10n.nextcloudSavedLocally; + final alreadyImported = context.l10n.notesImportAlreadyImported; + final synced = context.l10n.nextcloudSynced; + final source = folder + ? await getDirectoryPath(confirmButtonText: context.l10n.notesImport) + : (await openFile( + acceptedTypeGroups: [ + XTypeGroup( + label: context.l10n.fileTypeMarkdown, + extensions: ['md', 'markdown'], + ), + ], + ))?.path; + if (source == null || !context.mounted) return; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + final service = NotesTransferService(repository); + try { + var review = await service.review(source, accountId); + final pending = (await repository.store.importOperations( + accountId, + )).where((o) => o['root'] == review.root).toList(); + if (!context.mounted) return; + if (pending.isNotEmpty) { + final choice = await showBusyMarkModalDialog<String>( + context, + builder: (_) => NotesImportOperationDialog(operations: pending), + ); + if (choice == null || !context.mounted) return; + if (choice.isNotEmpty) { + review = await service.resumeImport(choice, accountId); + } + if (!context.mounted) return; + } + final confirmed = await showBusyMarkModalDialog<bool>( + context, + builder: (_) => NotesImportReviewDialog(review: review), + ); + if (confirmed != true || !context.mounted) return; + await showBusyMarkModalDialog<void>( + context, + barrierDismissible: false, + builder: (_) => NotesTransferProgressDialog( + title: context.l10n.notesImport, + operation: (cancel, progress) async { + final results = await service.importReviewed( + review, + accountId, + cancellation: cancel, + onProgress: progress, + ); + ref + .read(workspaceControllerProvider.notifier) + .synchronizeImportedNotes(accountId); + return [ + for (final result in results) + '${result.item}: ${result.error ?? [if (result.alreadyImported) alreadyImported, repository.noteById(result.noteId ?? '')?.hasPendingChanges == false ? synced : savedLocally].join(' · ')}', + ]; + }, + ), + ); + } on Object catch (error) { + if (context.mounted) { + await showBusyMarkModalDialog<void>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.warning, + children: [Text(error.toString())], + ), + ); + } + } +} + +/// Choosing a new import never reuses a prior source-item association. Resume +/// is explicit and selects a persisted reviewed operation within this account. +class NotesImportOperationDialog extends StatelessWidget { + const NotesImportOperationDialog({super.key, required this.operations}); + final List<Map<String, dynamic>> operations; + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.notesImport, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + ], + children: [ + ListTile( + title: Text(context.l10n.notesImportNew), + onTap: () => Navigator.pop(context, ''), + ), + for (final operation in operations) + ListTile( + title: Text(context.l10n.notesImportResume), + subtitle: Text( + '${operation['root']}\n${DateTime.fromMillisecondsSinceEpoch((operation['created_at'] as int) * 1000).toLocal()}', + ), + onTap: () => Navigator.pop(context, operation['id'] as String), + ), + ], + ); +} + +class NotesImportReviewDialog extends StatefulWidget { + const NotesImportReviewDialog({super.key, required this.review}); + final NotesImportReview review; + @override + State<NotesImportReviewDialog> createState() => + _NotesImportReviewDialogState(); +} + +class _NotesImportReviewDialogState extends State<NotesImportReviewDialog> { + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.notesImport, + maxWidth: 780, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.notesImport, + onPressed: widget.review.items.any((i) => i.selected) + ? () => Navigator.pop(context, true) + : null, + ), + ], + children: [ + for (final issue in widget.review.issues) Text(issue), + SizedBox( + height: 400, + child: ListView.builder( + itemCount: widget.review.items.length, + itemBuilder: (context, index) { + final item = widget.review.items[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CheckboxListTile( + value: item.selected, + onChanged: item.alreadyImported + ? null + : (v) => setState(() => item.selected = v ?? false), + title: Text(item.title), + subtitle: Text( + item.alreadyImported + ? context.l10n.notesImportAlreadyImported + : item.collision + ? context.l10n.notesImportDistinct + : item.path, + ), + ), + TextFormField( + initialValue: item.category, + readOnly: item.alreadyImported, + decoration: InputDecoration( + labelText: context.l10n.syntaxReferenceCategory, + ), + onChanged: (v) => item.category = v, + ), + Text('${context.l10n.notesAttachments}: ${item.media.length}'), + for (final reference in item.media.keys) + Text(reference, style: Theme.of(context).textTheme.bodySmall), + for (final issue in item.issues) + Text('${context.l10n.notesIncomplete}: $issue'), + const Divider(), + ], + ); + }, + ), + ), + ], + ); +} + +class NotesTransferProgressDialog extends StatefulWidget { + const NotesTransferProgressDialog({ + super.key, + required this.title, + required this.operation, + }); + final String title; + final Future<List<String>> Function( + NotesTransferCancellation, + void Function(int, int), + ) + operation; + @override + State<NotesTransferProgressDialog> createState() => + _NotesTransferProgressDialogState(); +} + +class _NotesTransferProgressDialogState + extends State<NotesTransferProgressDialog> { + final _cancel = NotesTransferCancellation(); + int _completed = 0, _total = 0; + List<String>? _result; + @override + void initState() { + super.initState(); + unawaited(_run()); + } + + Future<void> _run() async { + List<String> result; + try { + result = await widget.operation(_cancel, (a, b) { + if (mounted) { + setState(() { + _completed = a; + _total = b; + }); + } + }); + } on Object catch (error) { + result = [error.toString()]; + } + if (mounted) setState(() => _result = result); + } + + @override + void dispose() { + _cancel.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: _result != null, + child: BusyMarkDialogShell( + title: widget.title, + closable: _result != null, + actions: [ + BusyMarkDialogButton( + label: _result == null ? context.l10n.cancel : context.l10n.close, + onPressed: () { + if (_result == null) { + setState(_cancel.cancel); + } else { + Navigator.pop(context); + } + }, + ), + ], + children: [ + Text('$_completed/$_total'), + if (_result == null) + const LinearProgressIndicator() + else + SizedBox( + height: 300, + child: ListView( + children: [for (final line in _result!) Text(line)], + ), + ), + if (_cancel.cancelled) Text(context.l10n.notesIncomplete), + ], + ), + ); +} + +Future<NotesOfflineStatus> _inspectRecoveryAvailability( + NotesRepository repository, + NextcloudNote note, { + required String source, +}) async { + final dependencies = await repository.attachmentAvailability( + note.localId, + source: source, + ); + return NotesOfflineStatus( + revision: note.revision, + textAvailable: true, + requiredCount: dependencies.required.length, + availableCount: dependencies.available.length, + missing: List<String>.from( + dependencies.required.where( + (String r) => !dependencies.available.contains(r), + ), + ), + external: List<String>.from(dependencies.external), + ); +} diff --git a/lib/src/platform/native_writerside_dialog_service.dart b/lib/src/platform/native_writerside_dialog_service.dart index 3d7c5d07..038246d6 100644 --- a/lib/src/platform/native_writerside_dialog_service.dart +++ b/lib/src/platform/native_writerside_dialog_service.dart @@ -91,6 +91,7 @@ class NativeWritersideDialogService { required String instanceTitleLabel, required String tocTitleLabel, required String instanceExplanation, + bool instanceTitleEditable = true, required String tocExplanation, required String documentationLabel, required String documentationUrl, @@ -109,6 +110,7 @@ class NativeWritersideDialogService { 'instanceTitleLabel': instanceTitleLabel, 'tocTitleLabel': tocTitleLabel, 'instanceExplanation': instanceExplanation, + 'instanceTitleEditable': instanceTitleEditable, 'tocExplanation': tocExplanation, 'documentationLabel': documentationLabel, 'documentationUrl': documentationUrl, diff --git a/lib/src/references/markdown_move_service.dart b/lib/src/references/markdown_move_service.dart new file mode 100644 index 00000000..b88564ce --- /dev/null +++ b/lib/src/references/markdown_move_service.dart @@ -0,0 +1,745 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:path/path.dart' as p; +import 'package:crypto/crypto.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/anchored_path_guard.dart'; +import '../core/atomic_file_writer.dart'; +import '../core/linux_file_identity.dart'; +import '../core/linux_atomic_file_api.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_service.dart'; +import '../workspace/text_format_metadata.dart'; +import 'markdown_references.dart'; + +class MarkdownDestinationEdit { + const MarkdownDestinationEdit(this.start, this.end, this.before, this.after); + final int start, end; + final String before, after; +} + +class MarkdownMoveFile { + MarkdownMoveFile(this.path, this.load, List<MarkdownDestinationEdit> edits) + : edits = List.unmodifiable(edits); + final String path; + final WorkspaceFileLoad load; + final List<MarkdownDestinationEdit> edits; + String get result { + var text = load.text; + for (final edit in edits.reversed) { + text = text.replaceRange(edit.start, edit.end, edit.after); + } + return text; + } +} + +class MarkdownMovePlan { + MarkdownMovePlan({ + required this.workspaceId, + required this.root, + required this.source, + required this.destination, + required this.anchor, + required Map<String, String> mapping, + required Map<String, WorkspaceFileSnapshot> snapshots, + required Map<String, FileStat> identities, + required List<String> inventory, + required List<MarkdownMoveFile> files, + }) : mapping = Map.unmodifiable(mapping), + snapshots = Map.unmodifiable(snapshots), + identities = Map.unmodifiable(identities), + inventory = List.unmodifiable(inventory), + files = List.unmodifiable(files); + final String workspaceId, root, source, destination; + final CanonicalPathAnchor anchor; + final Map<String, String> mapping; + final Map<String, WorkspaceFileSnapshot> snapshots; + final Map<String, FileStat> identities; + final List<String> inventory; + final List<MarkdownMoveFile> files; +} + +class MarkdownRecoveryFile { + const MarkdownRecoveryFile(this.path, this.load, this.before); + final String path; + final WorkspaceFileLoad load; + final List<int> before; +} + +class MarkdownRecoveryPlan { + MarkdownRecoveryPlan({ + required this.root, + required this.journal, + required this.journalSnapshot, + required this.source, + required this.destination, + required this.moved, + required this.moveEvidence, + required List<MarkdownRecoveryFile> files, + }) : files = List.unmodifiable(files); + final String root, journal, source, destination; + final WorkspaceFileSnapshot journalSnapshot; + final bool moved; + final String moveEvidence; + final List<MarkdownRecoveryFile> files; +} + +/// Uses ordinary workspace publication and lossless destination edits. A durable +/// journal retains both versions if a process dies between file writes and move. +class MarkdownMoveService { + const MarkdownMoveService({this.workspaceService = const WorkspaceService()}); + final WorkspaceService workspaceService; + String remap(String path, String source, String destination) => + p.equals(path, source) + ? destination + : p.isWithin(source, path) + ? p.join(destination, p.relative(path, from: source)) + : path; + Future<MarkdownMovePlan> prepare( + Workspace workspace, + String source, + String destination, + ) async { + final anchor = await captureCanonicalDirectoryAnchor(workspace.rootPath); + if (!p.equals(anchor.rootPath, anchor.requestedRootPath)) { + throw StateError('Workspace path changed.'); + } + source = p.normalize(p.absolute(source)); + destination = p.normalize(p.absolute(destination)); + final from = await resolveAnchoredPath(anchor, source, allowRoot: false); + final to = await resolveAnchoredPath(anchor, destination, allowRoot: false); + if (from.type == FileSystemEntityType.notFound || + to.type != FileSystemEntityType.notFound || + p.isWithin(source, destination)) { + throw StateError('The move destination is unavailable.'); + } + if (workspace.writersideProject != null && + (from.type == FileSystemEntityType.directory || + workspace.writersideProject!.modules.any( + (m) => m.topics.any((t) => p.equals(t.filePath, source)), + ))) { + throw StateError( + 'Use the established Writerside reviewed refactoring for owned topics.', + ); + } + final mapping = <String, String>{source: destination}; + if (from.type == FileSystemEntityType.directory) { + await for (final entity in Directory( + source, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(anchor, entity.path, allowRoot: false); + mapping[entity.path] = remap(entity.path, source, destination); + } + } + final inventory = await _inventory(workspace.rootPath); + final snapshots = <String, WorkspaceFileSnapshot>{}; + final identities = <String, FileStat>{}; + for (final path in mapping.keys) { + if (await FileSystemEntity.type(path, followLinks: false) == + FileSystemEntityType.file) { + snapshots[path] = await workspaceService.fileSnapshot(path); + } + } + final files = <MarkdownMoveFile>[]; + for (final path in inventory) { + await resolveAnchoredPath(anchor, path, allowRoot: false); + final load = await workspaceService.loadTextWithSnapshot(path); + if (load.format.hasMixedLineEndings) { + throw StateError('Review requires consistent line endings: $path'); + } + final text = load.text; + final references = await _scanMoveReferences(path, text); + final edits = <int, MarkdownDestinationEdit>{}; + final movedSource = remap(path, source, destination); + for (final ref in references) { + final uri = Uri.tryParse(ref.destination); + final span = ref.destinationSpan; + if (uri == null || + uri.hasScheme || + uri.hasAuthority || + uri.path.isEmpty || + span == null) { + continue; + } + final target = p.normalize( + p.join(p.dirname(path), Uri.decodeComponent(uri.path)), + ); + if (!p.isWithin(workspace.rootPath, target)) continue; + final movedTarget = remap(target, source, destination); + if (p.equals(path, movedSource) && p.equals(target, movedTarget)) { + continue; + } + await resolveAnchoredPath( + anchor, + target, + allowRoot: false, + allowMissingAncestors: true, + ); + var replacement = p + .relative(movedTarget, from: p.dirname(movedSource)) + .split('/') + .map( + (part) => Uri.encodeComponent( + part, + ).replaceAll('(', '%28').replaceAll(')', '%29'), + ) + .join('/'); + if (uri.hasQuery) replacement += '?${uri.query}'; + if (uri.hasFragment) replacement += '#${uri.fragment}'; + if (ref.kind == AuthoredReferenceKind.htmlLink || + ref.kind == AuthoredReferenceKind.htmlMedia) { + replacement = const HtmlEscape( + HtmlEscapeMode.attribute, + ).convert(replacement); + } + final before = text.substring(span.startOffset, span.endOffset); + if (before != replacement) { + edits[span.startOffset] = MarkdownDestinationEdit( + span.startOffset, + span.endOffset, + before, + replacement, + ); + } + } + snapshots[path] = load.snapshot; + if (edits.isNotEmpty) { + files.add( + MarkdownMoveFile( + path, + load, + edits.values.toList()..sort((a, b) => a.start.compareTo(b.start)), + ), + ); + } + } + for (final path in {...mapping.keys, ...snapshots.keys}) { + identities[path] = await FileStat.stat(path); + } + return MarkdownMovePlan( + workspaceId: workspace.id, + root: workspace.rootPath, + source: source, + destination: destination, + anchor: anchor, + mapping: mapping, + snapshots: snapshots, + identities: identities, + inventory: inventory, + files: files, + ); + } + + Future<List<String>> _inventory(String root) async { + final paths = <String>[]; + final pending = <Directory>[Directory(root)]; + while (pending.isNotEmpty) { + await for (final entity in pending.removeLast().list( + followLinks: false, + )) { + if (const {'.git', '.hg', '.svn'}.contains(p.basename(entity.path))) { + continue; + } + if (entity is Link) { + final type = await FileSystemEntity.type(entity.path); + if (type == FileSystemEntityType.directory || + { + '.md', + '.markdown', + }.contains(p.extension(entity.path).toLowerCase())) { + throw StateError( + 'Cannot inspect linked source safely: ${entity.path}', + ); + } + } else if (entity is Directory) { + pending.add(entity); + } else if (entity is File && + { + '.md', + '.markdown', + }.contains(p.extension(entity.path).toLowerCase())) { + paths.add(entity.path); + } + } + } + paths.sort(); + return paths; + } + + Future<void> validate(MarkdownMovePlan plan) async { + final inventory = await _inventory(plan.root); + if (inventory.join('\n') != plan.inventory.join('\n')) { + throw StateError('Workspace files changed; review again.'); + } + final source = await resolveAnchoredPath( + plan.anchor, + plan.source, + allowRoot: false, + ); + final destination = await resolveAnchoredPath( + plan.anchor, + plan.destination, + allowRoot: false, + ); + if (source.type == FileSystemEntityType.notFound || + destination.type != FileSystemEntityType.notFound) { + throw StateError('Move paths changed; review again.'); + } + for (final entry in plan.snapshots.entries) { + await resolveAnchoredPath(plan.anchor, entry.key, allowRoot: false); + if ((await workspaceService.fileSnapshot( + entry.key, + )).differsFrom(entry.value)) { + throw StateError('Reviewed file changed: ${entry.key}'); + } + } + for (final entry in plan.identities.entries) { + final current = await FileStat.stat(entry.key); + final before = entry.value; + if (current.type != before.type || + current.changed != before.changed || + current.modified != before.modified || + current.mode != before.mode || + current.size != before.size) { + throw StateError('Reviewed filesystem identity changed: ${entry.key}'); + } + } + if (source.type == FileSystemEntityType.directory) { + final paths = <String>{plan.source}; + await for (final entity in Directory( + plan.source, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(plan.anchor, entity.path, allowRoot: false); + paths.add(entity.path); + } + if (paths.length != plan.mapping.length || + !paths.containsAll(plan.mapping.keys)) { + throw StateError('Moved folder membership changed; review again.'); + } + } + } + + Future<String> apply( + MarkdownMovePlan plan, { + required Future<String> Function() move, + required void Function() validateBuffers, + }) async { + validateBuffers(); + await validate(plan); + validateBuffers(); + final folder = p.join(plan.root, '.busymark-refactoring'); + await resolveAnchoredPath(plan.anchor, folder, allowRoot: false); + await Directory(folder).create(); + final journal = File(p.join(folder, '${const Uuid().v4()}.json')); + final data = <String, Object?>{ + 'version': 2, + 'phase': 'prepared', + 'source': p.relative(plan.source, from: plan.root), + 'destination': p.relative(plan.destination, from: plan.root), + 'moveEntries': await _moveEvidence(plan, rewritten: false), + 'files': [ + for (final file in plan.files) + { + 'path': p.relative(file.path, from: plan.root), + 'before': base64Encode(file.load.format.encode(file.load.text)), + 'after': base64Encode(file.load.format.encode(file.result)), + }, + ], + }; + await const AtomicFileWriter().writeBytes( + journal.path, + utf8.encode(jsonEncode(data)), + overwrite: false, + ); + try { + await validate(plan); + validateBuffers(); + final writtenIdentities = <String, String?>{}; + await workspaceService.saveFormattedTextBatch( + [ + for (final file in plan.files) + WorkspaceBatchTextWrite( + path: file.path, + text: file.result, + expectedSnapshot: file.load.snapshot, + format: file.load.format, + ), + ], + validateBeforeCommit: validateBuffers, + onCommitted: (_) { + for (final file in plan.files) { + writtenIdentities[file.path] = LinuxFileIdentity.read(file.path); + } + }, + ); + validateBuffers(); + // Atomic rewrites can replace the moved file's inode. Persist the actual + // complete post-write identities BEFORE the rename, including files with + // no reference edits. A crash cannot leave an unrecorded move eligible + // for pathname-only inference. + data['moveEntries'] = await _moveEvidence( + plan, + rewritten: true, + expectedIdentities: { + for (final entry in data['moveEntries'] as List<Map<String, Object?>>) + p.join(plan.root, entry['path'] as String): + entry['identity'] as String?, + ...writtenIdentities, + }, + ); + data['phase'] = 'moving'; + await const AtomicFileWriter().writeBytes( + journal.path, + utf8.encode(jsonEncode(data)), + overwrite: true, + ); + await prepareRecovery(plan.root, journal.path); + // Move revalidates canonical paths and collisions through WorkspaceService. + final target = await move(); + if (!p.equals(target, plan.destination)) { + throw StateError('Unexpected move result.'); + } + final completed = await prepareRecovery(plan.root, journal.path); + if (!completed.moved) throw StateError('Moved identity changed.'); + await journal.delete(); + return target; + } on Object { + // Rollback compares exact post-operation bytes. Newer external content is + // never replaced, and the journal survives any incomplete restoration. + await recover(plan.root, journal.path); + rethrow; + } + } + + Future<List<Map<String, Object?>>> _moveEvidence( + MarkdownMovePlan plan, { + required bool rewritten, + Map<String, String?>? expectedIdentities, + }) async { + final result = <Map<String, Object?>>[]; + for (final path in plan.mapping.keys) { + await resolveAnchoredPath(plan.anchor, path, allowRoot: false); + final identity = LinuxFileIdentity.read(path); + if (identity == null) { + throw UnsupportedError('Durable move identity is unavailable: $path'); + } + if (expectedIdentities != null && expectedIdentities[path] != identity) { + throw StateError('Moved filesystem identity changed: $path'); + } + final type = await FileSystemEntity.type(path, followLinks: false); + final changed = plan.files + .where((f) => p.equals(f.path, path)) + .firstOrNull; + final before = plan.snapshots[path]?.contentHash; + final after = changed == null + ? before + : sha256 + .convert(changed.load.format.encode(changed.result)) + .toString(); + if (type == FileSystemEntityType.file) { + final snapshot = await workspaceService.fileSnapshot(path); + if (snapshot.contentHash != (rewritten ? after : before) || + LinuxFileIdentity.read(path) != identity) { + throw StateError('Moved source changed: $path'); + } + } else if (type != FileSystemEntityType.directory) { + throw StateError('Unsupported moved entry: $path'); + } + result.add({ + 'path': p.relative(path, from: plan.root), + 'type': type == FileSystemEntityType.directory ? 'directory' : 'file', + 'identity': identity, + if (before != null) 'hashes': [before, after], + }); + } + return result; + } + + Future<void> recover( + String root, + String journalPath, { + bool undoMove = true, + }) async { + final plan = await prepareRecovery(root, journalPath); + await recoverReviewed(plan, undoMove: undoMove); + } + + Future<MarkdownRecoveryPlan> prepareRecovery( + String root, + String journalPath, + ) async { + final anchor = await captureCanonicalDirectoryAnchor(root); + if (!p.equals( + p.dirname(journalPath), + p.join(root, '.busymark-refactoring'), + ) || + p.extension(journalPath) != '.json') { + throw StateError('Invalid refactoring journal location.'); + } + await resolveAnchoredPath(anchor, journalPath, allowRoot: false); + final journalSnapshot = await workspaceService.fileSnapshot(journalPath); + final data = + jsonDecode(await File(journalPath).readAsString()) + as Map<String, dynamic>; + if (data['version'] != 2 || + !{'prepared', 'moving'}.contains(data['phase'])) { + // Older journals lack moved-object identity. Keep that evidence for + // manual review rather than guess which existing file was moved. + throw StateError( + 'Refactoring journal lacks verified move identity; manual review is required.', + ); + } + String safe(Object? relative) => _safeJournalPath(root, relative); + + final source = safe(data['source']), + destination = safe(data['destination']); + await resolveAnchoredPath(anchor, source, allowRoot: false); + await resolveAnchoredPath(anchor, destination, allowRoot: false); + final sourceExists = + await FileSystemEntity.type(source, followLinks: false) != + FileSystemEntityType.notFound; + final destinationExists = + await FileSystemEntity.type(destination, followLinks: false) != + FileSystemEntityType.notFound; + if (sourceExists == destinationExists || + data['phase'] == 'prepared' && destinationExists) { + throw StateError('Move paths changed; recovery evidence is retained.'); + } + final moved = destinationExists; + final entries = data['moveEntries'] as List; + await _validateMoveEntries( + anchor, + source, + destination, + entries, + moved: moved, + ); + final files = <MarkdownRecoveryFile>[]; + for (final raw in data['files'] as List) { + final original = safe(raw['path']); + final path = moved ? remap(original, source, destination) : original; + await resolveAnchoredPath(anchor, path, allowRoot: false); + final current = await File(path).readAsBytes(); + final before = base64Decode(raw['before'] as String), + after = base64Decode(raw['after'] as String); + if (base64Encode(current) == base64Encode(before)) continue; + if (base64Encode(current) != base64Encode(after)) { + throw StateError('Recovery needs review of newer content: $path'); + } + final load = await workspaceService.loadTextWithSnapshot(path); + if (base64Encode(load.format.encode(load.text)) != base64Encode(after)) { + throw StateError('Recovery source changed: $path'); + } + files.add(MarkdownRecoveryFile(path, load, List.unmodifiable(before))); + } + if ((await workspaceService.fileSnapshot( + journalPath, + )).differsFrom(journalSnapshot)) { + throw StateError('Recovery evidence changed.'); + } + return MarkdownRecoveryPlan( + root: root, + journal: journalPath, + journalSnapshot: journalSnapshot, + source: source, + destination: destination, + moved: moved, + moveEvidence: jsonEncode(entries), + files: files, + ); + } + + String _safeJournalPath(String root, Object? relative) { + if (relative is! String || + p.isAbsolute(relative) || + p.split(relative).contains('..')) { + throw StateError('Unsafe refactoring journal.'); + } + return p.join(root, relative); + } + + Future<void> _validateMoveEntries( + CanonicalPathAnchor anchor, + String source, + String destination, + List entries, { + required bool moved, + Map<String, String?> writtenIdentities = const {}, + }) async { + String safe(Object? relative) => + _safeJournalPath(anchor.rootPath, relative); + if (entries.isEmpty) throw StateError('Missing move identity evidence.'); + final paths = <String>{}; + for (final raw in entries) { + final original = safe(raw['path']); + if (!(p.equals(original, source) || p.isWithin(source, original))) { + throw StateError('Invalid moved entry.'); + } + final path = moved ? remap(original, source, destination) : original; + if (!paths.add(path)) throw StateError('Duplicate moved entry.'); + await resolveAnchoredPath(anchor, path, allowRoot: false); + final identity = LinuxFileIdentity.read(path); + final type = await FileSystemEntity.type(path, followLinks: false); + if (identity == null || + identity != + (writtenIdentities.containsKey(path) + ? writtenIdentities[path] + : raw['identity']) || + (type == FileSystemEntityType.directory + ? 'directory' + : type == FileSystemEntityType.file + ? 'file' + : 'unsupported') != + raw['type']) { + throw StateError('Moved filesystem identity changed: $path'); + } + if (type == FileSystemEntityType.file && + !(raw['hashes'] as List).contains( + (await workspaceService.fileSnapshot(path)).contentHash, + )) { + throw StateError('Moved content changed: $path'); + } + if (LinuxFileIdentity.read(path) != identity) { + throw StateError('Moved filesystem identity changed: $path'); + } + } + final active = moved ? destination : source; + final membership = <String>{active}; + if (await FileSystemEntity.type(active, followLinks: false) == + FileSystemEntityType.directory) { + await for (final entry in Directory( + active, + ).list(recursive: true, followLinks: false)) { + await resolveAnchoredPath(anchor, entry.path, allowRoot: false); + membership.add(entry.path); + } + } + if (paths.length != membership.length || !paths.containsAll(membership)) { + throw StateError('Moved folder membership changed.'); + } + } + + Future<void> recoverReviewed( + MarkdownRecoveryPlan plan, { + bool undoMove = true, + void Function()? validateBuffers, + }) async { + validateBuffers?.call(); + if (plan.moved && !undoMove) { + throw StateError( + 'Restoring references requires restoring the moved identity.', + ); + } + final anchor = await captureCanonicalDirectoryAnchor(plan.root); + await resolveAnchoredPath(anchor, plan.journal, allowRoot: false); + final currentPlan = await prepareRecovery(plan.root, plan.journal); + if (currentPlan.moved != plan.moved || + currentPlan.source != plan.source || + currentPlan.destination != plan.destination || + currentPlan.journalSnapshot.differsFrom(plan.journalSnapshot) || + currentPlan.files.length != plan.files.length) { + throw StateError('Recovery changed; review again.'); + } + for (final file in plan.files) { + await resolveAnchoredPath(anchor, file.path, allowRoot: false); + if ((await workspaceService.fileSnapshot( + file.path, + )).differsFrom(file.load.snapshot)) { + throw StateError('Recovery source changed: ${file.path}'); + } + } + validateBuffers?.call(); + if (plan.moved && undoMove) { + await resolveAnchoredPath(anchor, plan.source, allowRoot: false); + await resolveAnchoredPath(anchor, plan.destination, allowRoot: false); + if (await FileSystemEntity.type(plan.source, followLinks: false) != + FileSystemEntityType.notFound) { + throw StateError('Recovery destination is occupied.'); + } + // Check identities immediately before a no-replace rename. Restore the + // moved object first so a recreated original path can never receive + // restored incoming references. + await prepareRecovery(plan.root, plan.journal); + final error = LinuxAtomicFileApi.instance.publishNoReplace( + plan.destination, + plan.source, + directory: true, + ); + if (error != null) { + throw FileSystemException( + 'Cannot restore moved identity (errno $error)', + plan.source, + ); + } + } + final restoredIdentities = <String, String?>{}; + final restoredPaths = { + for (final file in plan.files) + plan.moved + ? remap(file.path, plan.destination, plan.source) + : file.path, + }; + final unwrittenIdentities = <String, String>{ + for (final entry in jsonDecode(plan.moveEvidence) as List) + if (!restoredPaths.contains(_safeJournalPath(plan.root, entry['path']))) + _safeJournalPath(plan.root, entry['path']): + entry['identity'] as String, + }; + void validateRecoveryPublication() { + validateBuffers?.call(); + if (FileSystemEntity.typeSync(plan.destination, followLinks: false) != + FileSystemEntityType.notFound || + unwrittenIdentities.entries.any( + (entry) => LinuxFileIdentity.read(entry.key) != entry.value, + )) { + throw StateError('Moved identity changed during recovery publication.'); + } + } + + await workspaceService.saveFormattedTextBatch( + [ + for (final file in plan.files) + WorkspaceBatchTextWrite( + path: plan.moved && undoMove + ? remap(file.path, plan.destination, plan.source) + : file.path, + text: decodeUtf8Document(file.before).text, + format: decodeUtf8Document(file.before).format, + expectedSnapshot: file.load.snapshot, + ), + ], + validateBeforeCommit: validateRecoveryPublication, + onCommitted: (snapshots) { + for (final path in snapshots.keys) { + restoredIdentities[path] = LinuxFileIdentity.read(path); + } + }, + ); + // Revalidate even an unedited moved document after restoring references; + // a concurrent replacement must retain the recovery evidence. + if (await FileSystemEntity.type(plan.source, followLinks: false) == + FileSystemEntityType.notFound || + await FileSystemEntity.type(plan.destination, followLinks: false) != + FileSystemEntityType.notFound || + (await workspaceService.fileSnapshot( + plan.journal, + )).differsFrom(plan.journalSnapshot)) { + throw StateError('Recovery paths or evidence changed.'); + } + await _validateMoveEntries( + anchor, + plan.source, + plan.destination, + jsonDecode(plan.moveEvidence) as List, + moved: false, + writtenIdentities: restoredIdentities, + ); + await File(plan.journal).delete(); + } +} + +Future<List<AuthoredReference>> _scanMoveReferences(String path, String text) => + Isolate.run(() => markdownReferences(identity: path, source: text)); diff --git a/lib/src/references/markdown_move_ui.dart b/lib/src/references/markdown_move_ui.dart new file mode 100644 index 00000000..3048ef00 --- /dev/null +++ b/lib/src/references/markdown_move_ui.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app/busymark_dialogs.dart'; +import '../app/busymark_design.dart'; +import '../app/localization.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_safety.dart'; +import 'markdown_move_service.dart'; + +Future<void> reviewMarkdownRecovery( + BuildContext context, + WidgetRef ref, + String journal, +) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null || + !await confirmSafeToChangeWorkspaceFiles( + context, + ref, + workspace.files.map((f) => f.absolutePath), + ) || + !context.mounted) { + return; + } + try { + final plan = await const MarkdownMoveService().prepareRecovery( + workspace.rootPath, + journal, + ); + if (!context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + final accepted = await showBusyMarkModalDialog<bool>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.notesRecovery, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.apply, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [ + Text('${plan.destination} → ${plan.source}'), + SizedBox( + height: 360, + child: ListView( + children: [for (final file in plan.files) Text(file.path)], + ), + ), + ], + ), + ); + if (accepted == true) { + await ref + .read(workspaceControllerProvider.notifier) + .recoverMarkdownMove(plan); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } +} + +Future<({bool proceed, MarkdownMovePlan? plan})> reviewMarkdownMove( + BuildContext context, + WidgetRef ref, + String source, + String destination, +) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace?.kind != WorkspaceKind.markdownFolder) { + return (proceed: true, plan: null); + } + if (!await confirmSafeToChangeWorkspaceFiles( + context, + ref, + workspace!.files.map((f) => f.absolutePath), + ) || + !context.mounted) { + return (proceed: false, plan: null); + } + try { + final plan = await ref + .read(workspaceControllerProvider.notifier) + .prepareMarkdownMove(source, destination); + if (plan == null || + !context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return (proceed: false, plan: null); + } + final focus = FocusManager.instance.primaryFocus; + final accepted = await showBusyMarkModalDialog<bool>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, + maxWidth: 720, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context, false), + label: context.l10n.cancel, + ), + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context, true), + label: context.l10n.apply, + ), + ], + children: [ + Text('$source → $destination'), + SizedBox( + height: 360, + child: ListView( + children: [ + for (final file in plan.files) ...[ + Text( + file.path, + style: Theme.of(context).textTheme.titleSmall, + ), + for (final edit in file.edits) + SelectableText( + '${edit.start}: ${edit.before} → ${edit.after}', + ), + ], + ], + ), + ), + ], + ), + ); + if (focus?.context?.mounted == true) focus!.requestFocus(); + return (proceed: accepted == true, plan: plan); + } on Object catch (error) { + if (context.mounted) { + await showBusyMarkModalDialog<void>( + context, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context), + label: context.l10n.close, + ), + ], + children: [Text(error.toString())], + ), + ); + } + return (proceed: false, plan: null); + } +} diff --git a/lib/src/references/markdown_references.dart b/lib/src/references/markdown_references.dart new file mode 100644 index 00000000..a731950a --- /dev/null +++ b/lib/src/references/markdown_references.dart @@ -0,0 +1,252 @@ +// These are source adapters for BusyMark's supported grammar. Candidates are +// accepted by the existing parser, never by a link-looking regular expression. +// ignore: implementation_imports +import 'package:markdown/src/link_parser.dart' show LinkParser; +// ignore: implementation_imports +import 'package:markdown/src/util.dart' show normalizeLinkLabel; + +import '../core/source_span.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/markdown_model.dart'; +import '../markdown/markdown_parser.dart'; +import '../markdown/markdown_source_map.dart'; +import '../writerside/writerside_html_reference_scanner.dart'; + +enum AuthoredReferenceKind { + link, + referenceLink, + htmlLink, + media, + htmlMedia, + topic, + include, +} + +class AuthoredReference { + const AuthoredReference({ + required this.destination, + required this.kind, + required this.occurrence, + required this.destinationSpan, + this.label = '', + this.origin, + this.moduleId, + }); + final String destination, label; + final AuthoredReferenceKind kind; + final SourceSpan occurrence; + final SourceSpan? destinationSpan; + final String? origin, moduleId; +} + +({int start, int end})? markdownDestinationRange( + String source, + int start, + int limit, +) { + while (start < limit && RegExp(r'\s').hasMatch(source[start])) { + start++; + } + if (start >= limit) return null; + var end = start; + if (source[start] == '<') { + start++; + end = start; + while (end < limit) { + if (source[end] == '\\') { + end += 2; + continue; + } + if (source[end] == '>') return (start: start, end: end); + end++; + } + return null; + } + var depth = 0; + while (end < limit) { + final char = source[end]; + if (char == '\\') { + end += 2; + continue; + } + if (char == '(') depth++; + if (char == ')') { + if (depth == 0) break; + depth--; + } + if (RegExp(r'\s').hasMatch(char)) break; + end++; + } + return end <= limit ? (start: start, end: end) : null; +} + +List<AuthoredReference> markdownReferences({ + required String identity, + required String source, + MarkdownMode mode = MarkdownMode.commonMark, +}) { + final parsed = const MarkdownParser().parse( + filePath: identity, + source: source, + mode: mode, + validateLocalReferences: false, + ); + final context = const MarkdownSourceMapper().createInlineParserContext( + documentSource: source, + mode: mode, + ); + final mask = writersideMarkdownLiteralMask( + source: source, + protectedRanges: parsed.codeBlocks.map((b) => b.span), + ); + SourceSpan span(int start, int end) => SourceSpan.fromOffsets( + filePath: identity, + source: source, + startOffset: start, + endOffset: end, + ); + final definitions = <String, SourceSpan>{}; + // LinkParser validates balanced/escaped destinations, multiline definitions + // and titles. A parser probe proves this is the winning declaration, including + // container/prose/code context and CommonMark's first-definition rule. + for (final match in RegExp( + r'\[(?:\\.|[^\]\\]){1,999}\]:', + ).allMatches(source)) { + if (mask[match.start]) continue; + final blank = source.indexOf(RegExp(r'\n[ \t]*\n'), match.start); + final limit = blank < 0 ? source.length : blank; + final parser = LinkParser( + source + .substring(match.start, limit) + .replaceAll(RegExp(r'(?<=\n) {0,3}(?:> ?)+'), ''), + )..parseDefinition(); + if (!parser.valid || parser.label == null) continue; + final label = normalizeLinkLabel(parser.label!); + if (definitions.containsKey(label) || + !context.referenceDefinitions.containsKey(label)) { + continue; + } + var destinationStart = match.end; + while (destinationStart < limit && + RegExp(r'[\s>]').hasMatch(source[destinationStart])) { + destinationStart++; + } + final range = markdownDestinationRange(source, destinationStart, limit); + if (range == null) continue; + var probe = 'https://busymark-reference.invalid/definition'; + while (source.contains(probe)) { + probe += 'x'; + } + final candidate = source.replaceRange(range.start, range.end, probe); + final verified = const MarkdownSourceMapper() + .createInlineParserContext(documentSource: candidate, mode: mode) + .referenceDefinitions[label]; + if (verified?.destination == probe) { + definitions[label] = span(range.start, range.end); + } + } + final result = <AuthoredReference>[]; + final seen = <String>{}; + for (final mapping in context.parsePositionedBlocks( + source, + includeCodeBlocks: false, + )) { + for (final entry in mapping.ranges.entries) { + final inline = entry.key; + final range = entry.value; + if (inline.destination == null || + !{BusyInlineKind.link, BusyInlineKind.image}.contains(inline.kind) || + range.start < 0 || + range.end > source.length || + range.start == range.end || + // The parser owns label semantics. Code spans *inside* link text + // remain formatting; only a link starting in literal code/comment + // context is excluded. + mask[range.start]) { + continue; + } + if (!seen.add('${range.start}:${range.end}')) continue; + SourceSpan? destination; + if (range.isReference) { + final raw = source.substring(range.start, range.end); + final suffix = RegExp(r'\[([^\]]*)\]$').firstMatch(raw)?.group(1); + final label = suffix?.isNotEmpty == true + ? suffix! + : range.labelStart != null && range.labelEnd != null + ? source.substring(range.labelStart!, range.labelEnd!) + : ''; + destination = definitions[normalizeLinkLabel(label)]; + } else if (range.isAutolink) { + destination = source[range.start] == '<' + ? span(range.start + 1, range.end - 1) + : span(range.start, range.end); + } else if (range.labelEnd != null && + range.labelEnd! + 1 < range.end && + source[range.labelEnd! + 1] == '(') { + final found = markdownDestinationRange( + source, + range.labelEnd! + 2, + range.end, + ); + if (found != null) destination = span(found.start, found.end); + } + if (destination == null) { + continue; // Authored HTML is mapped by its tokenizer below. + } + result.add( + AuthoredReference( + destination: inline.destination!, + kind: inline.kind == BusyInlineKind.image + ? AuthoredReferenceKind.media + : range.isReference + ? AuthoredReferenceKind.referenceLink + : AuthoredReferenceKind.link, + occurrence: span(range.start, range.end), + destinationSpan: destination, + label: inline.text, + ), + ); + } + } + for (final html + in const WritersideAuthoredHtmlReferenceScanner().scanMarkdownAnchors( + filePath: identity, + source: source, + protectedSource: mask, + )) { + result.add( + AuthoredReference( + destination: html.href, + kind: AuthoredReferenceKind.htmlLink, + occurrence: html.anchorSpan ?? html.startTagSpan, + destinationSpan: html.hrefSpan, + label: html.innerContentSpan == null + ? '' + : source.substring( + html.innerContentSpan!.startOffset, + html.innerContentSpan!.endOffset, + ), + origin: html.origin, + ), + ); + } + for (final media + in const WritersideAuthoredHtmlReferenceScanner().scanMarkdownMedia( + filePath: identity, + source: source, + protectedSource: mask, + )) { + result.add( + AuthoredReference( + destination: media.destination, + kind: AuthoredReferenceKind.htmlMedia, + occurrence: media.span, + destinationSpan: media.span, + ), + ); + } + result.sort( + (a, b) => a.occurrence.startOffset.compareTo(b.occurrence.startOffset), + ); + return List.unmodifiable(result); +} diff --git a/lib/src/references/reference_controller.dart b/lib/src/references/reference_controller.dart new file mode 100644 index 00000000..3bf2c730 --- /dev/null +++ b/lib/src/references/reference_controller.dart @@ -0,0 +1,302 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/domain/notes_models.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import '../markdown/markdown_model.dart'; +import 'reference_index.dart'; +import 'writerside_reference_adapter.dart'; + +final referenceIndexProvider = + NotifierProvider<ReferenceController, AsyncValue<ReferenceIndex>>( + ReferenceController.new, + ); + +typedef ReferenceBatchParser = + Future<List<ParsedReferenceDocument>> Function( + List<ReferenceDocument> documents, + ); +final referenceBatchParserProvider = Provider<ReferenceBatchParser>( + (ref) => parseReferenceBatch, +); + +/// Coalesces edits and filesystem/repository notifications outside widget builds. +/// Content digests retain unchanged parsing; generations fence obsolete results. +class ReferenceController extends Notifier<AsyncValue<ReferenceIndex>> { + Timer? _timer; + StreamSubscription<void>? _notes; + int _generation = 0; + final _cache = <String, ParsedReferenceDocument>{}; + @override + AsyncValue<ReferenceIndex> build() { + ref.listen(workspaceControllerProvider, (before, after) => refresh()); + ref.listen(nextcloudNotesRepositoryProvider, (before, after) { + _notes?.cancel(); + _notes = after.value?.changes.listen((_) => refresh()); + refresh(); + }, fireImmediately: true); + ref.onDispose(() { + _generation++; + _timer?.cancel(); + _notes?.cancel(); + }); + refresh(); + return const AsyncLoading(); + } + + void refresh() { + final generation = ++_generation; + _timer?.cancel(); + _timer = Timer( + const Duration(milliseconds: 200), + () => _rebuild(generation), + ); + } + + Future<void> _rebuild(int generation) async { + try { + final snapshot = ref.read(workspaceControllerProvider); + final workspace = snapshot.workspace; + if (workspace == null) { + if (generation == _generation && ref.mounted) { + _cache.clear(); + state = AsyncData(ReferenceIndex(parsed: const [])); + } + return; + } + final documents = <ReferenceDocument>[]; + Uri? server; + final missingNoteIds = <int>{}; + String? root; + if (workspace.isRemote) { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + server = repository.accountById(workspace.nextcloudAccountId!)?.server; + missingNoteIds.addAll( + repository.notes + .where( + (n) => + n.accountId == workspace.nextcloudAccountId && + n.syncState == NoteSyncState.deletedRemotely && + n.serverId != null, + ) + .map((n) => n.serverId!), + ); + for (final note in repository.notes.where( + (n) => + n.accountId == workspace.nextcloudAccountId && + !isNotesRecovery(n), + )) { + final buffer = snapshot.documentBuffers + .where((b) => b.remoteNote?.localId == note.localId) + .firstOrNull; + documents.add( + ReferenceDocument( + identity: note.localId, + title: note.title, + source: buffer?.text ?? note.content, + localId: note.localId, + serverId: note.serverId, + available: !note.error, + ), + ); + } + } else { + root = workspace.filesystemRootPath; + for (final file in workspace.files.where( + (f) => { + DocumentKind.markdown, + DocumentKind.writersideMarkdownTopic, + DocumentKind.writersideXmlTopic, + }.contains(f.kind), + )) { + if (generation != _generation || !ref.mounted) return; + final buffer = snapshot.documentBuffers + .where((b) => b.filePath == file.absolutePath) + .firstOrNull; + var source = buffer?.text; + var available = true; + if (source == null) { + try { + final disk = File(file.absolutePath); + final canonical = await disk.resolveSymbolicLinks(); + final canonicalRoot = await Directory( + root!, + ).resolveSymbolicLinks(); + if (!p.isWithin(canonicalRoot, canonical) || + !p.equals(canonical, file.absolutePath) || + (await disk.stat()).size > 2 * 1024 * 1024) { + throw const FileSystemException( + 'Unavailable reference source.', + ); + } + source = await disk.readAsString(); + } on Object { + available = false; + source = ''; + } + } + documents.add( + ReferenceDocument( + identity: file.absolutePath, + path: file.absolutePath, + title: p.basenameWithoutExtension(file.relativePath), + source: source, + available: available, + mode: file.kind == DocumentKind.writersideMarkdownTopic + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark, + ), + ); + } + } + final parsed = <ParsedReferenceDocument>[]; + final changed = <ReferenceDocument>[]; + for (final document in documents) { + final cached = _cache[document.identity]; + if (cached?.document.revision == document.revision && + cached?.document.available == document.available && + cached?.document.title == document.title && + cached?.document.mode == document.mode && + cached?.document.serverId == document.serverId) { + parsed.add(cached!); + } else { + changed.add(document); + } + } + // A worker transfer is limited to 16 documents, never the entire corpus. + for (var offset = 0; offset < changed.length; offset += 16) { + final batch = changed.skip(offset).take(16).toList(); + final values = await ref.read(referenceBatchParserProvider)(batch); + if (generation != _generation || !ref.mounted) return; + parsed.addAll(values); + // Keep completed, current batches when a later edit supersedes the + // rebuild. Their revisions are checked before reuse on the next pass. + _cache.addEntries(values.map((d) => MapEntry(d.document.identity, d))); + } + final writerside = workspace.writersideProject == null + ? null + : await ref + .read(workspaceControllerProvider.notifier) + .writersideEditorIndex(); + if (generation != _generation || !ref.mounted) return; + _cache + ..clear() + ..addEntries(parsed.map((d) => MapEntry(d.document.identity, d))); + final index = ReferenceIndex( + parsed: parsed, + root: root, + server: server, + missingNoteIds: missingNoteIds, + previous: state.value, + ownedPaths: + writerside?.modulesById.values + .expand((m) => m.topics) + .map((t) => t.filePath) + .toSet() ?? + const {}, + ); + if (writerside != null) { + addWritersideReferences( + index, + writerside, + workspace.writersideProject?.activeInstanceId, + ); + } + state = AsyncData(index); + } on Object catch (error, stack) { + if (ref.mounted && generation == _generation) { + state = AsyncError(error, stack); + } + } + } +} + +final referencePanelRequestProvider = + NotifierProvider<ReferencePanelRequest, int>(ReferencePanelRequest.new); + +class ReferencePanelRequest extends Notifier<int> { + @override + int build() => 0; + void request() => state++; +} + +Future<List<ParsedReferenceDocument>> parseReferenceBatch( + List<ReferenceDocument> documents, +) => Isolate.run(() => documents.map(ParsedReferenceDocument.new).toList()); + +typedef ReferenceQuery = ({ + String identity, + bool incoming, + String? anchor, + int count, +}); + +class ReferencePanelResults { + const ReferencePanelResults(this.headings, this.references, this.total); + final List<ReferenceTarget> headings; + final List<IndexedReference> references; + final int total; +} + +// Cached queries keep corpus traversal out of widget builds. +final referencePanelResultsProvider = Provider.autoDispose + .family<AsyncValue<ReferencePanelResults>, ReferenceQuery>((ref, query) { + return ref.watch(referenceIndexProvider).whenData((index) { + final values = <IndexedReference>[]; + var total = 0; + for (var offset = 0; offset < query.count; offset += 100) { + final page = index.query( + query.identity, + incoming: query.incoming, + anchor: query.anchor, + offset: offset, + ); + values.addAll(page.values); + total = page.total; + if (page.nextOffset == null) break; + } + final headings = <String, ReferenceTarget>{ + for (final heading in index.headingsFor(query.identity)) + heading.anchor!: heading, + }.values.toList(); + return ReferencePanelResults( + headings, + List.unmodifiable(values), + total, + ); + }); + }); + +typedef ReferenceTargetQuery = ({String text, int count}); +final referenceTargetResultsProvider = Provider.autoDispose + .family< + AsyncValue<({List<ReferenceTarget> values, int total})>, + ReferenceTargetQuery + >((ref, query) { + return ref.watch(referenceIndexProvider).whenData((index) { + final values = <ReferenceTarget>[]; + var total = 0; + for (final target in index.targets) { + if (!'${target.document.title} ${target.label ?? ''} ${target.document.path ?? ''} ${target.moduleId ?? ''}' + .toLowerCase() + .contains(query.text)) { + continue; + } + total++; + if (values.length < query.count) values.add(target); + } + return ( + values: List<ReferenceTarget>.unmodifiable(values), + total: total, + ); + }); + }); diff --git a/lib/src/references/reference_index.dart b/lib/src/references/reference_index.dart new file mode 100644 index 00000000..1a78f0ac --- /dev/null +++ b/lib/src/references/reference_index.dart @@ -0,0 +1,366 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../core/source_span.dart'; +import '../markdown/markdown_model.dart'; +import '../markdown/markdown_parser.dart'; +import '../nextcloud_notes/data/server_uri.dart'; +import 'markdown_references.dart'; + +enum ReferenceResolution { + resolved, + missing, + ambiguous, + unavailable, + unsupported, +} + +String referenceRevision(String source) => + sha256.convert(utf8.encode(source)).toString(); + +class ReferenceDocument { + const ReferenceDocument({ + required this.identity, + required this.title, + required this.source, + this.path, + this.localId, + this.serverId, + this.mode = MarkdownMode.commonMark, + this.available = true, + }); + final String identity, title, source; + final String? path, localId; + final int? serverId; + final MarkdownMode mode; + final bool available; + String get revision => referenceRevision(source); +} + +class ReferenceTarget { + const ReferenceTarget( + this.document, { + this.anchor, + this.label, + this.span, + this.moduleId, + this.scopeSpan, + }); + final ReferenceDocument document; + final String? anchor, label, moduleId; + final SourceSpan? span; + final SourceSpan? scopeSpan; + String get key => '${document.identity}#${anchor ?? ''}'; +} + +class IndexedReference { + const IndexedReference({ + required this.source, + required this.authored, + required this.resolution, + this.target, + }); + final ReferenceDocument source; + final AuthoredReference authored; + final ReferenceResolution resolution; + final ReferenceTarget? target; + String get revision => source.revision; +} + +class ReferencePage { + const ReferencePage(this.values, this.total, this.nextOffset); + final List<IndexedReference> values; + final int total; + final int? nextOffset; +} + +class ParsedReferenceDocument { + ParsedReferenceDocument(this.document) + : headings = document.available + ? const MarkdownParser() + .parse( + filePath: document.identity, + source: document.source, + mode: document.mode, + validateLocalReferences: false, + ) + .headings + : const [], + authored = document.available + ? markdownReferences( + identity: document.identity, + source: document.source, + mode: document.mode, + ) + : const []; + final ReferenceDocument document; + final List<MarkdownHeading> headings; + final List<AuthoredReference> authored; +} + +/// Entirely derived; no writes to notes, source, history or synchronization. +class ReferenceIndex { + ReferenceIndex({ + required List<ParsedReferenceDocument> parsed, + this.root, + this.server, + Set<int> missingNoteIds = const {}, + Set<String> ownedPaths = const {}, + ReferenceIndex? previous, + }) : missingNoteIds = Set.unmodifiable(missingNoteIds) { + parsedDocuments = List.unmodifiable(parsed); + documents = { + for (final value in parsed) value.document.identity: value.document, + }; + final paths = { + for (final value in parsed) + if (value.document.path != null) + p.normalize(value.document.path!): value, + }; + final ids = { + for (final value in parsed) + if (value.document.serverId != null) value.document.serverId!: value, + }; + targets = [ + for (final value in parsed) ...[ + ReferenceTarget(value.document), + for (final heading + in ownedPaths.contains(value.document.path) + ? <MarkdownHeading>[] + : value.headings) + ReferenceTarget( + value.document, + anchor: heading.id, + label: heading.text, + span: heading.span, + ), + ], + ]; + references = []; + final reusable = + previous != null && + previous.root == root && + previous.server == server && + previous.missingNoteIds.length == missingNoteIds.length && + previous.missingNoteIds.every(missingNoteIds.contains) && + previous.documents.length == documents.length && + documents.entries.every((entry) { + final old = previous.documents[entry.key]; + return old != null && + old.path == entry.value.path && + old.serverId == entry.value.serverId; + }); + final owned = ownedPaths; + final allTargetsUnchanged = + reusable && + documents.entries.every( + (entry) => identical(previous.documents[entry.key], entry.value), + ); + for (final value in parsed) { + if (owned.contains(value.document.path)) continue; + if (reusable && + identical( + previous.documents[value.document.identity], + value.document, + )) { + final cached = + previous._outgoing[value.document.identity] ?? + const <IndexedReference>[]; + if (cached.every( + (r) => + (r.target == null && + (allTargetsUnchanged || + r.resolution == ReferenceResolution.unsupported)) || + (r.target != null && + identical( + documents[r.target!.document.identity], + r.target!.document, + )), + )) { + references.addAll(cached); + continue; + } + } + for (final authored in value.authored) { + var resolution = ReferenceResolution.unsupported; + ReferenceTarget? target; + final uri = Uri.tryParse(authored.destination); + ParsedReferenceDocument? destination; + if (server != null) { + final noteId = nextcloudNoteId(server!, authored.destination); + if (noteId != null) { + if (missingNoteIds.contains(noteId)) { + resolution = ReferenceResolution.missing; + } else { + destination = ids[noteId]; + resolution = ReferenceResolution.unavailable; + } + } else if (authored.destination.startsWith('#')) { + destination = value; + } + } else if (uri != null && + !uri.hasScheme && + !uri.hasAuthority && + root != null) { + try { + final path = uri.path.isEmpty + ? value.document.path + : p.normalize( + p.join( + p.dirname(value.document.path!), + Uri.decodeComponent(uri.path), + ), + ); + if (path != null && + (p.equals(path, root!) || p.isWithin(root!, path))) { + destination = paths[path]; + resolution = + destination == null && + !const { + '.md', + '.markdown', + }.contains(p.extension(path).toLowerCase()) + ? ReferenceResolution.unsupported + : ReferenceResolution.missing; + } + } on FormatException { + /* malformed destinations remain unsupported */ + } + } + if (destination != null) { + resolution = destination.document.available + ? ReferenceResolution.resolved + : ReferenceResolution.unavailable; + String? anchor; + try { + // URI fragments retain percent encoding. Semantic IDs are decoded + // once, while authored.destination remains unchanged for edits. + anchor = uri == null ? null : Uri.decodeComponent(uri.fragment); + } on FormatException { + resolution = ReferenceResolution.unsupported; + } + SourceSpan? span; + if (anchor != null && + anchor.isNotEmpty && + destination.document.available) { + final matches = destination.headings + .where((h) => h.id == anchor) + .toList(); + resolution = matches.isEmpty + ? ReferenceResolution.missing + : matches.length > 1 + ? ReferenceResolution.ambiguous + : ReferenceResolution.resolved; + span = matches.singleOrNull?.span; + } + target = ReferenceTarget( + destination.document, + anchor: anchor?.isEmpty == true ? null : anchor, + span: span, + ); + } + references.add( + IndexedReference( + source: value.document, + authored: authored, + resolution: resolution, + target: target, + ), + ); + } + } + rebuildLookups(); + } + final String? root; + final Uri? server; + final Set<int> missingNoteIds; + late final Map<String, ReferenceDocument> documents; + late final List<ParsedReferenceDocument> parsedDocuments; + late final List<ReferenceTarget> targets; + late final List<IndexedReference> references; + final _incoming = <String, List<IndexedReference>>{}; + final _outgoing = <String, List<IndexedReference>>{}; + final _headings = <String, List<ReferenceTarget>>{}; + + /// Called by the format adapter before publishing a completed index. + void rebuildLookups() { + _incoming.clear(); + _outgoing.clear(); + _headings.clear(); + for (final target in targets.where((t) => t.anchor != null)) { + _headings.putIfAbsent(target.document.identity, () => []).add(target); + } + for (final headings in _headings.values) { + headings.sort( + (a, b) => + (a.span?.startOffset ?? 0).compareTo(b.span?.startOffset ?? 0), + ); + } + for (final reference in references) { + _outgoing.putIfAbsent(reference.source.identity, () => []).add(reference); + final identity = reference.target?.document.identity; + if (identity != null) { + _incoming.putIfAbsent(identity, () => []).add(reference); + } + } + } + + List<ReferenceTarget> headingsFor(String identity) => + List.unmodifiable(_headings[identity] ?? const []); + + ReferencePage query( + String identity, { + bool incoming = false, + String? anchor, + int offset = 0, + int limit = 100, + }) { + if (offset < 0 || limit < 1 || limit > 100) { + throw ArgumentError('Invalid reference page.'); + } + final candidates = + (incoming ? _incoming : _outgoing)[identity] ?? + const <IndexedReference>[]; + final values = anchor == null + ? candidates + : candidates + .where( + (r) => incoming + ? r.target?.document.identity == identity && + r.target?.anchor == anchor + : r.source.identity == identity && + _insideHeading(r, anchor), + ) + .toList(); + return ReferencePage( + List.unmodifiable(values.skip(offset).take(limit)), + values.length, + offset + limit < values.length ? offset + limit : null, + ); + } + + bool _insideHeading(IndexedReference reference, String anchor) { + final headings = + (_headings[reference.source.identity] ?? const <ReferenceTarget>[]) + .where((t) => t.span != null) + .toList(); + final selected = headings.where((h) => h.anchor == anchor).singleOrNull; + if (selected == null) return false; + final start = selected.span!.startOffset; + if (selected.scopeSpan case final scope?) { + return reference.authored.occurrence.startOffset >= scope.startOffset && + reference.authored.occurrence.startOffset < scope.endOffset; + } + final end = + headings + .where((h) => h.span!.startOffset > start) + .firstOrNull + ?.span + ?.startOffset ?? + reference.source.source.length; + return reference.authored.occurrence.startOffset >= start && + reference.authored.occurrence.startOffset < end; + } +} diff --git a/lib/src/references/reference_ui.dart b/lib/src/references/reference_ui.dart new file mode 100644 index 00000000..0d18d98c --- /dev/null +++ b/lib/src/references/reference_ui.dart @@ -0,0 +1,479 @@ +import '../editor/link_destination.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/busymark_design.dart'; +import '../app/busymark_dialogs.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/localization.dart'; +import '../app/app_settings.dart'; +import '../nextcloud_notes/data/server_uri.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/presentation/notes_workspace_ui.dart'; +import '../workspace/workspace_controller.dart'; +import 'reference_controller.dart'; +import 'reference_index.dart'; +import 'markdown_move_ui.dart'; +import 'dart:io'; +import 'dart:async'; + +Future<LinkDestination?> showReferenceLinkChooser( + BuildContext context, { + String initialDestination = '', +}) async { + final focus = FocusManager.instance.primaryFocus; + final value = await showBusyMarkModalDialog<LinkDestination>( + context, + builder: (_) => + ReferenceLinkChooser(initialDestination: initialDestination), + ); + if (focus?.context?.mounted == true) focus!.requestFocus(); + return value; +} + +class ReferenceLinkChooser extends ConsumerStatefulWidget { + const ReferenceLinkChooser({super.key, this.initialDestination = ''}); + final String initialDestination; + @override + ConsumerState<ReferenceLinkChooser> createState() => + _ReferenceLinkChooserState(); +} + +class _ReferenceLinkChooserState extends ConsumerState<ReferenceLinkChooser> { + late final _url = TextEditingController(text: widget.initialDestination); + String _query = ''; + int _limit = 100; + late final String? _workspaceId; + late final String? _bufferId; + bool? _prettyUrls; + String? _routeFailure; + @override + void initState() { + super.initState(); + final state = ref.read(workspaceControllerProvider); + _workspaceId = state.workspace?.id; + _bufferId = state.activeBufferId; + if (state.workspace?.nextcloudAccountId case final id?) { + unawaited(_confirmRoute(id)); + } + } + + Future<void> _confirmRoute(String id) async { + try { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + final pretty = await repository.confirmNoteWebRoute(id); + if (mounted) setState(() => _prettyUrls = pretty); + } on Object catch (error) { + if (mounted) setState(() => _routeFailure = error.toString()); + } + } + + @override + void dispose() { + _url.dispose(); + super.dispose(); + } + + void _complete(LinkDestination destination) { + final state = ref.read(workspaceControllerProvider); + Navigator.pop( + context, + state.workspace?.id == _workspaceId && state.activeBufferId == _bufferId + ? destination + : null, + ); + } + + LinkDestination? _destination(ReferenceTarget target, ReferenceIndex index) { + final state = ref.read(workspaceControllerProvider); + if (!target.document.available || + index.missingNoteIds.contains(target.document.serverId) || + state.workspace?.id != _workspaceId || + state.activeBufferId != _bufferId) { + return null; + } + if (index.server != null) { + if (target.document.serverId == null || _prettyUrls == null) return null; + // Nextcloud browser heading fragments are not a verified portable route. + if (target.anchor != null) return null; + return LinkDestination( + nextcloudNoteUrl( + index.server!, + target.document.serverId!, + pretty: _prettyUrls!, + ).toString(), + ); + } + final source = state.activeBuffer?.filePath; + final path = target.document.path; + if (source == null || path == null) return null; + final relative = p.equals(source, path) && target.anchor != null + ? '' + : p.relative(path, from: p.dirname(source)); + final project = state.workspace?.writersideProject; + final sourceModule = project?.topicOwnerForPath(source); + final origin = + sourceModule != null && + target.moduleId != null && + sourceModule.config.moduleName != target.moduleId + ? target.moduleId + : null; + final href = (sourceModule == null + ? relative + : p.equals(source, path) && target.anchor != null + ? '' + : origin == null + ? relative + : project?.modules + .expand((m) => m.topics) + .where((t) => p.equals(t.filePath, path)) + .firstOrNull + ?.fileName ?? + relative); + return LinkDestination( + href + .split('/') + .map( + (part) => Uri.encodeComponent( + part, + ).replaceAll('(', '%28').replaceAll(')', '%29'), + ) + .join('/') + + (target.anchor == null + ? '' + : '#${Uri.encodeComponent(target.anchor!)}'), + origin: origin, + ); + } + + @override + Widget build(BuildContext context) { + final data = ref.watch( + referenceTargetResultsProvider((text: _query, count: _limit)), + ); + final index = ref.watch(referenceIndexProvider).value; + ref.watch( + workspaceControllerProvider.select( + (s) => (s.workspace?.id, s.activeBufferId), + ), + ); + return BusyMarkDialogShell( + title: context.l10n.referenceChooseTarget, + maxWidth: 650, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context), + label: context.l10n.cancel, + ), + BusyMarkDialogButton( + onPressed: () => _complete(LinkDestination(_url.text)), + label: context.l10n.apply, + ), + ], + children: [ + if (_routeFailure != null) Text(_routeFailure!), + TextField( + controller: _url, + autofocus: true, + decoration: InputDecoration(labelText: context.l10n.source), + onSubmitted: (value) => _complete(LinkDestination(value)), + ), + const SizedBox(height: BusyMarkSpacing.md), + TextField( + decoration: InputDecoration(labelText: context.l10n.quickOpen), + onChanged: (query) => setState(() { + _query = query.toLowerCase(); + _limit = 100; + }), + ), + SizedBox( + height: 320, + child: data.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Text(error.toString()), + data: (page) { + final targets = page.values; + return ListView.builder( + itemCount: + targets.length + (page.total > targets.length ? 1 : 0), + itemBuilder: (context, i) { + if (i == _limit) { + return TextButton( + onPressed: () => setState(() => _limit += 100), + child: Text(context.l10n.workspaceSearchShowMore), + ); + } + final target = targets[i]; + final destination = index == null + ? null + : _destination(target, index); + return ListTile( + title: Text(target.label ?? target.document.title), + subtitle: Text( + destination == null + ? index?.missingNoteIds.contains( + target.document.serverId, + ) == + true + ? context.l10n.referenceMissing + : index?.server == null || + !target.document.available + ? context.l10n.referenceUnavailable + : context.l10n.referenceNoPortableId + : '${destination.href}${destination.origin == null ? '' : ' / ${destination.origin}'}', + ), + enabled: destination != null, + onTap: () => _complete(destination!), + ); + }, + ); + }, + ), + ), + ], + ); + } +} + +class ReferencesPanel extends ConsumerStatefulWidget { + const ReferencesPanel({super.key}); + @override + ConsumerState<ReferencesPanel> createState() => _ReferencesPanelState(); +} + +class _ReferencesPanelState extends ConsumerState<ReferencesPanel> { + bool _incoming = false; + String? _anchor; + int _limit = 100; + String? _identity; + Future<void> _navigate( + IndexedReference result, + bool target, { + bool source = false, + }) async { + final workspace = ref.read(workspaceControllerProvider).workspace; + final document = target ? result.target?.document : result.source; + if (document == null || workspace == null) return; + final controller = ref.read(workspaceControllerProvider.notifier); + final opened = document.localId != null + ? await controller.openNextcloudNote(document.localId!) + : await controller.openActiveFile(document.path!); + if (!mounted || + !opened || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null || + (buffer.remoteNote?.localId ?? buffer.filePath) != document.identity) { + return; + } + var span = target ? result.target?.span : result.authored.occurrence; + if (referenceRevision(buffer.text) != document.revision) { + ref.read(referenceIndexProvider.notifier).refresh(); + // Refresh stale positions instead of selecting unrelated text. The user + // can navigate again when the refreshed occurrence is visible. + return; + } + if (span == null) return; + if (source) { + controller.updateActiveEditorMode(DocumentViewModePreference.source); + } + ref + .read(notesMatchNavigationProvider.notifier) + .navigate( + document.identity, + span.startOffset, + span.endOffset, + document.revision, + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(workspaceControllerProvider); + final identity = + state.activeBuffer?.remoteNote?.localId ?? state.activeBuffer?.filePath; + if (identity != _identity) { + _identity = identity; + _anchor = null; + _limit = 100; + } + final data = ref.watch( + referencePanelResultsProvider(( + identity: identity ?? '', + incoming: _incoming, + anchor: _anchor, + count: _limit, + )), + ); + final recovery = + ref.watch(refactoringRecoveryProvider).value ?? const <String>[]; + final account = state.workspace?.isRemote == true + ? ref.watch(nextcloudConnectionProvider).account + : null; + return Column( + children: [ + for (final journal in recovery) + TextButton( + onPressed: () async { + await reviewMarkdownRecovery(context, ref, journal); + if (mounted) ref.invalidate(refactoringRecoveryProvider); + }, + child: Text(context.l10n.notesRecovery), + ), + Text( + account == null + ? state.workspace?.filesystemRootPath ?? '' + : '${account.loginName} · ${account.server}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (state.workspace?.writersideProject case final project?) + Text( + '${project.activeModule?.config.moduleName ?? ''} / ${project.activeInstanceId ?? ''}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SegmentedButton<bool>( + segments: [ + ButtonSegment( + value: true, + label: Text(context.l10n.referenceIncoming), + ), + ButtonSegment( + value: false, + label: Text(context.l10n.referenceOutgoing), + ), + ], + selected: {_incoming}, + onSelectionChanged: (v) => setState(() { + _incoming = v.single; + _limit = 100; + }), + ), + Expanded( + child: data.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Column( + children: [ + Text(error.toString()), + TextButton( + onPressed: () => + ref.read(referenceIndexProvider.notifier).refresh(), + child: Text(context.l10n.tocTemplateRetry), + ), + ], + ), + data: (page) { + final headings = page.headings; + final selected = page.references; + if (!headings.any((h) => h.anchor == _anchor)) _anchor = null; + return Column( + children: [ + DropdownButton<String>( + value: _anchor, + isExpanded: true, + hint: Text(context.l10n.referenceWholeDocument), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.referenceWholeDocument), + ), + for (final h in headings) + DropdownMenuItem( + value: h.anchor, + child: Text(h.label ?? h.anchor!), + ), + ], + onChanged: (v) => setState(() { + _anchor = v; + _limit = 100; + }), + ), + Expanded( + child: ListView.builder( + itemCount: + selected.length + + (page.total > selected.length ? 1 : 0), + itemBuilder: (context, i) { + if (i == _limit) { + return TextButton( + onPressed: () => setState(() => _limit += 100), + child: Text(context.l10n.workspaceSearchShowMore), + ); + } + final result = selected[i]; + return ListTile( + title: Text( + _incoming + ? result.source.title + : result.target?.document.title ?? + result.authored.destination, + ), + subtitle: Text( + '${result.authored.label}\n${_resolutionLabel(context, result.resolution)}' + '${result.authored.moduleId == null ? '' : '\n${result.authored.moduleId} / ${result.authored.origin ?? ''}'}', + ), + onTap: () => + _navigate(result, _incoming ? false : true), + trailing: PopupMenuButton<bool>( + icon: const Icon(BusyMarkGlyphs.link), + onSelected: (target) => + _navigate(result, target, source: true), + itemBuilder: (context) => [ + PopupMenuItem( + value: false, + child: Text( + context.l10n.referenceSourceLocation, + ), + ), + if (result.target != null) + PopupMenuItem( + value: true, + child: Text( + context.l10n.referenceTargetLocation, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} + +String _resolutionLabel(BuildContext context, ReferenceResolution status) => + switch (status) { + ReferenceResolution.resolved => context.l10n.referenceResolved, + ReferenceResolution.missing => context.l10n.referenceMissing, + ReferenceResolution.ambiguous => context.l10n.referenceAmbiguous, + ReferenceResolution.unavailable => context.l10n.referenceUnavailable, + ReferenceResolution.unsupported => context.l10n.referenceUnsupported, + }; + +final refactoringRecoveryProvider = FutureProvider<List<String>>((ref) async { + final workspace = ref.watch(workspaceControllerProvider).workspace; + if (workspace?.filesystemRootPath == null) return const []; + final folder = Directory( + p.join(workspace!.rootPath, '.busymark-refactoring'), + ); + if (!await folder.exists()) return const []; + if (await FileSystemEntity.isLink(folder.path)) return const []; + return (await folder.list(followLinks: false).toList()) + .whereType<File>() + .where((f) => p.extension(f.path) == '.json') + .map((f) => f.path) + .toList(); +}); diff --git a/lib/src/references/writerside_reference_adapter.dart b/lib/src/references/writerside_reference_adapter.dart new file mode 100644 index 00000000..2381ed06 --- /dev/null +++ b/lib/src/references/writerside_reference_adapter.dart @@ -0,0 +1,229 @@ +import '../writerside/writerside_document_resolver.dart'; +import '../writerside/writerside_project.dart'; +import '../writerside/writerside_document.dart'; +import '../writerside/writerside_schema.dart'; +import 'reference_index.dart'; +import 'markdown_references.dart'; + +// Adapts the established Writerside symbol index; there is no second parser. +void addWritersideReferences( + ReferenceIndex index, + WritersideProjectIndex writerside, + String? instanceId, +) { + final documents = index.documents, + targets = index.targets, + references = index.references; + final origins = writerside.modulesById; + String symbolLabel(WritersideSymbol symbol) { + final node = origins[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull; + if (node is WritersideMarkdownBlockNode) return node.block.plainText; + if (node is WritersideElementNode) { + return node.attributes['title'] ?? symbol.name; + } + return symbol.name; + } + + for (final symbol in writerside.symbols.where( + (s) => { + WritersideSymbolKind.topic, + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + }.contains(s.kind), + )) { + final document = documents[symbol.filePath]; + if (document == null) continue; + final anchor = symbol.kind == WritersideSymbolKind.topic + ? null + : symbol.name; + if (anchor == null) { + targets.removeWhere( + (t) => + t.document.identity == document.identity && + t.anchor == null && + t.moduleId == null, + ); + } + if (!targets.any( + (t) => t.document.identity == document.identity && t.anchor == anchor, + )) { + targets.add( + ReferenceTarget( + document, + anchor: anchor, + label: symbolLabel(symbol), + span: symbol.span, + moduleId: symbol.moduleId, + scopeSpan: writerside.modulesById[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull + ?.span, + ), + ); + } + } + for (final ref in writerside.references.where( + (r) => { + WritersideSymbolKind.topic, + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + }.contains(r.kind), + )) { + final document = documents[ref.filePath]; + if (document == null) continue; + final sourceModule = origins[ref.moduleId]; + final sourceTopic = sourceModule?.topics + .where((t) => t.filePath == ref.filePath) + .firstOrNull; + final sourceInstance = sourceModule?.instances + .where((i) => i.id == instanceId) + .firstOrNull; + final sourceNode = sourceTopic?.document + .walk() + .where( + (n) => + n.span.startOffset <= ref.span.startOffset && + n.span.endOffset >= ref.span.endOffset, + ) + .lastOrNull; + Map<String, String>? dynamicLink; + if (ref.value.contains('%') && + sourceModule != null && + sourceTopic != null && + sourceNode != null && + sourceNode is WritersideElementNode && + sourceNode.semanticKind == WritersideSemanticKind.link) { + dynamicLink = const WritersideDocumentResolver().resolveAuthoredLink( + context: WritersideResolveContext( + module: sourceModule, + topic: sourceTopic, + instance: sourceInstance, + modulesByOrigin: origins, + ), + node: sourceNode, + destination: ref.value, + origin: ref.origin, + ); + } + final definitions = writerside.definitions( + dynamicLink?['resolved-destination'] ?? ref.value, + moduleId: ref.moduleId, + origin: ref.origin, + kind: ref.kind, + filePath: ref.filePath, + referenceOffset: ref.span.startOffset, + scopeReference: ref.scopeReference, + ); + final unique = <String, WritersideSymbol>{ + for (final symbol in definitions) + '${symbol.filePath}:${symbol.kind == WritersideSymbolKind.topic ? '' : '${symbol.name}:${symbol.span?.startOffset}'}': + symbol, + }; + var resolution = unique.isEmpty + ? ReferenceResolution.missing + : unique.length > 1 + ? ReferenceResolution.ambiguous + : ReferenceResolution.resolved; + if (unique.isEmpty && + writerside.modulesById[ref.origin ?? ref.moduleId] + ?.isUnparsedTopicReference(ref.value.split('#').first) == + true) { + resolution = ReferenceResolution.unavailable; + } + ReferenceTarget? target; + var includeReference = ref.kind == WritersideSymbolKind.snippet; + if (unique.length == 1) { + final symbol = unique.values.single; + final targetDocument = documents[symbol.filePath]; + if (targetDocument != null) { + target = ReferenceTarget( + targetDocument, + anchor: symbol.kind == WritersideSymbolKind.topic + ? null + : symbol.name, + label: symbolLabel(symbol), + span: symbol.span, + moduleId: symbol.moduleId, + scopeSpan: origins[symbol.moduleId]?.topics + .where((t) => t.filePath == symbol.filePath) + .firstOrNull + ?.document + .contentById(symbol.name) + ?.firstOrNull + ?.span, + ); + } + final module = origins[ref.moduleId]; + final topic = module?.topics + .where((t) => t.filePath == ref.filePath) + .firstOrNull; + final instance = module?.instances + .where((i) => i.id == instanceId) + .firstOrNull; + final node = topic?.document + .walk() + .where( + (n) => + n.span.startOffset <= ref.span.startOffset && + n.span.endOffset >= ref.span.endOffset, + ) + .lastOrNull; + if (module != null && topic != null && node != null) { + includeReference = + node is WritersideElementNode && + node.semanticKind == WritersideSemanticKind.include; + final available = includeReference + ? const WritersideDocumentResolver().authoredIncludeAvailable( + context: WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: origins, + ), + include: node, + ) + : (dynamicLink ?? + const WritersideDocumentResolver().resolveAuthoredLink( + context: WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: origins, + ), + node: node, + destination: ref.value, + origin: ref.origin, + ))['resolved-available'] == + 'true'; + if (!available) resolution = ReferenceResolution.unavailable; + } + } + references.add( + IndexedReference( + source: document, + authored: AuthoredReference( + destination: ref.value, + kind: includeReference + ? AuthoredReferenceKind.include + : AuthoredReferenceKind.topic, + occurrence: ref.span, + destinationSpan: ref.span, + label: ref.sourceValue ?? ref.value, + origin: ref.origin, + moduleId: ref.moduleId, + ), + resolution: resolution, + target: target, + ), + ); + } + index.rebuildLookups(); +} diff --git a/lib/src/spellcheck/spelling_inline_projection.dart b/lib/src/spellcheck/spelling_inline_projection.dart index 9d6a2fe6..dbf7f6ba 100644 --- a/lib/src/spellcheck/spelling_inline_projection.dart +++ b/lib/src/spellcheck/spelling_inline_projection.dart @@ -157,6 +157,10 @@ List<SpellingInlineProjection> projectSpellingInlineRuns({ switch (inline.kind) { case BusyInlineKind.math: case BusyInlineKind.html: + case BusyInlineKind.writersideControl: + case BusyInlineKind.writersidePath: + case BusyInlineKind.writersideUiPath: + case BusyInlineKind.writersideShortcut: case BusyInlineKind.writersideVariable: case BusyInlineKind.unknown: barrier(); diff --git a/lib/src/spellcheck/wysiwyg_spelling_projection.dart b/lib/src/spellcheck/wysiwyg_spelling_projection.dart index eb34e664..dc6e3504 100644 --- a/lib/src/spellcheck/wysiwyg_spelling_projection.dart +++ b/lib/src/spellcheck/wysiwyg_spelling_projection.dart @@ -172,7 +172,7 @@ final class WysiwygSpellingProjector { if (block.isGenerated || block.isSourceProtected || block.isSourceOnly || - !_eligibleBlockKinds.contains(block.kind)) { + !_eligibleBlock(block)) { return; } if (block.kind == BusyBlockKind.table) { @@ -462,7 +462,7 @@ _MergedRichProjection _mergeFieldMappings({ List<({int start, int end})> _sourceOnlyRegions(List<BusyBlock> blocks) { final result = <({int start, int end})>[]; void visit(BusyBlock block) { - if (!_eligibleBlockKinds.contains(block.kind) || + if (!_eligibleBlock(block) || block.isSourceProtected || block.isGenerated || block.isSourceOnly) { @@ -729,7 +729,7 @@ List<_SourceFieldRegion> _sourceFieldRegions( if (block.isGenerated || block.isSourceProtected || block.isSourceOnly || - !_eligibleBlockKinds.contains(block.kind)) { + !_eligibleBlock(block)) { return; } if (block.kind == BusyBlockKind.table) { @@ -788,3 +788,7 @@ const _eligibleBlockKinds = { BusyBlockKind.writersideTabs, BusyBlockKind.writersideProcedure, }; + +bool _eligibleBlock(BusyBlock block) => + _eligibleBlockKinds.contains(block.kind) || + block.attributes['busymark-list-form'] == 'markdown'; diff --git a/lib/src/visualization/web_render_host.dart b/lib/src/visualization/web_render_host.dart index 4ec3ab11..11981829 100644 --- a/lib/src/visualization/web_render_host.dart +++ b/lib/src/visualization/web_render_host.dart @@ -295,6 +295,10 @@ class PlatformWebRenderHost implements WebRenderHost { } on TimeoutException { cancel(); rethrow; + } catch (_) { + // Native cancellation can finish before an in-flight WebKit callback. + cancellationToken.throwIfCancelled(); + rethrow; } finally { cancellationToken.removeListener(cancel); } diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart index e7a0b4d8..c4b977e0 100644 --- a/lib/src/workspace/document_buffer.dart +++ b/lib/src/workspace/document_buffer.dart @@ -34,6 +34,7 @@ class DocumentUndoState { this.undo = const [], this.redo = const [], this.activeGroup, + this.restoredEntry, }); static const historyLimit = 100; @@ -41,6 +42,38 @@ class DocumentUndoState { final List<DocumentHistoryState> undo; final List<DocumentHistoryState> redo; final String? activeGroup; + final DocumentHistoryState? restoredEntry; + + /// Identify an actual Undo/Redo traversal by the retained history objects. + /// Equal text, a new edit, or a grouped edit does not establish restoration. + DocumentHistoryState? restoredEntryFrom(DocumentUndoState previous) { + bool same(List<DocumentHistoryState> a, List<DocumentHistoryState> b) => + a.length == b.length && + Iterable.generate(a.length).every((i) => identical(a[i], b[i])); + bool appended( + List<DocumentHistoryState> before, + List<DocumentHistoryState> after, + ) => + after.isNotEmpty && + same( + before.skip(math.max(0, before.length + 1 - historyLimit)).toList(), + after.take(after.length - 1).toList(), + ) && + !before.any((entry) => identical(entry, after.last)); + if (previous.undo.isNotEmpty && + identical(restoredEntry, previous.undo.last) && + same(previous.undo.take(previous.undo.length - 1).toList(), undo) && + appended(previous.redo, redo)) { + return previous.undo.last; + } + if (previous.redo.isNotEmpty && + identical(restoredEntry, previous.redo.last) && + same(previous.redo.take(previous.redo.length - 1).toList(), redo) && + appended(previous.undo, undo)) { + return previous.redo.last; + } + return null; + } DocumentUndoState push(DocumentHistoryState state, {String? group}) { if (group != null && group == activeGroup && undo.isNotEmpty) { @@ -57,6 +90,7 @@ class DocumentUndoState { DocumentUndoState afterUndo(DocumentHistoryState current) => DocumentUndoState( + restoredEntry: undo.last, undo: List.unmodifiable(undo.take(undo.length - 1)), redo: List.unmodifiable( [...redo, current].skip(math.max(0, redo.length + 1 - historyLimit)), @@ -66,6 +100,7 @@ class DocumentUndoState { DocumentUndoState afterRedo(DocumentHistoryState current) => DocumentUndoState( + restoredEntry: redo.last, undo: List.unmodifiable( [...undo, current].skip(math.max(0, undo.length + 1 - historyLimit)), ), diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index d35b45a7..4e107f1c 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -684,6 +684,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { _selectPage(SettingsPage.appearance); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 22189c42..75ef3f7d 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -325,6 +325,7 @@ class _WelcomeScreenState extends ConsumerState<WelcomeScreen> { context.go(settingsLocation(SettingsReturnTarget.welcome)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 7aa58be1..576d286d 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1,3 +1,17 @@ +import '../../editor/wysiwyg/wysiwyg_commands.dart'; +import '../../writerside/writerside_property_validation.dart'; +import '../../writerside/writerside_effective_properties.dart'; +import '../../editor/wysiwyg/writerside_inspector_target.dart'; +import '../../editor/wysiwyg/writerside_properties.dart'; +import '../../editor/wysiwyg/writerside_property_session.dart'; +import '../../editor/wysiwyg/wysiwyg_document_controller.dart'; +import '../../editor/wysiwyg/wysiwyg_inline_controller.dart'; +import '../../references/markdown_move_ui.dart'; +import '../../nextcloud_notes/presentation/notes_workspace_ui.dart'; +import '../../nextcloud_notes/data/server_uri.dart'; +import '../../references/reference_ui.dart'; +import '../../references/reference_controller.dart'; +import '../../nextcloud_notes/domain/notes_search.dart'; import '../../assets/provider_asset_ingestion_service.dart'; import '../../assets/document_media_context.dart'; import '../../nextcloud_notes/application/nextcloud_connection.dart'; @@ -11,7 +25,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show setEquals; +import 'package:flutter/foundation.dart' show setEquals, mapEquals; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -68,6 +82,7 @@ import '../../editor/source/source_document.dart'; import '../../editor/source/source_editor.dart'; import '../../editor/source/source_search.dart'; import '../../editor/wysiwyg/wysiwyg_editor.dart'; +import '../../editor/wysiwyg/writerside_editing_adapter.dart'; import '../../editor/wysiwyg/wysiwyg_session_state.dart'; import '../../editor/writerside_video_view.dart'; import '../../feedback/presentation/feedback_dialog.dart'; @@ -129,6 +144,7 @@ import '../../writerside/writerside_parsers.dart'; import '../../writerside/writerside_document_resolver.dart'; import '../../writerside/writerside_document_renderer.dart'; import '../../writerside/writerside_title_editor.dart'; +import '../workspace_service.dart' show WritersideTitleEditSession; final _problemsPresentationProvider = NotifierProvider<_ProblemsPresentation, bool>(_ProblemsPresentation.new); @@ -628,9 +644,13 @@ class _SearchNavigationTarget { required this.endOffset, required this.query, required this.request, + this.localId, + this.digest, }); - final String filePath; + final String? filePath; + final String? localId; + final String? digest; final int line; final int startOffset; final int endOffset; @@ -678,6 +698,68 @@ class _SearchReturnFocus { } } +Future<void> _publishWritersideTitleDialog( + BuildContext context, + WidgetRef ref, + WritersideTitleEditSession session, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final originalTitle = session.topic.title ?? ''; + final originalInstanceTitle = + session.topic.titleOverrides + .where((override) => override.instance == session.instanceId) + .firstOrNull + ?.title ?? + ''; + final originalTocTitle = session.identity.tocTitle ?? ''; + final nativeEdit = await const NativeWritersideDialogService().showEditTitle( + dialogTitle: context.l10n.tocEditTitleDialog, + topicTitleLabel: context.l10n.tocTopicTitleField, + advancedLabel: context.l10n.tocAdvancedSettings, + instanceTitleLabel: context.l10n.tocInstanceTitleField(session.instanceId), + tocTitleLabel: context.l10n.tocOnlyTitleField, + instanceTitleEditable: session.topic.format == WritersideTopicFormat.xml, + instanceExplanation: session.topic.format == WritersideTopicFormat.xml + ? context.l10n.tocInstanceTitleExplanation + : context.l10n.wsMarkdownInstanceTitleLimitation, + tocExplanation: context.l10n.tocOnlyTitleExplanation, + documentationLabel: context.l10n.tocTitleDocumentationLink, + documentationUrl: 'https://www.jetbrains.com/help/writerside/topics.html', + initialTitle: originalTitle, + initialInstanceTitle: originalInstanceTitle, + initialTocTitle: originalTocTitle, + cancelLabel: context.l10n.cancel, + okLabel: context.l10n.tocOk, + textDirection: Directionality.of(context), + ); + if (!context.mounted) return; + final WritersideTitleEdit? edit; + if (nativeEdit.available) { + final values = nativeEdit.value; + edit = values == null + ? null + : WritersideTitleEdit( + title: values.title == originalTitle ? null : values.title, + instanceTitle: + session.topic.format != WritersideTopicFormat.xml || + values.instanceTitle == originalInstanceTitle + ? null + : values.instanceTitle, + tocTitle: values.tocTitle == originalTocTitle + ? null + : values.tocTitle, + ); + } else { + edit = await showBusyMarkModalDialog<WritersideTitleEdit>( + context, + builder: (_) => WritersideTitleDialog(session: session), + ); + } + if (edit != null && context.mounted) { + await controller.editWritersideTitles(session, edit); + } +} + class WorkspaceScreen extends ConsumerWidget { const WorkspaceScreen({super.key}); @@ -1258,6 +1340,11 @@ class WorkspaceScreen extends ConsumerWidget { } void _openSearch(WidgetRef ref) { + if (ref.read(workspaceControllerProvider).workspace?.isRemote == true) { + final target = ref.context; + unawaited(showNotesSearch(target, ref)); + return; + } final search = ref.read(_workspaceSearchProvider); ref.read(_headerSearchFocusRequestProvider.notifier).request(); if (!search.active) { @@ -1412,6 +1499,7 @@ class WorkspaceScreen extends ConsumerWidget { context.go(settingsLocation(SettingsReturnTarget.workspace)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.quickOpen: case BusyMarkMainMenuAction.commandPalette: return; case BusyMarkMainMenuAction.syntaxReference: @@ -2271,6 +2359,7 @@ class _Sidebar extends ConsumerStatefulWidget { } class _SidebarState extends ConsumerState<_Sidebar> { + late final WorkspaceController _topicActionController; late int _tab; late String _workspaceId; String? _activeFilePath; @@ -2280,6 +2369,10 @@ class _SidebarState extends ConsumerState<_Sidebar> { @override void dispose() { + final controller = _topicActionController; + if (controller.renameInspectedTopic == _renameInspectedTopic) { + controller.renameInspectedTopic = null; + } _replacementCancellation?.cancel(); super.dispose(); } @@ -2287,6 +2380,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { @override void initState() { super.initState(); + _topicActionController = ref.read(workspaceControllerProvider.notifier); + _topicActionController.renameInspectedTopic = _renameInspectedTopic; _workspaceId = widget.workspace.id; _activeFilePath = widget.workspace.activeFilePath; _tab = _initialSidebarTabIndex(widget.workspace); @@ -2334,6 +2429,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { final repositoryInfo = gitState.attachedWorkspace?.id == widget.workspace.id ? gitState.repositoryInfo : null; + ref.listen(referencePanelRequestProvider, (_, next) { + _selectTab(_SidebarTab.references, tabs); + }); ref.listen<_SidebarTab?>(_sidebarShortcutRequestProvider, (_, next) { if (next == null) { return; @@ -2446,6 +2544,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { .target ?.requestEditorFocus(), ), + _SidebarTab.references => const ReferencesPanel(), _SidebarTab.localHistory => LocalHistoryPanel( focusSearchRequest: ref.watch( localHistoryFindRequestProvider, @@ -2530,6 +2629,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { ); } + Future<bool> _renameInspectedTopic(String path) => + _startWritersideTopicRename(context, path); + Future<bool> _startWritersideTopicRename( BuildContext context, String topicPath, @@ -3068,7 +3170,16 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } -enum _SidebarTab { notes, files, toc, outline, git, localHistory, clipboard } +enum _SidebarTab { + notes, + files, + toc, + outline, + git, + localHistory, + clipboard, + references, +} int _preferredSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); @@ -3096,16 +3207,19 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.untitledMarkdown => const [ _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.singleMarkdown => const [ _SidebarTab.outline, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.markdownFolder => const [ _SidebarTab.files, @@ -3113,6 +3227,7 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.git, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], WorkspaceKind.writersideModule => const [ _SidebarTab.files, @@ -3121,6 +3236,7 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { _SidebarTab.git, _SidebarTab.localHistory, _SidebarTab.clipboard, + _SidebarTab.references, ], }; } @@ -3134,6 +3250,7 @@ String _sidebarTabLabel(BuildContext context, _SidebarTab tab) { _SidebarTab.git => context.l10n.git, _SidebarTab.localHistory => context.l10n.localHistory, _SidebarTab.clipboard => context.l10n.clipboardHistory, + _SidebarTab.references => context.l10n.references, }; } @@ -3146,6 +3263,7 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { _SidebarTab.git => BusyMarkGlyphs.branch, _SidebarTab.localHistory => BusyMarkGlyphs.sidebarLocalHistory, _SidebarTab.clipboard => BusyMarkGlyphs.copy, + _SidebarTab.references => BusyMarkGlyphs.link, }; } @@ -3161,6 +3279,7 @@ String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { _SidebarTab.git => BusyMarkCommandIds.sidebarGit, _SidebarTab.localHistory => BusyMarkCommandIds.localHistory, _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, + _SidebarTab.references => BusyMarkCommandIds.references, }; return commands[id]?.shortcut?.label; } @@ -4741,9 +4860,21 @@ class _FilesTabState extends ConsumerState<_FilesTab> { !await saveOrConfirmSafeToChangeActiveFile(context, ref)) { return false; } + if (!mounted) return false; + final plan = await reviewMarkdownMove( + this.context, + ref, + path, + p.join(p.dirname(path), newName), + ); + if (!mounted || !plan.proceed) return false; return ref .read(workspaceControllerProvider.notifier) - .renameWorkspaceEntity(path, newName); + .renameWorkspaceEntity( + path, + newName, + reviewedPlan: plan.plan, + ); }()); if (renamed) { setState(() => _cutEntry = null); @@ -4760,9 +4891,21 @@ class _FilesTabState extends ConsumerState<_FilesTab> { return; } final directory = isFolder ? path : p.dirname(path); + if (!mounted) return; + final plan = await reviewMarkdownMove( + this.context, + ref, + entry.path, + p.join(directory, p.basename(entry.path)), + ); + if (!mounted || !plan.proceed) return; final moved = await ref .read(workspaceControllerProvider.notifier) - .moveWorkspaceEntity(entry.path, directory); + .moveWorkspaceEntity( + entry.path, + directory, + reviewedPlan: plan.plan, + ); if (moved) { setState(() => _cutEntry = null); _expandedPaths.addAll(_directoryAncestorPaths(directory)); @@ -6931,59 +7074,7 @@ class _TocTabState extends ConsumerState<_TocTab> { topicPath: topic.filePath, ); if (session == null || !mounted || !context.mounted) return; - final originalTitle = session.topic.title ?? ''; - final originalInstanceTitle = - session.topic.titleOverrides - .where((override) => override.instance == session.instanceId) - .firstOrNull - ?.title ?? - ''; - final originalTocTitle = session.identity.tocTitle ?? ''; - final nativeEdit = await const NativeWritersideDialogService() - .showEditTitle( - dialogTitle: context.l10n.tocEditTitleDialog, - topicTitleLabel: context.l10n.tocTopicTitleField, - advancedLabel: context.l10n.tocAdvancedSettings, - instanceTitleLabel: context.l10n.tocInstanceTitleField( - session.instanceId, - ), - tocTitleLabel: context.l10n.tocOnlyTitleField, - instanceExplanation: context.l10n.tocInstanceTitleExplanation, - tocExplanation: context.l10n.tocOnlyTitleExplanation, - documentationLabel: context.l10n.tocTitleDocumentationLink, - documentationUrl: - 'https://www.jetbrains.com/help/writerside/topics.html', - initialTitle: originalTitle, - initialInstanceTitle: originalInstanceTitle, - initialTocTitle: originalTocTitle, - cancelLabel: context.l10n.cancel, - okLabel: context.l10n.tocOk, - textDirection: Directionality.of(context), - ); - if (!mounted || !context.mounted) return; - final WritersideTitleEdit? edit; - if (nativeEdit.available) { - final values = nativeEdit.value; - edit = values == null - ? null - : WritersideTitleEdit( - title: values.title == originalTitle ? null : values.title, - instanceTitle: values.instanceTitle == originalInstanceTitle - ? null - : values.instanceTitle, - tocTitle: values.tocTitle == originalTocTitle - ? null - : values.tocTitle, - ); - } else { - edit = await showBusyMarkModalDialog<WritersideTitleEdit>( - context, - builder: (_) => WritersideTitleDialog(session: session), - ); - } - if (edit != null && mounted) { - await controller.editWritersideTitles(session, edit); - } + await _publishWritersideTitleDialog(context, ref, session); case _TocTreeAction.duplicate: if (topic == null || rawNode == null) return; String? validateDuplicateName(String name) { @@ -10259,6 +10350,18 @@ List<DocumentOutlineHeading> _activeDocumentOutline(WorkspaceState state) { return preview.outline; } final buffer = state.activeBuffer; + if (workspace != null && + buffer != null && + resolveWorkspaceDocumentContext(workspace, buffer).kind == + DocumentKind.writersideXmlTopic) { + return const WritersideEditingAdapter() + .parseXml( + filePath: buffer.filePath ?? workspace.activeFilePath ?? '', + source: buffer.text, + ) + ?.outline ?? + const []; + } final markdown = workspace?.markdown; if (workspace == null || markdown == null) return const []; if (buffer == null) { @@ -11966,6 +12069,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _SourceNavigationTarget? _lastSourceNavigationTarget; final _sourceEditorKey = GlobalKey<BusyMarkSourceEditorState>(); final _wysiwygEditorKey = GlobalKey<BusyMarkWysiwygEditorState>(); + WritersideInspectorTarget? _inspected; + WritersideInspectorTarget? _pendingInspectorHistoryRestore; + final _inspectedHistory = + Map<DocumentHistoryState, WritersideInspectorTarget>.identity(); + bool _videoPropertyDraft = false; + final _propertiesPanelKey = GlobalKey(); + late final WorkspaceController _inspectorWorkspaceController; late final SpellingSessionController _spelling; final _previewBlockContexts = <int, BuildContext>{}; String _lastPath = ''; @@ -11976,6 +12086,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _cachedWysiwygPath; String? _cachedWysiwygSource; MarkdownMode? _cachedWysiwygMode; + DocumentKind? _cachedWysiwygKind; String? _wysiwygScrollHeadingId; String? _wysiwygScrollBlockId; String? _wysiwygSearchQuery; @@ -11988,6 +12099,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { @override void initState() { super.initState(); + _inspectorWorkspaceController = ref.read( + workspaceControllerProvider.notifier, + ); + _inspectorWorkspaceController.toggleElementProperties = _toggleProperties; + _inspectorWorkspaceController.propertyEdits.addListener( + _handlePropertyDraftChange, + ); _spelling = ref.read(spellingSessionControllerProvider) ..addListener(_handleSpellingChanged); _spelling.synchronizeOpenBuffers( @@ -12001,6 +12119,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { @override void dispose() { + _inspectorWorkspaceController.propertyEdits.removeListener( + _handlePropertyDraftChange, + ); + if (_inspectorWorkspaceController.toggleElementProperties == + _toggleProperties) { + _inspectorWorkspaceController.toggleElementProperties = null; + } _previewItemPositionsListener.itemPositions.removeListener( _handlePreviewVisibleItemsChanged, ); @@ -12033,10 +12158,22 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { bufferId: nextBuffer.id, ); } + // Refresh the pinned source owner before building this revision. A + // temporary unavailable pane would dispose the focused property field + // between Undo and Redo. Editor selection restoration remains independent. + _pendingInspectorHistoryRestore = null; + _trackInspectedHistory(previousBuffer, nextBuffer); + final document = _wysiwygDocument(); + _refreshInspectedElement(document); } _spelling.synchronizeOpenBuffers( widget.state.documentBuffers.map((buffer) => buffer.id), ); + if (previousBuffer?.id != nextBuffer?.id) { + _inspected = null; + _pendingInspectorHistoryRestore = null; + _inspectedHistory.clear(); + } final path = widget.state.activeBuffer?.filePath ?? ''; if (path != _lastPath || previousBuffer?.id != nextBuffer?.id) { _lastPath = path; @@ -12125,10 +12262,41 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } _scrollToOutlineTarget(next); }); + ref.listen(notesMatchNavigationProvider, (previous, next) { + if (next == null || + next.request == previous?.request || + (widget.state.activeBuffer?.remoteNote?.localId ?? + widget.state.activeBuffer?.filePath) != + next.localId) { + return; + } + final source = widget.state.activeBuffer!.text; + if (notesSearchDigest(source) != next.digest) return; + final line = SourceDocument( + fullText: source, + ).lineIndex.lineNumberAtOffset(next.start); + final old = ref.read(_searchNavigationTargetProvider); + ref + .read(_searchNavigationTargetProvider.notifier) + .set( + _SearchNavigationTarget( + filePath: null, + localId: next.localId, + digest: next.digest, + line: line, + startOffset: next.start, + endOffset: next.end, + query: '', + request: (old?.request ?? 0) + 1, + ), + ); + }); final searchTarget = ref.watch(_searchNavigationTargetProvider); if (searchTarget != null && - ref.read(_workspaceSearchProvider).active && - ref.read(_workspaceSearchProvider).query == searchTarget.query && + (searchTarget.localId != null || + (ref.read(_workspaceSearchProvider).active && + ref.read(_workspaceSearchProvider).query == + searchTarget.query)) && searchTarget.request != _lastSearchNavigationRequest) { _scrollToSearchTarget(searchTarget); } @@ -12310,10 +12478,44 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { Expanded( key: const ValueKey('document-wysiwyg-pane'), child: BusyMarkWysiwygEditor( + onInspectElement: _inspectEditorElement, + onPropertiesRequested: _showProperties, + onVideoPropertiesRequested: (document, id) { + _inspectEditorElement( + document, + id, + null, + true, + false, + ); + setState(() => _videoPropertyDraft = true); + _showProperties(); + }, + onChooseLink: (initial) => + showReferenceLinkChooser( + context, + initialDestination: initial, + ), + writersideProjectIndex: widget + .state + .workspace + ?.writersideProject + ?.index, + writersideModuleId: _activeWritersideModuleId(), + resolvedWritersideProperties: + _effectiveDocumentPropertyOccurrences( + _wysiwygDocument()!, + ), + writersideSettings: + resolveWorkspaceDocumentContext( + widget.state.workspace!, + activeBuffer!, + ).writersideModule?.config.settings ?? + const WritersideSettingsConfig(), key: _wysiwygEditorKey, document: wysiwygDocument, - documentId: activeBuffer?.id, - contentRevision: activeBuffer?.revision, + documentId: activeBuffer.id, + contentRevision: activeBuffer.revision, spellingAnnotations: _spelling.annotations, onCheckSpelling: () => unawaited(_openSpellingReview()), @@ -12327,8 +12529,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ) .retain, initialSessionState: - activeBuffer?.editorState.wysiwygState ?? - const WysiwygEditorSessionState(), + activeBuffer.editorState.wysiwygState, useExternalUndoHistory: true, onSessionChanged: (documentId, session) { final latest = ref @@ -12394,19 +12595,19 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ), onDocumentChanged: (document) => _cacheWysiwygDocument( - activeBuffer?.id, + activeBuffer.id, document, ), onSourceChanged: (filePath, value) => _handleWysiwygSourceChanged( - activeBuffer?.id, + activeBuffer.id, filePath, value, ), onTransactionalSourceChanged: (filePath, value, [undoGroup]) => _handleWysiwygSourceChanged( - activeBuffer?.id, + activeBuffer.id, filePath, value, undoGroup, @@ -12414,7 +12615,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { onSpellingSourceChanged: (filePath, value, before, after) => _handleWysiwygSpellingSourceChanged( - activeBuffer?.id, + activeBuffer.id, filePath, value, before, @@ -12476,10 +12677,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { expressionId: expressionId, code: code, sourceSpan: sourceSpan, - expectedRevision: - activeBuffer?.revision, - expectedFilePath: - activeBuffer?.filePath, + expectedRevision: activeBuffer.revision, + expectedFilePath: activeBuffer.filePath, ), ), ), @@ -12487,6 +12686,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { Expanded( key: const ValueKey('document-source-pane'), child: BusyMarkSourceEditor( + onChooseLink: () => + showReferenceLinkChooser(context), key: _sourceEditorKey, text: widget.state.activeText, language: _sourceSyntaxLanguage( @@ -12789,7 +12990,1042 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { : media?.context ?? DocumentMediaContext.unavailable, child: DocumentReadOnlyScope( readOnly: activeBuffer?.readonly ?? false, - child: editor, + child: _withProperties(editor), + ), + ); + } + + WritersidePropertySession get _propertyEdits => + ref.read(workspaceControllerProvider.notifier).propertyEdits; + + void _showProperties() { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setElementPropertiesVisible(true), + ); + } + + // The command inspects the current selection even when the pane is open. + // Closing is a separate guarded action on the pane itself. + void _toggleProperties() { + final editor = _wysiwygEditorKey.currentState; + if (editor != null) { + editor.inspectCurrentSelection(); + } else { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) _inspectSourceSelection(buffer.editorState.selection); + } + _showProperties(); + } + + void _handlePropertyDraftChange() { + if (_propertyEdits.hasPending) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _propertyEdits.hasPending) return; + final live = ref.read(workspaceControllerProvider).activeBuffer; + final restored = _pendingInspectorHistoryRestore; + _pendingInspectorHistoryRestore = null; + setState(() { + if (restored?.matches(live?.id, live?.revision, live?.text) == true) { + _inspected = restored; + } + }); + final document = _wysiwygDocument(); + _refreshInspectedElement(document); + }); + } + + void _refreshInspectedElement(BusyDocument? document) { + final live = ref.read(workspaceControllerProvider).activeBuffer; + final captured = _inspected; + if (live == null || + captured == null || + captured.unavailable || + captured.bufferId != live.id || + captured.matches(live.id, live.revision, live.text)) { + return; + } + final refreshed = document == null + ? null + : captured.refreshUnchangedElement( + revision: live.revision, + source: live.text, + document: document, + ); + if (mounted) { + if (refreshed == null) { + setState(() => _inspected = captured.markUnavailable()); + } else if (!_propertyEdits.hasPending) { + setState(() => _inspected = refreshed); + } + } + } + + void _trackInspectedHistory(DocumentBuffer before, DocumentBuffer after) { + final captured = _inspected; + if (captured == null) return; + final previous = before.editorState.undoState; + final next = after.editorState.undoState; + final existing = {...previous.undo, ...previous.redo}; + if (captured.matches(before.id, before.revision, before.text)) { + for (final entry in [...next.undo, ...next.redo]) { + if (!existing.contains(entry) && entry.text == before.text) { + _inspectedHistory[entry] = captured; + } + } + } + final restored = next.restoredEntryFrom(previous); + final owner = _inspectedHistory[restored]; + if (owner != null && + restored!.text == after.text && + owner.source == after.text && + owner.bufferId == after.id) { + final document = _wysiwygDocument(); + if (document != null) { + final refreshed = owner.refreshUnchangedElement( + revision: after.revision, + source: after.text, + document: document, + ); + if (_propertyEdits.hasPending) { + _pendingInspectorHistoryRestore = refreshed; + } else { + _inspected = refreshed ?? captured; + } + } + } + final retained = {...next.undo, ...next.redo}; + _inspectedHistory.removeWhere((entry, _) => !retained.contains(entry)); + } + + void _inspectEditorElement( + BusyDocument document, + String? id, + BusyInlineStyleRange? inline, + bool explicit, + bool multiple, { + bool revisionRefresh = false, + }) { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null || !document.isWriterside) return; + if (!explicit) { + _refreshInspectedElement(document); + return; + } + final incoming = WritersideInspectorTarget.fromSelection( + bufferId: buffer.id, + revision: buffer.revision, + source: buffer.text, + document: document, + blockId: id, + inline: inline, + multiple: multiple, + ); + if (!incoming.matches(buffer.id, buffer.revision, buffer.text)) { + if (explicit) _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + // The live editing model can retain IDs across structural edits. Bind its + // explicit address to freshly parsed source only after the complete authored + // projection and emitted bytes agree; an ordinal alone is not identity. + final authored = _wysiwygDocument(); + final parsed = authored == null + ? null + : busyMarkWysiwygBindMarkdownReferences(authored); + if (parsed == null || !incoming.hasSameAuthoredState(parsed)) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + var siblings = parsed.blocks.where((b) => !b.isSourceOnly).toList(); + BusyBlock? selected; + for (final ordinal in incoming.semanticAddress) { + if (ordinal >= siblings.length) return; + selected = siblings[ordinal]; + siblings = selected.children.where((b) => !b.isSourceOnly).toList(); + } + document = parsed; + id = selected?.id; + if (inline != null && selected != null) { + final beforeInline = inline; + inline = busyInlineReferenceRanges(selected.inlines) + .where( + (r) => + r.kind == beforeInline.kind && + r.start == beforeInline.start && + r.end == beforeInline.end && + r.referenceIndex == beforeInline.referenceIndex, + ) + .singleOrNull; + if (inline == null) return; + } + if (!_propertyEdits.transition()) return; + final current = ref.read(workspaceControllerProvider).activeBuffer; + if (current == null || current.id != buffer.id) return; + if (current.revision != buffer.revision || current.text != buffer.text) { + final fresh = _wysiwygDocument(); + if (fresh == null) return; + final reconciled = incoming.refreshUnchangedElement( + revision: current.revision, + source: current.text, + document: fresh, + ); + if (reconciled == null) return; + document = fresh; + id = reconciled.block?.id; + inline = reconciled.inline; + } + final target = WritersideInspectorTarget.fromSelection( + bufferId: current.id, + revision: current.revision, + source: current.text, + document: document, + blockId: id, + inline: inline, + multiple: multiple, + ); + if (mounted) { + setState(() { + _inspected = target; + _inspectedHistory.clear(); + _pendingInspectorHistoryRestore = null; + _videoPropertyDraft = false; + }); + } + } + + void _inspectSourceSelection(TextSelection selection) { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + final document = _wysiwygDocument(); + if (buffer == null || document == null || !document.isWriterside) return; + final requested = WritersideInspectorTarget.fromSource( + bufferId: buffer.id, + revision: buffer.revision, + source: buffer.text, + document: document, + selection: selection, + ); + if (!requested.matches(buffer.id, buffer.revision, buffer.text)) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + if (!_propertyEdits.transition()) return; + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (live?.id != buffer.id) return; + final fresh = _wysiwygDocument(); + if (fresh == null) return; + final target = requested.matches(live!.id, live.revision, live.text) + ? requested + : requested.refreshUnchangedElement( + revision: live.revision, + source: live.text, + document: fresh, + ); + if (target == null) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + if (mounted) { + setState(() { + _inspected = target; + _inspectedHistory.clear(); + _pendingInspectorHistoryRestore = null; + }); + } + } + + Widget _withProperties(Widget editor) { + final settings = ref.watch(appSettingsControllerProvider); + final document = _wysiwygDocument(); + final writerside = + document?.isWriterside == true || + _activeDocumentKind(widget.state.workspace) == + DocumentKind.writersideXmlTopic; + if (!writerside || !settings.elementPropertiesVisible) return editor; + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + final captured = _inspected; + final valid = + captured != null && + captured.matches(buffer?.id, buffer?.revision, buffer?.text); + // An invalid draft keeps its captured identity; unresolved reading targets clear. + final target = + valid || + _propertyEdits.hasPending && + captured?.bufferId == buffer?.id && + widget.viewMode != DocumentViewModePreference.preview + ? captured + : null; + final panel = + target == null || + target.multiple || + target.block == null && !target.topic + ? DecoratedBox( + key: _propertiesPanelKey, + decoration: BoxDecoration( + color: BusyMarkSurfaceColors.of(context).panel, + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: Text(context.l10n.wsElementProperties), + ), + ), + BusyMarkHeaderIconButton( + icon: BusyMarkGlyphs.windowClose, + tooltip: context.l10n.close, + onPressed: _closeProperties, + ), + ], + ), + Padding( + padding: const EdgeInsets.all(12), + child: Text( + document == null || + captured != null && + !valid && + captured.bufferId == buffer?.id + ? context.l10n.wsPropertiesUnavailable + : context.l10n.wsPropertiesSelection, + ), + ), + BusyMarkDialogButton( + label: context.l10n.wsTopicProperties, + onPressed: document?.isWriterside == true + ? _inspectTopic + : null, + ), + ], + ), + ) + : _buildWorkspaceProperties(target); + return LayoutBuilder( + builder: (context, constraints) { + final width = settings.elementPropertiesWidth + .clamp(260.0, math.max(260.0, constraints.maxWidth - 32)) + .toDouble(); + if (constraints.maxWidth < width + 480) { + return Stack( + children: [ + Positioned.fill(child: editor), + PositionedDirectional( + top: 0, + bottom: 0, + end: 0, + width: math.min(width, constraints.maxWidth), + child: Material(elevation: 8, child: panel), + ), + ], + ); + } + return Row( + children: [ + Expanded(child: editor), + _ElementPropertiesResizeHandle( + width: settings.elementPropertiesWidth, + onResize: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setElementPropertiesWidth(value), + ), + ), + SizedBox(width: width, child: panel), + ], + ); + }, + ); + } + + void _inspectTopic() { + if (!_propertyEdits.transition()) return; + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + final document = _wysiwygDocument(); + if (buffer == null || document?.isWriterside != true) return; + _inspectedHistory.clear(); + _pendingInspectorHistoryRestore = null; + setState( + () => _inspected = WritersideInspectorTarget.fromTopic( + bufferId: buffer.id, + revision: buffer.revision, + source: buffer.text, + document: document!, + ), + ); + } + + void _closeProperties() { + if (!_propertyEdits.transition()) return; + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setElementPropertiesVisible(false), + ); + _wysiwygEditorKey.currentState?.restoreEditingFocus(); + _sourceEditorKey.currentState?.restoreSpellingFocus(); + } + + String? _effectivePropertySource; + String? _effectivePropertyBufferId; + Object? _effectivePropertyModule; + Map<int, Map<String, String>> _effectivePropertyOccurrences = const {}; + + Map<int, Map<String, String>> _effectiveDocumentPropertyOccurrences( + BusyDocument document, + ) { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + final buffer = state.activeBuffer; + if (workspace == null || buffer == null || !document.isWriterside) { + return const {}; + } + final context = resolveWorkspaceDocumentContext(workspace, buffer); + final module = context.writersideModule; + final original = context.writersideTopic; + if (module == null || original == null) return const {}; + if (_effectivePropertyBufferId == buffer.id && + _effectivePropertySource == buffer.text && + identical(_effectivePropertyModule, module)) { + return _effectivePropertyOccurrences; + } + final topic = document.isXmlTopic + ? const WritersideTopicParser().parseXml( + filePath: original.filePath, + source: buffer.text, + topicsRoot: original.topicRoot, + ) + : const WritersideTopicParser().parseMarkdown( + filePath: original.filePath, + source: buffer.text, + topicsRoot: original.topicRoot, + ); + final instance = _tocInstanceForTreePath( + module, + _tocInstanceTreePathForActiveFile(workspace), + ); + _effectivePropertyOccurrences = const WritersideDocumentResolver() + .resolvePropertyOccurrences( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: + workspace.writersideProject?.modulesByOrigin ?? const {}, + ), + ); + _effectivePropertyBufferId = buffer.id; + _effectivePropertySource = buffer.text; + _effectivePropertyModule = module; + return _effectivePropertyOccurrences; + } + + WritersideEffectiveProperties _effectiveInspectorProperties( + WritersideInspectorTarget target, + ) { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + final buffer = state.activeBuffer; + if (workspace == null || + buffer == null || + !target.matches(buffer.id, buffer.revision, buffer.text)) { + return const WritersideEffectiveProperties(); + } + final module = resolveWorkspaceDocumentContext( + workspace, + buffer, + ).writersideModule; + return WritersideEffectiveProperties( + settings: module?.config.settings ?? const WritersideSettingsConfig(), + resolvedValues: + _effectiveDocumentPropertyOccurrences( + target.document, + )[target.block?.sourceSpan?.startOffset] ?? + const {}, + inheritedIgnoreVars: target.path + .where( + (b) => + b.id != target.block?.id && + b.attributes.containsKey('ignore-vars'), + ) + .lastOrNull + ?.attributes['ignore-vars'], + ); + } + + Widget _buildWorkspaceProperties(WritersideInspectorTarget target) { + String? resultingId; + bool commit(bool Function(BusyMarkWysiwygDocumentController) mutation) { + if (!mounted) return false; + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (!target.sameElement(_inspected) || + !target.matches(live?.id, live?.revision, live?.text)) { + return _propertyEdits.reject(WritersidePropertyRejection.stale); + } + if (live!.readonly || + live.editorState.mode == DocumentViewModePreference.preview) { + return _propertyEdits.reject(WritersidePropertyRejection.readOnly); + } + final controller = BusyMarkWysiwygDocumentController( + document: target.document, + ); + try { + if (!mutation(controller)) return false; + final editedAddress = WritersideInspectorTarget.fromSelection( + bufferId: target.bufferId, + revision: target.revision, + source: target.source, + document: controller.document, + blockId: resultingId ?? target.block?.id, + ).semanticAddress; + final source = controller.markdown; + final requiresSourceChange = !target.hasSameAuthoredState( + controller.document, + ); + if (requiresSourceChange && source == target.source) { + return _propertyEdits.reject( + WritersidePropertyRejection.invalid, + context.l10n.wsPropertiesNotSerialized, + ); + } + final accepted = ref + .read(workspaceControllerProvider.notifier) + .applyInspectorSource( + bufferId: target.bufferId, + revision: target.revision, + expectedSource: target.source, + source: source, + requiresSourceChange: requiresSourceChange, + ); + if (!accepted) { + return _propertyEdits.reject(WritersidePropertyRejection.stale); + } + if (source != target.source) { + _clearWysiwygCache(); + final document = _wysiwygDocument(); + final next = ref.read(workspaceControllerProvider).activeBuffer!; + _trackInspectedHistory(live, next); + if (document != null) { + final ordinal = editedAddress; + var siblings = document.blocks + .where((b) => !b.isSourceOnly) + .toList(); + BusyBlock? block; + for (final i in ordinal) { + if (i >= siblings.length) { + block = null; + break; + } + block = siblings[i]; + siblings = block.children.where((b) => !b.isSourceOnly).toList(); + } + _inspected = target.topic && target.block == null + ? WritersideInspectorTarget.fromTopic( + bufferId: next.id, + revision: next.revision, + source: next.text, + document: document, + ) + : WritersideInspectorTarget.fromSelection( + bufferId: next.id, + revision: next.revision, + source: next.text, + document: document, + blockId: block?.id, + inline: target.inline == null || block == null + ? null + : (() { + final before = busyInlineReferenceRanges( + target.block!.inlines, + ); + final index = before.indexWhere( + (r) => + r.kind == target.inline!.kind && + r.start == target.inline!.start && + r.end == target.inline!.end && + (target.inline!.referenceIndex == null || + r.referenceIndex == + target.inline!.referenceIndex), + ); + final after = busyInlineReferenceRanges( + block!.inlines, + ); + return index >= 0 && + index < after.length && + after[index].kind == target.inline!.kind + ? after[index] + : null; + })(), + ); + } + if (mounted) { + setState(() { + _videoPropertyDraft = false; + }); + } + } + return true; + } finally { + controller.dispose(); + } + } + + final block = + target.block ?? + const BusyBlock(id: '@topic', kind: BusyBlockKind.heading); + return BusyMarkWritersideProperties( + key: _propertiesPanelKey, + documentIdentity: target.bufferId, + effectiveProperties: _effectiveInspectorProperties(target), + session: _propertyEdits, + onUndo: () => + ref.read(workspaceControllerProvider.notifier).undoActiveBuffer(), + onRedo: () => + ref.read(workspaceControllerProvider.notifier).redoActiveBuffer(), + document: target.document, + path: target.path, + target: target.block, + topicSelected: target.topic, + readOnly: + widget.viewMode == DocumentViewModePreference.preview || + widget.state.activeBuffer?.readonly == true, + onEditTitles: widget.state.activeBuffer?.filePath == null + ? null + : () => unawaited(_editInspectorTitles(target)), + navigationEditable: target.navigationEditable, + hasFileIdentity: widget.state.activeBuffer?.filePath != null, + onRenameTopic: widget.state.activeBuffer?.filePath == null + ? null + : () => unawaited(_renameInspectorTopic(target)), + onClose: _closeProperties, + videoDraft: _videoPropertyDraft, + onInsertVideo: (source) => commit((c) { + if (source.trim().isEmpty) return false; + resultingId = c.insertWriterside( + BusyWritersideInsertCommand.video, + target.block!.id, + title: context.l10n.video, + attributes: {'src': source.trim()}, + ); + return resultingId != null; + }), + onTargetSelected: (id) { + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (live?.id != target.bufferId) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + if (id == '@topic') { + _inspectTopic(); + } else { + final requested = WritersideInspectorTarget.fromSelection( + bufferId: target.bufferId, + revision: target.revision, + source: target.source, + document: target.document, + blockId: id, + ); + final fresh = _wysiwygDocument(); + final selected = fresh == null + ? null + : requested.matches(live!.id, live.revision, live.text) + ? requested + : requested.refreshUnchangedElement( + revision: live.revision, + source: live.text, + document: fresh, + ); + if (selected == null) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + _inspectEditorElement( + selected.document, + selected.block?.id, + null, + true, + false, + ); + } + }, + onProperty: (name, value) { + if (!mounted) return false; + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (!target.sameElement(_inspected) || + !target.matches(live?.id, live?.revision, live?.text)) { + return _propertyEdits.reject(WritersidePropertyRejection.stale); + } + if (live!.readonly || + live.editorState.mode == DocumentViewModePreference.preview) { + return _propertyEdits.reject(WritersidePropertyRejection.readOnly); + } + if (name == 'admonition-type') { + final style = busyAdmonitionStyleFromName(value); + if (style == null || !busyMarkWysiwygCanApplyAdmonitionStyle(block)) { + return false; + } + return commit((c) { + c.applyAdmonitionStyle(block.id, style); + return true; + }); + } + final tag = WritersideEditingAdapter.propertyTag( + target.document, + block, + ); + final reason = + block.kind == BusyBlockKind.heading && + !target.document.isXmlTopic && + {'id', 'title'}.contains(name) && + (value == null || value.isEmpty) + ? null + : validateWritersideProperty( + tag, + name, + value, + attributes: block.attributes, + applicableAttributes: + WritersideEditingAdapter.propertyAttributes( + target.document, + block, + ), + hasTitleChild: block.children.any( + (b) => b.attributes['element'] == 'title', + ), + inlineCode: block.kind == BusyBlockKind.codeBlock + ? block.plainText + : null, + codeSource: + block.kind == BusyBlockKind.codeBlock && + block.attributes['src']?.isNotEmpty != true + ? block.plainText + : resolveWorkspaceDocumentContext( + ref.read(workspaceControllerProvider).workspace!, + ref.read(workspaceControllerProvider).activeBuffer!, + ) + .writersideModule + ?.sourceFiles[WritersideSourceLoader.key( + target.document.filePath, + block.attributes['src'] ?? '', + )] + ?.text, + ); + if (reason != null) { + return _propertyEdits.reject( + WritersidePropertyRejection.invalid, + tag == 'code-block' && + name == 'src' && + block.plainText.trim().isNotEmpty + ? context.l10n.wsCodeSourceConflict + : reason, + ); + } + return commit((c) => c.updateWritersideProperty(block.id, name, value)); + }, + onHeadingText: (text) => commit((c) { + if (text.trim().isEmpty) return false; + if (block.plainText != text) c.updateBlockText(block.id, text); + return true; + }), + onNavigation: (present, forElements, depth) => commit( + (c) => c.setTopicNavigation( + present: present, + forElements: forElements, + depth: depth, + ), + ), + onTopicProperty: (name, value) => commit( + (c) => name == '@navigation' + ? value == 'default' + ? c.resetTopicNavigation() + : value == 'none' + ? c.updateTopicNavigation('for', 'none') + : c.resetTopicNavigation() && + c.updateTopicNavigation('for', '') + : name == 'switcher-label' + ? c.updateTopicSwitcherLabel(value) + : c.updateTopicNavigation(name, value), + ), + inlineTarget: target.inline, + onInlineProperty: (name, value) => commit( + (c) => + target.inline != null && + c.updateInlineReference(block.id, target.inline!, name, value), + ), + onChooseInclude: () => unawaited(_chooseInclude(target, commit)), + onChooseCodeSource: () => _chooseCodeSource(target), + onManageItem: (item, direction, remove) => commit( + (c) => c.manageWritersideItem( + block.id, + itemId: item, + direction: direction, + remove: remove, + title: context.l10n.tab, + ), + ), + onSourceLocation: () { + if (!_propertyEdits.transition()) return; + final current = _inspected; + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (current == null || + current.bufferId != target.bufferId || + !current.matches(live?.id, live?.revision, live?.text)) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + final requested = target.matches(live?.id, live?.revision, live?.text) + ? target + : target.refreshUnchangedElement( + revision: live!.revision, + source: live.text, + document: current.document, + ); + if (!current.sameElement(requested)) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + final offset = current.sourceLocation; + if (offset == null) { + _propertyEdits.reject(WritersidePropertyRejection.unresolved); + return; + } + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorState( + live!.editorState.copyWith( + selection: TextSelection.collapsed(offset: offset), + ), + ); + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final next = ref.read(workspaceControllerProvider).activeBuffer; + if (next?.id == live.id && next?.revision == live.revision) { + _sourceEditorKey.currentState?.scrollToOffset(offset); + } + }); + }, + ); + } + + Future<void> _renameInspectorTopic(WritersideInspectorTarget target) async { + final current = ref.read(workspaceControllerProvider).activeBuffer; + if (current?.id != target.bufferId || current?.filePath == null) return; + final path = current!.filePath!; + final settings = ref.read(appSettingsControllerProvider.notifier); + await settings.setSidebarVisible(true); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final live = ref.read(workspaceControllerProvider).activeBuffer; + if (live?.id != target.bufferId || live?.filePath != path) return; + final rename = ref + .read(workspaceControllerProvider.notifier) + .renameInspectedTopic; + if (rename != null) await rename(path); + } + + Future<void> _editInspectorTitles(WritersideInspectorTarget target) async { + final state = ref.read(workspaceControllerProvider); + final buffer = state.activeBuffer; + final workspace = state.workspace; + if (workspace == null || + buffer == null || + (_inspected?.bufferId != target.bufferId || + _inspected?.topic != true || + _inspected?.matches(buffer.id, buffer.revision, buffer.text) != + true) || + buffer.filePath == null) { + return; + } + final module = resolveWorkspaceDocumentContext( + workspace, + buffer, + ).writersideModule; + final instance = workspace.writersideProject?.activeInstance; + if (module == null || instance == null) return; + final entries = instance.tocRoots + .expand((n) => n.flatten()) + .where( + (n) => + !n.included && + n.origin == null && + n.topicReference != null && + p.equals( + module.topicByReference(n.topicReference!)?.filePath ?? '', + buffer.filePath!, + ), + ) + .toList(); + if (entries.length != 1 || entries.single.sourceTocPath == null) { + _propertyEdits.reject(WritersidePropertyRejection.unresolved); + return; + } + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + buffer.filePath!, + instance.sourceTreePath, + ]) || + !mounted) { + return; + } + final session = await ref + .read(workspaceControllerProvider.notifier) + .prepareWritersideTitleEdit( + treePath: instance.sourceTreePath, + tocPath: entries.single.sourceTocPath!, + identity: WritersideTocNodeIdentity.fromNode(entries.single), + topicModuleRoot: module.rootPath, + topicPath: buffer.filePath!, + ); + if (session != null && mounted) { + await _publishWritersideTitleDialog(context, ref, session); + } + } + + Future<String?> _chooseCodeSource(WritersideInspectorTarget target) async { + final state = ref.read(workspaceControllerProvider); + final buffer = state.activeBuffer; + final workspace = state.workspace; + if (buffer == null || + workspace == null || + !target.matches(buffer.id, buffer.revision, buffer.text)) { + return null; + } + final module = resolveWorkspaceDocumentContext( + workspace, + buffer, + ).writersideModule; + if (module == null) { + _propertyEdits.reject(WritersidePropertyRejection.unresolved); + return null; + } + final chosen = await openFile( + initialDirectory: module.config.snippetsDir == null + ? p.dirname(buffer.filePath!) + : p.join(module.rootPath, module.config.snippetsDir!), + confirmButtonText: context.l10n.open, + ); + if (!mounted || chosen == null) return null; + final live = ref.read(workspaceControllerProvider); + if (!target.sameElement(_inspected) || + !target.matches( + live.activeBuffer?.id, + live.activeBuffer?.revision, + live.activeBuffer?.text, + ) || + live.workspace?.id != workspace.id) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return null; + } + final currentModule = resolveWorkspaceDocumentContext( + live.workspace!, + live.activeBuffer!, + ).writersideModule; + if (currentModule?.rootPath != module.rootPath || + currentModule?.config.snippetsDir != module.config.snippetsDir) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return null; + } + final reference = await const WritersideSourceLoader().referenceForFile( + selectedPath: chosen.path, + documentPath: buffer.filePath!, + workspaceRoot: module.rootPath, + snippetsDirectory: module.config.snippetsDir, + ); + if (!mounted) return null; + if (reference == null) { + _propertyEdits.reject(WritersidePropertyRejection.unresolved); + } + return reference; + } + + Future<void> _chooseInclude( + WritersideInspectorTarget target, + bool Function(bool Function(BusyMarkWysiwygDocumentController)) commit, + ) async { + if (!_propertyEdits.flush()) return; + final index = ref + .read(workspaceControllerProvider) + .workspace + ?.writersideProject + ?.index; + final moduleId = _activeWritersideModuleId(); + if (index == null || moduleId == null) return; + final candidates = index.symbols + .where( + (s) => + s.kind == WritersideSymbolKind.element || + s.kind == WritersideSymbolKind.snippet, + ) + .where( + (s) => + index.modulesById[s.moduleId]?.topics + .where((t) => t.filePath == s.filePath) + .firstOrNull + ?.document + .elementById(s.name) + ?.name != + 'topic', + ) + .toList(); + final files = { + for (final s in candidates) '${s.moduleId}\u0000${s.filePath}', + }.toList()..sort(); + final file = await showBusyMarkMenu<String>( + context: context, + focusFirst: true, + items: [ + for (final file in files) + BusyMarkPopupMenuItem( + value: file, + label: + '${file.split('\u0000').first} · ${p.basename(file.split('\u0000').last)}', + ), + ], + ); + if (file == null || !mounted) return; + final parts = file.split('\u0000'); + final selected = await showBusyMarkMenu<WritersideSymbol>( + context: context, + focusFirst: true, + items: [ + for (final s in candidates.where( + (s) => s.moduleId == parts.first && s.filePath == parts.last, + )) + BusyMarkPopupMenuItem(value: s, label: s.name), + ], + ); + if (selected == null || !mounted) return; + final live = ref.read(workspaceControllerProvider); + if (!identical(live.workspace?.writersideProject?.index, index) || + !target.matches( + live.activeBuffer?.id, + live.activeBuffer?.revision, + live.activeBuffer?.text, + )) { + _propertyEdits.reject(WritersidePropertyRejection.stale); + return; + } + final topic = index.modulesById[selected.moduleId]?.topics + .where((t) => t.filePath == selected.filePath) + .firstOrNull; + if (topic == null || topic.document.elementById(selected.name) == null) { + return; + } + commit( + (c) => c.updateIncludeReference( + target.block!.id, + from: topic.fileName, + elementId: selected.name, + origin: selected.moduleId == moduleId ? null : selected.moduleId, ), ); } @@ -13674,6 +14910,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = document.filePath; _cachedWysiwygSource = document.source; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; } void _clearWysiwygCache() { @@ -13682,6 +14919,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = null; _cachedWysiwygSource = null; _cachedWysiwygMode = null; + _cachedWysiwygKind = null; } String? _activeEditorPath() { @@ -13920,7 +15158,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ref.read(_searchNavigationTargetProvider)?.request != target.request || _lastSearchNavigationRequest == target.request || - target.filePath != widget.state.activeBuffer?.filePath) { + (target.localId != null + ? target.localId != + (widget.state.activeBuffer?.remoteNote?.localId ?? + widget.state.activeBuffer?.filePath) || + notesSearchDigest(widget.state.activeBuffer?.text ?? '') != + target.digest + : target.filePath != widget.state.activeBuffer?.filePath)) { return; } _lastSearchNavigationRequest = target.request; @@ -14169,12 +15413,29 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { bool _canUseWysiwyg(Workspace? workspace) { final kind = _activeDocumentKind(workspace); - return kind?.supportsAiMarkdownEditing ?? false; + return kind?.supportsVisualEditing ?? false; } - BusyDocument? _wysiwygDocument() { + String? _activeWritersideModuleId() { final workspace = widget.state.workspace; final buffer = widget.state.activeBuffer; + if (workspace == null || buffer == null) return null; + final module = resolveWorkspaceDocumentContext( + workspace, + buffer, + ).writersideModule; + if (module == null) return null; + for (final entry + in workspace.writersideProject?.index.modulesById.entries ?? + const Iterable.empty()) { + if (p.equals(entry.value.rootPath, module.rootPath)) return entry.key; + } + return null; + } + + BusyDocument? _wysiwygDocument() { + final workspace = ref.read(workspaceControllerProvider).workspace; + final buffer = ref.read(workspaceControllerProvider).activeBuffer; if (workspace == null || buffer == null) { return null; } @@ -14183,9 +15444,19 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (_cachedWysiwygBufferId == buffer.id && _cachedWysiwygPath == activePath && _cachedWysiwygSource == buffer.text && - _cachedWysiwygMode == context.markdownMode) { + _cachedWysiwygMode == context.markdownMode && + _cachedWysiwygKind == context.kind) { return _cachedWysiwygDocument; } + if (context.kind == DocumentKind.writersideXmlTopic) { + final document = const WritersideEditingAdapter().parseXml( + filePath: activePath, + source: buffer.text, + ); + _clearWysiwygCache(); + if (document != null) _cacheWysiwygDocument(buffer.id, document); + return document; + } final currentMarkdown = workspace.markdown; if (currentMarkdown != null && p.equals(currentMarkdown.filePath, activePath) && @@ -14197,6 +15468,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = activePath; _cachedWysiwygSource = buffer.text; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; return document; } try { @@ -14214,6 +15486,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _cachedWysiwygPath = activePath; _cachedWysiwygSource = buffer.text; _cachedWysiwygMode = context.markdownMode; + _cachedWysiwygKind = context.kind; return document; } on Object { return null; @@ -14328,7 +15601,9 @@ bool _sameWysiwygSession( WysiwygEditorSessionState left, WysiwygEditorSessionState right, ) { - return left.activeBlockId == right.activeBlockId && + return left.sourceSignature == right.sourceSignature && + mapEquals(left.sourceBindings, right.sourceBindings) && + left.activeBlockId == right.activeBlockId && left.activeCellId == right.activeCellId && left.anchorBlockId == right.anchorBlockId && left.anchorOffset == right.anchorOffset && @@ -16327,6 +17602,27 @@ Future<void> _openPreviewLink( } final uri = parseSchemedUri(target); final remoteState = ref.read(workspaceControllerProvider); + final accountId = remoteState.workspace?.nextcloudAccountId; + final account = accountId == null + ? null + : ref + .read(nextcloudNotesRepositoryProvider) + .value + ?.accountById(accountId); + final linkedId = account == null + ? null + : nextcloudNoteId(account.server, target); + if (linkedId != null) { + final opened = await ref + .read(workspaceControllerProvider.notifier) + .openNextcloudNoteLink(linkedId); + if (opened && context.mounted && uri?.hasFragment == true) { + _navigatePreviewAnchor(context, ref, '', uri!.fragment); + } else if (!opened && context.mounted) { + _showPreviewLinkMessage(context, context.l10n.couldNotOpenTarget(target)); + } + return; + } if (remoteState.workspace?.isRemote == true && (uri == null || !isLaunchableExternalUri(uri))) { if (target.startsWith('#')) { @@ -18234,3 +19530,68 @@ class _EmptyPane extends StatelessWidget { ); } } + +/// A narrow separator remains operable without a mouse. +class _ElementPropertiesResizeHandle extends StatefulWidget { + const _ElementPropertiesResizeHandle({ + required this.width, + required this.onResize, + }); + final double width; + final ValueChanged<double> onResize; + @override + State<_ElementPropertiesResizeHandle> createState() => + _ElementPropertiesResizeHandleState(); +} + +class _ElementPropertiesResizeHandleState + extends State<_ElementPropertiesResizeHandle> { + bool focused = false; + @override + Widget build(BuildContext context) { + final rtl = Directionality.of(context) == TextDirection.rtl; + void resize(double delta) => widget.onResize(widget.width + delta); + return Semantics( + label: context.l10n.wsElementProperties, + value: '${widget.width.round()}', + increasedValue: '${(widget.width + 20).round()}', + decreasedValue: '${(widget.width - 20).round()}', + onIncrease: () => resize(20), + onDecrease: () => resize(-20), + child: Focus( + onFocusChange: (value) => setState(() => focused = value), + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + resize(rtl ? -20 : 20); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + resize(rtl ? 20 : -20); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) => + resize(rtl ? details.delta.dx : -details.delta.dx), + child: SizedBox( + width: 8, + height: double.infinity, + child: ColoredBox( + color: focused + ? Theme.of(context).colorScheme.primary + : BusyMarkSurfaceColors.of( + context, + ).panel.withValues(alpha: 0), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index 4466e5c6..53934b06 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -138,6 +138,8 @@ class _WritersideTitleDialogState extends State<WritersideTitleDialog> { ); late final _original = (_title.text, _instance.text, _toc.text); bool _advanced = false; + bool get _instanceEditable => + widget.session.topic.format == WritersideTopicFormat.xml; @override void initState() { super.initState(); @@ -164,7 +166,9 @@ class _WritersideTitleDialogState extends State<WritersideTitleDialog> { context, WritersideTitleEdit( title: _title.text == _original.$1 ? null : _title.text, - instanceTitle: _instance.text == _original.$2 ? null : _instance.text, + instanceTitle: !_instanceEditable || _instance.text == _original.$2 + ? null + : _instance.text, tocTitle: _toc.text == _original.$3 ? null : _toc.text, ), ); @@ -176,6 +180,7 @@ class _WritersideTitleDialogState extends State<WritersideTitleDialog> { String? hint, Widget? explanation, bool autofocus = false, + bool readOnly = false, }) => Padding( padding: const EdgeInsets.only(bottom: BusyMarkSpacing.md), child: Column( @@ -184,6 +189,7 @@ class _WritersideTitleDialogState extends State<WritersideTitleDialog> { TextField( controller: controller, autofocus: autofocus, + readOnly: readOnly, decoration: InputDecoration(labelText: label, hintText: hint), onSubmitted: (_) => _submit(), ), @@ -226,16 +232,28 @@ class _WritersideTitleDialogState extends State<WritersideTitleDialog> { ), ), if (_advanced) ...[ - _field( - context.l10n.tocInstanceTitleField(widget.session.instanceId), - _instance, - hint: _title.text, - explanation: Text(context.l10n.tocInstanceTitleExplanation), - ), + if (_instanceEditable || _instance.text.isNotEmpty) + _field( + context.l10n.tocInstanceTitleField(widget.session.instanceId), + _instance, + readOnly: !_instanceEditable, + explanation: Text( + _instanceEditable + ? context.l10n.tocInstanceTitleExplanation + : context.l10n.wsMarkdownInstanceTitleLimitation, + ), + ) + else + Padding( + padding: const EdgeInsets.only(bottom: BusyMarkSpacing.md), + child: Text(context.l10n.wsMarkdownInstanceTitleLimitation), + ), _field( context.l10n.tocOnlyTitleField, _toc, - hint: _instance.text.isEmpty ? _title.text : _instance.text, + hint: !_instanceEditable || _instance.text.isEmpty + ? _title.text + : _instance.text, explanation: Text.rich( TextSpan( children: [ diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 4bbdb20e..06d78a48 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1,3 +1,6 @@ +import '../editor/wysiwyg/writerside_property_session.dart'; +import '../references/markdown_move_service.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -25,6 +28,7 @@ import '../local_history/local_history_models.dart'; import '../nextcloud_notes/domain/notes_models.dart'; import '../nextcloud_notes/domain/notes_conflict.dart'; import '../nextcloud_notes/application/notes_repository.dart'; +import '../nextcloud_notes/application/notes_sync_coordinator.dart'; import '../nextcloud_notes/application/nextcloud_connection.dart'; import '../nextcloud_notes/application/notes_media.dart'; import '../export/markdown_copy_export_service.dart'; @@ -224,16 +228,20 @@ class WorkspaceController extends Notifier<WorkspaceState> { late WorkspaceService _service; late AppSettingsController _settingsController; + final propertyEdits = WritersidePropertySession(); + + /// The mounted workspace owns inspector transitions and focus restoration. + void Function()? toggleElementProperties; + Future<bool> Function(String topicPath)? renameInspectedTopic; late DocumentSessionStore _sessionStore; late DocumentRecoveryStore _recoveryStore; late WorkspaceFileMonitor _fileMonitor; late LocalHistoryController _localHistory; NotesRepository? _notesRepository; StreamSubscription<void>? _notesSubscription; - final _remoteSyncTasks = <String, Future<void>>{}; - final _remoteSyncRequested = <String>{}; - final _remoteRetryTimers = <String, Timer>{}; - final _remoteRetryCounts = <String, int>{}; + NotesSyncCoordinator? _notesCoordinator; + bool _notesApplicationVisible = true; + bool _notesWorkspaceActive = true; final _remoteDurabilityWrites = <String, int>{}; var _restoringLocalHistorySession = false; StreamSubscription<WorkspaceFileMonitorEvent>? _fileMonitorSubscription; @@ -327,6 +335,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { WorkspaceState build() { _service = ref.read(workspaceServiceProvider); _settingsController = ref.read(appSettingsControllerProvider.notifier); + _settingsController.processPendingProperties = propertyEdits.flush; _sessionStore = ref.read(documentSessionStoreProvider); _recoveryStore = ref.read(documentRecoveryStoreProvider); _fileMonitor = ref.read(workspaceFileMonitorProvider); @@ -350,6 +359,8 @@ class WorkspaceController extends Notifier<WorkspaceState> { } }); ref.onDispose(() { + _settingsController.processPendingProperties = null; + propertyEdits.dispose(); _fileMonitorWorkspaceGeneration++; _removalOperationRevision++; _cancelPendingDerivedRefresh(); @@ -361,9 +372,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { _restoredRecoveryOwnerIdsByBuffer.clear(); unawaited(_fileMonitorSubscription?.cancel()); unawaited(_notesSubscription?.cancel()); - for (final timer in _remoteRetryTimers.values) { - timer.cancel(); - } + _notesCoordinator?.dispose(); }); return const WorkspaceState(); } @@ -452,6 +461,12 @@ class WorkspaceController extends Notifier<WorkspaceState> { await repository.initialize(); if (!ref.mounted) throw StateError('Workspace closed'); _notesRepository = repository; + _notesCoordinator = NotesSyncCoordinator( + repository, + onError: (error, stack) => _reportNextcloudFailure(error), + ); + _updateNotesCoordinator(); + listenSelf((previous, next) => _updateNotesCoordinator()); bool bindingGuard(String localId, Set<String> ids) => !state.documentBuffers.any( (b) => @@ -603,6 +618,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { List<DocumentSessionEntry>? restoredTabs, String? activeBufferId, }) async { + if (!propertyEdits.flush()) return false; if (state.hasUnsavedChanges) return false; final prior = state; final priorRevision = _activeDocumentRevision; @@ -670,7 +686,11 @@ class WorkspaceController extends Notifier<WorkspaceState> { ); } _schedulePersistence(); - _scheduleRemoteSync(accountId); + unawaited( + _notesCoordinator + ?.request(NotesSyncTrigger.opening) + .catchError((Object error) => _reportNextcloudFailure(error)), + ); return true; } on Object catch (error) { if (ref.mounted) { @@ -686,6 +706,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { } Future<bool> openNextcloudNote(String localId) async { + if (!propertyEdits.flush()) return false; final repository = await _ensureNotesRepository(); final note = repository.noteById(localId); final workspace = state.workspace; @@ -717,11 +738,42 @@ class WorkspaceController extends Notifier<WorkspaceState> { return result; } + Future<bool> openNextcloudNoteLink(int serverId) async { + final workspace = state.workspace; + final accountId = workspace?.nextcloudAccountId; + if (accountId == null) return false; + try { + final repository = await _ensureNotesRepository(); + final note = await repository.linkedNote(accountId, serverId); + if (!ref.mounted || + state.workspace?.id != workspace?.id || + state.workspace?.nextcloudAccountId != accountId) { + return false; + } + if (!await openNextcloudNote(note.localId)) return false; + // Cached navigation works offline. Refresh through the existing repository + // boundary, without a second synchronization coordinator or polling loop. + unawaited( + repository.refreshLinkedNote(accountId, serverId).catchError(( + Object error, + ) { + _reportNextcloudFailure(error); + return note; + }), + ); + return true; + } on Object catch (error) { + _reportNextcloudFailure(error); + return false; + } + } + Future<bool> createNextcloudNote({ String title = 'New note', String category = '', String content = '', }) async { + if (!propertyEdits.flush()) return false; try { final accountId = state.workspace?.nextcloudAccountId; if (accountId == null) return false; @@ -841,118 +893,156 @@ class WorkspaceController extends Notifier<WorkspaceState> { } } - void _scheduleRemoteSync(String accountId, {bool automaticRetry = false}) { + void _updateNotesCoordinator() { + _notesCoordinator?.setActiveAccount( + _notesWorkspaceActive ? state.workspace?.nextcloudAccountId : null, + ); + _notesCoordinator?.setVisible(_notesApplicationVisible); + _notesRepository?.setDisplayedMediaNotes([ + if (state.activeBuffer?.remoteNote?.localId case final id?) id, + ]); + } + + void setNotesApplicationVisible(bool visible) { + _notesApplicationVisible = visible; + _notesCoordinator?.setVisible(visible); + } + + void setNotesWorkspaceActive(bool active) { + _notesWorkspaceActive = active; + _updateNotesCoordinator(); + if (active) _notesCoordinator?.focused(); + } + + void refreshNotesOnFocus() => _notesCoordinator?.focused(); + void _scheduleRemoteSync(String accountId) { if (!ref.mounted) return; - _remoteRetryTimers.remove(accountId)?.cancel(); - if (!automaticRetry) _remoteRetryCounts.remove(accountId); - _remoteSyncRequested.add(accountId); - if (_remoteSyncTasks.containsKey(accountId)) return; - final task = () async { - do { - _remoteSyncRequested.remove(accountId); - try { - await (await _ensureNotesRepository()).synchronize(accountId); - } on Object catch (error) { - if (ref.mounted && state.workspace?.nextcloudAccountId == accountId) { - state = state.copyWith( - message: WorkspaceMessage( - WorkspaceMessageCode.saveFailed, - error: error, - ), - ); - } - } - // An edit committed during the request schedules one more pass. Network - // failures do not request a pass, so this cannot spin on server locks. - } while (ref.mounted && _remoteSyncRequested.contains(accountId)); - _remoteSyncTasks.remove(accountId); - _scheduleRemoteRetry(accountId); - }(); - _remoteSyncTasks[accountId] = task; - unawaited(task); - } - - void _scheduleRemoteRetry(String accountId) { - if (!ref.mounted || state.workspace?.nextcloudAccountId != accountId) { - return; - } - final repository = _notesRepository; - if (repository == null) return; - final accountError = repository.accountError(accountId); - final retryable = - accountError?.code == NotesFailureCode.network || - accountError?.code == NotesFailureCode.server || - repository.notes.any( - (note) => - note.accountId == accountId && - note.hasPendingChanges && - { - NoteSyncState.offline, - NoteSyncState.locked, - NoteSyncState.pending, - }.contains(note.syncState), - ); - if (!retryable) { - _remoteRetryCounts.remove(accountId); - return; - } - final attempt = _remoteRetryCounts[accountId] ?? 0; - // Six delayed attempts, then explicit Refresh remains available. Creation - // uncertainty, conflicts, keyring/authentication and forbidden errors never - // trigger an automatic retry. - if (attempt >= 6) return; - _remoteRetryCounts[accountId] = attempt + 1; - _remoteRetryTimers[accountId] = Timer( - Duration(seconds: math.min(300, 5 * (1 << attempt))), - () { - _remoteRetryTimers.remove(accountId); - if (ref.mounted && state.workspace?.nextcloudAccountId == accountId) { - _scheduleRemoteSync(accountId, automaticRetry: true); - } - }, + _notesCoordinator?.setActiveAccount( + _notesWorkspaceActive ? state.workspace?.nextcloudAccountId : null, + ); + _notesCoordinator?.setVisible(_notesApplicationVisible); + unawaited( + _notesCoordinator + ?.request(NotesSyncTrigger.localChange) + .catchError((Object error) => _reportNextcloudFailure(error)), ); } Future<void> refreshNextcloudNotes() async { - final account = state.workspace?.nextcloudAccountId; - if (account != null) { - _remoteRetryTimers.remove(account)?.cancel(); - _remoteRetryCounts.remove(account); - await (await _ensureNotesRepository()).synchronize(account); - _scheduleRemoteRetry(account); + if (state.workspace?.nextcloudAccountId == null) return; + await _ensureNotesRepository(); + _updateNotesCoordinator(); + await _notesCoordinator?.request(NotesSyncTrigger.manual); + } + + Future<void> retryRejectedNextcloudNote(String localId) async { + try { + final repository = await _ensureNotesRepository(); + await repository.retryRejectedNote(localId); + final note = repository.noteById(localId); + if (note != null) _scheduleRemoteSync(note.accountId); + } on Object catch (error) { + _reportNextcloudFailure(error); } } Future<void> updateNextcloudNoteMetadata( String localId, { + NotesMetadataSnapshot? snapshot, String? title, String? category, bool? favorite, }) async { try { final repository = await _ensureNotesRepository(); + final beforeRevision = repository.noteById(localId)?.revision; final buffer = state.documentBuffers .where((b) => b.remoteNote?.localId == localId) .firstOrNull; if (buffer?.isDirty == true && - !await _saveRemoteBufferSnapshot(buffer!)) { + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { return; } final note = repository.noteById(localId); if (note == null) return; - await repository.save( - localId, - content: note.content, + final updated = await repository.patchMetadata( + snapshot ?? repository.metadataSnapshot(localId), title: title, category: category, favorite: favorite, ); - _scheduleRemoteSync(note.accountId); + if (updated.revision != beforeRevision) { + _scheduleRemoteSync(note.accountId); + } } on Object catch (error) { _reportNextcloudFailure(error); } } + Future<List<NotesBatchOutcome>> batchNextcloudMetadata( + List<NotesMetadataSnapshot> reviewed, { + String? category, + bool? favorite, + }) async { + final repository = await _ensureNotesRepository(); + final eligible = <NotesMetadataSnapshot>[]; + final failed = <NotesBatchOutcome>[]; + for (final snapshot in reviewed) { + final buffer = state.documentBuffers + .where((b) => b.remoteNote?.localId == snapshot.note.localId) + .firstOrNull; + if (buffer?.isDirty == true && + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { + failed.add( + NotesBatchOutcome( + snapshot.note.localId, + snapshot.note.title, + NotesBatchStatus.failed, + 'Editor save failed.', + ), + ); + } else { + eligible.add(snapshot); + } + } + final outcomes = await repository.patchMetadataBatch( + eligible, + category: category, + favorite: favorite, + ); + for (final account in eligible.map((s) => s.note.accountId).toSet()) { + _scheduleRemoteSync(account); + } + return [...outcomes, ...failed]; + } + + Future<List<NextcloudNote>?> captureNextcloudExport(Set<String> ids) async { + final accountId = state.workspace?.nextcloudAccountId; + if (accountId == null) return null; + for (final id in ids) { + final buffer = state.documentBuffers + .where((b) => b.remoteNote?.localId == id) + .firstOrNull; + if (buffer?.isDirty == true && + !await _saveRemoteBufferSnapshot(buffer!, scheduleSync: false)) { + return null; + } + } + if (state.workspace?.nextcloudAccountId != accountId || + state.documentBuffers.any( + (b) => ids.contains(b.remoteNote?.localId) && b.isDirty, + )) { + return null; + } + return (await _ensureNotesRepository()).captureLocalSnapshot( + accountId, + ids, + ); + } + + void synchronizeImportedNotes(String accountId) => + _scheduleRemoteSync(accountId); + Future<bool> deleteNextcloudNote(String localId) async { try { final repository = await _ensureNotesRepository(); @@ -1075,6 +1165,30 @@ class WorkspaceController extends Notifier<WorkspaceState> { } } + Future<bool> recoverDeletedNextcloudNote( + String localId, { + String? content, + }) async { + try { + final repository = await _ensureNotesRepository(); + final original = repository.noteById(localId); + if (original == null || + state.workspace?.nextcloudAccountId != original.accountId) { + return false; + } + final recovered = await repository.recoverAsNew( + localId, + content: content, + ); + final opened = await openNextcloudNote(recovered.localId); + _scheduleRemoteSync(original.accountId); + return opened; + } on Object catch (error) { + _reportNextcloudFailure(error); + return false; + } + } + Future<bool> recoverNextcloudHistoryRevision({ required LocalHistoryDocument document, required LocalHistoryRevision revision, @@ -1110,9 +1224,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); _invalidateActiveDocumentOperations(); - _remoteSyncRequested.remove(accountId); - _remoteRetryTimers.remove(accountId)?.cancel(); - _remoteRetryCounts.remove(accountId); + _notesCoordinator?.removeAccount(accountId); for (final buffer in state.documentBuffers) { await _localHistory.handleBufferClosed(buffer.id, historySettled: true); } @@ -3527,6 +3639,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { } Future<bool> reloadBufferFromDisk(String bufferId) async { + if (!propertyEdits.flush()) return false; final buffer = state.documentBuffers .where((candidate) => candidate.id == bufferId) .firstOrNull; @@ -3672,6 +3785,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { /// Starts a document from Welcome, replacing the previous workspace only /// after its buffers have been resolved and their history has been flushed. Future<bool> createMarkdownWorkspace() async { + if (!propertyEdits.flush()) return false; final previous = state; final previousOperation = _activeDocumentRevision; if (previous.hasUnsavedChanges || previous.isLoading) return false; @@ -3706,6 +3820,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { } Future<void> createMarkdownFile() async { + if (!propertyEdits.flush()) return; if (state.workspace?.isRemote == true) { await createNextcloudNote(); return; @@ -3769,6 +3884,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { } Future<void> openPath(String path) async { + if (!propertyEdits.flush()) return; _invalidateFileMonitorWorkspace(); _removalOperationRevision++; final closingBuffers = state.documentBuffers; @@ -3853,6 +3969,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { Future<bool> createWritersideProject( WritersideProjectCreateRequest request, ) async { + if (!propertyEdits.flush()) return false; _invalidateFileMonitorWorkspace(); _removalOperationRevision++; final closingBuffers = state.documentBuffers; @@ -4107,10 +4224,159 @@ class WorkspaceController extends Notifier<WorkspaceState> { return created; } - Future<bool> renameWorkspaceEntity(String path, String newName) async { + Future<MarkdownMovePlan?> prepareMarkdownMove( + String source, + String destination, + ) async { + final workspace = state.workspace; + if (workspace == null || workspace.kind != WorkspaceKind.markdownFolder) { + return null; + } + _requireCleanAffectedFiles( + workspace, + workspace.files.map((f) => f.absolutePath), + ); + return MarkdownMoveService( + workspaceService: _service, + ).prepare(workspace, source, destination); + } + + Future<bool> recoverMarkdownMove(MarkdownRecoveryPlan plan) { + return _runWorkspaceFileOperation((workspace) async { + if (workspace.id != state.workspace?.id || + !p.equals(workspace.rootPath, plan.root)) { + throw StateError('Recovery workspace changed.'); + } + void validateBuffers() => _requireCleanAffectedFiles( + workspace, + workspace.files.map((f) => f.absolutePath), + ); + validateBuffers(); + final service = MarkdownMoveService(workspaceService: _service); + for (final file in plan.files) { + if (!await _localHistory.capturePath( + path: file.path, + text: file.load.text, + format: file.load.format, + reason: LocalHistoryCaptureReason.beforeRestore, + requireProtection: true, + )) { + throw StateError('Local History could not protect recovery.'); + } + } + if (!plan.moved) { + await service.recoverReviewed(plan, validateBuffers: validateBuffers); + return state.activeBuffer?.filePath; + } + final active = state.activeBuffer?.filePath; + final transitions = _beginLocalHistoryPathTransitions( + plan.destination, + plan.source, + ); + String? historyId; + var committed = false; + try { + await _localHistory.runStagedPathRemap( + sourcePath: plan.destination, + destinationPath: plan.source, + filesystemOperation: () async { + await service.recoverReviewed( + plan, + validateBuffers: validateBuffers, + ); + return plan.source; + }, + didCommit: (_) => true, + onOperationStaged: (id) => historyId = id, + ); + committed = true; + _remapOpenWorkspacePaths(workspace, plan.destination, plan.source); + await _localHistory.acknowledgeStagedPathOperation(historyId); + return _remapMovedPath(active, plan.destination, plan.source); + } finally { + if (!committed) { + await _localHistory.cancelStagedPathOperation(historyId); + } + await _finishLocalHistoryPathTransitions( + transitions, + committed: committed, + ); + } + }); + } + + Future<MarkdownMovePlan?> _protectMarkdownMove( + Workspace workspace, + String source, + String destination, + MarkdownMovePlan? reviewed, + ) async { + if (workspace.kind != WorkspaceKind.markdownFolder || + p.equals(source, destination)) { + return null; + } + final service = MarkdownMoveService(workspaceService: _service); + final plan = + reviewed ?? await service.prepare(workspace, source, destination); + if (plan.workspaceId != workspace.id || + !p.equals(plan.source, source) || + !p.equals(plan.destination, destination)) { + throw StateError('The reviewed operation changed.'); + } + if (reviewed == null && plan.files.isNotEmpty) { + throw StateError('Review the affected links before moving this file.'); + } + void validateBuffers() => + _requireCleanAffectedFiles(workspace, plan.snapshots.keys); + validateBuffers(); + for (final file in plan.files) { + if (!await _localHistory.capturePath( + path: file.path, + text: file.load.text, + format: file.load.format, + reason: LocalHistoryCaptureReason.beforeRestore, + requireProtection: true, + )) { + throw StateError( + 'Local History could not protect the reviewed changes.', + ); + } + } + return plan; + } + + Future<String> _applyMarkdownMove( + Workspace workspace, + String source, + String destination, + MarkdownMovePlan? plan, + Future<String> Function() move, + ) async { + if (plan == null) return move(); + if (state.workspace?.id != plan.workspaceId) { + throw StateError('The reviewed workspace changed.'); + } + void validateBuffers() => + _requireCleanAffectedFiles(workspace, plan.snapshots.keys); + return MarkdownMoveService( + workspaceService: _service, + ).apply(plan, move: move, validateBuffers: validateBuffers); + } + + Future<bool> renameWorkspaceEntity( + String path, + String newName, { + MarkdownMovePlan? reviewedPlan, + }) async { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { final expectedTarget = p.normalize(p.join(p.dirname(path), newName)); + final protectedPlan = await _protectMarkdownMove( + workspace, + path, + expectedTarget, + reviewedPlan, + ); final transitions = _beginLocalHistoryPathTransitions( path, expectedTarget, @@ -4121,8 +4387,13 @@ class WorkspaceController extends Notifier<WorkspaceState> { final target = await _localHistory.runStagedPathRemap( sourcePath: path, destinationPath: expectedTarget, - filesystemOperation: () => - _service.renameEntity(workspace, path, newName), + filesystemOperation: () => _applyMarkdownMove( + workspace, + path, + expectedTarget, + protectedPlan, + () => _service.renameEntity(workspace, path, newName), + ), didCommit: (_) => true, onOperationStaged: (value) => historyOperationId = value, ); @@ -4144,13 +4415,20 @@ class WorkspaceController extends Notifier<WorkspaceState> { Future<bool> moveWorkspaceEntity( String sourcePath, - String targetDirectoryPath, - ) async { + String targetDirectoryPath, { + MarkdownMovePlan? reviewedPlan, + }) async { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { final expectedTarget = p.normalize( p.join(targetDirectoryPath, p.basename(sourcePath)), ); + final protectedPlan = await _protectMarkdownMove( + workspace, + sourcePath, + expectedTarget, + reviewedPlan, + ); final transitions = _beginLocalHistoryPathTransitions( sourcePath, expectedTarget, @@ -4161,8 +4439,14 @@ class WorkspaceController extends Notifier<WorkspaceState> { final target = await _localHistory.runStagedPathRemap( sourcePath: sourcePath, destinationPath: expectedTarget, - filesystemOperation: () => - _service.moveEntity(workspace, sourcePath, targetDirectoryPath), + filesystemOperation: () => _applyMarkdownMove( + workspace, + sourcePath, + expectedTarget, + protectedPlan, + () => + _service.moveEntity(workspace, sourcePath, targetDirectoryPath), + ), didCommit: (_) => true, onOperationStaged: (value) => historyOperationId = value, ); @@ -4769,6 +5053,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { DocumentBuffer? discardAuthorization, bool? preflushedHistorySettled, }) async { + if (!propertyEdits.flush()) return false; final workspace = state.workspace; if (workspace == null || !workspace.openFilePaths.contains(path)) { return false; @@ -4872,6 +5157,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { String bufferId, { bool discard = false, }) async { + if (!propertyEdits.flush()) return false; final closingWorkspace = state.workspace; _cancelAutoSave(bufferId); return _enqueueBufferWrite(bufferId, () async { @@ -5216,6 +5502,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { required List<DocumentBuffer> documentBuffers, required List<String> openFilePaths, }) async { + if (!propertyEdits.flush()) return false; _cancelPendingDerivedRefresh(); final operationRevision = _invalidateActiveDocumentOperations(); final workspaceId = workspace.id; @@ -5343,7 +5630,10 @@ class WorkspaceController extends Notifier<WorkspaceState> { final live = liveBuffers .where((candidate) => candidate.id == removedId) .firstOrNull; - if (before != null && live != null && !identical(before, live)) { + if (before != null && + live != null && + !identical(before, live) && + !_sameCleanClosingSource(before, live)) { return false; } } @@ -5411,6 +5701,30 @@ class WorkspaceController extends Notifier<WorkspaceState> { return true; } + // Editor restoration can report a caret/folding session while activation of + // the next tab is awaiting parsing. A clean close owns no discarded edits; + // allow only that metadata refresh. Dirty discard and all source, filesystem + // identity, origin and permission changes still invalidate publication. + bool _sameCleanClosingSource(DocumentBuffer before, DocumentBuffer live) => + !before.isDirty && + !live.isDirty && + before.id == live.id && + before.revision == live.revision && + before.text == live.text && + before.lastSavedText == live.lastSavedText && + before.filePath == live.filePath && + before.untitledName == live.untitledName && + identical(before.diskSnapshot, live.diskSnapshot) && + identical(before.diskVersionSnapshot, live.diskVersionSnapshot) && + before.diskState == live.diskState && + before.diskVersionText == live.diskVersionText && + identical(before.format, live.format) && + before.remoteNote == live.remoteNote && + before.remoteTitle == live.remoteTitle && + identical(before.remoteEditBase, live.remoteEditBase) && + before.readonly == live.readonly && + before.recovered == live.recovered; + void updateActiveEditorState(DocumentEditorState editorState) { final buffer = state.activeBuffer; if (buffer == null) { @@ -5567,6 +5881,7 @@ class WorkspaceController extends Notifier<WorkspaceState> { } void updateActiveEditorMode(DocumentViewModePreference mode) { + if (!propertyEdits.flush()) return; final buffer = state.activeBuffer; if (buffer == null || buffer.editorState.mode == mode) { return; @@ -5672,6 +5987,33 @@ class WorkspaceController extends Notifier<WorkspaceState> { ); } + bool applyInspectorSource({ + required String bufferId, + required int revision, + required String expectedSource, + required String source, + bool requiresSourceChange = false, + }) { + final buffer = state.activeBuffer; + if (buffer == null || + buffer.id != bufferId || + buffer.revision != revision || + buffer.text != expectedSource || + buffer.readonly || + buffer.editorState.mode == DocumentViewModePreference.preview) { + return false; + } + if (source == expectedSource) return !requiresSourceChange; + updateActiveSourceText( + source, + sourceBufferId: bufferId, + sourceFilePath: buffer.filePath, + previousSelection: buffer.editorState.selection, + selection: buffer.editorState.selection, + ); + return state.activeBuffer?.text == source; + } + void updateActiveSourceText( String text, { String? sourceBufferId, @@ -6064,7 +6406,11 @@ class WorkspaceController extends Notifier<WorkspaceState> { ActiveDocumentSaveTarget? target, LineEndingNormalization? mixedLineEndingNormalization, }) async { - final operationTarget = target ?? captureActiveDocumentSaveTarget(); + if (target != null && !isActiveDocumentSaveTargetCurrent(target)) { + return false; + } + if (!propertyEdits.flush()) return false; + final operationTarget = captureActiveDocumentSaveTarget(); if (operationTarget == null || !isActiveDocumentSaveTargetCurrent(operationTarget)) { return false; @@ -6295,6 +6641,16 @@ class WorkspaceController extends Notifier<WorkspaceState> { Map<String, LineEndingNormalization> mixedLineEndingNormalizations = const {}, }) async { + if (!propertyEdits.flush()) { + return SaveAllResult( + savedBufferIds: const [], + failedBufferIds: [ + if (state.activeBufferId != null) state.activeBufferId!, + ], + conflictBufferIds: const [], + normalizationRequiredBufferIds: const [], + ); + } _cancelAllAutoSaves(); final saved = <String>[]; final failed = <String>[]; @@ -6429,7 +6785,11 @@ class WorkspaceController extends Notifier<WorkspaceState> { bool overwriteExisting = false, LineEndingNormalization? mixedLineEndingNormalization, }) async { - final operationTarget = target ?? captureActiveDocumentSaveTarget(); + if (target != null && !isActiveDocumentSaveTargetCurrent(target)) { + return false; + } + if (!propertyEdits.flush()) return false; + final operationTarget = captureActiveDocumentSaveTarget(); if (operationTarget == null || !isActiveDocumentSaveTargetCurrent(operationTarget)) { return false; diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index be602526..8932d849 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -47,6 +47,13 @@ extension DocumentKindAiSupport on DocumentKind { this == DocumentKind.writersideMarkdownTopic; } +extension DocumentKindVisualEditingSupport on DocumentKind { + bool get supportsVisualEditing => + this == DocumentKind.markdown || + this == DocumentKind.writersideMarkdownTopic || + this == DocumentKind.writersideXmlTopic; +} + extension DocumentKindSpellingSupport on DocumentKind { bool get supportsSpelling => this == DocumentKind.markdown || diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index 0efaf568..8bee2963 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -25,6 +25,9 @@ XTypeGroup _markdownSaveType(BuildContext context) => XTypeGroup( ); Future<bool> confirmSafeToContinue(BuildContext context, WidgetRef ref) async { + if (!ref.read(workspaceControllerProvider.notifier).propertyEdits.flush()) { + return false; + } final state = ref.read(workspaceControllerProvider); if (!state.hasUnsavedChanges) { return true; @@ -92,6 +95,9 @@ Future<bool> confirmSafeToCloseActiveDocument( BuildContext context, WidgetRef ref, ) async { + if (!ref.read(workspaceControllerProvider.notifier).propertyEdits.flush()) { + return false; + } final active = ref.read(workspaceControllerProvider).activeBuffer; if (active == null || !active.isDirty) { return true; @@ -360,6 +366,9 @@ Future<bool> saveOrConfirmSafeToChangeActiveFile( BuildContext context, WidgetRef ref, ) async { + if (!ref.read(workspaceControllerProvider.notifier).propertyEdits.flush()) { + return false; + } final state = ref.read(workspaceControllerProvider); if (!state.isDirty) { return true; @@ -378,6 +387,7 @@ Future<bool> saveActiveWithOverwriteConfirmation( ActiveDocumentSaveTarget? target, }) async { final controller = ref.read(workspaceControllerProvider.notifier); + if (!controller.propertyEdits.flush()) return false; final operationTarget = target ?? controller.captureActiveDocumentSaveTarget(); if (operationTarget == null || diff --git a/lib/src/writerside/writerside_document.dart b/lib/src/writerside/writerside_document.dart index 86107e92..0281d272 100644 --- a/lib/src/writerside/writerside_document.dart +++ b/lib/src/writerside/writerside_document.dart @@ -76,6 +76,32 @@ class WritersideDocument { Iterable<WritersideElementNode> get elements => walk().whereType<WritersideElementNode>(); + /// Topic title children have a different owner from chapter title children. + /// Native Markdown headings establish that boundary without an XML root. + Iterable<WritersideElementNode> get topicTitleElements { + if (format == WritersideDocumentFormat.xmlTopic) { + return (rootElement?.children ?? const <WritersideDocumentNode>[]) + .whereType<WritersideElementNode>() + .where((e) => e.name == 'title'); + } + final headings = nodes.whereType<WritersideMarkdownBlockNode>().where( + (n) => n.block.kind == BusyBlockKind.heading, + ); + final topic = headings + .where((n) => n.block.attributes['level'] == '1') + .firstOrNull; + final chapter = headings + .where( + (n) => topic == null || n.span.startOffset > topic.span.startOffset, + ) + .firstOrNull; + return nodes.whereType<WritersideElementNode>().where( + (e) => + e.name == 'title' && + (chapter == null || e.span.startOffset < chapter.span.startOffset), + ); + } + WritersideElementNode? elementById(String id) { for (final element in elements) { if (element.attributes['id'] == id) { diff --git a/lib/src/writerside/writerside_document_parser.dart b/lib/src/writerside/writerside_document_parser.dart index bda103fa..79993152 100644 --- a/lib/src/writerside/writerside_document_parser.dart +++ b/lib/src/writerside/writerside_document_parser.dart @@ -119,12 +119,12 @@ class WritersideDocumentParser { qualifiedName: event.name, attributes: { for (final attribute in event.attributes) - _localName(attribute.name): attribute.value, + _attributeName(attribute.name): attribute.value, }, qualifiedAttributes: [ for (final attribute in event.attributes) WritersideQualifiedAttribute( - name: _localName(attribute.name), + name: _attributeName(attribute.name), qualifiedName: attribute.name, value: attribute.value, ), @@ -420,7 +420,7 @@ Map<String, SourceSpan> _attributeSpans({ ); final valueStart = quoteOffset + 1; final valueEnd = valueStart + match.group(3)!.length; - result[_localName(match.group(1)!)] = SourceSpan.fromOffsets( + result[_attributeName(match.group(1)!)] = SourceSpan.fromOffsets( filePath: filePath, source: fullSource, startOffset: openingOffset + valueStart, @@ -432,6 +432,11 @@ Map<String, SourceSpan> _attributeSpans({ String _localName(String value) => value.split(':').last.toLowerCase(); +// Attributes in different namespaces can share a local name. Their authored +// identities must remain distinct when a sibling or parent is modified. +String _attributeName(String value) => + value.contains(':') ? value : _localName(value); + String? _safeSubstring(String value, int start, int end) { if (start < 0 || end < start || end > value.length) { return null; diff --git a/lib/src/writerside/writerside_document_renderer.dart b/lib/src/writerside/writerside_document_renderer.dart index e4109c5c..e8e9da1f 100644 --- a/lib/src/writerside/writerside_document_renderer.dart +++ b/lib/src/writerside/writerside_document_renderer.dart @@ -1,3 +1,4 @@ +import 'writerside_effective_properties.dart'; import 'dart:convert'; import '../export/openapi_static_export_mapper.dart'; import '../export/markdown_export_document.dart'; @@ -598,22 +599,37 @@ class _WritersideRenderSession { element.children, headingLevel: headingLevel, ); - final inheritedCollapsible = element.attributes['collapsible']; result.add( BusyBlock( id: _nodeId(element), kind: BusyBlockKind.writersideRawXml, children: [ for (final child in children) - inheritedCollapsible == null || - child.attributes.containsKey('collapsible') - ? child - : child.copyWith( - attributes: { - ...child.attributes, - 'collapsible': inheritedCollapsible, - }, - ), + child.copyWith( + attributes: { + ...child.attributes, + 'busymark-effective-collapsible': + const WritersideEffectiveProperties() + .resolve( + 'def', + 'collapsible', + child.attributes, + definitionList: element.attributes, + ) + .value ?? + '', + 'busymark-effective-default-state': + const WritersideEffectiveProperties() + .resolve( + 'def', + 'default-state', + child.attributes, + definitionList: element.attributes, + ) + .value ?? + '', + }, + ), ], attributes: attributes, sourceSpan: element.span, diff --git a/lib/src/writerside/writerside_document_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index c10f6cce..f03ca068 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -1,3 +1,4 @@ +import 'writerside_effective_properties.dart'; import 'dart:convert'; import 'package:html/parser.dart' as html; import 'package:path/path.dart' as p; @@ -91,6 +92,142 @@ class ResolvedWritersideDocument { class WritersideDocumentResolver { const WritersideDocumentResolver(); + /// Inspector values use the same lexical scope, variables and conditions as + /// publication. The source occurrence, rather than a reusable parser ID, + /// identifies the requested authored element. + Map<String, String> resolveAuthoredProperties( + WritersideDocument document, + WritersideResolveContext context, + int offset, + ) { + final resolved = resolve( + _capitalizationDefaults(document, context), + context, + ).document; + for (final node in resolved.walk()) { + if (node.span.startOffset != offset || + node.span.filePath != document.filePath) { + continue; + } + if (node is WritersideElementNode) return node.attributes; + if (node is WritersideMarkdownBlockNode) return node.block.attributes; + } + return const {}; + } + + Map<int, Map<String, String>> resolvePropertyOccurrences( + WritersideDocument document, + WritersideResolveContext context, + ) => { + for (final node in resolve( + _capitalizationDefaults(document, context), + context, + ).document.walk()) ...{ + if (node.span.filePath == document.filePath && + node is WritersideElementNode) + node.span.startOffset: node.attributes, + if (node.span.filePath == document.filePath && + node is WritersideMarkdownBlockNode) + node.span.startOffset: node.block.attributes, + }, + }; + + // Resolve configured capitalization through the existing lexical-variable + // machinery. The authored document remains unchanged, and this projection + // does not treat a resolved default as a locally authored attribute. + WritersideDocument _capitalizationDefaults( + WritersideDocument document, + WritersideResolveContext context, + ) { + Map<String, String> attributes(String tag, Map<String, String> authored) { + final configured = context.module.config.settings.defaultProperties + .where( + (p) => + p.elementName == tag && + p.propertyName == 'caps' && + p.value != null, + ) + .firstOrNull; + return {if (configured != null) 'caps': configured.value!, ...authored}; + } + + WritersideDocumentNode visit(WritersideDocumentNode node) { + if (node is WritersideElementNode) { + return node.copyWith( + attributes: attributes(node.name, node.attributes), + children: node.children.map(visit).toList(), + ); + } + if (node is WritersideMarkdownBlockNode && + node.block.kind == BusyBlockKind.heading) { + return node.copyWith( + block: node.block.copyWith( + attributes: attributes('chapter', node.block.attributes), + ), + ); + } + return node; + } + + return document.copyWith(nodes: document.nodes.map(visit).toList()); + } + + /// Authored-reference inspection uses exactly the publication link resolver. + Map<String, String> resolveAuthoredLink({ + required WritersideResolveContext context, + required WritersideDocumentNode node, + required String destination, + String? origin, + }) { + if (destination.contains('%')) { + // Resolve lexical variables and instance conditions through the same + // authored tree used by preview; retain the original occurrence span. + final resolved = resolve(context.topic.document, context).document; + final link = resolved + .walk() + .whereType<WritersideElementNode>() + .where( + (candidate) => + candidate.semanticKind == WritersideSemanticKind.link && + candidate.span.filePath == node.span.filePath && + candidate.span.startOffset == node.span.startOffset, + ) + .firstOrNull; + if (link != null) return link.attributes; + } + return _ResolveState(context)._resolveLink( + {'href': destination, if (origin != null) 'origin': origin}, + module: context.module, + topic: context.topic, + node: node, + ); + } + + bool matchesInstance( + String? condition, + WritersideModule module, + WritersideInstance? instance, + ) => _matchesWritersideInstance(condition, module, instance); + + bool authoredIncludeAvailable({ + required WritersideResolveContext context, + required WritersideElementNode include, + }) { + final state = _ResolveState(context); + if (!state._matchesConditions(include.attributes, null, context.module)) { + return false; + } + final result = state._resolveInclude( + include, + module: context.module, + topic: context.topic, + variables: const {}, + includeStack: <String>{}, + inheritedIgnoreVariables: false, + ); + return result.isNotEmpty && !result.any((n) => identical(n, include)); + } + /// Navigation-only attributes share the renderer's condition and variable /// engine, without manufacturing a document for groups and external links. String resolveNavigationTitle( @@ -330,7 +467,9 @@ class _ResolveState { inheritedIgnoreVariables, ); final attributes = <String, String>{ - for (final entry in element.attributes.entries) + for (final entry in WritersideEffectiveProperties( + settings: module.config.settings, + ).defaultsFor(element.name, element.attributes).entries) entry.key: _interpolate( entry.value, scopedVariables, @@ -537,12 +676,7 @@ class _ResolveState { attributes['anchor']?.replaceFirst(RegExp(r'^#'), '') ?? (hash < 0 ? '' : href.substring(hash + 1)); final destination = '$path${anchor.isEmpty ? '' : '#$anchor'}'; - var decodedAnchor = anchor; - try { - decodedAnchor = Uri.decodeComponent(anchor); - } on FormatException { - // Preserve malformed fragments so normal reference validation reports them. - } + final decodedAnchor = decodeWritersideReferenceComponent(anchor); if (Uri.tryParse(path)?.hasScheme == true) { return { 'resolved-destination': destination, @@ -915,7 +1049,9 @@ class _ResolveState { } bool _ignoreVariablesFor(WritersideElementNode element, bool inherited) { - final explicit = element.attributes['ignore-vars']; + final explicit = WritersideEffectiveProperties( + settings: context.module.config.settings, + ).defaultsFor(element.name, element.attributes)['ignore-vars']; if (explicit == 'true') { return true; } @@ -928,11 +1064,14 @@ class _ResolveState { if (context.module.config.settings.smartIgnoreVars != true) { return false; } - return { - WritersideSemanticKind.codeBlock, - WritersideSemanticKind.link, - WritersideSemanticKind.image, - }.contains(element.semanticKind); + return element.semanticKind == WritersideSemanticKind.codeBlock || + { + WritersideSemanticKind.link, + WritersideSemanticKind.image, + }.contains(element.semanticKind) && + RegExp(r'^(?:https?:)?//').hasMatch( + element.attributes[element.name == 'img' ? 'src' : 'href'] ?? '', + ); } BusyBlock _resolveMarkdownBlock( @@ -943,10 +1082,27 @@ class _ResolveState { required bool ignoreVariables, required WritersideDocumentNode sourceNode, }) { - final explicit = block.attributes['ignore-vars']; + final tag = + block.attributes['element'] ?? + switch (block.kind) { + BusyBlockKind.heading => + block.attributes['level'] == '1' ? 'topic' : 'chapter', + BusyBlockKind.codeBlock => 'code-block', + BusyBlockKind.image => 'img', + BusyBlockKind.table => 'table', + _ => 'p', + }; + final effectiveAttributes = WritersideEffectiveProperties( + settings: module.config.settings, + ).defaultsFor(tag, block.attributes); + final explicit = effectiveAttributes['ignore-vars']; final smart = context.module.config.settings.smartIgnoreVars == true && - {BusyBlockKind.codeBlock, BusyBlockKind.image}.contains(block.kind); + (block.kind == BusyBlockKind.codeBlock || + block.kind == BusyBlockKind.image && + RegExp( + r'^(?:https?:)?//', + ).hasMatch(block.attributes['src'] ?? '')); final ignore = explicit == 'true' || (explicit != 'false' && (ignoreVariables || smart)); @@ -954,7 +1110,17 @@ class _ResolveState { final children = inline.children .map(resolveInline) .toList(growable: false); - final inlineIgnore = inline.attributes['ignore-vars'] == 'true' || ignore; + final inlineTag = inline.kind == BusyInlineKind.image ? 'img' : 'a'; + final explicitInline = WritersideEffectiveProperties( + settings: module.config.settings, + ).defaultsFor(inlineTag, inline.attributes)['ignore-vars']; + final smartInline = + module.config.settings.smartIgnoreVars == true && + {BusyInlineKind.image, BusyInlineKind.link}.contains(inline.kind) && + RegExp(r'^(?:https?:)?//').hasMatch(inline.destination ?? ''); + final inlineIgnore = + explicitInline == 'true' || + explicitInline != 'false' && (ignore || smartInline); final resolved = inline.copyWith( text: children.isEmpty ? _interpolate( @@ -1048,7 +1214,7 @@ class _ResolveState { ], attributes: { if (sourceText != null) writersideResolvedSourceAttribute: sourceText, - for (final entry in block.attributes.entries) + for (final entry in effectiveAttributes.entries) entry.key: _interpolate( entry.value, variables, diff --git a/lib/src/writerside/writerside_document_serializer.dart b/lib/src/writerside/writerside_document_serializer.dart index 54d94030..2f949c71 100644 --- a/lib/src/writerside/writerside_document_serializer.dart +++ b/lib/src/writerside/writerside_document_serializer.dart @@ -60,6 +60,40 @@ class WritersideDocumentSerializer { } String _serializeElement(WritersideElementNode element) { + final originalAttributes = { + for (final a in element.qualifiedAttributes) a.name: a.value, + }; + final opening = _originalOpening(element.rawSource); + final originalName = RegExp( + r'^<([^\s/>]+)', + ).firstMatch(element.rawSource)?.group(1); + if (opening != null && originalName == element.qualifiedName) { + final patched = _patchOpening(element, opening, originalAttributes); + if (patched != null) { + if (opening.endsWith('/>') && element.children.isEmpty) return patched; + if (!opening.endsWith('/>')) { + final closing = RegExp( + r'</[^>]+>\s*$', + dotAll: true, + ).firstMatch(element.rawSource)?.group(0); + if (closing != null) { + return '$patched${element.children.map(_serializeNode).join()}$closing'; + } + } + } + } + if (opening != null && + !opening.endsWith('/>') && + originalName == element.qualifiedName && + _sameStringMap(element.attributes, originalAttributes)) { + final closing = RegExp( + r'</[^>]+>\s*$', + dotAll: true, + ).firstMatch(element.rawSource)?.group(0); + if (closing != null) { + return '$opening${element.children.map(_serializeNode).join()}$closing'; + } + } final attributes = _serializeAttributes(element); if (element.children.isEmpty && RegExp(r'/\s*>$').hasMatch(element.rawSource.trim())) { @@ -70,6 +104,72 @@ class WritersideDocumentSerializer { '$children</${element.qualifiedName}>'; } + String? _originalOpening(String source) { + if (!source.startsWith('<')) return null; + String? quote; + for (var i = 1; i < source.length; i++) { + final c = source[i]; + if (quote != null) { + if (c == quote) quote = null; + } else if (c == '"' || c == "'") { + quote = c; + } else if (c == '>') { + return source.substring(0, i + 1); + } + } + return null; + } + + // Existing parser spans delimit values, so editing one attribute preserves + // other authored bytes, namespace declarations, quote style and spacing. + String? _patchOpening( + WritersideElementNode element, + String opening, + Map<String, String> original, + ) { + final edits = <({int start, int end, String value})>[]; + for (final attribute in element.qualifiedAttributes) { + final name = attribute.name; + if (element.attributes[name] == original[name]) continue; + final span = element.attributeSpans[name]; + if (span == null) return null; + final start = span.startOffset - element.span.startOffset; + final end = span.endOffset - element.span.startOffset; + if (start < 1 || end >= opening.length) return null; + if (element.attributes.containsKey(name)) { + final quote = opening[start - 1]; + final value = _escapeText( + element.attributes[name]!, + ).replaceAll(quote, quote == "'" ? ''' : '"'); + edits.add((start: start, end: end, value: value)); + } else { + final prefix = opening.substring(0, start - 1); + final match = RegExp(r'\s+[^\s=]+\s*=\s*$').firstMatch(prefix); + if (match == null) return null; + edits.add((start: match.start, end: end + 1, value: '')); + } + } + final additions = element.attributes.entries + .where((entry) => !original.containsKey(entry.key)) + .map((entry) => ' ${entry.key}="${_escapeAttribute(entry.value)}"') + .join(); + if (additions.isNotEmpty) { + var at = opening.endsWith('/>') ? opening.length - 2 : opening.length - 1; + // Keep authored closing whitespace after the new attribute. Removing the + // override after reparsing then removes only its own separator. + while (at > 1 && RegExp(r'\s').hasMatch(opening[at - 1])) { + at--; + } + edits.add((start: at, end: at, value: additions)); + } + edits.sort((a, b) => b.start.compareTo(a.start)); + var result = opening; + for (final edit in edits) { + result = result.replaceRange(edit.start, edit.end, edit.value); + } + return result; + } + String _serializeAttributes(WritersideElementNode element) { final originalValues = <String, String>{ for (final attribute in element.qualifiedAttributes) diff --git a/lib/src/writerside/writerside_effective_properties.dart b/lib/src/writerside/writerside_effective_properties.dart new file mode 100644 index 00000000..05fee977 --- /dev/null +++ b/lib/src/writerside/writerside_effective_properties.dart @@ -0,0 +1,180 @@ +import 'writerside_model.dart'; + +enum WritersidePropertyOrigin { + element, + ancestor, + module, + markdownMarker, + builtIn, + unresolved, +} + +class WritersideEffectiveProperty { + const WritersideEffectiveProperty( + this.authored, + this.value, + this.origin, { + this.limitation, + }); + final String? authored; + final String? value; + final WritersidePropertyOrigin origin; + + /// A verified builder limitation, independent of the resolved value. + final String? limitation; + bool get unresolved => value == null; +} + +/// Rules are tied to the supported builder. State-only forms are retained, +/// but 2026.08.0328 requires effective collapsible=true for published collapse. +class WritersideEffectiveProperties { + const WritersideEffectiveProperties({ + this.settings = const WritersideSettingsConfig(), + this.variables = const {}, + this.resolvedValues = const {}, + this.inheritedIgnoreVars, + }); + final WritersideSettingsConfig settings; + final Map<String, String> variables; + + /// Values from the existing document resolver for this authored occurrence. + final Map<String, String> resolvedValues; + final String? inheritedIgnoreVars; + + WritersideEffectiveProperty resolve( + String element, + String property, + Map<String, String> attributes, { + Map<String, String>? definitionList, + }) { + final authored = attributes[property]; + // The supported builder accepts module <caps> declarations but leaves + // heading/TOC text unchanged. Local caps and default-property are applied. + // Keep this distinction explicit instead of calling an omitted caps value + // unresolved or promising the documented module rule's ignored effect. + final capsLimitation = + property == 'caps' && + authored == null && + settings.capsRules.any((r) => r.target == element) + ? 'module-caps-ignored' + : null; + String? value = authored; + var origin = WritersidePropertyOrigin.element; + if (value == null && + element == 'list' && + property == 'type' && + attributes['busymark-native-list-type'] == 'decimal') { + value = 'decimal'; + origin = WritersidePropertyOrigin.markdownMarker; + } + if (value == null && + element == 'list' && + property == 'start' && + attributes['busymark-native-list-start'] != null && + attributes['busymark-native-list-start'] != '1') { + value = attributes['busymark-native-list-start']; + origin = WritersidePropertyOrigin.markdownMarker; + } + final inherited = + element == 'def' && + property == 'default-state' && + authored == 'inherited'; + if (element == 'def' && property == 'collapsible') { + value = definitionList?['collapsible'] ?? 'false'; + origin = definitionList == null + ? WritersidePropertyOrigin.builtIn + : WritersidePropertyOrigin.ancestor; + } else if (inherited) { + value = definitionList?['default-state']; + origin = WritersidePropertyOrigin.ancestor; + } else if (value == null) { + final defaults = settings.defaultProperties + .where( + (p) => + p.elementName == element && + p.propertyName == property && + p.value != null, + ) + .toList(); + if (defaults.length == 1 || defaults.isNotEmpty && property == 'caps') { + // Capitalization default-property uses the first declaration in the + // pinned builder, independently of ignored module <caps> rules. + value = defaults.first.value; + origin = WritersidePropertyOrigin.module; + } + if (defaults.length > 1 && property != 'caps') { + return WritersideEffectiveProperty( + authored, + null, + WritersidePropertyOrigin.unresolved, + ); + } + } + if (value == null && element == 'def' && property == 'default-state') { + value = definitionList?['default-state']; + origin = WritersidePropertyOrigin.ancestor; + } + if (value == null && + property == 'ignore-vars' && + inheritedIgnoreVars != null) { + value = inheritedIgnoreVars; + origin = WritersidePropertyOrigin.ancestor; + } + if (value == null) { + value = switch (property) { + 'collapsible' => + element == 'def' + ? (definitionList?['collapsible'] ?? 'false') + : 'false', + 'default-state' => 'collapsed', + 'ignore-vars' => + settings.smartIgnoreVars == true && + (element == 'code-block' || + {'img', 'a'}.contains(element) && + RegExp(r'^(?:https?:)?//').hasMatch( + attributes[element == 'img' ? 'src' : 'href'] ?? '', + )) + ? 'true' + : 'false', + 'type' when element == 'deflist' => 'full', + 'caps' => 'aswritten', + 'type' when element == 'list' => + attributes['busymark-native-list-type'] ?? 'bullet', + 'sorted' when element == 'list' => 'none', + 'start' when element == 'list' => '1', + _ => null, + }; + origin = + element == 'def' && + property == 'collapsible' && + definitionList?.containsKey('collapsible') == true + ? WritersidePropertyOrigin.ancestor + : WritersidePropertyOrigin.builtIn; + } + var unresolved = false; + if (value?.contains('%') == true && resolvedValues.containsKey(property)) { + value = resolvedValues[property]; + } + value = value?.replaceAllMapped(RegExp(r'%([^%]+)%'), (match) { + final replacement = variables[match[1]]; + if (replacement == null) unresolved = true; + return replacement ?? match[0]!; + }); + return WritersideEffectiveProperty( + authored, + unresolved ? null : value, + unresolved ? WritersidePropertyOrigin.unresolved : origin, + limitation: capsLimitation, + ); + } + + Map<String, String> defaultsFor( + String element, + Map<String, String> authored, + ) => { + for (final p in settings.defaultProperties) + if (p.elementName == element && p.propertyName != null && p.value != null) + p.propertyName!: p.value!, + ...authored, + }; +} diff --git a/lib/src/writerside/writerside_html_reference_scanner.dart b/lib/src/writerside/writerside_html_reference_scanner.dart index 539d9260..15dfc6a4 100644 --- a/lib/src/writerside/writerside_html_reference_scanner.dart +++ b/lib/src/writerside/writerside_html_reference_scanner.dart @@ -46,6 +46,89 @@ class WritersideAuthoredHtmlAnchorReference { class WritersideAuthoredHtmlReferenceScanner { const WritersideAuthoredHtmlReferenceScanner(); + List<({String destination, SourceSpan span})> scanMarkdownMedia({ + required String filePath, + required String source, + required List<bool> protectedSource, + }) { + final candidates = <({String destination, SourceSpan span})>[]; + final tokenizer = HtmlTokenizer( + source, + generateSpans: true, + attributeSpans: true, + ); + while (tokenizer.moveNext()) { + final token = tokenizer.current; + if (token is! StartTagToken || + !{'img', 'video', 'source', 'audio'}.contains(token.name)) { + continue; + } + for (final attribute in token.attributeSpans ?? []) { + if (!{'src', 'preview-src', 'poster'}.contains(attribute.name)) { + continue; + } + final start = attribute.startValue, end = attribute.endValue; + if (start == null || + end == null || + start >= end || + _rangeIsProtected(protectedSource, start, end)) { + continue; + } + candidates.add(( + destination: attribute.value, + span: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: start, + endOffset: end, + ), + )); + } + } + var probed = source; + final probes = <String>{}; + for (var i = candidates.length - 1; i >= 0; i--) { + final probe = 'https://busymark-media-$i.invalid/asset.png'; + probes.add(probe); + probed = probed.replaceRange( + candidates[i].span.startOffset, + candidates[i].span.endOffset, + probe, + ); + } + final rendered = <String>{}; + void inline(BusyInline value) { + if (value.kind == BusyInlineKind.image && value.destination != null) { + rendered.add(value.destination!); + } + value.children.forEach(inline); + } + + void block(BusyBlock value) { + for (final name in ['src', 'preview-src', 'poster']) { + if (value.attributes[name] case final String destination) { + rendered.add(destination); + } + } + value.inlines.forEach(inline); + value.children.forEach(block); + } + + const MarkdownAstAdapter() + .parse( + filePath: filePath, + source: probed, + mode: MarkdownMode.commonMark, + ) + .blocks + .forEach(block); + return [ + for (var i = 0; i < candidates.length; i++) + if (rendered.contains('https://busymark-media-$i.invalid/asset.png')) + candidates[i], + ]; + } + List<WritersideAuthoredHtmlAnchorReference> scanMarkdownAnchors({ required String filePath, required String source, diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index 081600bd..6106a5f1 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -655,7 +655,8 @@ class WritersideModule { String reference, { WritersideTopic? fromTopic, }) { - final normalized = _normalizedTopicReference(reference); + final decoded = decodeWritersideReferenceComponent(reference); + final normalized = _normalizedTopicReference(decoded); if (normalized.isEmpty) { return fromTopic == null ? const [] : [fromTopic]; } @@ -699,6 +700,22 @@ String _normalizedTopicReference(String value) { return p.normalize(value.trim()).replaceAll(r'\', '/'); } +/// URI destinations may contain authored Unicode or unresolved variables. +String decodeWritersideReferenceComponent(String value) { + try { + return Uri.decodeComponent( + value.replaceAllMapped( + RegExp(r'[^\x00-\x7f]+'), + (match) => Uri.encodeComponent(match[0]!), + ), + ); + } on ArgumentError { + return value; + } on FormatException { + return value; + } +} + extension _FirstOrNull<T> on List<T> { T? get firstOrNull => isEmpty ? null : first; T? get singleOrNull => length == 1 ? single : null; diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index 6f614ca5..ff44bcd6 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -1494,7 +1494,7 @@ List<WritersideTopicTitleOverride> _topicTitleOverrides( return [ // Only authored top-level semantic titles are overrides. Their text is // already XML-decoded; fenced examples remain Markdown blocks. - for (final element in document.nodes.whereType<WritersideElementNode>()) + for (final element in document.topicTitleElements) if (element.name == 'title') if (_trimmedOrNull(element.attributes['instance']) case final instance?) WritersideTopicTitleOverride( diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index 4afc4a3f..2ae12c91 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -649,7 +649,10 @@ class WritersideProjectIndex { ); } final hash = value.indexOf('#'); - final normalized = hash < 0 ? value : value.substring(hash + 1); + var normalized = hash < 0 ? value : value.substring(hash + 1); + if (hash >= 0) { + normalized = decodeWritersideReferenceComponent(normalized); + } final module = modulesById[targetModule]; final fromTopic = module?.topics .where((topic) => topic.filePath == filePath) @@ -666,7 +669,25 @@ class WritersideProjectIndex { ? fromTopic : module?.topicByReference(targetPath, fromTopic: fromTopic) ?? module?.topicsById[targetPath]; - if (hash >= 0 && targetPath.isNotEmpty && module != null && topic == null) { + final matchingTopics = + targetPath.isEmpty || + (hash < 0 && + { + WritersideSymbolKind.element, + WritersideSymbolKind.snippet, + WritersideSymbolKind.seealso, + }.contains(kind)) + ? [if (fromTopic != null) fromTopic] + : module?.topicsMatchingReference(targetPath, fromTopic: fromTopic) ?? + const <WritersideTopic>[]; + final targetPaths = { + for (final candidate in matchingTopics) candidate.filePath, + if (topic != null) topic.filePath, + }; + if (hash >= 0 && + targetPath.isNotEmpty && + module != null && + targetPaths.isEmpty) { return const []; } final wantsElement = @@ -716,14 +737,14 @@ class WritersideProjectIndex { }.contains(symbol.kind) : kind == null || symbol.kind == kind) && (!wantsElement || - (topic == null + (targetPaths.isEmpty ? filePath == null || symbol.filePath == filePath - : symbol.filePath == topic.filePath)) && + : targetPaths.contains(symbol.filePath))) && (symbol.name == normalized || symbol.qualifiedName == reference || (!wantsElement && symbol.kind == WritersideSymbolKind.topic && - topic?.filePath == symbol.filePath) || + targetPaths.contains(symbol.filePath)) || (resourceMatch && p.basename(symbol.name) == p.basename(normalized))); }); diff --git a/lib/src/writerside/writerside_property_validation.dart b/lib/src/writerside/writerside_property_validation.dart new file mode 100644 index 00000000..2a3bd640 --- /dev/null +++ b/lib/src/writerside/writerside_property_validation.dart @@ -0,0 +1,93 @@ +import 'writerside_code_selection.dart'; +import 'writerside_schema.dart'; + +/// Scalar validation shares schema rules and the existing snippet selector. +/// An unavailable source prevents range verification, not legal authoring. +String? validateWritersideProperty( + String tag, + String attribute, + String? value, { + Map<String, String> attributes = const {}, + String? codeSource, + String? inlineCode, + bool hasTitleChild = false, + Iterable<String>? applicableAttributes, +}) { + final name = attribute == 'language' ? 'lang' : attribute; + if (!(applicableAttributes ?? WritersideSchema.attributesFor(tag)).contains( + name, + )) { + return 'unsupported attribute: $name'; + } + if (value == null || value.isEmpty && name != 'alt') { + return WritersideSchema.requiredAttributesFor(tag).contains(name) && + !(name == 'title' && hasTitleChild) + ? 'required attribute: $name' + : null; + } + final invalid = WritersideSchema.invalidAttributeValue(tag, name, value); + if (invalid != null) return '$name: $invalid'; + if (value.contains('%')) return null; + if (tag == 'show-structure' && + name == 'for' && + value != 'none' && + value + .split(',') + .any( + (v) => !{'chapter', 'procedure', 'tab', 'def'}.contains(v.trim()), + )) { + return 'for: expected chapter, procedure, tab, def, or none'; + } + if (tag == 'code-block' && + name == 'src' && + value != attributes['src'] && + inlineCode?.trim().isNotEmpty == true) { + return 'src: the authored code block is nonempty; preserve it or clear it explicitly in the editor before selecting a source file'; + } + if (name == 'id' && !RegExp(r'^[A-Za-z_][A-Za-z0-9_.-]*$').hasMatch(value)) { + return 'id: expected an identifier starting with a letter or underscore'; + } + if (tag == 'code-block' && + {'include-lines', 'emphasize-lines'}.contains(name)) { + for (final part in value.split(',')) { + final match = RegExp(r'^\s*(\d+)(?:\s*-\s*(\d+))?\s*$').firstMatch(part); + if (match == null || + int.parse(match[1]!) < 1 || + int.parse(match[2] ?? match[1]!) < int.parse(match[1]!)) { + return '$name: expected positive lines or ascending ranges'; + } + } + if (name == 'include-lines' && + attributes['include-symbol']?.isNotEmpty == true) { + return 'include-lines conflicts with include-symbol'; + } + if (codeSource != null) { + try { + final snippet = name == 'emphasize-lines' + ? const WritersideCodeSelection().select(codeSource, attributes) + : codeSource; + const WritersideCodeSelection().select(snippet, { + 'include-lines': value, + }); + } on FormatException catch (e) { + return '$name: ${e.message}'; + } + } + } + if (tag == 'code-block' && + name == 'include-symbol' && + attributes['include-lines']?.isNotEmpty == true) { + return 'include-symbol conflicts with include-lines'; + } + if (tag == 'code-block' && name == 'include-symbol' && codeSource != null) { + try { + const WritersideCodeSelection().select(codeSource, { + ...attributes, + name: value, + }); + } on FormatException catch (e) { + return '$name: ${e.message}'; + } + } + return null; +} diff --git a/lib/src/writerside/writerside_schema.dart b/lib/src/writerside/writerside_schema.dart index 462b2ce5..2a1a448d 100644 --- a/lib/src/writerside/writerside_schema.dart +++ b/lib/src/writerside/writerside_schema.dart @@ -739,17 +739,58 @@ class WritersideSchema { }); } - static Iterable<String> attributesFor(String element) => { + /// Authored input remains lossless even when a capability is too broad to + /// authorize completion or a new property. Do not use this for authoring. + static Iterable<String> preservedAttributesFor(String element) => { ...?writersideOfficialRules[element.toLowerCase()]?.attributes.keys, ...?elements[element.toLowerCase()]?.attributes, }; + /// The generated rules are authoritative for attribute applicability. The + /// overlays below have dedicated published-output checks for the pinned builder. + static Iterable<String> attributesFor(String element) { + final name = element.toLowerCase(); + final official = writersideOfficialRules[name]; + return { + if (official != null) + ...official.attributes.keys + else + ...?elements[name]?.attributes, + if (name == 'li') 'checked', + // The property-conformance HTML checks verify snippet emphasis, including + // its Markdown fenced-code form, against the same pinned builder. + if (name == 'code-block') 'emphasize-lines', + // Published by conformance_project/features.topic and its HTML snapshot. + if (name == 'table') 'sticky-header', + // Current table/td reference documents interactive sorting. The contract + // fixture verifies data-sortable on the table and its excluded column. + if (name == 'table' || name == 'td') 'sortable', + if (name == 'a') 'as', + }; + } + static Set<String> requiredAttributesFor(String element) => { ...?writersideOfficialRules[element]?.required, ...?elements[element]?.requiredAttributes, }; static Set<String> valuesFor(String element, String attribute) { + // lists.html documents checkbox lists; the list/capitalization fixture + // verifies their published checkboxes with builder 2026.08.0328. + if (element == 'list' && attribute == 'type') { + return { + ...?writersideOfficialRules[element]?.values[attribute], + 'checkbox', + }; + } + // The generated XSD omits li@checked. Builder 2026.08.0328 renders it as + // a checkbox state; the property-conformance fixture verifies this overlay. + if (element == 'li' && attribute == 'checked') { + return const {'true', 'false'}; + } + if (element == 'table' && attribute == 'sticky-header') { + return const {'true', 'false'}; + } if (attribute == 'sortable') return const {'true', 'false'}; if (attribute == 'generate-samples') { return const {'all', 'request', 'response', 'none'}; @@ -784,6 +825,10 @@ class WritersideSchema { {'colspan', 'rowspan'}.contains(attribute)) { final number = int.tryParse(value); if (number == null || + (element == 'list' && attribute == 'start' && number < 1) || + (element == 'list' && + attribute == 'columns' && + (number < 1 || number > 5)) || ((type == 'positiveInteger' || {'colspan', 'rowspan'}.contains(attribute)) && number < 1) || diff --git a/lib/src/writerside/writerside_source_loader.dart b/lib/src/writerside/writerside_source_loader.dart index 07360964..c6df59fb 100644 --- a/lib/src/writerside/writerside_source_loader.dart +++ b/lib/src/writerside/writerside_source_loader.dart @@ -36,6 +36,49 @@ class WritersideSourceLoader { static String key(String topicPath, String reference) => '$topicPath\u0000$reference'; + /// Convert a chooser result to an authored reference using the same guarded + /// lookup as rendering. A snippet name is preferred only if it resolves to + /// this file (a topic-relative file may shadow it). + Future<String?> referenceForFile({ + required String selectedPath, + required String documentPath, + required String workspaceRoot, + String? snippetsDirectory, + }) async { + try { + final anchor = await captureCanonicalDirectoryAnchor(workspaceRoot); + final selected = await resolveAnchoredPath( + anchor, + selectedPath, + allowRoot: false, + ); + if (selected.type != FileSystemEntityType.file) return null; + final snippets = snippetsDirectory == null + ? null + : p.normalize(p.join(workspaceRoot, snippetsDirectory)); + final candidates = <String>{ + if (snippets != null && p.isWithin(snippets, selectedPath)) + p.relative(selectedPath, from: snippets), + p.relative(selectedPath, from: p.dirname(documentPath)), + }; + for (final reference in candidates) { + final loaded = await load( + reference: reference, + documentPath: documentPath, + workspaceRoot: workspaceRoot, + directories: [if (snippetsDirectory != null) snippetsDirectory], + readText: false, + ); + if (loaded.path == selected.path) return reference; + } + } on FileSystemException { + return null; + } on AnchoredPathViolation { + return null; + } + return null; + } + Future<WritersideSourceFile> load({ required String reference, required String documentPath, diff --git a/lib/src/writerside/writerside_summary_exporter.dart b/lib/src/writerside/writerside_summary_exporter.dart deleted file mode 100644 index 40a6a28f..00000000 --- a/lib/src/writerside/writerside_summary_exporter.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:convert'; - -import 'writerside_model.dart'; - -class WritersideSummaryExporter { - const WritersideSummaryExporter(); - - String export(WritersideModule module) { - return const JsonEncoder.withIndent(' ').convert(toJson(module)); - } - - Map<String, Object?> toJson(WritersideModule module) { - final config = module.config; - return { - 'rootPath': module.rootPath, - 'config': { - 'filePath': config.filePath, - 'fileName': config.configFileName, - 'version': config.version, - 'moduleName': config.moduleName, - 'topicRoots': [ - for (final root in config.topicRoots) - {'dir': root.dir, 'explicit': root.explicit}, - ], - 'imageRoots': [ - for (final root in config.imageRoots) - { - 'dir': root.dir, - 'version': root.version, - 'webPath': root.webPath, - 'explicit': root.explicit, - }, - ], - 'buildConfigDir': config.buildConfigDir, - 'buildConfigExplicit': config.buildConfigExplicit, - 'apiSpecificationsDir': config.apiSpecificationsDir, - 'apiSpecificationsExplicit': config.apiSpecificationsExplicit, - 'snippetsDir': config.snippetsDir, - 'resourcesFile': config.resourcesFile, - 'resourcesDir': config.resourcesDir, - 'varsFile': config.varsFile, - 'categoriesFile': config.categoriesFile, - 'instanceGroupsFile': config.instanceGroupsFile, - 'instances': [ - for (final instance in config.instances) - { - 'src': instance.src, - 'webPath': instance.webPath, - 'version': instance.version, - 'keymapsMode': instance.keymapsMode, - }, - ], - 'settings': { - 'smartIgnoreVars': config.settings.smartIgnoreVars, - 'disableWebNamePreprocessing': - config.settings.disableWebNamePreprocessing, - 'wrsSupernovaUseVersion': config.settings.wrsSupernovaUseVersion, - 'capsRules': [ - for (final rule in config.settings.capsRules) - {'style': rule.style, 'target': rule.target}, - ], - 'defaultProperties': [ - for (final property in config.settings.defaultProperties) - { - 'elementName': property.elementName, - 'propertyName': property.propertyName, - 'value': property.value, - }, - ], - }, - }, - 'instances': [ - for (final instance in module.instances) - { - 'id': instance.id, - 'name': instance.name, - 'sourceTreePath': instance.sourceTreePath, - 'startPage': instance.startPage, - 'status': instance.status, - 'isLibrary': instance.isLibrary, - 'version': instance.version, - 'effectiveVersion': instance.effectiveVersion, - 'webPath': instance.webPath, - 'keymapsMode': instance.keymapsMode, - 'allowSearchEngineIndexing': instance.allowSearchEngineIndexing, - 'offlineArtifact': instance.offlineArtifact, - 'topics': instance.topicFileSet.toList()..sort(), - }, - ], - 'topics': [ - for (final topic in module.topics) - { - 'id': topic.id, - 'fileName': topic.fileName, - 'baseName': topic.baseName, - 'filePath': topic.filePath, - 'topicRoot': topic.topicRoot, - 'format': topic.format.name, - 'title': topic.title, - 'webFileName': topic.webFileName, - 'titleOverrides': [ - for (final override in topic.titleOverrides) - {'instance': override.instance, 'title': override.title}, - ], - }, - ], - 'diagnostics': [ - for (final diagnostic in module.diagnostics) - { - 'code': diagnostic.code, - 'severity': diagnostic.severity.name, - if (diagnostic.args.isNotEmpty) 'args': diagnostic.args, - 'filePath': diagnostic.filePath, - }, - ], - }; - } -} diff --git a/lib/src/writerside/writerside_title_editor.dart b/lib/src/writerside/writerside_title_editor.dart index b95d9f3a..f583ef0e 100644 --- a/lib/src/writerside/writerside_title_editor.dart +++ b/lib/src/writerside/writerside_title_editor.dart @@ -118,11 +118,7 @@ class WritersideTitleEditor { } } if (edit.instanceTitle != null) { - final scope = topic.format == WritersideTopicFormat.xml - ? document.rootElement!.children - : document.nodes; - final overrides = scope - .whereType<WritersideElementNode>() + final overrides = document.topicTitleElements .where( (node) => node.name == 'title' && @@ -133,11 +129,22 @@ class WritersideTitleEditor { throw const BusyMarkException('writerside.toc.path-invalid'); } final value = edit.instanceTitle!; + final existing = overrides.firstOrNull; + final rootName = document.rootElement?.qualifiedName ?? ''; + final prefix = + topic.format == WritersideTopicFormat.xml && rootName.contains(':') + ? '${rootName.split(':').first}:' + : ''; final replacement = value.trim().isEmpty ? '' : XmlElement( - XmlName.parts('title'), - [XmlAttribute(XmlName.parts('instance'), instanceId)], + XmlName.parse(existing?.qualifiedName ?? '${prefix}title'), + existing == null + ? [XmlAttribute(XmlName.parts('instance'), instanceId)] + : [ + for (final a in existing.qualifiedAttributes) + XmlAttribute(XmlName.parse(a.qualifiedName), a.value), + ], [XmlText(value)], ).toXmlString(); if (replacement.isNotEmpty) _validateXml(replacement); @@ -229,7 +236,7 @@ class WritersideTitleEditor { ); } } else if (value.trim().isNotEmpty) { - final offset = span.startOffset + '<toc-element'.length; + final offset = span.startOffset + 1 + root.qualifiedName.length; resultTree = treeSource.replaceRange( offset, offset, diff --git a/lib/src/writerside/writerside_toc_editor.dart b/lib/src/writerside/writerside_toc_editor.dart index 406edb4a..218e3282 100644 --- a/lib/src/writerside/writerside_toc_editor.dart +++ b/lib/src/writerside/writerside_toc_editor.dart @@ -228,8 +228,8 @@ class WritersideTocEditor { final parent = _elementAtPath(session.root, nodePath, role: 'source'); if (!identity.matches(parent)) throw _invalidPath(nodePath, role: 'source'); final children = parent.childElements.toList(); - // The original build deletes non-TOC sub-tags when sorting. The handoff - // requires preserving embedded structures, so reject this case explicitly. + // Reordering must not discard non-TOC child structures. Reject parents + // containing them before replacing the TOC child slots. if (children.any((element) => element.name.local != 'toc-element') || permutation.length != children.length || permutation.toSet().length != children.length || diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 0eeeef62..070476dd 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -112,6 +112,12 @@ set(VISUALIZATION_WEB_VERSION "${VISUALIZATION_WEB_DIR}/VERSION") set(VISUALIZATION_WEB_HARNESS "${VISUALIZATION_WEB_DIR}/harness.html") set(VISUALIZATION_WEB_BOOTSTRAP "${VISUALIZATION_WEB_DIR}/bootstrap.js") set(VISUALIZATION_WEB_ENGINES "${VISUALIZATION_WEB_DIR}/render-engines.js") +set(VISUALIZATION_WEB_OPERATION_ENGINES + "${VISUALIZATION_WEB_DIR}/mermaid-engine.js" + "${VISUALIZATION_WEB_DIR}/plantuml-engine.js" + "${VISUALIZATION_WEB_DIR}/openapi-engine.js" + "${VISUALIZATION_WEB_DIR}/math-engine.js" +) set(VISUALIZATION_WEB_SCALAR "${VISUALIZATION_WEB_DIR}/scalar.js") set(VISUALIZATION_WEB_VIZ "${VISUALIZATION_WEB_DIR}/viz-global.js") add_custom_command( @@ -120,6 +126,7 @@ add_custom_command( "${VISUALIZATION_WEB_HARNESS}" "${VISUALIZATION_WEB_BOOTSTRAP}" "${VISUALIZATION_WEB_ENGINES}" + ${VISUALIZATION_WEB_OPERATION_ENGINES} "${VISUALIZATION_WEB_SCALAR}" "${VISUALIZATION_WEB_VIZ}" COMMAND @@ -130,6 +137,9 @@ add_custom_command( "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/package.json" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/package-lock.json" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/render_engines.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/mermaid_renderer.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/plantuml_renderer.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/openapi_renderer.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/mathjax_renderer.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/mermaid_math_disabled.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/build_render_engines.js" @@ -147,6 +157,7 @@ add_custom_target(busymark_visualization_web ALL "${VISUALIZATION_WEB_HARNESS}" "${VISUALIZATION_WEB_BOOTSTRAP}" "${VISUALIZATION_WEB_ENGINES}" + ${VISUALIZATION_WEB_OPERATION_ENGINES} "${VISUALIZATION_WEB_SCALAR}" "${VISUALIZATION_WEB_VIZ}" ) diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index c3062fa2..9166eb5c 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -88,6 +88,7 @@ <url type="bugtracker">https://github.com/busystack/busymark/issues</url> <content_rating type="oars-1.1" /> <releases> + <release version="0.6.2" date="2026-10-08" /> <release version="0.6.1" date="2026-10-07" /> <release version="0.6.0" date="2026-10-05" /> <release version="0.5.1" date="2026-09-22" /> diff --git a/linux/runner/web_render_host.cc b/linux/runner/web_render_host.cc index e1e6fa58..5ac672cb 100644 --- a/linux/runner/web_render_host.cc +++ b/linux/runner/web_render_host.cc @@ -6,6 +6,7 @@ #include <webkit2/webkit2.h> #include <cstring> +#include <initializer_list> namespace { @@ -16,6 +17,30 @@ constexpr char kReferenceUri[] = "busymark-render://app/reference.html"; constexpr gsize kMaximumRequestBytes = 20 * 1024 * 1024; constexpr gsize kMaximumPngBytes = 64 * 1024 * 1024; +// All transitions and GTK/WebKit construction run on the GTK main context. +// Scheduled is cancellable before any WebKit resources have been constructed. +enum class RenderState { dormant, scheduled, loading, ready, failed, stopped }; + +class InitializationProbe { + public: + explicit InitializationProbe(const char* operation) + : operation_(operation), + enabled_(g_strcmp0(g_getenv("BUSYMARK_VISUALIZATION_TRACE"), "1") == 0), + start_(enabled_ ? g_get_monotonic_time() : 0) {} + ~InitializationProbe() { + if (enabled_) { + g_printerr("visualization-init %s start_us=%" G_GINT64_FORMAT + " duration_us=%" G_GINT64_FORMAT "\n", + operation_, start_, g_get_monotonic_time() - start_); + } + } + + private: + const char* operation_; + bool enabled_; + gint64 start_; +}; + struct PendingRequest { BusyMarkWebRenderHost* host; FlMethodCall* method_call; @@ -29,6 +54,14 @@ struct PendingRequest { gboolean responded; }; +void trace_request(PendingRequest* request, const char* event) { + if (g_strcmp0(g_getenv("BUSYMARK_VISUALIZATION_TRACE"), "1") == 0) { + g_printerr("visualization-request %s %s %s time_us=%" G_GINT64_FORMAT "\n", + request->operation, request->request_id, event, + g_get_monotonic_time()); + } +} + struct ReferenceLoadData { gchar* arguments_json; gboolean started; @@ -51,11 +84,13 @@ struct _BusyMarkWebRenderHost { GtkWidget* offscreen_window; WebKitWebView* web_view; GCancellable* active_cancellable; + GCancellable* initialization_cancellable; GQueue* queue; PendingRequest* active; guint recreate_source_id; gchar* resource_root; - gboolean ready; + GList* reference_windows; + RenderState state; gboolean recreate_requested; gboolean shutting_down; }; @@ -69,6 +104,8 @@ namespace { void pump_requests(BusyMarkWebRenderHost* self); void recreate_render_view(BusyMarkWebRenderHost* self); void schedule_render_view_recreation(BusyMarkWebRenderHost* self); +gboolean ensure_context(BusyMarkWebRenderHost* self); +void fail_initialization(BusyMarkWebRenderHost* self, const gchar* message); void respond_error(FlMethodCall* method_call, const gchar* code, @@ -96,6 +133,7 @@ void respond_pending_error(PendingRequest* request, const gchar* code, const gchar* message) { if (!request->responded) { + trace_request(request, code); request->responded = TRUE; respond_error(request->method_call, code, message); } @@ -151,6 +189,10 @@ gboolean is_allowed_resource_name(const gchar* path) { g_strcmp0(name, "reference.html") == 0 || g_strcmp0(name, "bootstrap.js") == 0 || g_strcmp0(name, "render-engines.js") == 0 || + g_strcmp0(name, "mermaid-engine.js") == 0 || + g_strcmp0(name, "plantuml-engine.js") == 0 || + g_strcmp0(name, "openapi-engine.js") == 0 || + g_strcmp0(name, "math-engine.js") == 0 || g_strcmp0(name, "reference.js") == 0 || g_strcmp0(name, "scalar.js") == 0 || g_strcmp0(name, "viz-global.js") == 0; @@ -158,9 +200,9 @@ gboolean is_allowed_resource_name(const gchar* path) { void uri_scheme_request_cb(WebKitURISchemeRequest* request, gpointer user_data) { - auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); + const auto* resource_root = static_cast<const gchar*>(user_data); const gchar* path = webkit_uri_scheme_request_get_path(request); - if (self->resource_root == nullptr || !is_allowed_resource_name(path)) { + if (resource_root == nullptr || !is_allowed_resource_name(path)) { g_autoptr(GError) error = g_error_new_literal( G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED, "Visualization resource is not available."); @@ -168,8 +210,7 @@ void uri_scheme_request_cb(WebKitURISchemeRequest* request, return; } const gchar* name = path[0] == '/' ? path + 1 : path; - g_autofree gchar* filename = - g_build_filename(self->resource_root, name, nullptr); + g_autofree gchar* filename = g_build_filename(resource_root, name, nullptr); gchar* contents = nullptr; gsize length = 0; g_autoptr(GError) error = nullptr; @@ -302,6 +343,9 @@ void complete_active(BusyMarkWebRenderHost* self) { } void respond_json(PendingRequest* request, const gchar* json) { + if (request->responded) { + return; + } g_autoptr(FlJsonMessageCodec) codec = fl_json_message_codec_new(); g_autoptr(GError) error = nullptr; g_autoptr(FlValue) value = @@ -312,7 +356,21 @@ void respond_json(PendingRequest* request, const gchar* json) { : "WebKit returned invalid JSON."); return; } + FlValue* engine_failed = + fl_value_get_type(value) == FL_VALUE_TYPE_MAP + ? fl_value_lookup_string(value, "engineLoadFailed") + : nullptr; + if (engine_failed != nullptr && + fl_value_get_type(engine_failed) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(engine_failed)) { + request->host->recreate_requested = TRUE; + respond_pending_error( + request, "visualization.hostUnavailable", + "The bundled visualization engine could not be loaded."); + return; + } request->responded = TRUE; + trace_request(request, "completed"); fl_method_call_respond_success(request->method_call, value, nullptr); } @@ -335,6 +393,13 @@ void snapshot_finished_cb(GObject* object, g_autoptr(GError) error = nullptr; cairo_surface_t* surface = webkit_web_view_get_snapshot_finish( WEBKIT_WEB_VIEW(object), result, &error); + if (request->responded || self->shutting_down) { + if (surface != nullptr) { + cairo_surface_destroy(surface); + } + complete_active(self); + return; + } if (surface == nullptr) { respond_pending_error(request, "visualization.rasterFailed", error != nullptr ? error->message @@ -356,6 +421,7 @@ void snapshot_finished_cb(GObject* object, g_autoptr(GBytes) owned_bytes = g_byte_array_free_to_bytes(bytes); g_autoptr(FlValue) value = fl_value_new_uint8_list_from_bytes(owned_bytes); request->responded = TRUE; + trace_request(request, "completed"); fl_method_call_respond_success(request->method_call, value, nullptr); gtk_widget_set_size_request(GTK_WIDGET(self->web_view), 1, 1); gtk_widget_set_size_request(self->offscreen_window, 1, 1); @@ -367,7 +433,7 @@ void snapshot_finished_cb(GObject* object, gboolean begin_snapshot_cb(gpointer user_data) { auto* request = static_cast<PendingRequest*>(user_data); BusyMarkWebRenderHost* self = request->host; - if (self->shutting_down || self->web_view == nullptr) { + if (request->responded || self->shutting_down || self->web_view == nullptr) { request->snapshot_wait_source_id = 0; respond_pending_error(request, "visualization.hostUnavailable", "The WebKit host is shutting down."); @@ -453,8 +519,14 @@ void javascript_finished_cb(GObject* object, auto* request = static_cast<PendingRequest*>(user_data); BusyMarkWebRenderHost* self = request->host; g_autoptr(GError) error = nullptr; + trace_request(request, "javascript-delivered"); JSCValue* value = webkit_web_view_call_async_javascript_function_finish( WEBKIT_WEB_VIEW(object), result, &error); + if (request->responded || self->shutting_down) { + g_clear_object(&value); + complete_active(self); + return; + } if (value == nullptr) { respond_pending_error(request, "visualization.webRenderFailed", error != nullptr ? error->message @@ -481,6 +553,7 @@ void javascript_finished_cb(GObject* object, } void start_request(BusyMarkWebRenderHost* self, PendingRequest* request) { + trace_request(request, "started"); self->active = request; request->host = BUSYMARK_WEB_RENDER_HOST(g_object_ref(self)); self->active_cancellable = g_cancellable_new(); @@ -498,44 +571,127 @@ void start_request(BusyMarkWebRenderHost* self, PendingRequest* request) { "}" "const request = JSON.parse(requestJson);" "request.operation = operation;" - "return JSON.stringify(await window.busymarkRender(request));"; + "try { return JSON.stringify(await window.busymarkRender(request)); }" + "catch (error) {" + "if (error?.busymarkEngineLoadFailed !== true) throw error;" + "return JSON.stringify({engineLoadFailed: true});" + "}"; webkit_web_view_call_async_javascript_function( self->web_view, kBody, -1, arguments, nullptr, kHarnessUri, self->active_cancellable, javascript_finished_cb, request); } void pump_requests(BusyMarkWebRenderHost* self) { - if (self->shutting_down || !self->ready || self->active != nullptr || - self->web_view == nullptr || g_queue_is_empty(self->queue)) { + if (self->shutting_down || self->active != nullptr || + g_queue_is_empty(self->queue)) { + return; + } + if (self->state == RenderState::dormant || + self->state == RenderState::failed) { + self->recreate_requested = TRUE; + schedule_render_view_recreation(self); + } + if (self->state != RenderState::ready || self->web_view == nullptr) { return; } start_request( self, static_cast<PendingRequest*>(g_queue_pop_head(self->queue))); } -void render_load_changed_cb(WebKitWebView*, - WebKitLoadEvent event, - gpointer user_data) { +void harness_ready_cb(GObject* object, GAsyncResult* result, + gpointer user_data) { auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); - if (event == WEBKIT_LOAD_FINISHED) { - self->ready = TRUE; - if (self->release_smoke_recovery_call != nullptr) { - g_autoptr(FlValue) result = fl_value_new_null(); - fl_method_call_respond_success(self->release_smoke_recovery_call, result, - nullptr); - g_clear_object(&self->release_smoke_recovery_call); + g_autoptr(GError) error = nullptr; + g_autoptr(JSCValue) value = + webkit_web_view_call_async_javascript_function_finish( + WEBKIT_WEB_VIEW(object), result, &error); + if (!self->shutting_down && WEBKIT_WEB_VIEW(object) == self->web_view && + self->state == RenderState::loading) { + g_clear_object(&self->initialization_cancellable); + if (value == nullptr || !jsc_value_to_boolean(value)) { + fail_initialization( + self, error != nullptr + ? error->message + : "The visualization harness did not initialize."); + } else { + self->state = RenderState::ready; + if (self->release_smoke_recovery_call != nullptr) { + g_autoptr(FlValue) response = fl_value_new_null(); + fl_method_call_respond_success(self->release_smoke_recovery_call, + response, nullptr); + g_clear_object(&self->release_smoke_recovery_call); + } + pump_requests(self); } - pump_requests(self); + } + g_object_unref(self); +} + +void render_load_changed_cb(WebKitWebView* web_view, WebKitLoadEvent event, + gpointer user_data) { + auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); + if (!self->shutting_down && web_view == self->web_view && + self->state == RenderState::loading && event == WEBKIT_LOAD_FINISHED && + self->initialization_cancellable == nullptr) { + self->initialization_cancellable = g_cancellable_new(); + // Load-finished can precede the module's ready event. Keep the existing + // harness deadline; Dart request deadlines also include this wait. + constexpr char kBody[] = + "if (typeof window.busymarkRender !== 'function') {" + "await new Promise((resolve, reject) => {" + "const timer = window.setTimeout(() => reject(new Error(" + "'The visualization harness did not initialize.')), 15000);" + "window.addEventListener('busymark-render-ready', () => {" + "window.clearTimeout(timer); resolve();" + "}, { once: true });" + "});" + "}" + "return true;"; + webkit_web_view_call_async_javascript_function( + web_view, kBody, -1, nullptr, nullptr, kHarnessUri, + self->initialization_cancellable, harness_ready_cb, g_object_ref(self)); } } +void fail_initialization(BusyMarkWebRenderHost* self, const gchar* message) { + self->state = RenderState::failed; + while (!g_queue_is_empty(self->queue)) { + auto* request = static_cast<PendingRequest*>(g_queue_pop_head(self->queue)); + respond_pending_error(request, "visualization.hostUnavailable", message); + pending_request_free(request); + } + if (self->release_smoke_recovery_call != nullptr) { + respond_error(self->release_smoke_recovery_call, + "visualization.hostUnavailable", message); + g_clear_object(&self->release_smoke_recovery_call); + } + // Tear down outside WebKit's load signal. A later legitimate request retries. + self->recreate_requested = FALSE; + schedule_render_view_recreation(self); +} + +gboolean render_load_failed_cb(WebKitWebView* web_view, WebKitLoadEvent, + const gchar*, GError* error, + gpointer user_data) { + auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); + if (!self->shutting_down && web_view == self->web_view) { + fail_initialization(self, error->message); + } + return TRUE; +} + void render_process_terminated_cb(WebKitWebView*, WebKitWebProcessTerminationReason, gpointer user_data) { auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); - self->ready = FALSE; + if (self->shutting_down) { + return; + } + self->state = RenderState::scheduled; self->recreate_requested = TRUE; if (self->active_cancellable != nullptr) { + respond_pending_error(self->active, "visualization.webRenderFailed", + "The WebKit renderer process terminated."); g_cancellable_cancel(self->active_cancellable); } else if (!self->shutting_down) { schedule_render_view_recreation(self); @@ -543,7 +699,13 @@ void render_process_terminated_cb(WebKitWebView*, } void destroy_render_view(BusyMarkWebRenderHost* self) { - self->ready = FALSE; + if (self->initialization_cancellable != nullptr) { + g_cancellable_cancel(self->initialization_cancellable); + g_clear_object(&self->initialization_cancellable); + } + if (self->web_view != nullptr) { + g_signal_handlers_disconnect_by_data(self->web_view, self); + } self->web_view = nullptr; if (self->offscreen_window != nullptr) { gtk_widget_destroy(self->offscreen_window); @@ -553,13 +715,22 @@ void destroy_render_view(BusyMarkWebRenderHost* self) { void recreate_render_view(BusyMarkWebRenderHost* self) { destroy_render_view(self); - if (self->shutting_down || self->resource_root == nullptr) { + if (self->shutting_down) { + return; + } + if (!ensure_context(self)) { + fail_initialization(self, + "The visualization context could not be created."); return; } + self->state = RenderState::loading; self->offscreen_window = gtk_offscreen_window_new(); gtk_window_set_default_size(GTK_WINDOW(self->offscreen_window), 1, 1); - self->web_view = WEBKIT_WEB_VIEW( - webkit_web_view_new_with_context(self->context)); + { + InitializationProbe probe("view"); + self->web_view = + WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(self->context)); + } configure_web_view(self->web_view); // GtkOffscreenWindow cannot provide a GL context for compositing. Set this // before realizing/loading the view, including after WebKit process recovery. @@ -570,13 +741,21 @@ void recreate_render_view(BusyMarkWebRenderHost* self) { WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); g_signal_connect(self->web_view, "load-changed", G_CALLBACK(render_load_changed_cb), self); + g_signal_connect(self->web_view, "load-failed", + G_CALLBACK(render_load_failed_cb), self); g_signal_connect(self->web_view, "web-process-terminated", G_CALLBACK(render_process_terminated_cb), self); gtk_container_add(GTK_CONTAINER(self->offscreen_window), GTK_WIDGET(self->web_view)); gtk_widget_set_size_request(GTK_WIDGET(self->web_view), 1, 1); - gtk_widget_show_all(self->offscreen_window); - webkit_web_view_load_uri(self->web_view, kHarnessUri); + { + InitializationProbe probe("offscreen-show"); + gtk_widget_show_all(self->offscreen_window); + } + { + InitializationProbe probe("harness-dispatch"); + webkit_web_view_load_uri(self->web_view, kHarnessUri); + } } gboolean recreate_render_view_cb(gpointer user_data) { @@ -584,7 +763,19 @@ gboolean recreate_render_view_cb(gpointer user_data) { self->recreate_source_id = 0; if (!self->shutting_down && self->recreate_requested) { self->recreate_requested = FALSE; - recreate_render_view(self); + if (!g_queue_is_empty(self->queue) || + self->release_smoke_recovery_call != nullptr || + self->web_view != nullptr) { + recreate_render_view(self); + } else { + self->state = RenderState::dormant; + } + } else if (!self->shutting_down && self->state == RenderState::failed) { + destroy_render_view(self); + // URI callbacks own their resource path independently of the host. + if (self->reference_windows == nullptr) { + g_clear_object(&self->context); + } } return G_SOURCE_REMOVE; } @@ -593,6 +784,9 @@ void schedule_render_view_recreation(BusyMarkWebRenderHost* self) { if (self->shutting_down || self->recreate_source_id != 0) { return; } + if (self->state != RenderState::failed) { + self->state = RenderState::scheduled; + } self->recreate_source_id = g_idle_add_full( G_PRIORITY_DEFAULT_IDLE, recreate_render_view_cb, g_object_ref(self), g_object_unref); @@ -661,6 +855,24 @@ void reference_process_terminated_cb(WebKitWebView* web_view, webkit_web_view_reload(web_view); } +void reference_window_destroyed_cb(GtkWidget* window, gpointer user_data) { + auto* self = BUSYMARK_WEB_RENDER_HOST(user_data); + self->reference_windows = g_list_remove(self->reference_windows, window); + auto* child = static_cast<GtkWidget*>( + g_object_get_data(G_OBJECT(window), "busymark-reference-view")); + if (child != nullptr) { + // An asynchronous JavaScript call can retain the view beyond its window. + // Disconnect both callbacks before freeing their shared load data. + g_signal_handlers_disconnect_matched( + child, G_SIGNAL_MATCH_FUNC, 0, 0, nullptr, + reinterpret_cast<gpointer>(reference_process_terminated_cb), nullptr); + g_signal_handlers_disconnect_matched( + child, G_SIGNAL_MATCH_FUNC, 0, 0, nullptr, + reinterpret_cast<gpointer>(reference_load_changed_cb), nullptr); + g_object_set_data(G_OBJECT(window), "busymark-reference-view", nullptr); + } +} + void open_reference_window(BusyMarkWebRenderHost* self, FlMethodCall* method_call, FlValue* args, @@ -670,7 +882,15 @@ void open_reference_window(BusyMarkWebRenderHost* self, "The bundled visualization resources could not be found."); return; } + if (!ensure_context(self)) { + respond_error(method_call, "visualization.hostUnavailable", + "The visualization context could not be created."); + return; + } GtkWidget* window = gtk_application_window_new(self->application); + self->reference_windows = g_list_prepend(self->reference_windows, window); + g_signal_connect(window, "destroy", G_CALLBACK(reference_window_destroyed_cb), + self); gtk_window_set_default_size(GTK_WINDOW(window), 1100, 760); if (self->parent_window != nullptr) { gtk_window_set_transient_for(GTK_WINDOW(window), self->parent_window); @@ -700,6 +920,10 @@ void open_reference_window(BusyMarkWebRenderHost* self, G_CALLBACK(reference_load_changed_cb), load_data, reference_load_data_free, static_cast<GConnectFlags>(0)); + // Keep the view available to the window's destroy handler even if GTK has + // already removed its child, or an async callback keeps the view alive. + g_object_set_data_full(G_OBJECT(window), "busymark-reference-view", + g_object_ref(web_view), g_object_unref); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(web_view)); gtk_widget_show_all(window); webkit_web_view_load_uri(web_view, kReferenceUri); @@ -726,6 +950,33 @@ gboolean is_render_operation(const gchar* method) { g_strcmp0(method, "rasterizeSvg") == 0; } +gboolean has_argument(FlValue* args, const char* name, FlValueType type) { + FlValue* value = fl_value_lookup_string(args, name); + return value != nullptr && fl_value_get_type(value) == type; +} + +gboolean valid_operation_arguments(const gchar* method, FlValue* args) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + if (g_strcmp0(method, "renderMathBatch") == 0) { + return has_argument(args, "expressions", FL_VALUE_TYPE_LIST); + } + if (g_strcmp0(method, "rasterizeSvg") == 0) { + if (!has_argument(args, "svg", FL_VALUE_TYPE_STRING)) { + return FALSE; + } + for (const auto* name : {"width", "height", "scale"}) { + if (!has_argument(args, name, FL_VALUE_TYPE_INT) && + !has_argument(args, name, FL_VALUE_TYPE_FLOAT)) { + return FALSE; + } + } + return TRUE; + } + return has_argument(args, "source", FL_VALUE_TYPE_STRING); +} + void cancel_render_request(BusyMarkWebRenderHost* self, FlMethodCall* method_call, FlValue* args) { @@ -744,6 +995,8 @@ void cancel_render_request(BusyMarkWebRenderHost* self, if (self->active != nullptr && g_strcmp0(self->active->request_id, request_id) == 0) { cancelled = TRUE; + respond_pending_error(self->active, "visualization.cancelled", + "The visualization render was cancelled."); if (self->active_cancellable != nullptr) { g_cancellable_cancel(self->active_cancellable); } @@ -815,7 +1068,7 @@ gboolean terminate_web_process_for_release_smoke_cb(gpointer user_data) { self->release_smoke_recovery_call == nullptr) { return G_SOURCE_REMOVE; } - self->ready = FALSE; + self->state = RenderState::scheduled; webkit_web_view_terminate_web_process(self->web_view); return G_SOURCE_REMOVE; } @@ -828,8 +1081,8 @@ void terminate_web_process_for_release_smoke(BusyMarkWebRenderHost* self, "The release visualization smoke hook is disabled."); return; } - if (!self->ready || self->web_view == nullptr || self->active != nullptr || - !g_queue_is_empty(self->queue) || + if (self->state != RenderState::ready || self->web_view == nullptr || + self->active != nullptr || !g_queue_is_empty(self->queue) || self->release_smoke_recovery_call != nullptr) { respond_error(method_call, "visualization.hostBusy", "The WebKit host is not idle for its recovery check."); @@ -871,6 +1124,12 @@ void method_call_cb(FlMethodChannel*, fl_method_call_respond_not_implemented(method_call, nullptr); return; } + if (!valid_operation_arguments(method, + fl_method_call_get_args(method_call))) { + respond_error(method_call, "visualization.invalidArguments", + "Visualization arguments are invalid."); + return; + } g_autoptr(GError) error = nullptr; g_autofree gchar* arguments_json = encode_arguments(fl_method_call_get_args(method_call), &error); @@ -911,6 +1170,7 @@ void method_call_cb(FlMethodChannel*, ? g_strdup(fl_value_get_string(request_id_value)) : g_uuid_string_random(); request->arguments_json = g_strdup(arguments_json); + trace_request(request, "accepted"); g_queue_push_tail(self->queue, request); pump_requests(self); } @@ -923,6 +1183,7 @@ void busymark_web_render_host_shutdown(BusyMarkWebRenderHost* self) { return; } self->shutting_down = TRUE; + self->state = RenderState::stopped; if (self->release_smoke_terminate_source_id != 0) { g_source_remove(self->release_smoke_terminate_source_id); self->release_smoke_terminate_source_id = 0; @@ -938,6 +1199,8 @@ void busymark_web_render_host_shutdown(BusyMarkWebRenderHost* self) { g_clear_object(&self->release_smoke_recovery_call); } if (self->active_cancellable != nullptr) { + respond_pending_error(self->active, "visualization.hostUnavailable", + "The WebKit host is shutting down."); g_cancellable_cancel(self->active_cancellable); } while (!g_queue_is_empty(self->queue)) { @@ -948,6 +1211,10 @@ void busymark_web_render_host_shutdown(BusyMarkWebRenderHost* self) { pending_request_free(request); } destroy_render_view(self); + while (self->reference_windows != nullptr) { + gtk_widget_destroy(GTK_WIDGET(self->reference_windows->data)); + } + g_clear_object(&self->context); } void busymark_web_render_host_register_channel(BusyMarkWebRenderHost* self, @@ -970,7 +1237,25 @@ BusyMarkWebRenderHost* busymark_web_render_host_new( self->application = application; self->parent_window = parent_window; self->resource_root = locate_resource_root(); - self->context = webkit_web_context_new_ephemeral(); + return self; +} + +namespace { + +gboolean ensure_context(BusyMarkWebRenderHost* self) { + if (self->shutting_down || self->resource_root == nullptr) { + return FALSE; + } + if (self->context != nullptr) { + return TRUE; + } + { + InitializationProbe probe("context"); + self->context = webkit_web_context_new_ephemeral(); + } + if (self->context == nullptr) { + return FALSE; + } webkit_web_context_set_cache_model(self->context, WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER); webkit_web_context_set_spell_checking_enabled(self->context, FALSE); @@ -978,8 +1263,8 @@ BusyMarkWebRenderHost* busymark_web_render_host_new( // boundary inside both strict Snap and conventional Linux packages. webkit_web_context_set_sandbox_enabled(self->context, TRUE); webkit_web_context_register_uri_scheme(self->context, kScheme, - uri_scheme_request_cb, self, - nullptr); + uri_scheme_request_cb, + g_strdup(self->resource_root), g_free); WebKitSecurityManager* security_manager = webkit_web_context_get_security_manager(self->context); webkit_security_manager_register_uri_scheme_as_secure(security_manager, @@ -990,12 +1275,19 @@ BusyMarkWebRenderHost* busymark_web_render_host_new( webkit_web_context_get_cookie_manager(self->context); webkit_cookie_manager_set_accept_policy( cookie_manager, WEBKIT_COOKIE_POLICY_ACCEPT_NEVER); - recreate_render_view(self); - return self; + return TRUE; } +} // namespace + static void busymark_web_render_host_dispose(GObject* object) { auto* self = BUSYMARK_WEB_RENDER_HOST(object); + // Until disposal, late calls still receive the existing hostUnavailable + // response from method_call_cb. Detach before the borrowed host pointer dies. + if (self->channel != nullptr) { + fl_method_channel_set_method_call_handler(self->channel, nullptr, nullptr, + nullptr); + } busymark_web_render_host_shutdown(self); g_clear_object(&self->channel); g_clear_object(&self->release_smoke_recovery_call); @@ -1020,4 +1312,5 @@ static void busymark_web_render_host_class_init( static void busymark_web_render_host_init(BusyMarkWebRenderHost* self) { self->queue = g_queue_new(); + self->state = RenderState::dormant; } diff --git a/linux/runner/web_render_host.h b/linux/runner/web_render_host.h index 1163b824..c333c004 100644 --- a/linux/runner/web_render_host.h +++ b/linux/runner/web_render_host.h @@ -10,6 +10,9 @@ G_DECLARE_FINAL_TYPE(BusyMarkWebRenderHost, WEB_RENDER_HOST, GObject) +// Creates only the channel host. WebKit resources are constructed on demand +// on the GTK main context. The owner keeps application/parent_window alive +// until shutdown; pending async callbacks retain the host until completion. BusyMarkWebRenderHost* busymark_web_render_host_new( GtkApplication* application, GtkWindow* parent_window); diff --git a/linux/runner/writerside_dialog_host.cc b/linux/runner/writerside_dialog_host.cc index d8fa6e43..1a8c7973 100644 --- a/linux/runner/writerside_dialog_host.cc +++ b/linux/runner/writerside_dialog_host.cc @@ -229,6 +229,7 @@ struct TitleInheritance { GtkWidget* topic_entry; GtkWidget* instance_entry; GtkWidget* toc_entry; + bool instance_effective; }; void update_title_inheritance(GtkEditable*, gpointer user_data) { @@ -240,7 +241,10 @@ void update_title_inheritance(GtkEditable*, gpointer user_data) { gtk_entry_set_placeholder_text(GTK_ENTRY(inheritance->instance_entry), topic); gtk_entry_set_placeholder_text(GTK_ENTRY(inheritance->toc_entry), - instance[0] == '\0' ? topic : instance); + !inheritance->instance_effective || + instance[0] == '\0' + ? topic + : instance); } void update_title_validity(GtkEditable* editable, gpointer user_data) { @@ -322,10 +326,20 @@ void show_edit_title(DialogHandlerData* data, gtk_entry_set_activates_default(GTK_ENTRY(instance_entry), TRUE); gtk_entry_set_activates_default(GTK_ENTRY(toc_entry), TRUE); gint row = 0; - add_title_field(GTK_GRID(grid), &row, - lookup_string(args, "instanceTitleLabel"), instance_entry, - wrapping_explanation( - lookup_string(args, "instanceExplanation"))); + FlValue* editable_arg = fl_value_lookup_string(args, "instanceTitleEditable"); + const bool instance_editable = editable_arg == nullptr || + (fl_value_get_type(editable_arg) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(editable_arg)); + gtk_editable_set_editable(GTK_EDITABLE(instance_entry), instance_editable); + if (instance_editable || std::strlen(lookup_string(args, "initialInstanceTitle")) > 0) { + add_title_field(GTK_GRID(grid), &row, + lookup_string(args, "instanceTitleLabel"), instance_entry, + wrapping_explanation(lookup_string(args, "instanceExplanation"))); + } else { + gtk_grid_attach(GTK_GRID(grid), + wrapping_explanation(lookup_string(args, "instanceExplanation")), + 0, row++, 2, 1); + } GtkWidget* toc_explanation_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); gtk_box_pack_start( @@ -343,7 +357,10 @@ void show_edit_title(DialogHandlerData* data, gtk_container_add(GTK_CONTAINER(advanced), grid); gtk_box_pack_start(GTK_BOX(content), advanced, FALSE, FALSE, 0); - TitleInheritance inheritance = {topic_entry, instance_entry, toc_entry}; + if (gtk_widget_get_parent(instance_entry) == nullptr) { + g_object_ref_sink(instance_entry); + } + TitleInheritance inheritance = {topic_entry, instance_entry, toc_entry, instance_editable}; g_signal_connect(topic_entry, "changed", G_CALLBACK(update_title_inheritance), &inheritance); g_signal_connect(instance_entry, "changed", @@ -369,7 +386,12 @@ void show_edit_title(DialogHandlerData* data, g_autofree gchar* toc = response == GTK_RESPONSE_OK ? g_strdup(gtk_entry_get_text(GTK_ENTRY(toc_entry))) : nullptr; + const bool instance_unparented = + gtk_widget_get_parent(instance_entry) == nullptr; gtk_widget_destroy(dialog); + if (instance_unparented) { + g_object_unref(instance_entry); + } if (data->view != nullptr && gtk_widget_get_realized(data->view)) { gtk_widget_grab_focus(data->view); } diff --git a/packages/busymark_spellcheck_native/DEPENDENCIES.md b/packages/busymark_spellcheck_native/DEPENDENCIES.md index 0d4b7571..5b613d25 100644 --- a/packages/busymark_spellcheck_native/DEPENDENCIES.md +++ b/packages/busymark_spellcheck_native/DEPENDENCIES.md @@ -7,7 +7,7 @@ BusyMark embeds Hunspell instead of using a host installation. - Source: `https://github.com/hunspell/hunspell/archive/refs/tags/v1.7.3.tar.gz` - SHA-256: `933be3dac6fd55f6e752331a170efb7e33800e40fae1156d8434cc8c85379a1b` - Vendored component: `third_party/hunspell/src/hunspell/` plus upstream - license files + license files; unused Autotools inputs are excluded The package build hook compiles the vendored engine and BusyMark wrapper into one bundled native code asset. Pango and GLib come from the GTK SDK/runtime used diff --git a/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/Makefile.am b/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/Makefile.am deleted file mode 100644 index 3ccffef9..00000000 --- a/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/Makefile.am +++ /dev/null @@ -1,23 +0,0 @@ -lib_LTLIBRARIES = libhunspell-1.7.la -libhunspell_1_7_includedir = $(includedir)/hunspell -libhunspell_1_7_la_SOURCES=affentry.cxx affentry.hxx affixmgr.cxx affixmgr.hxx \ - atypes.hxx baseaffix.hxx csutil.cxx csutil.hxx \ - filemgr.cxx filemgr.hxx hashmgr.cxx hashmgr.hxx \ - htypes.hxx hunspell.cxx hunspell.h hunspell.hxx \ - hunzip.cxx hunzip.hxx langnum.hxx \ - phonet.cxx phonet.hxx replist.cxx replist.hxx \ - suggestmgr.cxx suggestmgr.hxx utf_info.hxx w_char.hxx -nodist_libhunspell_1_7_la_SOURCES=hunvisapi.h - - -libhunspell_1_7_include_HEADERS=hunspell.hxx hunspell.h hunvisapi.h \ - w_char.hxx atypes.hxx - -# libtool version-info: current:revision:age -# - source changed, no API change: r += 1 -# - public API added: c += 1; r = 0; a += 1 -# - public API removed or signature change: c += 1; r = 0; a = 0 -# Linux SONAME suffix is (current - age). See RELEASING.md. -libhunspell_1_7_la_LDFLAGS=-no-undefined -version-info 1:0:1 - -AM_CXXFLAGS=$(CFLAG_VISIBILITY) -DBUILDING_LIBHUNSPELL $(HUNSPELL_STATIC_DEFINE) diff --git a/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/hunvisapi.h.in b/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/hunvisapi.h.in deleted file mode 100644 index d8bacc67..00000000 --- a/packages/busymark_spellcheck_native/third_party/hunspell/src/hunspell/hunvisapi.h.in +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef HUNSPELL_VISIBILITY_H_ -#define HUNSPELL_VISIBILITY_H_ - -#if defined(HUNSPELL_STATIC) -# define LIBHUNSPELL_DLL_EXPORTED -#elif defined(_WIN32) -# if defined(BUILDING_LIBHUNSPELL) -# define LIBHUNSPELL_DLL_EXPORTED __declspec(dllexport) -# else -# define LIBHUNSPELL_DLL_EXPORTED __declspec(dllimport) -# endif -#elif defined(BUILDING_LIBHUNSPELL) && @HAVE_VISIBILITY@ -# define LIBHUNSPELL_DLL_EXPORTED __attribute__((__visibility__("default"))) -#else -# define LIBHUNSPELL_DLL_EXPORTED -#endif - -#endif diff --git a/packages/busymark_spellcheck_native/tool/fetch_hunspell.sh b/packages/busymark_spellcheck_native/tool/fetch_hunspell.sh index 2175d44f..36f01a77 100755 --- a/packages/busymark_spellcheck_native/tool/fetch_hunspell.sh +++ b/packages/busymark_spellcheck_native/tool/fetch_hunspell.sh @@ -27,6 +27,10 @@ mkdir -p -- "${TEMP_DIR}/source" "${TEMP_DIR}/prepared/src" tar --extract --gzip --file "${ARCHIVE}" --directory "${TEMP_DIR}/source" \ --strip-components=1 cp -R -- "${TEMP_DIR}/source/src/hunspell" "${TEMP_DIR}/prepared/src/" +# The Dart build hook compiles the engine directly and uses hunvisapi.h. +# Autotools inputs cannot build a standalone project in this source subset. +rm -- "${TEMP_DIR}/prepared/src/hunspell/Makefile.am" \ + "${TEMP_DIR}/prepared/src/hunspell/hunvisapi.h.in" mkdir -p -- "${TEMP_DIR}/prepared/licenses" for license in COPYING COPYING.LESSER COPYING.MPL license.hunspell license.myspell; do test -f "${TEMP_DIR}/source/${license}" diff --git a/pubspec.yaml b/pubspec.yaml index 8d465423..6af14e7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.6.1 +version: 0.6.2 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index ff9ff326..5c3d1462 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.6.1" +version: "0.6.2" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/fixtures/writerside/list_capitalization_conformance/cfg/buildprofiles.xml b/test/fixtures/writerside/list_capitalization_conformance/cfg/buildprofiles.xml new file mode 100644 index 00000000..01ed61be --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/cfg/buildprofiles.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<buildprofiles> + <shortcuts> + <src>keymap.xml</src> + <layout name="Linux" display-name="Linux"/> + </shortcuts> + <build-profile instance="conformance"> + <variables> + <noindex-content>true</noindex-content> + <offline-docs>false</offline-docs> + </variables> + </build-profile> +</buildprofiles> diff --git a/test/fixtures/writerside/list_capitalization_conformance/conformance.tree b/test/fixtures/writerside/list_capitalization_conformance/conformance.tree new file mode 100644 index 00000000..94c800fa --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/conformance.tree @@ -0,0 +1 @@ +<instance-profile id="conformance" name="Lists and capitalization" start-page="lists.topic"><toc-element topic="lists.topic"/><toc-element topic="native-lists.md"/><toc-element topic="numbering.md"/><toc-element topic="nested-references.md"/><toc-element topic="explicit-inspection.md"/><toc-element topic="semantic-commands.topic"/></instance-profile> diff --git a/test/fixtures/writerside/list_capitalization_conformance/images/logo.png b/test/fixtures/writerside/list_capitalization_conformance/images/logo.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/list_capitalization_conformance/images/logo.png differ diff --git a/test/fixtures/writerside/list_capitalization_conformance/images/logo_dark.png b/test/fixtures/writerside/list_capitalization_conformance/images/logo_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/list_capitalization_conformance/images/logo_dark.png differ diff --git a/test/fixtures/writerside/list_capitalization_conformance/keymap.xml b/test/fixtures/writerside/list_capitalization_conformance/keymap.xml new file mode 100644 index 00000000..f7c5f66b --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/keymap.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<keymap id="conformance-keymap"> + <layouts> + <layout name="Linux" platform="PC"/> + </layouts> + <actions> + <action id="$Save"> + <description>Save</description> + <shortcut layout="Linux">Ctrl+S</shortcut> + </action> + <action id="$Copy"> + <description>Copy</description> + <shortcut layout="Linux">Ctrl+C</shortcut> + </action> + </actions> +</keymap> diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/explicit-inspection.md b/test/fixtures/writerside/list_capitalization_conformance/topics/explicit-inspection.md new file mode 100644 index 00000000..bc76bb8d --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/explicit-inspection.md @@ -0,0 +1,29 @@ +# Explicit inspection + +## Chapter A + +Paragraph A. + +## Chapter B + +Paragraph B. + +- Parent item + - Child item + +Other paragraph. + +> - Multiline <shortcut +> key="$Copy">Copy</shortcut> and <shortcut key="$Copy">Copy</shortcut>. +> - Nested <a +> href="lists.topic#absent">Read</a> and <a href="lists.topic#absent">Read</a>. + +Semantic insertion. + +Tip **example**. + +Note example. + +Warning example. + +Quote example. diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/lists.topic b/test/fixtures/writerside/list_capitalization_conformance/topics/lists.topic new file mode 100644 index 00000000..dfff3445 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/lists.topic @@ -0,0 +1,16 @@ +<topic id="lists" title="XML lists and capitalization"> +<p id="caret">Caret A</p> +<chapter id="absent" title="a journey through HTTP and APIs"><p>Chapter body.</p></chapter> +<chapter id="title-case" title="a journey through HTTP and APIs" caps="title"><p>Body.</p></chapter> +<chapter id="sentence-case" title="a journey through HTTP and APIs" caps="sentence"><p>Body.</p></chapter> +<chapter id="as-written" title="a journey through HTTP and APIs" caps="aswritten"><p>Body.</p></chapter> +<chapter id="upper-case" title="a journey through HTTP and APIs" caps="upper"><p>Body.</p></chapter> +<chapter id="variable-case" title="a journey through HTTP and APIs" caps="%case%"><p>Body.</p></chapter> +<chapter id="folded" title="Folded chapter" collapsible="true"><p>Hidden body.</p></chapter> +<code-block id="code" lang="text">Code B</code-block> +<img id="image" src="logo.png" alt="Image B"/> +<list id="letters" type="alpha-lower" start="3" columns="2" sorted="asc"><li id="letter-one"><p>Zebra</p></li><li><p>Alpha</p></li></list> +<list id="checkbox" type="checkbox"><li checked="true"><p>Done</p></li><li><p>Pending</p></li></list> +<list id="markerless" type="none" sorted="desc"><li><p>Alpha</p></li><li><p>Zebra</p></li></list> +<list id="xml-parent" type="decimal" start="3"><li><p>Parent item</p><list id="xml-nested" type="alpha-lower" start="4"><li><p>Nested item</p></li></list></li><li><p>Sibling item</p></li></list> +</topic> diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/native-lists.md b/test/fixtures/writerside/list_capitalization_conformance/topics/native-lists.md new file mode 100644 index 00000000..772e6c70 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/native-lists.md @@ -0,0 +1,43 @@ +# Native lists and capitalization + +Caret A + +## a journey through HTTP and APIs + +Body **formatting**. + +مرحبا بالمستندات التقنية + +3) Parent **caption** [guide](lists.topic) + + Second paragraph. + + 4) Nested first + 5) Nested second + +1) Sibling + +After list. + +- [x] Done +- Pending + +{type="checkbox" id="native-checkbox"} + +- Zebra +- Alpha + +{type="alpha-lower" start="3" columns="2" sorted="asc" id="native-letters"} + +- Alpha +- Zebra + +{type="none" sorted="desc" id="native-markerless"} + ++ Separate first ++ Separate second + +- Another first +- Another second + +<list id="embedded" type="decimal" start="6"><li id="embedded-item"><p>Embedded item</p></li><li><p>Embedded second</p></li></list> diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/nested-references.md b/test/fixtures/writerside/list_capitalization_conformance/topics/nested-references.md new file mode 100644 index 00000000..db3b1386 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/nested-references.md @@ -0,0 +1,22 @@ +# Nested reference ownership + +Caret A + +- Parent %product% in parent + - Child %product% and %product% + +* Multiline first + + continuation %product% and %product% + + * Descendant %product% + +> + Quote parent %product% +> + Quote child %product% and %product% + +- Reference owner + - Links [Same](lists.topic){id="link-one"} [Same](lists.topic){id="link-two"}, images ![Icon](logo.png) ![Icon](logo.png), keys <shortcut key="$Copy">Copy</shortcut> <shortcut key="$Copy">Copy</shortcut>. + ++ XML owner + + <chapter title="XML references"><p>XML %product%<img src="logo.png" alt="Icon"/>%product%%product%<br/>%product%</p></chapter> diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/numbering.md b/test/fixtures/writerside/list_capitalization_conformance/topics/numbering.md new file mode 100644 index 00000000..4cbfb177 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/numbering.md @@ -0,0 +1,16 @@ +# Marker and module defaults + +7. Zebra +8. Alpha + +{id="native-seven"} + +1. Zebra +2. Alpha + +{id="native-one"} + +7. Zebra +8. Alpha + +{start="9" id="native-nine"} diff --git a/test/fixtures/writerside/list_capitalization_conformance/topics/semantic-commands.topic b/test/fixtures/writerside/list_capitalization_conformance/topics/semantic-commands.topic new file mode 100644 index 00000000..7f3e39df --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/topics/semantic-commands.topic @@ -0,0 +1 @@ +<topic id="semantic-commands" title="Semantic commands"><p>Semantic insertion.</p><p>Tip <b>example</b>.</p><p>Note example.</p><p>Warning example.</p><p>Quote example.</p></topic> diff --git a/test/fixtures/writerside/list_capitalization_conformance/v.list b/test/fixtures/writerside/list_capitalization_conformance/v.list new file mode 100644 index 00000000..fe5153b5 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/v.list @@ -0,0 +1 @@ +<vars><var name="case" value="title"/><var name="product" value="Resolved product"/><var name="long_product_name" value="Resolved long product"/></vars> diff --git a/test/fixtures/writerside/list_capitalization_conformance/variants/generic.cfg b/test/fixtures/writerside/list_capitalization_conformance/variants/generic.cfg new file mode 100644 index 00000000..a9a7d85a --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/variants/generic.cfg @@ -0,0 +1,5 @@ +<ihp version="2026.2"> + <module name="Conformance"/><topics dir="topics"/><images dir="images"/> + <vars src="v.list"/><build-config dir="cfg"/><instance src="conformance.tree"/> + <settings><caps for="chapter" style="sentence"/><default-property element-name="chapter" property-name="caps" value="title"/><default-property element-name="chapter" property-name="caps" value="sentence"/></settings> +</ihp> diff --git a/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.cfg b/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.cfg new file mode 100644 index 00000000..4c2fb638 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.cfg @@ -0,0 +1,5 @@ +<ihp version="2026.2"> + <module name="Conformance"/><topics dir="topics"/><images dir="images"/> + <vars src="v.list"/><build-config dir="cfg"/><instance src="conformance.tree"/> + <settings><default-property element-name="list" property-name="start" value="5"/></settings> +</ihp> diff --git a/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.tree b/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.tree new file mode 100644 index 00000000..87f1c067 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/variants/list-start.tree @@ -0,0 +1 @@ +<instance-profile id="conformance" name="Marker defaults" start-page="numbering.md"><toc-element topic="numbering.md"/></instance-profile> diff --git a/test/fixtures/writerside/list_capitalization_conformance/variants/list-type.cfg b/test/fixtures/writerside/list_capitalization_conformance/variants/list-type.cfg new file mode 100644 index 00000000..2960e947 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/variants/list-type.cfg @@ -0,0 +1,5 @@ +<ihp version="2026.2"> + <module name="Conformance"/><topics dir="topics"/><images dir="images"/> + <vars src="v.list"/><build-config dir="cfg"/><instance src="conformance.tree"/> + <settings><default-property element-name="list" property-name="type" value="alpha-lower"/></settings> +</ihp> diff --git a/test/fixtures/writerside/list_capitalization_conformance/variants/variable.cfg b/test/fixtures/writerside/list_capitalization_conformance/variants/variable.cfg new file mode 100644 index 00000000..ecf6d1cb --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/variants/variable.cfg @@ -0,0 +1,5 @@ +<ihp version="2026.2"> + <module name="Conformance"/><topics dir="topics"/><images dir="images"/> + <vars src="v.list"/><build-config dir="cfg"/><instance src="conformance.tree"/> + <settings><default-property element-name="chapter" property-name="caps" value="%case%"/></settings> +</ihp> diff --git a/test/fixtures/writerside/list_capitalization_conformance/writerside.cfg b/test/fixtures/writerside/list_capitalization_conformance/writerside.cfg new file mode 100644 index 00000000..decc68b4 --- /dev/null +++ b/test/fixtures/writerside/list_capitalization_conformance/writerside.cfg @@ -0,0 +1,5 @@ +<ihp version="2026.2"> + <module name="Conformance"/><topics dir="topics"/><images dir="images"/> + <vars src="v.list"/><build-config dir="cfg"/><instance src="conformance.tree"/> + <settings><caps for="chapter" style="title"/><caps for="chapter" style="sentence"/><caps for="chapter" style="%case%"/><caps for="topic" style="title"/><caps for="toc-element" style="title"/></settings> +</ihp> diff --git a/test/fixtures/writerside/property_conformance/c.list b/test/fixtures/writerside/property_conformance/c.list new file mode 100644 index 00000000..0dfaea6e --- /dev/null +++ b/test/fixtures/writerside/property_conformance/c.list @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<categories> + <category id="related" name="Related" order="1"/> +</categories> diff --git a/test/fixtures/writerside/property_conformance/cfg/buildprofiles.xml b/test/fixtures/writerside/property_conformance/cfg/buildprofiles.xml new file mode 100644 index 00000000..01ed61be --- /dev/null +++ b/test/fixtures/writerside/property_conformance/cfg/buildprofiles.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<buildprofiles> + <shortcuts> + <src>keymap.xml</src> + <layout name="Linux" display-name="Linux"/> + </shortcuts> + <build-profile instance="conformance"> + <variables> + <noindex-content>true</noindex-content> + <offline-docs>false</offline-docs> + </variables> + </build-profile> +</buildprofiles> diff --git a/test/fixtures/writerside/property_conformance/cfg/glossary.xml b/test/fixtures/writerside/property_conformance/cfg/glossary.xml new file mode 100644 index 00000000..1aa736e7 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/cfg/glossary.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE terms SYSTEM "https://resources.jetbrains.com/writerside/1.0/glossary.dtd"> +<terms> + <term name="semantic">Markup that conveys the meaning of content.</term> +</terms> diff --git a/test/fixtures/writerside/property_conformance/conformance.tree b/test/fixtures/writerside/property_conformance/conformance.tree new file mode 100644 index 00000000..99e0deb8 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/conformance.tree @@ -0,0 +1 @@ +<instance-profile id="conformance" name="Properties" start-page="properties.topic"><toc-element topic="properties.topic"/><toc-element topic="properties-markdown.md"/></instance-profile> diff --git a/test/fixtures/writerside/property_conformance/images/logo.png b/test/fixtures/writerside/property_conformance/images/logo.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_conformance/images/logo.png differ diff --git a/test/fixtures/writerside/property_conformance/images/logo_dark.png b/test/fixtures/writerside/property_conformance/images/logo_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_conformance/images/logo_dark.png differ diff --git a/test/fixtures/writerside/property_conformance/images/other.png b/test/fixtures/writerside/property_conformance/images/other.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_conformance/images/other.png differ diff --git a/test/fixtures/writerside/property_conformance/images/other_dark.png b/test/fixtures/writerside/property_conformance/images/other_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_conformance/images/other_dark.png differ diff --git a/test/fixtures/writerside/property_conformance/keymap.xml b/test/fixtures/writerside/property_conformance/keymap.xml new file mode 100644 index 00000000..0581c4b8 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/keymap.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<keymap id="conformance-keymap"> + <layouts> + <layout name="Linux" platform="PC"/> + </layouts> + <actions> + <action id="$Save"> + <description>Save</description> + <shortcut layout="Linux">Ctrl+S</shortcut> + </action> + </actions> +</keymap> diff --git a/test/fixtures/writerside/property_conformance/resources/sample-resource.txt b/test/fixtures/writerside/property_conformance/resources/sample-resource.txt new file mode 100644 index 00000000..50b01c78 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/resources/sample-resource.txt @@ -0,0 +1 @@ +BusyMark Writerside conformance resource. diff --git a/test/fixtures/writerside/property_conformance/topics/properties-markdown.md b/test/fixtures/writerside/property_conformance/topics/properties-markdown.md new file mode 100644 index 00000000..84eb5684 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/topics/properties-markdown.md @@ -0,0 +1,208 @@ +--- +switcher-label: Author's: "Desktop" \ Keys & Tools +--- + +# Markdown Properties + +Text **bold**. +{id="intro"} + +## State only {id="md-state" default-state="expanded"} + +Content. + +```text +inline +``` +{id="md-code" collapsible="false"} + +```text +``` +{id="md-file" src="sample.txt" include-lines="2-3" emphasize-lines="1"} + +![Markdown Logo][logo]{id="image-markdown" width="48" thumbnail="true"} + +[logo]: logo.png + +Before [![Original][logo]{id="source-override" src="other.png" alt="Different" width="80" style="inline"}](properties.topic) after. + +Read [one](properties.topic#image-xml){id="reference-override"} and [two][guide]. + +[guide]: properties.topic#image-paragraph + +![Original][logo]{id="empty-alt" alt=""} + +```java +hello +``` +{id="lang-override" lang="text"} + +<code-block id="embedded-code" lang="text" collapsible="false" default-state="expanded" switcher-key="platform">embedded code</code-block> + +Use `{id="literal-code"}`. +{id="paragraph-code-owner"} + +Use \{id="literal-escaped"\}. +{id="paragraph-escaped-owner"} + +Read [**reference**](properties.topic){id="owned-link"}. +{id="paragraph-link-owner"} + +Show ![Logo](logo.png){id="owned-image" width="24"}. +{id="paragraph-image-owner"} + +- Native first item +- Native second item + +{id="native-list" instance="conformance"} + +<list type="bullet"> +<li><p>First <b>item</b>.</p></li> +<li id="converted-list-item" instance="conformance" filter="release"><p>Second item.</p></li> +</list> + +> A **note**. + +{style="note" id="native-note" instance="conformance" filter="release"} + +- [x] Checked native +- [ ] Unchecked native + +{id="checked-native-list"} + +<list type="checkbox" id="checked-xml-list"> +<li id="checked-xml-item" checked="true"><p>Checked XML</p></li> +<li id="unchecked-xml-item" checked="false"><p>Unchecked XML</p></li> +</list> + +See [`{id="linked-literal"}`](properties.topic){id="code-reference"} + +Show ![\{id="image-literal"}](logo.png){id="alt-reference" width="24"} + +Native authored start override. + +3. Native parent + 4. Native nested first + 5. Native nested second +4. Native sibling + +{start="10" id="native-numbered-list"} + +Native marker starts. + +3. Native marker parent + 4. Native marker nested first + 5. Native marker nested second +4. Native marker sibling + +{id="native-marker-list"} + +Native nested authored start override. + +3. Native override parent + 4. Native override nested first + 5. Native override nested second + + {start="7" id="native-nested-list"} + +4. Native override sibling + +{start="10" id="native-override-list"} + +Converted numbered lists. + +<list start="10" id="numbered-list" type="decimal"> + +<li id="numbered-parent"> + +<p>Parent</p> + +<list type="decimal" start="4"> + +<li> + +<p>Nested first</p> + +</li> + +<li> + +<p>Nested second</p> + +</li> + +</list> + +</li> + +<li> + +<p>Sibling</p> + +</li> + +</list> + +<list type="decimal" start="3"> + +<li id="marker-parent"> + +<p>Parent</p> + +<list type="decimal" start="4"> + +<li> + +<p>Nested first</p> + +</li> + +<li> + +<p>Nested second</p> + +</li> + +</list> + +</li> + +<li> + +<p>Sibling</p> + +</li> + +</list> + +<list start="10" type="decimal"> + +<li id="nested-parent"> + +<p>Parent</p> + +<list start="7" id="nested-list" type="decimal"> + +<li> + +<p>Nested first</p> + +</li> + +<li> + +<p>Nested second</p> + +</li> + +</list> + +</li> + +<li> + +<p>Sibling</p> + +</li> + +</list> diff --git a/test/fixtures/writerside/property_conformance/topics/properties.topic b/test/fixtures/writerside/property_conformance/topics/properties.topic new file mode 100644 index 00000000..f5160ad8 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/topics/properties.topic @@ -0,0 +1,56 @@ +<topic id="properties" title="Properties"> +<chapter id="chapter-absent" title="chapter-absent" ><p>Chapter body.</p></chapter> +<chapter id="chapter-false" title="chapter-false" collapsible="false"><p>Chapter body.</p></chapter> +<chapter id="chapter-true" title="chapter-true" collapsible="true"><p>Chapter body.</p></chapter> +<chapter id="chapter-expanded" title="chapter-expanded" default-state="expanded"><p>Chapter body.</p></chapter> +<chapter id="chapter-collapsed" title="chapter-collapsed" default-state="collapsed"><p>Chapter body.</p></chapter> +<chapter id="chapter-false-expanded" title="chapter-false-expanded" collapsible="false" default-state="expanded"><p>Chapter body.</p></chapter> +<chapter id="chapter-false-collapsed" title="chapter-false-collapsed" collapsible="false" default-state="collapsed"><p>Chapter body.</p></chapter> +<procedure id="procedure-absent" title="procedure-absent" ><step>First step.</step></procedure> +<procedure id="procedure-false" title="procedure-false" collapsible="false"><step>First step.</step></procedure> +<procedure id="procedure-true" title="procedure-true" collapsible="true"><step>First step.</step></procedure> +<procedure id="procedure-expanded" title="procedure-expanded" default-state="expanded"><step>First step.</step></procedure> +<procedure id="procedure-collapsed" title="procedure-collapsed" default-state="collapsed"><step>First step.</step></procedure> +<procedure id="procedure-false-expanded" title="procedure-false-expanded" collapsible="false" default-state="expanded"><step>First step.</step></procedure> +<procedure id="procedure-false-collapsed" title="procedure-false-collapsed" collapsible="false" default-state="collapsed"><step>First step.</step></procedure> +<code-block id="code-block-absent" lang="text" >first +second</code-block> +<code-block id="code-block-false" lang="text" collapsible="false">first +second</code-block> +<code-block id="code-block-true" lang="text" collapsible="true">first +second</code-block> +<code-block id="code-block-expanded" lang="text" default-state="expanded">first +second</code-block> +<code-block id="code-block-collapsed" lang="text" default-state="collapsed">first +second</code-block> +<code-block id="code-block-false-expanded" lang="text" collapsible="false" default-state="expanded">first +second</code-block> +<code-block id="code-block-false-collapsed" lang="text" collapsible="false" default-state="collapsed">first +second</code-block> +<deflist id="deflist-absent" ><def title="Absent" id="deflist-absent-item-absent">Absent state.</def><def title="Inherited" id="deflist-absent-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-absent-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-absent-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-false" collapsible="false"><def title="Absent" id="deflist-false-item-absent">Absent state.</def><def title="Inherited" id="deflist-false-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-false-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-false-item-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-true" collapsible="true"><def title="Absent" id="deflist-true-item-absent">Absent state.</def><def title="Inherited" id="deflist-true-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-true-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-true-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-expanded" default-state="expanded"><def title="Absent" id="deflist-expanded-item-absent">Absent state.</def><def title="Inherited" id="deflist-expanded-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-expanded-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-expanded-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-collapsed" default-state="collapsed"><def title="Absent" id="deflist-collapsed-item-absent">Absent state.</def><def title="Inherited" id="deflist-collapsed-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-collapsed-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-collapsed-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-false-expanded" collapsible="false" default-state="expanded"><def title="Absent" id="deflist-false-expanded-item-absent">Absent state.</def><def title="Inherited" id="deflist-false-expanded-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-false-expanded-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-false-expanded-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<deflist id="deflist-false-collapsed" collapsible="false" default-state="collapsed"><def title="Absent" id="deflist-false-collapsed-item-absent">Absent state.</def><def title="Inherited" id="deflist-false-collapsed-item-inherited" default-state="inherited">Inherited state.</def><def title="Expanded" id="deflist-false-collapsed-item-expanded" default-state="expanded">Expanded state.</def><def title="Collapsed" id="deflist-false-collapsed-item-collapsed" default-state="collapsed">Collapsed state.</def></deflist> +<chapter id="chapter-true-expanded" title="chapter-true-expanded" collapsible="true" default-state="expanded"><p>Chapter body.</p></chapter> +<chapter id="chapter-true-collapsed" title="chapter-true-collapsed" collapsible="true" default-state="collapsed"><p>Chapter body.</p></chapter> +<procedure id="procedure-true-expanded" title="procedure-true-expanded" collapsible="true" default-state="expanded"><step>Step.</step></procedure> +<procedure id="procedure-true-collapsed" title="procedure-true-collapsed" collapsible="true" default-state="collapsed"><step>Step.</step></procedure> +<code-block id="code-block-true-expanded" lang="text" collapsible="true" default-state="expanded">first +second</code-block> +<code-block id="code-block-true-collapsed" lang="text" collapsible="true" default-state="collapsed">first +second</code-block> +<deflist id="deflist-true-expanded" collapsible="true" default-state="expanded"><def title="Absent" id="deflist-true-expanded-item-absent">Absent.</def><def title="Inherited" id="deflist-true-expanded-item-inherited" default-state="inherited">Inherited.</def><def title="Collapsed" id="deflist-true-expanded-item-collapsed" default-state="collapsed">Collapsed.</def><def title="False" id="deflist-true-expanded-item-false" collapsible="false">False.</def></deflist> +<deflist id="deflist-true-collapsed" collapsible="true" default-state="collapsed"><def title="Absent" id="deflist-true-collapsed-item-absent">Absent.</def><def title="Inherited" id="deflist-true-collapsed-item-inherited" default-state="inherited">Inherited.</def><def title="Collapsed" id="deflist-true-collapsed-item-collapsed" default-state="collapsed">Collapsed.</def><def title="False" id="deflist-true-collapsed-item-false" collapsible="false">False.</def></deflist> +<chapter id="formatted-title" title="Default title"><title instance="conformance">Install <b>now</b>

Body.

+Child title only

Body.

+

Before Inline Logo after.

+Logo +

Variable attributes.

+variable code +

Default body.

+

Wide body.

+

Full body.

+ diff --git a/test/fixtures/writerside/property_conformance/topics/sample.txt b/test/fixtures/writerside/property_conformance/topics/sample.txt new file mode 100644 index 00000000..ff6e6b1a --- /dev/null +++ b/test/fixtures/writerside/property_conformance/topics/sample.txt @@ -0,0 +1,3 @@ +first +second +third diff --git a/test/fixtures/writerside/property_conformance/v.list b/test/fixtures/writerside/property_conformance/v.list new file mode 100644 index 00000000..4869b02c --- /dev/null +++ b/test/fixtures/writerside/property_conformance/v.list @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/writerside/property_conformance/writerside.cfg b/test/fixtures/writerside/property_conformance/writerside.cfg new file mode 100644 index 00000000..6992cbe4 --- /dev/null +++ b/test/fixtures/writerside/property_conformance/writerside.cfg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/fixtures/writerside/property_contract_conformance/c.list b/test/fixtures/writerside/property_contract_conformance/c.list new file mode 100644 index 00000000..0dfaea6e --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/c.list @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/property_contract_conformance/cfg/buildprofiles.xml b/test/fixtures/writerside/property_contract_conformance/cfg/buildprofiles.xml new file mode 100644 index 00000000..01ed61be --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/cfg/buildprofiles.xml @@ -0,0 +1,13 @@ + + + + keymap.xml + + + + + true + false + + + diff --git a/test/fixtures/writerside/property_contract_conformance/cfg/glossary.xml b/test/fixtures/writerside/property_contract_conformance/cfg/glossary.xml new file mode 100644 index 00000000..1aa736e7 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/cfg/glossary.xml @@ -0,0 +1,5 @@ + + + + Markup that conveys the meaning of content. + diff --git a/test/fixtures/writerside/property_contract_conformance/conformance.tree b/test/fixtures/writerside/property_contract_conformance/conformance.tree new file mode 100644 index 00000000..387bb331 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/conformance.tree @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/property_contract_conformance/images/logo.png b/test/fixtures/writerside/property_contract_conformance/images/logo.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_contract_conformance/images/logo.png differ diff --git a/test/fixtures/writerside/property_contract_conformance/images/logo_dark.png b/test/fixtures/writerside/property_contract_conformance/images/logo_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_contract_conformance/images/logo_dark.png differ diff --git a/test/fixtures/writerside/property_contract_conformance/images/other.png b/test/fixtures/writerside/property_contract_conformance/images/other.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_contract_conformance/images/other.png differ diff --git a/test/fixtures/writerside/property_contract_conformance/images/other_dark.png b/test/fixtures/writerside/property_contract_conformance/images/other_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_contract_conformance/images/other_dark.png differ diff --git a/test/fixtures/writerside/property_contract_conformance/keymap.xml b/test/fixtures/writerside/property_contract_conformance/keymap.xml new file mode 100644 index 00000000..0581c4b8 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/keymap.xml @@ -0,0 +1,12 @@ + + + + + + + + Save + Ctrl+S + + + diff --git a/test/fixtures/writerside/property_contract_conformance/topics/contract-markdown.md b/test/fixtures/writerside/property_contract_conformance/topics/contract-markdown.md new file mode 100644 index 00000000..315927f6 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/topics/contract-markdown.md @@ -0,0 +1,24 @@ +--- +switcher-label: Markdown platform +custom-setting: untouched +--- +# Markdown topic heading {id="authored-topic-anchor"} + + + +## Markdown chapter heading {id="markdown-chapter" title="Published Markdown override" collapsible="true" default-state="expanded" switcher-key="One"} + +Literal %product%. +{instance="conformance" filter="public" ignore-vars="true" id="markdown-paragraph"} + +

Excluded Markdown content.

+ +## Second Markdown chapter {id="markdown-second" switcher-key="Two"} + +Second content. + +Do this. + +

Tab body.

+ +

Definition body.

diff --git a/test/fixtures/writerside/property_contract_conformance/topics/contract-xml.topic b/test/fixtures/writerside/property_contract_conformance/topics/contract-xml.topic new file mode 100644 index 00000000..1b96786c --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/topics/contract-xml.topic @@ -0,0 +1,15 @@ + + + Other <b>topic title</b> + + + Other <b>chapter title</b> +

Literal %product%.

+

Excluded XML content.

+
+

Second content.

+ Do this. +

Tab body.

+

Definition body.

+
ItemValue
B2
A1
+
diff --git a/test/fixtures/writerside/property_contract_conformance/v.list b/test/fixtures/writerside/property_contract_conformance/v.list new file mode 100644 index 00000000..4869b02c --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/v.list @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/writerside/property_contract_conformance/writerside.cfg b/test/fixtures/writerside/property_contract_conformance/writerside.cfg new file mode 100644 index 00000000..6992cbe4 --- /dev/null +++ b/test/fixtures/writerside/property_contract_conformance/writerside.cfg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/c.list b/test/fixtures/writerside/property_defaults_conformance/c.list new file mode 100644 index 00000000..0dfaea6e --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/c.list @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml b/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml new file mode 100644 index 00000000..01ed61be --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/cfg/buildprofiles.xml @@ -0,0 +1,13 @@ + + + + keymap.xml + + + + + true + false + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml b/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml new file mode 100644 index 00000000..1aa736e7 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/cfg/glossary.xml @@ -0,0 +1,5 @@ + + + + Markup that conveys the meaning of content. + diff --git a/test/fixtures/writerside/property_defaults_conformance/conformance.tree b/test/fixtures/writerside/property_defaults_conformance/conformance.tree new file mode 100644 index 00000000..99e0deb8 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/conformance.tree @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/property_defaults_conformance/images/logo.png b/test/fixtures/writerside/property_defaults_conformance/images/logo.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_defaults_conformance/images/logo.png differ diff --git a/test/fixtures/writerside/property_defaults_conformance/images/logo_dark.png b/test/fixtures/writerside/property_defaults_conformance/images/logo_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_defaults_conformance/images/logo_dark.png differ diff --git a/test/fixtures/writerside/property_defaults_conformance/images/other.png b/test/fixtures/writerside/property_defaults_conformance/images/other.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_defaults_conformance/images/other.png differ diff --git a/test/fixtures/writerside/property_defaults_conformance/images/other_dark.png b/test/fixtures/writerside/property_defaults_conformance/images/other_dark.png new file mode 100644 index 00000000..d04c6b87 Binary files /dev/null and b/test/fixtures/writerside/property_defaults_conformance/images/other_dark.png differ diff --git a/test/fixtures/writerside/property_defaults_conformance/keymap.xml b/test/fixtures/writerside/property_defaults_conformance/keymap.xml new file mode 100644 index 00000000..0581c4b8 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/keymap.xml @@ -0,0 +1,12 @@ + + + + + + + + Save + Ctrl+S + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt b/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt new file mode 100644 index 00000000..50b01c78 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/resources/sample-resource.txt @@ -0,0 +1 @@ +BusyMark Writerside conformance resource. diff --git a/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md b/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md new file mode 100644 index 00000000..84eb5684 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/topics/properties-markdown.md @@ -0,0 +1,208 @@ +--- +switcher-label: Author's: "Desktop" \ Keys & Tools +--- + +# Markdown Properties + +Text **bold**. +{id="intro"} + +## State only {id="md-state" default-state="expanded"} + +Content. + +```text +inline +``` +{id="md-code" collapsible="false"} + +```text +``` +{id="md-file" src="sample.txt" include-lines="2-3" emphasize-lines="1"} + +![Markdown Logo][logo]{id="image-markdown" width="48" thumbnail="true"} + +[logo]: logo.png + +Before [![Original][logo]{id="source-override" src="other.png" alt="Different" width="80" style="inline"}](properties.topic) after. + +Read [one](properties.topic#image-xml){id="reference-override"} and [two][guide]. + +[guide]: properties.topic#image-paragraph + +![Original][logo]{id="empty-alt" alt=""} + +```java +hello +``` +{id="lang-override" lang="text"} + +embedded code + +Use `{id="literal-code"}`. +{id="paragraph-code-owner"} + +Use \{id="literal-escaped"\}. +{id="paragraph-escaped-owner"} + +Read [**reference**](properties.topic){id="owned-link"}. +{id="paragraph-link-owner"} + +Show ![Logo](logo.png){id="owned-image" width="24"}. +{id="paragraph-image-owner"} + +- Native first item +- Native second item + +{id="native-list" instance="conformance"} + + +
  • First item.

  • +
  • Second item.

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

  • +
  • Unchecked XML

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

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

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

    Sibling

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

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

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

    Sibling

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

    Parent

    + + + +
  • + +

    Nested first

    + +
  • + +
  • + +

    Nested second

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

    Sibling

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

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

    +

    Chapter body.

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

    Chapter body.

    +

    Chapter body.

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

    Body.

    +Child title only

    Body.

    +

    Before Inline Logo after.

    +Logo +

    Variable attributes.

    +variable code +

    Default body.

    +

    Wide body.

    +

    Full body.

    +
    diff --git a/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt b/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt new file mode 100644 index 00000000..ff6e6b1a --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/topics/sample.txt @@ -0,0 +1,3 @@ +first +second +third diff --git a/test/fixtures/writerside/property_defaults_conformance/v.list b/test/fixtures/writerside/property_defaults_conformance/v.list new file mode 100644 index 00000000..4869b02c --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/v.list @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/writerside/property_defaults_conformance/writerside.cfg b/test/fixtures/writerside/property_defaults_conformance/writerside.cfg new file mode 100644 index 00000000..83650a43 --- /dev/null +++ b/test/fixtures/writerside/property_defaults_conformance/writerside.cfg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/test/fixtures/writerside/reference_conformance/guide.tree b/test/fixtures/writerside/reference_conformance/guide.tree new file mode 100644 index 00000000..f5e516a4 --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/guide.tree @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/reference_conformance/topics/source.md b/test/fixtures/writerside/reference_conformance/topics/source.md new file mode 100644 index 00000000..cbfe46ce --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/topics/source.md @@ -0,0 +1,15 @@ +# Reference authoring + +[Inline element](target.topic#actual-id) and [Repeated reference][target]. + +[Second reference][target] + +[target]: target.topic#actual-id "Preserved reference title" + + + +`[Code exclusion](missing.topic)` + +## Explicit heading {id="explicit-heading"} + +[Same document](#explicit-heading) diff --git a/test/fixtures/writerside/reference_conformance/topics/target.topic b/test/fixtures/writerside/reference_conformance/topics/target.topic new file mode 100644 index 00000000..d943e55f --- /dev/null +++ b/test/fixtures/writerside/reference_conformance/topics/target.topic @@ -0,0 +1,5 @@ + +

    Element destination.

    +

    Authored reusable content.

    +

    XML semantic link

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

    text bold

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

    VariableElementAmbiguous

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

    Target

    Reusable link

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

    A

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

    B

    ', + ); + final project = await const WritersideProjectService().load(root.path); + ReferenceIndex index(String instance) { + final topics = project.modules.expand((m) => m.topics).toList(); + final value = ReferenceIndex( + root: root.path, + parsed: [ + for (final topic in topics) + ParsedReferenceDocument( + ReferenceDocument( + identity: topic.filePath, + path: topic.filePath, + title: topic.title ?? topic.fileName, + source: topic.document.source, + ), + ), + ], + ownedPaths: topics.map((t) => t.filePath).toSet(), + ); + addWritersideReferences(value, project.index, instance); + return value; + } + + final guide = index('guide'); + final authored = guide.references + .where((r) => r.source.path!.endsWith('source.topic')) + .toList(); + final element = authored.singleWhere( + (r) => r.authored.destination.contains('#explicit'), + ); + expect(element.resolution, ReferenceResolution.resolved); + expect(element.target!.anchor, 'explicit'); + expect(element.target!.moduleId, 'shared'); + expect( + authored + .singleWhere((r) => r.authored.destination == '%target%') + .resolution, + ReferenceResolution.resolved, + ); + expect( + authored + .singleWhere((r) => r.authored.destination == 'dup.topic') + .resolution, + ReferenceResolution.ambiguous, + ); + final include = authored + .where((r) => r.authored.kind == AuthoredReferenceKind.include) + .toList(); + expect(include, isNotEmpty); + expect(include.map((r) => r.authored.origin), everyElement('shared')); + expect( + include.map((r) => r.resolution), + everyElement(ReferenceResolution.unavailable), + ); + expect( + index('beta').references + .where( + (r) => + r.source.path!.endsWith('source.topic') && + r.authored.kind == AuthoredReferenceKind.include, + ) + .map((r) => r.resolution), + everyElement(ReferenceResolution.resolved), + ); + // Reuse indexes its library occurrence once, not an expansion in the caller. + expect( + guide.references.where((r) => r.authored.destination == '#explicit'), + hasLength(1), + ); + expect( + guide.references + .singleWhere((r) => r.authored.destination == '#explicit') + .source + .path, + endsWith('target.topic'), + ); + }, + ); +} diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index a1dab3a6..db8681c5 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -200,7 +200,16 @@ void main() { ]) { expect(combined, isNot(contains(removed))); } - expect(RegExp(r'Ctrl\+P(?![A-Za-z])').hasMatch(combined), isFalse); + // Ctrl+P is now the approved Quick Open binding. Printing remains absent. + final shortcuts = File( + 'lib/src/app/busymark_shortcuts.dart', + ).readAsStringSync(); + expect(shortcuts, contains("quickOpenLabel = 'Ctrl+P'")); + expect(shortcuts, contains("commandPaletteLabel = 'Ctrl+Shift+P'")); + final registry = File( + 'lib/src/app/command_registry.dart', + ).readAsStringSync(); + expect(registry, contains('BusyMarkAppShortcutAction.quickOpen')); }); test('product stderr logging is isolated behind debug logging', () { @@ -224,57 +233,6 @@ void main() { } }); - test('grouped action rows use one BusyMark-owned rounded surface', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - - expect(design, contains('class _BusyMarkGroupedListSurface')); - expect(design, contains('final fallbackShape = RoundedRectangleBorder')); - expect(design, contains('BorderRadius.circular(BusyMarkRadius.lg)')); - expect(design, contains('this.clipBehavior = Clip.antiAlias')); - expect(design, contains('height: 1')); - expect(design, isNot(contains('YaruTileList'))); - expect(design, isNot(contains('YaruBorderContainer'))); - }); - - test('BusyMark dialog title bars use the same surface as dialog body', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - final dialogChrome = RegExp( - r'Color busyMarkDialogSurfaceColor[\s\S]*?class BusyMarkDialogShell', - ).firstMatch(design)!.group(0)!; - final dialogShell = RegExp( - r'class BusyMarkDialogShell[\s\S]*?abstract final class BusyMarkPushButton', - ).firstMatch(design)!.group(0)!; - - expect(dialogChrome, contains('busyMarkDialogSurfaceColor(context)')); - expect(dialogChrome, contains('YaruDialogTitleBar(')); - expect(dialogChrome, contains('backgroundColor: dialogSurface')); - expect(dialogChrome, contains('border:')); - expect(dialogShell, contains('child: Dialog(')); - expect(dialogShell, contains('clipBehavior: Clip.antiAlias')); - }); - - test('BusyMark dialog buttons are thin semantic framework adapters', () { - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); - final dialogButton = RegExp( - r'class BusyMarkDialogButton[\s\S]*?class BusyMarkFloatingTextEntryGroup', - ).firstMatch(design)!.group(0)!; - - expect(design, contains('abstract final class BusyMarkPushButton')); - expect(design, contains('static FilledButton standard(')); - expect(design, contains('static FilledButton standardIcon(')); - expect(design, contains('return FilledButton.icon(')); - expect(design, contains('static ElevatedButton suggested(')); - expect(design, contains('static ElevatedButton destructive(')); - expect(dialogButton, contains('BusyMarkPushButton.standard(')); - expect(dialogButton, contains('BusyMarkPushButton.suggested(')); - expect(dialogButton, contains('BusyMarkPushButton.destructive(')); - expect(dialogButton, isNot(contains('FocusableActionDetector('))); - expect(dialogButton, isNot(contains('GestureDetector('))); - expect(dialogButton, isNot(contains('busyMarkSurfaceDecoration('))); - expect(dialogButton, isNot(contains('minHeight:'))); - expect(dialogButton, isNot(contains('minWidth:'))); - }); - test('Writerside New Topic uses the established modal editor components', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', @@ -1762,95 +1720,6 @@ void main() { expect(preview, contains('span.startOffset')); }); - test('source editor line numbers use measured editor layout', () { - final sourceEditor = File( - 'lib/src/editor/source/source_editor.dart', - ).readAsStringSync(); - final sourceGutter = File( - 'lib/src/editor/source/source_gutter.dart', - ).readAsStringSync(); - final sourceController = File( - 'lib/src/editor/source/source_controller.dart', - ).readAsStringSync(); - - expect(sourceEditor, contains('isCollapsed: true')); - expect( - sourceEditor, - contains('_sourceLineHeight(context, sourceStrutStyle)'), - ); - expect(sourceEditor, contains('_SourceRenderedTextLayer')); - expect(sourceController, contains('renderText = false')); - expect(sourceEditor, contains('controller.buildSourceTextSpan')); - expect(sourceGutter, contains('TextPainter(')); - expect(sourceGutter, contains('computeLineMetrics()')); - expect(sourceGutter, contains('getOffsetForCaret')); - expect(sourceGutter, contains('sourceTextHeightForLine')); - expect(sourceEditor, contains('_CollapsedSourceLineOverlay')); - expect(sourceEditor, contains('_sourceStrutStyle(')); - expect(sourceEditor, contains('folded: _foldedRegionKeys.isNotEmpty')); - expect(sourceEditor, contains('if (folded)')); - expect(sourceEditor, contains('return null')); - expect( - sourceEditor, - contains('StrutStyle.fromTextStyle(_sourceTextStyle)'), - ); - expect(sourceEditor, contains('strutStyle: sourceStrutStyle')); - expect(sourceEditor, contains('TextOverflow.ellipsis')); - expect(sourceEditor, contains('Color.alphaBlend')); - expect(sourceEditor, contains("'\$trimmed ...'")); - }); - - test('document view modes drive source preview and split layouts', () { - final settings = File('lib/src/app/app_settings.dart').readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final sourceController = File( - 'lib/src/editor/source/source_controller.dart', - ).readAsStringSync(); - final settingsScreen = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - - expect( - settings, - contains( - 'enum DocumentViewModePreference { editor, source, preview, split }', - ), - ); - expect( - settings, - contains('documentViewMode: DocumentViewModePreference.editor'), - ); - expect(settings, contains('Future setDocumentViewMode')); - expect(workspace, contains('final editorVisible = widget.viewMode ==')); - expect(workspace, contains('DocumentViewModePreference.editor')); - expect( - workspace, - contains('widget.viewMode != DocumentViewModePreference.preview'), - ); - expect( - workspace, - contains('widget.viewMode != DocumentViewModePreference.source'), - ); - expect(workspace, contains('BusyMarkWysiwygEditor')); - expect(sourceController, contains('visualMarkdown = false')); - expect(workspace, isNot(contains('class _VisualMarkdownEditorPane'))); - expect(workspace, contains('if (sourceVisible && previewVisible)')); - expect(workspace, contains('if (previewVisible)')); - expect(workspace, isNot(contains('class _DiffSharedHunkHeader'))); - expect( - workspace, - contains('target: _diffChangeTarget(diff, _currentChangeIndex)'), - ); - expect(workspace, contains('format: context.l10n.gitDiffHunkRange')); - expect(workspace, contains('noLinesText: context.l10n.gitDiffNoLines')); - expect(workspace, contains('showFileActions: !splitVisible')); - expect(workspace, contains('showHunkHeaders: !splitVisible')); - expect(settingsScreen, isNot(contains('Show preview pane'))); - expect(settingsScreen, isNot(contains('setPreviewVisible'))); - }); - test('WYSIWYG toolbar exposes fuller Markdown controls', () { final toolbar = File( 'lib/src/editor/wysiwyg/wysiwyg_toolbar.dart', @@ -1926,6 +1795,11 @@ void main() { expect(toolbar, isNot(contains('transparent: true'))); expect(RegExp(r'transparent: false').allMatches(toolbar), hasLength(3)); expect(toolbar, contains('BusyMarkHeaderIconButton(')); + expect(toolbar, contains('BusyMarkSubmenuItem(')); + expect(toolbar, contains('BusyWritersideToolbarSemantic')); + expect(toolbar, contains('context.l10n.wsInsertElements')); + expect(toolbar, contains('context.l10n.wsAdmonitionsAndQuotes')); + expect(toolbar, contains('context.l10n.wsSemanticText')); expect(toolbar, contains('_editorToolbarButtonBackground(context)')); expect(toolbar, contains('return theme.colorScheme.primary')); expect( @@ -1938,7 +1812,7 @@ void main() { expect(toolbar, isNot(contains('foregroundColor: colors.foreground'))); expect(RegExp(r'elevated: true').allMatches(toolbar), hasLength(3)); expect(toolbar, isNot(contains('accented: true'))); - expect(toolbar, contains('clipBehavior: Clip.none')); + expect(toolbar, contains('clipBehavior: Clip.hardEdge')); expect(toolbar, contains('hitTestBehavior: HitTestBehavior.deferToChild')); expect(toolbar, contains('horizontal: BusyMarkSpacing.sm')); expect(toolbar, contains('vertical: BusyMarkSpacing.xs')); diff --git a/test/src/source_autocomplete_test.dart b/test/src/source_autocomplete_test.dart index f52b3c4e..6ca9aa34 100644 --- a/test/src/source_autocomplete_test.dart +++ b/test/src/source_autocomplete_test.dart @@ -4,6 +4,76 @@ import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test( + 'list type completion offers the independently verified checkbox value', + () { + const source = '(); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'renderMermaid') return nativeResponse.future; + if (call.method == 'cancelRender') return true; + throw MissingPluginException(); + }); + const host = PlatformWebRenderHost(channel: channel); + final token = VisualizationCancellationToken(); + final operation = host.renderMermaid( + source: 'graph TD; A-->B', + theme: VisualizationTheme.light, + cancellationToken: token, + ); + final assertion = expectLater( + operation, + throwsA(isA()), + ); + token.cancel(); + nativeResponse.completeError( + PlatformException(code: 'visualization.cancelled'), + ); + await assertion; + }); + test('times out and requests native cancellation', () async { final renderCompleter = Completer(); String? renderRequestId; diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 40b23ceb..3c4054ed 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/editor/wysiwyg/writerside_editing_adapter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; @@ -38,8 +41,315 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + for (final change in ['session', 'source']) { + testWidgets( + 'clean close validates source identity during $change updates', + (tester) async { + final service = _ClosingAutosaveWorkspaceService(); + final creating = _createControllerHarness(service: service); + await tester.pump(Duration.zero); + final harness = await creating; + await harness.settingsController.setValidateOnEdit(false); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(service.path); + final before = controller.state.activeBuffer!; + await controller.openActiveFile(service.bPath); + await controller.activateDocumentBuffer(before.id); + service.pauseActivation = true; + final closing = controller.closeDocumentBuffer(before.id); + await tester.pump(); + expect(service.activationStarted.isCompleted, isTrue); + if (change == 'session') { + controller.updateActiveEditorState( + before.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 2), + ), + ); + } else { + controller.updateActiveSourceText('# New unsaved content\n'); + } + service.releaseActivation.complete(); + await tester.pump(); + expect(await closing, change == 'session'); + if (change == 'source') { + expect(controller.state.activeBuffer!.id, before.id); + expect(controller.state.activeText, '# New unsaved content\n'); + expect(controller.state.activeBuffer!.isDirty, isTrue); + } else { + expect( + controller.state.documentBuffers.any((b) => b.id == before.id), + isFalse, + ); + } + }, + ); + } _registerResponsivenessTests(); _registerCorrectionBoundaryTests(); + _registerReferenceMoveTests(); + for (final outerOverride in [false, true]) { + test( + 'numbered item property preserves starts through buffer undo redo save close reopen ($outerOverride)', + () async { + final root = await _createWritableWritersideFixture( + 'numbered-inspector', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 'topics/numbered.md')); + final original = + '# Numbered\n\n3. Parent\n 4. Nested first\n 5. Nested second\n4. Sibling\n' + '${outerOverride ? '\n{start="10" id="numbered-list"}\n' : ''}'; + await file.writeAsString(original); + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(file.path); + final buffer = controller.state.activeBuffer!; + BusyDocument parseNumbered(String source) => const MarkdownParser() + .parse( + filePath: file.path, + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final visual = BusyMarkWysiwygDocumentController( + document: parseNumbered(buffer.text), + ); + addTearDown(visual.dispose); + final target = visual.document.blocks[1].children.first; + expect( + visual.updateWritersideProperty(target.id, 'id', 'parent-item'), + isTrue, + ); + final converted = visual.markdown; + void verify(String source) { + final list = parseNumbered( + source, + ).blocks.singleWhere((b) => b.attributes['element'] == 'list'); + expect(list.attributes['start'], outerOverride ? '10' : '3'); + final item = list.children.firstWhere( + (b) => b.attributes['element'] == 'li', + ); + expect(item.attributes['id'], 'parent-item'); + expect( + item.children + .singleWhere((b) => b.attributes['element'] == 'list') + .attributes['start'], + '4', + ); + } + + expect( + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: original, + source: converted, + requiresSourceChange: true, + ), + isTrue, + ); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + verify(controller.state.activeText); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, original); + expect(controller.redoActiveBuffer(), isTrue); + expect(controller.state.activeText, converted); + verify(controller.state.activeText); + expect(await controller.saveActive(), isTrue); + expect(await controller.closeDocumentBuffer(buffer.id), isTrue); + await controller.openActiveFile(file.path); + expect(controller.state.activeText, await file.readAsString()); + verify(controller.state.activeText); + }, + ); + } + test( + 'inspector pending text is flushed by Save and uses one buffer undo operation', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-inspector-save-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 't.topic')); + const original = '

    Text

    \n'; + await file.writeAsString(original); + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(file.path); + final buffer = controller.state.activeBuffer!; + final draft = controller.propertyEdits.bind('id-target-generation', '', ( + value, + ) { + final visual = BusyMarkWysiwygDocumentController( + document: const WritersideEditingAdapter().parseXml( + filePath: file.path, + source: buffer.text, + )!, + ); + final paragraph = visual.document.blocks + .firstWhere((b) => b.attributes['element'] == 'topic') + .children + .firstWhere((b) => b.kind == BusyBlockKind.paragraph); + final accepted = + visual.updateWritersideProperty(paragraph.id, 'id', value) && + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: buffer.text, + source: visual.markdown, + ); + visual.dispose(); + return accepted; + }); + controller.propertyEdits.edit(draft, 'intro'); + expect(controller.state.activeText, original); + expect(await controller.saveActive(), isTrue); + expect(await file.readAsString(), contains('id="intro"')); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, original); + expect(controller.redoActiveBuffer(), isTrue); + final live = controller.state.activeBuffer!; + expect( + controller.applyInspectorSource( + bufferId: live.id, + revision: live.revision, + expectedSource: live.text, + source: live.text, + requiresSourceChange: true, + ), + isFalse, + reason: + 'An intended semantic change lost by serialization must not succeed', + ); + + expect( + controller.applyInspectorSource( + bufferId: buffer.id, + revision: buffer.revision, + expectedSource: buffer.text, + source: 'WRONG', + ), + isFalse, + ); + expect( + controller.applyInspectorSource( + bufferId: live.id, + revision: live.revision, + expectedSource: live.text, + source: live.text, + ), + isTrue, + ); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + hasLength(1), + ); + }, + ); + test( + 'invalid property draft cancels Save, close, and view changes without losing text', + () async { + final harness = await _createControllerHarness(); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath('test/fixtures/markdown/basic.md'); + final buffer = controller.state.activeBuffer!; + final draft = controller.propertyEdits.bind( + 'captured', + 'old', + (_) => false, + ); + controller.propertyEdits.edit(draft, 'invalid'); + expect(await controller.saveActive(), isFalse); + expect( + await controller.closeDocumentBuffer(buffer.id, discard: true), + isFalse, + ); + controller.updateActiveEditorMode(DocumentViewModePreference.source); + expect( + controller.state.activeBuffer!.editorState.mode, + buffer.editorState.mode, + ); + expect(draft.text, 'invalid'); + controller.propertyEdits.revert(draft); + expect(controller.propertyEdits.flush(), isTrue); + }, + ); + test( + 'XML visual edits use buffer history, save and reopen without Markdown conversion', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-xml-authoring-buffer-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File(p.join(root.path, 't.topic')); + const original = + '\n

    Text

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

    Text

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

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

    Save Save & %product%

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

    Nested

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

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

    Nested

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

    Here

    Other

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

    Original

    Protected

    Target

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

    Here

    Other

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

    $fragment

    Target

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

    A Save & close Z

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

    Definition

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

    Definition

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

    Protected special

    Editable

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

    Protected special

    Changed

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

    Copy here.

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

  • Nested
  • Second

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

  • Second

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

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

    Content

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

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

    Keep

    Two

    Meaning

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

    Meaning

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

    Content

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

    Intro

    Content

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

    Text path UI Ctrl+S

    Desktop content

    Terminal content

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

    Text

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

    Text

    ', + )! + : const MarkdownParser() + .parse( + filePath: 'a.md', + source: source ?? '# Title\n\nText\n', + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + +Widget _app( + Widget child, { + Brightness brightness = Brightness.light, + TextDirection direction = TextDirection.ltr, + double scale = 1, +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: brightness), + home: Directionality( + textDirection: direction, + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold(body: child), + ), + ), +); + +Finder _contentFields() => find.byWidgetPredicate( + (w) => w is TextField && w.controller is BusyMarkWysiwygTextController, +); +Finder _textField(String text) => _contentFields() + .evaluate() + .where((e) => (e.widget as TextField).controller!.text == text) + .map((e) => find.byWidget(e.widget)) + .first; + +Future inspectSelection(WidgetTester tester) async { + tester + .state(find.byType(BusyMarkWysiwygEditor)) + .inspectCurrentSelection(); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets( + 'navigation selection represents authored declarations after reopening', + (tester) async { + var c = BusyMarkWysiwygDocumentController(document: _document(true)); + addTearDown(() => c.dispose()); + Future> show() async { + final document = c.document; + await tester.pumpWidget( + _app( + BusyMarkWritersideProperties( + document: document, + target: document.blocks.first, + path: [document.blocks.first], + topicSelected: true, + onClose: () {}, + onTargetSelected: (_) {}, + onProperty: (_, _) => true, + onHeadingText: (_) => true, + onTopicProperty: (name, value) => name == '@navigation' + ? value == 'default' + ? c.resetTopicNavigation() + : value == 'none' + ? c.updateTopicNavigation('for', 'none') + : c.resetTopicNavigation() && + c.updateTopicNavigation('for', '') + : c.updateTopicNavigation(name, value), + onManageItem: (_, _, _) {}, + ), + ), + ); + final section = find.byWidgetPredicate( + (w) => + w is TextButton && + w.key == const ValueKey('writerside-disclosure-Topic navigation'), + ); + if (find + .byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('all'), + ) + .evaluate() + .isEmpty) { + await tester.ensureVisible(section); + await tester.tap(section); + await tester.pumpAndSettle(); + } + return tester.widget>( + find.byWidgetPredicate( + (w) => w is BusyMarkComboRow && w.values.contains('all'), + ), + ); + } + + for (final option in ['all', 'none', 'default']) { + (await show()).onSelected(option); + final saved = c.markdown; + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: _document(true, source: saved), + ); + expect((await show()).selected, option, reason: saved); + } + expect(c.updateTopicNavigation('for', 'chapter,def'), isTrue); + final saved = c.markdown; + c.dispose(); + c = BusyMarkWysiwygDocumentController( + document: _document(true, source: saved), + ); + expect((await show()).selected, 'custom'); + }, + ); + testWidgets('definition layout displays module default and reset origin', ( + tester, + ) async { + final c = BusyMarkWysiwygDocumentController( + document: _document( + true, + source: + '

    Body

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

    Body

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

    Press Ctrl+S now.

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

    Before Read guide and Other.

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

    Before.

    $style example.

    After.

    ' + : '# Title\n\nBefore.\n\n$style **example**.\n\nAfter.\n'; + var source = original; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(xml, source: original), + onSourceChanged: (_, value) => source = value, + ), + openProperties: false, + ); + await tester.tap(_textField('$style example.')); + await tester.pumpAndSettle(); + labels.add(style); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + final changed = source; + expect( + changed, + contains( + xml + ? '<${style.toLowerCase()}>' + : style == 'Tip' + ? '> Tip **example**.' + : 'style="${style.toLowerCase()}"', + ), + ); + expect(changed, contains(xml ? 'example' : '**example**')); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, original); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, changed); + expect( + tester + .widget(_textField('$style example.')) + .focusNode! + .hasFocus, + isTrue, + ); + }, + ); + } + } + testWidgets('Writerside semantic commands are disabled in literal code', ( + tester, + ) async { + var edits = 0; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document( + false, + source: '# Topic\n\n```text\nLiteral code\n```\n', + ), + onSourceChanged: (_, _) => edits++, + ), + openProperties: false, + ); + await tester.tap(_textField('Literal code')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Writerside')); + await tester.pumpAndSettle(); + final semantic = presentations.single.singleWhere( + (e) => e['label'] == 'Semantic text', + ); + expect((semantic['children'] as List).map((e) => e['enabled']), [ + false, + false, + false, + false, + ]); + expect(edits, 0); + }); + testWidgets('video insertion draft and cancellation do not modify source', ( + tester, + ) async { + var source = ''; + await mount( + tester, + BusyMarkWysiwygEditor( + document: _document(true), + onSourceChanged: (_, s) => source = s, + ), + ); + await tester.tap(_textField('Text')); + await insert(tester, 'Video'); + expect(source, isEmpty); + final entry = find.byKey(const ValueKey('writerside-video-source')); + final field = find.descendant(of: entry, matching: find.byType(TextField)); + await tester.enterText(field, 'https://youtu.be/cancelled'); + await tester.tap( + find.descendant( + of: find.byType(BusyMarkWritersideProperties), + matching: find.byTooltip('Close'), + ), + ); + await tester.pumpAndSettle(); + expect(source, isEmpty); + await insert(tester, 'Video'); + await tester.enterText(field, 'https://youtu.be/example'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(source, contains('

    Before Read guide after.

    Logo

    Press Ctrl+S.

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

    Text

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

    Original owner

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

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

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

    Before

    After

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

    ', '

    \n

    Inserted

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

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

    Caret A

    Body

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

    Caret A

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

    Body

    ', + '

    Different body

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

    Before Read guide and Guide.

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

    Before $token and $token.

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

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

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

    Keys .

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

    Body

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

    A

    B

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

    A

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

    ', '>B

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

    A

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

    A

    B

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

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

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

    %product%
    %product%

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

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

    Text

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

    Body

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

    Body

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

    Text

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

    Text

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

    Before Logo after.

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

    Body

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

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

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

    Body

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

    Body

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

    Body

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

    Body

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

    Body

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

    A long paragraph.

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

    First line
    Second line.

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

    First line
    Second line.

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

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

    %product%Product%product%%product%

    ' + : '

    %product%
    %product%

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

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

    '), source.indexOf('

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

    & %product%Product
    %product%

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

    First

    Target

    Third

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

    Body

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

    Original owner

    \n

    Other owner

    \n
    \n' + : '# Invalidation\n\nOriginal owner\n\nOther owner\n'; + +Future main(List arguments) async { + WidgetsFlutterBinding.ensureInitialized(); + if (arguments.length != 2) throw ArgumentError('FIXTURE OUTPUT_DIRECTORY'); + final root = await Directory.systemTemp.createTemp( + 'busymark-authoring-native-', + ); + final fixture = Directory(p.absolute(arguments[0])); + await for (final item in fixture.list(recursive: true)) { + final destination = p.join( + root.path, + p.relative(item.path, from: fixture.path), + ); + if (item is Directory) await Directory(destination).create(recursive: true); + if (item is File) { + await File(destination).parent.create(recursive: true); + await item.copy(destination); + } + } + if (Platform.environment['BUSYMARK_PROPERTY_CONTRACT_ONLY'] != '1') { + for (final name in ['contract-xml.topic', 'contract-markdown.md']) { + await File( + p.join( + 'test/fixtures/writerside/property_contract_conformance/topics', + name, + ), + ).copy(p.join(root.path, 'topics', name)); + } + await File(p.join(root.path, 'topics/authoring.topic')).writeAsString( + '

    Text

    Nested content

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

    Caret A

    Nested body

    Definition.

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

    Press Ctrl+S now.

    Open Guide now.

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

    Before Read guide and Other.

    Vars %product% and %product%.

    Keys Save and Save.

    Empty .

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

    %product%Product%product%%product%

    %product%
    %product%

    '; + await File( + p.join( + root.path, + 'topics/variable-media-${xml ? 'xml.topic' : 'markdown.md'}', + ), + ).writeAsString( + xml + ? '$body' + : '# Variable media Markdown\n\n$body\n', + ); + } + final variables = File(p.join(root.path, 'v.list')); + await variables.writeAsString( + (await variables.readAsString()).replaceFirst( + '', + '', + ), + ); + final tree = File(p.join(root.path, 'conformance.tree')); + await tree.writeAsString( + (await tree.readAsString()).replaceFirst( + '', + '', + ), + ); + } + const presentationSource = + '# Inspector presentation\n\n## 2. Diagnosed cause and source map\n\nBody for presentation checks.\n\n## Long generated anchor documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation documentation\n\nLong anchor body.\n'; + await File( + p.join(root.path, 'topics/inspector-presentation.md'), + ).writeAsString(presentationSource); + final presentationTree = File(p.join(root.path, 'conformance.tree')); + await presentationTree.writeAsString( + (await presentationTree.readAsString()).replaceFirst( + '', + '', + ), + ); + final defaultsFixture = Directory( + p.join(fixture.parent.path, 'property_defaults_conformance'), + ); + final defaultsRoot = Directory('${root.path}-defaults'); + await for (final item in defaultsFixture.list(recursive: true)) { + final destination = p.join( + defaultsRoot.path, + p.relative(item.path, from: defaultsFixture.path), + ); + if (item is Directory) await Directory(destination).create(recursive: true); + if (item is File) { + await File(destination).parent.create(recursive: true); + await item.copy(destination); + } + } + final output = await Directory( + p.absolute(arguments[1]), + ).create(recursive: true); + final contextualFixture = Directory( + p.absolute('test/fixtures/writerside/list_capitalization_conformance'), + ); + final contextualRoot = Directory('${root.path}-contextual'); + await for (final item in contextualFixture.list(recursive: true)) { + final destination = p.join( + contextualRoot.path, + p.relative(item.path, from: contextualFixture.path), + ); + if (item is Directory) await Directory(destination).create(recursive: true); + if (item is File) { + await File(destination).parent.create(recursive: true); + await item.copy(destination); + } + } + await windowManager.ensureInitialized(); + for (final (xml, malformed, draft) in _invalidationCases) { + await File( + p.join( + contextualRoot.path, + 'topics/${_invalidationStem(xml, malformed, draft)}.${xml ? 'topic' : 'md'}', + ), + ).writeAsString(_invalidationSource(xml, malformed, draft)); + } + await LinuxHeaderBarService.instance.initialize(); + final settings = AppSettings.defaults().copyWith( + localeTag: 'en', + themeModePreference: BusyMarkThemeModePreference.light, + documentViewMode: DocumentViewModePreference.editor, + autoSave: false, + elementPropertiesVisible: true, + elementPropertiesWidth: 390, + reopenPreviousWorkspaceOnStartup: false, + confirmCloseWithUnsavedChanges: false, + ); + runApp( + ProviderScope( + overrides: [ + writersideTemplateServiceProvider.overrideWithValue( + WritersideTemplateService( + storagePath: p.join(output.path, 'support/templates.json'), + ), + ), + startupPathProvider.overrideWithValue(root.path), + initialSystemAccentColorProvider.overrideWithValue( + busyMarkDefaultAccentColor, + ), + localSettingsStoreProvider.overrideWithValue( + _Settings(settings.toJson()), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + localHistoryStoreProvider.overrideWithValue( + FileLocalHistoryStore( + rootDirectory: () async => + Directory(p.join(output.path, 'history')), + ), + ), + ], + child: _Harness(root: root, output: output), + ), + ); + await windowManager.waitUntilReadyToShow( + const WindowOptions(size: Size(1400, 960), center: true), + () async { + await windowManager.show(); + await windowManager.focus(); + }, + ); +} + +class _Harness extends ConsumerStatefulWidget { + const _Harness({required this.root, required this.output}); + final Directory root; + final Directory output; + @override + ConsumerState<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends ConsumerState<_Harness> { + final _boundary = GlobalKey(); + final _checks = []; + var _pointer = 0; + var _nativeMenuOpen = false; + int? _accessibilitySessionExit; + + Future _startAccessibilitySession() async { + if (Platform.environment['BUSYMARK_NATIVE_PROBE'] != '1') return; + await _pause(); + final process = await Process.start('/usr/bin/python3', [ + p.absolute('tools/linux_native_menu_probe.py'), + '$pid', + 'hold', + ]); + process.stderr.transform(utf8.decoder).listen(stderr.write); + unawaited( + process.exitCode.then((code) { + _accessibilitySessionExit = code; + }), + ); + final ready = await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first + .timeout(const Duration(seconds: 30)); + _check(ready == 'ready', 'Native accessibility session connects'); + } + + Future _native( + String command, [ + String? argument, + String? secondArgument, + ]) async { + if (_accessibilitySessionExit != null) { + throw StateError( + 'Native accessibility session exited: $_accessibilitySessionExit', + ); + } + final result = await Process.run('/usr/bin/python3', [ + p.absolute('tools/linux_native_menu_probe.py'), + '$pid', + command, + ?argument, + ?secondArgument, + ]); + if (result.exitCode != 0) { + throw StateError('Native $command failed: ${result.stderr}'); + } + return result.stdout.toString().trim(); + } + + Future _tapLabel(String label) async { + if (_nativeMenuOpen) { + stdout.writeln('GTK menu: $label'); + _nativeMenuOpen = await _native('choose', label) == 'submenu'; + await _pause(); + } else { + await _tap(_text(label)); + } + } + + Future _hoverProperties(Element element) async { + final label = AppLocalizations.of( + _elements((w) => w is BusyMarkWysiwygEditor).single, + ).wsElementProperties; + await Scrollable.ensureVisible( + element, + alignment: 0.25, + duration: const Duration(milliseconds: 200), + ); + await _pause(); + final box = element.findRenderObject()! as RenderBox; + final origin = await windowManager.getPosition(); + final point = box.localToGlobal(box.size.center(Offset.zero)) + origin; + await _native('move-pointer', '${point.dx.round()}', '${point.dy.round()}'); + await _until( + () => + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).length == + 1, + 'One contextual Element Properties icon appears for ${element.widget.runtimeType}', + ); + final icon = _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).single; + final margin = icon.findRenderObject()! as RenderBox; + final target = + margin.localToGlobal(margin.size.center(Offset.zero)) + origin; + await _native( + 'move-pointer', + '${target.dx.round()}', + '${target.dy.round()}', + ); + await _pause(); + _check( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == label, + ).length == + 1, + 'Contextual Properties remains reachable across the margin', + ); + await _native('click-pointer'); + await _pause(); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_run())); + } + + @override + Widget build(BuildContext context) => + RepaintBoundary(key: _boundary, child: const BusyMarkApp()); + Future _pause() => + Future.delayed(const Duration(milliseconds: 700)); + Future _until(bool Function() condition, String label) async { + var settled = 0; + for (var attempt = 0; attempt < 40; attempt++) { + if (condition() && !ref.read(workspaceControllerProvider).isLoading) { + if (++settled >= 2) return; + } else { + settled = 0; + } + await _pause(); + } + throw StateError( + '$label; workspace message: ${ref.read(workspaceControllerProvider).message}', + ); + } + + List _elements(bool Function(Widget) predicate) { + final found = []; + void visit(Element element) { + if (predicate(element.widget)) { + final render = element.findRenderObject(); + if (render is RenderBox && + render.attached && + render.hasSize && + !render.size.isEmpty) { + found.add(element); + } + } + element.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return found; + } + + Element _text(String text) => + _elements((widget) => widget is Text && widget.data == text).last; + Future _tap( + Element element, { + int buttons = kPrimaryMouseButton, + }) async { + stdout.writeln( + 'Tap: ${element.widget is Text ? (element.widget as Text).data : element.widget.runtimeType}', + ); + await Scrollable.ensureVisible( + element, + alignment: 0.25, + duration: const Duration(milliseconds: 200), + ).timeout(const Duration(seconds: 5)); + await _pause(); + final box = element.findRenderObject()! as RenderBox; + final position = box.localToGlobal(box.size.center(Offset.zero)); + final pointer = ++_pointer; + GestureBinding.instance.handlePointerEvent( + PointerDownEvent( + pointer: pointer, + position: position, + buttons: buttons, + kind: PointerDeviceKind.mouse, + ), + ); + GestureBinding.instance.handlePointerEvent( + PointerUpEvent( + pointer: pointer, + position: position, + kind: PointerDeviceKind.mouse, + ), + ); + await _pause(); + } + + Future _key( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, + ) async { + if (_nativeMenuOpen) { + await _native( + 'key', + logical == LogicalKeyboardKey.escape ? 'Escape' : logical.keyLabel, + ); + if (logical == LogicalKeyboardKey.escape || + logical == LogicalKeyboardKey.enter) { + _nativeMenuOpen = false; + } + await _pause(); + return; + } + _keyData(physical, logical, ui.KeyEventType.down); + _keyData(physical, logical, ui.KeyEventType.up); + if (logical == LogicalKeyboardKey.enter) { + // Native text input also delivers a platform action for Enter. Framework + // KeyData alone does not emulate that second half of the input event. + final input = _elements( + (widget) => widget is EditableText && widget.focusNode.hasFocus, + ).firstOrNull; + if (input is StatefulElement && input.state is EditableTextState) { + (input.state as EditableTextState).performAction(TextInputAction.done); + } + } + await _pause(); + } + + void _keyData( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, + ui.KeyEventType type, + ) { + // The embedding test must dispatch a framework key message as well as + // update HardwareKeyboard; its public replacement only registers handlers. + // ignore: deprecated_member_use + ServicesBinding.instance.keyEventManager.handleKeyData( + ui.KeyData( + type: type, + timeStamp: Duration.zero, + physical: physical.usbHidUsage, + logical: logical.keyId, + character: null, + synthesized: true, + ), + ); + } + + Future _capture(String name) async { + if (await windowManager.getPosition() != Offset.zero) { + await windowManager.setPosition(Offset.zero); + } + await _pause(); + if (Platform.environment['BUSYMARK_NATIVE_PROBE'] == '1') { + await _native('capture', p.join(widget.output.path, '$name.png')); + return; + } + await WidgetsBinding.instance.endOfFrame; + final image = + await (_boundary.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + p.join(widget.output.path, '$name.png'), + ).writeAsBytes(bytes!.buffer.asUint8List()); + } + + void _check(bool value, String label) { + if (!value) throw StateError(label); + _checks.add(label); + } + + Element _field(String text) => _elements( + (w) => + w is TextField && + w.controller is BusyMarkWysiwygTextController && + w.controller!.text == text, + ).first; + + String _inspectorLookupContext(String expected, int count) { + final state = ref.read(workspaceControllerProvider); + final targets = _elements((w) => w is BusyMarkWritersideProperties) + .map((e) { + final panel = e.widget as BusyMarkWritersideProperties; + final target = panel.target; + return '${panel.document.filePath}: ' + '${panel.topicSelected ? 'topic' : target?.attributes['element'] ?? target?.kind.name ?? 'no element'} ' + '(block=${target?.id}, anchor=${target?.attributes['id']}, ' + 'inline=${panel.inlineTarget?.kind.name})'; + }) + .join('; '); + return 'Expected exactly one inspector control "$expected", found $count; ' + 'document=${state.activeBuffer?.filePath}, ' + 'buffer=${state.activeBuffer?.id}; ' + 'targets=${targets.isEmpty ? 'no inspector' : targets}'; + } + + List _inspectorControls( + bool Function(Widget) matches, + ) => _elements(matches) + .where( + (e) => + e.findAncestorWidgetOfExactType() != + null, + ) + .toList(); + + Element _singleInspectorControl( + String expected, + bool Function(Widget) matches, + ) { + final controls = _inspectorControls(matches); + if (controls.length != 1) { + throw StateError(_inspectorLookupContext(expected, controls.length)); + } + return controls.single; + } + + BusyMarkGroupedTextEntry _inspectorProperty(String label) => + _singleInspectorControl( + label, + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ).widget + as BusyMarkGroupedTextEntry; + + Future _expandInspectorSection( + String title, + String expectedControl, + bool Function(Widget) hasControl, + ) async { + if (_inspectorControls(hasControl).isNotEmpty) { + _singleInspectorControl(expectedControl, hasControl); + return; + } + await _tap( + _singleInspectorControl( + '$title section', + (w) => + w is TextButton && + w.key == ValueKey('writerside-disclosure-$title'), + ), + ); + await _until( + () => _inspectorControls(hasControl).isNotEmpty, + _inspectorLookupContext(expectedControl, 0), + ); + _singleInspectorControl(expectedControl, hasControl); + } + + Future _requestProperties() async { + await _native('chord', 'ctrl+shift+p'); + await _until( + () => _elements( + (w) => w is BusyMarkGroupedTextEntry && w.label == 'Type a command', + ).isNotEmpty, + 'Element Properties command palette opens', + ); + await _type( + _elements( + (w) => w is TextField && w.decoration?.labelText == 'Type a command', + ).single, + 'Element Properties', + ); + await _native('chord', 'Return'); + await _pause(); + } + + Future _closeInspector() async { + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ).single, + ); + await _pause(); + } + + Future _topicProperties() async { + await _tap( + _singleInspectorControl( + 'Element', + (w) => w is BusyMarkComboRow && w.title == 'Element', + ), + ); + _nativeMenuOpen = true; + await _tapLabel('Topic Properties'); + } + + Future _choose(String key, List labels) async { + await _tap(_elements((w) => w.key == ValueKey(key)).single); + _nativeMenuOpen = true; + for (final label in labels) { + _nativeMenuOpen = true; + await _tapLabel(label); + } + _check(!_nativeMenuOpen, 'GTK menu completed: ${labels.join(" / ")}'); + } + + Future _type(Element e, String text) async { + await _tap(e); + final field = e.widget as TextField; + field.controller!.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + field.onChanged?.call(text); + await _pause(); + } + + String get _source => ref.read(workspaceControllerProvider).activeText; + Future _captureScaled(String name) async { + // Exercise the actual Linux window at accessibility text scaling. GTK's + // DPI setting does not change Flutter's text scaler on this embedding. + Element? media; + _elements( + (w) => w is BusyMarkWritersideProperties, + ).single.visitAncestorElements((e) { + if (e.widget is MediaQuery) { + media = e; + } + return true; + }); + final element = media!; + final original = element.widget as MediaQuery; + // Scale the outer view data. BusyMark derives another MediaQuery for GTK + // animation settings, which can rebuild during a capture; overriding that + // inner copy would be lost instead of propagating through the real app. + // Inherited-widget updates must notify their dependants during a build + // scope. Updating the element directly from this async probe asserts in + // the debug application and prevents the scaling acceptance check. + element.owner!.buildScope(element, () { + element.update( + MediaQuery( + data: original.data.copyWith(textScaler: TextScaler.linear(1.5)), + child: original.child, + ), + ); + }); + await WidgetsBinding.instance.endOfFrame; + _check( + MediaQuery.textScalerOf( + _elements((w) => w is BusyMarkWritersideProperties).single, + ).scale(14) == + 21, + 'Linux text scaling applied', + ); + await _capture(name); + _check( + MediaQuery.textScalerOf( + _elements((w) => w is BusyMarkWritersideProperties).single, + ).scale(14) == + 21, + 'Linux text scaling remains applied through capture', + ); + element.owner!.buildScope(element, () => element.update(original)); + await _pause(); + } + + Future _runInlineReferences( + Future Function() openInspector, + ) async { + final controller = ref.read(workspaceControllerProvider.notifier); + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + Future editorCaret(String text, int offset) async { + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap(_field(text)); + (_field(text).widget as TextField).controller!.selection = + TextSelection.collapsed(offset: offset); + await _pause(); + await _requestProperties(); + } + + Future sourceCaret(int offset) async { + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + await _requestProperties(); + } + + Future property(String label, String value) async { + final entry = + _elements( + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ).single.widget + as BusyMarkGroupedTextEntry; + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + } + + Future showSource(int offset, String label) async { + await _tapLabel('Show in Source'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + '$label switches to Source', + ); + await _pause(); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .selection + .extentOffset == + offset, + '$label navigates to exact markup occurrence', + ); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null, + '$label restores source focus', + ); + } + + Future reopen() async { + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Inline references saved', + ); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + await File(buffer.filePath!).readAsString() == buffer.text, + 'Inline references persisted exactly', + ); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Inline reference buffer closes', + ); + await controller.openActiveFile(buffer.filePath!); + await _pause(); + } + + if (Platform.environment['BUSYMARK_VARIABLE_MEDIA_ONLY'] != '1') { + for (final xml in [true, false]) { + final suffix = xml ? 'xml' : 'markdown'; + await controller.openActiveFile( + p.join( + widget.root.path, + 'topics/inline-references-${xml ? 'xml.topic' : 'markdown.md'}', + ), + ); + await editorCaret('Before Read guide and Other.', 9); + if (!ref.read(appSettingsControllerProvider).elementPropertiesVisible) { + await openInspector(); + } + final original = _source; + for (final offset in [9, 15]) { + await editorCaret('Before Read guide and Other.', offset); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.link && + inspector().inlineTarget!.end - + inspector().inlineTarget!.start == + 'Read guide'.length, + '$suffix mixed link selects complete owner at caret $offset', + ); + } + await _capture('inline-$suffix-mixed-link-editor'); + await property('Link target (href)', 'inspector.topic#nested'); + _check( + _source.contains(xml ? 'Read guide' : 'Read **guide**') && + _source.contains( + xml ? 'Other' : '[Other][ref]', + ), + '$suffix link edit retains mixed formatting and other occurrence', + ); + final changed = _source; + _check( + changed != original && + changed.contains( + xml + ? 'href="inspector.topic#nested"' + : '(inspector.topic#nested "Kept title")', + ), + '$suffix link destination changes', + ); + await showSource( + _source.indexOf(xml ? ' verifyMedia(String stage) async { + for (final image in [true, false]) { + final names = image ? imageNames : breakNames; + final text = image + ? '${names.first}$alt${names.skip(1).join()}' + : names.join('\n'); + final paragraph = _source.indexOf( + image ? '

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

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

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

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

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

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

    ' + : xml + ? '

    Replacement owner

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

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

    '), + 'Explicit inspected B receives the edit', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'Installation' && + w.document.source == _source, + ).length == + 1, + 'Property publication preserves inspected B while caret stays in A', + ); + _check( + controller.undoActiveBuffer(), + 'Explicit B property has one undo', + ); + await _pause(); + _check( + _source.contains('title="Block B"'), + 'Undo restores inspected B', + ); + await _until( + () => + _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.plainText == 'Block B' && + w.document.source == _source, + ).length == + 1, + 'Undo preserves explicitly inspected B at the new revision', + ); + _check( + _inspectorProperty('Default title (title)').controller!.text == + 'Block B' && + (_field('Caret A').widget as TextField) + .controller! + .selection + .baseOffset == + caret.baseOffset && + (_field('Caret A').widget as TextField) + .controller! + .selection + .extentOffset == + caret.extentOffset && + (_field('Caret A').widget as TextField).focusNode!.hasFocus, + 'Off-caret B inspection survives Undo without selecting A', + ); + } + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Source inspector loaded', + ); + _check( + _elements((w) => w is BusyMarkWysiwygEditor).isEmpty, + 'Source inspector has no hidden WYSIWYG', + ); + final sourceElement = + _elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement; + final sourceState = sourceElement.state as BusyMarkSourceEditorState; + final codeOffset = suffix == 'topic' + ? _source.indexOf(' w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + properties.target!.kind == BusyBlockKind.codeBlock && + properties.path.any((b) => b.plainText == 'Nested'), + '$suffix Source caret resolves code and chapter ancestry', + ); + await _capture('inspector-$suffix-source-code'); + BusyMarkGroupedTextEntry property(String label) => + _inspectorProperty(label); + final sourceBeforeChooser = _source; + await _capture('inspector-single-source-field'); + Element sourceButton(String tooltip) => + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == tooltip, + ).singleWhere( + (e) => + e + .findAncestorWidgetOfExactType() + ?.label == + 'Source file (src)', + ); + await _tap(sourceButton('Open')); + await _pause(); + await _capture('inspector-native-source-chooser'); + await _native('chord', 'Escape'); + await _pause(); + _check( + _source == sourceBeforeChooser, + '$suffix native file selection cancellation changes nothing', + ); + await _tap(sourceButton('Remove')); + await _pause(); + _check( + !_source.contains('src="inspector-sample.txt"') && + !_source.contains('first\nsecond'), + '$suffix removing src does not import file bytes', + ); + await _tap(sourceButton('Open')); + await _pause(); + await _native('chord', 'ctrl+l'); + await _native( + 'type', + p.join(widget.root.path, 'topics/inspector-sample.txt'), + ); + await _native('chord', 'Return'); + await _pause(); + await _until( + () => _source == sourceBeforeChooser, + '$suffix native file choice edits the same src transaction', + ); + _check( + (_elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Source file (src)', + ).single.widget + as BusyMarkGroupedTextEntry) + .controller! + .text == + 'inspector-sample.txt', + 'Source field displays chosen topic-relative reference', + ); + await _capture('inspector-source-file-selected'); + await _closeInspector(); + _check( + !ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Close control uses the workspace draft handler', + ); + await paletteProperties(); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Palette opens one workspace inspector', + ); + final id = property('Element anchor (id)'); + await _tap( + _elements( + (w) => w is TextField && w.controller == id.controller, + ).single, + ); + final beforeHistory = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + id.controller!.text = 'saved-code'; + await _native('chord', 'ctrl+s'); + await _until(() { + final current = ref.read(workspaceControllerProvider).activeBuffer; + return current != null && + !current.isDirty && + File( + current.filePath!, + ).readAsStringSync().contains('id="saved-code"'); + }, '$suffix focused native Save reaches disk'); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + (await File( + buffer.filePath!, + ).readAsString()).contains('id="saved-code"'), + '$suffix native Ctrl+S commits focused property before saving', + ); + _check( + buffer.editorState.undoState.undo.length == beforeHistory + 1, + '$suffix property commit records one undo', + ); + _check( + buffer.editorState.selection.extentOffset == codeOffset, + '$suffix Source selection survives property transaction', + ); + final invalidId = property('Element anchor (id)'); + invalidId.focusNode!.requestFocus(); + invalidId.controller!.text = 'invalid id'; + final beforeInvalid = _source; + await _native('chord', 'ctrl+s'); + await _pause(); + _check( + _source == beforeInvalid && controller.propertyEdits.hasPending, + '$suffix invalid draft blocks Save and retains its target', + ); + await _native('chord', 'ctrl+shift+3'); + await _pause(); + _check( + _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + '$suffix invalid draft cancels view transition', + ); + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ) + .where( + (e) => + e + .findAncestorWidgetOfExactType< + BusyMarkWritersideProperties + >() != + null, + ) + .single, + ); + _check( + _elements((w) => w is BusyMarkWritersideProperties).isNotEmpty && + controller.propertyEdits.hasPending, + '$suffix invalid draft cancels inspector closure', + ); + await _capture('inspector-$suffix-invalid-draft'); + await paletteProperties(); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible && + controller.propertyEdits.hasPending, + 'Palette close retains invalid draft and inspector', + ); + invalidId.focusNode!.requestFocus(); + await _pause(); + await _key(PhysicalKeyboardKey.escape, LogicalKeyboardKey.escape); + _check( + !controller.propertyEdits.hasPending && + invalidId.controller!.text == 'saved-code', + '$suffix Escape explicitly reverts pending text', + ); + final extraction = property('Include lines (include-lines)'); + await _tap( + _elements( + (w) => w is TextField && w.controller == extraction.controller, + ).single, + ); + extraction.controller!.text = '2-3'; + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + final emphasis = property( + 'Emphasize displayed lines (emphasize-lines)', + ); + await _tap( + _elements( + (w) => w is TextField && w.controller == emphasis.controller, + ).single, + ); + emphasis.controller!.text = '1'; + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + await _capture('inspector-$suffix-code-properties'); + _check(await controller.saveActive(), '$suffix Source inspector save'); + _check( + await controller.closeDocumentBuffer(buffer.id), + '$suffix Source inspector close', + ); + await controller.openActiveFile(buffer.filePath!); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Reopened Source inspector', + ); + final reopened = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + reopened.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkGroupedTextEntry && + w.label == 'Element anchor (id)' && + w.controller?.text == 'saved-code', + ).isNotEmpty, + '$suffix reopened Source caret resolves saved code', + ); + _check( + property('Element anchor (id)').controller!.text == 'saved-code' && + property('Include lines (include-lines)').controller!.text == + '2-3' && + property( + 'Emphasize displayed lines (emphasize-lines)', + ).controller!.text == + '1', + '$suffix saved code properties survive close, reopen and reinspection', + ); + await _native('chord', 'ctrl+shift+4'); + await _pause(); + await _capture('inspector-$suffix-split'); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + final initial = _source; + final delayedProperties = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + final liveSourceState = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + liveSourceState.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.attributes['element'] != 'code-block' && + w.target?.kind != BusyBlockKind.codeBlock, + ).isNotEmpty, + '$suffix Source retargets to chapter', + ); + _check( + !delayedProperties.onProperty('collapsed-title', 'Delayed result') && + _source == initial, + '$suffix delayed completion rejects a different target in the same unchanged buffer', + ); + liveSourceState.scrollToOffset( + _source.indexOf(suffix == 'topic' ? ' _elements( + (w) => + w is BusyMarkWritersideProperties && + w.target?.kind == BusyBlockKind.codeBlock, + ).isNotEmpty, + '$suffix Source restores code target', + ); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Enabled'); + await _capture('inspector-$suffix-override'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Use default'); + _check( + _source == initial, + '$suffix reset removes authored collapse and restores source', + ); + await windowManager.setSize(const Size(800, 900)); + await _capture('inspector-$suffix-narrow-overlay'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == 390, + 'Narrow layout preserves preferred inspector width', + ); + await windowManager.setSize(const Size(1400, 960)); + await _native('chord', 'ctrl+shift+3'); + await _pause(); + final reading = + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + _check( + reading.readOnly, + '$suffix Reading retains validated read-only properties', + ); + await _capture('inspector-$suffix-reading'); + _check( + _source == initial, + '$suffix navigation and Reading leave source unchanged', + ); + await File( + p.join(widget.output.path, 'inspector.$suffix'), + ).writeAsString(_source); + await _native('chord', 'ctrl+shift+1'); + } + BusyMarkWritersideProperties inspector() => + _elements((w) => w is BusyMarkWritersideProperties).single.widget + as BusyMarkWritersideProperties; + BusyMarkComboRow combo(String title) => + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single.widget + as BusyMarkComboRow; + Future chooseCombo(String title, String label) async { + await _tap( + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel(label); + } + + Future propertyText(String label, String value) async { + await _expandInspectorSection( + 'Advanced', + label, + (w) => w is BusyMarkGroupedTextEntry && w.label == label, + ); + final entry = _inspectorProperty(label); + await _type( + _elements( + (w) => w is TextField && w.controller == entry.controller, + ).single, + value, + ); + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Focused $label saved', + ); + } + + Future reopenSource() async { + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check(await controller.saveActive(), 'Correction saves to buffer'); + _check( + await controller.closeDocumentBuffer(buffer.id), + 'Correction closes document', + ); + await controller.openActiveFile(buffer.filePath!); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Correction Source reopened', + ); + } + + Future sourceCaret(int offset) async { + if (offset < 0) { + throw StateError( + 'Missing authored occurrence in ${ref.read(workspaceControllerProvider).activeBuffer?.displayName}', + ); + } + ((_elements((w) => w is BusyMarkSourceEditor).single as StatefulElement) + .state + as BusyMarkSourceEditorState) + .scrollToOffset(offset); + await _pause(); + await _requestProperties(); + } + + Future showTopicNavigation() => _expandInspectorSection( + 'Topic navigation', + 'Navigation elements', + (w) => + w is BusyMarkComboRow && w.title == 'Navigation elements', + ); + + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + for (final choice in [ + ('all', 'All titled elements'), + ('none', 'No navigation'), + ('default', 'Use default'), + ]) { + await chooseCombo('Navigation elements', choice.$2); + _check( + combo('Navigation elements').selected == choice.$1, + 'Navigation ${choice.$1} selected after native choice', + ); + await reopenSource(); + await sourceCaret(0); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + _check( + combo('Navigation elements').selected == choice.$1, + 'Navigation ${choice.$1} survives save close reopen', + ); + } + await chooseCombo('Navigation elements', 'Custom navigation'); + await propertyText('Navigation elements (for)', 'chapter,def'); + await reopenSource(); + await sourceCaret(0); + await _pause(); + await _requestProperties(); + await _topicProperties(); + await _pause(); + await showTopicNavigation(); + _check( + combo('Navigation elements').selected == 'custom', + 'Custom navigation survives reopening', + ); + await _capture('inspector-navigation-reopened'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector-mappings.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Mapping controls open in Source', + ); + final mappingsPath = ref + .read(workspaceControllerProvider) + .activeBuffer! + .filePath!; + Future mappingCaret(String markup) async { + final state = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + state.scrollToOffset(_source.indexOf(markup) + 2); + await _pause(); + await _requestProperties(); + } + + Future reopenMapping(String markup) async { + await _native('chord', 'ctrl+s'); + await _until( + () => !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Mapping source saved', + ); + _check( + await controller.closeDocumentBuffer( + ref.read(workspaceControllerProvider).activeBuffer!.id, + ), + 'Mapping buffer closes', + ); + await controller.openActiveFile(mappingsPath); + await _native('chord', 'ctrl+shift+2'); + await _pause(); + await mappingCaret(markup); + await _pause(); + } + + await mappingCaret('Ctrl+S'), + 'Shortcut action and help instance survive reopen without replacing literal content', + ); + await _capture('inspector-shortcut-mapping'); + await mappingCaret('
    Guide'), + 'Link target edits href and retains formatted label', + ); + await _capture('inspector-link-target-mapping'); + await _native('chord', 'ctrl+shift+1'); + await _pause(); + await _tap(_field('Press Ctrl+S now.')); + final shortcutField = _field('Press Ctrl+S now.').widget as TextField; + shortcutField.controller!.selection = const TextSelection.collapsed( + offset: 8, + ); + await _pause(); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.link, + 'Ordinary shortcut selection preserves the explicitly inspected link', + ); + await _requestProperties(); + _check( + inspector().inlineTarget?.kind == BusyInlineKind.writersideShortcut && + _elements( + (w) => + w is BusyMarkGroupedTextEntry && w.label == 'Action ID (key)', + ).isNotEmpty, + 'Editor exposes the same shortcut action control', + ); + await _capture('inspector-editor-shortcut-mapping'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/inspector-corrections.md'), + ); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => _elements( + (w) => + w is TextField && w.controller?.text == 'Use {id="literal-code"}', + ).isNotEmpty, + 'Literal inline code Editor loaded', + ); + await _type(_field('Use {id="literal-code"}'), 'Try {id="literal-code"}'); + for (final text in [ + 'See {id="linked-literal"}', + 'Show {id="image-literal"}', + ]) { + await _type(_field(text), text.replaceFirst(RegExp(r'^\w+'), 'Read')); + } + await _native('chord', 'ctrl+s'); + await reopenSource(); + _check( + _source.contains('Try `{id="literal-code"}`'), + 'Ordinary content edit save reopen preserves inline code', + ); + await sourceCaret(_source.indexOf('Try')); + await _pause(); + _check( + inspector().target!.attributes['id'] == null, + 'Literal code is not a paragraph ID', + ); + await _capture('inspector-inline-ownership-reopened'); + for (final reference in [ + ( + identity: 'code-reference', + kind: BusyInlineKind.link, + literal: 'linked-literal', + ), + ( + identity: 'alt-reference', + kind: BusyInlineKind.image, + literal: 'image-literal', + ), + ]) { + await sourceCaret( + _source.indexOf( + 'Read ${reference.kind == BusyInlineKind.link ? '[' : '!['}', + ), + ); + await _pause(); + final target = inspector().target!; + final inline = target.inlines.singleWhere( + (i) => i.kind == reference.kind, + ); + _check( + target.attributes['id'] == null && + inline.attributes['id'] == reference.identity, + 'Reopened ${reference.identity} belongs to its inline reference, not paragraph', + ); + _check( + reference.kind == BusyInlineKind.link + ? inline.children.single.kind == BusyInlineKind.code && + inline.children.single.text == '{id="${reference.literal}"}' + : inline.text == '{id="${reference.literal}"}' && + inline.attributes['width'] == '24', + 'Ordinary content edit save reopen retains ${reference.identity} literal and formatting', + ); + } + await _capture('inspector-linked-code-reopened'); + await sourceCaret(_source.indexOf('Use [') + 1); + await _pause(); + _check( + inspector().target!.attributes['id'] == null && + inspector().target!.inlines.any( + (i) => i.attributes['id'] == 'owned-link', + ), + 'Trailing link attributes belong only to the link', + ); + await sourceCaret(_source.indexOf('- Second')); + await _pause(); + final beforeItemUndo = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + await propertyText('Element anchor (id)', 'saved-item'); + _check( + inspector().target!.attributes['id'] == 'saved-item' && + inspector().path.any((b) => b.attributes['element'] == 'list'), + 'Native item conversion retains inspected item and ancestry', + ); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + beforeItemUndo + 1, + 'Native item property creates exactly one undo operation', + ); + _check( + controller.undoActiveBuffer(), + 'Native item conversion undo succeeds', + ); + await _pause(); + _check( + _source.contains('- Second item') && !_source.contains(' A')); + await _pause(); + await propertyText('Element anchor (id)', 'saved-note'); + await reopenSource(); + await sourceCaret(_source.indexOf('> A')); + await _pause(); + _check( + inspector().target!.attributes['id'] == 'saved-note' && + inspector().target!.attributes['style'] == 'note', + 'Native admonition ID and style survive save close reopen', + ); + _check( + _source.contains('> A **note**.\n\n{'), + 'Admonition attributes use the builder-verified blank-line placement', + ); + await _capture('inspector-admonition-reopened'); + for (final numbered in [ + (text: 'Numbered parent', identity: 'saved-numbered-item', start: '10'), + (text: 'Marker parent', identity: 'saved-marker-item', start: '3'), + ]) { + await sourceCaret(_source.indexOf('3. ${numbered.text}')); + await _pause(); + final original = _source; + final undoCount = ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + await propertyText('Element anchor (id)', numbered.identity); + final converted = _source; + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undoCount + 1, + '${numbered.identity} conversion creates one undo operation', + ); + _check( + controller.undoActiveBuffer(), + '${numbered.identity} undo succeeds', + ); + await _pause(); + _check( + _source == original, + '${numbered.identity} undo restores original markers and authored override', + ); + _check( + controller.redoActiveBuffer(), + '${numbered.identity} redo succeeds', + ); + await _pause(); + _check( + _source == converted, + '${numbered.identity} redo restores converted source', + ); + await reopenSource(); + await sourceCaret(_source.indexOf('
  • b.attributes['element'] == 'list', + ); + final nested = target.children.singleWhere( + (b) => b.attributes['element'] == 'list', + ); + _check( + target.attributes['id'] == numbered.identity && + list.attributes['start'] == numbered.start && + nested.attributes['start'] == '4', + '${numbered.identity} save close reopen preserves outer ${numbered.start} and nested 4', + ); + } + await _capture('inspector-numbered-list-reopened'); + await File( + p.join(widget.output.path, 'inspector-corrections.md'), + ).writeAsString(_source); + await controller.openPath('${widget.root.path}-defaults'); + await controller.openActiveFile( + p.join('${widget.root.path}-defaults', 'topics/properties.topic'), + ); + await _native('chord', 'ctrl+shift+2'); + await _until( + () => _elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + 'Module-default Source document loaded', + ); + final defaultsSource = _source; + final defaultsSourceState = + (_elements((w) => w is BusyMarkSourceEditor).single + as StatefulElement) + .state + as BusyMarkSourceEditorState; + defaultsSourceState.scrollToOffset( + _source.indexOf(' defaultCombo(String title) => + _elements( + (w) => w is BusyMarkComboRow && w.title == title, + ).single.widget + as BusyMarkComboRow; + _check( + defaultCombo('Collapsible (collapsible)').labelFor('') == + 'Use default' && + defaultCombo( + 'Published initial state (default-state)', + ).labelFor('') == + 'Use default', + 'Inspector resolves omitted code properties from module defaults', + ); + _check( + _source == defaultsSource && + !ref.read(workspaceControllerProvider).activeBuffer!.isDirty, + 'Opening effective defaults is source-neutral', + ); + _check( + _elements( + (w) => w is Text && w.data == 'Default: Enabled · Module', + ).isNotEmpty && + _elements( + (w) => w is Text && w.data == 'Default: Expanded · Module', + ).isNotEmpty, + 'Compact default values appear beneath selectors', + ); + _check( + defaultCombo('Collapsible (collapsible)').tooltip == + 'Effective: Enabled — module default' && + _elements( + (w) => + w is Semantics && + w.properties.hint == 'Effective: Enabled — module default', + ).isNotEmpty, + 'Default controls retain complete provenance in help and accessibility', + ); + await _capture('inspector-module-defaults'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Disabled'); + await _native('chord', 'ctrl+s'); + await _pause(); + _check( + _source + .substring( + _source.indexOf('id="code-block-absent"'), + _source.indexOf('>', _source.indexOf('id="code-block-absent"')), + ) + .contains('collapsible="false"'), + 'Explicit false overrides the module collapse default', + ); + await _capture('inspector-module-override'); + await _tap( + _elements( + (w) => + w is BusyMarkComboRow && + w.title == 'Collapsible (collapsible)', + ).single, + ); + _nativeMenuOpen = true; + await _tapLabel('Use default'); + _check( + _source == defaultsSource, + 'Reset removes local override and restores the module default', + ); + await sourceCaret(_source.indexOf(' w is GestureDetector).firstWhere((e) { + Element? ancestor = e; + while (ancestor != null) { + if (ancestor.widget.runtimeType.toString() == + '_ElementPropertiesResizeHandle') { + return true; + } + Element? parent; + ancestor.visitAncestorElements((e) { + parent = e; + return false; + }); + ancestor = parent; + } + return false; + }); + final preferredWidth = ref + .read(appSettingsControllerProvider) + .elementPropertiesWidth; + Focus.of(resize).requestFocus(); + await _pause(); + await _native('chord', 'Left'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == + preferredWidth + 20, + 'Native keyboard increases inspector width', + ); + await _native('chord', 'Right'); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesWidth == + preferredWidth, + 'Native keyboard restores preferred inspector width', + ); + await sourceCaret(_source.indexOf(' w is TextField && w.controller?.text == 'code-block-absent', + ).single, + ); + await _native('chord', 'Tab'); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType< + BusyMarkWritersideProperties + >() != + null, + 'Native Tab traverses inspector controls', + ); + await _tap( + _elements( + (w) => w is BusyMarkHeaderIconButton && w.tooltip == 'Close', + ).singleWhere( + (e) => + e.findAncestorWidgetOfExactType() != + null, + ), + ); + await _until( + () => _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Inspector closes without pending drafts', + ); + _check( + FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() != + null, + 'Closing inspector restores Source editor focus', + ); + await ref + .read(appSettingsControllerProvider.notifier) + .setElementPropertiesVisible(true); + await controller.openPath(widget.root.path); + await controller.openActiveFile(p.join(widget.root.path, 'README.md')); + await _native('chord', 'ctrl+shift+1'); + await _until( + () => + _elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty && + _elements( + (w) => w is TextField && w.controller?.text == 'Plain text', + ).isNotEmpty, + 'Ordinary Markdown Editor available', + ); + _check( + _elements( + (w) => w.key == const ValueKey('wysiwyg-writerside'), + ).isEmpty && + _elements((w) => w is BusyMarkWritersideProperties).isEmpty, + 'Ordinary Markdown has no Writerside controls or properties', + ); + _check( + ref.read(appSettingsControllerProvider).elementPropertiesVisible, + 'Inapplicable document preserves inspector visibility preference', + ); + await _type(_field('Plain text'), 'Edited ordinary text'); + _check( + _source.contains('# Ordinary Markdown') && + _source.contains('Edited ordinary text'), + 'Ordinary Markdown remains visually editable', + ); + _check(await controller.saveActive(), 'Ordinary Markdown save succeeds'); + await _capture('authoring-ordinary-markdown'); + await File(p.join(widget.output.path, 'generated.topic')).writeAsString( + await File( + p.join(widget.root.path, 'topics/authoring.topic'), + ).readAsString(), + ); + await File(p.join(widget.output.path, 'generated.md')).writeAsString( + await File( + p.join(widget.root.path, 'topics/authoring-markdown.md'), + ).readAsString(), + ); + } on Object catch (error, stack) { + failure = error; + stderr.writeln('$error\n$stack'); + await _capture('failure'); + } + await _finish(failure); + } + + Future _finish(Object? failure) async { + if (Platform.environment['BUSYMARK_NATIVE_PROBE'] == '1') { + if (_accessibilitySessionExit != null) { + failure ??= StateError('Native accessibility session exited early'); + } else if (failure == null) { + _checks.add( + 'Native accessibility session remains connected through all input', + ); + } + } + await File(p.join(widget.output.path, 'result.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'fixture': widget.root.path, + 'checks': _checks, + 'failure': failure?.toString(), + }), + ); + exit(failure == null ? 0 : 1); + } +} + +class _Settings implements LocalSettingsStore { + _Settings(this.value); + Map value; + @override + Future> load() async => value; + @override + Future save(Map json) async { + value = json; + } +} diff --git a/tools/fetch_visualization_web.sh b/tools/fetch_visualization_web.sh index 3240c4c4..dfb8e4cf 100755 --- a/tools/fetch_visualization_web.sh +++ b/tools/fetch_visualization_web.sh @@ -30,6 +30,9 @@ SOURCE_FINGERPRINT="$({ "${SOURCE_DIR}/package.json" \ "${SOURCE_DIR}/package-lock.json" \ "${SOURCE_DIR}/render_engines.js" \ + "${SOURCE_DIR}/mermaid_renderer.js" \ + "${SOURCE_DIR}/plantuml_renderer.js" \ + "${SOURCE_DIR}/openapi_renderer.js" \ "${SOURCE_DIR}/mathjax_renderer.js" \ "${SOURCE_DIR}/mermaid_math_disabled.js" \ "${SOURCE_DIR}/build_render_engines.js" \ @@ -44,6 +47,10 @@ VERSION_FINGERPRINT="mermaid=${MERMAID_VERSION};plantuml=${PLANTUML_VERSION};sca if [[ -f "${OUTPUT_DIR}/VERSION" ]] && [[ "$(<"${OUTPUT_DIR}/VERSION")" == "${VERSION_FINGERPRINT}" ]] && [[ -s "${OUTPUT_DIR}/render-engines.js" ]] && + [[ -s "${OUTPUT_DIR}/mermaid-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/plantuml-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/openapi-engine.js" ]] && + [[ -s "${OUTPUT_DIR}/math-engine.js" ]] && [[ -s "${OUTPUT_DIR}/scalar.js" ]] && [[ -s "${OUTPUT_DIR}/viz-global.js" ]] && [[ -s "${OUTPUT_DIR}/harness.html" ]] && @@ -69,6 +76,9 @@ TOOL_DIR="${TEMP_DIR}/tool" mkdir -p -- "${TOOL_DIR}" cp -- "${SOURCE_DIR}/package.json" "${SOURCE_DIR}/package-lock.json" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/render_engines.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/mermaid_renderer.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/plantuml_renderer.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/openapi_renderer.js" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/mathjax_renderer.js" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/build_render_engines.js" "${TOOL_DIR}/" npm ci \ diff --git a/tools/linux_native_menu_probe.py b/tools/linux_native_menu_probe.py index 29939e52..6200af0c 100644 --- a/tools/linux_native_menu_probe.py +++ b/tools/linux_native_menu_probe.py @@ -10,6 +10,7 @@ import json import os import sys +import subprocess import time target_gdk_backend = os.environ.get("GDK_BACKEND") @@ -23,7 +24,7 @@ gi.require_version("Atspi", "2.0") gi.require_version("Gdk", "3.0") -from gi.repository import Atspi, Gdk # noqa: E402 +from gi.repository import Atspi, Gdk, GLib # noqa: E402 def app_for_pid(pid): @@ -83,12 +84,47 @@ def main(): raise RuntimeError("The Wayland probe requires an isolated enclosing X display") pid, command, *args = sys.argv[1:] app = app_for_pid(int(pid)) - if command == "capture": + if command == "hold": + # Keep a client connected for the fixture's whole lifetime. GTK 3.24 + # can free its current key listener when the last short-lived AT-SPI + # client exits during dispatch (GNOME/gtk merge request 10391). + # Accessibility stays enabled, and individual commands still inspect + # and activate the real native controls independently. + loop = GLib.MainLoop() + listener = Atspi.EventListener.new(lambda *_: None) + if not listener.register("object:state-changed"): + raise RuntimeError("Could not retain the native accessibility session") + + def target_alive(): + try: + os.kill(int(pid), 0) + except ProcessLookupError: + loop.quit() + return GLib.SOURCE_REMOVE + return GLib.SOURCE_CONTINUE + + GLib.timeout_add(250, target_alive) + print("ready", flush=True) + loop.run() + listener.deregister("object:state-changed") + elif command == "capture": root = Gdk.get_default_root_window() capture = Gdk.pixbuf_get_from_window(root, 0, 0, root.get_width(), root.get_height()) capture.savev(args[0], "png", [], []) elif command == "key": key(args[0]) + elif command == "chord": + subprocess.run(["xdotool", "key", "--clearmodifiers", args[0]], check=True) + time.sleep(0.3) + elif command == "move-pointer": + subprocess.run(["xdotool", "mousemove", "--sync", str(int(args[0])), str(int(args[1]))], check=True) + time.sleep(0.3) + elif command == "click-pointer": + subprocess.run(["xdotool", "click", "--clearmodifiers", "1"], check=True) + time.sleep(0.3) + elif command == "type": + subprocess.run(["xdotool", "type", "--clearmodifiers", "--", args[0]], check=True) + time.sleep(0.3) elif command == "inspect": print(json.dumps([ {"name": node.get_name(), "role": node.get_role_name(), diff --git a/tools/nextcloud_login_live_acceptance.py b/tools/nextcloud_login_live_acceptance.py index f207be0f..d417a098 100644 --- a/tools/nextcloud_login_live_acceptance.py +++ b/tools/nextcloud_login_live_acceptance.py @@ -13,14 +13,14 @@ from pathlib import Path import requests -from playwright.sync_api import sync_playwright +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright def main(): - if len(sys.argv) != 4: + if len(sys.argv) not in (4, 5): raise SystemExit( "Usage: python3 tools/nextcloud_login_live_acceptance.py " - " " + " [private-BusyMark-portable-link.json]" ) account = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) server = account["server"].rstrip("/") @@ -51,6 +51,7 @@ def main(): external_browser = launched.returncode == 0 except (OSError, subprocess.TimeoutExpired): external_browser = False + portable_checks = None with sync_playwright() as playwright: browser = playwright.chromium.launch( executable_path=os.getenv("BUSYMARK_CHROME_PATH", "/usr/bin/google-chrome"), @@ -60,8 +61,9 @@ def main(): if "BUSYMARK_TEST_TLS_SPKI" in os.environ else []), ) try: - page = browser.new_page() - page.goto(flow["login"], wait_until="networkidle", timeout=60000) + context = browser.new_context() + page = context.new_page() + page.goto(flow["login"], wait_until="domcontentloaded", timeout=60000) if page.locator("#user").count() == 0: page.get_by_role("button", name=re.compile("Log in", re.I)).first.click() page.locator("#user").fill(account["user"]) @@ -93,6 +95,85 @@ def main(): allow_redirects=False, ) assert after.status_code == 404, "Login Flow success was not one-time." + if len(sys.argv) == 5: + published = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8")) + source_url = published["href"].rsplit("/note/", 1)[0] + "/note/" + str(published["sourceId"]) + page.goto(source_url, wait_until="domcontentloaded", timeout=60000) + wizard = page.locator('#firstrunwizard[role=dialog]') + if wizard.count() and wizard.is_visible(): + wizard.get_by_role('button', name=re.compile('close', re.I)).first.click() + wizard.wait_for(state='hidden') + link = page.locator('a[href="' + published["href"] + '"]').filter(has_text="Selected link").first + if link.count() == 0: + preview = page.get_by_role("button", name=re.compile("preview|read.?mode", re.I)) + if preview.count(): + preview.first.click() + link.wait_for(state="visible", timeout=60000) + # The asynchronously loaded first-run wizard can appear after + # the editor link becomes visible on a newly provisioned user. + try: + wizard.wait_for(state="visible", timeout=5000) + except PlaywrightTimeoutError: + pass + else: + wizard.get_by_role('button', name=re.compile('close', re.I)).first.click() + wizard.wait_for(state='hidden') + page.screenshot(path=str(Path(sys.argv[3]).with_suffix(".source.png")), full_page=True) + # Nextcloud's rich editor opens ordinary links through its + # link context menu; keyboard modifiers select editor content. + link.click(button='right') + page.wait_for_timeout(300) + page.screenshot(path=str(Path(sys.argv[3]).with_suffix('.link-menu.png')), full_page=True) + Path(sys.argv[3]).with_suffix('.link-menu.html').write_text(page.content()) + with page.expect_popup(timeout=60000) as popup: + page.get_by_role('button', name='Open link', exact=True).click() + popup.value.wait_for_load_state('domcontentloaded', timeout=60000) + page.wait_for_timeout(1500) + page.screenshot(path=str(Path(sys.argv[3]).with_suffix('.after-click.png')), full_page=True) + Path(sys.argv[3]).with_suffix('.after-click.html').write_text(page.content()) + opened = next((candidate for candidate in page.context.pages if re.search(r"/note/" + str(published["targetId"]) + r"(?:[#?]|$)", candidate.url)), None) + Path(sys.argv[3]).with_suffix('.pages.json').write_text(json.dumps([p.url for p in page.context.pages])) + if opened is None: + probes = [] + for route in ['/index.php/apps/notes/note/', '/apps/notes/note/']: + probe = page.context.new_page() + probe.goto(server + route + str(published['targetId']), wait_until='domcontentloaded', timeout=60000) + probe.wait_for_timeout(1500) + probes.append({'requested': server + route + str(published['targetId']), 'url': probe.url, + 'headings': probe.locator('.ProseMirror h1').all_text_contents()}) + Path(sys.argv[3]).with_suffix('.route-probes.json').write_text(json.dumps(probes,indent=2)) + assert opened is not None, "Stored BusyMark link did not open the intended browser note." + opened.get_by_text(published["marker"], exact=True).first.wait_for(state="visible", timeout=60000) + opened.screenshot(path=str(Path(sys.argv[3]).with_suffix(".target.png")), full_page=True) + browser_url = opened.url + route_checks = {} + for name, route in [("frontController", "/index.php/apps/notes/note/"), ("pretty", "/apps/notes/note/")]: + probe = page.context.new_page() + requested = server + route + str(published["targetId"]) + probe.goto(requested, wait_until="domcontentloaded", timeout=60000) + probe.wait_for_timeout(1500) + route_checks[name] = { + "requested": requested, + "opened": probe.url, + "openedIntendedNote": bool(re.search(r"/note/" + str(published["targetId"]) + r"(?:[#?]|$)", probe.url)) and probe.get_by_text(published["marker"], exact=True).count() > 0, + } + probe.close() + opened.goto(published["href"] + "#heading", wait_until="domcontentloaded", timeout=60000) + opened.get_by_text(published["marker"], exact=True).first.wait_for(state="visible", timeout=60000) + portable_checks = { + "storedBusyMarkLinkOpenedIntendedNote": True, + "generatedHref": published["href"], + "browserNoteUrl": browser_url, + "routes": route_checks, + "browserFragment": opened.evaluate("location.hash"), + "browserHasBusyMarkHeadingId": opened.locator('[id="heading"]').count() > 0, + "browserHeadingIds": opened.locator('.ProseMirror h1, .ProseMirror h2, .ProseMirror h3').evaluate_all('(nodes) => nodes.map(node => node.id)'), + "installationPath": requests.utils.urlparse(server).path, + } + except Exception: + page.screenshot(path=str(Path(sys.argv[3]).with_suffix('.failure.png')), full_page=True) + Path(sys.argv[3]).with_suffix('.failure.html').write_text(page.content()) + raise finally: browser.close() report = { @@ -101,6 +182,7 @@ def main(): "pending404": True, "browserGrant": True, "success200Once": True, + **({"portableNotes": portable_checks} if portable_checks is not None else {}), } Path(sys.argv[3]).write_text(json.dumps(report, indent=2), encoding="utf-8") print("Live browser Login Flow v2 acceptance passed.") diff --git a/tools/nextcloud_notes_desktop_acceptance.dart b/tools/nextcloud_notes_desktop_acceptance.dart new file mode 100644 index 00000000..3f102972 --- /dev/null +++ b/tools/nextcloud_notes_desktop_acceptance.dart @@ -0,0 +1,3091 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/quick_open.dart'; +import 'package:busymark/src/app/startup_path.dart'; +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_settings_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_capabilities.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_secret_store.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/data/server_uri.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/presentation/notes_workspace_ui.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/references/reference_controller.dart'; +import 'package:busymark/src/references/reference_ui.dart'; +import 'package:busymark/src/workspace/recovery_persistence.dart'; +import 'package:busymark/src/workspace/session_persistence.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:uuid/uuid.dart'; +import 'package:window_manager/window_manager.dart'; + +// Reusable native integration target. Production widgets/controllers, private +// profile, one explicitly trusted test CA, and only test-owned server resources. + +enum _Scenario { + full('full'), + workspace('workspace'), + offlineStart('offline-start'), + offlineResume('offline-resume'), + references('references'), + referencesResume('references-resume'), + localReferences('local-references'), + localReferencesResume('local-references-resume'), + writersideReferences('writerside-references'), + recoveryStart('recovery-start'), + recoveryResume('recovery-resume'), + performance('performance'); + + const _Scenario(this.option); + final String option; + + bool get reopensProfile => const { + offlineResume, + referencesResume, + localReferencesResume, + recoveryResume, + performance, + }.contains(this); + + bool get retainsResources => + const {offlineStart, references, recoveryStart}.contains(this); + + bool get endsOffline => const { + offlineStart, + recoveryStart, + referencesResume, + localReferencesResume, + performance, + }.contains(this); +} + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + if (args.length < 4 || args.length > 5) { + stderr.writeln( + 'Usage: PRIVATE_CREDENTIALS TEST_CA PRIVATE_OUTPUT DISPOSABLE_CONTAINER ' + '[${_Scenario.values.map((s) => '--${s.option}').join('|')}]', + ); + exit(64); + } + final option = args.length == 5 ? args[4] : '--full'; + final scenario = _Scenario.values + .where((s) => option == '--${s.option}') + .firstOrNull; + if (scenario == null) { + stderr.writeln('Unknown scenario: $option'); + exit(64); + } + final credentials = jsonDecode(await File(args[0]).readAsString()) as Map; + final output = Directory(args[2]); + await output.create(recursive: true); + final tls = SecurityContext(withTrustedRoots: true) + ..setTrustedCertificates(args[1]); + final transport = _Transport(IOClient(HttpClient(context: tls))); + final server = Uri.parse(credentials['server'] as String); + final login = credentials['loginName'] as String; + final password = credentials['appPassword'] as String; + NextcloudAccount account; + if (scenario.reopensProfile) { + final persisted = await NotesStore.open( + path: '${output.path}/notes.sqlite3', + ); + account = (await persisted.accounts()).singleWhere( + (a) => a.server == server && a.loginName == login, + ); + await persisted.close(); + transport.offline = true; + } else { + final caps = await fetchNotesCapabilities( + client: transport, + server: server, + loginName: login, + appPassword: password, + ); + account = NextcloudAccount( + id: const Uuid().v4(), + server: server, + loginName: login, + appVersion: caps.appVersion, + apiVersion: caps.apiVersion, + ); + } + await windowManager.ensureInitialized(); + await LinuxHeaderBarService.instance.initialize(); + final settings = AppSettings.defaults().copyWith( + localeTag: 'en', + automaticSpelling: false, + autoSave: false, + sidebarVisible: true, + documentViewMode: DocumentViewModePreference.editor, + reopenPreviousWorkspaceOnStartup: false, + confirmCloseWithUnsavedChanges: false, + ); + runApp( + ProviderScope( + overrides: [ + startupPathProvider.overrideWithValue(null), + initialSystemAccentColorProvider.overrideWithValue( + busyMarkDefaultAccentColor, + ), + localSettingsStoreProvider.overrideWithValue( + _Settings(settings.toJson()), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + localHistoryStoreProvider.overrideWithValue( + FileLocalHistoryStore( + rootDirectory: () async => Directory('${output.path}/history'), + ), + ), + nextcloudHttpClientProvider.overrideWithValue(transport), + nextcloudSecretStoreProvider.overrideWithValue( + _Secrets(account.id, password), + ), + nextcloudNotesDatabasePathProvider.overrideWith( + (_) async => '${output.path}/notes.sqlite3', + ), + ], + child: _Harness( + account: account, + transport: transport, + password: password, + output: output, + containerName: args[3], + scenario: scenario, + ), + ), + ); + await windowManager.waitUntilReadyToShow( + const WindowOptions(size: Size(1360, 900), center: true), + () async { + await windowManager.show(); + await windowManager.focus(); + }, + ); +} + +class _HeldWrite { + _HeldWrite(this.status); + final int status; + final started = Completer(); + final release = Completer(); +} + +class _Transport extends http.BaseClient { + _Transport(this.inner); + final http.Client inner; + bool offline = false; + bool rejectNextWriteCredentials = false; + bool throttleNextWrite = false; + _HeldWrite? holdNextWrite; + int writes = 0; + int creates = 0; + int collections = 0; + @override + Future send(http.BaseRequest request) async { + if (request.method == 'GET' && request.url.path.endsWith('/v1/notes')) { + collections++; + } + if (offline) { + throw http.ClientException('Simulated desktop loss of connectivity.'); + } + final creation = + request.method == 'POST' && request.url.path.endsWith('/v1/notes'); + if (creation) creates++; + if (request.method == 'PUT' && request.url.path.contains('/v1/notes/')) { + writes++; + if (rejectNextWriteCredentials) { + rejectNextWriteCredentials = false; + request.headers['Authorization'] = nextcloudAuthorization( + 'invalid-desktop-fixture', + 'invalid-desktop-password', + ); + } + if (throttleNextWrite) { + throttleNextWrite = false; + return Future.value( + http.StreamedResponse( + Stream.value(utf8.encode('{}')), + 429, + headers: {'Retry-After': '5'}, + request: request, + ), + ); + } + } + final held = + creation || + (request.method == 'PUT' && request.url.path.contains('/v1/notes/')) + ? holdNextWrite + : null; + if (held != null) { + holdNextWrite = null; + held.started.complete(); + await held.release.future; + if (held.status == 429) { + // A simulated rejection prevents execution; successful writes use HTTPS. + return http.StreamedResponse( + Stream.value(utf8.encode('{}')), + 429, + headers: {'Retry-After': '5'}, + request: request, + ); + } + } + return inner.send(request); + } + + @override + void close() => inner.close(); +} + +class _Settings implements LocalSettingsStore { + _Settings(this.value); + Map value; + @override + Future> load() async => value; + @override + Future save(Map json) async { + value = json; + } +} + +class _Secrets implements NextcloudSecretStore { + _Secrets(this.id, this.password); + final String id; + String? password; + @override + Future read(String accountId) async => + accountId == id ? password : null; + @override + Future write(String accountId, String value) async { + password = value; + } + + @override + Future delete(String accountId) async { + password = null; + } +} + +class _Harness extends ConsumerStatefulWidget { + const _Harness({ + required this.account, + required this.transport, + required this.password, + required this.output, + required this.containerName, + required this.scenario, + }); + final NextcloudAccount account; + final _Transport transport; + final String password; + final Directory output; + final String containerName; + final _Scenario scenario; + bool get resumeOffline => scenario == _Scenario.offlineResume; + bool get stopOffline => scenario == _Scenario.offlineStart; + bool get correctionsStart => scenario == _Scenario.recoveryStart; + bool get resumeReferences => const { + _Scenario.referencesResume, + _Scenario.localReferencesResume, + }.contains(scenario); + @override + ConsumerState<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends ConsumerState<_Harness> { + final boundary = GlobalKey(); + final checks = {}; + final created = {}; + NotesSettings? original; + late NotesRepository repository; + late NotesApiClient api; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(run())); + } + + @override + Widget build(BuildContext context) => + RepaintBoundary(key: boundary, child: const BusyMarkApp()); + void check(bool result, String name) { + checks[name] = result; + if (!result) throw StateError(name); + } + + Future wait( + bool Function() test, + String name, { + int seconds = 30, + }) async { + final deadline = DateTime.now().add(Duration(seconds: seconds)); + while (!test()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError( + 'Timed out: $name; workspace error: ${ref.read(workspaceControllerProvider).message?.error}', + ); + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } + + List widgets() { + final result = []; + void visit(Element e) { + if (e.widget is T) result.add(e.widget as T); + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return result; + } + + Future label(String label) async { + VoidCallback? action; + await wait(() { + void visit(Element e) { + if (e.widget is Text && (e.widget as Text).data == label) { + e.visitAncestorElements((a) { + if (a.widget is ButtonStyleButton && + (a.widget as ButtonStyleButton).onPressed != null) { + action = (a.widget as ButtonStyleButton).onPressed; + return false; + } + return true; + }); + } + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return action != null; + }, 'action $label'); + action!(); + } + + void edit(TextField field, String value) { + TextEditingController? controller = field.controller; + if (controller == null) { + void visit(Element e) { + if (identical(e.widget, field)) { + void child(Element c) { + if (c.widget is EditableText) { + controller = (c.widget as EditableText).controller; + } + c.visitChildren(child); + } + + e.visitChildren(child); + } + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + } + if (controller == null) throw StateError('Rendered input missing'); + controller!.text = value; + field.onChanged?.call(value); + } + + Future capture(String name) async { + await Future.delayed(const Duration(milliseconds: 600)); + await WidgetsBinding.instance.endOfFrame; + final image = + await (boundary.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + '${widget.output.path}/$name.png', + ).writeAsBytes(data!.buffer.asUint8List()); + } + + Future png(Color color) async { + final recorder = ui.PictureRecorder(); + Canvas( + recorder, + ).drawRect(const Rect.fromLTWH(0, 0, 64, 64), Paint()..color = color); + final picture = recorder.endRecording(); + final image = await picture.toImage(64, 64); + picture.dispose(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); + } + + Future metadataRaces(WorkspaceController workspace) async { + for (final draft in [false, true]) { + for (final status in [200, 429]) { + for (final previous in [true, false]) { + final suffix = '$draft-$status-$previous'; + final a = 'Native race A-$suffix'; + final b = 'Native race B-$suffix'; + final c = 'Native race C-$suffix'; + widget.transport.offline = draft; + final note = await repository.create( + widget.account.id, + title: a, + category: 'Desktop/Remote', + content: 'native race body', + ); + if (!draft) { + await workspace.refreshNextcloudNotes(); + created.add(repository.noteById(note.localId)!.serverId!); + } + widget.transport.offline = true; + await workspace.openNextcloudNote(note.localId); + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-${note.localId}'), + ), + 'held metadata row', + ); + widgets() + .firstWhere( + (t) => t.key == ValueKey('nextcloud-note-${note.localId}'), + ) + .onLongPress!(); + await wait( + () => widgets().any((f) => f.controller?.text == a), + 'held properties dialog', + ); + final snapshot = repository.metadataSnapshot(note.localId); + await repository.patchMetadata(snapshot, title: b); + edit( + widgets().firstWhere((f) => f.controller?.text == a), + c, + ); + final held = _HeldWrite(status); + widget.transport.holdNextWrite = held; + widget.transport.offline = false; + final sending = workspace.refreshNextcloudNotes(); + late int requests; + try { + await Future.any([ + held.started.future, + sending.then((_) => throw StateError('No held UI write.')), + ]); + await label('Save'); + await wait( + () => repository.noteById(note.localId)!.metadataConflict != null, + 'held properties conflict', + ); + requests = widget.transport.writes + widget.transport.creates; + } finally { + held.release.complete(); + } + await sending; + var current = repository.noteById(note.localId)!; + if (current.serverId != null) created.add(current.serverId!); + await workspace.refreshNextcloudNotes(); + check( + current.title == c && + current.metadataConflict?.alternative.title == b && + current.syncState == NoteSyncState.conflict && + widget.transport.writes + widget.transport.creates == requests, + 'nativeHeldResponseBlocksPublication$suffix', + ); + await wait( + () => widgets().any((t) => t.data == 'Conflicts'), + 'held conflict status', + ); + await label('Compare'); + await wait( + () => widgets().any( + (d) => + d.items?.any( + (i) => + i.child is Text && + (i.child as Text).data == 'Previous local edit: $b', + ) == + true, + ), + 'held B/C review', + ); + if (draft && status == 200 && previous) { + await capture('held-post-metadata-review'); + } + await label(previous ? 'Use previous edit' : 'Keep Mine'); + await wait( + () => repository.noteById(note.localId)!.metadataConflict == null, + 'held metadata choice', + ); + final deadline = repository.noteById(note.localId)!.retryNotBefore; + if (deadline != null && DateTime.now().isBefore(deadline)) { + await Future.delayed( + deadline.difference(DateTime.now()) + const Duration(seconds: 1), + ); + } + await workspace.refreshNextcloudNotes(); + current = repository.noteById(note.localId)!; + created.add(current.serverId!); + check( + current.title == (previous ? b : c) && + current.syncState == NoteSyncState.synced && + (await api.get(current.serverId!)).title == current.title, + 'nativeHeldReviewedChoiceSynchronizes$suffix', + ); + } + } + } + } + + Future everydayJourney(WorkspaceController workspace) async { + // Actual production widgets are used for navigation, search, batch review, + // offline status, recovery and import review. Provider calls supply fixtures + // or selected paths where an OS file chooser is otherwise interactive. + late NextcloudNote draft; + if (widget.resumeOffline) { + final state = + jsonDecode( + await File( + '${widget.output.path}/offline-journey.json', + ).readAsString(), + ) + as Map; + draft = repository.noteById(state['localId'] as String)!; + created.addAll((state['ownedServerIds'] as List).cast()); + check( + draft.accountId == widget.account.id && + draft.hasPendingChanges && + draft.content.contains('busymark-attachment:'), + 'processRestartPendingIdentityAndText', + ); + final available = await repository.attachmentAvailability(draft.localId); + check( + available.required.isNotEmpty && + available.available.length == available.required.length && + available.external.isEmpty, + 'processRestartPendingAttachmentBytes', + ); + await workspace.openNextcloudNote(draft.localId); + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-${draft.localId}'), + ), + 'restarted pending note row', + ); + check( + widget.transport.offline && widget.transport.writes == 0, + 'workspaceRestartBeforeAnyPublication', + ); + final offline = await ref.read( + notesOfflineProvider(widget.account.id).future, + ); + check( + offline.categoryRetained('Workspace'), + 'processRestartCategoryRequirement', + ); + await ref.read(localHistoryControllerProvider.notifier).refresh(); + check( + ref.read(localHistoryControllerProvider).snapshot.documents.isNotEmpty, + 'processRestartRetainedHistory', + ); + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + if (mode != DocumentViewModePreference.source) { + await wait( + () => widgets().any((i) => i.image is FileImage), + 'restart media ${mode.name}', + ); + } + await capture('workspace-restart-${mode.name}'); + } + await capture('workspace-pending-process-restart'); + } else { + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'All notes', + ), + 'all notes destination', + ); + widgets() + .firstWhere( + (t) => t.title is Text && (t.title as Text).data == 'All notes', + ) + .onTap!(); + widget.transport.offline = false; + final categoryFixture = await repository.create( + widget.account.id, + title: 'Workspace category fixture', + category: 'Workspace/子/Sub', + ); + await repository.synchronize(widget.account.id); + created.add(repository.noteById(categoryFixture.localId)!.serverId!); + final navigation = NotesNavigationController() + ..update(repository.notes, widget.account.id); + final categoryIndex = + navigation.categoryPaths.indexOf('Workspace/子/Sub') + 4; + await Future.delayed(const Duration(milliseconds: 200)); + void scrollCategory(Element element) { + if (element is StatefulElement && element.state is ScrollableState) { + final position = (element.state as ScrollableState).position; + if (position.axis == Axis.vertical && + position.viewportDimension == 240) { + position.jumpTo( + (categoryIndex * 36.0).clamp(0, position.maxScrollExtent), + ); + } + } + element.visitChildren(scrollCategory); + } + + WidgetsBinding.instance.rootElement?.visitChildren(scrollCategory); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'Sub', + ), + 'nested category navigation', + ); + widgets() + .firstWhere((t) => t.title is Text && (t.title as Text).data == 'Sub') + .onTap!(); + final previousId = ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId; + await label('New note'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId != + previousId, + 'nested filtered creation', + ); + final draftId = ref + .read(workspaceControllerProvider) + .activeBuffer! + .remoteNote! + .localId; + check( + repository.noteById(draftId)!.category == 'Workspace/子/Sub', + 'nativeNestedFilteredCreation', + ); + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-$draftId'), + ), + 'new note row', + ); + widgets() + .firstWhere((t) => t.key == ValueKey('nextcloud-note-$draftId')) + .onLongPress!(); + await wait( + () => + widgets().any((f) => f.decoration?.labelText == 'Title'), + 'new note properties', + ); + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Title', + ), + 'Workspace desktop café', + ); + await label('Save'); + await wait( + () => repository.noteById(draftId)!.title == 'Workspace desktop café', + 'reviewed title', + ); + await workspace.refreshNextcloudNotes(); + draft = repository.noteById(draftId)!; + created.add(draft.serverId!); + widget.transport.offline = true; + await workspace.openNextcloudNote(draft.localId); + workspace.updateActiveText( + '# Everyday workspace\n\nalpha beta café 中文 foo_bar\n', + ); + final attachment = await repository.addAttachment( + draft.localId, + filename: 'café%20.png', + bytes: await png(Colors.green), + ); + workspace.updateActiveText( + '${ref.read(workspaceControllerProvider).activeText}\n\n![image](${attachment.reference})\n\n', + ); + check(await workspace.saveActive(), 'workspaceOfflineEditorMediaSave'); + final freshStore = await NotesStore.open(path: repository.store.path); + final persisted = (await freshStore.notes()).firstWhere( + (n) => n.localId == draft.localId, + ); + check( + persisted.content.contains(attachment.reference) && + (await freshStore.attachments(draft.localId)).isNotEmpty, + 'workspaceRestartDurableTextAndMedia', + ); + await freshStore.close(); + await capture('workspace-sidebar'); + if (widget.stopOffline) { + final offline = await ref.read( + notesOfflineProvider(widget.account.id).future, + ); + await offline.setRequirement('category', 'Workspace'); + await File('${widget.output.path}/offline-journey.json').writeAsString( + jsonEncode({ + 'localId': draft.localId, + 'ownedServerIds': created.toList(), + }), + flush: true, + ); + return; + } + } + final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + await wait( + () => widgets().isNotEmpty, + 'quick open dialog', + ); + widgets() + .firstWhere((f) => f.decoration?.labelText == 'Quick Open') + .onChanged!('Workspace desktop'); + await wait( + () => widgets().any( + (t) => + t.title is Text && + (t.title as Text).data == draft.title && + t.onTap != null, + ), + 'quick open results', + ); + await capture('workspace-quick-open'); + final quickTile = widgets().lastWhere( + (t) => + t.title is Text && + (t.title as Text).data == draft.title && + t.onTap != null, + ); + quickTile.onTap!(); + await quick; + check( + ref.read(workspaceControllerProvider).activeBuffer?.remoteNote?.localId == + draft.localId, + 'workspaceQuickOpenStableIdentity', + ); + final search = showNotesSearch( + rootNavigatorKey.currentContext!, + ref, + query: '"alpha beta" category:Workspace', + ); + await wait( + () => widgets().isNotEmpty, + 'notes search dialog', + ); + await wait( + () => widgets().any( + (t) => + t.title is Text && + (t.title as Text).data == draft.title && + t.trailing is IconButton, + ), + 'indexed result', + ); + await capture('workspace-search'); + final result = widgets().lastWhere( + (t) => + t.title is Text && + (t.title as Text).data == draft.title && + t.trailing is IconButton, + ); + (result.trailing! as IconButton).onPressed!(); + await search; + check( + ref.read(appSettingsControllerProvider).documentViewMode == + DocumentViewModePreference.source, + 'workspaceExplicitSourceLocation', + ); + final seed = await repository.create( + widget.account.id, + title: 'Workspace second', + category: 'Workspace/子/Sub', + content: 'alpha beta second', + ); + final batch = showNotesBatch(rootNavigatorKey.currentContext!, ref, [ + draft.localId, + seed.localId, + ], initialAction: 'move'); + await wait( + () => widgets().any( + (f) => f.decoration?.labelText == 'Category', + ), + 'batch category input', + ); + widgets() + .firstWhere((f) => f.decoration?.labelText == 'Category') + .onChanged!('Workspace/Moved/子'); + await capture('workspace-batch-review'); + await label('Save'); + await wait( + () => + repository.noteById(draft.localId)!.category == 'Workspace/Moved/子' && + repository.noteById(seed.localId)!.category == 'Workspace/Moved/子', + 'batch durability', + ); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await batch; + check(true, 'workspaceReviewedBatchOrganize'); + widget.transport.offline = false; + await workspace.refreshNextcloudNotes(); + for (final id in [draft.localId, seed.localId]) { + created.add(repository.noteById(id)!.serverId!); + } + final offline = await ref.read( + notesOfflineProvider(widget.account.id).future, + ); + await offline.setRequirement('category', 'Workspace'); + await offline.reconcile(); + check( + (await offline.inspect(repository.noteById(draft.localId)!)).available, + 'workspaceCategoryOfflineCompleteness', + ); + final status = showNotesOffline( + rootNavigatorKey.currentContext!, + ref, + repository.noteById(draft.localId)!, + ); + await wait( + () => widgets().any((t) => t.data == 'Make available offline'), + 'offline status dialog', + ); + await capture('workspace-offline-status'); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await status; + widget.transport.offline = true; + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await workspace.openNextcloudNote(draft.localId); + if (mode != DocumentViewModePreference.source) { + await wait( + () => widgets().any((i) => i.image is FileImage), + 'offline retained image ${mode.name}', + ); + } + } + check(true, 'workspaceDisconnectReopenRetainedMediaAllViews'); + widget.transport.offline = false; + await workspace.deleteNextcloudNote(draft.localId); + created.remove(repository.noteById(draft.localId)!.serverId); + final recover = showNotesRecovery( + rootNavigatorKey.currentContext!, + ref, + repository.noteById(draft.localId)!, + ); + await wait( + () => widgets().isNotEmpty, + 'recovery preview', + ); + await capture('workspace-recovery'); + await label('Recovery: New note'); + await recover; + final recoveredId = ref + .read(workspaceControllerProvider) + .activeBuffer! + .remoteNote! + .localId; + check(recoveredId != draft.localId, 'workspaceRecoveryNewIdentity'); + await workspace.refreshNextcloudNotes(); + created.add(repository.noteById(recoveredId)!.serverId!); + final snapshot = await workspace.captureNextcloudExport({ + recoveredId, + seed.localId, + }); + final cancellation = NotesTransferCancellation(); + final exportDialog = showBusyMarkModalDialog( + rootNavigatorKey.currentContext!, + builder: (_) => NotesTransferProgressDialog( + title: 'Export notes', + operation: (cancel, progress) async { + final exported = await NotesTransferService(repository) + .exportSnapshot( + notes: snapshot!, + destination: widget.output.path, + cancellation: cancel, + onProgress: progress, + ); + check(exported.complete, 'workspacePortableExportComplete'); + return [exported.path]; + }, + ), + ); + await wait( + () => widgets().isNotEmpty, + 'export progress', + ); + await wait( + () => widgets().isEmpty, + 'export complete', + ); + await capture('workspace-export'); + await label('Close'); + await exportDialog; + final exported = widget.output.listSync().whereType().firstWhere( + (d) => d.path.contains('BusyMark-notes-'), + ); + final review = await NotesTransferService( + repository, + ).review(exported.path, widget.account.id); + final importReview = showBusyMarkModalDialog( + rootNavigatorKey.currentContext!, + builder: (_) => NotesImportReviewDialog(review: review), + ); + await wait( + () => widgets().isNotEmpty, + 'import review', + ); + await capture('workspace-import-review'); + await label('Import notes'); + check(await importReview == true, 'workspaceImportReviewAccepted'); + final imported = await NotesTransferService( + repository, + ).importReviewed(review, widget.account.id, cancellation: cancellation); + check( + imported.every((i) => i.noteId != null), + 'workspaceImportDurableDistinctIdentities', + ); + await workspace.refreshNextcloudNotes(); + for (final item in imported) { + created.add(repository.noteById(item.noteId!)!.serverId!); + } + check( + imported.every( + (i) => + repository.noteById(i.noteId!)!.syncState == NoteSyncState.synced, + ), + 'workspaceImportedMediaSynced', + ); + if (widget.resumeOffline) { + await navigationJourney(workspace, repository.noteById(recoveredId)!); + } + } + + Future treeAction( + String name, { + String? menu, + bool expand = false, + }) async { + Element? element; + await wait(() { + void visit(Element e) { + if (e.widget.runtimeType.toString() == '_SidebarTreeRow' && + (e.widget as dynamic).title.toString().contains(name)) { + element = e; + } + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return element != null; + }, 'tree row $name'); + final dynamic row = element!.widget; + if (expand) { + if (row.expanded == false) row.onTap(); + return; + } + if (menu == null) { + row.onTap(); + return; + } + row.onMenuRequested(element!, const Offset(230, 180)); + await Future.delayed(const Duration(milliseconds: 250)); + await nativeShortcut('home', focusWindow: false); + final index = menu == 'Rename' || menu == 'Rename Topic File' + ? 1 + : menu == 'Cut' + ? 2 + : menu == 'Paste' + ? 3 + : throw StateError('Unexpected menu'); + for (var i = 0; i < index; i++) { + await nativeShortcut('down', focusWindow: false); + } + await nativeShortcut('enter', focusWindow: false); + await Future.delayed(const Duration(milliseconds: 300)); + await WidgetsBinding.instance.endOfFrame; + } + + Future localReferenceJourney(WorkspaceController workspace) async { + final root = Directory('${widget.output.path}/local'); + if (widget.resumeReferences) { + await workspace.openFolder(root.path); + await workspace.openActiveFile('${root.path}/source.md'); + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tapAuthoredLink('Selected link'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${root.path}/new/renamed.md', + 'local link after process restart', + ); + await wait( + () => widgets().any((i) => i.image is FileImage), + 'local media after process restart', + ); + await capture('references-local-process-restart'); + check(true, 'referencesLocalLinksAndMediaAfterProcessRestart'); + return; + } + await root.create(recursive: true); + await Directory('${root.path}/old').create(); + await Directory('${root.path}/new').create(); + await Directory('${root.path}/.drafts').create(); + await File( + '${root.path}/.drafts/hidden.md', + ).writeAsString('[Hidden source](../old/target.md#actual-heading)\n'); + await File('${root.path}/media.png').writeAsBytes(await png(Colors.green)); + await File('${root.path}/old/target.md').writeAsString( + '# References local target\n\n## Actual heading\n\n![Media](../media.png)\n', + ); + await File( + '${root.path}/source.md', + ).writeAsString('# Local source\n\nSelected link\n'); + await workspace.openFolder(root.path); + await workspace.openActiveFile('${root.path}/source.md'); + workspace.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await chooseAuthoredLink('Actual heading', 'old/target.md#actual-heading'); + await workspace.saveActive(); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.references); + await wait( + () => + widgets().isNotEmpty && + widgets().any( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('Resolved'), + ), + 'local resolved outgoing references', + ); + await capture('references-local-references'); + widgets() + .firstWhere( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('Resolved'), + ) + .onTap!(); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${root.path}/old/target.md', + 'local reference navigation', + ); + await nativeShortcut('ctrl+1'); + await treeAction('old', expand: true); + await treeAction('target.md', menu: 'Rename'); + await wait( + () => widgets().any((f) => f.controller?.text == 'target.md'), + 'ordinary rename input', + ); + edit( + widgets().firstWhere((f) => f.controller?.text == 'target.md'), + 'renamed.md', + ); + await label('Rename'); + await wait( + () => widgets().any( + (t) => t.data?.contains('target.md#actual-heading') == true, + ), + 'exact destination review', + ); + await capture('references-refactoring-review'); + await label('Apply'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${root.path}/old/renamed.md', + 'reviewed rename publication', + ); + await treeAction('renamed.md', menu: 'Cut'); + await treeAction('new', menu: 'Paste'); + await wait( + () => widgets().any( + (t) => t.data?.contains('old/renamed.md') == true, + ), + 'move link review', + ); + await capture('references-move-review'); + await label('Apply'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${root.path}/new/renamed.md', + 'ordinary reviewed move', + ); + check( + (await File( + '${root.path}/source.md', + ).readAsString()).contains('new/renamed.md#actual-heading') && + (await File( + '${root.path}/new/renamed.md', + ).readAsString()).contains('../media.png') && + (await File( + '${root.path}/.drafts/hidden.md', + ).readAsString()).contains('../new/renamed.md#actual-heading'), + 'referencesOrdinaryRenameMoveIncomingOutgoingMedia', + ); + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await capture('references-local-${mode.name}'); + } + await workspace.openFolder(root.path); + await workspace.openActiveFile('${root.path}/source.md'); + check( + ref + .read(workspaceControllerProvider) + .activeText + .contains('new/renamed.md#actual-heading'), + 'referencesLocalWorkspaceReopenLinks', + ); + } + + Future writersideReferenceJourney(WorkspaceController workspace) async { + final root = Directory('${widget.output.path}/writerside'); + await root.create(recursive: true); + final copied = await Process.run('cp', [ + '-a', + 'test/fixtures/writerside/reference_conformance/.', + root.path, + ]); + if (copied.exitCode != 0) throw StateError('Fixture copy failed'); + await workspace.openFolder(root.path); + await workspace.openActiveFile('${root.path}/topics/target.topic'); + workspace.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await chooseAuthoredLink( + 'Explicit heading', + 'source.md#explicit-heading', + selected: 'Element destination.', + ); + await workspace.saveActive(); + check( + (await File('${root.path}/topics/target.topic').readAsString()).contains( + 'Element destination.', + ), + 'referencesWritersideXmlChooserSemanticLink', + ); + await workspace.openActiveFile('${root.path}/topics/source.md'); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.references); + await wait( + () => + widgets().isNotEmpty && + ref + .read(referenceIndexProvider) + .value + ?.references + .any( + (r) => r.authored.destination == 'target.topic#actual-id', + ) == + true, + 'Writerside element references', + ); + await capture('references-writerside-references'); + await nativeShortcut('ctrl+1'); + await treeAction('topics', expand: true); + await treeAction('target.topic', menu: 'Rename Topic File'); + await wait( + () => + widgets().any((f) => f.controller?.text == 'target.topic'), + 'Writerside rename dialog', + ); + edit( + widgets().firstWhere( + (f) => f.controller?.text == 'target.topic', + ), + 'renamed.topic', + ); + await label('Preview'); + await wait( + () => widgets().any((t) => t.data == 'Rename Preview'), + 'established Writerside rename preview', + ); + await capture('references-writerside-refactoring'); + await label('Do Refactor'); + await wait( + () => + File('${root.path}/topics/renamed.topic').existsSync() && + File( + '${root.path}/topics/source.md', + ).readAsStringSync().contains('[target]: renamed.topic#actual-id') && + !ref.read(workspaceControllerProvider).isLoading, + 'Writerside reviewed topic publication', + ); + final source = await File('${root.path}/topics/source.md').readAsString(); + check( + source.contains('[target]: renamed.topic#actual-id') && + source.contains('from="renamed.topic"') && + source.contains('`[Code exclusion](missing.topic)`'), + 'referencesWritersideEstablishedReviewedRenameAndSourcePreservation', + ); + await writersideContextJourney(workspace); + } + + Future writersideContextJourney(WorkspaceController workspace) async { + final root = Directory('${widget.output.path}/writerside-contexts'); + Future write(String path, String source) async { + final file = File('${root.path}/$path'); + await file.parent.create(recursive: true); + await file.writeAsString(source); + } + + for (final module in ['main', 'shared']) { + await write( + '$module/writerside.cfg', + '' + '', + ); + for (final instance in ['guide', 'beta']) { + final topic = module == 'main' ? 'source' : 'target'; + await write( + '$module/$instance.tree', + '' + '' + '${module == 'main' ? '' : ''}' + '', + ); + } + } + await write( + 'main/topics/source.topic', + '

    ' + 'Element' + 'Ambiguous

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

    Target

    ' + '

    Reusable origin

    ', + ); + for (final directory in ['first', 'nested']) { + await write( + 'shared/topics/$directory/dup.topic', + '

    Duplicate

    ', + ); + } + final sourcePath = '${root.path}/main/topics/source.topic'; + final targetPath = '${root.path}/shared/topics/target.topic'; + await workspace.openFolder(root.path); + await workspace.openActiveFile(sourcePath); + workspace.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await nativeShortcut('ctrl+2'); + await wait( + () => widgets>().any( + (w) => w.key == const ValueKey('writerside-instance-selector'), + ), + 'production instance selector', + ); + final guideSelector = widgets>().singleWhere( + (w) => w.key == const ValueKey('writerside-instance-selector'), + ); + guideSelector.onSelected('${root.path}/main/guide.tree'); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.references); + await wait( + () => + widgets().any( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains( + 'Unavailable in this context', + ), + ) && + widgets().any( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('Ambiguous'), + ), + 'conditional include and cross-module ambiguity', + ); + await capture('references-writerside-guide-unavailable'); + widgets() + .firstWhere( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('target.topic#explicit'), + ) + .onTap!(); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + targetPath, + 'cross-module element UI navigation', + ); + await workspace.openActiveFile(sourcePath); + await nativeShortcut('ctrl+2'); + await wait( + () => widgets>().any( + (w) => w.key == const ValueKey('writerside-instance-selector'), + ), + 'instance selection after reference navigation', + ); + widgets>() + .singleWhere( + (w) => w.key == const ValueKey('writerside-instance-selector'), + ) + .onSelected('${root.path}/main/beta.tree'); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.references); + await wait( + () => widgets().any( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('piece\nResolved'), + ), + 'beta include available in References', + ); + await capture('references-writerside-beta-include'); + widgets() + .firstWhere( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data!.contains('piece\nResolved'), + ) + .onTap!(); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + targetPath, + 'include origin UI navigation', + ); + await nativeShortcut('ctrl+2'); + await wait( + () => widgets>().any( + (w) => w.key == const ValueKey('writerside-module-selector'), + ), + 'production module selector', + ); + widgets>() + .singleWhere( + (w) => w.key == const ValueKey('writerside-module-selector'), + ) + .onSelected('shared'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .workspace + ?.writersideProject + ?.activeModuleId == + 'shared', + 'shared module UI context', + ); + await capture('references-writerside-shared-module'); + check(true, 'referencesCrossModuleElementIncludeAndInstanceUiContexts'); + } + + Future chooseAuthoredLink( + String search, + String destinationLabel, { + String selected = 'Selected link', + }) async { + await wait( + () => widgets().isNotEmpty, + 'source editor for link authoring', + ); + final editor = + (widgets().single.key as GlobalKey).currentState! + as BusyMarkSourceEditorState; + final source = ref.read(workspaceControllerProvider).activeText; + final start = source.indexOf(selected); + editor.scrollToOffset(start, updateSelection: false); + await WidgetsBinding.instance.endOfFrame; + final editable = widgets().firstWhere( + (e) => e.controller.text == source, + ); + editable.controller.selection = TextSelection( + baseOffset: start, + extentOffset: start + selected.length, + ); + editable.focusNode.requestFocus(); + await nativeShortcut('ctrl+k'); + await wait( + () => widgets().isNotEmpty, + 'production link chooser', + ); + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Quick Open', + ), + search, + ); + await wait( + () => widgets().any( + (t) => + t.subtitle is Text && (t.subtitle as Text).data == destinationLabel, + ), + 'resolved target chooser row', + ); + await capture('references-target-chooser'); + widgets() + .firstWhere( + (t) => + t.subtitle is Text && + (t.subtitle as Text).data == destinationLabel, + ) + .onTap!(); + await wait( + () => widgets().isEmpty, + 'chooser accepted', + ); + } + + Future tapAuthoredLink(String text) async { + VoidCallback? action; + void spans(InlineSpan span) { + if (span is TextSpan) { + if (span.toPlainText() == text && + span.recognizer is TapGestureRecognizer) { + action = (span.recognizer! as TapGestureRecognizer).onTap; + } + for (final child in span.children ?? const []) { + spans(child); + } + } + } + + await wait(() { + for (final rich in widgets()) { + spans(rich.text); + } + return action != null; + }, 'rendered link gesture'); + action!(); + } + + Future referenceJourney(WorkspaceController workspace) async { + if (widget.resumeReferences) { + final saved = + jsonDecode( + await File( + '${widget.output.path}/portable-browser.json', + ).readAsString(), + ) + as Map; + final source = repository.noteById(saved['sourceLocalId'] as String)!; + final target = repository.noteById(saved['targetLocalId'] as String)!; + created.addAll((saved['ownedServerIds'] as List).cast()); + await workspace.openNextcloudNote(source.localId); + final browserUrl = saved['browserNoteUrl']; + if (browserUrl is String) { + workspace.updateActiveText( + '${source.content}\n[Browser returned]($browserUrl)\n', + ); + } + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tapAuthoredLink( + browserUrl is String ? 'Browser returned' : 'Selected link', + ); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + target.localId, + 'offline ID navigation after process restart', + ); + check( + widget.transport.offline && + ref + .read(workspaceControllerProvider) + .activeText + .contains(saved['marker'] as String), + 'referencesCachedOfflineLinkAfterProcessRestart', + ); + await capture('references-offline-restart'); + if (browserUrl is String) { + check(true, 'referencesActualBrowserCopiedUrlInternalNavigation'); + } + return; + } + final marker = 'References intended target ${const Uuid().v4()}'; + final target = await repository.create( + widget.account.id, + title: 'References portable target', + category: 'References/Original', + content: '# References target\n\n$marker\n\n## Heading\n', + ); + final source = await repository.create( + widget.account.id, + title: 'References portable source', + category: 'References/Original', + content: '# References source\n\nSelected link\n', + ); + await repository.synchronize(widget.account.id); + final targetTitle = repository.noteById(target.localId)!.title; + final sourceTitle = repository.noteById(source.localId)!.title; + final targetId = repository.noteById(target.localId)!.serverId!; + created.add(targetId); + created.add(repository.noteById(source.localId)!.serverId!); + final pretty = await repository.confirmNoteWebRoute(widget.account.id); + final href = nextcloudNoteUrl( + widget.account.server, + targetId, + pretty: pretty, + ).toString(); + await workspace.openNextcloudNote(source.localId); + workspace.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await chooseAuthoredLink('References portable target', href); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('[Selected link]($href)'), + 'authored portable Markdown', + ); + await workspace.saveActive(); + await repository.synchronize(widget.account.id); + final stored = await api.get( + repository.noteById(source.localId)!.serverId!, + ); + check( + stored.content.contains('[Selected link]($href)'), + 'referencesProductionChooserStoredPortableLink', + ); + workspace.updateActiveEditorMode(DocumentViewModePreference.editor); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.editor); + await wait( + () => + widgets().isNotEmpty && + widgets().any( + (e) => e.controller.text == 'Selected link', + ), + 'Editor link selection', + ); + final editable = widgets().firstWhere( + (e) => e.controller.text == 'Selected link', + ); + editable.controller.selection = const TextSelection( + baseOffset: 0, + extentOffset: 13, + ); + editable.focusNode.requestFocus(); + await nativeShortcut('ctrl+k'); + await wait( + () => widgets().isNotEmpty, + 'Editor target chooser', + ); + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Quick Open', + ), + 'References portable target', + ); + await wait( + () => widgets().any( + (t) => t.subtitle is Text && (t.subtitle as Text).data == href, + ), + 'Editor target row', + ); + widgets() + .firstWhere( + (t) => t.subtitle is Text && (t.subtitle as Text).data == href, + ) + .onTap!(); + await wait( + () => widgets().isEmpty, + 'Editor chooser completion', + ); + check( + ref.read(workspaceControllerProvider).activeText.contains(href), + 'referencesEditorLinkChooserPreservesSelection', + ); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.references); + await wait( + () => + widgets().isNotEmpty && + widgets().any((t) => t.data == targetTitle), + 'production References outgoing panel', + ); + await capture('references-references-outgoing'); + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tapAuthoredLink('Selected link'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + target.localId, + 'rendered HTTPS link internal navigation', + ); + widgets>() + .firstWhere((w) => w.segments.any((s) => s.value == true)) + .onSelectionChanged!({true}); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == sourceTitle, + ), + 'incoming reference', + ); + await capture('references-references-incoming'); + check(true, 'referencesRenderedLinkAndIncomingReferences'); + await nativeShortcut('ctrl+1'); + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-${target.localId}'), + ), + 'target properties row', + ); + widgets() + .firstWhere( + (t) => t.key == ValueKey('nextcloud-note-${target.localId}'), + ) + .onLongPress!(); + await wait( + () => widgets().any((f) => f.controller?.text == targetTitle), + 'target properties dialog', + ); + edit( + widgets().firstWhere((f) => f.controller?.text == targetTitle), + 'References renamed target', + ); + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Category', + ), + 'References/Moved', + ); + await label('Save'); + await repository.synchronize(widget.account.id); + check( + repository.noteById(target.localId)!.serverId == targetId && + (await api.get(targetId)).category == 'References/Moved', + 'referencesTitleCategoryDoNotRetargetLink', + ); + await workspace.openNextcloudNote(source.localId); + workspace.updateActiveText('${stored.content}\n[Browser pasted]($href)\n'); + await workspace.saveActive(); + await repository.synchronize(widget.account.id); + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tapAuthoredLink('Browser pasted'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + target.localId, + 'pasted browser note URL navigation', + ); + check(true, 'referencesPastedBrowserRouteNavigatesInternally'); + await File('${widget.output.path}/portable-browser.json').writeAsString( + jsonEncode({ + 'sourceLocalId': source.localId, + 'targetLocalId': target.localId, + 'sourceId': repository.noteById(source.localId)!.serverId, + 'targetId': targetId, + 'href': href, + 'ownedServerIds': created.toList(), + 'marker': marker, + }), + ); + widget.transport.offline = true; + await workspace.openNextcloudNote(source.localId); + await workspace.openNextcloudNote(source.localId); + widget.transport.offline = false; + final originalSource = ref.read(workspaceControllerProvider).activeText; + workspace.updateActiveText( + '$originalSource\n[Missing target](${nextcloudNoteUrl(widget.account.server, 99999999, pretty: pretty)})', + ); + final unsavedSource = ref.read(workspaceControllerProvider).activeText; + if (!ref.read(workspaceControllerProvider).activeBuffer!.isDirty) { + throw StateError('Missing-target fixture must start with a dirty source'); + } + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tapAuthoredLink('Missing target'); + await wait( + () => ref.read(workspaceControllerProvider).message?.error != null, + 'explicit missing linked-note error', + ); + check( + ref.read(workspaceControllerProvider).activeBuffer?.remoteNote?.localId == + source.localId && + ref.read(workspaceControllerProvider).activeText == unsavedSource && + (ref.read(workspaceControllerProvider).activeBuffer!.isDirty || + (repository.noteById(source.localId)!.content == unsavedSource && + repository.noteById(source.localId)!.hasPendingChanges)), + 'referencesMissingTargetPreservesDirtySource', + ); + await capture('references-missing-target-failure'); + workspace.updateActiveText(originalSource); + widget.transport.offline = true; + await capture('references-offline-ready'); + } + + Future correctionSearch(String query, List counts) async { + final searching = showNotesSearch( + rootNavigatorKey.currentContext!, + ref, + account: widget.account.id, + query: query, + ); + for (final count in counts) { + await wait( + () => widgets().any( + (l) => + l.key == const ValueKey('notes-search-results') && + l.childrenDelegate.estimatedChildCount == count, + ), + 'correction results $count', + ); + final list = widgets().singleWhere( + (l) => l.key == const ValueKey('notes-search-results'), + ); + final delegate = list.childrenDelegate as SliverChildBuilderDelegate; + final keys = {}; + for (var i = 0; i < count; i++) { + final row = + delegate.builder(rootNavigatorKey.currentContext!, i)! as ListTile; + keys.add((row.key! as ValueKey).value); + } + check(keys.length == count, 'correctionSearchExactIdentities$count'); + await capture('correction-search-$count'); + if (count != counts.last) await label('Show more results'); + } + check( + !widgets().any((t) => t.data == 'Show more results'), + 'correctionSearchNoHiddenFinalOccurrences${counts.last}', + ); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await searching; + await wait( + () => widgets().isEmpty, + 'correction search route closed', + ); + } + + Future correctionReview( + NotesImportReview review, { + String? category, + }) async { + final dialog = showBusyMarkModalDialog( + rootNavigatorKey.currentContext!, + builder: (_) => NotesImportReviewDialog(review: review), + ); + await wait( + () => widgets().isNotEmpty, + 'correction import review', + ); + if (category != null) { + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Category', + ), + category, + ); + } + await capture('correction-import-${category == null ? 'resume' : 'new'}'); + await label('Import notes'); + check( + await dialog == true, + 'correctionImportReviewAccepted${category == null ? 'Resume' : 'New'}', + ); + } + + Future correctionRecovery(String id, {required bool recover}) async { + bool recoveryHit(ListTile tile) => + tile.key is ValueKey<(String, String, int?, int?)> && + (tile.key! as ValueKey<(String, String, int?, int?)>).value.$1 == + 'notes-search-hit' && + (tile.key! as ValueKey<(String, String, int?, int?)>).value.$2 == id; + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'Recovery', + ), + 'correction Recovery destination', + ); + widgets() + .firstWhere( + (t) => t.title is Text && (t.title as Text).data == 'Recovery', + ) + .onTap!(); + await Future.delayed(const Duration(milliseconds: 200)); + final active = ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId; + widgets() + .firstWhere((f) => f.readOnly && f.decoration?.hintText == 'Search') + .onTap!(); + await wait( + () => widgets().isNotEmpty, + 'correction Recovery Search', + ); + check( + widgets().single.recovery, + 'correctionRecoverySearchExplicitScope', + ); + edit( + widgets().firstWhere( + (f) => f.controller != null && f.decoration?.labelText == 'Search', + ), + 'retainedcorrectionneedle', + ); + await wait( + () => widgets().any(recoveryHit), + 'correction deleted search result', + ); + await capture('correction-recovery-search'); + check( + !widgets().any((b) => b.tooltip == 'Source location'), + 'correctionRecoveryHasPreviewInsteadOfLiveSource', + ); + widgets().firstWhere(recoveryHit).onTap!(); + await wait( + () => widgets().isEmpty, + 'correction Recovery search route closed', + ); + await wait( + () => widgets().isNotEmpty, + 'correction Recovery preview', + ); + check( + widgets().single.note.localId == id && + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + active, + 'correctionRecoveryPreviewPreservesCurrentDocument', + ); + await capture('correction-recovery-preview'); + if (recover) { + await label('Recovery: New note'); + await wait( + () => widgets().isEmpty, + 'correction new recovery note', + ); + check( + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId != + id, + 'correctionRecoveryDistinctIdentity', + ); + } else { + Navigator.of(rootNavigatorKey.currentContext!).pop(); + } + await wait( + () => widgets().isEmpty, + 'correction Recovery preview route closed', + ); + } + + Future correctionJourney(WorkspaceController workspace) async { + final stateFile = File('${widget.output.path}/correction-journey.json'); + final service = NotesTransferService(repository); + if (widget.correctionsStart) { + widget.transport.offline = true; + for (var n = 0; n < 10; n++) { + await repository.create( + widget.account.id, + title: 'Correction paging $n', + category: 'Correction/Paging', + content: List.filled(32, 'desktopcorrectionneedle').join(' '), + ); + } + await repository.create( + widget.account.id, + title: 'Correction forty', + category: 'Correction/Paging', + content: List.filled(40, 'desktopfortyneedle').join(' '), + ); + await correctionSearch('desktopcorrectionneedle', [80, 160, 240, 320]); + await correctionSearch('desktopfortyneedle', [40]); + final source = await Directory( + '${widget.output.path}/correction-source', + ).create(); + final document = File('${source.path}/Correction import.md'); + await document.writeAsString( + '![image](./images/caf%C3%A9%2520.png)\n\n', + ); + final missing = await service.review(document.path, widget.account.id); + await correctionReview(missing, category: 'Correction/Original'); + final first = await service.importReviewed( + missing, + widget.account.id, + cancellation: NotesTransferCancellation(), + ); + await Directory('${source.path}/images').create(); + await File( + '${source.path}/images/café%20.png', + ).writeAsBytes(await png(Colors.green)); + final repaired = await service.review(document.path, widget.account.id); + await correctionReview(repaired, category: 'Correction/New/子'); + final second = await service.importReviewed( + repaired, + widget.account.id, + cancellation: NotesTransferCancellation(), + ); + check( + second.single.noteId != first.single.noteId && + !second.single.alreadyImported && + repository.noteById(second.single.noteId!)!.category == + 'Correction/New/子', + 'correctionNewImportDistinctReviewedCategory', + ); + await workspace.openNextcloudNote(second.single.noteId!); + check( + await repository.resolveCachedMedia( + widget.account.id, + second.single.noteId!, + (await repository.attachments( + second.single.noteId!, + )).single.reference, + ) != + null, + 'correctionRepairedRelativeMediaUsable', + ); + await capture('correction-imported-media'); + await Directory('${source.path}/chapters').create(); + await File('${source.path}/chapters/Correction nested.md').writeAsString( + '![image](../images/caf%C3%A9%2520.png)\n\n', + ); + await File( + '${widget.output.path}/outside.png', + ).writeAsBytes(await png(Colors.red)); + await File( + '${source.path}/Correction escape.md', + ).writeAsString('![image](../outside.png)\n\n'); + final nested = await service.review(source.path, widget.account.id); + final safe = nested.items.singleWhere( + (i) => i.title == 'Correction nested', + ); + final escape = nested.items.singleWhere( + (i) => i.title == 'Correction escape', + ); + check( + safe.media.isNotEmpty && + safe.issues.isEmpty && + escape.media.isEmpty && + escape.issues.isNotEmpty, + 'correctionLocalRelativeContainment', + ); + for (final item in nested.items) { + item.selected = identical(item, safe); + item.category = 'Correction/Nested'; + } + await correctionReview(nested); + await service.importReviewed( + nested, + widget.account.id, + cancellation: NotesTransferCancellation(), + ); + final resumeSource = await Directory( + '${widget.output.path}/correction-resume', + ).create(); + for (var n = 0; n < 3; n++) { + await File( + '${resumeSource.path}/Correction $n.md', + ).writeAsString('resume text $n'); + } + final partial = await service.review( + resumeSource.path, + widget.account.id, + category: 'Correction/Resume', + ); + final cancel = NotesTransferCancellation(); + final imported = await service.importReviewed( + partial, + widget.account.id, + cancellation: cancel, + onProgress: (_, _) => cancel.cancel(), + ); + final tombstone = await repository.create( + widget.account.id, + title: 'Correction deleted media', + category: 'Correction/Offline', + ); + final attachment = await repository.addAttachment( + tombstone.localId, + filename: 'recover.png', + bytes: await png(Colors.red), + ); + await repository.save( + tombstone.localId, + content: '![image](${attachment.reference})', + ); + await repository.deleteAttachment( + tombstone.localId, + attachment.reference, + retainForHistory: true, + ); + final offlineDialog = showNotesOffline( + rootNavigatorKey.currentContext!, + ref, + repository.noteById(tombstone.localId)!, + ); + await wait( + () => widgets().any((t) => t.data == 'Incomplete · 0/1'), + 'correction unavailable deleted media', + ); + await capture('correction-offline-deleted'); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await offlineDialog; + final deleted = await repository.create( + widget.account.id, + title: 'Correction retained note', + category: 'Correction/Recovery', + content: 'retainedcorrectionneedle', + ); + await repository.delete(deleted.localId); + await correctionRecovery(deleted.localId, recover: false); + await stateFile.writeAsString( + jsonEncode({ + 'operation': partial.operationId, + 'firstImported': imported.single.noteId, + 'tombstone': tombstone.localId, + 'attachment': attachment.id, + 'reference': attachment.reference, + 'repaired': second.single.noteId, + 'deleted': deleted.localId, + 'created': created.toList(), + }), + ); + check( + widget.transport.creates == 0 && widget.transport.writes == 0, + 'correctionOfflineStartNoPublication', + ); + return; + } + final state = jsonDecode(await stateFile.readAsString()) as Map; + created.addAll((state['created'] as List).cast()); + check( + widget.transport.offline && + widget.transport.creates == 0 && + widget.transport.writes == 0, + 'correctionActualOfflineProcessRestart', + ); + await correctionSearch('desktopcorrectionneedle', [80, 160, 240, 320]); + final requirements = await repository.store.importOperations( + widget.account.id, + ); + final choosing = showBusyMarkModalDialog( + rootNavigatorKey.currentContext!, + builder: (_) => NotesImportOperationDialog(operations: requirements), + ); + await wait( + () => widgets().isNotEmpty, + 'correction durable operation choice', + ); + await capture('correction-import-operation-choice'); + widgets() + .firstWhere( + (t) => + t.title is Text && + (t.title as Text).data == 'Resume interrupted import', + ) + .onTap!(); + final operationId = await choosing; + check(operationId == state['operation'], 'correctionExplicitResumeChosen'); + final resumed = await service.resumeImport(operationId!, widget.account.id); + await correctionReview(resumed); + final outcomes = await service.importReviewed( + resumed, + widget.account.id, + cancellation: NotesTransferCancellation(), + ); + check( + outcomes.length == 3 && + outcomes.first.alreadyImported && + outcomes.first.noteId == state['firstImported'], + 'correctionRestartResumeNoDuplicate', + ); + final tombstoneId = state['tombstone'] as String; + final availability = await repository.attachmentAvailability(tombstoneId); + check( + availability.available.isEmpty && + await repository.store.attachmentBytes( + state['attachment'] as String, + ) != + null && + await repository.resolveCachedMedia( + widget.account.id, + tombstoneId, + state['reference'] as String, + ) == + null, + 'correctionDeletedBytesRemainRecoveryOnlyAfterRestart', + ); + final replacement = await repository.addAttachment( + tombstoneId, + filename: 'recover.png', + bytes: await png(Colors.blue), + ); + await repository.save( + tombstoneId, + content: '![image](${replacement.reference})', + ); + check( + (await repository.attachmentAvailability(tombstoneId)).available.length == + 1, + 'correctionLegitimateReplacementAvailable', + ); + final repairedId = state['repaired'] as String; + await workspace.openNextcloudNote(repairedId); + final repairedMedia = await repository.resolveCachedMedia( + widget.account.id, + repairedId, + (await repository.attachments(repairedId)).single.reference, + ); + check(repairedMedia != null, 'correctionRepairedMediaAfterProcessRestart'); + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .mode == + mode, + 'repaired media active view ${mode.name}', + ); + if (mode != DocumentViewModePreference.source) { + await wait( + () => widgets().any( + (image) => + image.image is FileImage && + (image.image as FileImage).file.path == repairedMedia, + ), + 'repaired media rendered ${mode.name}', + ); + } + if (mode == DocumentViewModePreference.source || + mode == DocumentViewModePreference.split) { + await wait( + () => widgets().isNotEmpty, + 'repaired media source ${mode.name}', + ); + } + await capture('correction-repaired-${mode.name}'); + check(true, 'correctionRepairedMediaActualView${mode.name}'); + } + await correctionRecovery(state['deleted'] as String, recover: true); + widget.transport.offline = false; + await workspace.refreshNextcloudNotes(); + final current = repository.notes + .where( + (n) => + n.title.startsWith('Correction') || + n.category.startsWith('Correction'), + ) + .where((n) => !isNotesRecovery(n)) + .toList(); + created.addAll(current.map((n) => n.serverId).whereType()); + check( + current.every((n) => n.syncState == NoteSyncState.synced), + 'correctionImportedReplacementRecoveredRealSynchronization', + ); + check( + await repository.store.attachmentBytes(state['attachment'] as String) != + null, + 'correctionPublicationRetainsOriginalRecoveryBytes', + ); + await everydayJourney(workspace); + } + + Future performanceJourney(WorkspaceController workspace) async { + check(repository.notes.length >= 10000, 'largeNoteLibrary'); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'All notes', + ), + 'large sidebar', + ); + await capture('workspace-10000-sidebar'); + Future quickSample(String? screenshot) async { + final watch = Stopwatch()..start(); + final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + await wait( + () => + widgets().isNotEmpty && + widgets().any( + (t) => + t.title is Text && + (t.title as Text).data == repository.notes.first.title, + ), + 'large quick open', + ); + watch.stop(); + if (screenshot != null) await capture(screenshot); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await quick; + await wait( + () => widgets().isEmpty, + 'quick open dismissal before the next sample', + ); + return watch.elapsedMilliseconds; + } + + final baselineQuick = []; + for (var i = 0; i < 5; i++) { + baselineQuick.add( + await quickSample(i == 0 ? 'references-quick-open-baseline' : null), + ); + } + ref.read(referencePanelRequestProvider.notifier).request(); + await WidgetsBinding.instance.endOfFrame; + final indexingQuick = []; + for (var i = 0; i < 5; i++) { + indexingQuick.add( + await quickSample(i == 0 ? 'workspace-10000-quick-open' : null), + ); + } + final search = NotesSearchController(repository.store, widget.account.id); + final querying = search.search('é', limit: 2000); + final saved = Stopwatch()..start(); + final original = repository.notes.first; + await repository.save( + original.localId, + content: '${original.content}\nlarge UI durable edit', + ); + saved.stop(); + await querying; + check(search.state.hits.isNotEmpty, 'workspaceLargeUiSearchDuringEdit'); + await search.dispose(); + final dialog = showNotesSearch( + rootNavigatorKey.currentContext!, + ref, + query: repository.notes.first.title, + ); + await wait( + () => + widgets().isNotEmpty && + widgets().any((b) => b.tooltip == 'Source location'), + 'large indexed search UI', + ); + await capture('workspace-10000-search'); + Navigator.of(rootNavigatorKey.currentContext!).pop(); + await dialog; + await File('${widget.output.path}/ui-performance.json').writeAsString( + jsonEncode({ + 'notes': repository.notes.length, + 'quickOpenBaselineMs': baselineQuick, + 'quickOpenDuringReferenceIndexMs': indexingQuick, + 'durableSaveDuringSearchMs': saved.elapsedMilliseconds, + 'rssBytes': ProcessInfo.currentRss, + 'maximumRssBytes': ProcessInfo.maxRss, + }), + ); + } + + Future navigationJourney( + WorkspaceController workspace, + NextcloudNote note, + ) async { + await workspace.openNextcloudNote(note.localId); + await nativeShortcut('ctrl+p'); + await wait(() => widgets().isNotEmpty, 'native Ctrl+P'); + edit( + widgets().firstWhere( + (f) => f.decoration?.labelText == 'Quick Open', + ), + note.title, + ); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == note.title, + ), + 'native Quick Open result', + ); + // The imported copy has the same title/category. Capture the selected + // document identity before Enter instead of guessing from duplicate titles. + final selectedIdentity = rankQuickOpen( + widgets().single.documents, + note.title, + ).first.localId; + await capture('workspace-restart-native-quick-open'); + await nativeShortcut('enter'); + await wait( + () => + widgets().isEmpty && + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + selectedIdentity, + 'native Quick Open Enter', + ); + check(true, 'nativeCtrlPEnterStableIdentity'); + await nativeShortcut('ctrl+p'); + await wait( + () => widgets().isNotEmpty, + 'Quick Open reopened', + ); + await nativeShortcut('escape'); + await wait(() => widgets().isEmpty, 'Quick Open Escape'); + check(true, 'nativeQuickOpenEscape'); + await nativeShortcut('ctrl+shift+p'); + await wait( + () => + widgets().any((t) => t.data == 'Command Palette') && + widgets().isEmpty, + 'native Ctrl+Shift+P', + ); + await nativeShortcut('escape'); + await wait( + () => !widgets().any((t) => t.data == 'Command Palette'), + 'command palette Escape', + ); + check(true, 'nativeCtrlShiftPCommandPalette'); + // Local Markdown navigation/Quick Open remains available after changing workspace. + final folder = await Directory( + '${widget.output.path}/local-smoke', + ).create(); + final file = File('${folder.path}/local.md'); + await file.writeAsString('# Local Markdown\nlocal search smoke'); + await workspace.openFolder(folder.path); + await workspace.openActiveFile(file.path); + final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + await wait(() => widgets().isNotEmpty, 'local Quick Open'); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'local', + ), + 'local file result', + ); + widgets() + .lastWhere((t) => t.title is Text && (t.title as Text).data == 'local') + .onTap!(); + await quick; + check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == file.path, + 'workspaceLocalMarkdownQuickOpen', + ); + await localSearchSmoke( + 'local search smoke', + 'workspace-local-markdown-search', + ); + final writerside = Directory('${widget.output.path}/writerside-smoke'); + if (await writerside.exists()) await writerside.delete(recursive: true); + await writerside.create(); + final copied = await Process.run('cp', [ + '-a', + 'test/fixtures/writerside/basic_project/.', + writerside.path, + ]); + check(copied.exitCode == 0, 'writersideFixtureCopy'); + await workspace.openFolder(writerside.path); + await workspace.openActiveFile('${writerside.path}/topics/intro.md'); + await localSearchSmoke('Introduction', 'workspace-writerside-search'); + final writerQuick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + await wait( + () => + widgets().isNotEmpty && + widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'install', + ), + 'writerside Quick Open', + ); + widgets() + .lastWhere( + (t) => t.title is Text && (t.title as Text).data == 'install', + ) + .onTap!(); + await writerQuick; + check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${writerside.path}/topics/install.topic', + 'workspaceWritersideQuickOpen', + ); + } + + Future localSearchSmoke(String query, String screenshot) async { + await windowManager.focus(); + final key = await Process.run( + 'python3', + ['tools/nextcloud_notes_native_key.py', 'ctrl+f'], + environment: {'BUSYMARK_NATIVE_ACCEPTANCE': '1'}, + ); + check(key.exitCode == 0, 'nativeSearchShortcut$screenshot'); + await wait( + () => widgets().any( + (f) => + f.decoration?.hintText == 'Search' && + f.onChanged != null && + f.controller != null, + ), + 'local search field', + ); + edit( + widgets().lastWhere( + (f) => + f.decoration?.hintText == 'Search' && + f.onChanged != null && + f.controller != null, + ), + query, + ); + await wait( + () => widgets().any((t) => t.data?.contains(query) == true), + 'local indexed result $query', + ); + await capture(screenshot); + ref.read(workspaceSearchCloseRequestProvider.notifier).request(); + check(true, screenshot); + } + + Future nativeShortcut(String key, {bool focusWindow = true}) async { + if (focusWindow) await windowManager.focus(); + final result = await Process.run( + 'python3', + ['tools/nextcloud_notes_native_key.py', key], + environment: {'BUSYMARK_NATIVE_ACCEPTANCE': '1'}, + ); + if (result.exitCode != 0) { + throw StateError('Native acceptance key failed: $key'); + } + } + + Future run() async { + Object? failure; + StackTrace? failureStack; + final diagnostics = ref.listenManual(workspaceControllerProvider, ( + before, + after, + ) { + if (after.message?.error != null) { + stderr.writeln('Acceptance workspace failure: ${after.message!.error}'); + } + }); + try { + repository = await ref.read(nextcloudNotesRepositoryProvider.future); + await repository.upsertAccount(widget.account); + api = NotesApiClient( + client: widget.transport, + account: widget.account, + appPassword: widget.password, + ); + if (!widget.scenario.reopensProfile && !widget.stopOffline) { + original = await api.getSettings(); + } + await ref.read(nextcloudConnectionProvider.notifier).reload(); + if (widget.scenario != _Scenario.full) { + final workspace = ref.read(workspaceControllerProvider.notifier); + check( + await workspace.openNextcloudWorkspace(widget.account.id), + 'openNativeWorkspace', + ); + ref.read(appRouterProvider).go('/workspace'); + switch (widget.scenario) { + case _Scenario.localReferences || _Scenario.localReferencesResume: + await localReferenceJourney(workspace); + case _Scenario.writersideReferences: + await writersideReferenceJourney(workspace); + case _Scenario.references || _Scenario.referencesResume: + await referenceJourney(workspace); + case _Scenario.recoveryStart || _Scenario.recoveryResume: + await correctionJourney(workspace); + case _Scenario.performance: + await performanceJourney(workspace); + case _Scenario.workspace || + _Scenario.offlineStart || + _Scenario.offlineResume: + await everydayJourney(workspace); + case _Scenario.full: + throw StateError('Full scenario dispatched separately.'); + } + } else { + final seed = await repository.create( + widget.account.id, + title: 'Desktop category fixture', + category: 'Desktop/Child', + content: '# Desktop fixture', + ); + await repository.synchronize(widget.account.id); + created.add(repository.noteById(seed.localId)!.serverId!); + final workspace = ref.read(workspaceControllerProvider.notifier); + check( + await workspace.openNextcloudWorkspace(widget.account.id), + 'openNativeWorkspace', + ); + ref.read(appRouterProvider).go('/workspace'); + await wait( + () => widgets().any( + (t) => t.title is Text && (t.title as Text).data == 'Desktop', + ), + 'category hierarchy', + ); + widgets() + .firstWhere( + (t) => t.title is Text && (t.title as Text).data == 'Desktop', + ) + .onTap!(); + final priorIds = repository.notes.map((n) => n.localId).toSet(); + widget.transport.offline = true; + await label('New note'); + await wait(() { + final active = ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId; + return active != null && !priorIds.contains(active); + }, 'category creation'); + final id = ref + .read(workspaceControllerProvider) + .activeBuffer! + .remoteNote! + .localId; + check( + repository.noteById(id)!.category == 'Desktop', + 'selectedParentCategoryCreation', + ); + workspace.updateActiveText( + '# Offline desktop edit\n\nDurable content.', + ); + check(await workspace.saveActive(), 'offlineSaveDurability'); + final newer = repository.noteById(id)!; + await wait( + () => + widgets() + .where( + (t) => + t.key is ValueKey && + (t.key! as ValueKey).value.startsWith( + 'nextcloud-note-', + ), + ) + .firstOrNull + ?.key == + ValueKey('nextcloud-note-$id'), + 'offline activity reordered sidebar', + ); + final rows = widgets() + .where( + (t) => + t.key is ValueKey && + (t.key! as ValueKey).value.startsWith( + 'nextcloud-note-', + ), + ) + .toList(); + check( + rows.first.key == ValueKey('nextcloud-note-$id') && + newer.localActivityMicros != null, + 'offlineActivityOrdering', + ); + await capture('offline-category'); + widget.transport.offline = false; + await workspace.refreshNextcloudNotes(); + created.add(repository.noteById(id)!.serverId!); + // Keep the dialog open across a real independent remote metadata write. + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-$id'), + ), + 'note row', + ); + widgets() + .firstWhere((t) => t.key == ValueKey('nextcloud-note-$id')) + .onLongPress!(); + await wait( + () => widgets().any( + (f) => f.controller?.text == newer.title, + ), + 'properties dialog', + ); + edit( + widgets().firstWhere( + (f) => f.controller?.text == newer.title, + ), + 'Desktop edited title', + ); + final before = repository.noteById(id)!; + await api.update(before.copyWith(category: 'Desktop/Remote')); + await repository.synchronize(widget.account.id, allowWrites: false); + workspace.updateActiveText('# Dirty while properties are open\n'); + await label('Save'); + await wait( + () => repository.noteById(id)!.title == 'Desktop edited title', + 'metadata save', + ); + check( + repository.noteById(id)!.category == 'Desktop/Remote' && + repository.noteById(id)!.content.contains('Dirty while'), + 'propertiesConcurrencyAndDirtyBuffer', + ); + await workspace.refreshNextcloudNotes(); + for (final draft in [false, true]) { + widget.transport.offline = true; + final reviewId = draft + ? (await repository.create( + widget.account.id, + title: 'Desktop A draft', + category: 'Desktop/Remote', + content: 'draft body', + )).localId + : id; + await workspace.openNextcloudNote(reviewId); + final title = repository.noteById(reviewId)!.title; + await wait( + () => widgets().any( + (t) => t.key == ValueKey('nextcloud-note-$reviewId'), + ), + 'metadata review row', + ); + widgets() + .firstWhere((t) => t.key == ValueKey('nextcloud-note-$reviewId')) + .onLongPress!(); + await wait( + () => widgets().any((f) => f.controller?.text == title), + 'stale metadata dialog', + ); + final snapshot = repository.metadataSnapshot(reviewId); + await repository.patchMetadata(snapshot, title: 'Desktop B $draft'); + edit( + widgets().firstWhere((f) => f.controller?.text == title), + 'Desktop C $draft', + ); + await label('Save'); + await wait( + () => repository.noteById(reviewId)!.metadataConflict != null, + 'local metadata conflict', + ); + widget.transport.offline = false; + await label('Compare'); + await wait( + () => widgets().any( + (d) => + d.items?.any( + (i) => + i.child is Text && + (i.child as Text).data == + 'Previous local edit: Desktop B $draft', + ) == + true, + ), + 'reviewed B/C choices', + ); + await capture('local-metadata-conflict-$draft'); + await label('Use previous edit'); + await wait( + () => repository.noteById(reviewId)!.metadataConflict == null, + 'previous local choice applied', + ); + await workspace.refreshNextcloudNotes(); + final selected = repository.noteById(reviewId)!; + created.add(selected.serverId!); + check( + selected.title == 'Desktop B $draft' && + (await api.get(selected.serverId!)).title == selected.title, + 'nativeReviewedBPreservedDraft$draft', + ); + } + await metadataRaces(workspace); + await workspace.openNextcloudNote(id); + workspace.updateActiveText('# Native authenticated edit'); + await workspace.saveActive(); + widget.transport.rejectNextWriteCredentials = true; + await workspace.refreshNextcloudNotes(); + check( + repository.noteById(id)!.syncState == NoteSyncState.reconnectRequired, + 'nativeReal401BlocksWrite', + ); + final verified = await fetchNotesCapabilities( + client: widget.transport, + server: widget.account.server, + loginName: widget.account.loginName, + appPassword: widget.password, + ); + await repository.upsertAccount( + widget.account.copyWith( + appVersion: verified.appVersion, + apiVersion: verified.apiVersion, + ), + reconnect: true, + ); + await ref.read(nextcloudConnectionProvider.notifier).reload(); + await workspace.refreshNextcloudNotes(); + check( + repository.noteById(id)!.syncState == NoteSyncState.synced && + (await api.get(repository.noteById(id)!.serverId!)).content == + '# Native authenticated edit', + 'nativeVerifiedReconnectResumesWrite', + ); + workspace.updateActiveText('# First throttled edit'); + await workspace.saveActive(); + widget.transport.throttleNextWrite = true; + await workspace.refreshNextcloudNotes(); + final deadline = repository.noteById(id)!.retryNotBefore!; + final writes = widget.transport.writes; + workspace.updateActiveText('# Edited during throttling'); + await workspace.saveActive(); + await repository.patchMetadata( + repository.metadataSnapshot(id), + favorite: !repository.noteById(id)!.favorite, + ); + await workspace.refreshNextcloudNotes(); + check( + widget.transport.writes == writes && + repository.noteById(id)!.retryNotBefore == deadline, + 'nativeEditsKeepRetryAfter', + ); + await Future.delayed( + deadline.difference(DateTime.now()) + const Duration(seconds: 1), + ); + await workspace.refreshNextcloudNotes(); + check( + repository.noteById(id)!.syncState == NoteSyncState.synced, + 'nativeDeadlinePublishesEditedRevision', + ); + // Open and save the actual server settings controls. + ref + .read(appRouterProvider) + .go('/settings?page=nextcloudNotes&returnTo=workspace'); + await wait( + () => + ref.read(notesSettingsProvider).draft != null && + !ref.read(notesSettingsProvider).busy, + 'settings load', + ); + await wait( + () => widgets().any( + (f) => f.controller?.text == original!.fileSuffix, + ), + 'rendered settings suffix', + ); + final formSuffix = widgets().firstWhere( + (f) => f.controller?.text == original!.fileSuffix, + ); + edit(formSuffix, ' .desktop '); + await Future.delayed(const Duration(milliseconds: 100)); + await label('Save'); + await wait( + () => + ref.read(notesSettingsProvider).phase == NotesSettingsPhase.saved, + 'settings save', + ); + check( + ref.read(notesSettingsProvider).draft!.fileSuffix == '.desktop' && + ref.read(notesSettingsProvider).normalized, + 'settingsCustomSuffixNormalization', + ); + await capture('server-settings'); + await api.updateSettings({'fileSuffix': original!.fileSuffix}); + await repository.synchronize(widget.account.id, allowWrites: false); + ref.read(appRouterProvider).go('/workspace'); + await workspace.openNextcloudNote(id); + // The transport loss is simulated; the recovery probe and subsequent PUT + // use actual authenticated HTTPS requests and the production 60s timer. + widget.transport.offline = true; + workspace.updateActiveText('# Connectivity recovery desktop\n'); + await workspace.saveActive(); + await workspace.refreshNextcloudNotes(); + check( + repository.accountError(widget.account.id)?.code == + NotesFailureCode.network, + 'offlineFailurePresented', + ); + widget.transport.offline = false; + await wait( + () => repository.noteById(id)!.syncState == NoteSyncState.synced, + 'bounded connectivity recovery', + seconds: 75, + ); + check( + (await api.get( + repository.noteById(id)!.serverId!, + )).content.contains('Connectivity recovery'), + 'authenticatedConnectivityRecovery', + ); + // Idle discovery uses the unmodified production polling interval. + final current = repository.noteById(id)!; + await api.update( + current.copyWith(content: '# Remote idle discovery\n'), + ); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.text + .contains('Remote idle discovery') == + true, + 'idle discovery', + seconds: 75, + ); + check(true, 'healthyIdleRemoteDiscovery'); + // A hidden window suspends reads; restoring/focusing triggers a check. + await windowManager.hide(); + await Future.delayed(const Duration(seconds: 1)); + final count = widget.transport.collections; + final focusRemote = await api.get(repository.noteById(id)!.serverId!); + await api.update( + repository + .noteById(id)! + .copyWith( + etag: focusRemote.etag, + content: '# Remote focus discovery\n', + ), + ); + await Future.delayed(const Duration(seconds: 11)); + check(widget.transport.collections == count, 'hiddenSuspension'); + await windowManager.show(); + await windowManager.focus(); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.text + .contains('Remote focus discovery') == + true, + 'native focus refresh', + ); + check(true, 'nativeFocusResumeRefresh'); + // Publish a small managed image, then replace its server file in this + // disposable fixture without changing its path or the note ETag. + final attachment = await repository.addAttachment( + id, + filename: 'café.png', + bytes: await png(Colors.red), + ); + workspace.updateActiveText( + '# Desktop media\n\n![image](${attachment.reference})\n', + ); + await workspace.saveActive(); + await workspace.refreshNextcloudNotes(); + var note = repository.noteById(id)!; + final remoteAttachment = (await repository.store.attachments( + id, + )).firstWhere((a) => a.id == attachment.id); + final rawPath = remoteAttachment.remotePath!; + final destination = attachmentMarkdownReference(rawPath); + workspace.updateActiveText('\n'); + await workspace.saveActive(); + await workspace.refreshNextcloudNotes(); + note = repository.noteById(id)!; + check( + note.content.contains('café.png'), + 'nativeLiteralUnicodeHtmlDestination', + ); + final oldPath = await repository.resolveMedia( + widget.account.id, + id, + destination, + ); + for (final mode in DocumentViewModePreference.values) { + workspace.updateActiveEditorMode(mode); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + workspace.updateActiveText( + '${note.content}\nView flow: ${mode.name}\n', + ); + check(await workspace.saveActive(), '${mode.name}LocalSave'); + await workspace.refreshNextcloudNotes(); + note = repository.noteById(id)!; + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .mode == + mode && + (mode == DocumentViewModePreference.editor || + mode == DocumentViewModePreference.source || + ref.read(workspaceControllerProvider).preview != null), + 'rendered ${mode.name} mode', + ); + await capture('media-${mode.name}-before'); + check( + ref.read(workspaceControllerProvider).activeBuffer!.text == + note.content, + '${mode.name}MediaSaveFlow', + ); + } + final replacement = File('${widget.output.path}/replacement.png'); + await replacement.writeAsBytes(await png(Colors.blue)); + final serverFile = + '/var/www/html/data/${widget.account.loginName}/files/${original!.notesPath}/${note.category}/$rawPath'; + final copied = await Process.run('docker', [ + 'cp', + replacement.path, + '${widget.containerName}:$serverFile', + ]); + check(copied.exitCode == 0, 'replaceTestOwnedAttachment'); + final permissions = await Process.run('docker', [ + 'exec', + widget.containerName, + 'chown', + 'www-data:www-data', + serverFile, + ]); + check(permissions.exitCode == 0, 'replacementPermissions'); + await workspace.refreshNextcloudNotes(); + final newPath = await repository.resolveMedia( + widget.account.id, + id, + destination, + ); + check( + newPath != oldPath && + (await api.get(note.serverId!)).etag == note.etag, + 'samePathReplacementUnchangedNoteEtag', + ); + await wait( + () => widgets().any( + (i) => + i.image is FileImage && + (i.image as FileImage).file.path == newPath, + ), + 'actual displayed refreshed image', + ); + check(true, 'alreadyOpenPreviewUsesReplacement'); + await capture('media-split-after'); + workspace.updateActiveEditorMode(DocumentViewModePreference.editor); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.editor); + await wait( + () => widgets().any( + (i) => + i.image is FileImage && + (i.image as FileImage).file.path == newPath, + ), + 'actual editor refreshed image', + ); + check(true, 'alreadyOpenEditorUsesReplacement'); + await capture('media-editor-after'); + await everydayJourney(workspace); + } + } catch (error, stack) { + failure = error; + try { + await capture('${widget.scenario.option}-failure'); + } catch (_) {} + failureStack = stack; + } finally { + diagnostics.close(); + widget.transport.offline = widget.scenario.endsOffline; + if (original != null) { + try { + await api.updateSettings(original!.toJson()); + } catch (_) {} + } + if (!widget.scenario.retainsResources) { + for (final id in created) { + try { + await api.delete(id); + } catch (_) {} + } + } + await File( + '${widget.output.path}/report-${widget.scenario.option}.json', + ).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'passed': failure == null, + 'notesVersion': widget.account.appVersion, + 'apiVersion': widget.account.apiVersion, + 'checks': checks, + if (failure != null) 'failure': '$failure', + if (failureStack != null) 'stack': '$failureStack', + }), + ); + if (failure != null) { + stderr.writeln('Desktop acceptance failed: $failure'); + exit(1); + } + await ref + .read(workspaceControllerProvider.notifier) + .discardRecoveryForShutdown(); + await SystemNavigator.pop(); + } + } +} diff --git a/tools/nextcloud_notes_live_acceptance.dart b/tools/nextcloud_notes_live_acceptance.dart index 748755da..06cab1d7 100644 --- a/tools/nextcloud_notes_live_acceptance.dart +++ b/tools/nextcloud_notes_live_acceptance.dart @@ -1,3 +1,10 @@ +import 'package:busymark/src/nextcloud_notes/domain/notes_retrieval.dart'; +import 'package:busymark/src/nextcloud_notes/data/server_uri.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_offline_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_transfer_service.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_navigation.dart'; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -11,6 +18,15 @@ import 'package:busymark/src/nextcloud_notes/data/notes_api_client.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_capabilities.dart'; import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_conflict.dart'; + +class _HeldWrite { + _HeldWrite(this.status); + final int status; + final started = Completer(); + final release = Completer(); + int? executedId; +} class _Connection extends http.BaseClient { _Connection(this._client); @@ -18,6 +34,11 @@ class _Connection extends http.BaseClient { bool offline = false; bool loseNextCreateResponse = false; bool discardNextCreate = false; + bool loseNextSettingsResponse = false; + bool loseNextUploadResponse = false; + int? nextCollectionFailure; + bool rejectNextPutCredentials = false; + String? nextPutRetryAfter; Map? discardedCreation; int attachmentUploads = 0; int noteUpdates = 0; @@ -25,9 +46,44 @@ class _Connection extends http.BaseClient { final attachmentUploadsById = {}; int noteCreates = 0; int chunkRequests = 0; + _HeldWrite? holdNextWrite; @override Future send(http.BaseRequest request) async { if (offline) throw http.ClientException('Intentional offline acceptance.'); + if (rejectNextPutCredentials && + request.method == 'PUT' && + request.url.path.contains('/v1/notes/')) { + rejectNextPutCredentials = false; + // Real server authentication rejection with intentionally invalid fixture + // credentials, confined to this one write. Never logged or persisted. + request.headers['Authorization'] = nextcloudAuthorization( + 'invalid-review-fixture', + 'invalid-review-password', + ); + } + if (request.method == 'GET' && + request.url.path.endsWith('/v1/notes') && + nextCollectionFailure != null) { + final status = nextCollectionFailure!; + nextCollectionFailure = null; + return http.StreamedResponse( + Stream.value(utf8.encode('{}')), + status, + request: request, + ); + } + if (request.method == 'PUT' && + request.url.path.contains('/v1/notes/') && + nextPutRetryAfter != null) { + final deadline = nextPutRetryAfter!; + nextPutRetryAfter = null; + return http.StreamedResponse( + Stream.value(utf8.encode('{}')), + 429, + headers: {'Retry-After': deadline}, + request: request, + ); + } if (request.url.queryParameters.containsKey('chunkCursor')) chunkRequests++; final creation = request.method == 'POST' && request.url.path.endsWith('/v1/notes'); @@ -37,7 +93,7 @@ class _Connection extends http.BaseClient { 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/')) { + if (request.method == 'PUT' && request.url.path.contains('/v1/notes/')) { noteUpdates++; final id = int.parse(request.url.pathSegments.last); noteUpdatesById.update(id, (v) => v + 1, ifAbsent: () => 1); @@ -50,7 +106,54 @@ class _Connection extends http.BaseClient { 'Intentional nondelivery of creation request.', ); } + final held = + creation || + (request.method == 'PUT' && request.url.path.contains('/v1/notes/')) + ? holdNextWrite + : null; + if (held != null) { + holdNextWrite = null; + held.started.complete(); + await held.release.future; + if (held.status == 429) { + // Definitive rejection: deliberately do not execute this fixture write. + return http.StreamedResponse( + Stream.value(utf8.encode('{}')), + 429, + headers: {'Retry-After': '10'}, + request: request, + ); + } + } final response = await _client.send(request); + if (held?.status == 503) { + // Simulate a server failure after a real successful write. A POST outcome + // remains uncertain; its known test-owned ID is retained only for cleanup. + final bytes = await response.stream.toBytes(); + _require( + response.statusCode == 200, + 'Held fixture write was not successful.', + ); + held!.executedId = (jsonDecode(utf8.decode(bytes)) as Map)['id'] as int; + return http.StreamedResponse( + Stream.value(utf8.encode('{}')), + 503, + request: request, + ); + } + if ((loseNextSettingsResponse && + request.method == 'PUT' && + request.url.path.endsWith('/v1/settings')) || + (loseNextUploadResponse && + request.method == 'POST' && + request.url.path.contains('/attachment/'))) { + loseNextSettingsResponse = false; + loseNextUploadResponse = false; + await response.stream.drain(); + throw http.ClientException( + 'Intentional lost response after a real server write.', + ); + } if (creation && loseNextCreateResponse) { loseNextCreateResponse = false; await response.stream.drain(); @@ -95,6 +198,8 @@ Future main(List arguments) async { final checks = {}; NotesRepository? repository; final createdIds = {}; + NotesSettings? originalSettings; + DateTime? editClock; try { final capabilities = await fetchNotesCapabilities( client: connection, @@ -123,6 +228,13 @@ Future main(List arguments) async { final result = NotesRepository( store: store, clientForAccount: (value) async => api(value), + clock: () => editClock ?? DateTime.now(), + fetchCapabilities: (value) => fetchNotesCapabilities( + client: connection, + server: value.server, + loginName: value.loginName, + appPassword: password, + ), ); await result.initialize(); return result; @@ -130,6 +242,110 @@ Future main(List arguments) async { repository = await reopen(); await repository.upsertAccount(account); + originalSettings = await api(account).getSettings(); + final linked = await repository.create( + account.id, + title: 'References portable target', + category: 'References/Scoped', + content: '# Target\nconfirmed portable identity', + ); + await repository.synchronize(account.id); + final target = repository.noteById(linked.localId)!; + createdIds.add(target.serverId!); + final first = await api(account).retrieve(target.serverId!); + final conditional = await api( + account, + ).retrieve(target.serverId!, cached: (first as NotesReturned).note); + if (conditional is! NotesNotModified) { + throw StateError('Single-note conditional GET did not return 304.'); + } + checks['referencesConditionalSingleNote200And304'] = true; + final projected = await api(account).query( + NotesRetrievalScope( + category: 'References/Scoped', + exclude: {'content', 'title'}, + ), + chunkSize: 1, + ); + if (!projected.notes.any((n) => n.id == target.serverId) || + projected.notes.any((n) => n.has('content') || n.has('title'))) { + throw StateError('Filtered/projected live response semantics differed.'); + } + checks['referencesFilteredProjectedRetrieval'] = true; + final unidentified = await api(account).query( + NotesRetrievalScope( + category: 'References/Scoped', + exclude: {'id', 'content'}, + ), + chunkSize: 1, + ); + final singleProjection = await api(account).getProjection( + target.serverId!, + NotesRetrievalScope(exclude: {'id', 'content'}), + ); + if (unidentified.notes.isEmpty || + unidentified.notes.any((n) => n.id != null || n.has('content')) || + singleProjection.id != null || + singleProjection.has('content')) { + throw StateError('Excluded identifiers were not represented as omitted.'); + } + checks['referencesExcludedIdentifiersAndSingleProjection'] = true; + final checkpoint = repository.accountById(account.id)!.listEtag; + await repository.save(target.localId, content: 'pending local edit'); + final refreshed = await repository.refreshLinkedNote( + account.id, + target.serverId!, + ); + if (refreshed.content != 'pending local edit' || + !refreshed.hasPendingChanges || + repository.accountById(account.id)!.listEtag != checkpoint) { + throw StateError('304 changed pending local state.'); + } + checks['referencesConditionalReadPreservesPendingAndCheckpoint'] = true; + final url = nextcloudNoteUrl( + account.server, + target.serverId!, + pretty: await repository.confirmNoteWebRoute(account.id), + ); + final source = await repository.create( + account.id, + title: 'References link source', + content: '[portable target]($url)', + ); + await repository.synchronize(account.id); + final sourceId = repository.noteById(source.localId)!.serverId!; + createdIds.add(sourceId); + if (!(await api(account).get(sourceId)).content.contains(url.toString())) { + throw StateError('Portable link publication failed.'); + } + checks['referencesPortableUrlPublished'] = true; + + await _protocolChecks( + repository, + connection, + account, + api, + createdIds, + checks, + directory, + (value) => editClock = value, + ); + await _reviewCorrections( + connection, + account, + api, + createdIds, + checks, + directory, + ); + await _metadataRaceChecks( + connection, + account, + api, + createdIds, + checks, + directory, + ); if (credentials['lockedNoteId'] case final int lockedId) { await repository.synchronize(account.id); final locked = repository.notes.firstWhere((n) => n.serverId == lockedId); @@ -529,6 +745,23 @@ Future main(List arguments) async { 'Explicit online deletion failed.', ); checks['explicitFreshCheckedDeletion'] = true; + await _everydayWorkspaceChecks( + repository, + connection, + account, + api, + createdIds, + checks, + directory.path, + ); + await _workspaceCorrectionChecks( + connection, + account, + api, + createdIds, + checks, + directory.path, + ); await File(arguments[1]).writeAsString( const JsonEncoder.withIndent( ' ', @@ -538,12 +771,13 @@ Future main(List arguments) async { stdout.writeln( 'Live Nextcloud Notes acceptance passed (${checks.length} checks).', ); - } on Object catch (error) { + } on Object catch (error, stack) { await File(arguments[1]).writeAsString( const JsonEncoder.withIndent(' ').convert({ 'ok': false, 'checks': checks, 'failureType': error.runtimeType.toString(), + 'failureStack': stack.toString(), 'failure': error is NotesException ? error.message : error is StateError @@ -556,6 +790,19 @@ Future main(List arguments) async { exitCode = 1; } finally { connection.offline = false; + if (originalSettings != null && + repository != null && + repository.accounts.isNotEmpty) { + try { + await NotesApiClient( + client: connection, + account: repository.accounts.first, + appPassword: password, + ).updateSettings(originalSettings.toJson()); + } on Object { + /* Fixture teardown still bounds cleanup. */ + } + } // Every destructive cleanup target was created by this isolated test run. for (final id in createdIds) { try { @@ -580,3 +827,986 @@ Future main(List arguments) async { await directory.delete(recursive: true); } } + +Future _protocolChecks( + NotesRepository repository, + _Connection connection, + NextcloudAccount account, + NotesApiClient Function(NextcloudAccount) api, + Set createdIds, + Map checks, + Directory directory, + void Function(DateTime?) setClock, +) async { + final settings = await repository.getSettings(account.id); + final changed = await repository.changeSettings(account.id, settings, { + 'fileSuffix': '.busyacceptance', + }); + _require( + changed.fileSuffix == '.busyacceptance' && + changed.notesPath == settings.notesPath, + 'Custom suffix/partial settings update failed.', + ); + final normalized = await repository.changeSettings(account.id, changed, { + 'fileSuffix': 'md', + }); + _require( + normalized.fileSuffix.startsWith('.'), + 'Server suffix normalization was not adopted.', + ); + await repository.changeSettings(account.id, normalized, { + 'fileSuffix': settings.fileSuffix, + }); + checks['settingsPartialCustomSuffixNormalization'] = true; + connection.loseNextSettingsResponse = true; + final lost = await repository.changeSettings(account.id, settings, { + 'notesPath': 'BusyMark Protocol isolated collection', + }); + _require( + lost.notesPath == 'BusyMark Protocol isolated collection' && + repository.accounts.single.settingsAttempt == null && + repository.accounts.single.listEtag == null, + 'Lost settings response did not reconcile by read/reset checkpoint.', + ); + await repository.changeSettings(account.id, lost, { + 'notesPath': settings.notesPath, + }); + checks['lostSettingsResponseReconciledRealWrite'] = true; + await repository.synchronize(account.id, allowWrites: false); + _require( + repository.accounts.single.lastServerCheck != null, + 'Complete server check timestamp missing.', + ); + await repository.maintainCapabilities(account.id, force: true); + _require( + repository.accounts.single.appVersion == account.appVersion, + 'Capabilities were not maintained without reconnect.', + ); + checks['maintainedCapabilitiesAndServerCheck'] = true; + + final editTime = DateTime.now().toUtc().subtract(const Duration(days: 3)); + setClock(editTime); + final draft = await repository.create( + account.id, + title: 'Protocol timestamp and filenames', + category: 'BusyMarkAcceptance', + content: '# Edit time', + ); + final captured = draft.localActivityMicros; + setClock(editTime.add(const Duration(days: 2))); + await repository.synchronize(account.id); + var note = repository.noteById(draft.localId)!; + createdIds.add(note.serverId!); + _require( + (await api(account).get(note.serverId!)).modified == + editTime.millisecondsSinceEpoch ~/ 1000 && + note.localActivityMicros == captured, + 'Delayed creation changed the local edit time.', + ); + checks['delayedPublicationTimestampFidelity'] = true; + setClock(null); + final names = [ + 'é 日本.txt', + 'literal%.txt', + 'space # (1).txt', + '%2F.txt', + '%2e%2e.txt', + ]; + for (final filename in names) { + final bytes = Uint8List.fromList(utf8.encode('fixture:$filename')); + final attachment = await repository.addAttachment( + note.localId, + filename: filename, + bytes: bytes, + ); + await repository.save( + note.localId, + content: + '${repository.noteById(note.localId)!.content}\n[file](${attachment.reference})', + ); + await repository.synchronize(account.id); + final stored = (await repository.attachments( + note.localId, + )).singleWhere((a) => a.id == attachment.id); + _require( + stored.remotePath != null && + repository + .noteById(note.localId)! + .content + .contains(attachmentMarkdownReference(stored.remotePath!)), + 'Raw filename was not durably published into Markdown.', + ); + final file = await api(account).fetchAttachment( + note.serverId!, + stored.remotePath!, + destination: File('${directory.path}/filename-${attachment.id}'), + ); + _require( + base64Encode(await file.readAsBytes()) == base64Encode(bytes), + 'Filename upload/Markdown/download round trip failed.', + ); + } + checks['unicodePercentSpaceHashParenthesesAttachmentRoundTrips'] = true; + await repository.save( + note.localId, + content: '${repository.noteById(note.localId)!.content}\nPending edit', + ); + connection.nextCollectionFailure = 404; + await repository.synchronize(account.id); + _require( + repository.noteById(note.localId)!.syncState != + NoteSyncState.deletedRemotely && + repository.noteById(note.localId)!.hasPendingChanges, + 'Injected collection 404 poisoned pending work.', + ); + connection.nextPutRetryAfter = '3600'; + await repository.synchronize(account.id); + note = repository.noteById(note.localId)!; + _require( + note.syncState == NoteSyncState.throttled && note.retryNotBefore != null, + 'Injected throttle did not preserve a deadline.', + ); + final updates = connection.noteUpdates; + await repository.synchronize(account.id); + _require( + connection.noteUpdates == updates && + repository.noteById(note.localId)!.hasPendingChanges, + 'Write retried before the throttle deadline.', + ); + setClock(note.retryNotBefore!.add(const Duration(seconds: 1))); + await repository.synchronize(account.id); + setClock(null); + checks['simulatedCollection404AndThrottlingAroundRealRequests'] = true; + final staged = await repository.addAttachment( + note.localId, + filename: 'lost-upload.txt', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await repository.save( + note.localId, + content: + '${repository.noteById(note.localId)!.content}\n[lost](${staged.reference})', + ); + connection.loseNextUploadResponse = true; + await repository.synchronize(account.id); + final uploads = connection.attachmentUploads; + await repository.synchronize(account.id); + _require( + connection.attachmentUploads == uploads && + (await repository.attachments( + note.localId, + )).singleWhere((a) => a.id == staged.id).state == + 'uncertain', + 'Lost upload was repeated automatically.', + ); + checks['lostRealUploadResponsePreservesUncertainty'] = true; + // Explicitly remove the note; uncertain attachment bytes remain in local recovery. + await repository.delete(note.localId); + createdIds.remove(note.serverId!); +} + +Future _reviewCorrections( + _Connection connection, + NextcloudAccount account, + NotesApiClient Function(NextcloudAccount) api, + Set createdIds, + Map checks, + Directory directory, +) async { + var now = DateTime.now(); + Future open() async { + final r = NotesRepository( + store: await NotesStore.open(path: '${directory.path}/review.sqlite3'), + clientForAccount: (a) async => api(a), + clock: () => now, + ); + await r.initialize(); + if (r.accounts.isEmpty) await r.upsertAccount(account); + return r; + } + + var r = await open(); + Future restart() async { + await r.dispose(); + r = await open(); + } + + try { + final owned = await r.create( + account.id, + title: 'Review auth and Unicode', + category: 'BusyMarkReview', + content: 'body', + ); + await r.synchronize(account.id); + final id = owned.localId; + createdIds.add(r.noteById(id)!.serverId!); + await r.save(id, content: 'durable authenticated edit'); + connection.rejectNextPutCredentials = true; + await r.synchronize(account.id); + _require( + r.noteById(id)!.syncState == NoteSyncState.reconnectRequired, + 'Real invalid-credential PUT did not produce an authentication block.', + ); + await restart(); + // The account identity and valid app password came from the verified Login + // Flow; this authenticated read verifies the restored credential boundary. + await api(account).list(); + await r.upsertAccount(account, reconnect: true); + await r.synchronize(account.id); + _require( + r.noteById(id)!.syncState == NoteSyncState.synced && + (await api(account).get(r.noteById(id)!.serverId!)).content == + 'durable authenticated edit', + 'Restart/reconnect did not resume the real authenticated write.', + ); + checks['real401RestartVerifiedReconnectResumesWrite'] = true; + + await r.save(id, content: 'first throttled edit'); + connection.nextPutRetryAfter = '600'; + await r.synchronize(account.id); + final deadline = r.noteById(id)!.retryNotBefore!; + final updates = connection.noteUpdates; + await r.save(id, content: 'content edited while throttled'); + await r.patchMetadata( + r.metadataSnapshot(id), + category: 'BusyMarkReview/Edited', + ); + await restart(); + await r.synchronize(account.id); + _require( + r.noteById(id)!.retryNotBefore == deadline && + connection.noteUpdates == updates, + 'Content/metadata editing or restart bypassed injected Retry-After.', + ); + now = deadline; + await r.synchronize(account.id); + _require( + r.noteById(id)!.syncState == NoteSyncState.synced && + connection.noteUpdates == updates + 1, + 'Expired throttle did not publish the edited revision.', + ); + checks['simulated429ContentMetadataRestartDeadlineExpiryRealPublication'] = + true; + now = DateTime.now(); + + final bytes = Uint8List.fromList([1, 2, 3, 4]); + final attachment = await r.addAttachment( + id, + filename: 'café.png', + bytes: bytes, + ); + await r.save(id, content: '![unicode](${attachment.reference})'); + await r.synchronize(account.id); + final path = (await r.attachments( + id, + )).singleWhere((a) => a.id == attachment.id).remotePath!; + await r.save(id, content: ''); + await r.synchronize(account.id); + for (final destination in [path, attachmentMarkdownReference(path)]) { + final file = await r.resolveMedia(account.id, id, destination); + _require( + file != null && + base64Encode(await File(file).readAsBytes()) == base64Encode(bytes), + 'Literal/encoded Unicode HTML media failed.', + ); + } + final downloaded = await api(account).fetchAttachment( + r.noteById(id)!.serverId!, + path, + destination: File('${directory.path}/unicode-review.png'), + ); + _require( + base64Encode(await downloaded.readAsBytes()) == base64Encode(bytes), + 'Real Unicode download did not round trip.', + ); + checks['literalAndEncodedUnicodeHtmlUploadPublicationDownload'] = true; + + var sequence = 0; + final prefix = DateTime.now().microsecondsSinceEpoch; + for (final draft in [false, true]) { + for (final resolution in [ + NoteConflictResolution.takeRemote, + NoteConflictResolution.keepLocal, + NoteConflictResolution.merge, + NoteConflictResolution.saveAsNew, + ]) { + final suffix = '$prefix-${sequence++}'; + final a = 'A-$suffix', b = 'B-$suffix', c = 'C-$suffix'; + final note = await r.create( + account.id, + title: a, + category: 'BusyMarkReview/Choices', + content: 'choice body', + ); + if (!draft) { + await r.synchronize(account.id); + createdIds.add(r.noteById(note.localId)!.serverId!); + } + final snapshot = r.metadataSnapshot(note.localId); + await r.patchMetadata(snapshot, title: b); + await r.patchMetadata(snapshot, title: c); + await restart(); + await r.resolveConflict( + note.localId, + resolution, + metadataChoices: {NotesMergeAttribute.title: NotesMergeChoice.remote}, + ); + final chosen = resolution == NoteConflictResolution.saveAsNew + ? r.notes.singleWhere( + (n) => + n.localId != note.localId && + n.title == c && + n.serverId == null, + ) + : r.noteById(note.localId)!; + await r.synchronize(account.id); + final published = r.noteById(chosen.localId)!; + createdIds.add(published.serverId!); + final expected = + resolution == NoteConflictResolution.takeRemote || + resolution == NoteConflictResolution.merge + ? b + : c; + _require( + published.title == expected && + (await api(account).get(published.serverId!)).title == expected, + 'Reviewed local choice was substituted during real server synchronization.', + ); + } + } + checks['serverAndDraftEveryReviewedMetadataChoiceRestartRealSync'] = true; + } finally { + await r.dispose(); + } +} + +Future _metadataRaceChecks( + _Connection connection, + NextcloudAccount account, + NotesApiClient Function(NextcloudAccount) api, + Set createdIds, + Map checks, + Directory directory, +) async { + var now = DateTime.now(); + Future open() async { + final r = NotesRepository( + store: await NotesStore.open( + path: '${directory.path}/metadata-race.sqlite3', + ), + clientForAccount: (a) async => api(a), + clock: () => now, + ); + await r.initialize(); + if (r.accounts.isEmpty) await r.upsertAccount(account); + return r; + } + + var r = await open(); + Future restart() async { + await r.dispose(); + r = await open(); + } + + var sequence = 0; + final prefix = DateTime.now().microsecondsSinceEpoch; + try { + for (final draft in [false, true]) { + for (final status in [200, 429, 503]) { + for (final choice in [ + NoteConflictResolution.takeRemote, + NoteConflictResolution.keepLocal, + ]) { + now = DateTime.now(); + final suffix = '$prefix-${sequence++}'; + final a = 'Race A-$suffix', + b = 'Race B-$suffix', + c = 'Race C-$suffix'; + final note = await r.create( + account.id, + title: a, + category: 'BusyMarkRace', + content: 'race body', + ); + if (!draft) { + await r.synchronize(account.id); + createdIds.add(r.noteById(note.localId)!.serverId!); + } + final snapshot = r.metadataSnapshot(note.localId); + await r.patchMetadata(snapshot, title: b); + final sent = r.noteById(note.localId)!; + final held = _HeldWrite(status); + connection.holdNextWrite = held; + final sending = r.synchronize(account.id); + late int revision, requests; + try { + await Future.any([ + held.started.future, + sending.then((_) => throw StateError('No held write.')), + ]); + await r.patchMetadata(snapshot, title: c); + revision = r.noteById(note.localId)!.revision; + requests = connection.noteUpdates + connection.noteCreates; + } finally { + held.release.complete(); + } + await sending; + if (held.executedId != null) createdIds.add(held.executedId!); + var current = r.noteById(note.localId)!; + if (current.serverId != null) createdIds.add(current.serverId!); + _require( + current.revision == revision && + current.ackRevision == + (status == 200 ? sent.revision : sent.ackRevision) && + current.title == c && + current.metadataConflict?.alternative.title == b, + 'Older held response lost the reviewed revision or alternatives.', + ); + now = now.add(const Duration(minutes: 1)); + await r.synchronize(account.id); + await restart(); + await r.synchronize(account.id); + _require( + connection.noteUpdates + connection.noteCreates == requests, + 'Unresolved metadata published after response/retry/restart.', + ); + await r.resolveConflict(note.localId, choice); + final expected = choice == NoteConflictResolution.takeRemote ? b : c; + current = r.noteById(note.localId)!; + _require( + current.title == expected, + 'Explicit metadata choice was substituted.', + ); + if (draft && status == 503) { + await r.synchronize(account.id); + _require( + connection.noteUpdates + connection.noteCreates == requests && + r.noteById(note.localId)!.syncState == + NoteSyncState.creationUncertain, + 'Metadata resolution released an uncertain real creation.', + ); + final candidate = r + .uncertainCreationCandidates(note.localId) + .singleWhere((n) => n.serverId == held.executedId); + await r.resolveConflict( + note.localId, + NoteConflictResolution.useServerNote, + creationCandidateServerId: candidate.serverId, + creationReview: r.creationReview(note.localId, candidate.base!), + ); + } else if (current.syncState == NoteSyncState.conflict) { + // A simulated failed response may follow a real accepted PUT. Review + // that actual server observation separately from the B/C decision. + await r.resolveConflict( + note.localId, + NoteConflictResolution.keepLocal, + ); + } + await r.synchronize(account.id); + current = r.noteById(note.localId)!; + createdIds.add(current.serverId!); + _require( + current.syncState == NoteSyncState.synced && + current.title == expected && + (await api(account).get(current.serverId!)).title == expected, + 'Selected race alternative did not synchronize to the real server.', + ); + } + } + } + checks['heldPutPostResponsesMetadataAlternativesRetryRestartRealSync'] = + true; + checks['heldPossiblyExecutedPostRequiresSeparateCreationDecision'] = true; + } finally { + await r.dispose(); + } +} + +Future _everydayWorkspaceChecks( + NotesRepository r, + _Connection connection, + NextcloudAccount account, + NotesApiClient Function(NextcloudAccount) api, + Set owned, + Map checks, + String root, +) async { + connection.offline = true; + final draft = await r.create( + account.id, + title: 'M2 portable 同じ', + category: 'M2/子/Sub', + content: 'pending alpha beta café 中文 foo_bar', + ); + final attachment = await r.addAttachment( + draft.localId, + filename: 'café%20.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await r.save( + draft.localId, + content: + 'pending alpha beta café 中文 foo_bar\n![image](${attachment.reference})\n', + ); + final second = await r.create( + account.id, + title: draft.title, + category: draft.category, + content: 'duplicate alpha beta', + ); + final outcomes = await r.patchMetadataBatch([ + r.metadataSnapshot(draft.localId), + r.metadataSnapshot(second.localId), + ], favorite: true); + _require( + outcomes.every((o) => o.status == NotesBatchStatus.changed), + 'Offline batch did not persist every reviewed note.', + ); + final search = NotesSearchController(r.store, account.id); + await search.search('"alpha beta" category:"M2/子"'); + _require( + search.state.error == null && + search.state.hits.any((h) => h.localId == draft.localId), + 'Indexed pending note search failed.', + ); + await search.dispose(); + connection.offline = false; + await r.synchronize(account.id); + for (final note in [draft, second]) { + final published = r.noteById(note.localId)!; + if (published.serverId != null) owned.add(published.serverId!); + _require( + published.syncState == NoteSyncState.synced, + 'Offline organization did not synchronize: ${published.syncState.name}; ${published.errorMessage}; deadline=${published.retryNotBefore}; scope=${published.failureScope}', + ); + final actual = await api(account).get(published.serverId!); + _require( + actual.favorite && actual.category == 'M2/子/Sub', + 'Server metadata did not match the batch.', + ); + } + checks['m2OfflineBatchIndexedPendingTextRealSync'] = true; + var offline = NotesOfflineController(r, account.id); + await offline.initialize(); + await offline.setRequirement('category', 'M2'); + await offline.reconcile(); + final published = r.noteById(draft.localId)!; + _require( + (await offline.inspect(published)).available, + 'Category retention is incomplete.', + ); + connection.offline = true; + final media = await r.attachmentAvailability(draft.localId); + _require( + media.required.isNotEmpty && + media.available.length == media.required.length, + 'Retained bytes unavailable offline.', + ); + final export = await NotesTransferService(r).exportSnapshot( + notes: [published, r.noteById(second.localId)!], + destination: root, + cancellation: NotesTransferCancellation(), + ); + _require(export.complete, 'Portable snapshot silently omitted media.'); + final review = await NotesTransferService(r).review(export.path, account.id); + _require( + review.items.length == 2 && review.items.every((i) => i.collision), + 'Import review lost duplicate title collisions.', + ); + final imported = await NotesTransferService(r).importReviewed( + review, + account.id, + cancellation: NotesTransferCancellation(), + ); + _require(imported.every((i) => i.noteId != null), 'Portable import failed.'); + final repeated = await NotesTransferService(r).importReviewed( + review, + account.id, + cancellation: NotesTransferCancellation(), + ); + _require( + repeated.every((i) => i.alreadyImported), + 'Resume duplicated a completed import.', + ); + checks['m2RetainedCategoryPortableMarkdownHtmlMediaDuplicateRoundTrip'] = + true; + await offline.dispose(); + connection.offline = false; + await r.delete(draft.localId); + owned.remove(published.serverId); + final recovered = await r.recoverAsNew(draft.localId); + _require( + recovered.localId != draft.localId && recovered.serverId == null, + 'Recovery overwrote an existing identity.', + ); + await r.synchronize(account.id); + for (final id in [recovered.localId, ...imported.map((i) => i.noteId!)]) { + final note = r.noteById(id)!; + if (note.serverId != null) owned.add(note.serverId!); + _require( + note.syncState == NoteSyncState.synced, + 'Recovered/imported media did not synchronize.', + ); + final actual = await api(account).get(note.serverId!); + _require( + actual.favorite && actual.category == 'M2/子/Sub', + 'Round trip lost exact organization metadata.', + ); + } + checks['m2DeletedNoteRetainedMediaRecoveryAndImportedRealPublication'] = true; +} + +Future _workspaceCorrectionChecks( + _Connection connection, + NextcloudAccount account, + NotesApiClient Function(NextcloudAccount) api, + Set owned, + Map checks, + String root, +) async { + late NotesRepository r; + Future open() async { + r = NotesRepository( + store: await NotesStore.open(path: '$root/corrections.sqlite3'), + clientForAccount: (account) async => api(account), + ); + await r.initialize(); + if (r.accounts.isEmpty) await r.upsertAccount(account); + } + + Future restart() async { + await r.dispose(); + await open(); + } + + Future synchronize() async { + await r.synchronize(account.id); + owned.addAll( + r.notes + .where( + (n) => + n.title.startsWith('Correction') || + n.category.startsWith('Correction'), + ) + .map((n) => n.serverId) + .whereType(), + ); + } + + await open(); + try { + connection.offline = true; + const token = 'correctionneedle'; + final expected = <(String, int?, int?)>{}; + for (var n = 0; n < 10; n++) { + final note = await r.create( + account.id, + title: 'Correction paging $n', + category: 'Correction/Paging', + content: List.filled(32, token).join(' '), + ); + expected.addAll([ + for (var i = 0; i < 32; i++) + ( + note.localId, + i * (token.length + 1), + i * (token.length + 1) + token.length, + ), + ]); + } + final forty = await r.create( + account.id, + title: 'Correction forty', + category: 'Correction/Paging', + content: List.filled(40, 'fortyneedle').join(' '), + ); + await restart(); + final search = NotesSearchController(r.store, account.id); + for (final limit in [80, 160, 240, 320, 400]) { + await search.search(token, limit: limit); + final actual = search.state.hits + .map((h) => (h.localId, h.start, h.end)) + .toSet(); + _require( + actual.length == limit.clamp(0, 320) && + actual.length == search.state.hits.length && + expected.containsAll(actual) && + search.state.truncated == (limit < 320), + 'Indexed occurrence continuation omitted or duplicated results.', + ); + if (limit >= 320) { + _require( + actual.containsAll(expected), + 'Indexed continuation lost final identities.', + ); + } + } + await search.search('fortyneedle'); + _require( + search.state.hits.length == 40 && + search.state.hits.every((h) => h.localId == forty.localId) && + !search.state.truncated, + 'Occurrences beyond 32 were omitted.', + ); + await search.dispose(); + connection.offline = false; + await synchronize(); + checks['correctionCompleteOccurrenceAndDocumentContinuationAfterRestartAndSync'] = + true; + + final source = await Directory('$root/correction-source').create(); + final document = File('${source.path}/Correction-import.md'); + await document.writeAsString( + '![image](./images/caf%C3%A9%2520.png)\n\n', + ); + var service = NotesTransferService(r); + var review = await service.review(document.path, account.id); + review.items.single.category = 'Correction/Import'; + _require( + review.items.single.issues.isNotEmpty, + 'Missing media was not reported.', + ); + final first = await service.importReviewed( + review, + account.id, + cancellation: NotesTransferCancellation(), + ); + await synchronize(); + await Directory('${source.path}/images').create(); + await File('${source.path}/images/café%20.png').writeAsBytes([1, 2, 3]); + await restart(); + service = NotesTransferService(r); + review = await service.review(document.path, account.id); + review.items.single.category = 'Correction/New/子'; + _require( + review.items.single.issues.isEmpty && + review.items.single.media.isNotEmpty, + 'Safe ./ media was rejected.', + ); + final fresh = await service.importReviewed( + review, + account.id, + cancellation: NotesTransferCancellation(), + ); + _require( + !fresh.single.alreadyImported && + fresh.single.noteId != first.single.noteId && + r.noteById(fresh.single.noteId!)!.category == 'Correction/New/子', + 'A deliberately new import reused an old association.', + ); + await synchronize(); + _require( + (await r.attachmentAvailability(fresh.single.noteId!)).available.length == + 1, + 'Repaired media was not staged/published for Markdown and HTML.', + ); + checks['correctionNewImportHonorsReviewedCategoryAndRepairedRelativeMedia'] = + true; + + await Directory('${source.path}/chapters').create(); + await File('${source.path}/chapters/Correction-nested.md').writeAsString( + '![image](../images/caf%C3%A9%2520.png)\n\n', + ); + await File('$root/outside.png').writeAsBytes([9, 9, 9]); + await File( + '${source.path}/Correction-escape.md', + ).writeAsString('![image](../outside.png)\n\n'); + final paths = await service.review( + source.path, + account.id, + category: 'Correction/Relative', + ); + final nested = paths.items.singleWhere( + (i) => i.title == 'Correction-nested', + ); + final escape = paths.items.singleWhere( + (i) => i.title == 'Correction-escape', + ); + _require( + nested.media.isNotEmpty && + nested.issues.isEmpty && + escape.media.isEmpty && + escape.issues.isNotEmpty, + 'Local-import containment failed.', + ); + for (final item in paths.items) { + item.selected = identical(item, nested); + } + final importedPaths = await service.importReviewed( + paths, + account.id, + cancellation: NotesTransferCancellation(), + ); + await synchronize(); + _require( + r.noteById(importedPaths.single.noteId!)!.syncState == + NoteSyncState.synced, + 'Safe nested relative media did not synchronize.', + ); + checks['correctionRelativeMarkdownHtmlInsideRootAndOutsideRootBoundary'] = + true; + + final resumeSource = await Directory('$root/correction-resume').create(); + for (var n = 0; n < 3; n++) { + await File( + '${resumeSource.path}/Correction-$n.md', + ).writeAsString('resumable $n'); + } + final original = await service.review( + resumeSource.path, + account.id, + category: 'Correction/Resume', + ); + final cancellation = NotesTransferCancellation(); + final partial = await service.importReviewed( + original, + account.id, + cancellation: cancellation, + onProgress: (_, _) => cancellation.cancel(), + ); + await restart(); + service = NotesTransferService(r); + final resumed = await service.resumeImport( + original.operationId, + account.id, + ); + final finished = await service.importReviewed( + resumed, + account.id, + cancellation: NotesTransferCancellation(), + ); + _require( + finished.length == 3 && + finished.first.alreadyImported && + finished.first.noteId == partial.single.noteId && + finished.map((o) => o.noteId).toSet().length == 3, + 'Restarting an import duplicated its durable items.', + ); + await synchronize(); + checks['correctionDurableReviewedOperationResumesExactlyOnce'] = true; + + final liveId = fresh.single.noteId!; + final published = r.noteById(liveId)!; + final reference = (await r.attachments(liveId)).first.remotePath!; + if (account.supportsAttachmentDeletion) { + await r.deleteAttachment( + liveId, + attachmentMarkdownReference(reference), + retainForHistory: true, + ); + } else { + // 6.0.2 has no remote DELETE contract: exercise the ordinary local draft + // deletion boundary, which retains the same recovery-only tombstone. + final draft = await r.create( + account.id, + title: 'Correction local tombstone', + category: 'Correction/Offline', + ); + final file = await r.addAttachment( + draft.localId, + filename: 'local.png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await r.save(draft.localId, content: '![image](${file.reference})'); + await r.deleteAttachment( + draft.localId, + file.reference, + retainForHistory: true, + ); + final availability = await r.attachmentAvailability(draft.localId); + _require( + availability.required.length == 1 && + availability.available.isEmpty && + await r.store.attachmentBytes(file.id) != null, + 'Local recovery tombstone counted as live offline media.', + ); + } + if (account.supportsAttachmentDeletion) { + final availability = await r.attachmentAvailability(liveId); + _require( + availability.available.isEmpty && + (await r.attachments(liveId)).any((a) => a.state == 'deleted'), + 'Remote recovery tombstone counted as live offline media.', + ); + connection.offline = true; + await restart(); + final offline = NotesOfflineController(r, account.id); + await offline.initialize(); + await offline.setRequirement('note', liveId); + _require( + !(await offline.inspect(r.noteById(liveId)!)).available && + await r.resolveCachedMedia( + account.id, + liveId, + attachmentMarkdownReference(reference), + ) == + null, + 'Restart resurrected deleted media.', + ); + await offline.dispose(); + final replacement = await r.addAttachment( + liveId, + filename: 'café%20.png', + bytes: Uint8List.fromList([7, 8, 9]), + ); + await r.save(liveId, content: '![image](${replacement.reference})'); + connection.offline = false; + await synchronize(); + _require( + (await r.attachmentAvailability(liveId)).available.length == 1 && + await r.resolveCachedMedia( + account.id, + liveId, + attachmentMarkdownReference(reference), + ) != + null, + 'Legitimate replacement upload did not supersede the tombstone.', + ); + } + checks['correctionLiveOfflineEligibilitySeparateFromRecoveryBytes'] = true; + + await r.delete(published.localId); + owned.remove(published.serverId); + await restart(); + final recovery = NotesSearchController(r.store, account.id, recovery: true); + await recovery.search('title:Correction-import'); + _require( + recovery.state.hits.any((h) => h.localId == published.localId), + 'Recovery cannot search its retained deleted records.', + ); + final live = NotesSearchController(r.store, account.id); + await live.search('title:Correction-import'); + _require( + live.state.hits.every((h) => h.localId != published.localId), + 'Deleted recovery record leaked into live search.', + ); + final restored = await r.recoverAsNew(published.localId); + _require( + restored.localId != published.localId, + 'Recovery search overwrote the retained note.', + ); + await synchronize(); + _require( + r.noteById(restored.localId)!.syncState == NoteSyncState.synced, + 'Recovered search result did not synchronize.', + ); + await recovery.dispose(); + await live.dispose(); + checks['correctionRecoveryScopedSearchPreviewDataAndNewIdentityPublication'] = + true; + } finally { + connection.offline = false; + owned.addAll( + r.notes + .where( + (n) => + n.title.startsWith('Correction') || + n.category.startsWith('Correction'), + ) + .map((n) => n.serverId) + .whereType(), + ); + await r.dispose(); + } +} diff --git a/tools/nextcloud_notes_native_key.py b/tools/nextcloud_notes_native_key.py new file mode 100644 index 00000000..1e851b7b --- /dev/null +++ b/tools/nextcloud_notes_native_key.py @@ -0,0 +1,52 @@ +"""Send an acceptance key on the caller's isolated X11 display using XTest. + +The desktop harness focuses its own test window first. No additional package or +production dependency is required; X11/XTest are supplied by the Linux fixture. +""" +import ctypes +import ctypes.util +import os +import sys + +if os.environ.get('BUSYMARK_NATIVE_ACCEPTANCE') != '1': + raise SystemExit('Only the isolated Notes desktop acceptance harness may invoke this helper.') +bindings = { + 'ctrl+1': [b'Control_L', b'1'], + 'ctrl+2': [b'Control_L', b'2'], + 'ctrl+f': [b'Control_L', b'f'], + 'ctrl+k': [b'Control_L', b'k'], + 'ctrl+p': [b'Control_L', b'p'], + 'ctrl+shift+p': [b'Control_L', b'Shift_L', b'p'], + 'escape': [b'Escape'], + 'home': [b'Home'], + 'down': [b'Down'], + 'ctrl+z': [b'Control_L', b'z'], + 'ctrl+shift+z': [b'Control_L', b'Shift_L', b'z'], + 'enter': [b'Return'], +} +if len(sys.argv) != 2 or sys.argv[1] not in bindings: + raise SystemExit('Expected an acceptance search, Quick Open or dismissal key.') +x11 = ctypes.CDLL(ctypes.util.find_library('X11')) +xtest = ctypes.CDLL(ctypes.util.find_library('Xtst')) +x11.XOpenDisplay.argtypes = [ctypes.c_char_p] +x11.XOpenDisplay.restype = ctypes.c_void_p +x11.XStringToKeysym.argtypes = [ctypes.c_char_p] +x11.XStringToKeysym.restype = ctypes.c_ulong +x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong] +x11.XKeysymToKeycode.restype = ctypes.c_uint +x11.XFlush.argtypes = [ctypes.c_void_p] +x11.XCloseDisplay.argtypes = [ctypes.c_void_p] +xtest.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] +display = x11.XOpenDisplay(os.environ['DISPLAY'].encode()) +if not display: + raise SystemExit('Cannot open the isolated test display.') +try: + codes = [x11.XKeysymToKeycode(display, x11.XStringToKeysym(key)) + for key in bindings[sys.argv[1]]] + for code, pressed in ([(code, 1) for code in codes] + + [(code, 0) for code in reversed(codes)]): + if not xtest.XTestFakeKeyEvent(display, code, pressed, 0): + raise RuntimeError('Native key injection failed.') + x11.XFlush(display) +finally: + x11.XCloseDisplay(display) diff --git a/tools/nextcloud_notes_performance.dart b/tools/nextcloud_notes_performance.dart new file mode 100644 index 00000000..383de853 --- /dev/null +++ b/tools/nextcloud_notes_performance.dart @@ -0,0 +1,290 @@ +import 'dart:isolate'; +import 'package:busymark/src/references/reference_index.dart'; +// Reproducible real SQLite corpus. Run with `dart build cli` for AOT timing. +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:sqlite3/sqlite3.dart'; +import 'package:uuid/uuid.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_search_controller.dart'; + +Future main(List args) async { + if (args.length != 1) throw ArgumentError('Pass a private output directory.'); + final root = await Directory(args.single).create(recursive: true); + final path = '${root.path}/performance.sqlite3'; + if (await File(path).exists()) { + throw StateError('Use an empty output directory.'); + } + final store = await NotesStore.open(path: path); + const uuid = Uuid(); + final accountId = uuid.v4(); + await store.saveAccount( + NextcloudAccount( + id: accountId, + server: Uri.parse('https://benchmark.invalid'), + loginName: 'corpus', + appVersion: '6.1.0', + ), + ); + final beforeRss = ProcessInfo.currentRss; + var corpusBytes = 0, mediaBytes = 0; + final notes = []; + final seed = Stopwatch()..start(); + for (var batch = 0; batch < 10000; batch += 32) { + final group = []; + for (var i = batch; i < batch + 32 && i < 10000; i++) { + final source = List.generate( + 14, + (line) => + 'Row $line: café cafe\u0301 中文 日本語 Привет مرحبا 한국어 Ελληνικά. foo_bar C++ x.y 100% independent alpha beta. record-$i token${i % 97}.', + ).join('\n'); + final note = NextcloudNote( + localId: uuid.v4(), + accountId: accountId, + serverId: i + 1, + title: 'Duplicate ${i % 250}', + category: i % 10 == 0 ? '' : 'Area ${i % 20}/Group ${i % 71}/子', + content: i % 5 == 0 + ? '$source\n![media](.attachments.${i + 1}/café%2520.png)' + : source, + revision: 1, + ackRevision: 1, + localActivityMicros: 1700000000000000 + i, + syncState: NoteSyncState.synced, + ); + corpusBytes += utf8.encode(note.content).length; + group.add(note); + notes.add(note); + } + await store.commit(notes: group); + } + for (final note in notes.where((n) => n.serverId! % 5 == 1)) { + final bytes = Uint8List(2048); + await store.saveAttachment( + NotesAttachment( + id: uuid.v4(), + noteId: note.localId, + filename: 'café%20.png', + reference: '.attachments.${note.serverId}/café%2520.png', + remotePath: '.attachments.${note.serverId}/café%20.png', + state: 'cached', + ), + bytes, + ); + mediaBytes += bytes.length; + } + seed.stop(); + await store.rebuildIndex(); + final index = Stopwatch()..start(); + while (await store.indexStep() != 0) {} + index.stop(); + final search = NotesSearchController(store, accountId); + final queries = [ + 'token42', + '"alpha beta" category:"Area 7"', + '中文', + 'C++', + 'é', + ]; + final latencies = >{}; + for (final query in queries) { + await search.search(query); + if (search.state.error != null || search.state.hits.isEmpty) { + throw StateError('Corpus query failed.'); + } + final times = []; + for (var i = 0; i < 10; i++) { + final watch = Stopwatch()..start(); + await search.search(query); + watch.stop(); + times.add(watch.elapsedMicroseconds / 1000); + } + times.sort(); + latencies[query] = times; + } + await store.rebuildIndex(); + final saves = []; + final building = () async { + while (await store.indexStep() != 0) {} + }(); + for (var i = 0; i < 40; i++) { + final watch = Stopwatch()..start(); + final note = notes[i].copyWith( + content: '${notes[i].content}\nedit $i', + revision: 2, + ); + await store.saveNote(note); + watch.stop(); + saves.add(watch.elapsedMicroseconds / 1000); + await Future.delayed(Duration.zero); + } + await building; + saves.sort(); + final querying = search.search('nothing-matches-this-corpus'); + final duringQuery = Stopwatch()..start(); + await store.saveNote( + notes[0].copyWith(content: 'edited during a short-query scan', revision: 3), + ); + duringQuery.stop(); + await querying; + final repository = NotesRepository( + store: store, + clientForAccount: (_) async => throw StateError('Offline benchmark'), + ); + await repository.initialize(); + final importTimes = []; + final queryingImports = search.search('é', limit: 2000); + for (var i = 0; i < 20; i++) { + final watch = Stopwatch()..start(); + await repository.importLocal( + accountId: accountId, + sourceKey: 'benchmark:$i', + title: 'Imported $i', + category: 'Import/子', + content: notes[i].content, + media: {}, + ); + watch.stop(); + importTimes.add(watch.elapsedMicroseconds / 1000); + } + await queryingImports; + importTimes.sort(); + final referenceDocuments = notes + .map( + (note) => ReferenceDocument( + identity: note.localId, + localId: note.localId, + serverId: note.serverId, + title: note.title, + source: + '${note.content}\n# Technical\n[related](https://benchmark.invalid/index.php/apps/notes/note/${note.serverId! % 10000 + 1}) ' + '[same](#technical) [reference][related]\n\n[related]: https://benchmark.invalid/index.php/apps/notes/note/${note.serverId! % 10000 + 1}\n', + ), + ) + .toList(); + final referenceRss = ProcessInfo.currentRss; + final initialReferences = Stopwatch()..start(); + final referenceWork = _buildReferenceIndex(referenceDocuments); + final referenceSaveTimes = []; + for (var i = 0; i < 20; i++) { + final watch = Stopwatch()..start(); + await store.saveNote( + notes[i].copyWith( + content: '${notes[i].content}\nreference workload save $i', + revision: 4, + ), + ); + referenceSaveTimes.add(watch.elapsedMicroseconds / 1000); + } + final referenceSearch = Stopwatch()..start(); + await search.search('token42'); + referenceSearch.stop(); + final referenceIndex = await referenceWork; + initialReferences.stop(); + final incremental = Stopwatch()..start(); + final changed = ReferenceDocument( + identity: referenceDocuments.first.identity, + localId: referenceDocuments.first.localId, + serverId: 1, + title: referenceDocuments.first.title, + source: '${referenceDocuments.first.source}\n[new](#technical)', + ); + final updated = await _parseReferenceDocument(changed); + final updatedIndex = ReferenceIndex( + parsed: [ + for (final value in referenceIndex.parsedDocuments) + value.document.identity == changed.identity ? updated : value, + ], + server: referenceIndex.server, + previous: referenceIndex, + ); + if (updatedIndex.query(changed.identity).total < 4) { + throw StateError('Incremental reference update failed.'); + } + incremental.stop(); + final referenceQueryTimes = []; + for (var i = 0; i < 100; i++) { + final watch = Stopwatch()..start(); + final page = referenceIndex.query( + referenceDocuments[i].identity, + incoming: i.isOdd, + ); + if (page.total < 1) throw StateError('Reference corpus query failed.'); + referenceQueryTimes.add(watch.elapsedMicroseconds / 1000); + } + referenceQueryTimes.sort(); + referenceSaveTimes.sort(); + final referenceReport = { + 'documentCount': referenceDocuments.length, + 'occurrenceCount': referenceIndex.references.length, + 'corpusUtf8Bytes': referenceDocuments.fold( + 0, + (n, d) => n + utf8.encode(d.source).length, + ), + 'initialIndexMilliseconds': initialReferences.elapsedMilliseconds, + 'incrementalDocumentMilliseconds': incremental.elapsedMicroseconds / 1000, + 'queryMedianMilliseconds': referenceQueryTimes[50], + 'queryP95Milliseconds': referenceQueryTimes[95], + 'searchDuringIndexMilliseconds': referenceSearch.elapsedMicroseconds / 1000, + 'saveDuringIndexMedianMilliseconds': referenceSaveTimes[10], + 'saveDuringIndexP95Milliseconds': referenceSaveTimes[19], + 'rssBeforeBytes': referenceRss, + 'rssAfterBytes': ProcessInfo.currentRss, + }; + final report = { + 'references': referenceReport, + 'count': 10000, + 'categories': notes.map((n) => n.category).toSet().length, + 'duplicateTitleCount': 250, + 'corpusUtf8Bytes': corpusBytes, + 'attachmentCount': 2000, + 'attachmentBytes': mediaBytes, + 'sqlite': sqlite3.version.libVersion, + 'dart': Platform.version, + 'os': Platform.operatingSystemVersion, + 'processors': Platform.numberOfProcessors, + 'aot': const bool.fromEnvironment('dart.vm.product'), + 'seedAndIndexMilliseconds': seed.elapsedMilliseconds, + 'initialRebuildMilliseconds': index.elapsedMilliseconds, + 'warmSearchMilliseconds': { + for (final entry in latencies.entries) + entry.key: {'median': entry.value[5], 'p95': entry.value[9]}, + }, + 'saveDuringRebuildMilliseconds': { + 'median': saves[20], + 'p95': saves[38], + 'maximum': saves.last, + }, + 'saveDuringQueryMilliseconds': duringQuery.elapsedMicroseconds / 1000, + 'importDuringSearchMilliseconds': { + 'median': importTimes[10], + 'p95': importTimes[19], + }, + 'rssBeforeBytes': beforeRss, + 'rssAfterBytes': ProcessInfo.currentRss, + 'maximumRssBytes': ProcessInfo.maxRss, + 'databaseBytes': await File(path).length(), + }; + await File( + '${root.path}/report.json', + ).writeAsString(const JsonEncoder.withIndent(' ').convert(report)); + stdout.writeln(const JsonEncoder.withIndent(' ').convert(report)); + await search.dispose(); + await repository.dispose(); +} + +Future _buildReferenceIndex( + List documents, +) => Isolate.run( + () => ReferenceIndex( + parsed: documents.map(ParsedReferenceDocument.new).toList(), + server: Uri.parse('https://benchmark.invalid'), + ), +); +Future _parseReferenceDocument( + ReferenceDocument document, +) => Isolate.run(() => ParsedReferenceDocument(document)); diff --git a/tools/test_web_render_host.sh b/tools/test_web_render_host.sh new file mode 100755 index 00000000..d22a0075 --- /dev/null +++ b/tools/test_web_render_host.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +ulimit -c 0 +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +engine_root="$project_root/linux/flutter/ephemeral" +test -f "$engine_root/libflutter_linux_gtk.so" +assets="${BUSYMARK_VISUALIZATION_ASSETS:-$project_root/build/linux/x64/release/bundle/share/busymark/visualization}" +test -f "$assets/harness.html" +fixture_root="$(mktemp -d /tmp/bm-web-host.XXXXXX)" +trap 'rm -rf "$fixture_root"' EXIT +"${CXX:-clang++}" -std=c++17 -Wall -Wextra -Werror \ + "$project_root/tools/web_render_host_probe.cc" \ + -I"$engine_root" $(pkg-config --cflags --libs gtk+-3.0 webkit2gtk-4.1) \ + -L"$engine_root" -lflutter_linux_gtk -Wl,-rpath,"$engine_root" \ + -Wl,--wrap=fl_engine_get_binary_messenger \ + -Wl,--wrap=webkit_web_context_new_ephemeral \ + -Wl,--wrap=webkit_web_view_new_with_context \ + -Wl,--wrap=webkit_web_view_load_uri \ + -Wl,--wrap=g_file_get_contents \ + -o "$fixture_root/probe" +export BUSYMARK_VISUALIZATION_ASSETS="$assets" +export GSETTINGS_BACKEND=memory +export NO_AT_BRIDGE=1 +unset BUSYMARK_RELEASE_SMOKE +# Each case gets a fresh process and profile. Keep WebKit's sandbox enabled. +for operation in no-init pre-cancel renderMermaid renderPlantUml renderMathBatch \ + inspectOpenApi parseOpenApi rasterizeSvg openOpenApiReference concurrent-cancel \ + active-cancel engine-cancel snapshot-cancel engine-failure graphviz-failure failure load-failure harness-failure \ + shutdown-scheduled shutdown-loading shutdown-readiness shutdown-active \ + shutdown-channel shutdown-engine reference-shutdown recovery; do + profile="$fixture_root/$operation" + mkdir -p "$profile"/{home,config,cache,data,state,tmp} + HOME="$profile/home" XDG_CONFIG_HOME="$profile/config" \ + XDG_CACHE_HOME="$profile/cache" XDG_DATA_HOME="$profile/data" \ + XDG_STATE_HOME="$profile/state" TMPDIR="$profile/tmp" \ + timeout --signal=TERM 45s "$fixture_root/probe" "$operation" +done diff --git a/tools/validate_writerside_conformance.sh b/tools/validate_writerside_conformance.sh index 49d5eb79..76d9af11 100755 --- a/tools/validate_writerside_conformance.sh +++ b/tools/validate_writerside_conformance.sh @@ -27,6 +27,11 @@ if ! docker run --rm \ exit 1 fi +if [[ -n "${WRITERSIDE_CONFORMANCE_OUTPUT:-}" ]]; then + mkdir -p -- "$WRITERSIDE_CONFORMANCE_OUTPUT" + cp -a -- "$output_root/artifacts/." "$WRITERSIDE_CONFORMANCE_OUTPUT/" +fi + python3 - "$output_root/artifacts/report.json" <<'PYREPORT' import json, sys report = json.load(open(sys.argv[1])) @@ -40,7 +45,48 @@ if [[ "$source_root" == "test/fixtures/writerside/conformance_project" ]]; then test/fixtures/writerside/conformance_semantics.json --check fi -if [[ -n "${WRITERSIDE_CONFORMANCE_OUTPUT:-}" ]]; then - mkdir -p -- "$WRITERSIDE_CONFORMANCE_OUTPUT" - cp -a -- "$output_root/artifacts/." "$WRITERSIDE_CONFORMANCE_OUTPUT/" + +if [[ "$source_root" == "test/fixtures/writerside/reference_conformance" ]]; then + python3 tools/verify_writerside_reference_fixture.py \ + "$output_root/artifacts/webHelpGUIDE2-all.zip" +fi +if [[ "$source_root" == "test/fixtures/writerside/property_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" +fi +if [[ "$source_root" == "test/fixtures/writerside/property_contract_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" contract +fi +if [[ "$source_root" == "test/fixtures/writerside/list_capitalization_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" list-caps + for variant in generic variable list-type list-start; do + variant_sources="$work_root/$variant" + variant_output="$work_root/$variant-output" + cp -a -- "$source_copy" "$variant_sources" + cp -- "$source_copy/variants/$variant.cfg" "$variant_sources/writerside.cfg" + if [[ -f "$source_copy/variants/$variant.tree" ]]; then + cp -- "$source_copy/variants/$variant.tree" "$variant_sources/conformance.tree" + fi + WRITERSIDE_CONFORMANCE_OUTPUT="$variant_output" bash tools/validate_writerside_conformance.sh "$variant_sources" Conformance/conformance + python3 tools/verify_writerside_property_fixture.py "$variant_output/webHelpCONFORMANCE2-all.zip" "list-caps-$variant" + done +fi +if [[ "$source_root" == "test/fixtures/writerside/property_defaults_conformance" ]]; then + python3 tools/verify_writerside_property_fixture.py \ + "$output_root/artifacts/webHelpCONFORMANCE2-all.zip" defaults +fi +if [[ "$source_root" == "test/fixtures/writerside/conformance_project" ]]; then + evidence_root="${WRITERSIDE_CONFORMANCE_OUTPUT:-$work_root/evidence}" + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/reference-links" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/reference_conformance ReferenceLinks/guide + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/properties" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/property-defaults" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_defaults_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/property-contract" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/property_contract_conformance Conformance/conformance + WRITERSIDE_CONFORMANCE_OUTPUT="$evidence_root/list-capitalization" bash tools/validate_writerside_conformance.sh \ + test/fixtures/writerside/list_capitalization_conformance Conformance/conformance fi diff --git a/tools/verify_writerside_property_fixture.py b/tools/verify_writerside_property_fixture.py new file mode 100644 index 00000000..88abecac --- /dev/null +++ b/tools/verify_writerside_property_fixture.py @@ -0,0 +1,347 @@ +"""Assert published behavior of the pinned property fixtures, not just exit status.""" +import sys +import zipfile +from html.parser import HTMLParser + + +class Elements(HTMLParser): + def __init__(self, source): + super().__init__() + self.stack = [] + self.elements = {} + self.texts = {} + self.images = {} + self.links = {} + self.descendants = {} + self.feed(source) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + for _, parent in self.stack: + if 'id' in parent: self.descendants.setdefault(parent['id'], []).append((tag, attrs)) + if 'id' in parent and tag == 'img': self.images.setdefault(parent['id'], []).append(attrs) + if 'id' in parent and tag == 'a': self.links.setdefault(parent['id'], []).append(attrs) + if 'id' in attrs: + self.elements[attrs['id']] = (attrs, list(self.stack)) + if tag not in {'meta', 'link', 'img', 'br', 'hr', 'input'}: + self.stack.append((tag, attrs)) + + def handle_endtag(self, tag): + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index][0] == tag: + del self.stack[index:] + break + + def handle_data(self, data): + for _, attrs in self.stack: + if 'id' in attrs: + self.texts[attrs['id']] = self.texts.get(attrs['id'], '') + data + + +def verify_literal_references(actual): + link = actual.elements['code-reference'][0] + assert link['href'].endswith(('properties.html', 'authoring.html')), link + assert actual.texts['code-reference'] == '{id="linked-literal"}' + assert any(tag == 'code' or 'code' in attrs.get('class', '').split() + for tag, attrs in actual.descendants['code-reference']) + image = actual.elements['alt-reference'][0] + assert image['alt'] == '{id="image-literal"}', image + assert image['src'].endswith('/logo.png') and image['width'] == '24', image + assert 'linked-literal' not in actual.elements and 'image-literal' not in actual.elements + + +def verify_numbered_item(actual, identity, outer_start, nested_start): + _, parents = actual.elements[identity] + lists = [attrs for tag, attrs in parents if tag == 'ol'] + assert lists[-1]['start'] == str(outer_start), (identity, lists) + nested = [attrs for tag, attrs in actual.descendants[identity] if tag == 'ol'] + assert len(nested) == 1 and nested[0]['start'] == str(nested_start), (identity, nested) + + +def verify_inspector_presentation(archive): + actual = Elements(archive.read('inspector-presentation.html').decode('utf-8')) + identity = 'diagnosed-cause-map' + assert actual.texts[identity] == '2. Diagnosed cause and source map' + collapse = [attrs for _, attrs in actual.elements[identity][1] + if attrs.get('class') == 'collapse__title'] + assert len(collapse) == 1, actual.elements[identity] + # The pinned builder omits data-expanded for its collapsed initial state. + assert collapse[0].get('data-expanded') in {None, 'false'}, collapse[0] + generated = 'long-generated-anchor-' + '-'.join(['documentation'] * 12) + assert generated in actual.elements, generated + print('Native presentation HTML: authored chapter anchor, published collapse and unchanged generated anchor verified') + + +def verify_explicit_inspection(archive, native): + page = Elements(archive.read('explicit-inspection.html').decode('utf-8')) + keys = [(attrs.get('data-key'), page.texts[identity]) + for identity, (attrs, _) in page.elements.items() + if attrs.get('data-key') in {'$Save', '$Copy'}] + assert keys == ([('$Save', 'Ctrl+S'), ('$Copy', 'Ctrl+C')] if native else + [('$Copy', 'Ctrl+C'), ('$Copy', 'Ctrl+C')]), keys + links = [attrs['href'] for identity, (attrs, _) in page.elements.items() + if page.texts.get(identity) == 'Read' and 'href' in attrs] + assert links == (['lists.html#title-case', 'lists.html#absent'] if native else + ['lists.html#absent', 'lists.html#absent']), links + if native: + for name in ['explicit-inspection.html', 'semantic-commands.html']: + actual = Elements(archive.read(name).decode('utf-8')) + for css, text in [('control', 'Open & Files'), + ('filepath', 'docs/example.txt'), + ('menupath', 'File | Open'), + ('keystroke', 'Ctrl+M')]: + matching = [attrs for identity, (attrs, _) in actual.elements.items() + if css in attrs.get('class', '').split() and + actual.texts.get(identity) == text] + assert len(matching) == 1, (name, css, matching) + if css == 'keystroke': + assert 'data-key' not in matching[0], (name, matching) + for style in ['tip', 'note', 'warning']: + matching = [identity for identity, (attrs, _) in actual.elements.items() + if attrs.get('data-type') == style and + actual.texts.get(identity, '').strip() == + style.title() + ' example.'] + assert len(matching) == 1, (name, style, matching) + quotes = [identity for identity, (attrs, _) in actual.elements.items() + if 'quote' in attrs.get('class', '').split() and + actual.texts.get(identity, '').strip() == 'Quote example.'] + assert len(quotes) == 1, (name, quotes) + print('Explicit inspection HTML: exact multiline reference owners' + + (', four semantic commands, admonitions and quote saved by the UI verified' + if native else ' verified')) + + +if len(sys.argv) > 2 and sys.argv[2].startswith('list-caps'): + mode = sys.argv[2] + with zipfile.ZipFile(sys.argv[1]) as archive: + if mode == 'list-caps-list-start': + page = Elements(archive.read('numbering.html').decode()) + for identity, start in [('native-seven','7'),('native-one','5'),('native-nine','9')]: + assert page.elements[identity][0]['start'] == start, (identity, page.elements[identity]) + else: + xml = Elements(archive.read('lists.html').decode()) + md = Elements(archive.read('native-lists.html').decode()) + original = 'a journey through HTTP and APIs' + title = 'A Journey Through HTTP And APIs' + expected = title if mode in {'list-caps-generic','list-caps-variable'} else original + assert xml.texts['absent'] == expected, xml.texts['absent'] + assert xml.texts['title-case'] == title + assert xml.texts['sentence-case'] == 'A journey through HTTP and APIs' + assert xml.texts['as-written'] == original + assert xml.texts['upper-case'] == 'A JOURNEY THROUGH HTTP AND APIS' + assert xml.texts['variable-case'] == title + identity = 'a-journey-through-http-and-apis' + assert md.texts[identity] == ('A JOURNEY THROUGH HTTP AND APIS' if mode == 'list-caps-native' else expected) + assert xml.texts['lists.topic'] == 'XML lists and capitalization' + toc = __import__('json').loads(archive.read('HelpTOC.json'))['entities']['pages'] + assert toc['lists']['title'] == 'XML lists and capitalization' + assert toc['native-lists']['title'] == 'Native lists and capitalization' + for page, letter_id, check_id, none_id in [(xml,'letters','checkbox','markerless'),(md,'native-letters','native-checkbox','native-markerless')]: + letter = page.elements[letter_id][0] + assert letter['type'] == 'a' and letter['start'] == '3' and letter['data-columns'] == '2',letter + assert page.texts[letter_id] == 'AlphaZebra' + assert page.elements[check_id][0]['class'] == 'list _checkbox' + check_items = [attrs for tag,attrs in page.descendants[check_id] if tag == 'li'] + assert len(check_items) == 2 + if page is xml: + assert check_items[0].get('checked') == 'true' + else: + # The pinned builder removes GFM checkbox prefixes but + # does not initialize checked state from them. XML checked + # attributes retain that separately verified capability. + assert all('checked' not in attrs for attrs in check_items) + assert page.elements[none_id][0]['class'] == 'list _none' + assert page.texts[none_id] == 'ZebraAlpha' + if mode == 'list-caps-native': + parent = md.elements['reviewed-list'][0] + assert parent['start'] == '10' and parent['data-columns'] == '2',parent + assert md.elements['reviewed-nested'][0]['start'] == '12' + assert md.texts['reviewed-list'].index('Sibling') < md.texts['reviewed-list'].index('Parent caption') + assert md.elements['embedded'][0]['start'] == '6' + if mode in {'list-caps', 'list-caps-native'}: + source = archive.read('nested-references.html').decode() + references = Elements(source) + for identity in ['link-one', 'link-two']: + assert references.elements[identity][0]['href'] == 'lists.html' + assert references.texts[identity] == 'Same' + # All parent, nested, quoted and XML variables remain distinct. + expected = 12 if mode == 'list-caps-native' else 13 + assert source.count('Resolved product') == expected + assert source.count('Resolved long product') == (1 if mode == 'list-caps-native' else 0) + assert '"$Copy"' in source and 'char-C' in source + verify_explicit_inspection(archive, mode == 'list-caps-native') + print('List/capitalization HTML: '+mode+' published markers, numbering, columns, sorting, checkbox controls and capitalization verified') + sys.exit(0) + +if len(sys.argv) > 2 and sys.argv[2] == 'native-defaults': + with zipfile.ZipFile(sys.argv[1]) as archive: + actual = Elements(archive.read('properties.html').decode('utf-8')) + layout = actual.elements['layout-default'][0] + assert layout['data-style'] == 'title-left', layout + assert layout['data-title-width'] == 'medium', layout + print('Native reset layout HTML: module Medium verified') + sys.exit(0) + +if len(sys.argv) > 2 and sys.argv[2] == 'native': + with zipfile.ZipFile(sys.argv[1]) as archive: + verify_inspector_presentation(archive) + for page in ['inspector.html', 'inspector-markdown.html']: + actual = Elements(archive.read(page).decode('utf-8')) + code = actual.elements['saved-code'][0] + assert code['class'] == 'code-block', (page, code) + assert code['data-emphasize-lines'] == '1', (page, code) + snippet = actual.texts['saved-code'] + assert 'second' in snippet and 'third' in snippet and 'first' not in snippet + if page == 'inspector.html': + assert any(a.get('data-expandable') == 'true' and + a.get('data-expanded') == 'true' + for a, _ in actual.elements.values()) + corrections = Elements(archive.read('inspector-corrections.html').decode('utf-8')) + assert 'literal-code' not in corrections.elements + assert any('{id="literal-code"}' in text and 'Try' in text for text in corrections.texts.values()) + item, parents = corrections.elements['saved-item'] + assert item['class'] == 'list__item' and any(tag == 'ul' for tag, _ in parents) + assert corrections.elements['saved-note'][0]['data-type'] == 'note' + task, parents = corrections.elements['saved-task'] + assert 'checked' not in task and any(a.get('class') == 'list _checkbox' for _, a in parents) + assert any(a.get('checked') == 'true' for a, _ in corrections.elements.values()) + verify_literal_references(corrections) + verify_numbered_item(corrections, 'saved-numbered-item', 10, 4) + verify_numbered_item(corrections, 'saved-marker-item', 3, 4) + print('Native saved HTML: source inclusion, snippet emphasis, inherited state, literal inline references, ordered starts, list-item and admonition IDs verified') + sys.exit(0) + +# The formal Topic/Chapter/Paragraph contract has an independent HTML oracle. +# In particular, accepted H1 attribute syntax is NOT evidence of publishing it. +if len(sys.argv) > 2 and sys.argv[2] in {'contract', 'contract-native'}: + import json, re + native = sys.argv[2] == 'contract-native' + with zipfile.ZipFile(sys.argv[1]) as archive: + if native: + verify_inspector_presentation(archive) + for xml in [True, False]: + stem = 'contract-xml' if xml else 'contract-markdown' + source = archive.read(stem + '.html').decode() + actual = Elements(source) + topic_text = actual.texts[stem + ('.topic' if xml else '.md')] + expected_topic = ('Edited XML topic' if xml else 'Edited Markdown topic') if native else ('XML default topic' if xml else 'Markdown topic heading') + if native and xml: + # Native Edit Title uses the existing instance child, not the default. + assert topic_text == 'Published ' + stem + ' instance', topic_text + elif xml: + assert topic_text == expected_topic, topic_text + else: + anchor = 'edited-topic-anchor' if native else 'authored-topic-anchor' + assert topic_text == expected_topic + ' {id="' + anchor + '"}', topic_text + assert anchor not in actual.elements + assert 'Published contract-markdown instance' not in source + chapter = ('edited-' if native else '') + ('xml-chapter' if xml else 'markdown-chapter') + assert actual.texts[chapter] == (('Edited XML chapter' if native else 'XML default chapter') if xml else ('Edited Markdown override' if native else 'Published Markdown override')) + nav_match = re.search(r'', source, re.S) + assert nav_match, stem + nav = json.loads(nav_match[1]) + ids = {item['id'] for item in nav} + prefix = 'xml' if xml else 'markdown' + assert {chapter, prefix + '-procedure', prefix + '-tab', prefix + '-term'} <= ids, ids + assert 'Literal %product%.' in source, stem + assert 'Excluded XML content.' not in source and 'Excluded Markdown content.' not in source, stem + label = stem + ' platform edited' if native else ('XML platform' if xml else 'Markdown platform') + assert 'data-sections-label="' + label + '"' in source, stem + assert 'data-section="One"' in source and 'data-section="Two"' in source, stem + if xml: + assert actual.elements['contract-sorting'][0]['data-sortable'] == 'true' + assert 'sticky-header' in actual.elements['contract-sorting'][0]['class'] + assert any(tag == 'th' and attrs.get('data-sortable') == 'false' for tag, attrs in actual.descendants['contract-sorting']) + print('Property contract HTML: default/instance titles, chapter override and anchor, four navigation target kinds, conditions, literal variables and switcher owners verified; H1 attribute limitation explicit') + sys.exit(0) + +defaults = len(sys.argv) > 2 and sys.argv[2] == 'defaults' +with zipfile.ZipFile(sys.argv[1]) as archive: + xml = Elements(archive.read('properties.html').decode()) + md = Elements(archive.read('properties-markdown.html').decode()) + for tag in ['chapter', 'procedure', 'code-block', 'deflist']: + for case in ['absent', 'false', 'true', 'expanded', 'collapsed', + 'false-expanded', 'false-collapsed', 'true-expanded', 'true-collapsed']: + identity = f'{tag}-{case}' + enabled = case.startswith('true') or defaults and not case.startswith('false') + expanded = case.endswith('expanded') or defaults and not case.endswith('collapsed') + attrs, parents = xml.elements[identity] + if tag in {'chapter', 'procedure'}: + collapse = [a for _, a in parents if a.get('class') == 'collapse__title'] + assert bool(collapse) == enabled, (identity, parents) + if enabled: + assert (collapse[0].get('data-expanded') == 'true') == expanded, identity + elif tag == 'code-block': + assert (attrs['class'] == 'code-collapse') == enabled, (identity, attrs) + if enabled: + assert (attrs['data-is-expanded'] == 'true') == expanded, identity + else: + item_suffixes = ['absent', 'inherited', 'collapsed', 'false'] if case.startswith('true-') else ['absent', 'inherited', 'expanded', 'collapsed'] + for item in item_suffixes: + item_id = f'{identity}-item-{item}' + # The initial fixture contains a spelling preserved in evidence. + if item_id not in xml.elements: + item_id = f'{identity}-item-item-{item}' + item_attrs, _ = xml.elements[item_id] + assert (item_attrs['data-expandable'] == 'true') == enabled, item_id + if enabled: + item_expanded = item == 'expanded' or item == 'inherited' and expanded or item in {'absent', 'false'} and not defaults and expanded + assert (item_attrs['data-expanded'] == 'true') == item_expanded, (item_id, item_attrs) + assert md.elements['md-code'][0]['class'] == 'code-block' + file_attrs = md.elements['md-file'][0] + assert (file_attrs['class'] == 'code-collapse') == defaults, file_attrs + assert file_attrs['data-emphasize-lines'] == '1', file_attrs + assert 'intro' in md.elements + snippet = md.texts['md-file'] + assert 'second' in snippet and 'third' in snippet and 'first' not in snippet, snippet + assert 'Install' in xml.texts['formatted-title'] and 'now' in xml.texts['formatted-title'], xml.texts['formatted-title'] + assert xml.texts['child-title-only'] == 'Child title only' + assert xml.images['image-xml'][0]['width'] == '48' and xml.images['image-xml'][0]['height'] == '24' + assert xml.links['image-xml'][0]['class'] == 'lightbox' + assert xml.images['image-paragraph'][0]['width'] == '16' + assert md.images['image-markdown'][0]['width'] == '48' + assert md.links['image-markdown'][0]['class'] == 'lightbox' + override = md.elements['source-override'][0] + assert override['src'] == '/other.png' and override['alt'] == 'Different' + assert override['width'] == '80' + assert md.elements['reference-override'][0]['href'] == 'properties.html#image-xml' + assert md.elements['empty-alt'][0]['alt'] == 'Logo' + assert xml.elements['empty-alt-xml'][0]['alt'] == 'Logo' + assert md.elements['lang-override'][0]['data-lang'] == 'none' + variable_chapter, variable_parents = xml.elements['chapter-variable'] + assert any(attrs.get('class') == 'collapse__title' and attrs.get('data-expanded') == 'true' + for _, attrs in variable_parents) + assert xml.elements['code-variable'][0]['class'] == 'code-collapse' + assert xml.elements['code-variable'][0]['data-is-expanded'] == 'true' + assert md.elements['embedded-code'][0]['class'] == 'code-block' + assert md.texts['embedded-code'] == 'embedded code' + layout = xml.elements['layout-default'][0] + assert layout['data-style'] == ('title-left' if defaults else 'title-top'), layout + if defaults: + assert layout['data-title-width'] == 'medium', layout + assert xml.elements['layout-wide'][0]['data-title-width'] == 'wide' + assert xml.elements['layout-full'][0]['data-style'] == 'title-top' + assert '{id="literal-code"}' in md.texts['paragraph-code-owner'] + assert '{id="literal-escaped"}' in md.texts['paragraph-escaped-owner'] + assert 'literal-code' not in md.elements and 'literal-escaped' not in md.elements + for identity, owner in [('owned-link', 'paragraph-link-owner'), ('owned-image', 'paragraph-image-owner')]: + assert any(attrs.get('id') == owner for _, attrs in md.elements[identity][1]), (identity, owner) + assert md.elements['native-list'][0]['class'] == 'list _bullet' + item, parents = md.elements['converted-list-item'] + assert item['class'] == 'list__item' and any(tag == 'ul' for tag, _ in parents) + assert md.elements['checked-native-list'][0]['class'] == 'list _checkbox' + assert md.elements['checked-xml-item'][0]['checked'] == 'true' + assert 'checked' not in md.elements['unchecked-xml-item'][0] + verify_literal_references(md) + verify_numbered_item(md, 'numbered-parent', 10, 4) + verify_numbered_item(md, 'marker-parent', 3, 4) + verify_numbered_item(md, 'nested-parent', 10, 7) + for identity, start, nested_start in [('native-numbered-list', 10, 4), + ('native-marker-list', 3, 4), + ('native-override-list', 10, 7)]: + assert md.elements[identity][0]['start'] == str(start), identity + nested = [attrs for tag, attrs in md.descendants[identity] if tag == 'ol'] + assert len(nested) == 1 and nested[0]['start'] == str(nested_start), (identity, nested) + note = md.elements['native-note'][0] + assert note['class'] == 'prompt' and note['data-type'] == 'note', note +print('Pinned property HTML: collapse, state, inheritance, module layout, literal inline ownership, ordered starts, list/item and admonition IDs, snippet emphasis, formatted titles and images verified') diff --git a/tools/verify_writerside_reference_fixture.py b/tools/verify_writerside_reference_fixture.py new file mode 100644 index 00000000..7e51356a --- /dev/null +++ b/tools/verify_writerside_reference_fixture.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Assert supported authored-reference behavior in the pinned builder output.""" +import sys +import zipfile +from html.parser import HTMLParser + +class Links(HTMLParser): + def __init__(self): + super().__init__() + self.destinations = [] + self.ids = set() + self.text = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if 'id' in attrs: + self.ids.add(attrs['id']) + if tag == 'a': + self.destinations.append(attrs.get('href')) + + def handle_data(self, text): + self.text.append(text) + +with zipfile.ZipFile(sys.argv[1]) as bundle: + source, target = Links(), Links() + source.feed(bundle.read('source.html').decode('utf-8')) + target.feed(bundle.read('target.html').decode('utf-8')) + assert source.destinations.count('target.html#actual-id') == 3 + assert '#explicit-heading' in source.destinations + assert 'source.html#explicit-heading' in target.destinations + assert 'explicit-heading' in source.ids and 'actual-id' in target.ids + assert not any('missing' in (destination or '') for destination in source.destinations) + assert 'Authored reusable content.' in ''.join(source.text) +print('Official authored links, reference definitions, explicit IDs, include and code exclusions passed') diff --git a/tools/visualization/build_render_engines.js b/tools/visualization/build_render_engines.js index 94e4196f..77da4b4a 100644 --- a/tools/visualization/build_render_engines.js +++ b/tools/visualization/build_render_engines.js @@ -123,8 +123,17 @@ const mermaidManifest = JSON.parse( ) await build({ - entryPoints: [entryPoint], - outfile, + entryPoints: { + 'render-engines': entryPoint, + 'mermaid-engine': path.join(path.dirname(entryPoint), 'mermaid_renderer.js'), + 'plantuml-engine': path.join(path.dirname(entryPoint), 'plantuml_renderer.js'), + 'openapi-engine': path.join(path.dirname(entryPoint), 'openapi_renderer.js'), + 'math-engine': path.join(path.dirname(entryPoint), 'mathjax_renderer.js'), + }, + outdir: path.dirname(outfile), + // Fixed names keep the private resource allowlist exact, without admitting + // arbitrary paths or build-generated chunk names. No runtime dependency. + external: ['./mermaid-engine.js', './plantuml-engine.js', './openapi-engine.js', './math-engine.js'], bundle: true, format: 'esm', platform: 'browser', diff --git a/tools/visualization/harness.html b/tools/visualization/harness.html index 797299ea..84a0333c 100644 --- a/tools/visualization/harness.html +++ b/tools/visualization/harness.html @@ -5,7 +5,6 @@ -
    diff --git a/tools/visualization/mathjax_renderer.js b/tools/visualization/mathjax_renderer.js index 40935081..445a3895 100644 --- a/tools/visualization/mathjax_renderer.js +++ b/tools/visualization/mathjax_renderer.js @@ -21,8 +21,8 @@ import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js' import '@mathjax/src/js/input/tex/units/UnitsConfiguration.js' import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js' -// NewCM's dynamic SVG tables are all statically included in BusyMark's one -// deterministic bundle. MathJax still uses its asynchronous retry path, but +// NewCM's dynamic SVG tables are all statically included in BusyMark's dedicated +// offline math bundle. MathJax still uses its asynchronous retry path, but // the loader below only activates already-bundled table registrations. import '@mathjax/mathjax-newcm-font/js/svg/dynamic/PUA.js' import '@mathjax/mathjax-newcm-font/js/svg/dynamic/accents-b-i.js' diff --git a/tools/visualization/mermaid_renderer.js b/tools/visualization/mermaid_renderer.js new file mode 100644 index 00000000..35064fa0 --- /dev/null +++ b/tools/visualization/mermaid_renderer.js @@ -0,0 +1,63 @@ +import mermaid from 'mermaid' + +let renderSequence = 0 + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +export async function renderMermaid(source, theme) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + htmlLabels: false, + maxTextSize: 100000, + maxEdges: 500, + suppressErrorRendering: true, + deterministicIds: true, + deterministicIDSeed: 'busymark', + theme: 'base', + themeVariables: theme === 'dark' + ? { + background: '#202124', + primaryColor: '#303134', + primaryTextColor: '#f1f3f4', + primaryBorderColor: '#8ab4f8', + lineColor: '#bdc1c6', + secondaryColor: '#3c4043', + tertiaryColor: '#292a2d', + } + : { + background: '#ffffff', + primaryColor: '#e8f0fe', + primaryTextColor: '#202124', + primaryBorderColor: '#1a73e8', + lineColor: '#5f6368', + secondaryColor: '#f1f3f4', + tertiaryColor: '#ffffff', + }, + }) + try { + const id = `busymark-mermaid-${++renderSequence}` + const { svg } = await mermaid.render(id, source) + return { svg, diagnostics: [] } + } catch (error) { + return { + code: 'visualization.invalidMermaid', + message: String(error?.message ?? error ?? 'Mermaid could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidMermaid')], + } + } +} diff --git a/tools/visualization/openapi_renderer.js b/tools/visualization/openapi_renderer.js new file mode 100644 index 00000000..27479174 --- /dev/null +++ b/tools/visualization/openapi_renderer.js @@ -0,0 +1,395 @@ +import { bundle } from '@scalar/json-magic/bundle' +import { normalize, validate } from '@scalar/openapi-parser' +import { LineCounter, parseDocument } from 'yaml' + +const httpMethods = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]) + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +function collectReferences(value, references = new Set()) { + if (Array.isArray(value)) { + for (const item of value) collectReferences(item, references) + return [...references] + } + if (!value || typeof value !== 'object') return [...references] + for (const [key, item] of Object.entries(value)) { + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + references.add(item.split('#', 1)[0]) + } else { + collectReferences(item, references) + } + } + return [...references] +} + +function collectSourceReferences(value, sourceMap, path = [], references = []) { + if (Array.isArray(value)) { + value.forEach((item, index) => collectSourceReferences(item, sourceMap, [...path, index], references)) + return references + } + if (!value || typeof value !== 'object') return references + for (const [key, item] of Object.entries(value)) { + const itemPath = [...path, key] + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + const node = sourceMap.document.getIn(itemPath, true) + const position = Number.isInteger(node?.range?.[0]) + ? sourceMap.lineCounter.linePos(node.range[0]) + : undefined + references.push({ + value: item.split('#', 1)[0], + ...(position ? { line: position.line, column: position.col } : {}), + }) + } else { + collectSourceReferences(item, sourceMap, itemPath, references) + } + } + return references +} + +function portableDirectory(filename) { + const index = filename.lastIndexOf('/') + return index < 0 ? '' : filename.slice(0, index) +} + +function normalizePortablePath(path) { + const segments = [] + for (const segment of path.split('/')) { + if (!segment || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return segments.join('/') +} + +function canonicalizeReferences(value, filename) { + if (Array.isArray(value)) { + for (const item of value) canonicalizeReferences(item, filename) + return + } + if (!value || typeof value !== 'object') return + for (const [key, item] of Object.entries(value)) { + if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { + const [path, fragment] = item.split('#', 2) + value[key] = `${normalizePortablePath(`${portableDirectory(filename)}/${decodeURIComponent(path)}`)}${fragment === undefined ? '' : `#${fragment}`}` + } else { + canonicalizeReferences(item, filename) + } + } +} + +class OpenApiSourceError extends Error { + constructor(message, diagnostics) { + super(message) + this.name = 'OpenApiSourceError' + this.diagnostics = diagnostics + } +} + +function sourceDiagnostic(error, sourceMap, severity = 'error') { + const position = error?.linePos?.[0] + ?? (Number.isInteger(error?.pos?.[0]) ? sourceMap.lineCounter.linePos(error.pos[0]) : undefined) + const message = String(error?.message ?? 'OpenAPI source could not be parsed.').slice(0, 2000) + if (!sourceMap.entrypoint) { + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message, + severity, + sourceId: sourceMap.id, + ...(position ? { sourceLine: position.line, sourceColumn: position.col } : {}), + } + } + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message, + severity, + ...(position ? { line: position.line, column: position.col } : {}), + } +} + +function createSourceMap(id, source, entrypoint) { + const lineCounter = new LineCounter() + const document = parseDocument(source, { + lineCounter, + maxAliasCount: 10000, + merge: true, + }) + const sourceMap = { id, document, entrypoint, lineCounter } + const errors = document.errors.map((error) => sourceDiagnostic(error, sourceMap)) + if (errors.length > 0) { + throw new OpenApiSourceError('OpenAPI file could not be parsed: ' + id, errors) + } + return { + ...sourceMap, + warnings: document.warnings.map((warning) => sourceDiagnostic(warning, sourceMap, 'warning')), + } +} + +function parseFile(id, source, entrypoint) { + const sourceMap = createSourceMap(id, source, entrypoint) + const rawSpecification = normalize(source) + if (!rawSpecification || typeof rawSpecification !== 'object' || Array.isArray(rawSpecification)) { + throw new Error('OpenAPI file could not be parsed: ' + id) + } + const specification = structuredClone(rawSpecification) + canonicalizeReferences(specification, id) + return { + file: { + dir: portableDirectory(id), + filename: id, + isEntrypoint: entrypoint, + references: collectReferences(specification), + specification, + }, + rawSpecification, + sourceMap, + } +} + +function validationPathSegments(path) { + if (Array.isArray(path)) return path.map((segment) => String(segment)) + if (typeof path !== 'string' || path.length === 0 || !path.startsWith('/')) return [] + return path.slice(1).split('/').map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')) +} + +function validationLocation(sourceMap, requestedPath) { + const path = [...requestedPath] + while (path.length > 0) { + const node = sourceMap.document.getIn(path, true) + if (Number.isInteger(node?.range?.[0])) return sourceMap.lineCounter.linePos(node.range[0]) + path.pop() + } + return undefined +} + +function pathStartsWith(path, prefix) { + return prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]) +} + +function sourceMapForBundledUrl(value, parsedFiles) { + if (typeof value !== 'string') return undefined + const portableValue = normalizePortablePath(value.replace(/^\/+/, '')) + const entryDirectory = portableDirectory(parsedFiles[0].sourceMap.id) + const entryRelative = normalizePortablePath(`${entryDirectory}/${portableValue}`) + return parsedFiles + .map((parsed) => parsed.sourceMap) + .find((sourceMap) => sourceMap.id === portableValue || sourceMap.id === entryRelative) +} + +function bundledReferenceProvenance(document, parsedFiles) { + const mappings = document?.['x-ext-urls'] + if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings)) return [] + const sourceMapsByHash = new Map( + Object.entries(mappings) + .map(([hash, value]) => [hash, sourceMapForBundledUrl(value, parsedFiles)]) + .filter(([, sourceMap]) => sourceMap !== undefined), + ) + const provenance = [] + const visit = (value, bundledPath = []) => { + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, [...bundledPath, String(index)])) + return + } + if (!value || typeof value !== 'object') return + const ownerSourceMap = bundledPath[0] === 'x-ext' + ? sourceMapsByHash.get(bundledPath[1]) + : parsedFiles[0].sourceMap + const ownerPath = bundledPath[0] === 'x-ext' ? bundledPath.slice(2) : bundledPath + if (typeof value.$ref === 'string' && value.$ref.startsWith('#/x-ext/')) { + const target = validationPathSegments(value.$ref.slice(1)) + const targetSourceMap = sourceMapsByHash.get(target[1]) + if (ownerSourceMap && targetSourceMap) { + provenance.push({ + ownerId: ownerSourceMap.id, + renderedPath: ownerPath, + targetSourceMap, + targetPath: target.slice(2), + }) + } + } + for (const [key, item] of Object.entries(value)) { + if (key !== 'x-ext-urls') visit(item, [...bundledPath, key]) + } + } + visit(document) + return provenance.sort((left, right) => right.renderedPath.length - left.renderedPath.length) +} + +function validationAttribution(error, parsedFiles, provenance) { + let sourceMap = parsedFiles[0].sourceMap + let path = validationPathSegments(error?.path) + const visited = new Set() + for (;;) { + const mapping = provenance.find((candidate) => + candidate.ownerId === sourceMap.id && pathStartsWith(path, candidate.renderedPath)) + if (!mapping) break + const key = `${mapping.ownerId}\u0000${mapping.renderedPath.join('\u0000')}\u0000${mapping.targetSourceMap.id}` + if (visited.has(key)) break + visited.add(key) + path = [...mapping.targetPath, ...path.slice(mapping.renderedPath.length)] + sourceMap = mapping.targetSourceMap + } + return { sourceMap, location: validationLocation(sourceMap, path) } +} + +function validationDiagnostics(errors, parsedFiles, provenance) { + return (errors ?? []).slice(0, 200).map((error) => { + const { sourceMap, location } = validationAttribution(error, parsedFiles, provenance) + return { + code: String(error?.code ?? 'visualization.invalidOpenApi'), + message: String(error?.message ?? 'OpenAPI validation failed.').slice(0, 2000), + severity: 'error', + ...(sourceMap.entrypoint + ? (location ? { line: location.line, column: location.col } : {}) + : { + sourceId: sourceMap.id, + ...(location ? { sourceLine: location.line, sourceColumn: location.col } : {}), + }), + } + }) +} + +function uniqueValidationErrors(...groups) { + const byKey = new Map() + for (const error of groups.flat()) { + const key = String(error?.code ?? '') + '\u0000' + String(error?.path ?? '') + '\u0000' + String(error?.message ?? '') + if (!byKey.has(key)) byKey.set(key, error) + } + return [...byKey.values()] +} + +async function bundleOpenApi(parsedFiles) { + const entry = parsedFiles[0] + const documents = new Map( + parsedFiles.slice(1).map((parsed) => ['/' + parsed.file.filename, parsed.rawSpecification]), + ) + const document = await bundle(structuredClone(entry.rawSpecification), { + origin: '/' + entry.file.filename, + plugins: [{ + type: 'loader', + validate: (value) => documents.has(value), + exec: async (value) => { + const document = documents.get(value) + return document + ? { ok: true, data: structuredClone(document), raw: '' } + : { ok: false } + }, + }], + treeShake: false, + urlMap: true, + }) + const provenance = bundledReferenceProvenance(document, parsedFiles) + delete document['x-ext-urls'] + return { document, provenance } +} + +function operationSummary(document) { + const operations = [] + const tags = new Set( + Array.isArray(document.tags) + ? document.tags.map((tag) => tag?.name).filter((tag) => typeof tag === 'string') + : [], + ) + const paths = document.paths && typeof document.paths === 'object' ? document.paths : {} + for (const [path, pathItem] of Object.entries(paths)) { + if (!pathItem || typeof pathItem !== 'object') continue + for (const [method, operation] of Object.entries(pathItem)) { + if (!httpMethods.has(method.toLowerCase()) || !operation || typeof operation !== 'object') continue + const operationTags = Array.isArray(operation.tags) + ? operation.tags.filter((tag) => typeof tag === 'string') + : [] + for (const tag of operationTags) tags.add(tag) + operations.push({ + method: method.toUpperCase(), + path, + summary: typeof operation.summary === 'string' ? operation.summary : '', + operationId: typeof operation.operationId === 'string' ? operation.operationId : '', + tags: operationTags, + }) + } + } + operations.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)) + return { operations, tags: [...tags].sort(), pathCount: Object.keys(paths).length } +} + +export async function prepareOpenApi(request) { + const entryId = request.entryId || 'document.openapi' + const parsedFiles = [ + parseFile(entryId, request.source, true), + ...(request.dependencies ?? []).map((dependency) => parseFile(dependency.id, dependency.source, false)), + ] + const files = parsedFiles.map((parsed) => parsed.file) + const validation = await validate(files) + const bundled = await bundleOpenApi(parsedFiles) + const bundledDocument = bundled.document + const bundledValidation = await validate(bundledDocument) + const errors = uniqueValidationErrors(validation.errors ?? [], bundledValidation.errors ?? []) + const document = files[0].specification + const summaryDocument = bundledValidation.schema ?? validation.schema ?? bundledDocument + const summary = operationSummary(summaryDocument) + const specificationVersion = typeof document.openapi === 'string' + ? document.openapi + : typeof document.swagger === 'string' + ? document.swagger + : '' + return { + scalarContent: bundledDocument, + response: { + reference: { + title: typeof document.info?.title === 'string' ? document.info.title : 'OpenAPI', + apiVersion: typeof document.info?.version === 'string' ? document.info.version : '', + specificationVersion, + valid: validation.valid === true && bundledValidation.valid === true, + serverCount: Array.isArray(document.servers) ? document.servers.length : 0, + pathCount: summary.pathCount, + operations: summary.operations, + tags: summary.tags, + document: bundledDocument, + externalDocuments: parsedFiles.slice(1).map((parsed) => ({ + id: parsed.file.filename, + document: parsed.rawSpecification, + })), + }, + diagnostics: [ + ...parsedFiles.flatMap((parsed) => parsed.sourceMap.warnings), + ...validationDiagnostics(errors, parsedFiles, bundled.provenance), + ], + }, + } +} + +export function inspectOpenApi(source) { + try { + const sourceMap = createSourceMap('document.openapi', source, true) + const specification = normalize(source) + return { + references: specification && typeof specification === 'object' + ? collectSourceReferences(specification, sourceMap) + : [], + } + } catch { + return { references: [] } + } +} diff --git a/tools/visualization/plantuml_renderer.js b/tools/visualization/plantuml_renderer.js new file mode 100644 index 00000000..686226ab --- /dev/null +++ b/tools/visualization/plantuml_renderer.js @@ -0,0 +1,105 @@ +import { + render as renderPlantUmlIntoElement, + renderToString as renderPlantUmlToString, +} from '@plantuml/core' + +let renderSequence = 0 + +let graphvizLoad + +function loadGraphviz() { + graphvizLoad ??= new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = './viz-global.js' + script.onload = () => resolve() + script.onerror = () => { + script.remove() + graphvizLoad = undefined + const error = new Error('The bundled Graphviz engine could not be loaded.') + error.busymarkEngineLoadFailed = true + reject(error) + } + document.head.append(script) + }) + return graphvizLoad +} + +function diagnostic(error, fallbackCode) { + const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') + const location = error?.hash?.loc + const lineMatch = message.match(/(?:line|at line)\s+(\d+)/i) + return { + code: String(error?.code ?? fallbackCode), + message: message.slice(0, 2000), + severity: 'error', + ...(Number.isInteger(location?.first_line) + ? { line: location.first_line, column: (location.first_column ?? 0) + 1 } + : lineMatch + ? { line: Number.parseInt(lineMatch[1], 10) } + : {}), + } +} + +function renderDarkPlantUml(lines) { + return new Promise((resolve, reject) => { + const target = document.createElement('div') + target.id = `busymark-plantuml-${++renderSequence}` + target.style.cssText = 'position:fixed;left:-100000px;top:0;opacity:0;pointer-events:none' + document.body.append(target) + const cleanup = () => { + observer.disconnect() + window.clearTimeout(timeout) + target.remove() + } + const observer = new MutationObserver(() => { + const svg = target.querySelector('svg') + if (!svg) return + const result = new XMLSerializer().serializeToString(svg) + cleanup() + resolve(result) + }) + const timeout = window.setTimeout(() => { + cleanup() + reject(new Error('PlantUML did not finish rendering.')) + }, 15000) + observer.observe(target, { childList: true, subtree: true }) + renderPlantUmlIntoElement(lines, target.id, { dark: true }) + }) +} + +export async function renderPlantUml(source, theme) { + await loadGraphviz() + return new Promise((resolve) => { + const lines = source.replace(/\r\n?/g, '\n').split('\n') + try { + renderPlantUmlToString( + lines, + async (svg) => { + try { + resolve({ + svg: theme === 'dark' ? await renderDarkPlantUml(lines) : svg, + diagnostics: [], + }) + } catch (error) { + resolve({ + code: 'visualization.invalidPlantUml', + message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }) + } + }, + (error) => resolve({ + code: 'visualization.invalidPlantUml', + message: String(error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }), + ) + } catch (error) { + resolve({ + code: 'visualization.invalidPlantUml', + message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), + diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], + }) + } + }) +} diff --git a/tools/visualization/reference.html b/tools/visualization/reference.html index e3dc70c3..9df3fdb2 100644 --- a/tools/visualization/reference.html +++ b/tools/visualization/reference.html @@ -7,7 +7,6 @@ BusyMark API Reference - diff --git a/tools/visualization/render_engines.js b/tools/visualization/render_engines.js index 15772b28..41a4238e 100644 --- a/tools/visualization/render_engines.js +++ b/tools/visualization/render_engines.js @@ -1,26 +1,6 @@ -import { - render as renderPlantUmlIntoElement, - renderToString as renderPlantUmlToString, -} from '@plantuml/core' -import { bundle } from '@scalar/json-magic/bundle' -import { normalize, validate } from '@scalar/openapi-parser' -import mermaid from 'mermaid' -import { LineCounter, parseDocument } from 'yaml' -import { renderMathBatch } from './mathjax_renderer.js' - -const httpMethods = new Set([ - 'get', - 'put', - 'post', - 'delete', - 'options', - 'head', - 'patch', - 'trace', -]) - -let renderSequence = 0 - +// Each fixed offline engine bundle is loaded only when its operation is used. +// Module imports share evaluation within the reusable view. Rasterization needs +// only this small harness; recovery starts with a fresh module registry. function diagnostic(error, fallbackCode) { const message = String(error?.message ?? error?.str ?? error ?? 'Rendering failed.') const location = error?.hash?.loc @@ -37,489 +17,37 @@ function diagnostic(error, fallbackCode) { } } -async function renderMermaid(source, theme) { - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'strict', - htmlLabels: false, - maxTextSize: 100000, - maxEdges: 500, - suppressErrorRendering: true, - deterministicIds: true, - deterministicIDSeed: 'busymark', - theme: 'base', - themeVariables: theme === 'dark' - ? { - background: '#202124', - primaryColor: '#303134', - primaryTextColor: '#f1f3f4', - primaryBorderColor: '#8ab4f8', - lineColor: '#bdc1c6', - secondaryColor: '#3c4043', - tertiaryColor: '#292a2d', - } - : { - background: '#ffffff', - primaryColor: '#e8f0fe', - primaryTextColor: '#202124', - primaryBorderColor: '#1a73e8', - lineColor: '#5f6368', - secondaryColor: '#f1f3f4', - tertiaryColor: '#ffffff', - }, - }) +async function loadEngine(load) { try { - const id = `busymark-mermaid-${++renderSequence}` - const { svg } = await mermaid.render(id, source) - return { svg, diagnostics: [] } - } catch (error) { - return { - code: 'visualization.invalidMermaid', - message: String(error?.message ?? error ?? 'Mermaid could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidMermaid')], - } - } -} - -function renderDarkPlantUml(lines) { - return new Promise((resolve, reject) => { - const target = document.createElement('div') - target.id = `busymark-plantuml-${++renderSequence}` - target.style.cssText = 'position:fixed;left:-100000px;top:0;opacity:0;pointer-events:none' - document.body.append(target) - const cleanup = () => { - observer.disconnect() - window.clearTimeout(timeout) - target.remove() - } - const observer = new MutationObserver(() => { - const svg = target.querySelector('svg') - if (!svg) return - const result = new XMLSerializer().serializeToString(svg) - cleanup() - resolve(result) - }) - const timeout = window.setTimeout(() => { - cleanup() - reject(new Error('PlantUML did not finish rendering.')) - }, 15000) - observer.observe(target, { childList: true, subtree: true }) - renderPlantUmlIntoElement(lines, target.id, { dark: true }) - }) -} - -function renderPlantUml(source, theme) { - return new Promise((resolve) => { - const lines = source.replace(/\r\n?/g, '\n').split('\n') - try { - renderPlantUmlToString( - lines, - async (svg) => { - try { - resolve({ - svg: theme === 'dark' ? await renderDarkPlantUml(lines) : svg, - diagnostics: [], - }) - } catch (error) { - resolve({ - code: 'visualization.invalidPlantUml', - message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }) - } - }, - (error) => resolve({ - code: 'visualization.invalidPlantUml', - message: String(error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }), - ) - } catch (error) { - resolve({ - code: 'visualization.invalidPlantUml', - message: String(error?.message ?? error ?? 'PlantUML could not render this block.'), - diagnostics: [diagnostic(error, 'visualization.invalidPlantUml')], - }) - } - }) -} - -function collectReferences(value, references = new Set()) { - if (Array.isArray(value)) { - for (const item of value) collectReferences(item, references) - return [...references] - } - if (!value || typeof value !== 'object') return [...references] - for (const [key, item] of Object.entries(value)) { - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - references.add(item.split('#', 1)[0]) - } else { - collectReferences(item, references) - } - } - return [...references] -} - -function collectSourceReferences(value, sourceMap, path = [], references = []) { - if (Array.isArray(value)) { - value.forEach((item, index) => collectSourceReferences(item, sourceMap, [...path, index], references)) - return references - } - if (!value || typeof value !== 'object') return references - for (const [key, item] of Object.entries(value)) { - const itemPath = [...path, key] - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - const node = sourceMap.document.getIn(itemPath, true) - const position = Number.isInteger(node?.range?.[0]) - ? sourceMap.lineCounter.linePos(node.range[0]) - : undefined - references.push({ - value: item.split('#', 1)[0], - ...(position ? { line: position.line, column: position.col } : {}), - }) - } else { - collectSourceReferences(item, sourceMap, itemPath, references) - } - } - return references -} - -function portableDirectory(filename) { - const index = filename.lastIndexOf('/') - return index < 0 ? '' : filename.slice(0, index) -} - -function normalizePortablePath(path) { - const segments = [] - for (const segment of path.split('/')) { - if (!segment || segment === '.') continue - if (segment === '..') segments.pop() - else segments.push(segment) - } - return segments.join('/') -} - -function canonicalizeReferences(value, filename) { - if (Array.isArray(value)) { - for (const item of value) canonicalizeReferences(item, filename) - return - } - if (!value || typeof value !== 'object') return - for (const [key, item] of Object.entries(value)) { - if (key === '$ref' && typeof item === 'string' && !item.startsWith('#')) { - const [path, fragment] = item.split('#', 2) - value[key] = `${normalizePortablePath(`${portableDirectory(filename)}/${decodeURIComponent(path)}`)}${fragment === undefined ? '' : `#${fragment}`}` - } else { - canonicalizeReferences(item, filename) - } - } -} - -class OpenApiSourceError extends Error { - constructor(message, diagnostics) { - super(message) - this.name = 'OpenApiSourceError' - this.diagnostics = diagnostics - } -} - -function sourceDiagnostic(error, sourceMap, severity = 'error') { - const position = error?.linePos?.[0] - ?? (Number.isInteger(error?.pos?.[0]) ? sourceMap.lineCounter.linePos(error.pos[0]) : undefined) - const message = String(error?.message ?? 'OpenAPI source could not be parsed.').slice(0, 2000) - if (!sourceMap.entrypoint) { - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message, - severity, - sourceId: sourceMap.id, - ...(position ? { sourceLine: position.line, sourceColumn: position.col } : {}), - } - } - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message, - severity, - ...(position ? { line: position.line, column: position.col } : {}), - } -} - -function createSourceMap(id, source, entrypoint) { - const lineCounter = new LineCounter() - const document = parseDocument(source, { - lineCounter, - maxAliasCount: 10000, - merge: true, - }) - const sourceMap = { id, document, entrypoint, lineCounter } - const errors = document.errors.map((error) => sourceDiagnostic(error, sourceMap)) - if (errors.length > 0) { - throw new OpenApiSourceError('OpenAPI file could not be parsed: ' + id, errors) - } - return { - ...sourceMap, - warnings: document.warnings.map((warning) => sourceDiagnostic(warning, sourceMap, 'warning')), - } -} - -function parseFile(id, source, entrypoint) { - const sourceMap = createSourceMap(id, source, entrypoint) - const rawSpecification = normalize(source) - if (!rawSpecification || typeof rawSpecification !== 'object' || Array.isArray(rawSpecification)) { - throw new Error('OpenAPI file could not be parsed: ' + id) - } - const specification = structuredClone(rawSpecification) - canonicalizeReferences(specification, id) - return { - file: { - dir: portableDirectory(id), - filename: id, - isEntrypoint: entrypoint, - references: collectReferences(specification), - specification, - }, - rawSpecification, - sourceMap, - } -} - -function validationPathSegments(path) { - if (Array.isArray(path)) return path.map((segment) => String(segment)) - if (typeof path !== 'string' || path.length === 0 || !path.startsWith('/')) return [] - return path.slice(1).split('/').map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')) -} - -function validationLocation(sourceMap, requestedPath) { - const path = [...requestedPath] - while (path.length > 0) { - const node = sourceMap.document.getIn(path, true) - if (Number.isInteger(node?.range?.[0])) return sourceMap.lineCounter.linePos(node.range[0]) - path.pop() - } - return undefined -} - -function pathStartsWith(path, prefix) { - return prefix.length <= path.length && prefix.every((segment, index) => segment === path[index]) -} - -function sourceMapForBundledUrl(value, parsedFiles) { - if (typeof value !== 'string') return undefined - const portableValue = normalizePortablePath(value.replace(/^\/+/, '')) - const entryDirectory = portableDirectory(parsedFiles[0].sourceMap.id) - const entryRelative = normalizePortablePath(`${entryDirectory}/${portableValue}`) - return parsedFiles - .map((parsed) => parsed.sourceMap) - .find((sourceMap) => sourceMap.id === portableValue || sourceMap.id === entryRelative) -} - -function bundledReferenceProvenance(document, parsedFiles) { - const mappings = document?.['x-ext-urls'] - if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings)) return [] - const sourceMapsByHash = new Map( - Object.entries(mappings) - .map(([hash, value]) => [hash, sourceMapForBundledUrl(value, parsedFiles)]) - .filter(([, sourceMap]) => sourceMap !== undefined), - ) - const provenance = [] - const visit = (value, bundledPath = []) => { - if (Array.isArray(value)) { - value.forEach((item, index) => visit(item, [...bundledPath, String(index)])) - return - } - if (!value || typeof value !== 'object') return - const ownerSourceMap = bundledPath[0] === 'x-ext' - ? sourceMapsByHash.get(bundledPath[1]) - : parsedFiles[0].sourceMap - const ownerPath = bundledPath[0] === 'x-ext' ? bundledPath.slice(2) : bundledPath - if (typeof value.$ref === 'string' && value.$ref.startsWith('#/x-ext/')) { - const target = validationPathSegments(value.$ref.slice(1)) - const targetSourceMap = sourceMapsByHash.get(target[1]) - if (ownerSourceMap && targetSourceMap) { - provenance.push({ - ownerId: ownerSourceMap.id, - renderedPath: ownerPath, - targetSourceMap, - targetPath: target.slice(2), - }) - } - } - for (const [key, item] of Object.entries(value)) { - if (key !== 'x-ext-urls') visit(item, [...bundledPath, key]) - } + return await load() + } catch { + // WebKit caches failed module imports in the view's module registry. Tell + // the native host to retire that registry before processing queued work. + const error = new Error('The bundled visualization engine could not be loaded.') + error.busymarkEngineLoadFailed = true + throw error } - visit(document) - return provenance.sort((left, right) => right.renderedPath.length - left.renderedPath.length) -} - -function validationAttribution(error, parsedFiles, provenance) { - let sourceMap = parsedFiles[0].sourceMap - let path = validationPathSegments(error?.path) - const visited = new Set() - for (;;) { - const mapping = provenance.find((candidate) => - candidate.ownerId === sourceMap.id && pathStartsWith(path, candidate.renderedPath)) - if (!mapping) break - const key = `${mapping.ownerId}\u0000${mapping.renderedPath.join('\u0000')}\u0000${mapping.targetSourceMap.id}` - if (visited.has(key)) break - visited.add(key) - path = [...mapping.targetPath, ...path.slice(mapping.renderedPath.length)] - sourceMap = mapping.targetSourceMap - } - return { sourceMap, location: validationLocation(sourceMap, path) } -} - -function validationDiagnostics(errors, parsedFiles, provenance) { - return (errors ?? []).slice(0, 200).map((error) => { - const { sourceMap, location } = validationAttribution(error, parsedFiles, provenance) - return { - code: String(error?.code ?? 'visualization.invalidOpenApi'), - message: String(error?.message ?? 'OpenAPI validation failed.').slice(0, 2000), - severity: 'error', - ...(sourceMap.entrypoint - ? (location ? { line: location.line, column: location.col } : {}) - : { - sourceId: sourceMap.id, - ...(location ? { sourceLine: location.line, sourceColumn: location.col } : {}), - }), - } - }) -} - -function uniqueValidationErrors(...groups) { - const byKey = new Map() - for (const error of groups.flat()) { - const key = String(error?.code ?? '') + '\u0000' + String(error?.path ?? '') + '\u0000' + String(error?.message ?? '') - if (!byKey.has(key)) byKey.set(key, error) - } - return [...byKey.values()] -} - -async function bundleOpenApi(parsedFiles) { - const entry = parsedFiles[0] - const documents = new Map( - parsedFiles.slice(1).map((parsed) => ['/' + parsed.file.filename, parsed.rawSpecification]), - ) - const document = await bundle(structuredClone(entry.rawSpecification), { - origin: '/' + entry.file.filename, - plugins: [{ - type: 'loader', - validate: (value) => documents.has(value), - exec: async (value) => { - const document = documents.get(value) - return document - ? { ok: true, data: structuredClone(document), raw: '' } - : { ok: false } - }, - }], - treeShake: false, - urlMap: true, - }) - const provenance = bundledReferenceProvenance(document, parsedFiles) - delete document['x-ext-urls'] - return { document, provenance } -} - -function operationSummary(document) { - const operations = [] - const tags = new Set( - Array.isArray(document.tags) - ? document.tags.map((tag) => tag?.name).filter((tag) => typeof tag === 'string') - : [], - ) - const paths = document.paths && typeof document.paths === 'object' ? document.paths : {} - for (const [path, pathItem] of Object.entries(paths)) { - if (!pathItem || typeof pathItem !== 'object') continue - for (const [method, operation] of Object.entries(pathItem)) { - if (!httpMethods.has(method.toLowerCase()) || !operation || typeof operation !== 'object') continue - const operationTags = Array.isArray(operation.tags) - ? operation.tags.filter((tag) => typeof tag === 'string') - : [] - for (const tag of operationTags) tags.add(tag) - operations.push({ - method: method.toUpperCase(), - path, - summary: typeof operation.summary === 'string' ? operation.summary : '', - operationId: typeof operation.operationId === 'string' ? operation.operationId : '', - tags: operationTags, - }) - } - } - operations.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)) - return { operations, tags: [...tags].sort(), pathCount: Object.keys(paths).length } } export async function prepareOpenApi(request) { - const entryId = request.entryId || 'document.openapi' - const parsedFiles = [ - parseFile(entryId, request.source, true), - ...(request.dependencies ?? []).map((dependency) => parseFile(dependency.id, dependency.source, false)), - ] - const files = parsedFiles.map((parsed) => parsed.file) - const validation = await validate(files) - const bundled = await bundleOpenApi(parsedFiles) - const bundledDocument = bundled.document - const bundledValidation = await validate(bundledDocument) - const errors = uniqueValidationErrors(validation.errors ?? [], bundledValidation.errors ?? []) - const document = files[0].specification - const summaryDocument = bundledValidation.schema ?? validation.schema ?? bundledDocument - const summary = operationSummary(summaryDocument) - const specificationVersion = typeof document.openapi === 'string' - ? document.openapi - : typeof document.swagger === 'string' - ? document.swagger - : '' - return { - scalarContent: bundledDocument, - response: { - reference: { - title: typeof document.info?.title === 'string' ? document.info.title : 'OpenAPI', - apiVersion: typeof document.info?.version === 'string' ? document.info.version : '', - specificationVersion, - valid: validation.valid === true && bundledValidation.valid === true, - serverCount: Array.isArray(document.servers) ? document.servers.length : 0, - pathCount: summary.pathCount, - operations: summary.operations, - tags: summary.tags, - document: bundledDocument, - externalDocuments: parsedFiles.slice(1).map((parsed) => ({ - id: parsed.file.filename, - document: parsed.rawSpecification, - })), - }, - diagnostics: [ - ...parsedFiles.flatMap((parsed) => parsed.sourceMap.warnings), - ...validationDiagnostics(errors, parsedFiles, bundled.provenance), - ], - }, - } + return (await loadEngine(() => import('./openapi-engine.js'))).prepareOpenApi(request) } async function handleRequest(request) { switch (request.operation) { case 'renderMermaid': - return renderMermaid(request.source, request.theme) + return (await loadEngine(() => import('./mermaid-engine.js'))).renderMermaid(request.source, request.theme) case 'renderPlantUml': - return renderPlantUml(request.source, request.theme) + return (await loadEngine(() => import('./plantuml-engine.js'))).renderPlantUml(request.source, request.theme) case 'renderMathBatch': - return renderMathBatch(request) - case 'inspectOpenApi': { - try { - const sourceMap = createSourceMap('document.openapi', request.source, true) - const specification = normalize(request.source) - return { - references: specification && typeof specification === 'object' - ? collectSourceReferences(specification, sourceMap) - : [], - } - } catch { - return { references: [] } - } - } + return (await loadEngine(() => import('./math-engine.js'))).renderMathBatch(request) + case 'inspectOpenApi': + return (await loadEngine(() => import('./openapi-engine.js'))).inspectOpenApi(request.source) case 'parseOpenApi': try { return (await prepareOpenApi(request)).response } catch (error) { + if (error?.busymarkEngineLoadFailed === true) throw error return { code: 'visualization.invalidOpenApi', message: String(error?.message ?? error ?? 'The OpenAPI document could not be parsed.'), diff --git a/tools/visualization_smoke.py b/tools/visualization_smoke.py index 4aee7658..5f2fc357 100755 --- a/tools/visualization_smoke.py +++ b/tools/visualization_smoke.py @@ -86,6 +86,10 @@ def _serve(self, request: WebKit2.URISchemeRequest) -> None: "reference.html", "bootstrap.js", "render-engines.js", + "mermaid-engine.js", + "plantuml-engine.js", + "openapi-engine.js", + "math-engine.js", "reference.js", "scalar.js", "viz-global.js", diff --git a/tools/web_render_host_probe.cc b/tools/web_render_host_probe.cc new file mode 100644 index 00000000..c9cf29f9 --- /dev/null +++ b/tools/web_render_host_probe.cc @@ -0,0 +1,675 @@ +// Runs the production native channel and real WebKit; only Flutter transport +// and a one-shot context-allocation failure are injected. No Dart mock host. +#include +#include +#include +#include + +#include "../linux/runner/web_render_host.cc" + +constexpr char kMethods[] = "io.busystack.busymark/visualization"; +struct Handler { + FlBinaryMessengerMessageHandler callback; + gpointer data; + GDestroyNotify destroy; +}; + +struct TestMessenger { + GObject parent; + GHashTable* handlers; + GPtrArray* events; +}; +struct TestMessengerClass { + GObjectClass parent; +}; +static void messenger_interface_init(FlBinaryMessengerInterface* iface); +G_DEFINE_TYPE_WITH_CODE(TestMessenger, test_messenger, G_TYPE_OBJECT, + G_IMPLEMENT_INTERFACE(fl_binary_messenger_get_type(), + messenger_interface_init)) + +struct TestResponse { + FlBinaryMessengerResponseHandle parent; + GBytes* bytes; + bool responded; +}; +struct TestResponseClass { + FlBinaryMessengerResponseHandleClass parent; +}; +G_DEFINE_TYPE(TestResponse, test_response, + fl_binary_messenger_response_handle_get_type()) + +static void test_response_finalize(GObject* object) { + g_clear_pointer(&reinterpret_cast(object)->bytes, + g_bytes_unref); + G_OBJECT_CLASS(test_response_parent_class)->finalize(object); +} +static void test_response_class_init(TestResponseClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_response_finalize; +} +static void test_response_init(TestResponse*) {} +G_DEFINE_AUTOPTR_CLEANUP_FUNC(TestResponse, g_object_unref) + +static void free_handler(gpointer data) { + auto* handler = static_cast(data); + if (handler->destroy != nullptr) handler->destroy(handler->data); + delete handler; +} +static void set_handler(FlBinaryMessenger* messenger, const gchar* channel, + FlBinaryMessengerMessageHandler callback, gpointer data, + GDestroyNotify destroy) { + auto* self = reinterpret_cast(messenger); + g_hash_table_remove(self->handlers, channel); + if (callback != nullptr) { + g_hash_table_insert(self->handlers, g_strdup(channel), + new Handler{callback, data, destroy}); + } else if (destroy != nullptr) { + destroy(data); + } +} +static gboolean send_response(FlBinaryMessenger*, + FlBinaryMessengerResponseHandle* handle, + GBytes* bytes, GError**) { + auto* response = reinterpret_cast(handle); + g_assert_false(response->responded); + response->responded = true; + response->bytes = + bytes != nullptr ? g_bytes_ref(bytes) : g_bytes_new(nullptr, 0); + return TRUE; +} +static void send_event(FlBinaryMessenger* messenger, const gchar* channel, + GBytes* message, GCancellable*, + GAsyncReadyCallback callback, gpointer) { + (void)channel; + g_assert_null(callback); // FlEventChannel sends unacknowledged envelopes. + g_assert_nonnull(message); + g_ptr_array_add(reinterpret_cast(messenger)->events, + g_bytes_ref(message)); +} +static void shutdown_messenger(FlBinaryMessenger* messenger) { + g_hash_table_remove_all( + reinterpret_cast(messenger)->handlers); +} +static void messenger_interface_init(FlBinaryMessengerInterface* iface) { + iface->set_message_handler_on_channel = set_handler; + iface->send_response = send_response; + iface->send_on_channel = send_event; + iface->shutdown = shutdown_messenger; +} +static void test_messenger_finalize(GObject* object) { + auto* self = reinterpret_cast(object); + g_hash_table_unref(self->handlers); + g_ptr_array_unref(self->events); + G_OBJECT_CLASS(test_messenger_parent_class)->finalize(object); +} +static void test_messenger_class_init(TestMessengerClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_messenger_finalize; +} +static void test_messenger_init(TestMessenger* self) { + self->handlers = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, free_handler); + self->events = g_ptr_array_new_with_free_func( + reinterpret_cast(g_bytes_unref)); +} + +static TestMessenger* active_messenger = nullptr; +// --wrap affects the production host's call, not calls inside Flutter's shared +// library. This is the sole injected I/O boundary; channel handlers are real. +extern "C" FlBinaryMessenger* __wrap_fl_engine_get_binary_messenger( + FlEngine* engine) { + g_assert_true(FL_IS_ENGINE(engine)); + g_assert_nonnull(active_messenger); + return FL_BINARY_MESSENGER(active_messenger); +} + +static void drain() { + int iterations = 0; + while (g_main_context_pending(nullptr)) { + g_main_context_iteration(nullptr, FALSE); + g_assert_cmpint(++iterations, <, 10000); + } +} + +extern "C" WebKitWebContext* __real_webkit_web_context_new_ephemeral(); +static unsigned contexts = 0; +static unsigned views = 0; +static unsigned harness_loads = 0; +extern "C" GtkWidget* __real_webkit_web_view_new_with_context( + WebKitWebContext*); +extern "C" GtkWidget* __wrap_webkit_web_view_new_with_context( + WebKitWebContext* context) { + ++views; + return __real_webkit_web_view_new_with_context(context); +} +extern "C" void __real_webkit_web_view_load_uri(WebKitWebView*, const gchar*); +extern "C" void __wrap_webkit_web_view_load_uri(WebKitWebView* view, + const gchar* uri) { + if (g_strcmp0(uri, kHarnessUri) == 0) ++harness_loads; + __real_webkit_web_view_load_uri(view, uri); +} +static bool fail_context = false; +extern "C" WebKitWebContext* __wrap_webkit_web_context_new_ephemeral() { + ++contexts; + if (fail_context) { + fail_context = false; + return nullptr; + } + return __real_webkit_web_context_new_ephemeral(); +} + +static std::map resource_reads; +static bool fail_engine_read = false; +static const char* failed_engine_resource = "mermaid-engine.js"; +extern "C" gboolean __real_g_file_get_contents(const gchar*, gchar**, gsize*, + GError**); +extern "C" gboolean __wrap_g_file_get_contents(const gchar* path, + gchar** contents, gsize* length, + GError** error) { + const char* name = strrchr(path, '/'); + if (name != nullptr) { + ++resource_reads[name + 1]; + if (fail_engine_read && strcmp(name + 1, failed_engine_resource) == 0) { + fail_engine_read = false; + g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND, + "Injected missing engine bundle"); + return FALSE; + } + } + return __real_g_file_get_contents(path, contents, length, error); +} + +static void only_engine(const char* expected) { + for (const char* engine : {"mermaid", "plantuml", "math", "openapi"}) { + const std::string name = std::string(engine) + "-engine.js"; + g_assert_cmpuint( + resource_reads[name], ==, + expected != nullptr && strcmp(expected, engine) == 0 ? 1 : 0); + } + g_assert_cmpuint( + resource_reads["viz-global.js"], ==, + expected != nullptr && strcmp(expected, "plantuml") == 0 ? 1 : 0); +} + +static void until(const std::function& predicate) { + const gint64 deadline = g_get_monotonic_time() + 20000000; + while (!predicate() && g_get_monotonic_time() < deadline) { + g_main_context_iteration(nullptr, FALSE); + g_usleep(1000); + } + g_assert_true(predicate()); +} + +static FlMethodResponse* decode(FlMethodCodec* codec, GBytes* bytes) { + g_autoptr(GError) error = nullptr; + auto* response = + FL_METHOD_CODEC_GET_CLASS(codec)->decode_response(codec, bytes, &error); + g_assert_no_error(error); + g_assert_nonnull(response); + return response; +} + +struct Harness { + TestMessenger* messenger; + FlStandardMethodCodec* codec; + GtkApplication* application; + FlView* view; + BusyMarkWebRenderHost* host; + Harness() { + contexts = views = harness_loads = 0; + resource_reads.clear(); + messenger = static_cast( + g_object_new(test_messenger_get_type(), nullptr)); + active_messenger = messenger; + codec = fl_standard_method_codec_new(); + application = gtk_application_new("io.busystack.busymark.nativeprobe", + G_APPLICATION_NON_UNIQUE); + g_assert_true( + g_application_register(G_APPLICATION(application), nullptr, nullptr)); + g_autoptr(FlDartProject) project = fl_dart_project_new(); + view = fl_view_new(project); + g_object_ref_sink(view); + host = busymark_web_render_host_new(application, nullptr); + busymark_web_render_host_register_channel(host, view); + dormant(); + } + void dormant() { + g_assert_cmpuint(contexts, ==, 0); + g_assert_cmpuint(views, ==, 0); + g_assert_cmpuint(harness_loads, ==, 0); + g_assert_null(host->context); + g_assert_null(host->web_view); + g_assert_null(host->offscreen_window); + g_assert_true(resource_reads.empty()); + } + ~Harness() { + busymark_web_render_host_shutdown(host); + until([&] { return host->active == nullptr; }); + BusyMarkWebRenderHost* remaining = host; + g_object_add_weak_pointer(G_OBJECT(host), + reinterpret_cast(&remaining)); + g_object_unref(host); + until([&] { return remaining == nullptr; }); + shutdown_messenger(FL_BINARY_MESSENGER(messenger)); + gtk_widget_destroy(GTK_WIDGET(view)); + g_object_unref(view); + g_object_unref(application); + g_object_unref(codec); + g_object_unref(messenger); + active_messenger = nullptr; + drain(); + } + TestResponse* call(const char* method, FlValue* args) { + auto* handler = static_cast( + g_hash_table_lookup(messenger->handlers, kMethods)); + g_assert_nonnull(handler); + g_autoptr(GError) error = nullptr; + auto* method_codec = FL_METHOD_CODEC(codec); + g_autoptr(GBytes) bytes = + FL_METHOD_CODEC_GET_CLASS(method_codec) + ->encode_method_call(method_codec, method, args, &error); + g_assert_no_error(error); + auto* response = static_cast( + g_object_new(test_response_get_type(), nullptr)); + handler->callback(FL_BINARY_MESSENGER(messenger), kMethods, bytes, + FL_BINARY_MESSENGER_RESPONSE_HANDLE(response), + handler->data); + return response; + } + FlValue* success(TestResponse* response) { + until([&] { return response->responded; }); + g_autoptr(FlMethodResponse) decoded = + decode(FL_METHOD_CODEC(codec), response->bytes); + if (FL_IS_METHOD_ERROR_RESPONSE(decoded)) { + auto* e = FL_METHOD_ERROR_RESPONSE(decoded); + g_error("%s: %s", fl_method_error_response_get_code(e), + fl_method_error_response_get_message(e)); + } + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(decoded)); + return fl_value_ref(fl_method_success_response_get_result( + FL_METHOD_SUCCESS_RESPONSE(decoded))); + } + void error(TestResponse* response, const char* code) { + until([&] { return response->responded; }); + g_autoptr(FlMethodResponse) decoded = + decode(FL_METHOD_CODEC(codec), response->bytes); + g_assert_true(FL_IS_METHOD_ERROR_RESPONSE(decoded)); + g_assert_cmpstr( + fl_method_error_response_get_code(FL_METHOD_ERROR_RESPONSE(decoded)), + ==, code); + } +}; + +constexpr char kApi[] = + R"({"openapi":"3.0.3","info":{"title":"First reference","version":"1"},"paths":{"/health":{"get":{"responses":{"200":{"description":"Healthy"}}}}}})"; +constexpr char kSvg[] = + R"()"; +static FlValue* args(const char* operation, const char* id = "first") { + g_autoptr(FlJsonMessageCodec) codec = fl_json_message_codec_new(); + std::string json; + if (strcmp(operation, "renderMathBatch") == 0) { + json = + R"({"expressions":[{"id":"one","expression":"x^2","display":true}]})"; + } else if (strcmp(operation, "renderPlantUml") == 0) { + json = + R"({"source":"@startuml\nAlice -> Bob: First\n@enduml","theme":"light"})"; + } else if (strcmp(operation, "renderMermaid") == 0) { + json = R"({"source":"graph LR; First-->Output","theme":"light"})"; + } else { + json = "{}"; + } + FlValue* result = fl_json_message_codec_decode(codec, json.c_str(), nullptr); + g_assert_nonnull(result); + fl_value_set_string_take(result, "requestId", fl_value_new_string(id)); + if (strstr(operation, "OpenApi") != nullptr) { + fl_value_set_string_take(result, "source", fl_value_new_string(kApi)); + fl_value_set_string_take(result, "entryId", + fl_value_new_string("first.json")); + fl_value_set_string_take(result, "title", + fl_value_new_string("First reference")); + fl_value_set_string_take(result, "theme", fl_value_new_string("light")); + } + if (strcmp(operation, "rasterizeSvg") == 0) { + fl_value_set_string_take(result, "svg", fl_value_new_string(kSvg)); + fl_value_set_string_take(result, "width", fl_value_new_float(120)); + fl_value_set_string_take(result, "height", fl_value_new_float(80)); + fl_value_set_string_take(result, "scale", fl_value_new_float(1)); + } + return result; +} +static void verify(const char* operation, FlValue* result) { + if (strcmp(operation, "rasterizeSvg") == 0) { + g_assert_cmpint(fl_value_get_type(result), ==, FL_VALUE_TYPE_UINT8_LIST); + g_autoptr(GdkPixbufLoader) loader = + gdk_pixbuf_loader_new_with_type("png", nullptr); + g_assert_true( + gdk_pixbuf_loader_write(loader, fl_value_get_uint8_list(result), + fl_value_get_length(result), nullptr)); + g_assert_true(gdk_pixbuf_loader_close(loader, nullptr)); + GdkPixbuf* pixbuf = gdk_pixbuf_loader_get_pixbuf(loader); + g_assert_cmpint(gdk_pixbuf_get_width(pixbuf), ==, 120); + g_assert_cmpint(gdk_pixbuf_get_height(pixbuf), ==, 80); + const auto* pixels = gdk_pixbuf_get_pixels(pixbuf); + const int channels = gdk_pixbuf_get_n_channels(pixbuf); + g_assert_cmpint(pixels[0], >, 200); + g_assert_cmpint(pixels[100 * channels + 2], >, 200); + } else if (strcmp(operation, "renderMathBatch") == 0) { + FlValue* results = fl_value_lookup_string(result, "results"); + g_assert_cmpuint(fl_value_get_length(results), ==, 1); + FlValue* svg = + fl_value_lookup_string(fl_value_get_list_value(results, 0), "svg"); + g_assert_nonnull(svg); + g_assert_nonnull(strstr(fl_value_get_string(svg), "responded); + g_autoptr(TestResponse) invalid_render = h.call("renderMermaid", nullptr); + h.error(invalid_render, "visualization.invalidArguments"); + g_autoptr(TestResponse) invalid = h.call("copyVisualizationImage", nullptr); + h.error(invalid, "visualization.invalidArguments"); + g_autoptr(FlValue) too_large = args("renderMermaid"); + std::string large(kMaximumRequestBytes + 1, 'x'); + fl_value_set_string_take(too_large, "source", + fl_value_new_string(large.c_str())); + g_autoptr(TestResponse) rejected = h.call("renderMermaid", too_large); + h.error(rejected, "visualization.sourceTooLarge"); + g_autoptr(GdkPixbuf) pixbuf = + gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, 2, 2); + gdk_pixbuf_fill(pixbuf, 0xff0000ff); + gchar* png = nullptr; + gsize length = 0; + g_assert_true(gdk_pixbuf_save_to_buffer(pixbuf, &png, &length, "png", + nullptr, nullptr)); + g_autoptr(FlValue) clipboard = fl_value_new_map(); + fl_value_set_string_take( + clipboard, "png", + fl_value_new_uint8_list(reinterpret_cast(png), length)); + g_free(png); + g_autoptr(TestResponse) copied = + h.call("copyVisualizationImage", clipboard); + g_autoptr(FlValue) copy_result = h.success(copied); + g_autoptr(GdkPixbuf) readback = gtk_clipboard_wait_for_image( + gtk_clipboard_get(GDK_SELECTION_CLIPBOARD)); + g_assert_nonnull(readback); + h.dormant(); + } else if (strcmp(mode, "shutdown-channel") == 0) { + busymark_web_render_host_shutdown(h.host); + g_autoptr(FlValue) request = args("renderMermaid"); + g_autoptr(TestResponse) response = h.call("renderMermaid", request); + h.error(response, "visualization.hostUnavailable"); + h.dormant(); + } else if (strcmp(mode, "reference-shutdown") == 0) { + g_autoptr(FlValue) request = args("openOpenApiReference"); + g_autoptr(TestResponse) response = h.call("openOpenApiReference", request); + g_autoptr(FlValue) result = h.success(response); + busymark_web_render_host_shutdown(h.host); + drain(); + g_assert_null(h.host->reference_windows); + g_assert_null(h.host->context); + } else if (strcmp(mode, "openOpenApiReference") == 0) { + g_autoptr(FlValue) request = args(mode); + g_autoptr(TestResponse) response = h.call(mode, request); + g_autoptr(FlValue) result = h.success(response); + g_assert_cmpuint(contexts, ==, 1); + g_assert_null(h.host->web_view); + g_assert_null(h.host->offscreen_window); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 0); + g_assert_nonnull(h.host->reference_windows); + auto* window = GTK_WIDGET(h.host->reference_windows->data); + auto* view = WEBKIT_WEB_VIEW(gtk_bin_get_child(GTK_BIN(window))); + until([&] { return !webkit_web_view_is_loading(view); }); + // Verify actual Scalar DOM, rather than only the window-opening response. + bool finished = false; + webkit_web_view_call_async_javascript_function( + view, + "return await new Promise(resolve => { const poll = () => " + "document.body.innerText.includes('Healthy') ? resolve(true) : " + "setTimeout(poll, 20); poll(); });", + -1, nullptr, nullptr, nullptr, nullptr, + [](GObject* object, GAsyncResult* result, gpointer data) { + g_autoptr(GError) error = nullptr; + g_autoptr(JSCValue) value = + webkit_web_view_call_async_javascript_function_finish( + WEBKIT_WEB_VIEW(object), result, &error); + g_assert_no_error(error); + g_assert_nonnull(value); + *static_cast(data) = true; + }, + &finished); + until([&] { return finished; }); + // WebKit's platform default may itself be NEVER. Reference windows must + // retain that default rather than inherit the offscreen override. + g_autoptr(WebKitSettings) defaults = create_restricted_settings(); + g_assert_cmpint(webkit_settings_get_hardware_acceleration_policy( + webkit_web_view_get_settings(view)), + ==, + webkit_settings_get_hardware_acceleration_policy(defaults)); + only_engine("openapi"); + g_autoptr(FlValue) render = args("renderMermaid"); + g_autoptr(TestResponse) rendered = h.call("renderMermaid", render); + g_autoptr(FlValue) rendered_result = h.success(rendered); + verify("renderMermaid", rendered_result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(views, ==, 2); + g_assert_cmpuint(harness_loads, ==, 1); + } else { + g_autoptr(FlValue) request = args("renderMermaid"); + const gint64 started = g_get_monotonic_time(); + if (is_render_operation(mode)) { + g_clear_pointer(&request, fl_value_unref); + request = args(mode); + g_autoptr(TestResponse) response = h.call(mode, request); + g_assert_cmpuint(contexts, ==, 0); + g_autoptr(FlValue) result = h.success(response); + verify(mode, result); + only_engine(strcmp(mode, "renderMermaid") == 0 ? "mermaid" + : strcmp(mode, "renderPlantUml") == 0 ? "plantuml" + : strcmp(mode, "renderMathBatch") == 0 ? "math" + : strstr(mode, "OpenApi") != nullptr ? "openapi" + : nullptr); + g_assert_cmpuint(contexts, ==, 1); + std::printf("first-use %s duration_us=%" G_GINT64_FORMAT "\n", mode, + g_get_monotonic_time() - started); + g_autoptr(FlValue) repeat = args(mode, "second"); + g_autoptr(TestResponse) repeated = h.call(mode, repeat); + g_autoptr(FlValue) repeated_result = h.success(repeated); + verify(mode, repeated_result); + g_assert_cmpuint(resource_reads["render-engines.js"], ==, 1); + only_engine(strcmp(mode, "renderMermaid") == 0 ? "mermaid" + : strcmp(mode, "renderPlantUml") == 0 ? "plantuml" + : strcmp(mode, "renderMathBatch") == 0 ? "math" + : strstr(mode, "OpenApi") != nullptr ? "openapi" + : nullptr); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 1); + g_assert_true(webkit_web_context_is_ephemeral(h.host->context)); + g_assert_true(webkit_web_context_get_sandbox_enabled(h.host->context)); + auto* settings = webkit_web_view_get_settings(h.host->web_view); + g_assert_false(webkit_settings_get_enable_html5_local_storage(settings)); + g_assert_false(webkit_settings_get_enable_html5_database(settings)); + g_assert_cmpint( + webkit_settings_get_hardware_acceleration_policy(settings), ==, + WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); + g_assert_cmpuint(contexts, ==, 1); + } else if (strcmp(mode, "engine-failure") == 0 || + strcmp(mode, "graphviz-failure") == 0) { + const char* operation = strcmp(mode, "graphviz-failure") == 0 + ? "renderPlantUml" + : "renderMermaid"; + failed_engine_resource = strcmp(mode, "graphviz-failure") == 0 + ? "viz-global.js" + : "mermaid-engine.js"; + fail_engine_read = true; + g_clear_pointer(&request, fl_value_unref); + request = args(operation); + g_autoptr(TestResponse) first = h.call(operation, request); + g_autoptr(FlValue) math = args("renderMathBatch", "math"); + g_autoptr(TestResponse) second = h.call("renderMathBatch", math); + h.error(first, "visualization.hostUnavailable"); + g_autoptr(FlValue) math_result = h.success(second); + verify("renderMathBatch", math_result); + // Retry without the release-only process-termination hook. A failed + // module registry must not permanently poison this renderer operation. + g_autoptr(TestResponse) retry = h.call(operation, request); + g_autoptr(FlValue) result = h.success(retry); + verify(operation, result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(harness_loads, ==, 2); + g_assert_cmpuint(views, ==, 2); + } else if (strcmp(mode, "failure") == 0 || + strcmp(mode, "load-failure") == 0 || + strcmp(mode, "harness-failure") == 0) { + if (strcmp(mode, "failure") == 0) { + fail_context = true; + } else { + g_free(h.host->resource_root); + if (strcmp(mode, "harness-failure") == 0) { + h.host->resource_root = + g_dir_make_tmp("bm-harness-failure-XXXXXX", nullptr); + g_autofree gchar* path = + g_build_filename(h.host->resource_root, "harness.html", nullptr); + g_assert_true(g_file_set_contents( + path, "Missing engine", -1, nullptr)); + } else { + h.host->resource_root = g_strdup("/nonexistent/busymark-probe"); + } + } + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + g_autoptr(TestResponse) second = h.call("renderMermaid", request); + h.error(first, "visualization.hostUnavailable"); + h.error(second, "visualization.hostUnavailable"); + until([&] { return h.host->recreate_source_id == 0; }); + g_assert_null(h.host->web_view); + g_assert_null(h.host->context); + g_free(h.host->resource_root); + h.host->resource_root = locate_resource_root(); + g_autoptr(TestResponse) retry = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(retry); + verify("renderMermaid", result); + } else if (strcmp(mode, "concurrent-cancel") == 0 || + strcmp(mode, "pre-cancel") == 0) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + if (strcmp(mode, "concurrent-cancel") == 0) { + until([&] { return h.host->state == RenderState::loading; }); + } + g_autoptr(FlValue) cancel = args("cancelRender"); + g_autoptr(TestResponse) cancelled = h.call("cancelRender", cancel); + g_autoptr(FlValue) result = h.success(cancelled); + g_assert_true(fl_value_get_bool(result)); + h.error(first, "visualization.cancelled"); + if (strcmp(mode, "pre-cancel") == 0) { + until([&] { return h.host->recreate_source_id == 0; }); + h.dormant(); + } else { + g_autoptr(FlValue) math = args("renderMathBatch", "math"); + g_autoptr(FlValue) plant = args("renderPlantUml", "plant"); + g_autoptr(TestResponse) second = h.call("renderMathBatch", math); + g_autoptr(TestResponse) third = h.call("renderPlantUml", plant); + g_autoptr(FlValue) math_result = h.success(second); + g_autoptr(FlValue) plant_result = h.success(third); + verify("renderMathBatch", math_result); + verify("renderPlantUml", plant_result); + g_assert_cmpuint(contexts, ==, 1); + g_assert_cmpuint(views, ==, 1); + g_assert_cmpuint(harness_loads, ==, 1); + } + } else if (strcmp(mode, "active-cancel") == 0 || + strcmp(mode, "snapshot-cancel") == 0 || + strcmp(mode, "engine-cancel") == 0) { + if (strcmp(mode, "snapshot-cancel") == 0) { + g_clear_pointer(&request, fl_value_unref); + request = args("rasterizeSvg"); + } + g_autoptr(TestResponse) first = + h.call(strcmp(mode, "snapshot-cancel") == 0 ? "rasterizeSvg" + : "renderMermaid", + request); + until([&] { + return h.host->active != nullptr && + (strcmp(mode, "snapshot-cancel") != 0 || + h.host->active->snapshot_wait_source_id != 0); + }); + if (strcmp(mode, "engine-cancel") == 0) + until([&] { return resource_reads["mermaid-engine.js"] != 0; }); + g_autoptr(FlValue) cancel = args("cancelRender"); + g_autoptr(TestResponse) cancelled = h.call("cancelRender", cancel); + h.error(first, "visualization.cancelled"); + until([&] { return h.host->active == nullptr; }); + g_clear_pointer(&request, fl_value_unref); + request = args("renderMermaid"); + g_autoptr(TestResponse) repeated = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(repeated); + verify("renderMermaid", result); + } else if (strstr(mode, "shutdown-") == mode) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + if (strcmp(mode, "shutdown-loading") == 0) + until([&] { return h.host->state == RenderState::loading; }); + if (strcmp(mode, "shutdown-readiness") == 0) + until([&] { return h.host->initialization_cancellable != nullptr; }); + if (strcmp(mode, "shutdown-active") == 0) + until([&] { return h.host->active != nullptr; }); + if (strcmp(mode, "shutdown-engine") == 0) + until([&] { return resource_reads["mermaid-engine.js"] != 0; }); + busymark_web_render_host_shutdown(h.host); + h.error(first, "visualization.hostUnavailable"); + until([&] { return h.host->active == nullptr; }); + drain(); + g_assert_null(h.host->context); + g_assert_null(h.host->web_view); + g_assert_true(h.host->state == RenderState::stopped); + } else if (strcmp(mode, "recovery") == 0) { + g_autoptr(TestResponse) first = h.call("renderMermaid", request); + g_autoptr(FlValue) result = h.success(first); + g_setenv("BUSYMARK_RELEASE_SMOKE", "1", TRUE); + g_autoptr(TestResponse) recovery = + h.call("terminateWebProcessForReleaseSmoke", nullptr); + g_autoptr(FlValue) recovered = h.success(recovery); + g_autoptr(TestResponse) repeated = h.call("renderMermaid", request); + g_autoptr(FlValue) repeat = h.success(repeated); + verify("renderMermaid", repeat); + g_assert_cmpuint(views, ==, 2); + g_assert_cmpuint(harness_loads, ==, 2); + g_assert_cmpint(webkit_settings_get_hardware_acceleration_policy( + webkit_web_view_get_settings(h.host->web_view)), + ==, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); + g_assert_cmpuint(contexts, ==, 1); + } else { + g_error("Unknown probe %s", mode); + } + } + std::printf("PASS %s\n", mode); +} diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index 8f5de025..65642611 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -463,7 +463,7 @@ class _HarnessState extends ConsumerState<_Harness> { _check( await _native( 'has', - 'Used for the current instance only. By default, inherited from topic title.', + 'The pinned builder does not publish instance title overrides in Markdown topics. Existing markup is preserved; inspect it in Source.', ) == 'true' && await _native( diff --git a/tools/wysiwyg_selection_smoke.dart b/tools/wysiwyg_selection_smoke.dart index 623fe2e0..11c48405 100644 --- a/tools/wysiwyg_selection_smoke.dart +++ b/tools/wysiwyg_selection_smoke.dart @@ -488,9 +488,9 @@ class _ProbeState extends State<_Probe> { '02-document-select-all', expectedRange: globalRange, needles: const [ - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', - 'not the final integration result.', + '/opt/example/gateway', + '/opt/example/desktop_client', + 'must preserve its final punctuation.', ], ); await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); @@ -502,7 +502,7 @@ class _ProbeState extends State<_Probe> { await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); final paragraphIndex = blocks.indexWhere( (b) => b.plainText.contains( - 'The client must work before corpus population finishes.', + 'The editor preserves selection across paragraph boundaries.', ), ); await _jump(paragraphIndex - 1); @@ -547,7 +547,7 @@ class _ProbeState extends State<_Probe> { ), ); final listIndex = blocks.indexWhere( - (b) => b.plainText == '/mnt/Media/Linguality/gateway', + (b) => b.plainText == '/opt/example/gateway', ); await _jump(listIndex - 1); await _drag( @@ -565,8 +565,8 @@ class _ProbeState extends State<_Probe> { blocks[listIndex + 2].plainText.length, ), needles: [ - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', + '/opt/example/gateway', + '/opt/example/desktop_client', blocks[listIndex + 2].plainText, ], ); @@ -618,8 +618,8 @@ class _ProbeState extends State<_Probe> { ), needles: [ paragraph.plainText, - '/mnt/Media/Linguality/gateway', - '/mnt/Media/Linguality/learner_client', + '/opt/example/gateway', + '/opt/example/desktop_client', for (final cell in cells) cell.plainText, ], );