From c3b53fff88513eeeaf94f20f4eb16a365b8f59e0 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 18:31:21 -0700 Subject: [PATCH 01/35] Bump version to v0.4.2 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 10 +++++----- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index 92d33207..6d645a6e 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.4.1'; +const busyMarkAppVersion = '0.4.2'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 4ae6f1b0..5cdbda3c 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -87,6 +87,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 3dc26ed3..c4fa0a0c 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.4.1 +version: 0.4.2 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 3a0ac4c1..1767fd4d 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.4.1" +version: "0.4.2" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 0e18367a..ecd4b094 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.4\.1$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.4\.2$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.4\.1"$', multiLine: true)), + contains(RegExp(r'^version: "0\.4\.2"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,9 +57,9 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Mon, 14 Sep 2026 14:26:35 -0700 Subject: [PATCH 02/35] Improve Writerside TOC support --- assets/writerside/TGDP-LICENSE | 7 + assets/writerside/templates.json | 334 +++ .../writerside-toc-implementation.md | 271 +++ lib/l10n/app_ar.arb | 69 +- lib/l10n/app_de.arb | 71 +- lib/l10n/app_en.arb | 140 +- lib/l10n/app_es.arb | 71 +- lib/l10n/app_et.arb | 69 +- lib/l10n/app_fa.arb | 71 +- lib/l10n/app_fr.arb | 71 +- lib/l10n/app_hi.arb | 69 +- lib/l10n/app_id.arb | 71 +- lib/l10n/app_it.arb | 69 +- lib/l10n/app_ja.arb | 69 +- lib/l10n/app_ko.arb | 69 +- lib/l10n/app_nb.arb | 69 +- lib/l10n/app_nl.arb | 71 +- lib/l10n/app_pl.arb | 69 +- lib/l10n/app_pt.arb | 69 +- lib/l10n/app_pt_BR.arb | 69 +- lib/l10n/app_ru.arb | 69 +- lib/l10n/app_tr.arb | 71 +- lib/l10n/app_uk.arb | 69 +- lib/l10n/app_vi.arb | 69 +- lib/l10n/app_zh.arb | 69 +- lib/l10n/app_zh_CN.arb | 69 +- lib/l10n/generated/app_localizations.dart | 396 +++- lib/l10n/generated/app_localizations_ar.dart | 217 +- lib/l10n/generated/app_localizations_de.dart | 221 +- lib/l10n/generated/app_localizations_en.dart | 219 +- lib/l10n/generated/app_localizations_es.dart | 219 +- lib/l10n/generated/app_localizations_et.dart | 217 +- lib/l10n/generated/app_localizations_fa.dart | 219 +- lib/l10n/generated/app_localizations_fr.dart | 224 +- lib/l10n/generated/app_localizations_hi.dart | 217 +- lib/l10n/generated/app_localizations_id.dart | 219 +- lib/l10n/generated/app_localizations_it.dart | 218 +- lib/l10n/generated/app_localizations_ja.dart | 212 +- lib/l10n/generated/app_localizations_ko.dart | 211 +- lib/l10n/generated/app_localizations_nb.dart | 218 +- lib/l10n/generated/app_localizations_nl.dart | 221 +- lib/l10n/generated/app_localizations_pl.dart | 217 +- lib/l10n/generated/app_localizations_pt.dart | 434 +++- lib/l10n/generated/app_localizations_ru.dart | 217 +- lib/l10n/generated/app_localizations_tr.dart | 218 +- lib/l10n/generated/app_localizations_uk.dart | 217 +- lib/l10n/generated/app_localizations_vi.dart | 217 +- lib/l10n/generated/app_localizations_zh.dart | 422 +++- lib/src/app/busymark_design.dart | 222 +- lib/src/app/command_registry.dart | 38 +- lib/src/editor/source/source_commands.dart | 93 + lib/src/editor/source/source_editor.dart | 67 +- lib/src/platform/native_menu_service.dart | 22 +- .../presentation/workspace_screen.dart | 1965 +++++++++++++---- .../writerside_template_dialogs.dart | 723 ++++++ .../presentation/writerside_toc_dialogs.dart | 276 +++ lib/src/workspace/workspace_controller.dart | 385 ++-- lib/src/workspace/workspace_safety.dart | 19 + lib/src/workspace/workspace_service.dart | 425 +++- .../writerside_document_resolver.dart | 156 +- lib/src/writerside/writerside_model.dart | 5 + lib/src/writerside/writerside_parsers.dart | 41 +- .../writerside_template_service.dart | 443 ++++ .../writerside/writerside_title_editor.dart | 279 +++ lib/src/writerside/writerside_toc_editor.dart | 221 +- .../writerside/writerside_toc_navigation.dart | 91 + .../writerside_toc_presentation.dart | 106 + .../writerside/writerside_topic_creator.dart | 33 +- .../writerside_topic_removal_service.dart | 18 +- .../writerside/writerside_tree_resolver.dart | 1 + linux/runner/my_application.cc | 295 ++- pubspec.yaml | 1 + test/fixtures/writerside/toc_ui/api.tree | 4 + test/fixtures/writerside/toc_ui/guide.tree | 12 + test/fixtures/writerside/toc_ui/library.tree | 3 + .../writerside/toc_ui/topics/details.topic | 4 + .../writerside/toc_ui/topics/hidden.md | 3 + .../fixtures/writerside/toc_ui/topics/home.md | 3 + .../writerside/toc_ui/topics/reused.md | 6 + .../writerside/toc_ui/topics/review.md | 3 + .../writerside/toc_ui/topics/shared.md | 3 + .../writerside/toc_ui/topics/unlinked.md | 3 + test/fixtures/writerside/toc_ui/v.list | 1 + .../fixtures/writerside/toc_ui/writerside.cfg | 7 + test/src/app_smoke_test.dart | 1463 ++++++------ test/src/localization_audit_test.dart | 17 +- test/src/native_headerbar_audit_test.dart | 5 +- test/src/search_replace_service_test.dart | 12 +- test/src/source_audit_test.dart | 29 +- test/src/workspace_controller_test.dart | 132 ++ .../src/writerside_template_dialogs_test.dart | 259 +++ .../src/writerside_template_service_test.dart | 368 +++ test/src/writerside_toc_actions_test.dart | 413 ++++ test/src/writerside_toc_menu_test.dart | 263 +++ .../writerside_toc_source_widget_test.dart | 110 + test/src/writerside_toc_workspace_test.dart | 328 +++ tools/linux_native_menu_probe.py | 99 + tools/writerside_toc_visual_smoke.dart | 1032 +++++++++ 98 files changed, 16710 insertions(+), 1471 deletions(-) create mode 100644 assets/writerside/TGDP-LICENSE create mode 100644 assets/writerside/templates.json create mode 100644 docs/development/writerside-toc-implementation.md create mode 100644 lib/src/workspace/presentation/writerside_template_dialogs.dart create mode 100644 lib/src/workspace/presentation/writerside_toc_dialogs.dart create mode 100644 lib/src/writerside/writerside_template_service.dart create mode 100644 lib/src/writerside/writerside_title_editor.dart create mode 100644 lib/src/writerside/writerside_toc_navigation.dart create mode 100644 lib/src/writerside/writerside_toc_presentation.dart create mode 100644 test/fixtures/writerside/toc_ui/api.tree create mode 100644 test/fixtures/writerside/toc_ui/guide.tree create mode 100644 test/fixtures/writerside/toc_ui/library.tree create mode 100644 test/fixtures/writerside/toc_ui/topics/details.topic create mode 100644 test/fixtures/writerside/toc_ui/topics/hidden.md create mode 100644 test/fixtures/writerside/toc_ui/topics/home.md create mode 100644 test/fixtures/writerside/toc_ui/topics/reused.md create mode 100644 test/fixtures/writerside/toc_ui/topics/review.md create mode 100644 test/fixtures/writerside/toc_ui/topics/shared.md create mode 100644 test/fixtures/writerside/toc_ui/topics/unlinked.md create mode 100644 test/fixtures/writerside/toc_ui/v.list create mode 100644 test/fixtures/writerside/toc_ui/writerside.cfg create mode 100644 test/src/writerside_template_dialogs_test.dart create mode 100644 test/src/writerside_template_service_test.dart create mode 100644 test/src/writerside_toc_actions_test.dart create mode 100644 test/src/writerside_toc_menu_test.dart create mode 100644 test/src/writerside_toc_source_widget_test.dart create mode 100644 test/src/writerside_toc_workspace_test.dart create mode 100644 tools/linux_native_menu_probe.py create mode 100644 tools/writerside_toc_visual_smoke.dart diff --git a/assets/writerside/TGDP-LICENSE b/assets/writerside/TGDP-LICENSE new file mode 100644 index 00000000..47fe16cf --- /dev/null +++ b/assets/writerside/TGDP-LICENSE @@ -0,0 +1,7 @@ +# MIT No Attribution License + +Copyright (c) 2024 The Good Docs Project + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/assets/writerside/templates.json b/assets/writerside/templates.json new file mode 100644 index 00000000..ccbe23f1 --- /dev/null +++ b/assets/writerside/templates.json @@ -0,0 +1,334 @@ +[ + { + "id": "Writerside How to MD Topic.md", + "name": "How to", + "category": "default", + "extension": "md", + "source": "# ${TITLE}\n\nA How-to article is an action-oriented type of document.\nIt explains how to perform a specific task or solve a problem, and usually contains a sequence of steps.\nStart with a short introductory paragraph that explains what users will accomplish by following this procedure,\nwhat they need to perform it for, or define the target audience of the doc.\n\n> **Highlight important information**\n>\n> You can change the element to *tip* or *warning* by renaming the style attribute below.\n>\n{style=\"note\"}\n\n## Before you start\n\nIt is good practice to list the prerequisites that are required or recommended.\n\nMake sure that:\n- First prerequisite\n- Second prerequisite\n\n## How to perform a task\n\nSome introductory information.\n\n1. Step with a code block\n\n ```bash\n run this --that\n ```\n\n2. Step with a [link](https://www.jetbrains.com)\n\n3. Step with a list.\n - List item\n - List item\n - List item", + "sha256": "14229d6df17710577281329ba5dc18f4177ae46a61b0cbe8e3ee9558442adf5a", + "resource": "fileTemplates/internal/Writerside How to MD Topic.md.ft" + }, + { + "id": "Writerside How to XML Topic.topic", + "name": "How to", + "category": "default", + "extension": "topic", + "source": "\n\n\n\n

\n A How-to article is an action-oriented type of document.\n It explains how to perform a specific task or solve a problem, and usually contains a sequence of steps.\n Start with a short introductory paragraph that explains what users will accomplish by following this procedure,\n what they need to perform it for, or define the target audience of the doc.\n

\n\n \n

Highlight important information.

\n

\n You can change this element to <tip> or <warning>.\n

\n
\n\n \n

It is good practice to list the prerequisites that are required or recommended.

\n\n

Make sure that:

\n\n \n
  • First prerequisite
  • \n
  • Second prerequisite.
  • \n
    \n
    \n\n \n

    \n Avoid writing procedures that are longer than 5 steps to keep it simple.\n If the title of a procedure repeats the chapter title,\n consider dropping the 'title' attribute of the procedure.\n

    \n\n \n \n

    Step with a code block.

    \n\n \n Hello, world!\n \n
    \n\n \n Step with a link.\n \n\n \n

    Step with a list.

    \n\n \n
  • \n

    List item.

    \n
  • \n
  • \n

    List item.

    \n
  • \n
  • \n

    List item.

    \n
  • \n
    \n
    \n
    \n
    \n
    ", + "sha256": "800f54f85d418ce277265a9464a1898e9c1428c782b06c337cdd130f1821ca72", + "resource": "fileTemplates/internal/Writerside How to XML Topic.topic.ft" + }, + { + "id": "Writerside Overview MD Topic.md", + "name": "Overview", + "category": "default", + "extension": "md", + "source": "# ${TITLE}\n\nOverview articles give background information and provide context to a particular subject.\nTheir goal is to explain a concept, not to teach or give instructions.\n\n## What is product/service/concept\n\nProvide some background and context, explain choices and alternatives.\n\n## Glossary\n\nA definition list or a glossary:\n\nFirst Term\n: This is the definition of the first term.\n\nSecond Term\n: This is the definition of the second term.\n", + "sha256": "55609e3de33939c9291fa8b1f3598f2b1f7d089e14fe5114143f93f7311c4dc6", + "resource": "fileTemplates/internal/Writerside Overview MD Topic.md.ft" + }, + { + "id": "Writerside Overview XML Topic.topic", + "name": "Overview", + "category": "default", + "extension": "topic", + "source": "\n\n\n\n

    \n Overview articles give background information and provide context to a particular subject.\n Their goal is to explain a concept, not to teach or give instructions.\n

    \n\n \n

    Provide some background and context, explain choices and alternatives.

    \n
    \n \n

    A definition list or a glossary:

    \n \n \n This is the definition of the first term.\n \n \n This is the definition of the second term.\n \n \n
    \n
    ", + "sha256": "fdbd958051eb34099e0c207d33a20ee57e7966897981d067eac07bd1b0b7204e", + "resource": "fileTemplates/internal/Writerside Overview XML Topic.topic.ft" + }, + { + "id": "Writerside Reference MD Topic.md", + "name": "Reference", + "category": "default", + "extension": "md", + "source": "# ${TITLE}\n\nA reference article is information-oriented.\nIt provides a structured description of a product:\nits APIs, classes, functions, configuration options, actions, and so on.\nStart with a summary of what this reference article is about, and what the items you are describing are used for.\n\n## Command\n\nSyntax:\n\n```shell\ncmd [OPTIONS]\n```\n\n## Options\n\nDescribe what each option is used for:\n\n-o, --open\n: Opens a file.\n\n-c, --close\n: Closes a file.\n\n-v, --version\n: Displays version information.\n\n-h, --help\n: Displays help.\n\n\n \n", + "sha256": "948fdefb78b57693dd84460e8a5828a0438eda5eb5149a1cb37ca22ee611e126", + "resource": "fileTemplates/internal/Writerside Reference MD Topic.md.ft" + }, + { + "id": "Writerside Reference XML Topic.topic", + "name": "Reference", + "category": "default", + "extension": "topic", + "source": "\n\n\n\n

    \n A reference article is information-oriented.\n It provides a structured description of a product:\n its APIs, classes, functions, configuration options, actions, and so on.\n Start with a summary of what this reference article is about, and what the items you are describing are used for.\n

    \n\n \n

    Syntax:

    \n\n \n cmd [OPTIONS]\n \n
    \n\n \n

    Describe what each option is used for:

    \n\n \n \n Opens a file.\n \n \n Closes a file.\n \n \n Displays version information.\n \n \n Displays help.\n \n \n
    \n\n \n \n \n
    ", + "sha256": "432a8f5a3d90062c8539732737229ebe1a9a7d8a146234ba4efcf55bf79ebf61", + "resource": "fileTemplates/internal/Writerside Reference XML Topic.topic.ft" + }, + { + "id": "Writerside Section Starting Page.topic", + "name": "Section Starting Page", + "category": "default", + "extension": "topic", + "source": "\n\n\n\n\n\n \n Section starting page title\n \n Add an introductory paragraph: explain what this section is about in 2-3 short sentences.\n \n\n \n \n Custom card title\n Another custom title\n \n\n \n \n Get started\n \n \n Custom card title\n Custom card title\n \n\n \n \n Explore advanced features\n \n \n Custom card title\n Custom card title\n \n\n \n \n \n Other relevant topics as wide cards\n \n \n Custom card title\n Custom card title\n \n\n \n Other relevant topics as narrow cards\n \n \n \n Custom card title\n Custom card title\n Custom card title\n \n\n \n \n Other related topics as links\n \n \n Custom card title\n Custom card title\n \n \n Two in a row\n \n \n Custom card title\n Custom card title\n \n \n\n \n \n More related topics\n \n \n Custom card title\n Custom card title\n \n \n Three in a row\n \n \n Custom card title\n Custom card title\n \n \n Each group is narrow\n \n \n Custom card title\n Custom card title\n \n \n \n \n\n", + "sha256": "50a71c68e818cef51361a93e32e707be1cf7b0d266e7a24db0145b4e8b38eaea", + "resource": "fileTemplates/internal/Writerside Section Starting Page.topic.ft" + }, + { + "id": "Writerside Starter MD Topic.md", + "name": "Starter", + "category": "default", + "extension": "md", + "source": "# ${TITLE}\n\n\n\n## Add new topics\nYou can create empty topics, or choose a template for different types of content that contains some boilerplate structure to help you get started:\n\n![Create new topic options](new_topic_options.png){ width=290 }{border-effect=line}\n\n## Write content\n%product% supports two types of markup: Markdown and XML.\nWhen you create a new help article, you can choose between two topic types, but this doesn't mean you have to stick to a single format.\nYou can author content in Markdown and extend it with semantic attributes or inject entire XML elements.\n\n## Inject XML\nFor example, this is how you inject a procedure:\n\n\n \n

    Start typing and select a procedure type from the completion suggestions:

    \n \"completion\n
    \n \n

    Press Tab or Enter to insert the markup.

    \n
    \n
    \n\n## Add interactive elements\n\n### Tabs\nTo add switchable content, you can make use of tabs (inject them by starting to type `tab` on a new line):\n\n\n \n ![Alt Text](new_topic_options.png){ width=450 }\n \n \n \n ]]>\n \n\n\n### Collapsible blocks\nApart from injecting entire XML elements, you can use attributes to configure the behavior of certain elements.\nFor example, you can collapse a chapter that contains non-essential information:\n\n#### Supplementary info {collapsible=\"true\"}\nContent under a collapsible header will be collapsed by default,\nbut you can modify the behavior by adding the following attribute:\n`default-state=\"expanded\"`\n\n### Convert selection to XML\nIf you need to extend an element with more functions, you can convert selected content from Markdown to semantic markup.\nFor example, if you want to merge cells in a table, it's much easier to convert it to XML than do this in Markdown.\nPosition the caret anywhere in the table and press Alt+Enter:\n\n\"Convert\n\n## Feedback and support\nPlease report any issues, usability improvements, or feature requests to our\nYouTrack project\n(you will need to register).\n\nYou are welcome to join our\npublic Slack workspace.\nBefore you do, please read our [Code of conduct](https://www.jetbrains.com/help/writerside/writerside-code-of-conduct.html).\nWe assume that you’ve read and acknowledged it before joining.\n\nYou can also always email us at [writerside@jetbrains.com](mailto:writerside@jetbrains.com).\n\n\n \n Markup reference\n Reorder topics in the TOC\n Build and publish\n Configure Search\n \n", + "sha256": "aeee533f19001884dbd8687bdc24d42875de77626bfce292380f25dffe4fc312", + "resource": "fileTemplates/internal/Writerside Starter MD Topic.md.ft" + }, + { + "id": "Writerside Tutorial MD Topic.md", + "name": "Tutorial", + "category": "default", + "extension": "md", + "source": "# ${TITLE}\n\nTutorials are learning-oriented articles that help users to go through a process and achieve a deliverable.\nStart with an introduction: for whom is this tutorial and what the reader will achieve by reading it.\nAnswer the question: \"Why should I follow this?\"\n\nProvide a short outline for the tutorial.\nIn this tutorial, you will learn how to:\n* Do this\n* Deal with that\n\n## Before you start\n\nList the prerequisites that are required or recommended.\n\nMake sure that:\n- First prerequisite\n- Second prerequisite\n\n## Part 1\n\nDescribe what the user will learn and accomplish in the first part,\nthen write a step-by-step procedure but on a real-world example.\n\n1. Execute the following command in the terminal:\n\n ```bash\n run this --that\n ```\n\n2. Step with a [link](https://www.jetbrains.com)\n\n3. Final step in part 1.\n\n## Part 2\n\nThis is the second part of the tutorial:\n\n1. Step 1\n2. Step 2\n3. Step n\n\n## What you've learned {id=\"what-learned\"}\n\nSummarize what the reader achieved by completing this tutorial.\n\n\n\n\n", + "sha256": "5f4fed2a3acf0a9189ac414cabd193b30ede2fab107155f90d90f8d2c4466f22", + "resource": "fileTemplates/internal/Writerside Tutorial MD Topic.md.ft" + }, + { + "id": "Writerside Tutorial XML Topic.topic", + "name": "Tutorial", + "category": "default", + "extension": "topic", + "source": "\n\n\n\n

    \n Tutorials are learning-oriented articles that help users to go through a process and achieve a deliverable.\n Start with an introduction: for whom is this tutorial and what the reader will achieve by reading it.\n Answer the question: \"Why should I follow this?\"\n

    \n\n

    \n Provide a short outline for the tutorial.\n In this tutorial, you will learn how to:\n

    \n\n \n
  • Do this
  • \n
  • Deal with that
  • \n
    \n\n \n

    \n List the prerequisites that are required or recommended.\n Make sure that:\n

    \n\n \n
  • Prerequisite one
  • \n
  • Prerequisite two
  • \n
    \n
    \n\n \n

    \n Describe what the user will learn and accomplish in the first part,\n then write a step-by-step procedure but on a real-world example.\n

    \n\n \n \n

    Execute the following command in the terminal:

    \n\n \n run this --that\n \n
    \n\n \n Step with a link.\n \n\n \n Final step in part 1.\n \n
    \n
    \n\n \n

    This is the second part of the tutorial:

    \n\n \n \n From the menu, select Foo | Bar.\n \n \n Enter this text in the Text field.\n \n \n Click OK to...\n \n \n
    \n\n \n

    Summarize what the reader achieved by completing this tutorial.

    \n
    \n\n \n \n \n
    \n", + "sha256": "26dd80f32dcafd73e4877c019988dc01fdbc098e1febfc291a76a253b851773f", + "resource": "fileTemplates/internal/Writerside Tutorial XML Topic.topic.ft" + }, + { + "id": "api-quickstart/template_api-quickstart.md", + "name": "API quickstart", + "category": "tgdp", + "extension": "md", + "source": "# API Quick Start template\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_api-quickstart.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Before you start\n\nBefore you begin, make sure you meet these prerequisites:\n\n* Pre-requisite one\n* Pre-requisite two\n\n## Get started\n\n## Step 1 - {One-sentence description of the step}\n\n\n\n## Step 2 - {One-sentence description of the step}\n\n\n\n\n\n## What's next\n\n\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=API%20quickstart) to give feedback on this template.\n", + "sha256": "8026445856d8af61d208ca840a0136bfad2d4ee709281f58252590c6514c4619", + "resource": "tgdpTemplates/templates-main.zip!/api-quickstart/template_api-quickstart.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/api-quickstart/template_api-quickstart.md", + "description": "API quickstarts describe the easiest way for readers to achieve a result that shows off the capabilities of your service." + }, + { + "id": "api-reference/template_api-reference.md", + "name": "API reference", + "category": "tgdp", + "extension": "md", + "source": "# API Reference template\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_api-reference.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Overview\n\nUse the {product} APIs to {access | customize | program} the {features | functionality}.\n\n### Base URL\n\n```text\n{Provide the base URL of the API. For example: https://api.example.com}\n```\n\n### Authorization\n\nAuthentication and authorization {is | is not} required for requests to these APIs. Supported authentication methods are:\n{ Basic | Digest | OAuth | others}\n\n```text\n{Provide an example request with {Basic | Digest | OAuth | others} authentication.}\n```\n\n### Version\n\n{This section is optional.}\n\n{Provide the version number using semantic versioning or your product's API versioning scheme. For example: `0.0.1`}\n\n### Pagination\n\n{This section is optional.}\n\nDue to the potentially very large result sets from API calls, responses {are | can be} returned as shorter pages.\n\nPagination can be customized using {pagination settings}. If not specified, the default values are {values}.\n\n### Rate limiting and throttling\n\n{This section is optional.}\n\nThe {product} APIs use a {strategy-name} rate limiting strategy. The maximum number of requests allowed to access a {resource | endpoint |..} is {number} requests per {time period}.\n\n### HTTP status codes\n\nThe {product} APIs use the following standard HTTP response codes:\n\n| Status code | Message | Description |\n|-------------|-------------------|---------------|\n| `200 OK` | Request succeeds. | {description} |\n| | | |\n| | | |\n\n### Errors\n\n{This section is optional.}\n\nThe {product} APIs use the following error types:\n\n| Error | Description |\n|-----------------------------------------|------------------|\n| [{ExampleErrorType}](#exampleerrortype) | {Failure in ...} |\n| | |\n| | |\n\n#### ExampleErrorType\n\n| Field | Type | Description |\n|----------------|----------|--------------------------------------------------|\n| {errorType} | {enum} | {Predefined error codes. Possible enum values are x, y, ..., and z.} |\n| {errorMessage} | {string} | {Additional information about why the error occurs.} |\n\n## {Resource name}\n\nThe {resource name} is used to {functionality}.\n\n### Data model\n\n| Attribute | Type | Required? | Description |\n|-----------|--------|-----------|------------------------------|\n| {id} | string | Required | {Unique identifier of user} |\n| {name} | string | Optional | {Name of user} |\n| | | | |\n\n### Example\n\n```text\n{Provide an example of the data representation in the format that your project use.}\n```\n\n### Endpoints\n\nUse the following endpoints to interact with the {resource name} entities.\n\n| Method | Endpoint name | Description |\n|--------|------------------------------------------|-------------------------|\n| POST | {[Endpoint name A](#link_to_endpoint_a)} | Creates a {resource}. |\n| GET | {[Endpoint name B](#link_to_endpoint_b)} | Retrieves a {resource}. |\n| | | |\n\n## {Endpoint name}\n\n{Provide a one-line description of what the API does. Starts with a verb in the indicative mood. For example, \"Retrieves a user by `userID`\".}\n\n### Endpoint\n\n```text\n{METHOD} /{request-url}/{{path-parameter}}\n```\n\n### Description\n\n{Explain what the endpoint does.}\n\n{This paragraph is optional.} This endpoint has been deprecated. Use {the recommended endpoint} instead. For more information about how to migrate to {the recommended endpoint}, see [{the migration guide}](#link).\n\n{This paragraph is optional.} The maximum number of calls to this API endpoint is {number} per minute. For more information about API rate limiting/throttling, see [{the topic}](#example).\n\n### Authorization\n\nThe [{authorization method}](#authorization) is required for each API request.\n\n{This paragraph is optional.} Calling this endpoint also requires the {permission-name} permission.\n\n### Request schema\n\n#### Path parameters\n\n{This section is optional.}\n\n| Path parameter | Type | Required? | Description |\n|----------------|--------|-----------|------------------------------|\n| {id} | string | Required | {Unique identifier of user} |\n| | | | |\n\n#### Query parameters\n\n{This section is optional.}\n\n| Query parameter | Type | Required? | Description |\n|-----------------|------|-----------|-----------------------------------------|\n| {pageSize} | int | Optional | {The number of items to be returned in a single request. The default value is N.} |\n| | | | |\n\n#### Header parameters\n\n{This section is optional.}\n\n| Header parameter | Type | Required? | Description |\n|------------------|--------|-----------|--------------------------------------|\n| {Content-Type} | string | Required | {Media type of the resource. Must be an object.} |\n| | | | |\n\n#### Request body\n\n{This section is optional.}\n\n| Field | Type | Required? | Description |\n|--------|--------|-----------|----------------------------------|\n| {id} | string | Required | {Unique identifier of the user} |\n| {name} | string | Optional | {Name of the user} |\n\n### Request example\n\n```text\n{Provide an example of the API request, filled with sample values.}\n```\n\n### Response schema\n\n| Status code | Schema | Description |\n|-------------|-----------------------------------------|----------------------|\n| `2xx` | [{ExampleDataType}](#data-model) | {Describe the result where the request succeeds.} |\n| `4xx` | [{ExampleErrorType}](#exampleerrortype) | {Describe the result where the request fails with the specified error code.} |\n\n### Response example\n\n```text\n{Provide an example of the API response, filled with sample values.}\n```\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=API%20reference) to give feedback on this template.\n", + "sha256": "77a5375cca4031a23d88a4d935636b1ce70cc46f6f1121e4035cc7b0fd028144", + "resource": "tgdpTemplates/templates-main.zip!/api-reference/template_api-reference.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/api-reference/template_api-reference.md", + "description": "API references are technical manuals that provide API specifications and integration instructions to help your audience understand the service." + }, + { + "id": "bug-report/template_bug-report.md", + "name": "Bug report", + "category": "tgdp", + "extension": "md", + "source": "# Bug Report Template\n\n> If you need more information about how to fill in this template, read the accompanying [guide](guide_bug-report.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Title\n\n{A description of the issue using key words relating to the issue. 50 to 80 characters recommended. Description should cover the problem, the action, and the location (if relevant).}\n\n## Summary (optional)\n\n{If the title is insufficient for summarizing the bug, use the summary to detail the bug as concisely as possible. 3-4 sentences recommended.}\n\n## Environment\n\n{Detail the specific environment used when the bug was encountered.}\n\n* {Product version}\n* {Browser}\n* {Installed packages and versions}\n* {Operating system}\n* {Device model}\n* {Connection details (type, speed, any packet loss)}\n\n## Steps to reproduce\n\n{An ordered list of the steps taken where the result was encountering the bug. Include any observations of relevance such as unexpected output, visual glitches, unusual process duration.}\n\n* {Step 1}\n* {Step 2}\n* {Step 3}\n\n## Observed behavior\n\n{Briefly describe the result of the steps taken that resulted in the bug being encountered. Avoid any assumptions regarding the behavior.}\n\n## Expected behavior (optional)\n\n{Briefly describe the expected result of the steps taken.}\n\n## Proof (optional)\n\n{Provide a copy of any relevant logs, screenshots, or malformed output if relevant.}\n\n## Test data (optional)\n\n{If the bug is only encountered when using specific test data, provide a copy of that data. If the data needs to be redacted, please ensure the redacted data also causes the unexpected behavior using the same steps.}\n\n## Additional context (optional)\n\n{If any additional context is required, detail that context here.}\n\n{Please keep in mind the project team may need additional clarification after the report is submitted.}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Bug%20report) to give feedback on this template.\n", + "sha256": "0eac300bd4bf71f3fbb0ac4a8beb150fc3c180c211b4f2eec7742803f7d29fa0", + "resource": "tgdpTemplates/templates-main.zip!/bug-report/template_bug-report.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/bug-report/template_bug-report.md", + "description": "The bug report is an issue template that your users can fill out to provide you with higher-quality, actionable bug issues for your product." + }, + { + "id": "code-of-conduct/template_code-of-conduct.md", + "name": "Code of Conduct", + "category": "tgdp", + "extension": "md", + "source": "# {Organization name} Code of Conduct\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_code-of-conduct.md).\n\n---\n\n{Include your project's opening statement of purpose in this section.\nReplace your own project's name in the indicated placeholder text.\nYou are welcome to customize or reword this boilerplate text as needed.\nAlternatively, you can write your own project's statement of purpose from scratch.}\n\n{Project name} and its members, contributors, sponsors, and leaders pledge to make participation in our community a positive, inclusive, and safe experience for all.\n{Project name} encourages contributions from everyone who shares our goals and wants to participate in a healthy, constructive, and professional manner.\n\nThis Code of Conduct aims to support a community where all people should feel safe to participate, to introduce new ideas, and to inspire others.\nThis includes everyone, regardless of: ability, age, background, body size or type, caste, disability (either visible or invisible), education, ethnicity, family status, gender, gender identity and expression, geographic location, level of\nexperience, marital status, nationality or national origin, native language, personal appearance, race, religion, sexual identity and orientation, socio-economic status, or any other dimension of diversity.\n\nOpenness and respectful collaboration are core values at {Project name}.\nWe are committed to being a community that everyone feels good about joining, and we will always work to treat everyone well.\nNo matter how you identify yourself or how others perceive you, you are welcome.\nWe gain strength from diversity and actively seek participation from those who enhance it.\n\nThese guidelines exist to enable all {Project name} community members to collaborate effectively.\nAs such, this document outlines both expected behavior and behavior that will not be tolerated.\nThe Code of Conduct isn't an exhaustive list of things that you must do or can't do.\nRather, take it in the spirit in which it's intended.\nIt's a set of guidelines to make it easier to enrich our community.\n\n## Expected behavior\n\nWe expect our members, contributors, and leaders to:\n\n* Participate in the community actively and authentically. Your meaningful contributions add to the health and longevity of this community.\n* When you make commitments, do your best to keep those commitments. Other community members will trust you and build confidence in you if you fulfill your promises. If something may prevent you from keeping a commitment or if you discover you won't be able to complete a task on time, try to notify others as soon as possible.\n* Attempt collaboration before conflict. Seek out and be respectful of differing opinions, styles, viewpoints, and experiences.\n* Give and accept constructive feedback, gracefully. When expressing disagreement, be professional and respectful. Be open to learning from and educating others where needed.\n* Demonstrate empathy and kindness toward other people. Be considerate and respectful in your word choice, speech, and actions. Show respect with the terms you use to address others.\n* Look out for those around you in the community, especially if you are in a position of influence. Alert the {Project name} {link to your community moderators document [community moderators](../our-team/template_our-team.md)} if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.\n* Gracefully accept responsibility and apologize to those affected by any mistakes, whether made intentionally or unintentionally. If someone says they have been harmed through your words or actions, listen carefully, make amends, learn from the experience, and correct the behavior going forward.\n* Focus on what is best for the overall community, not just for each of us individually. Ensure that leadership roles and opportunities are well-distributed across the community membership and not just centered in one person or the same few people. To help our community develop and build up leaders at every level of the {Project name}, ensure that you share knowledge with others as much as possible and be mindful of fostering healthy dialogue where no one voice dominates a conversation.\n\n## Behavior that will not be tolerated\n\nThe following behaviors are unacceptable within our community, whether they occur online, offline, privately, or publicly:\n\n* Violent language or threats directed against another person.\n* Sexist, racist, or otherwise discriminatory jokes and language.\n* Deliberate intimidation, following, or stalking (online or in person).\n* Personal insults, especially those related to gender, sexual orientation, race, religion, or disability. This includes misgendering, name calling, and mockery.\n* Trolling (posting controversial comments in order to provoke others), insulting, or making derogatory comments.\n* The use of sexualized language, jokes, or imagery, and sexual attention, inappropriate physical contact, or sexual advances of any kind.\n* Bullying, tone-policing (attacking a person's tone rather than the content of their message), or repeatedly dominating a topic of conversation (such as regularly talking over another person or not inviting discussion from others where appropriate).\n* Publishing or threatening to publish others' private information without their explicit permission. Private information includes physical addresses, email addresses, and emails or other communications sent privately or non-publicly.\n* Deliberate \"outing\" of any private aspect of a person's identity without their consent except as necessary to protect vulnerable people from intentional abuse. This includes sharing personally identifying information (\"doxing\").\n* Excessive or unnecessary profanity.\n* Deliberate misgendering.\n* Knowingly making harmful false claims about a person.\n* Pushing a person to drink alcohol when they don't want to drink, or deceiving someone into drinking alcohol.\n* Marketing to our community members either individually or collectively without their express approval. Solicitations on behalf of your business should be restricted to designated channels or areas. For more information about appropriate ways to market your business to the community, ask one of the {Project name} {link to your community moderators document [community moderators](../our-team/template_our-team.md)}.\n* Repeatedly communicating with a community member who has asked you to leave them alone.\n* Sustained disruption of community events, including meetings and presentations.\n* Recording or photography at community meetings and events without explicit permission.\n* Advocating for, or encouraging, any of the above behavior.\n* Other conduct which could reasonably be considered inappropriate in a professional setting.\n\n## Consequences of unacceptable behavior\n\nUnacceptable behavior from any {Project name} community member, contributor, sponsors, or leaders will not be tolerated.\nWe expect everyone to comply with requests to stop unacceptable behavior immediately.\n\nIf any community member engages in unacceptable behavior, any community member or moderator should report the incident to the {Project name} {link to your community moderators document [community moderators](../our-team/template_our-team.md)}.\nThe moderators will investigate the incident to determine the incident's severity and overall impact to the community.\nSee our {link to your [Code of Conduct response plan](../code-of-conduct-response-plan/template_code-of-conduct-response-plan.md)} for more details.\n\nDepending on the risk and impact level, the moderators may respond by requiring:\n\n* **Correction:** A private, written warning from community moderators, providing clarity around the nature of the violation, an explanation of why the behavior was inappropriate, and what behavior is expected going forward. A public apology may be requested.\n* **Warning:** A warning with consequences for continued behavior. No interaction with the people involved is allowed for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media and it includes unsolicited interaction with community moderators. Violating these terms may lead to a temporary or permanent ban.\n* **Temporary ban:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with community moderators, is allowed during this period. Violating these terms may lead to a permanent ban. Readmittance to the community usually requires an additional meeting with a community moderator to ensure future compliance.\n* **Permanent ban:** A permanent ban from any sort of public interaction within the community.\n\nThe action taken is at the discretion of the {Project name} {link to your community moderators document [community moderators](../our-team/template_our-team.md)}.\nParticipants are expected to comply immediately, and further action may be taken in case a participant does not comply.\n\nEvery community member is entitled to one appeal using the [same process for reporting a Code of Conduct incident](#reporting-an-incident).\nCommunity members are expected to comply with the requested action while appeals are being considered.\nAfter an appeal has been resolved, the decision is considered final.\n\n## Reporting an incident\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be reported to one of the {Project name}\n{link to your community moderators document [community moderators](../our-team/template_our-team.md)}.\nIf you would like to discuss your concerns or if you have personally experienced unacceptable behavior, please reach out to a community moderator as soon as possible.\n\nWe encourage reaching out to a community moderator, even if you are unsure whether something counts as a Code of Conduct incident or even if the situation is merely something you observed and did not happen to you directly.\n\nPlease reach out as soon as possible if:\n\n* You would like to discuss any concerns.\n* You have personally experienced unacceptable or potentially unacceptable behavior.\n* You want to report a situation happening to someone else.\n\n## Addressing Code of Conduct reports\n\nAll complaints will be reviewed and investigated promptly and fairly.\nIf possible, community moderators will recuse themselves in cases where there is a conflict of interest.\nSee our {link to your [Code of Conduct response plan](../code-of-conduct-response-plan/template_code-of-conduct-response-plan.md)} for more details.\n\nAn internal record will be kept of all incidents.\nHowever, all community leaders are obligated to respect the privacy and security of the reporter of any incident.\nIn some cases, community moderators may determine that a public statement will need to be made.\nIf that's the case, the identities of all victims and reporters will remain confidential unless those individuals instruct us otherwise.\n\nIf you feel you have been unfairly accused of violating these guidelines, please follow the [same process for reporting a Code of Conduct incident](#reporting-an-incident).\n\n## Where this Code of Conduct applies\n\nThese guidelines apply to all members of the {Project name} and to all {Project name} activities, including but not limited to:\n\n* Representing the {Project name} at public events and in social media.\n* Participating in the {Project name} meetings and events, whether virtual or in person.\n* Participating in the {Project name}'s related messaging forums, including our Slack workspace and mailing list and other {Project name}-related correspondence.\n* Interpersonal communications between {Project name} community members, whether virtual or in person.\n\nWhile this Code of Conduct applies specifically to the {Project name}'s work and community, it is possible for actions taken outside of the{Project name}'s online or in-person spaces to have a deep impact on community health if it concerns the {Project name} or its members in some way.\nThe Code of Conduct moderators reserve the right to consider communications or actions that occur outside of official {Project name} spaces if they have a demonstrated impact on one or more community members.\n\n## Related resources\n\n{Include a list of links to related resources, such as your Code of Conduct Response Plan and the Our Team page where you list who can be contacted to report Code of Conduct incidents.\nYou can also include additional information, such as links to articles that explain the rationale behind your Code of Conduct.}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Code%20of%20conduct) to give feedback on this template.\n", + "sha256": "3f5d0defdfe2678c8db1cd0f74b758bbd78b7174abc565c29a6d356a0e2d8de0", + "resource": "tgdpTemplates/templates-main.zip!/code-of-conduct/template_code-of-conduct.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/code-of-conduct/template_code-of-conduct.md", + "description": "A Code of Conduct helps you govern your open source or developer community to ensure it remains healthy and open." + }, + { + "id": "code-of-conduct-incident-record/template_code-of-conduct-incident-record.md", + "name": "Code of Conduct incident record", + "category": "tgdp", + "extension": "md", + "source": "# {Organization name} Code of Conduct incident record\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_code-of-conduct-incident-record.md).\n\n---\n\nTIPS FOR THE COMMUNITY MODERATOR:\nWhen gathering information from an incident reporter, strive for these goals:\n\n* **Safety** - Bring in another community moderator if something about the situation is unsafe or dangerous, but avoid inviting too many moderators into the discussion. You might make the incident reporter feel overwhelmed if there are too many moderators.\n* **Privacy** - Where possible, always strive to uphold the incident reporter's privacy. Obtain permission to proceed with the investigation.\n* **Empathy** - Be an understanding, active listener who recognizes the real emotions being felt by the incident reporter. For example, say: \"It sounds like you felt (this emotion) when (this thing happened).\"\n* **Support** - You should default to believing the reporter. Ask if there's anything you can do to make the incident reporter feel safe, emotionally whole, or restored. Explain the possible outcomes that are available, as provided in the [Code of Conduct](CODE_OF_CONDUCT.md) (correction, warning, temporary ban, permanent ban). However, avoid making any direct promises for exactly how the report will be handled until the investigation is concluded.\n* **Acknowledgment** - At the end of the meeting, thank the incident reporter for being part of the community and for reaching out about the incident. Let the reporter know that you will be in touch to explain how the incident will be resolved after the investigation is complete.\n\n## Incident number\n\n{Assign a number to this incident for tracking purposes.\nIt can include the date an investigation was opened.\nFor example, yyyy-001.}\n\n## Name of community moderator who took the report\n\n{Put your name here.}\n\n## Name of incident reporter\n\n{If the reporter wants to be anonymous, include a simple description of their role or involvement in the project.}\n\n## Reporter's contact information (optional)\n\n{Ask the reporter if they would like to provide their name and contact information.\nIf yes, fill in the contact information.}\n\n## Permission from incident reporter to proceed?\n\n{Yes/no.} {If no, ask if they would like to hold the report in escrow.}\n\n## Date, time, and location of incident (optional)\n\n{yyyy-mm-dd} {time} {location}.\n\n## Additional witnesses or contacts (optional)\n\n{List anyone who might be able to provide additional information as part of the investigation.}\n\n## Incident description\n\n{Only document information required to inform the report resolution.\nWhere possible, avoid documenting your opinion about the incident, or any information about individuals that is not relevant to the report.}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Code%20of%20conduct%20incident%20record) to give feedback on this template.\n", + "sha256": "aa734d30f7e95eaaa0efa75a93a25851a4289d1c0dc7ad3dceff7748dd4ab2c9", + "resource": "tgdpTemplates/templates-main.zip!/code-of-conduct-incident-record/template_code-of-conduct-incident-record.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/code-of-conduct-incident-record/template_code-of-conduct-incident-record.md", + "description": "Used as part of the Code of Conduct process, an incident record is a form that is filled out when a community moderator takes an incident report from a community member." + }, + { + "id": "code-of-conduct-remediation-record/template_code-of-conduct-remediation-record.md", + "name": "Code of Conduct remediation record", + "category": "tgdp", + "extension": "md", + "source": "# {Organization name} Code of Conduct remediation record\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_code-of-conduct-remediation-record.md).\n\n---\n\nTIPS FOR THE COMMUNITY MODERATOR:\nWhen meeting with a community member to explain the outcome of a Code of Conduct violation, strive for these goals:\n\n* **Preparation** - Simply tell the community member beforehand that you wish to discuss an issue privately. Invite only one other community moderator for support if needed.\n* **Calmness** - When explaining what behavior prompted the Code of Conduct investigation, calmly state what the behavior was without judgment or emotion. State the impact on the incident reporter or the community.\n* **Clarity** - Set a clear behavior modification plan. If possible, get the community member to agree to this plan.\n* **Privacy** - Do not disclose the identity of the incident reporter or allow them to be contacted. If the community member under investigation wants to contact the incident reporter to apologize, explain that you can take the apology on their behalf. With your permission, a public apology may be acceptable under the circumstances.\n\n## Incident number\n\n{Use the same incident number that was assigned to the incident report.}\n\n## Name of community moderator who remediated the incident\n\n{Put your name here.}\n\n## Name of community member involved in the incident\n\n{List the name of the community member who committed the Code of Conduct violation.}\n\n## Severity level\n\nSelect one:\n\n[ ] High\n[ ] Medium\n[ ] Low\n\n## Impact level\n\nSelect one:\n\n[ ] High\n[ ] Low\n\n## Outcome\n\nSelect one:\n\n[ ] Correction\n[ ] Warning\n[ ] Temporary ban\n[ ] Permanent ban\n[ ] None\n\n{List any additional details about the outcome and consequences as needed.}\n\n## Behavior modification plan\n\n{List the details of the behavior modification plan as discussed with the other community moderators.}\n\n## Do you agree to follow the plan?\n\n{Yes/no.} {If no, proceed to the consequences listed in the next section.}\n\n## Consequences if they do not agree to the behavior modification plan\n\n{List the consequences if the community member who committed the Code of Conduction violation does not agree to the plan.\nThe suggested consequence is removal from the community.}\n\n## Who can they appeal this decision to?\n\n{Explain the options for appealing a decision as explained in the Code of Conduct policy.\nExplain that they must still comply with the incident response plan even if an appeal process is underway.}\n\n## Reported person's response to the plan\n\n{Provide details about how the community member responded to the proposed plan.}\n\n## Additional information gathered\n\n{List any other observations or notes as needed.}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Code%20of%20conduct%20remediation%20record) to give feedback on this template.\n", + "sha256": "6f06fa2750fed58fe52052759814ce8b7520c4d5f749e58393bbd5ee84030a9a", + "resource": "tgdpTemplates/templates-main.zip!/code-of-conduct-remediation-record/template_code-of-conduct-remediation-record.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/code-of-conduct-remediation-record/template_code-of-conduct-remediation-record.md", + "description": "Used as part of the Code of Conduct process, a remediation record is a form that is filled out when a community moderator meets with a community member to explain the consequences of a Code of Conduct violation." + }, + { + "id": "code-of-conduct-response-plan/template_code-of-conduct-response-plan.md", + "name": "Code of Conduct response plan", + "category": "tgdp", + "extension": "md", + "source": "# {Organization name} Code of Conduct response plan\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_code-of-conduct-response-plan.md).\n\n---\n\nThis document explains:\n\n* How to contact the current {Project name} community moderators to report a [Code of Conduct](code-of-conduct.md) incident.\n* The policies and procedures that community moderators should follow when responding to a Code of Conduct incident.\n* Additional governing policies for the community moderator team (also sometimes referred to as the \"Code of Conduct committee\").\n\n## Community moderators\n\nFor a list of the current {Project name} community moderators and the best way to reach them, see [Community Moderators](our-team.md).\nYou may contact any of these individuals to make a Code of Conduct incident report.\n\n{Instead of linking to a separate page, you could list the names of the community moderators and their preferred contact information.}\n\n## Contacting a community moderator\n\nYou can contact a community moderator to make a Code of Conduct incident report or to discuss the process and options related to Code of Conduct incidents.\nTo make an incident report, send a message to the community moderator that you have the best working relationship with and feel most comfortable talking.\n\nTo contact a moderator, please {describe your preferred contact method, such as sending a direct private message on Slack or some other method}.\nCommunity moderators will respond as soon as they possibly can.\nThey might also request a one-on-one meeting with you (such as a phone call or online video conference) to get more information about the incident.\n\n## Community moderator values\n\nCommunity moderators should strive to:\n\n* Occupy a position of trust within {Project name} community.\n* Be active listeners who can show empathy and understanding when meeting with an incident responder.\n* Respect the privacy of incident reporters or other potentially sensitive and private information they may have access to in this role.\n* Be fair and open-minded when investigating an incident and recommending a response.\n* Develop healthy self-care strategies to prevent burnout and reduce personal stress.\n\n## Requirements for community moderators\n\nThe {Project name} community moderators play an important role in the community because they help ensure the community is healthy, vibrant, and welcoming to all contributors.\nBecause of the crucial nature of this role, potential community moderators should be invested in the long-term health of the\n{Project name} community and should be willing to develop a set of communication skills that may require some formal training.\nFor that reason, individuals who are interested in serving as community moderators must:\n\n* Commit to a day or half-day formal training in incident response skills within {required time period} of joining the moderator team. Formal training involves taking an online training course in incident response skills, mediation, or arbitration. Or it could also involve participating in an informal workshop in incident response skills led by a current {Project name} moderator. Upon joining the moderator team, the new moderator can work with other moderators to develop a training plan and ensure this training requirement is met.\n* Be recommended for the team by either another community moderator or a member of the {Project name} core team. Nominated individuals should demonstrate the values and qualities necessary to carry out the responsibilities of community moderators. Note that {Project name} community members may first volunteer for consideration and then seek a recommendation afterwards.\n* Be active contributors to the {Project name} who have participated in the community for at least three months. Contributions can include authoring pull requests, submitting issues, attending meetings regularly, and participating in {Project name} community forums.\n* Not be subject of an ongoing {Project name} Code of Conduct incident.\n\n## Community moderator terms of service\n\nThe {Project name} community moderator team should consist of 3-5 community members at a given time.\nTo prevent burnout, community moderators should serve for a recommended term limit of {time period}, unless there have been few or no incidents in that space of time.\nCommunity members should stagger terms of service to ensure there is some continuity on the team over time.\nCommunity moderators may return to serve second terms after a break from service.\nIf possible, outgoing community moderators should recommend a replacement from the community.\n\n## Reviewing the Code of Conduct\n\nThe Code of Conduct and this document (the Code of Conduct response plan) should be reviewed by the moderator team at least once a year, typically in {time period}, to ensure these documents are meeting the needs of the community.\nThe {Project name} community will notify the community of any revisions by publicizing the revisions in the community's {communication platforms used by the community}.\n\n## Key terms used in this document\n\nThis section provides a definition of key terms and roles that appear in the incident response policy that follows this section:\n\n* **Incident** - A behavior by a member of the {Project name} that allegedly violates the community [Code of Conduct](code-of-conduct.md). Also known as a \"Code of Conduct incident\" or \"conduct violation.\"\n* **Incident report** - Begins when a member of the {Project name} reports behavior that violates the community Code of Conduct. The incident report refers to the violating behavior that is then investigated by community moderators. Also sometimes referred to as the \"report.\"\n* **Incident reporter** - The person who reports a Code of Conduct violation to a community moderator. Also sometimes referred to as the \"reporter.\"\n* **Handling an incident report** - The process of investigating and resolving an incident report as explained using the processes and guidelines in the subsequent sections. Also known as \"investigating a report.\"\n* **Investigating moderator** - The community moderator who will handle the incident report and ensure the report moves through all six stages. Also known as the \"investigator.\"\n* **Accused individual** - The accused individual is the person who is alleged to have violated the Code of Conduct.\n* **Escrowed reports** - An incident report where the reporter has not give permission to proceed with an investigation. If the reporter gives permission to keep the report \"in escrow,\" these escrowed reports will not be acted upon until there is a second report of the same incident or a similar incident involving the same individual. The goal of an escrow report is to retain a record of incidents in case there is a pattern of misbehavior by the same individual.\n\n## Handling incident reports\n\nAn incident report begins when a member of {Project name} contacts a community moderator to report an incident.\nThe moderator who is contacted should handle the incident report and should try to respond as soon as possible.\nThis moderator will become the investigating moderator.\n\nThe investigating moderator may involve another community moderator as an additional investigator or as a replacement investigator under these conditions:\n\n* If the moderator who was contacted by the incident reporter does not feel comfortable investigating and handling the incident alone.\n* If the moderator cannot handle the incident in a timely manner and must ask a different moderator to investigate the incident report.\n* If the moderator needs to be recused because of a conflict of interest.\n\nIf the moderator who was contacted by an incident reporter intends to involve an additional community moderator for support or as a replacement, they should first inform the incident reporter, explain the circumstances, and offer the opportunity to withdraw their incident report if they are uncomfortable having another moderator involved.\n\nTo promote impartiality, if the incident reporter is a community moderator themselves, then a different community moderator must handle the report.\nSee [#preventing-conflicts-of-interest](Preventing conflicts of interest) for more information.\n\n### Overview\n\nAll incident reports have six stages:\n\n1. Listen\n2. Triage\n3. Recommend\n4. Respond\n5. Follow up\n6. Resolve\n\nSee the following sections for more information about what occurs in each phase.\n\n### Listen\n\nDuring the listening phase, the investigating moderator will:\n\n* Listen to the incident reporter's explanation of the Code of Conduct violation.\n* Explain the available outcomes.\n* Obtain permission to proceed to the next steps in the investigation.\n* Fill out the {link to your [Code of Conduct incident record](code-of-conduct-incident-record.md)}. NOTE: This record can be filled out after taking the report if needed.\n\nThroughout the process, the investigating moderator will treat the reporter's identity as confidential and will only disclose their identity to other moderators on a need-to-know basis.\n\nThe investigating moderator should talk directly to the person who reported the incident either through an online video conference or by phone.\n\nDuring this meeting, the investigating moderator should:\n\n* Note the reporter's name and contact information.\n* If possible, note the incident's date, time, and/or location.\n* Listen carefully to the incident reporter and get a complete understanding of the incident, its context, and the parties involved. The moderator should strive to listen with empathy and understanding. They should default to believing the incident responder.\n* Ask what the incident reporter would need in order to feel emotionally whole or restored. Explain the possible outcomes that are available, as provided in the [Code of Conduct](code-of-conduct.md) (correction, warning, temporary ban, permanent ban). However, the moderator should not make any direct promises for exactly how the report will be handled until the investigation is concluded.\n* Obtain permission from the incident reporter to proceed with the investigation. If permission is not granted, the investigator can offer to hold the incident report in escrow. Escrowed reports will not be acted upon until there is a second report of the same incident or a similar incident involving the same individual. The goal of escrow reports is to retain incident reports in case there is a pattern of misbehavior by the same individual.\n\nDuring or immediately after the meeting, the investigating moderator should:\n\n* Fill out the {link to your [Code of Conduct incident record](code-of-conduct-incident-record.md)} to ensure that all information from the meeting has been accurately captured. The investigating moderator should avoid over-documenting the incident: only document information required to inform the report resolution. Where possible, avoid documenting your opinion about the incident, or any information about individuals that is not relevant to the report.\n* File the incident record in the {describe where these files are kept}. If permission was not obtained, the incident report is kept in the incident record archives. If the incident reporter wanted to keep the report in escrow, the incident report is kept in the escrow incident report archives.\n* If permission was obtained, proceed with the rest of the investigation.\n\nIf necessary, the moderator may need to conduct additional interviews with other corroborating witnesses or may have to review any additional recorded evidence of the incident (such as emails, documents, message transcripts, or chat histories).\n\n### Triage\n\nAfter completing the listening phase, the moderator should assign an initial risk and impact level to the incident using their best judgment based on the following guidelines.\n\n#### Severity levels\n\nSeverity refers to the overall seriousness of the behavior and the risk that behavior will be repeated:\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    Severity levelDefinitionExamples
    High\n
      \n
    • The incident is extremely severe and/or there is a high likelihood the behavior will occur again in the future.
    • \n
    • Incidents that are harassing, dangerous, abusive, violent, offensive (especially to marginalized groups), or which threaten the physical and/or psychological safety of one or more community members are designated as high severity.
    • \n
    • Repeated medium- or low-level offenses by the same individual are also automatically designated as high severity.
    • \n
    \n
    \n
      \n
    • Sexual assault or unwanted sexual attention
    • \n
    • Violent threats or language
    • \n
    • Personal attacks
    • \n
    • Derogatory language (especially aimed at marginalized groups)
    • \n
    • Repeated inappropriate comments after a warning
    • \n
    \n
    Medium\n
      \n
    • The incident is moderately severe and is potentially disruptive to the community.
    • \n
    • The incident could possibly cause one or more community members to feel unwelcome or uncomfortable in the community.
    • \n
    \n
    \n
      \n
    • Mildly inappropriate comments or jokes
    • \n
    • Bullying
    • \n
    • Tone-policing
    • \n
    • Repeatedly dominating a conversation (such as repeatedly talking over another person or not inviting discussion from others where appropriate)
    • \n
    • Excessive profanity
    • \n
    • Sustained disruptions of community events
    • \n
    \n
    LowThe incident is minor in nature and doesn't pose serious harm or risk of harm.\n
      \n
    • Heated discussions or disagreements between community members.
    • \n
    \n
    \n\n#### Impact levels\n\nImpact refers to how public the incident was and the number of community members who were or who could have been impacted by the incident, especially members of marginalized communities:\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    Impact levelDefinitionExamples
    High\n
      \n
    • The incident occurred in a public event, in a {Project name} meeting or community event, or on a community forum (such as on a mailing list or in Slack).
    • \n
    • The accused individual is a {Project name} leader or a high-profile community member.
    • \n
    • Incidents involving someone who was representing {Project name} in an official capacity while the incident occurred.
    • \n
    \n
    \n
      \n
    • Comments in the {Project name} Slack or mailing list.
    • \n
    • Comments or actions in a {Project name} meeting.
    • \n
    • Speaking or participating at a conference or fund-raising event as a representative of {Project name}.
    • \n
    \n
    Low\n
      \n
    • The incident occurred in a private conversation, message, or email. Also includes posts or comments made in a forum or context outside of official {Project name} channels, such as on a personal social media account.
    • \n
    \n
    \n
      \n
    • Comments in a private email.
    • \n
    • Comments in a direct message on Slack.
    • \n
    • Comments or actions made in a one-on-one meeting in person or virtually.
    • \n
    \n
    \n\n### Recommend\n\nOnce an initial severity or impact level has been assigned, the investigating moderator should send a private message to the rest of the community moderators through email.\nModerators who have recused themselves over conflicts of interest should not be included in this email.\nIt would also be appropriate to send a separate direct message on Slack to notify the other moderators to check for the email to ensure everyone is aware of the email.\n\nIn the email, indicate your assessment of the incident's severity and impact level and your recommended response.\nSee the [Code of Conduct](code-of-conduct.md) for the four possible responses to a conduct violation (correction, warning, temporary ban, permanent ban).\n\nCommunity moderators have an ethical responsibility to respond as soon as possible and work toward consensus.\nDelaying action in response to the Code of Conduct violation can possibly make the situation worse.\n\nIn their response, moderators should indicate whether they agree with the incident severity and impact levels and the recommended response.\nIf community moderators disagree with the original assessment, the moderators should indicate the nature of their disagreement. Where disagreements occur, the committee should work quickly to reach a consensus (ideally within 1-2 days) and may require a video conference discussion.\nIf a consensus cannot be reached and has ended in a stalemate, the response should be put to a vote.\nIn incidents where a tied vote occurs, the chair of the community moderators acts as the deciding vote.\n\nAfter a response has been recommended, the incident reporter should be notified of the outcome of the investigation and the recommended response before proceeding.\n\n### Respond\n\nOnce the incident response has been determined by the community moderators, the investigating moderator should meet with the accused individual in person (either through an online video conference or by phone).\nThe moderator may invite an additional moderator to attend the meeting if support is desired.\n\nBefore this meeting, the investigating moderator should fill out the [Code of Conduct remediation record](code-of-conduct-remediation-record.md) and use this document to guide the meeting.\n\nIn this meeting, the moderator should explain the nature of the reported incident and the specifics of the incident response (correction, warning, temporary ban, permanent ban).\nThe accused individual will be given a chance to respond (within reason) and will be informed about the process for appealing the incident response.\n\nIf a new Code of Conduct violation occurs in this meeting (such as a derogatory or threatening comment made to a community moderator or about another member of the community), it should be treated as a separate incident and should be reported as a new incident to the community moderators.\n\nIf the individual wishes to appeal the incident response, the community moderator can send them a link to this document for information.\nEnsure that the individual is aware that they must still comply with the incident response plan even if an appeal process is underway.\n\nTo protect the identity of the incident reporter, the accused individual must not be given the identity of the incident reporter nor will they be allowed to contact the incident reporter, even to apologize.\nIf an apology is required as part of the response, the following options are permissible:\n\n* The apology can be delivered to the investigating community moderator who will then deliver it to the incident reporter.\n* The apology may be delivered in a public forum with permission from the investigating community moderator.\n\nDuring or immediately after the meeting, the investigating moderator should fill out the any additional notes on the [Code of Conduct remediation record](code-of-conduct-remediation-record) to ensure that all information from the meeting has been accurately captured.\n\n### Resolve\n\nThe investigating moderator should implement the consequence(s) of the incident response, depending on what the response was.\nThe moderator should also follow up with the incident reporter to let them know what the outcome of the report was.\n\nIf a temporary ban was implemented, the community moderator who handled the incident should meet with the accused individual to ensure compliance before readmittance into the community.\n\nAll documentation should be stored in the {describe where your documents are kept}:\n\n* The [Code of Conduct incident record](code-of-conduct-incident-record.md) form.\n* The [Code of Conduct remediation record](code-of-conduct-remediation-record.md) form.\n\nIt is important to file this documentation to enable the community moderators to identify and prevent potential repeated patterns of abuse.\n\n## Handling incident appeals\n\nIf an accused individual wants to dispute the decision of the community moderators, that individual is entitled to one appeal.\nAn appeal can be requested directly from the community moderators using the same process of reporting an incident.\nThat means that the accused individual can send a direct message to one of the community moderators to request an appeal.\nWhile the appeal process is underway, the accused individual must still comply with the incident response plan.\n\nWhen an appeal is requested, 2-3 members of {Project name} {team name} will review the incident documentation and the reason for the appeal.\nThey will consult with the community moderators about the investigation and decision-making process to determine if the Code of Conduct was fairly and properly applied.\nThey will then recommend to uphold, modify, or reverse the original incident response.\nDecisions made by the {Project name} community moderators are final.\n\n## Preventing conflicts of interest\n\nA moderator is considered to have a conflict of interest when any of the following conditions are met:\n\n* The moderator is the individual accused of a Code of Conduct violation.\n* The moderator has a close working or personal relationship with the individual accused of a Code of Conduct violation that could impede their ability to be impartial.\n* The moderator was personally involved in the Code of Conduct violation in some way (such as being the direct target of a Code of Conduct violation). Merely witnessing or being present during the incident does not necessarily qualify as a conflict of interest. Merely being part of a protected group that was targeted in a derogatory statement or action does not necessarily qualify as a conflict of interest.\n\nModerators that meet any of these conditions should recuse themselves from all discussions and decisions about the incident where they have a conflict of interest.\nAnother member of the community moderation team should act as the investigating moderator.\nThe moderator with a conflict of interest should ensure that another moderator is designated to handle the incident.\n\nIf the accused individual is a leader or prominent member of the {Project name} community, avoidance of a conflict of interest may not be possible as all moderators could possibly have a personal working relationship with the accused individual.\nIn this situation, recusal is not necessary and moderators should instead make their best effort to remain impartial.\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Code%20of%20conduct%20response%20plan) to give feedback on this template.\n", + "sha256": "8af4d0c4a833c1e3515b87154fd8ecf663b00c08a6ea1753a3a15a2e0609003d", + "resource": "tgdpTemplates/templates-main.zip!/code-of-conduct-response-plan/template_code-of-conduct-response-plan.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/code-of-conduct-response-plan/template_code-of-conduct-response-plan.md", + "description": "Used as part of the Code of Conduct process, the response plan explains the policy your team will follow as you handle Code of Conduct incidents." + }, + { + "id": "concept/template_concept.md", + "name": "Concept", + "category": "tgdp", + "extension": "md", + "source": "# Concept template\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_concept.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\nA summary paragraph introducing a concept, explaining its importance or\nrelevance, and providing an overview of the content that will be covered\nin the document (scope).\n\nTypical wording to use is:\n\n* This article explains the basics of {concept} and how it works in {the tool or context}.\n\n{Then include a paragraph with a definition of the concept you are explaining.\nIf more definitions are needed, include those definitions here as a bulleted list.}\n\n{This section usually doesn't have a separate heading that explicitly says\nDefinition; it can precede all the rest.}\n\nTypical wordings to use are:\n\n* {X} is;\n\n* {X} represents\n\n* {X} is connected to\n\n* {X} are organized {describe the way how}\n\n* {X} is similar to\n\n* {X} addresses the common pain points of ...\n\n* {X} solves the challenge of ...\n\n* By implementing {X}, users can ...\n\n* By using {X}, {specify users/target audience} gain ...\n\n* To use {X}, you create {Y}\n\n{Add visual aid to complement your explanations visually.}\n\n{Image goes here.}\n\n(Optional) Image/Figure: {Image title, which concisely explains the image or\nfigure. It can be represented by annotations on the image.}\n\n## (Optional) Background\n\n{Use this section to provide a reader with a context, prehistory, or background information.}\n\nTypical wordings to use are:\n\n* The reason {X} is designed that way is because historically, ...\n\n* The idea of {X} originated from the growing demand for ...\n\n* Recent advancements in {X} and {Y} have opened up new possibilities\n for ...\n\n* With the rise of {X}, the need for things such as {Y} has become\n paramount.\n\n* Over the past decade, {advancements in technology} have transformed\n the way ...\n\n## Use cases\n\n{Use this section to provide use cases and explain how a reader can\nbenefit from a concept.}\n\n## (Optional) Comparison of {thing being compared}\n\n{Use this section to compare options or alternatives.}\n\nTable: {Table title which concisely explains the comparison.}\n\n## (Optional) Related resources\n\n{Use this section to provide links to documentation related to the concept that the user can read for more information.\nIf you can name this section manually (it is not generated automatically or has a heading pre-agreed within a team),\nwe recommend to use \"Related concepts\" or \"Additional information\" as more descriptive ones.}\n\nIf you would like to dive deeper or start implementing {concept},\ncheck out the following resources:\n\nHow-to guides\n\n1. Item 1\n\n2. Item 2\n\nLinked concepts\n\n1. Concept 1\n\n2. Concept 2\n\nExternal resources\n\n1. Resource 1\n\n2. Resource 2\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Concept) to give feedback on this template.\n", + "sha256": "6e1ffacc7c8711650ad79c37c0f306517c96d7a051e80786b688207094cf51ed", + "resource": "tgdpTemplates/templates-main.zip!/concept/template_concept.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/concept/template_concept.md", + "description": "An explanation of a concept, context, or background information about a product or its features." + }, + { + "id": "contact-support/template_contact-support.md", + "name": "Contact support", + "category": "tgdp", + "extension": "md", + "source": "{If you need more information about how to fill in this template, read the accompanying [Contact Support guide](../contact-support/guide_contact-support.md).}\n\n{This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.}\n\n# Contact {company name or product name} support\n\n{Write a hook to capture the attention of users and set a welcoming, friendly tone. For example, \"Hi! How can we help you?\".}\n\n## Our products\n\n{Product icon}\n\n[{Name and link to product page}]({Hyperlink to product's documentation or landing page here})\n\n{Tip: If your company/organization has more than one product, consider placing the products and their hyperlinked information in a row.}\n\n## Support plans\n\n{This section is optional. If support is only provided based on certain subscription levels or there are different tiers of support, indicate which subscription levels or plans come with support, possibly in table form.}\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n
    Free plan or basic license levelPro plan or next license level
    Free{Cost per month/annually}
    \n
      \n
    • {First feature}
    • \n
    • {Second feature}
    • \n
    • {Third feature}
    • \n
    \n
    \n
      \n
    • {First feature}
    • \n
    • {Second feature}
    • \n
    • {Third feature}
    • \n
    \n
    \n\n## Contact details\n\n{Delete any of the following sections that do not apply to your organization.}\n\n### Phone number\n\nOur agents are available by phone at:\n\n+x-xxx-xxx-xxxx\n\n{Replace placeholder text with your phone support phone number}\n\nAgents are available at {list available times and days that agents are available. For example: Mon-Fri x am-x pm time zone}\n\n{Tip: Hyperlink the support phone number for mobile users.}\n\n### Text message\n\nText the {company name} support team at:\n\n+x-xxx-xxx-xxxx\n\n{Replace placeholder text with your text support phone number}\n\n{Tip: Hyperlink the support phone number for mobile users.}\n\n### Email\n\nEmail us at:\n\n{[your-support-email@your-company-name.com](mailto:your-support-email@your-company-name.com)}\n\n{Tip: Hyperlink the email.}\n\nYou can expect a response by {list maximum response times here. For example, within 24 hours}.\n\n### Live chat\n\nNeed a quick response? Chat with a support agent:\n\n[{Live chat}]({link to chat url})\n\n### Social media\n\nConnect with us on:\n\n{X (Twitter)} at [{@companysocialprofilename}]({link to social media url})\n\n{LinkedIn} at {https://www.linkedin.com/in/your-company-name/}\n\n{Add additional social media accounts as needed.}\n\n### More support\n\nNeed more support? Check out these links below:\n\n* {hyperlink to self-help options, such as community forums, Slack or Discord, or a knowledge base}\n\n{Note that some companies might choose to put this section at the top of their contact support page in order to encourage users to solve problems on their own before contacting an agent.}\n\n{Explore other templates from [The Good Docs Project](https://gitlab.com/tgdp/templates). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Contact%20support) to give feedback on this template.}\n", + "sha256": "bd57faee70834ccc752c40f3269a2ee4f1dd6da33fb938239241c980bc856029", + "resource": "tgdpTemplates/templates-main.zip!/contact-support/template_contact-support.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/contact-support/template_contact-support.md", + "description": "A contact support page typically includes a list of the communication channels, discussion forums, and links to other resources to assist users with issues that they are having with your product." + }, + { + "id": "contributing-guide/template_contributing-guide.md", + "name": "Contributing guide", + "category": "tgdp", + "extension": "md", + "source": "# {Project Name} Contributing Guide\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_contributing-guide.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Welcome\n\nWelcome to the {Project Name} Contributing Guide, and thank you for your interest.\n\nIf you would like to contribute to a specific part of the project, check out the following list of contributions that we accept and their corresponding sections that are within this guide:\n\n* {Contribution type 1}\n * {Section 1}\n * {Section 2}\n * {Section 3}\n* {Contribution type 2}\n * {Section 1}\n * {Section 2}\n * {Section 3}\n\nHowever, at this time, we do not accept the following contributions:\n\n* {Contribution type 1}\n* {Contribution type 2}\n* {Contribution type 3}\n\n## {Project Name} overview\n\nThe purpose of the {Project Name} is to {describe your project's objectives or provide an external link to an introductory document, such as the project's README file}.\n\n## Ground rules\n\nBefore contributing, read our {link to the Code of Conduct} to learn more about our community guidelines and expectations.\n\n## Community engagement\n\nRefer to the following channels to connect with fellow contributors or to stay up-to-date with news about the {Project Name}:\n\n* Join our project contributors on {name and link to online chat}.\n* Participate in our project meetings on {specify the day of the week and cadence} at {specify time and timezone}, where you can provide a status update or raise questions and concerns about your contributions. Use the following link to join: {link to online meeting space}\n* Stay updated on the latest news and changes to the project by signing up to receive our newsletter. Sign up with the following link: {link to the sign-up page for the newsletter}\n\n## Share ideas\n\nTo share your new ideas for the project, perform the following actions:\n\n1. {Step 1}\n2. {Step 2}\n3. {Step 3}\n\n## Before you start\n\nBefore you start contributing, ensure you have the following:\n\n* {Item 1}\n* {Item 2}\n* {Item 3}\n\n## Environment setup\n\nTo set up your environment, perform the following actions:\n\n* {Step 1}\n* {Step 2}\n* {Step 3}\n\n### Troubleshoot\n\nIf you encounter issues as you set up your environment, refer to the following:\n\n* Windows: {share a link to an external page that shares troubleshooting steps or share the procedure as sub-bullets}\n* macOS: {share a link to an external page that shares troubleshooting steps or share the procedure as sub-bullets}\n* Linux: {share a link to an external page that shares troubleshooting steps or share the procedure as sub-bullets}\n\n## Best practices\n\n{Option 1} Our project has adopted the following best practices for contributing:\n\n* {Item 1}\n* {Item 2}\n* {Item 3}\n\n{Option 2} Our project uses the {name and link to resource for best practices, such as a coding style guide or writing style guide} as our parent guide for best practices. Reference the guide to familiarize yourself with the best practices we want contributors to follow.\n\n## Content style guide\n\nRead our {name and link to your style guide} to understand our guidelines for writing and formatting documents. The purpose of our style guide is to ensure consistency in the tone, voice, and structure of our documentation.\n\n## Contribution workflow\n\n### Fork and clone repositories\n\n{Provide instructions on how to fork and clone your repository. Alternatively, provide a link to a Git guide that explains the process.}\n\n### Report issues and bugs\n\n{Provide instructions on how to report problems.}\n\n### Issue management\n\n{Provide instructions on how to create, tag, and assign issues.}\n\n### Commit messages\n\n{Provide instructions on how to format commit messages.}\n\n### Branch creation\n\n{Provide instructions on how to create and/or name a branch.}\n\n### Pull requests\n\n{Provide instructions on how to submit a pull request. Share a link to an example pull request or include the pull request template you want contributors to use within this section.}\n\n### Releases\n\n{Provide a description of the release process and cadence for the project, such as the source code.}\n\n### Text formats\n\n{Provide information on what you need contributors to know and use to edit and create documents.}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Contributing%20guide) to give feedback on this template.\n", + "sha256": "957a27013f70ed250914fed119597dd3cc2441f062da9df2d77009ccf4ee0af9", + "resource": "tgdpTemplates/templates-main.zip!/contributing-guide/template_contributing-guide.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/contributing-guide/template_contributing-guide.md", + "description": "A CONTRIBUTING document tells users how they can contribute to your open source project and join the community." + }, + { + "id": "glossary/template_glossary.md", + "name": "Glossary", + "category": "tgdp", + "extension": "md", + "source": "# Glossary template\n\n{If you need more information about how to fill in this template, read the accompanying [Glossary template guide](https://gitlab.com/tgdp/templates/-/blob/main/glossary/guide_glossary.md).}\n\n{This template includes boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.}\n\n| Term | {Optional} Abbreviation | Definition | {Optional} Source |\n| --- | --- | --- | --- |\n| {Term} | {Abbreviation} | {Definition} | {Source} |\n\n{Explore other templates from [The Good Docs Project](https://gitlab.com/tgdp/templates). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Glossary) to give feedback on this template.}\n", + "sha256": "9d6c72af5007b097c461c6bc10f66be424c56ba6268733801c1f761a7ddd6596", + "resource": "tgdpTemplates/templates-main.zip!/glossary/template_glossary.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/glossary/template_glossary.md", + "description": "A reference document that lists and organizes terms and their definitions that are unique to your organization or which you use in a specific way." + }, + { + "id": "how-to/template_how-to.md", + "name": "How-to", + "category": "tgdp", + "extension": "md", + "source": "# Title\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_how-to.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Overview\n\nThis guide explains how to {insert a brief description of the task}.\n\n{Optional: Specify when and why your user might want to perform the task.}\n\n## Before you start\n\n{This section is optional}\n\nBefore you {insert a brief description of the task}, ensure:\n\n* Prerequisite 1\n* Prerequisite 2\n* Prerequisite 3\n\n## {Task name }\n\n{Optional: Provide a concise description of the purpose of this task. Only include this if the purpose is not clear from the task title.}\n\n{You can use this format to describe your steps:}\n\n1. {Write the step here. Use a verb to start.}\n\n {Optional: Explanatory text}\n\n {Optional: Code sample or screenshot that helps your users complete this step.}\n\n {Optional: The result of completing this step.}\n\n2. {Write the step here. Use a verb to start.}\n\n 2.1. {Substep 1}\n\n 2.2. {Substep 2}\n\n### {Sub-task}\n\n...\n\n{This section is optional. Include a sub-task only if the task is big and complex.}\n\n## See also\n\n{Include references and/or links to other related documentation such as other how-to guides, conceptual topics, troubleshooting information, and limitation details if any.\n\n* Reference link\n* Concept link\n* Troubleshooting link}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=How-to) to give feedback on this template.\n", + "sha256": "ec2e4735cd6eb1be8cad5054fa3cb22fd4bd572968706fd0a23bb4558bde5c04", + "resource": "tgdpTemplates/templates-main.zip!/how-to/template_how-to.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/template_how-to.md", + "description": "A how-to is a concise set of numbered steps to do one task with the product." + }, + { + "id": "installation-guide/template_installation-guide.md", + "name": "Installation guide", + "category": "tgdp", + "extension": "md", + "source": "# Title\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_installation-guide.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Introduction\n\n{The installation guide template includes information on writing procedures for successfully installing {product name}. {Insert your preferred description of the installation process.}\n\n{Optional: Add a demo GIF that shows what the installed project would look like or link to a sandbox or playground to test out the product and explore its features.}\n\n## Installation types\n\nThis guide explains the steps and instructions required to install {product name} on supported operating systems. It also explains how to configure, start, and uninstall {product name}}.\n\n{Include a table to capture the different installation types, such as:\n\n* Supported operating systems\n* The type of product installed (if provided in different versions, such as Main and Lite version)\n* Cloud providers, such as CodeSpaces, CodeSandBox, GitPod, etc.,) including a link to the right heading for all options.}\n\n| **Type** | **Description** | **More information** |\n| --------- | ----------------- | ------------------- |\n| {Name of installation type} | {Description of installation type} | {Link to the relevant installation steps section} |\n| {Name of installation type} | {Description of installation type} | {Link to the relevant installation steps section} |\n\n## Overview\n\n{This section is optional.}\n\n{Add a list of the available project versions, link to the installation guide for that version, and highlight the latest, beta, or stable version as listed in the following table:}\n\n| **Version** | **Build** | **Release Date** | **Status** |\n| -------- | --------- | -------- | ------- |\n| [V {versionNumber}](#link) | {versionNumber release} | {dd/mm/yyyy} | {Latest} |\n| [V {versionNumber}](#link) | {versionNumber release} | {dd/mm/yyyy} | {Beta} |\n| [V {versionNumber}](#link) | {versionNumber release} | {dd/mm/yyyy} | {Stable} |\n\n{Explain the intended result of the installation, such as the commands, command aliases, major flags, available plugins, files downloaded, or application programs.}\n\n{Add a sequential end-to-end summary of the installation process that can serve as a quick link or reference section for users as listed in the following table:}\n\n| | **Process** | **More information** |\n| ---- | ------------ | -------------------- |\n| 1. | Before installing, check the system requirements to ensure your computer is supported in the latest version of {product name}. | {Link to relevant documents} |\n| 2. | Check the system prerequisites to install all the required {software, dependencies, tools.}. | {Link to relevant documents} |\n| 3. | {List additional steps.} | {Link to relevant documents} |\n| 4. | Verify that the installation was successful. | {Link to relevant documents} |\n\n## System requirements\n\n{Start by breaking this into sub-sections based on the number of installation types (product type, operating system, or cloud-especially for projects that require self-hosting to work). Based on your use case, you can use this section reversely by having the installation type as the heading and system requirements for that type as a sub-section.}\n\n{Mention for all installation types.}\n\n## Before you begin\n\n{List and highlight all the required prerequisites here. Consider making this a table.}\n\nBefore installing {version number}, ensure you have:\n\n* {Prerequisite one}\n* {Prerequisite two}\n* {Prerequisite three}\n\n{Include prerequisites for all installation types.}\n\n| **Type** | **Prerequisites** | **Note(s)** |\n| --------- | ----------------- | ------------ |\n| {Installation type name} | {Installation type prerequisites} | {Important considerations} |\n| {Installation type name} | {Installation type prerequisites} | {Important considerations} |\n\n## Installation steps\n\nThe following procedure explains how to install {installation type name} and {optional version number}.\n\n{Provide a short introduction to the step-by-step procedure based on the installation type.}\n\nGet started with {version number} by {write the first step a user needs to start the installation. Use a verb to start.}\n\n### Step 1 - One-sentence description of the step\n\n{Optionally, introduce this section with brief explanatory text.}\n\n{Continue with a list section if these steps include a sequence of instructions}\n\n{Optional: include a code snippet or relevant screenshot that helps your users complete the steps.}\n\n{Optional: show the result of completing this step, such as a text output or an image).}\n\n#### 1.1. Substep 1 - One-sentence description of the step\n\n{Optionally, introduce this section with brief explanatory text.}\n\n{Continue with a list section if these steps include a sequence of instructions}\n\n#### 1.2. Substep 2 - One-sentence description of the step\n\n{Optionally, introduce this section with brief explanatory text.}\n\n{Continue with a list section if these steps include a sequence of instructions.}\n\n### Step 2 - One-sentence description of the step\n\n{Optionally, introduce this section with brief explanatory text.}\n\n{Continue with a list section if these steps include a sequence of instructions.}\n\n## Verify installation\n\n{Include test commands, intended outputs, or other steps to confirm the installation was successful.}\n\n## Post installation\n\n{Provide an overview of options or link to other relevant documentation once installation has been completed. Also, account for anticipated problems during or after installation.}\n\n{Optinal: include short introduction text.}\n\n### Configuration options\n\n{Provide information regarding post-installation configuration options.}\n\n{Describe the requirements for configuring the installed product.}\n\n{Link to relevant documentation if needed.}\n\n### Upgrade options\n\n{Provide information regarding upgrade options, also known as an update options.}\n\n{Describe how to install updates from a range of possible options.}\n\n{Provide a link to available updates with specific version numbers, release dates, and key features.}\n\nExample: To begin the system updates:\n\n1. Choose the version number.\n2. Download the update at [link](http://example.com).\n3. Double-click the update file.\n4. Additional steps as needed.\n\n### Downgrade options\n\n{Provide information regarding downgrading the version installed.}\n\n### Uninstallation options\n\n{Provide information regarding uninstalling the product, software, SDK, package, library, framework installed.}\n\n## Troubleshooting\n\n{This section helps solve problems encountered during installation. Start with a problem statement, then indicate the cause(s) and provide a solution. Additional information can be added (e.g., restart the computer)}.\n\n{Add a warning note and highlight in color if the action has the potential to affect security.\n\nCommunicating with the product engineers and programmers is essential to keep this section up-to-date.}\n\n### {Problem title: ...}\n\n{Optional: More details about the **problem**.}\n\n{Optional: More details about the **cause(s)** of the problem.}\n\n**Solution**: {Solution to the problem...}\n\n**Contact**: {Support/contact information for issue reports and feedback...}\n\n### {Problem title: ...}\n\n...\n\n## Next steps\n\n{Include what to do after a successful installation, such as a recommended next step or links to further recommended documentation.}\n\n## Product version history\n\n{History section with major changes to the installation guide tabulated following the Major.Minor.Patch semantic versioning specification.}\n\n## Definition of terms\n\n{Optional: Provide a glossary table describing the terms, acronyms, and abbreviations used in the installation guide.}\n\n| **Term** | **Meaning** |\n| ------------------------------------- | ------------ |\n| {Term, acronym, or abbreviation} | {Provide a definition of the term or acronym or abbreviation used in this guide.} |\n| {Term, acronym, or abbreviation} | {Provide a definition of the term or acronym or abbreviation used in this guide.} |\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Installation%20guide) to give feedback on this template.\n", + "sha256": "58eb89e453af1f069385c0bf243b6958884ec298949dc6e6f54c7ab74a03c105", + "resource": "tgdpTemplates/templates-main.zip!/installation-guide/template_installation-guide.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/installation-guide/template_installation-guide.md", + "description": "An installation guide explains all the necessary steps to install the product and set it up for further use." + }, + { + "id": "our-team/template_our-team.md", + "name": "Our team", + "category": "tgdp", + "extension": "md", + "source": "# The {Project name} team\r\n\r\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_our-team.md).\r\n>\r\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\r\n\r\nThis page lists the people who currently contribute in key roles in {Project name} and what their responsibilities are.\r\n\r\n{Project name} is powered by a great community and a team of dedicated folks who keep our project moving forward.\r\nWe are always looking for more people to help with:\r\n\r\n* {Task}\r\n* {Task}\r\n* {Task}\r\n\r\nIf you're interested in helping out, get in touch with someone from the team you're interested in.\r\n{Add a sentence or two about the best way people can contact the team and get involved.}\r\n\r\n## Table of contents\r\n\r\n* [Community moderators](#community-moderators)\r\n* [Name of first team or role](#name-of-first-team-or-role)\r\n* [Name of second team or role](#name-of-second-team-or-role)\r\n* [Thank you to past team members](#thank-you-to-past-team-members)\r\n\r\n## Community moderators\r\n\r\n{This section is optional, but recommended.\r\nOnly include this section if your project actually has individuals in this role.}\r\n\r\nThis team ensures our community is healthy, vibrant, and safe for all who want to participate.\r\n\r\nThis team:\r\n\r\n* Moderates our community forums.\r\n* Ensures community discussions are on topic and friendly.\r\n* Serves as a point of contact for any issues or questions.\r\n* Handles {insert link to your [Code of Conduct](url)} incidents.\r\n\r\nThe current community moderators are:\r\n\r\n* {Name}, {link to Slack handle or other contact info}\r\n* {Name}, {link to Slack handle or other contact info}\r\n* {Name}, {link to Slack handle or other contact info}\r\n\r\n{Writing tip: Consider listing people alphabetically by first name.}\r\n\r\nFor more information:\r\n\r\n* {Link to your Code of Conduct}\r\n* {link to your Code of Conduct response guide}\r\n\r\n## {Name of first team or role}\r\n\r\nThis team {write a sentence or two describing what the team is primarily responsible for}.\r\n\r\nThis team:\r\n\r\n* {Does this task}.\r\n* {Does this task}.\r\n* {Does this task}.\r\n\r\nThe current {name of team or role} members are:\r\n\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n\r\nFor more information:\r\n\r\n* {Link to document describing how to contact this team}\r\n* {link to document describing how to join this team}\r\n\r\n## {Name of second team or role}\r\n\r\nThis team {write a sentence or two describing what the team is primarily responsible for}.\r\n\r\nThis team:\r\n\r\n* {Does this task}.\r\n* {Does this task}.\r\n* {Does this task}.\r\n\r\nThe current {name of team or role} members are:\r\n\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n* {Role, optional}: {Name}, {link to Slack handle or other contact info}\r\n\r\nFor more information:\r\n\r\n* {Link to document describing how to contact this team}\r\n* {link to document describing how to join this team}\r\n\r\n## Thank you to past team members\r\n\r\n{This section is optional.}\r\n\r\nWe are thankful for the hard work and dedication of these past team members:\r\n\r\n* {Name}, {Role}\r\n* {Name}, {Role}\r\n* {Name}, {Role}\r\n\r\n---\r\n\r\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Our%20team) to give feedback on this template.\r\n", + "sha256": "e08f8f28bf68ad76d300d7cdc752143d94d5fff4639664e4137a62784857283a", + "resource": "tgdpTemplates/templates-main.zip!/our-team/template_our-team.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/our-team/template_our-team.md", + "description": "Our team documents help you clearly communicate who belongs to your open source project or organization and how contributors can contact or work with them." + }, + { + "id": "quickstart/template_quickstart.md", + "name": "Quickstart", + "category": "tgdp", + "extension": "md", + "source": "# Title\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_quickstart.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Overview\n\nThis quickstart guides you through:\n\n* [Part 1](#part-1-task-name)\n* [Part 2](#part-2-task-name)\n* [Part n](#part-n-task-name)\n\n:information_source: (Optional) Link each part to its corresponding section for easy access.\n\nIt is intended for {audience}. It assumes that you have basic knowledge of:\n\n* Concept 1\n* Concept 2\n* Concept 3\n\n:information_source: Link each concept to its corresponding content that helps users understand the concepts and ideas behind your software.\n\n## Before you start\n\n:information_source: This section is optional.\n\nBefore running this quickstart, complete the following prerequisites:\n\n* Prerequisite 1\n* Prerequisite 2\n* Prerequisite 3\n\n## Install\n\n:information_source: This section is optional. Use this section to provide installation instructions.\n\n## Part 1: {Task name}\n\n:information_source: Use this section to summarize what users complete in the following steps.\n\n### Step 1: {Step name}\n\n:information_source: You can use this format to describe your step:\n\nExplanatory text\n\n(Optional) Code sample or screenshot that helps your users complete this step.\n\n(Optional) Result on completing this step.\n\n#### (Optional) Sub step 1: {Substep name}\n\n:information_source: Beware of asking your users to do too much in one step. If needed, break a step into one or two smaller substeps.\n\n## Part 2: {Task name}\n\n...\n\n### Step 2: {Step name}\n\n...\n\n#### (Optional) Sub step 2: {Subtask name}\n\n...\n\n## Part n: {Task name}\n\n...\n\n### Step n: {Step name}\n\n...\n\n#### (Optional) Sub step n: {Subtask name}\n\n...\n\n## Next steps\n\nNow that you've completed this quickstart, try these to learn more about {feature}.\n\n* Reference Link 1\n* Reference Link 2\n* Reference Link n\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Quickstart) to give feedback on this template.\n", + "sha256": "60f43839a4fe50f1ee49f3f993d198577fabf2c1762de7d59f5adb6545d1c7c6", + "resource": "tgdpTemplates/templates-main.zip!/quickstart/template_quickstart.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/quickstart/template_quickstart.md", + "description": "A quickstart introduces your users to your application for the first time. It focuses on the primary feature of the application and helps your users to start using the application as quickly as possible." + }, + { + "id": "readme/template_readme.md", + "name": "README", + "category": "tgdp", + "extension": "md", + "source": "# README template\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_readme.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## {Project} logo and badges\n\n{This section is optional. Embed an image of the project logo and include links to relevant badges in the README.}\n\n## {Project Name}\n\n{Include the project URL and project owner name underneath the project name if applicable.}\n\n## Table of contents\n\n{This section is optional.}\n\n1. Heading 1\n\n2. Heading 2\n\n3. Heading 3...\n\n## Project description\n\n{The README template guide includes information on how to write a project description and a project description. Here are some examples of effective phrases for describing a project.}\n\nWith _{Project Name}_ you can _{verb}_ _{noun}_...\n\n_{Project Name}_ helps you _{verb}_ _{noun}_...\n\nUnlike _{alternative}_, _{Project Name}_ _{verb}_ _{noun}_...\n\n{Include screenshots and/or demo videos if applicable}\n\n## Who this project is for\n\nThis project is intended for {target user} who wants to {user objective}.\n\n## Project dependencies\n\nBefore using {Project Name}, ensure you have:\n\n* Prerequisite 1\n* Prerequisite 2\n* Prerequisite 3...\n\n## Instructions for using {Project Name}\n\nGet started with {Project Name} by {write the first step a user needs to start using the project. Use a verb to start.}.\n\n### Install {Project Name}\n\n1. {Write the step here.}\n\n {Explanatory text here}\n\n {Optional: Include a code sample or screenshot that helps your users complete this step}\n\n2. {Write the step here.}\n\n a. {Substep 1}\n\n b. {Substep 2}\n\n### Configure {Project Name}\n\n1. {Write the step here.}\n2. {Write the step here.}\n\n### Run {Project Name}\n\n1. {Write the step here.}\n2. {Write the step here.}\n\n### Troubleshoot {Project Name}\n\n1. {Write the step here.}\n2. {Write the step here.}\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    \n Issue\n \n Solution\n
    \n {Describe the issue here}\n \n {Write solution here}\n
    \n {Describe the issue here}\n \n {Write solution here}\n
    \n {Describe the issue here}\n \n {Write solution here}\n
    \n\nOther troubleshooting supports:\n\n* {Link to FAQs}\n* {Link to runbooks}\n* {Link to other relevant support information}\n\n## Contributing guidelines\n\n{Include a link to your contributing guide here. If you do not have a contributing guide, incorporate the information in the README.}\n\n## Additional documentation\n\n{Include links and brief descriptions to additional documentation. Examples provided in README template guide.}\n\nFor more information:\n\n* Reference link 1\n* Reference link 2\n* Reference link 3...\n\n## How to get help\n\n{Include links and brief descriptions for support resources. Examples provided in README template guide.}\n\n* Reference link 1\n* Reference link 2\n* Reference link 3...\n\n## Terms of use\n\n{Project Name} is licensed under {link to license file}.\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Readme) to give feedback on this template.\n", + "sha256": "cc3df24f80155a8cfea347f2e4ccfaf7d29146c9f2778a3eb1f44edf6e9cc438", + "resource": "tgdpTemplates/templates-main.zip!/readme/template_readme.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/readme/template_readme.md", + "description": "REAMDE files include information users need to know about your project, including how users can engage with the project and get started with the tool." + }, + { + "id": "reference/template_reference.md", + "name": "Reference", + "category": "tgdp", + "extension": "md", + "source": "# {reference article title}\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_reference.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Overview\n\n{Document author tip:\nSummarize what this reference article is about. Explain what all the entries defined on the page have in common. What you put here is reused in the Overview section of your doc site and included in HTML description tags. For help with writing and structuring a reference article, see the file about-reference.md in this directory. Check out https://www.markdownguide.org/basic-syntax/ if you need help with markdown syntax.\n}\n\n{Document author tip: It can be helpful to split up your reference page into subsets of related enries. For example, the reference page for an API endpoint might include subsets of entries for \"General Requirements\", \"Request Parameters\", and \"Responses\". The formats of entries might be different for each subset; for example, \"General Requirements\" might be a bulleted list, while \"Request Parameters\" and \"Responses\" are tables.\n}\n\n## {subset of reference entries}\n\n{table or other structured presentation of entries}\n\n{Document author tip: Here is a starter table for a subset of entries for parameters.}\n\n## Parameters\n\n|Name |Type |Required |Description |\n|:--- |:--- |:--- |:--- |\n|productCode|`string`|Yes|Code of the document product to return the schema for.
    • Here is a bulleted list with a \\| (pipe) inside a table.
    • Another bulleted list.
      • An indented list
    • Back to the list.
    |\n|||||\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Reference) to give feedback on this template.\n", + "sha256": "2149c5a71455189d530d535fd0aec609a6ec821941b5e8ceba329750f733e90d", + "resource": "tgdpTemplates/templates-main.zip!/reference/template_reference.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/reference/template_reference.md", + "description": "A reference document provides specific, in-depth details about a particular topic." + }, + { + "id": "release-notes/template_release-notes.md", + "name": "Release notes", + "category": "tgdp", + "extension": "md", + "source": "# Release notes - {Product name} {Release number - 0.0.0}\n\n> Thank you for downloading this template from The Good Docs Project\\! Before using the template, read this document to see high quality examples of the template in action and to review the resources that were consulted when this template was created. Want to explore more templates? Check them out in our [templates](https://gitlab.com/tgdp/templates) GitLab repository.\n\n{If you need more information about how to fill in this template, read the accompanying [Release notes process](process_release-notes.md) document}\n\n{This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.}\n\n{Optional: Release notes version \\- 0}\n\n{Release date \\- YYYY-MM-DD}\n\n{Optional: High-level summary}\n\n## New features\n\n* **{Feature name}**\n\n {Feature description}\n\n**Example:**\n\n* **Feature name**: We've introduced a new {feature} that allows users to {benefit or function}. This will help you {specific use case}.\n* **Feature name**: With this release, you can now {describe new functionality}, giving you greater control over {specific area}.\n\n## New features requiring configuration updates\n\n* **{Feature name}**\n\n {Feature description}\n\n**Example:**\n\n* **Feature name**: We've introduced a new {feature} that requires activating a configuration update. This feature allows users to {benefit or function}, improving {specific use case}.\n* **Feature name**: With this release, you can now {describe new functionality}, but it requires updating your configuration settings. This gives you greater control over {specific area}.\n\n## Improvements\n\n* **{Improvement title}**\n\n {Improvement description}\n\n**Example:**\n\n* **Enhanced functionality**: We've improved the speed of {specific feature}, reducing the time it takes to complete {task} by {X%}.\n* **Improved usability**: The {feature} has been redesigned for easier navigation, allowing you to {do something specific} with fewer clicks.\n\n## API/Web service updates\n\n* **{API/Web service update}**\n\n {Update description}\n\n## Bug fixes\n\n* **{Bug fix title}**\n\n {Bug fix description}\n\n**Example:**\n\n* **Fixed issue where {problem}**: Resolved an issue where {specific behavior} caused {impact}. Users will now experience {improved behavior}.\n* **Addressed crash during {specific task}**: A crash that occurred when {scenario} has been fixed, ensuring smoother operation.\n\n### Known issues\n\n* **{Known issue title}**\n\n {Known issue description}\n\n**Example:**\n\n* **Issue name**: We are aware of an issue with {feature}, which may cause {specific issue}. Our team is actively working on a resolution, and we expect to include a fix in the next release.\n\n## Optional: Deprecation notice\n\n* **{Deprecated feature name}**\n\n {Deprecated feature description}\n\n### Examples\n\n* **Feature name:** The {feature name} functionality has been deprecated as of this release. This feature will no longer be supported after {specific date}, as we have introduced {alternative feature or explanation of why it's no longer needed}. We recommend transitioning to {alternative solution}.\n* **Outdated functionality:** Support for {specific feature or integration} is deprecated in this release due to {reason, such as redundancy or incompatibility with new systems}. For continued use, please explore {alternative solution or updated approach}.\n* **Retired tool:** The {tool name} is being deprecated with this update. This tool will be phased out by {date}. Please migrate to {replacement tool or workflow}, which offers improved {specific benefit or functionality}.\n* **API change:** The {API endpoint name} is deprecated as of this release. This endpoint will no longer accept requests after {deprecation date}. Users should update their applications to use {new API endpoint or method}, which supports {enhanced functionality or feature}.\n\n{Explore other templates from [The Good Docs Project](https://gitlab.com/tgdp/templates). Use our [feedback form](https://thegooddocsproject.dev/feedback/) to give feedback on this template.}\n", + "sha256": "adcb0619862a056f68953761ecffcfd3041498ec99a4887dfb5d69fa7bfeb09c", + "resource": "tgdpTemplates/templates-main.zip!/release-notes/template_release-notes.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/release-notes/template_release-notes.md", + "description": "Release notes communicate new features, improvements, bug fixes, and known issues about a product to users and stakeholders." + }, + { + "id": "style-guide/template_style-guide.md", + "name": "Style guide", + "category": "tgdp", + "extension": "md", + "source": "\n# {Your Project Name} style guide\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_style-guide.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n{Version number and last updated date}\n\n## Introduction\n\n{Before using this template, please read the accompanying About the Style Guide\nTemplate documentation.}\n\nWelcome to our project! This style guide is intended for use by project\ncontributors, not necessarily end-users. It provides general guidance to anyone\nwho contributes to the project's documentation.\n\n## Intended audience and scope\n\nThis style guide is intended for use by any contributors that are writing\ndocumentation for {Your Project Name}, including software engineers. This guide\ncan help project contributors to communicate clearly and consistently in {you\ndefine the scope: the project's end-user documentation, API documentation,\nforum posts, and so forth.}\n\n## Our preferred style guide\n\nWe have adopted the\n{[your preferred style guide, such as the Google developer documentation style guide](https://developers.google.com/style)}\nfor {Your Project} documentation. For a quick summary, see the\n[Google style guide highlights](https://developers.google.com/style/highlights).\nThe rest of this document describes our project-specific customizations to\n{Google's or your preferred} guide.\n\nOur project uses {your choice: standard American spelling or standard British\nspelling, etc.} and our preferred dictionary is the\n{[American Heritage Dictionary](https://ahdictionary.com/) or your preferred\ndictionary}.\n\nWhen writing documentation for our project, align with the default guide's\nvoice and tone.\n\n## Glossary of preferred terms\n\n{Calling out how to write your project name first is optional. You can choose to include your project name in the word list instead.}\n\nThis {Your Project Name} is represented as {example}. It is {always/never}\ncapitalized.\n\nThe table provides guidelines about the terms you should and should not use for\nconsistency, listed in alphabetical order:\n\nPreferred Term | Avoid These Terms | Explanation\n--------------- | ----------------- | -----------\nfor example | e.g. | Avoid non-English words\npeople with disabilities |
    • the disabled
    • disabled people
    • people with handicaps
    • the handicapped
    | Use inclusive and bias-free language. See [Accessible Writing](#accessible-writing)\nthat is | i.e. | Avoid non-English words\n\n## Topic types and templates\n\nThis project recommends using the following templates from the\n[Good Docs project](https://github.com/thegooddocsproject/templates):\n\n* API Overview\n* API Quickstart\n* API Reference\n* Discussion Article\n* How To Guide\n* Logging Article\n* Reference Article\n* Tutorial\n\n## General writing tips\n\n{This section is optional.}\n\nFor some general tips about improving writing see:\n\n* [Write the Docs - Style Guides](https://www.writethedocs.org/guide/writing/style-guides/#writing-style)\n* [18F Content Guide](https://content-guide.18f.gov/)\n\n## Accessible writing\n\nDocumentation should be written in a way that supports people with disabilities\nand users with various input methods and devices. Improving accessibility also\nhelps make documentation clearer and more useful for everyone.\n\nFor resources on making your writing more accessible, see:\n\n* [Writing accessible documentation - Google developer documentation style guide](https://developers.google.com/style/accessibility)\n* [Accessibility guidelines and requirements - Microsoft Writing Style Guide](https://docs.microsoft.com/en-us/style-guide/accessibility/accessibility-guidelines-requirements)\n* [Writing for Accessibility - Mailchimp Content Style Guide](https://styleguide.mailchimp.com/writing-for-accessibility/)\n\n## Inclusive and bias-free writing\n\nWhen contributing to this project, you should strive to write documentation with\ninclusivity and diversity in mind. Inclusive language recognizes diversity and\nstrives to communicate respectfully to all people. This kind of language is\nsensitive to differences and seeks to promote equal opportunities.\n\nFor resources on making your writing more inclusive, see:\n\n* [Inclusive documentation - Google developer documentation style guide](https://developers.google.com/style/inclusive-documentation)\n* [The Conscious Style Guide - a collection of resources](https://consciousstyleguide.com/)\n* [Bias-free communication - Microsoft Writing Style Guide](https://docs.microsoft.com/en-us/style-guide/bias-free-communication)\n* [Guidelines for Inclusive Language - Linguistic Society of America](https://www.linguisticsociety.org/resource/guidelines-inclusive-language)\n\n## Using linters\n\n{This section is optional.}\n\nThis project uses the {your preferred linter.}\n\n{Provide instructions or policies related to the linter here.}\n\n## How the style guide is updated\n\n{Indicate here how frequently your style guide is reviewed, who owns the style\nguide, and how contributors can provide feedback on your style guide.}\n\n## Revision history\n\n{This section is optional or can be combined with the next section if needed.}\n\nThe following table describes the history of all decisions and revisions made to\nthis style guide over time. This guide uses the Major.Minor.Patch\n[semantic versioning](https://semver.org/) convention.\n\nEdition | Date | Lead Author(s) | Link to Repository Commit/Tag\n------- | ---- | -------------- | -----------------------------\n{0.1} | {YYYY-MM-DD} | {Your name here} | First draft of Style Guide\n\n## Decision log\n\n{This section is optional or can be combined with the previous section if\nneeded.}\n\nThe following table describes the history of all decisions made to this style\nguide over time:\n\nRef | Date | Description | Agreed to by\n--- | ---- | ----------- | ------------\n1 | {YYYY-MM-DD} | {Explain the decision that was made here} | {Name or role}\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Style%20guide) to give feedback on this template.\n", + "sha256": "a80205f6bfc951cd0642c25f43e0f52c945adb3ff9beea59a1207c4b73ab3562", + "resource": "tgdpTemplates/templates-main.zip!/style-guide/template_style-guide.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/style-guide/template_style-guide.md", + "description": "A style guide provides project contributors with general guidelines for writing project documentation." + }, + { + "id": "troubleshooting/template_troubleshooting.md", + "name": "Troubleshooting", + "category": "tgdp", + "extension": "md", + "source": "# Troubleshooting {product name, feature, or task}\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_troubleshooting.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n{Briefly describe the scope of the troubleshooting guide in 1-2 sentences. For example, explain if the troubleshooting guide covers the full scope of the product or a specific task or feature.}\n\n## Symptom 1\n\n{Describe the symptom here. For example, give the text of the error message or describe the performance issue the user might experience.}\n\n### Cause 1 of symptom 1\n\n{If there is only one cause, explain the cause here. If there are multiple causes, list one cause at a time.}\n\n### Solution or workaround to cause 1 of symptom 1\n\n{If there is only one cause, explain the cause here.}\n\n### Cause 2 of symptom 1\n\n{If there is only one cause, explain it here. If there are multiple causes, list one cause at a time.}\n\n{Explain what a successful result to the issue look like.}\n\n{Solution or workaround to cause 2 of symptom 1}\n{If there is only one cause, explain the cause here.}\n\n{Explain the result of the issue when solved.}\n\n### For more information\n\n{Add links to other sources could also aid the user in solving the issue they have with your product, task, or feature}\n\n* [Link](https://example.com/article1.html)\n* [Link](https://example.com/article2.html)\n* [Link](https://example.com/article3.html)\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Troubleshooting) to give feedback on this template.\n", + "sha256": "153edac00950a7b23f0446ae0724d3a129eb735f9d1cf2d8da6aa34bc264c91e", + "resource": "tgdpTemplates/templates-main.zip!/troubleshooting/template_troubleshooting.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/troubleshooting/template_troubleshooting.md", + "description": "A list of common problems (referred to as symptoms) experienced by users, an explanation of the causes, and steps to resolve the issue." + }, + { + "id": "tutorial/template_tutorial.md", + "name": "Tutorial", + "category": "tgdp", + "extension": "md", + "source": "# Title\n\n> If you need more information about how to fill in this template, read the accompanying [guide](./guide_tutorial.md).\n>\n> This template includes writing instructions and boilerplate text that you can customize, use as-is, or completely replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\n## Overview\n\nIn this tutorial, you'll learn how to {insert brief description of the main tutorial task}. This tutorial is intended for {audience}. It assumes you have basic knowledge of:\n\n* Concept 1\n* Concept 2\n* Concept 3...\n\nBy the end of this tutorial, you'll be able to:\n\n* Learning objective 1\n* Learning objective 2\n* Learning objective 3...\n\n## Background\n\n{This section is optional. Feel free to use some of the text below to help you get started.}\n\n* {product} is a {product type} that you can use to {common use case}...\n* {product} provides many of the same features as {competitors}, but with {feature}, you can...\n* Using {feature} enables you to {pain point}...\n\n## Before you start\n\n{Use this section to tell users about any prerequisites needed before they start the tutorial, such as:\n\n* Expected prior knowledge.\n* Software or hardware to obtain.\n* Environments to set up and configure.\n* Access codes to obtain.\n}\n\nBefore you start the tutorial, you should:\n\n* Prerequisite 1\n* Prerequisite 2\n* Prerequisite 3...\n\n## {Task name}\n\nTo get started, {the first thing your user should do}.\n\n1. {Write the step here. Use a verb to start.}\n\n {Explanatory text}\n\n {Optional: Code sample or screenshot that helps your users complete this step}\n\n {Optional: Result}\n\n2. {Write the step here. Use a verb to start.}\n \n a. {Substep 1}\n\n b. {Substep 1}\n\n## Summary\n\n{Use this section to summarize what the user learned in the tutorial.}\n\nIn this tutorial, you learned how to:\n\n* Summary point 1\n* Summary point 2\n* Summary point 3..\n\n## Next steps\n\n{Use this section to share links to related tutorials, videos, or other documentation}.\n\nConsider completing some other common tasks using {feature}:\n\n* Task 1\n* Task 2\n* Task 3...\n\n---\n\n> Explore other templates from [The Good Docs Project](https://thegooddocsproject.dev/). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Tutorial) to give feedback on this template.\n", + "sha256": "9c97e7a4b5074bd6b2da5a64f1a5c6f314b2ab834d9b21a7ed7f1edc2d3e230f", + "resource": "tgdpTemplates/templates-main.zip!/tutorial/template_tutorial.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/tutorial/template_tutorial.md", + "description": "Instructions for setting up an example project using the product, intended for the purpose of hands-on learning." + }, + { + "id": "user-personas/template_user-personas.md", + "name": "User personas", + "category": "tgdp", + "extension": "md", + "source": "# User personas template\n\nThis template includes writing instructions and boilerplate text that you can customize, use as-is, or replace with your own text. This text is indicated in {curly brackets}. Make sure you replace the placeholders with your own text.\n\nTake a look at the following accompanying resources:\n\n* For step-by-step guidance on creating user personas, explore our [User personas process](process_user-personas.md).\n\n* To see examples and explanations about how to fill in this template, explore the [User persona template guide](guide_user-personas.md).\n\n* To read more from the sources that inspired and shaped our documents on user personas, explore our [User personas resources](resources_user-personas.md).\n\n## {Optional: Visual representation of first user persona}\n\n## {Name or type of first user persona}\n\n{Use the [Process guide](process_user-personas.md)to help inform your selection process for the dimension and attributes. Choose the most important item from the following options for the dimension and remove it from the list. Keep the most relevant items as user persona attributes and eliminate unnecessary items for your project.}\n\n### {Dimension: description}\n\n**Goals:**\n\n* {Describe the persona's primary motivation, objective, purpose, or values.}\n* {Describe the persona's secondary motivation, objective, purpose, or values.}\n* {Describe the persona's tertiary motivation, objective, purpose, or values.}\n\n**Needs:**\n\n* {Describe the persona's primary requirements or desired capability.}\n* {Describe the persona's secondary requirements or desired capability.}\n* {Describe the persona's tertiary requirements or desired capability.}\n\n**Pain points:**\n\n* {Describe the persona's primary difficulties, challenges, or problems.}\n* {Describe the persona's secondary difficulties, challenges, or problems.}\n* {Describe the persona's tertiary difficulties, challenges, or problems.}\n\n**Role:**\n\n* {Describe the persona's primary business title or responsibilities.}\n* {Describe the persona's secondary role or responsibilities.}\n* {Describe the persona's tertiary role or responsibilities.}\n\n**Experience level:**\n\n* {Describe the persona's primary knowledge background, education, or comfort with product.}\n* {Describe the persona's secondary knowledge background, education, or comfort with product.}\n* {Describe the persona's tertiary knowledge background, education, or comfort with product.}\n\n**Technical expertise:**\n\n* {Describe the persona's primary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's secondary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's tertiary competencies, preferred operating systems, interfaces, or programming languages.}\n\n**Use case:**\n\n* {Describe the persona's primary tasks to perform.}\n* {Describe the persona's secondary tasks to perform.}\n* {Describe the persona's tertiary tasks to perform.}\n\n**Thinking pattern:**\n\n* {Describe the persona's primary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's secondary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's tertiary mindset, approach to tasks, or tolerance or aversion toward _x._}\n\n---\n\n## {Optional: Visual representation of second user persona}\n\n## {Name or type of second user persona}\n\n{Use the [Process guide](guide_user-personas.md)to help inform your selection process for the dimension and attributes. Choose the most important item from the following options for the dimension and remove it from the list. Keep the most relevant items as user persona attributes and eliminate unnecessary items for your project.}\n\n### {Dimension: description}\n\n**Goals:**\n\n* {Describe the persona's primary motivation, objective, purpose, or values.}\n* {Describe the persona's secondary motivation, objective, purpose, or values.}\n* {Describe the persona's tertiary motivation, objective, purpose, or values.}\n\n**Needs:**\n\n* {Describe the persona's primary requirements or desired capability.}\n* {Describe the persona's secondary requirements or desired capability.}\n* {Describe the persona's tertiary requirements or desired capability.}\n\n**Pain points:**\n\n* {Describe the persona's primary difficulties, challenges, or problems.}\n* {Describe the persona's secondary difficulties, challenges, or problems.}\n* {Describe the persona's tertiary difficulties, challenges, or problems.}\n\n**Role:**\n\n* {Describe the persona's primary business title or responsibilities.}\n* {Describe the persona's secondary role or responsibilities.}\n* {Describe the persona's tertiary role or responsibilities.}\n\n**Experience level:**\n\n* {Describe the persona's primary knowledge background, education, or comfort with product.}\n* {Describe the persona's secondary knowledge background, education, or comfort with product.}\n* {Describe the persona's tertiary knowledge background, education, or comfort with product.}\n\n**Technical expertise:**\n\n* {Describe the persona's primary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's secondary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's tertiary competencies, preferred operating systems, interfaces, or programming languages.}\n\n**Use case:**\n\n* {Describe the persona's primary tasks to perform.}\n* {Describe the persona's secondary tasks to perform.}\n* {Describe the persona's tertiary tasks to perform.}\n\n**Thinking pattern:**\n\n* {Describe the persona's primary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's secondary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's tertiary mindset, approach to tasks, or tolerance or aversion toward _x._}\n\n---\n\n## {Optional: Visual representation of third user persona}\n\n## {Name or type of third user persona}\n\n{Use the [Process guide](guide_user-personas.md) to help inform your selection process for the dimension and attributes. Choose the most important item from the following options for the dimension and remove it from the list. Keep the most relevant items as user persona attributes and eliminate unnecessary items for your project.}\n\n### {Dimension: description}\n\n**Goals:**\n\n* {Describe the persona's primary motivation, objective, purpose, or values.}\n* {Describe the persona's secondary motivation, objective, purpose, or values.}\n* {Describe the persona's tertiary motivation, objective, purpose, or values.}\n\n**Needs:**\n\n* {Describe the persona's primary requirements or desired capability.}\n* {Describe the persona's secondary requirements or desired capability.}\n* {Describe the persona's tertiary requirements or desired capability.}\n\n**Pain points:**\n\n* {Describe the persona's primary difficulties, challenges, or problems.}\n* {Describe the persona's secondary difficulties, challenges, or problems.}\n* {Describe the persona's tertiary difficulties, challenges, or problems.}\n\n**Role:**\n\n* {Describe the persona's primary business title or responsibilities.}\n* {Describe the persona's secondary role or responsibilities.}\n* {Describe the persona's tertiary role or responsibilities.}\n\n**Experience level:**\n\n* {Describe the persona's primary knowledge background, education, or comfort with product.}\n* {Describe the persona's secondary knowledge background, education, or comfort with product.}\n* {Describe the persona's tertiary knowledge background, education, or comfort with product.}\n\n**Technical expertise:**\n\n* {Describe the persona's primary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's secondary competencies, preferred operating systems, interfaces, or programming languages.}\n* {Describe the persona's tertiary competencies, preferred operating systems, interfaces, or programming languages.}\n\n**Use case:**\n\n* {Describe the persona's primary tasks to perform.}\n* {Describe the persona's secondary tasks to perform.}\n* {Describe the persona's tertiary tasks to perform.}\n\n**Thinking pattern:**\n\n* {Describe the persona's primary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's secondary mindset, approach to tasks, or tolerance or aversion toward _x._}\n* {Describe the persona's tertiary mindset, approach to tasks, or tolerance or aversion toward _x._}\n\n---\n\n{Explore other templates from [The Good Docs Project](https://gitlab.com/tgdp/templates). Use our [feedback form](https://thegooddocsproject.dev/feedback/?template=Persona%20template) to give feedback on this template.}\n", + "sha256": "492d5c88854e37065c265bf86136008f40d224719537fa812f975fe2b37602fb", + "resource": "tgdpTemplates/templates-main.zip!/user-personas/template_user-personas.md", + "url": "https://gitlab.com/tgdp/templates/-/blob/v1.2.0/user-personas/template_user-personas.md", + "description": "User personas are a framework to identify the characteristics that differentiate each user type for your product or service. Discovering more about your users will help you make user-centric product decisions and produce better documentation." + } +] diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md new file mode 100644 index 00000000..bbe016ff --- /dev/null +++ b/docs/development/writerside-toc-implementation.md @@ -0,0 +1,271 @@ +# Writerside Table of Contents implementation record + +Status: implemented, including native GTK submenus and the approved template +workflow. This is **not a claim of complete IDE, website, or pixel-perfect +Writerside parity**; retained safety and integration differences are listed below. + +## Baseline and decisions + +The receiving checkout was clean at `c3b53ff` (BusyMark 0.4.2). The named handoff +implementation points matched the receiving source; no archive was copied over it. +Flutter 3.47.2 and Dart 3.13.2 were used without upgrading dependencies. + +User decisions: retain BusyMark AI, Git, clipboard, and file commands **after** the +Writerside actions; use installed Writerside **2026.07.8925** for undocumented +behavior; reuse the existing usage-review sidebar, titled **Find**. + +The user approved application-support user-template storage and a dedicated +**File and Code Templates** editor, without project metadata. The installed +**Create Topic from Template** dialog takes precedence over the obsolete submenu +screenshot. The user also explicitly required BusyMark's existing native Linux +menus, not Material dropdowns. + +## Evidence + +The handoff's source identifiers are retained: + +| Ref | Official source | +| --- | --- | +| S1 | [Table of contents](https://www.jetbrains.com/help/writerside/table-of-contents.html) | +| S2 | [Context menu](https://resources.jetbrains.com/help/img/writerside/go-to-toc-element.png) | +| S3 | [Row presentation](https://resources.jetbrains.com/help/img/writerside/toc-elements-toc-view.png) | +| S4 | [Topics and titles](https://www.jetbrains.com/help/writerside/topics.html) | +| S5–S7 | [Basic title dialog](https://resources.jetbrains.com/help/img/writerside/edit-title-action.png), [instance title](https://resources.jetbrains.com/help/img/writerside/edit_title_instance_specific.png), [TOC title](https://resources.jetbrains.com/help/img/writerside/edit_title_toc_title.png) | +| S8 | [Add topics](https://www.jetbrains.com/help/writerside/add-a-topic.html) | +| S9–S10 | [Reuse topics](https://www.jetbrains.com/help/writerside/reuse-topics.html), [creation menu](https://resources.jetbrains.com/help/img/writerside/reuse-from-another.png) | +| S11–S12 | [Remove topics](https://www.jetbrains.com/help/writerside/delete-a-topic.html), [removal dialog](https://resources.jetbrains.com/help/img/writerside/remove_topic_dialog.png) | +| S13 | [Save topic as template](https://www.jetbrains.com/help/writerside/save-as-template.html) | +| S14 | [Preview topics](https://www.jetbrains.com/help/writerside/preview-topics.html) | +| S15 | [Instances and tree format](https://www.jetbrains.com/help/writerside/instances.html) | + +Installed-build evidence (**P**) is bytecode/resource inspection, not an interactive +JetBrains UI observation. Source artifact: +`/home/albert/.local/share/JetBrains/PyCharm2026.2/writerside/lib/writerside-2026.07.8925.jar`. +SHA-256: `0493a38ac6f09840302b150641fab8b91697955896572c7bf4367bbf7a08f810`. +Reproduce with `unzip -p` for resources and `javap -c -p -classpath ` +using the installed JDK. Relevant classes/resources: + +- `stardust.ui.tocTree.TocTreePopupKt`: context order, Group and Sort positions. +- `stardust.ui.views.dom.tree.actions.NewArticleActionGroup`: creation order. +- `stardust.ui.views.dom.tree.actions.DuplicateTopicAction`: source duplication, + new XML root ID, basic sibling reference rather than copied TOC descendants. +- `stardust.actions.CopySpecialAction` and `stardust.ui.tocTree.CopyTopicFileNameAction`, + `CopyTopicFilePathAction`, `CopyTopicTitleAction`, `CopyTocOwnIdAction`: labels, + order, availability, raw reference/canonical path/base title/explicit ID payloads. +- `stardust.ui.changes.EditTitleDialog`: collapsed advanced fields, blank override + removal and required base title. +- `stardust.ui.views.dom.tree.actions.GroupAction`: two or more siblings, source + ordering, New Group/Group Name/Enter interaction. +- `com.intellij.writerside.intentions.SortChildrenAlphabeticallyAction`: shallow, + case-sensitive natural string comparison of resolved tree-node names. +- `stardust.ui.views.dom.tree.actions.LinkTopicFileToTocAction`: single selection, + name filtering, picker caption and current-instance exclusion. +- `stardust.ui.changes.RemoveElementDialog`, `TocRemoveDialogData`, and + `RemoveTocElementIntention`: compact versus full removal and automatic-update default. +- `stardust.ui.views.dom.tree.actions.ScrollProductTreeToFileAction`, + `stardust.ui.tocTree.TocTreeImpl`, `TocElementKey`, `TocTreeAsyncView`: + explicit synchronization and breadth-first first topic match. +- `stardust.ui.views.dom.tree.TopicFromTemplate`, `actions.newtopic.NewTopicDialog`, + `actions.TemplateFromTopicAction`, `fileTemplates/internal/`, and + `tgdpTemplates/`: real template resources and modern creation/settings entry points. + `LocalFileTemplateProvider` and `FileTemplateUtilKt` establish simultaneous + literal `${TITLE}`/`${ID}` substitution, not Velocity execution. + `NewTopicDialog.Companion` establishes XML-only title escaping and filename + conversion; `TGDPFilesImporter.Companion` establishes brace adaptation, + first top-level H1 replacement, and relative-link resolution. +- `i18n/StardustBundle.properties`: installed English strings/defaults. + +## Action acceptance matrix + +Implementation paths below abbreviate `workspace_screen.dart` as **WS**, +`writerside_toc_dialogs.dart` as **Dialogs**, and the controller/service by class. +Test abbreviations: **A** = `writerside_toc_actions_test.dart`; **W** = +`writerside_toc_workspace_test.dart`; **M** = `writerside_toc_menu_test.dart`; +**E** = `writerside_toc_editor_test.dart`; **C** = `writerside_topic_creator_test.dart`; +**R** = `writerside_topic_removal_service_test.dart`; **App** = `app_smoke_test.dart`; +**TS/TD** = `writerside_template_service_test.dart` / `writerside_template_dialogs_test.dart`. + +“Native” means the production Flutter Linux application driven by the disposable +fixture harness, with source-file assertions and actual rendered captures. GTK +menus are activated through Linux accessibility and X11 keyboard events, not a +Flutter menu substitute. It is not a human manual run or an original Writerside +screenshot comparison. + +| Evidence | Final location / exact English label | Implementation | Behavioral verification | Linux/manual result / differences | +| --- | --- | --- | --- | --- | +| S1, S3, S4 | Sidebar: **Table of Contents** | `WritersideTocPresenter`, `WS._TocTabState` | W: contextual titles, instances, markers, included ownership; App: Files/TOC and RTL regressions | Native pane captured and inspected. Ordinary rows have no document icon; hidden rows remain muted. Existing glyphs are not JetBrains artwork. | +| S1, S2, P | Context: **New Topic** → choices; toolbar + uses current selection | `WS._showTocTreeMenu`, `_createTocItem`, `_TocHeader` | App: selected sibling path/identity; C: creation guards | Native sibling Markdown and template creation passed. | +| S1, S2, P | Context: **New Child Topic** → choices | Same dispatch with child placement | App: XML format and child source identity; C | Native XML child creation passed. | +| S8, P | Either creation menu: **Empty MD Topic**, **Empty XML Topic**; dialog **New Topic** | `WS._CreateWritersideTopicDialog`, `WritersideTopicCreator.create` | C: formats, roots, collisions, races, first home page; controller monitor-race and dirty inactive-tree tests | Native both formats passed; new documents opened with other tabs retained. Existing BusyMark empty-body content retained; not a claim of identical installed template bytes. | +| S1, P | Creation menu: **Empty Group**; dialog **New Empty Group**, **TOC title:**, **Cancel**, **OK** | `Dialogs.WritersideTocTextDialog`, `WritersideTocEditor.insertElement` | E/A structural paths; guarded insertion service | Native creation passed: a titled element without a topic reference. | +| S9–S10, P | Creation menu after separator: **Link Topic Files to TOC...**; picker **Select Topic to Add to the Current Instance** | `Dialogs.WritersideExistingTopicPicker`, `WorkspaceService.insertWritersideTocElement` | M: filter/arrows/submit/cancel; W: unchanged bytes and stale eligibility | Native linking passed, including unchanged source bytes. Candidates use canonical resolved membership, including includes. | +| S2, P | Context after creation separator: **Duplicate**; **Duplicate Topic**, **Topic Filename:** | `WorkspaceService.duplicateWritersideTopic`, guarded creator | W: new XML ID, unchanged body, basic sibling only; C publication guards | Native duplication passed: source copy opened with its new XML root ID. Filename validation retains BusyMark's conservative identifier restrictions; race errors use existing workspace error presentation. | +| S2, P | Context: **Copy Special** → **Topic File Name '{name}'**, **Topic File Path**, **Topic Title '{title}'**, **TOC Element ID '{id}'** | `WS._showTocTreeMenu`, `_showTopicContextMenu` | App: all four Copy Special clipboard payloads in RTL, including base versus navigation title; M nested keyboard/focus/RTL | Native menu captured. Payloads are raw topic reference, canonical path, base title, explicit ID; unavailable values are disabled. No claim that contextual TOC title is copied. | +| S2, S14 | Context: **Preview Topic** | `WS._showTopicContextMenu` preview case, existing view-mode/preview controller | Existing preview/view-mode suites; native selected-topic assertion | Native selected-topic preview passed and was captured. Uses BusyMark renderer, not the Writerside website renderer. | +| S2, S4–S7, P | Context: **Edit Title...**; dialog **Edit Title** with **Topic title:**, **Advanced Settings**, **Title for '{id}':**, **TOC-only title:**, **Cancel**, **OK** | `Dialogs.WritersideTitleDialog`, `WritersideTitleEditor.prepare`, `WorkspaceService.editWritersideTitles` | A: XML/Markdown escaping, independent overrides, inherited values, clearing, repeated occurrence; W: rollback on second-file guard | Native dialog/advanced fields captured and inspected. Front-matter title editing is rejected rather than silently changing a shadowed H1; edit that BusyMark extension in source. | +| S11–S12, P | Context: **Remove TOC Element...**; dialog **Remove TOC Element** | `WS._runWritersideTopicRemoval`, `WritersideTopicRemovalService` | R: references, redirect, malformed source, races, file retained/deleted; W: dirty guard rollback | Native ordinary removal and Find transition captured. Redirect precedes automatic-update checkbox; automatic update defaults on when analysis permits. Safety diagnostics are BusyMark-specific. | +| S1, P | Multi-selection: **Remove TOC Elements...**; compact **Remove {count} TOC Elements** | `WS._removeTocEntries`, guarded batch structural removal | E: source identities and direct-child promotion; App: multi-selection route | Existing direct-child promotion is preserved, as required by handoff. Installed descendant-inclusive count/removal is not cloned; selected-entry count is shown. | +| S11, user decision | Existing sidebar: **Find**, **Do Refactor** | `_WritersideTopicUsageReviewPanel`, usage navigation/reanalysis orchestration | R and controller stale-analysis tests; native review transition | Native surface captured and inspected. Sidebar placement is explicitly user-approved; this is not a general JetBrains tool-window clone. | +| S11 | Files context: **Refactor** → **Safe Delete**; dialog **Delete**, **Safe Delete**, **OK** | Files nested menu, same topic-removal analyzer/apply service | R: generic delete bypass prohibited, usage checks, orphan choices; W: rollback | Native Files → Refactor → Safe Delete passed; Delete dialog captured and inspected. Unchecking Safe Delete does not bypass BusyMark reference safety checks. | +| S2, S15, P | Context: **Set as Home Page** | `WritersideTocEditor.setHomePage`, controller/service | W: root attributes and other instance unchanged; C: first-topic initialization retained | Native reassignment passed and marker inspected. No home action for groups, URLs or library instances; included structural mutation remains restricted. | +| S1, P | Multi-selection after Duplicate: **Group**; **New Group**, **Group Name**, Enter | `WritersideTocEditor.groupElements`, `Dialogs.WritersideTocTextDialog` | A: source order, full nodes, cross-parent rejection; E guards | Native Group/Enter passed, wrapping the two selected source subtrees. Same-parent selections only, preserving complete XML subtrees. | +| S1, P | Context after source navigation: **Sort Child Topics Alphabetically** | `WorkspaceService.sortWritersideTocChildren`, `WritersideTocEditor.reorderChildren` | W: concurrent resolved-title dependency change rejected; native expected order | Native shallow contextual-title order passed. Deliberately rejects mixed XML child entries instead of reproducing the installed action's loss of includes/non-TOC tags. | +| S1 | Pointer: before/after/child drop, single/multiple selection | `WS._TocTabState` drag handlers, `WritersideTocEditor.moveSubtrees` | App: actual recognizers, child/before zones, multi-payload, ancestor rejection; A/E: full subtrees and identities | Native multi-item child drop passed; moved entries remained selected and descendants visible. Widget checks pass for collapsed-hover expansion and autoscroll to a distant destination in a 42-entry tree. | +| S1, S2 | Context: **Go to TOC Element in '{tree filename}'** | `WS._goToTocElement`, `writersideTocSourceSpan`, `SourceEditorState.scrollToOffset` | A: repeated references on one line and fresh offsets; source widget: folded destination; W: snippet owner | Native active and included owner source navigation passed; captures inspected. Native preview-only → source navigation also passed. | +| S1 | Topic/group double-click | TOC `WS._SidebarTreeRow` and `_SidebarRowSurface` callbacks | App: empty-group double-click/source-open assertion; exact-source helper tests | Production callbacks implemented; dedicated native double-click check not yet recorded. Existing single-click semantics retained. | +| S1, P | Toolbar: **Synchronize TOC and Editor** | `WS._rememberSyncFocus`, `_synchronizeToc`, `writersideTocBreadthFirstPath` | A: installed breadth-first duplicate match and tree-editor key behavior | Native both synchronization directions passed, including pre-toolbar focus tracking. Installed `.tree` editor action unusually passes explicit element ID as a topic key: no ID, or no matching topic reference, yields no selection. This limitation is retained, not described as arbitrary group synchronization. | +| S1 | Instance control context: **Open TOC File** | `WS._WritersideInstanceSelector`, `_openInstanceTree` | App: instance-context Open TOC File assertion; native source opening | Implemented; retained additionally in existing overflow. Native instance-context activation not yet recorded. | +| S1 | Tree source: Ctrl+Shift+Home / Ctrl+Shift+End | `source_commands.dart` line movement, source editor, command registry | A: CRLF, boundaries, reversed selection; source widget: folds, one edit, undo/redo | Implemented only in tree-editor context. Multi-line block behavior is conventional BusyMark behavior, not yet observed in original IDE. | +| S1, S15 | Source authoring: `hidden`, `href`, `toc-title`, nesting | Existing parser plus contextual presentation/refresh | Existing parser/resolver/source/controller suites; W row fixture | Native hidden/external rows captured. No invented Hide/Add External Link menus or search-index parity claim. | +| S8, S13, P | Creation menus: **Topic from Template...**; **Create Topic from Template** dialog | `WritersideTemplateDialog`, `WritersideTemplateService.generate`, guarded `WritersideTopicCreator.create` | TS: resource hashes, six families/10 variants, 22 TGDP topics, literal substitution, XML escaping, safe publication/rollback; TD: RTL/LTR format, validation and creation | Native XML template preview and creation passed; new tab and actual template body verified. BusyMark's preview renderer and conservative filenames are retained. Bundled TGDP collection is pinned, not automatically downloaded. | +| S2, S13, P, user decision | Context: **Save as Template**; toast/dialog: **Edit templates...**; **File and Code Templates**, **Files**, **Internal**, **OK** | `WS._showTopicContextMenu`, `WritersideTemplateService.saveTopic`, `WritersideTemplatesEditor` | TS: original extraction/naming, restart, stale-store rejection, serialized writes, source preservation, built-in reset; TD: staged edits, duplicate/delete, Cancel/OK, conflict recovery | Native save/edit/reuse passed with original topic unchanged. Application-support storage and dedicated editor are user-approved adaptations, not an IDE-wide settings clone. | +| S2, user decision | BusyMark AI, Git, clipboard and file extras follow Writerside actions | `WS._showTocTreeMenu` | App preserves copy/cut/paste, history and applicable Git/AI routes | User-approved layout difference. TOC, Files → Refactor, and flat menus use the existing GTK backend, now with real submenus. A framework fallback is retained only for an unavailable native host; no forced Material path remains on Linux. | + +## Safety and retained differences + +New mutations validate raw source identities and expected content, use guarded +publication, and inspect inactive affected buffers as well as the active document. +Title changes are prepared before a multi-file transaction; dirty-buffer and disk +guards are rechecked at publication, with rollback on partial failure. Topic +deletion uses the existing analyzer and rollback machinery. Clean tabs refresh; +unrelated dirty tabs remain untouched. File-monitor events are deferred during +foreground file operations so an own-write event cannot invalidate the subsequent +open-new-document step. + +Structural serialization remains the existing XML pretty-printer: semantic +preservation, not byte-for-byte formatting preservation. Included nodes remain +navigable without enabling unsupported cross-source moves. BusyMark's stricter +mixed-entry sort, safe-delete and front-matter restrictions are explicit above. +The two pictured toolbar chevrons' original tooltips have not been established; +no guessed tooltip names were added. + +## Template implementation and resource provenance + +`assets/writerside/templates.json` contains the installed resources, with each +original archive path and SHA-256 recorded alongside the unmodified source. +It includes six default families (10 Markdown/XML variants) and the 22 Markdown +TGDP entries in the bundled catalog. The bundled TGDP license is retained in +`assets/writerside/TGDP-LICENSE`. No six-name placeholder collection was invented. + +Custom templates and built-in overrides are stored at +`/writerside/templates.json`, not in the documentation +project. Reads create nothing. Writes are serialized with a process file lock +and an isolate queue, compare the editor's original snapshot, reject corrupt or +non-regular stores, and use atomic publication. Cancel does not publish staged +edits or deletions. Save as Template reads the current topic buffer when open, +derives the installed `Writerside_` unique name, substitutes the +installed source patterns, and leaves the topic file unchanged. + +The creation dialog uses the installed Default/Custom/The Good Docs Project +categories, search, title/filename, available Markdown/XML variants, rendered +preview, source link, and template-editing entry points. The editor provides +Files/Internal, new/duplicate/delete/reset, editable source, Cancel, and OK. +GTK owns dropdown presentation; Yaru owns the desktop format radio controls and +editor tabs. The editor is intentionally a scoped application-support editor, +not a clone of every IntelliJ settings component. + +Generation preserves the installed literal substitution contract; unknown tokens, +Velocity directives, and tokens inside replacement values are not evaluated. +XML titles use XML 1.0 escaping. TGDP generation adapts braces, replaces the first +top-level H1, and resolves relative inline link/image destinations against the +bundled v1.2.0 source URL. Repeated destinations are edited once each; code and +prose are not rewritten as links. Exotic/reference-style link source forms are +left unchanged when no unambiguous inline destination is available. There is no +remote TGDP updater or automatic image-file download. Customized malformed XML +and duplicate root IDs are rejected before any topic/tree publication. + +## Native menu integration + +`NativeMenuEntry.submenu` sends a recursive semantic model to the existing +`busymark/native_menus` channel. GTK constructs genuine `GMenu`/`GtkMenu` +submenus; separators and selection IDs retain a shared preorder, including +headings. Only enabled leaf actions can dispatch, and disabled ancestors remain +disabled. GTK 3 does not apply an action's sensitivity to submenu headings, so +heading availability is explicitly applied to the generated GTK widgets in +model order. Limits protect the host from excessive depth/entry counts. + +The existing popup anchoring, session-scoped dismissal, radio groups, and focus +return remain in place. Text direction is carried to each GTK submenu and its +anchor. The Linux menu hierarchy uses the host's keyboard/hover behavior +([GTK MenuShell](https://docs.gtk.org/gtk3/class.MenuShell.html)); it is not a +new Material dropdown implementation. Unit tests assert native-first dispatch, +nested payloads, exact leaf selection, disabled ancestors, and cancellation. +The existing main application menu remains flat. + +## Verification log + +Final verification, including templates and native menus: + +- `flutter gen-l10n`: exit 0, all 23 ARBs regenerated. +- Formatting checks: exit 0, 22 changed and 13 new non-generated Dart files. +- `flutter analyze --no-pub`: exit 0, no issues. +- Full `flutter test --no-pub --file-reporter json:/tmp/busymark-final-tests.json`: + exit 0, **1,905 passed, 58 skipped**. +- Documented focused Writerside/source/controller/export suite: exit 0, + **400 passed, 1 skipped**, report `/tmp/busymark-gtk-focused-verified.json`. +- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**. + This uses the pinned builder 2026.08.0328, distinct from the selected UI plugin + 2026.07.8925. It checks the semantic fixture, not website or menu parity. +- Linux interaction harness: exit 0, **35 checks passed**, **20 captures**. + Includes real GTK two-level keyboard traversal in LTR/RTL, disabled headings, + Escape, focus return, session-scoped dismissal, existing flat radio selectors, + and the core TOC/template workflows in the action matrix. +- Normal `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; + the bundle is restored to the normal application, not the acceptance harness. +- `git diff --check`: exit 0. + +The 58 full-suite skips are optional Typst/PDF/Poppler and D2 integrations that +require explicit tool-path environment configuration. The focused skip is a +Typst integration. No unavailable optional integration is counted as a passing +TOC or native-menu check. + +The first template full run exposed three test-contract problems: reviewed +technical strings in localization audits, the Chinese locale inheritance +contract, and an offscreen RTL clipboard item after adding Save as Template. +These were corrected and focused checks passed. The native menu run exposed +GTK's submenu-heading sensitivity behavior; an actual disabled-heading assertion +now covers it. A headerbar source audit was narrowed to its real scope (the main +menu stays flat, while TOC context menus may be nested). Existing popup cleanup +and native lifecycle contracts were retained. + +Native fixture: `test/fixtures/writerside/toc_ui/` includes Markdown/XML, +guide/API/library instances, reused titles, groups, hidden/external/included +nodes, and usage references. The harness copies it into a disposable directory +and isolates settings/session/recovery/history/template storage. Only its own +fixture documents are modified; its Safe Delete check deletes only a generated +duplicate, while the original fixture remains untouched. + +Reproduce in an isolated X11 desktop, then restore the normal target: + +```bash +flutter build linux --debug --no-pub --target tools/writerside_toc_visual_smoke.dart +BUSYMARK_NATIVE_PROBE=1 GDK_BACKEND=x11 NO_AT_BRIDGE=0 \ + /usr/bin/dbus-run-session -- /usr/bin/xvfb-run -a -s '-screen 0 1440x1000x24' \ + build/linux/x64/debug/bundle/busymark \ + test/fixtures/writerside/toc_ui /tmp/busymark-gtk-toc-final-acceptance +flutter build linux --debug --no-pub --target lib/main.dart +``` + +The driver requires `/usr/bin/python3`, GTK/AT-SPI introspection, XTest, Xvfb, +and a private D-Bus session. It targets only the probe process. Screen captures +include the actual GTK popup windows, not only Flutter's repaint boundary. +The virtual desktop emits missing portal/GPU and synthetic-trigger warnings, +and AT-SPI/GTK teardown diagnostics were observed during accessibility-driven +menu replacement. All recorded actions completed; this is not a claim of a +warning-free desktop or comprehensive assistive-technology validation. Original IDE +multi-line movement and pictured toolbar-chevron semantics remain unverified +as noted in the matrix. + +Inspected Linux evidence: +[results](/tmp/busymark-gtk-toc-final-acceptance/result.json), +[context menu](/tmp/busymark-gtk-toc-final-acceptance/02-context-and-creation-menu.png), +[creation submenu](/tmp/busymark-gtk-toc-final-acceptance/08-creation-menu.png), +[RTL keyboard submenus](/tmp/busymark-gtk-toc-final-acceptance/19-native-keyboard-rtl.png), +[title dialog](/tmp/busymark-gtk-toc-final-acceptance/03-edit-title.png), +[removal](/tmp/busymark-gtk-toc-final-acceptance/06-removal.png), +[Find](/tmp/busymark-gtk-toc-final-acceptance/07-find-review.png), +[template dialog](/tmp/busymark-gtk-toc-final-acceptance/15-template-dialog.png), +[XML preview](/tmp/busymark-gtk-toc-final-acceptance/16-template-xml-preview.png), +[template editor](/tmp/busymark-gtk-toc-final-acceptance/17-file-and-code-templates.png), +[custom template reuse](/tmp/busymark-gtk-toc-final-acceptance/18-custom-template-preview.png). +The disposable source results remain in `/tmp/busymark-toc-native-SZKSLU`. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 1ad588fa..d5949733 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "موضوع من قالب...", + "tocSaveAsTemplate": "حفظ كقالب", + "tocTemplateDialog": "إنشاء موضوع من قالب", + "tocTemplatesEditor": "قوالب الملفات والشيفرة", + "tocEditTemplates": "تحرير القوالب...", + "tocCreateCustomTemplate": "إنشاء قالب مخصص...", + "tocTemplateCreated": "تم إنشاء القالب", + "tocTemplateDefault": "افتراضي", + "tocTemplateCustom": "مخصص", + "tocTemplateFilename": "اسم الملف:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "داخلي", + "tocTemplateUnnamed": "بلا اسم", + "tocTemplateNew": "قالب جديد...", + "tocTemplateName": "الاسم:", + "tocTemplateExtension": "الامتداد:", + "tocTemplateLoadError": "تعذر تحميل القوالب. لم تتغير القوالب الموجودة.", + "tocTemplateSaveError": "تعذر حفظ القوالب. لا تزال تعديلاتك متاحة هنا.", + "tocTemplateConflict": "تغيرت القوالب في نافذة أخرى. ألغِ وأعد فتح المحرر.", + "tocTemplateDuplicateName": "يوجد بالفعل قالب بهذا الاسم والامتداد.", + "tocTemplateFormat": "التنسيق:", + "tocTemplateReset": "إعادة تعيين", + "tocTemplateRetry": "إعادة المحاولة", + "tocRemoveElementsDialog": "إزالة {count} من عناصر جدول المحتويات", + "tocRemoveElementsRetainFiles": "لن تُحذف ملفات المصدر المرتبطة بعناصر جدول المحتويات هذه.", + "tocOk": "موافق", + "tocSelectExistingTopic": "اختر موضوعًا لإضافته إلى النسخة الحالية", + "tocGroupName": "اسم المجموعة", + "tocMoveLineUp": "نقل السطر لأعلى", + "tocMoveLineDown": "نقل السطر لأسفل", + "tocEmptyMdTopic": "موضوع MD فارغ", + "tocEmptyXmlTopic": "موضوع XML فارغ", + "tocCopySpecial": "نسخ خاص", + "tocCopyFileName": "اسم ملف الموضوع '{name}'", + "tocCopyFilePath": "مسار ملف الموضوع", + "tocCopyTopicTitle": "عنوان الموضوع '{title}'", + "tocCopyElementId": "معرّف عنصر جدول المحتويات '{id}'", + "tocCopyFileNameUnavailable": "اسم ملف الموضوع", + "tocCopyTopicTitleUnavailable": "عنوان الموضوع", + "tocCopyElementIdUnavailable": "معرّف عنصر جدول المحتويات", + "tocPreviewTopic": "معاينة الموضوع", + "tocEditTitleAction": "تحرير العنوان...", + "tocEditTitleDialog": "تحرير العنوان", + "tocRemoveElementAction": "إزالة عنصر جدول المحتويات...", + "tocSetHomePage": "تعيين كصفحة رئيسية", + "tocGoToElement": "الانتقال إلى عنصر جدول المحتويات في '{fileName}'", + "tocSynchronize": "مزامنة جدول المحتويات والمحرر", + "tocEmptyGroupAction": "مجموعة فارغة", + "tocNewEmptyGroup": "مجموعة فارغة جديدة", + "tocGroupAction": "تجميع", + "tocNewGroup": "مجموعة جديدة", + "tocSortChildren": "ترتيب المواضيع الفرعية أبجديًا", + "tocLinkTopicFiles": "ربط ملفات المواضيع بجدول المحتويات...", + "tocTitleField": "عنوان جدول المحتويات:", + "tocTopicTitleField": "عنوان الموضوع:", + "tocAdvancedSettings": "إعدادات متقدمة", + "tocInstanceTitleField": "عنوان '{instanceId}':", + "tocOnlyTitleField": "عنوان خاص بجدول المحتويات:", + "tocFindUsages": "بحث", + "tocRefactorMenu": "إعادة الهيكلة", + "tocSafeDelete": "حذف آمن", + "tocDuplicate": "تكرار", + "tocDuplicateFilename": "اسم ملف الموضوع:", + "tocDuplicateDialog": "تكرار الموضوع", "pdfTitlePage": "صفحة العنوان", "pdfIncludeTitlePage": "تضمين صفحة العنوان", "pdfTitlePageNote": "لا تحتوي هذه الصفحة على رأس أو تذييل أو رقم صفحة ظاهر.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "موضوعات", + "toc": "جدول المحتويات", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "موضوع جديد على المستوى نفسه", + "newSiblingTopic": "موضوع جديد", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b4380d5f..09e85971 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Thema aus Vorlage...", + "tocSaveAsTemplate": "Als Vorlage speichern", + "tocTemplateDialog": "Thema aus Vorlage erstellen", + "tocTemplatesEditor": "Datei- und Codevorlagen", + "tocEditTemplates": "Vorlagen bearbeiten...", + "tocCreateCustomTemplate": "Eigene Vorlage erstellen...", + "tocTemplateCreated": "Vorlage erstellt", + "tocTemplateDefault": "Standard", + "tocTemplateCustom": "Benutzerdefiniert", + "tocTemplateFilename": "Dateiname:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Intern", + "tocTemplateUnnamed": "Unbenannt", + "tocTemplateNew": "Neue Vorlage...", + "tocTemplateName": "Name:", + "tocTemplateExtension": "Erweiterung:", + "tocTemplateLoadError": "Vorlagen konnten nicht geladen werden. Bestehende Vorlagen wurden nicht geändert.", + "tocTemplateSaveError": "Vorlagen konnten nicht gespeichert werden. Ihre Änderungen sind hier noch verfügbar.", + "tocTemplateConflict": "Vorlagen wurden in einem anderen Fenster geändert. Brechen Sie ab und öffnen Sie den Editor erneut.", + "tocTemplateDuplicateName": "Eine Vorlage mit diesem Namen und dieser Erweiterung existiert bereits.", + "tocTemplateFormat": "Format:", + "tocTemplateReset": "Zurücksetzen", + "tocTemplateRetry": "Erneut versuchen", + "tocRemoveElementsDialog": "{count} Inhaltsverzeichniselemente entfernen", + "tocRemoveElementsRetainFiles": "Die mit diesen Inhaltsverzeichniselementen verknüpften Quelldateien werden nicht gelöscht.", + "tocOk": "OK", + "tocSelectExistingTopic": "Thema zum Hinzufügen zur aktuellen Instanz auswählen", + "tocGroupName": "Gruppenname", + "tocMoveLineUp": "Zeile nach oben verschieben", + "tocMoveLineDown": "Zeile nach unten verschieben", + "tocEmptyMdTopic": "Leeres MD-Thema", + "tocEmptyXmlTopic": "Leeres XML-Thema", + "tocCopySpecial": "Speziell kopieren", + "tocCopyFileName": "Themendateiname '{name}'", + "tocCopyFilePath": "Themendateipfad", + "tocCopyTopicTitle": "Thementitel '{title}'", + "tocCopyElementId": "ID des Inhaltsverzeichniselements '{id}'", + "tocCopyFileNameUnavailable": "Themendateiname", + "tocCopyTopicTitleUnavailable": "Thementitel", + "tocCopyElementIdUnavailable": "ID des Inhaltsverzeichniselements", + "tocPreviewTopic": "Thema in Vorschau anzeigen", + "tocEditTitleAction": "Titel bearbeiten...", + "tocEditTitleDialog": "Titel bearbeiten", + "tocRemoveElementAction": "Inhaltsverzeichniselement entfernen...", + "tocSetHomePage": "Als Startseite festlegen", + "tocGoToElement": "Zum Inhaltsverzeichniselement in '{fileName}' wechseln", + "tocSynchronize": "Inhaltsverzeichnis und Editor synchronisieren", + "tocEmptyGroupAction": "Leere Gruppe", + "tocNewEmptyGroup": "Neue leere Gruppe", + "tocGroupAction": "Gruppieren", + "tocNewGroup": "Neue Gruppe", + "tocSortChildren": "Untergeordnete Themen alphabetisch sortieren", + "tocLinkTopicFiles": "Themendateien mit Inhaltsverzeichnis verknüpfen...", + "tocTitleField": "Inhaltsverzeichnistitel:", + "tocTopicTitleField": "Thementitel:", + "tocAdvancedSettings": "Erweiterte Einstellungen", + "tocInstanceTitleField": "Titel für '{instanceId}':", + "tocOnlyTitleField": "Nur im Inhaltsverzeichnis verwendeter Titel:", + "tocFindUsages": "Suchen", + "tocRefactorMenu": "Refaktorieren", + "tocSafeDelete": "Sicher löschen", + "tocDuplicate": "Duplizieren", + "tocDuplicateFilename": "Themendateiname:", + "tocDuplicateDialog": "Thema duplizieren", "pdfTitlePage": "Titelseite", "pdfIncludeTitlePage": "Titelseite einschließen", "pdfTitlePageNote": "Diese Seite hat keine Kopfzeile, Fußzeile oder sichtbare Seitenzahl.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Themen", + "toc": "Inhaltsverzeichnis", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Neues Thema auf gleicher Ebene", + "newSiblingTopic": "Neues Thema", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -2890,7 +2955,7 @@ "newInstance": "Neue Instanz", "newTocLibrary": "Neue TOC-Bibliothek", "editInstance": "Instanz bearbeiten", - "openTocFile": "TOC-Datei öffnen", + "openTocFile": "Inhaltsverzeichnisdatei öffnen", "createInstance": "Instanz erstellen", "createTocLibrary": "TOC-Bibliothek erstellen", "instanceContent": "Inhalt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7acff47..846c6e5d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,138 @@ { + "tocTopicFromTemplate": "Topic from Template...", + "@tocTopicFromTemplate": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocSaveAsTemplate": "Save as Template", + "@tocSaveAsTemplate": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateDialog": "Create Topic from Template", + "@tocTemplateDialog": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplatesEditor": "File and Code Templates", + "@tocTemplatesEditor": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocEditTemplates": "Edit templates...", + "@tocEditTemplates": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocCreateCustomTemplate": "Create custom template...", + "@tocCreateCustomTemplate": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateCreated": "Template created", + "@tocTemplateCreated": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateDefault": "Default", + "@tocTemplateDefault": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateCustom": "Custom", + "@tocTemplateCustom": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateFilename": "Filename:", + "@tocTemplateFilename": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateMarkdown": "Markdown (.md)", + "@tocTemplateMarkdown": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateXml": "XML (.topic)", + "@tocTemplateXml": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplatesInternal": "Internal", + "@tocTemplatesInternal": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateUnnamed": "Unnamed", + "@tocTemplateUnnamed": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateNew": "New template...", + "@tocTemplateNew": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateName": "Name:", + "@tocTemplateName": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateExtension": "Extension:", + "@tocTemplateExtension": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateLoadError": "Could not load templates. Existing templates have not been changed.", + "@tocTemplateLoadError": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateSaveError": "Could not save templates. Your edits are still available here.", + "@tocTemplateSaveError": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateConflict": "Templates changed in another window. Cancel and reopen the editor before editing again.", + "@tocTemplateConflict": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateDuplicateName": "A template with this name and extension already exists.", + "@tocTemplateDuplicateName": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateFormat": "Format:", + "@tocTemplateFormat": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateReset": "Reset", + "@tocTemplateReset": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocTemplateRetry": "Retry", + "@tocTemplateRetry": {"description": "Writerside template dialog or user-template storage feedback."}, + "tocRemoveElementsDialog": "Remove {count} TOC Elements", + "tocRemoveElementsRetainFiles": "The source files associated with these TOC elements won't be deleted.", + "@tocRemoveElementsDialog": { + "description": "Compact removal confirmation title; counts selected elements, not promoted descendants.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@tocRemoveElementsRetainFiles": { + "description": "Original compact group/multiple TOC removal explanation." + }, + "tocOk": "OK", + "tocSelectExistingTopic": "Select Topic to Add to the Current Instance", + "tocGroupName": "Group Name", + "tocMoveLineUp": "Move Line Up", + "tocMoveLineDown": "Move Line Down", + "tocEmptyMdTopic": "Empty MD Topic", + "@tocEmptyMdTopic": {"description":"Writerside Table of Contents command or dialog text."}, + "tocEmptyXmlTopic": "Empty XML Topic", + "@tocEmptyXmlTopic": {"description":"Writerside Table of Contents command or dialog text."}, + "tocCopySpecial": "Copy Special", + "@tocCopySpecial": {"description":"Writerside Table of Contents command or dialog text."}, + "tocCopyFileName": "Topic File Name '{name}'", + "@tocCopyFileName": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"name":{"type":"String"}}}, + "tocCopyFilePath": "Topic File Path", + "@tocCopyFilePath": {"description":"Writerside Table of Contents command or dialog text."}, + "tocCopyTopicTitle": "Topic Title '{title}'", + "@tocCopyTopicTitle": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"title":{"type":"String"}}}, + "tocCopyElementId": "TOC Element ID '{id}'", + "@tocCopyElementId": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"id":{"type":"String"}}}, + "tocCopyFileNameUnavailable": "Topic File Name", + "@tocCopyFileNameUnavailable": {"description":"Writerside Table of Contents command or dialog text."}, + "tocCopyTopicTitleUnavailable": "Topic Title", + "@tocCopyTopicTitleUnavailable": {"description":"Writerside Table of Contents command or dialog text."}, + "tocCopyElementIdUnavailable": "TOC Element ID", + "@tocCopyElementIdUnavailable": {"description":"Writerside Table of Contents command or dialog text."}, + "tocPreviewTopic": "Preview Topic", + "@tocPreviewTopic": {"description":"Writerside Table of Contents command or dialog text."}, + "tocEditTitleAction": "Edit Title...", + "@tocEditTitleAction": {"description":"Writerside Table of Contents command or dialog text."}, + "tocEditTitleDialog": "Edit Title", + "@tocEditTitleDialog": {"description":"Writerside Table of Contents command or dialog text."}, + "tocRemoveElementAction": "Remove TOC Element...", + "@tocRemoveElementAction": {"description":"Writerside Table of Contents command or dialog text."}, + "tocSetHomePage": "Set as Home Page", + "@tocSetHomePage": {"description":"Writerside Table of Contents command or dialog text."}, + "tocGoToElement": "Go to TOC Element in '{fileName}'", + "@tocGoToElement": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"fileName":{"type":"String"}}}, + "tocSynchronize": "Synchronize TOC and Editor", + "@tocSynchronize": {"description":"Writerside Table of Contents command or dialog text."}, + "tocEmptyGroupAction": "Empty Group", + "@tocEmptyGroupAction": {"description":"Writerside Table of Contents command or dialog text."}, + "tocNewEmptyGroup": "New Empty Group", + "@tocNewEmptyGroup": {"description":"Writerside Table of Contents command or dialog text."}, + "tocGroupAction": "Group", + "@tocGroupAction": {"description":"Writerside Table of Contents command or dialog text."}, + "tocNewGroup": "New Group", + "@tocNewGroup": {"description":"Writerside Table of Contents command or dialog text."}, + "tocSortChildren": "Sort Child Topics Alphabetically", + "@tocSortChildren": {"description":"Writerside Table of Contents command or dialog text."}, + "tocLinkTopicFiles": "Link Topic Files to TOC...", + "@tocLinkTopicFiles": {"description":"Writerside Table of Contents command or dialog text."}, + "tocTitleField": "TOC title:", + "@tocTitleField": {"description":"Writerside Table of Contents command or dialog text."}, + "tocTopicTitleField": "Topic title:", + "@tocTopicTitleField": {"description":"Writerside Table of Contents command or dialog text."}, + "tocAdvancedSettings": "Advanced Settings", + "@tocAdvancedSettings": {"description":"Writerside Table of Contents command or dialog text."}, + "tocInstanceTitleField": "Title for '{instanceId}':", + "@tocInstanceTitleField": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"instanceId":{"type":"String"}}}, + "tocOnlyTitleField": "TOC-only title:", + "@tocOnlyTitleField": {"description":"Writerside Table of Contents command or dialog text."}, + "tocFindUsages": "Find", + "@tocFindUsages": {"description":"Writerside Table of Contents command or dialog text."}, + "tocRefactorMenu": "Refactor", + "@tocRefactorMenu": {"description":"Writerside Table of Contents command or dialog text."}, + "tocSafeDelete": "Safe Delete", + "@tocSafeDelete": {"description":"Writerside Table of Contents command or dialog text."}, + "tocDuplicate": "Duplicate", + "@tocDuplicate": {"description":"Writerside Table of Contents command or dialog text."}, + "tocDuplicateFilename": "Topic Filename:", + "@tocDuplicateFilename": {"description":"Writerside Table of Contents command or dialog text."}, + "tocDuplicateDialog": "Duplicate Topic", + "@tocDuplicateDialog": {"description":"Writerside Table of Contents command or dialog text."}, "pdfTitlePage": "Title page", "@pdfTitlePage": {"description": "PDF-specific title-page settings group."}, "pdfIncludeTitlePage": "Include title page", @@ -724,7 +858,7 @@ }, "files": "Files", "@files": {"description": "Files sidebar tab label."}, - "toc": "Topics", + "toc": "Table of Contents", "@toc": {"description": "Writerside Topics sidebar tab label."}, "tocActions": "TOC actions", "@tocActions": {"description": "Tooltip for the Writerside TOC action menu button."}, @@ -747,7 +881,7 @@ "@newTopic": {"description": "Create new Writerside topic action label."}, "newChildTopic": "New Child Topic", "@newChildTopic": {"description": "Create new child Writerside topic action label."}, - "newSiblingTopic": "New Sibling Topic", + "newSiblingTopic": "New Topic", "@newSiblingTopic": {"description": "Create new sibling Writerside topic action label."}, "renameTopicFile": "Rename Topic File", "@renameTopicFile": {"description": "Context-menu action that renames a Writerside topic file."}, @@ -2053,7 +2187,7 @@ "@newTocLibrary": {"description": "Action that creates a Writerside library instance for reusable TOC sections."}, "editInstance": "Edit instance", "@editInstance": {"description": "Action and dialog title for editing a Writerside instance."}, - "openTocFile": "Open TOC file", + "openTocFile": "Open TOC File", "@openTocFile": {"description": "Action that opens the selected Writerside instance tree file."}, "createInstance": "Create instance", "@createInstance": {"description": "Dialog title for creating a Writerside instance."}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7c9ea5e9..4ba0b351 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Tema desde plantilla...", + "tocSaveAsTemplate": "Guardar como plantilla", + "tocTemplateDialog": "Crear tema desde plantilla", + "tocTemplatesEditor": "Plantillas de archivo y código", + "tocEditTemplates": "Editar plantillas...", + "tocCreateCustomTemplate": "Crear plantilla personalizada...", + "tocTemplateCreated": "Plantilla creada", + "tocTemplateDefault": "Predeterminadas", + "tocTemplateCustom": "Personalizadas", + "tocTemplateFilename": "Nombre de archivo:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Internas", + "tocTemplateUnnamed": "Sin nombre", + "tocTemplateNew": "Nueva plantilla...", + "tocTemplateName": "Nombre:", + "tocTemplateExtension": "Extensión:", + "tocTemplateLoadError": "No se pudieron cargar las plantillas. No se han cambiado las existentes.", + "tocTemplateSaveError": "No se pudieron guardar las plantillas. Los cambios siguen disponibles aquí.", + "tocTemplateConflict": "Las plantillas cambiaron en otra ventana. Cancela y vuelve a abrir el editor.", + "tocTemplateDuplicateName": "Ya existe una plantilla con este nombre y extensión.", + "tocTemplateFormat": "Formato:", + "tocTemplateReset": "Restablecer", + "tocTemplateRetry": "Reintentar", + "tocRemoveElementsDialog": "Eliminar {count} elementos del índice", + "tocRemoveElementsRetainFiles": "No se eliminarán los archivos fuente asociados a estos elementos del índice.", + "tocOk": "Aceptar", + "tocSelectExistingTopic": "Seleccionar un tema para añadir a la instancia actual", + "tocGroupName": "Nombre del grupo", + "tocMoveLineUp": "Mover línea hacia arriba", + "tocMoveLineDown": "Mover línea hacia abajo", + "tocEmptyMdTopic": "Tema MD vacío", + "tocEmptyXmlTopic": "Tema XML vacío", + "tocCopySpecial": "Copia especial", + "tocCopyFileName": "Nombre del archivo del tema '{name}'", + "tocCopyFilePath": "Ruta del archivo del tema", + "tocCopyTopicTitle": "Título del tema '{title}'", + "tocCopyElementId": "ID del elemento del índice '{id}'", + "tocCopyFileNameUnavailable": "Nombre del archivo del tema", + "tocCopyTopicTitleUnavailable": "Título del tema", + "tocCopyElementIdUnavailable": "ID del elemento del índice", + "tocPreviewTopic": "Vista previa del tema", + "tocEditTitleAction": "Editar título...", + "tocEditTitleDialog": "Editar título", + "tocRemoveElementAction": "Quitar elemento del índice...", + "tocSetHomePage": "Establecer como página de inicio", + "tocGoToElement": "Ir al elemento del índice en '{fileName}'", + "tocSynchronize": "Sincronizar índice y editor", + "tocEmptyGroupAction": "Grupo vacío", + "tocNewEmptyGroup": "Nuevo grupo vacío", + "tocGroupAction": "Agrupar", + "tocNewGroup": "Nuevo grupo", + "tocSortChildren": "Ordenar temas secundarios alfabéticamente", + "tocLinkTopicFiles": "Vincular archivos de temas al índice...", + "tocTitleField": "Título del índice:", + "tocTopicTitleField": "Título del tema:", + "tocAdvancedSettings": "Configuración avanzada", + "tocInstanceTitleField": "Título para '{instanceId}':", + "tocOnlyTitleField": "Título exclusivo del índice:", + "tocFindUsages": "Buscar", + "tocRefactorMenu": "Refactorizar", + "tocSafeDelete": "Eliminación segura", + "tocDuplicate": "Duplicar", + "tocDuplicateFilename": "Nombre del archivo del tema:", + "tocDuplicateDialog": "Duplicar tema", "pdfTitlePage": "Portada", "pdfIncludeTitlePage": "Incluir portada", "pdfTitlePageNote": "Esta página no tiene encabezado, pie de página ni número de página visible.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Temas", + "toc": "Tabla de contenidos", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nuevo tema del mismo nivel", + "newSiblingTopic": "Nuevo tema", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -2890,7 +2955,7 @@ "newInstance": "Nueva instancia", "newTocLibrary": "Nueva biblioteca de TOC", "editInstance": "Editar instancia", - "openTocFile": "Abrir archivo de TOC", + "openTocFile": "Abrir archivo del índice", "createInstance": "Crear instancia", "createTocLibrary": "Crear biblioteca de TOC", "instanceContent": "Contenido", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 444e6369..73c6c78e 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Teema mallist...", + "tocSaveAsTemplate": "Salvesta mallina", + "tocTemplateDialog": "Loo teema mallist", + "tocTemplatesEditor": "Faili- ja koodimallid", + "tocEditTemplates": "Muuda malle...", + "tocCreateCustomTemplate": "Loo kohandatud mall...", + "tocTemplateCreated": "Mall loodud", + "tocTemplateDefault": "Vaikimisi", + "tocTemplateCustom": "Kohandatud", + "tocTemplateFilename": "Failinimi:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Sisemised", + "tocTemplateUnnamed": "Nimetu", + "tocTemplateNew": "Uus mall...", + "tocTemplateName": "Nimi:", + "tocTemplateExtension": "Laiend:", + "tocTemplateLoadError": "Malle ei saanud laadida. Olemasolevaid malle pole muudetud.", + "tocTemplateSaveError": "Malle ei saanud salvestada. Muudatused on siin endiselt alles.", + "tocTemplateConflict": "Malle muudeti teises aknas. Tühista ja ava redaktor uuesti.", + "tocTemplateDuplicateName": "Selle nime ja laiendiga mall on juba olemas.", + "tocTemplateFormat": "Vorming:", + "tocTemplateReset": "Lähtesta", + "tocTemplateRetry": "Proovi uuesti", + "tocRemoveElementsDialog": "Eemalda {count} sisukorraelementi", + "tocRemoveElementsRetainFiles": "Nende sisukorraelementidega seotud lähtefaile ei kustutata.", + "tocOk": "OK", + "tocSelectExistingTopic": "Vali praegusesse eksemplari lisatav teema", + "tocGroupName": "Rühma nimi", + "tocMoveLineUp": "Liiguta rida üles", + "tocMoveLineDown": "Liiguta rida alla", + "tocEmptyMdTopic": "Tühi MD-teema", + "tocEmptyXmlTopic": "Tühi XML-teema", + "tocCopySpecial": "Erikopeerimine", + "tocCopyFileName": "Teemafaili nimi '{name}'", + "tocCopyFilePath": "Teemafaili tee", + "tocCopyTopicTitle": "Teema pealkiri '{title}'", + "tocCopyElementId": "Sisukorra elemendi ID '{id}'", + "tocCopyFileNameUnavailable": "Teemafaili nimi", + "tocCopyTopicTitleUnavailable": "Teema pealkiri", + "tocCopyElementIdUnavailable": "Sisukorra elemendi ID", + "tocPreviewTopic": "Teema eelvaade", + "tocEditTitleAction": "Muuda pealkirja...", + "tocEditTitleDialog": "Muuda pealkirja", + "tocRemoveElementAction": "Eemalda sisukorra element...", + "tocSetHomePage": "Määra avaleheks", + "tocGoToElement": "Mine sisukorra elemendi juurde failis '{fileName}'", + "tocSynchronize": "Sünkrooni sisukord ja redaktor", + "tocEmptyGroupAction": "Tühi rühm", + "tocNewEmptyGroup": "Uus tühi rühm", + "tocGroupAction": "Rühmita", + "tocNewGroup": "Uus rühm", + "tocSortChildren": "Sordi alamteemad tähestikuliselt", + "tocLinkTopicFiles": "Lingi teemafailid sisukorraga...", + "tocTitleField": "Sisukorra pealkiri:", + "tocTopicTitleField": "Teema pealkiri:", + "tocAdvancedSettings": "Täpsemad seaded", + "tocInstanceTitleField": "Pealkiri eksemplarile '{instanceId}':", + "tocOnlyTitleField": "Ainult sisukorra pealkiri:", + "tocFindUsages": "Otsi", + "tocRefactorMenu": "Refaktoreeri", + "tocSafeDelete": "Turvaline kustutamine", + "tocDuplicate": "Dubleeri", + "tocDuplicateFilename": "Teemafaili nimi:", + "tocDuplicateDialog": "Dubleeri teema", "pdfTitlePage": "Tiitelleht", "pdfIncludeTitlePage": "Lisa tiitelleht", "pdfTitlePageNote": "Sellel lehel pole päist, jalust ega nähtavat leheküljenumbrit.", @@ -1309,7 +1374,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Teemad", + "toc": "Sisukord", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1357,7 +1422,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Uus samatasemeline teema", + "newSiblingTopic": "Uus teema", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 6c913f01..3f91e034 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "موضوع از الگو...", + "tocSaveAsTemplate": "ذخیره به‌عنوان الگو", + "tocTemplateDialog": "ایجاد موضوع از الگو", + "tocTemplatesEditor": "الگوهای فایل و کد", + "tocEditTemplates": "ویرایش الگوها...", + "tocCreateCustomTemplate": "ایجاد الگوی سفارشی...", + "tocTemplateCreated": "الگو ایجاد شد", + "tocTemplateDefault": "پیش‌فرض", + "tocTemplateCustom": "سفارشی", + "tocTemplateFilename": "نام فایل:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "داخلی", + "tocTemplateUnnamed": "بی‌نام", + "tocTemplateNew": "الگوی جدید...", + "tocTemplateName": "نام:", + "tocTemplateExtension": "پسوند:", + "tocTemplateLoadError": "بارگیری الگوها ممکن نشد. الگوهای موجود تغییر نکرده‌اند.", + "tocTemplateSaveError": "ذخیره الگوها ممکن نشد. ویرایش‌های شما همچنان اینجا هستند.", + "tocTemplateConflict": "الگوها در پنجره دیگری تغییر کرده‌اند. لغو کنید و ویرایشگر را دوباره باز کنید.", + "tocTemplateDuplicateName": "الگویی با این نام و پسوند از قبل وجود دارد.", + "tocTemplateFormat": "قالب:", + "tocTemplateReset": "بازنشانی", + "tocTemplateRetry": "تلاش مجدد", + "tocRemoveElementsDialog": "حذف {count} عنصر فهرست مطالب", + "tocRemoveElementsRetainFiles": "فایل‌های منبع مرتبط با این عناصر فهرست مطالب حذف نخواهند شد.", + "tocOk": "تأیید", + "tocSelectExistingTopic": "موضوعی برای افزودن به نمونهٔ کنونی انتخاب کنید", + "tocGroupName": "نام گروه", + "tocMoveLineUp": "انتقال سطر به بالا", + "tocMoveLineDown": "انتقال سطر به پایین", + "tocEmptyMdTopic": "موضوع MD خالی", + "tocEmptyXmlTopic": "موضوع XML خالی", + "tocCopySpecial": "کپی ویژه", + "tocCopyFileName": "نام پروندهٔ موضوع '{name}'", + "tocCopyFilePath": "مسیر پروندهٔ موضوع", + "tocCopyTopicTitle": "عنوان موضوع '{title}'", + "tocCopyElementId": "شناسهٔ عنصر فهرست مطالب '{id}'", + "tocCopyFileNameUnavailable": "نام پروندهٔ موضوع", + "tocCopyTopicTitleUnavailable": "عنوان موضوع", + "tocCopyElementIdUnavailable": "شناسهٔ عنصر فهرست مطالب", + "tocPreviewTopic": "پیش‌نمایش موضوع", + "tocEditTitleAction": "ویرایش عنوان...", + "tocEditTitleDialog": "ویرایش عنوان", + "tocRemoveElementAction": "حذف عنصر فهرست مطالب...", + "tocSetHomePage": "تنظیم به‌عنوان صفحهٔ اصلی", + "tocGoToElement": "رفتن به عنصر فهرست مطالب در «{fileName}»", + "tocSynchronize": "همگام‌سازی فهرست مطالب و ویرایشگر", + "tocEmptyGroupAction": "گروه خالی", + "tocNewEmptyGroup": "گروه خالی جدید", + "tocGroupAction": "گروه‌بندی", + "tocNewGroup": "گروه جدید", + "tocSortChildren": "مرتب‌سازی الفبایی زیرموضوع‌ها", + "tocLinkTopicFiles": "پیوند پرونده‌های موضوع به فهرست مطالب...", + "tocTitleField": "عنوان فهرست مطالب:", + "tocTopicTitleField": "عنوان موضوع:", + "tocAdvancedSettings": "تنظیمات پیشرفته", + "tocInstanceTitleField": "عنوان برای «{instanceId}»:", + "tocOnlyTitleField": "عنوان مختص فهرست مطالب:", + "tocFindUsages": "یافتن", + "tocRefactorMenu": "بازآرایی", + "tocSafeDelete": "حذف ایمن", + "tocDuplicate": "تکثیر", + "tocDuplicateFilename": "نام پروندهٔ موضوع:", + "tocDuplicateDialog": "تکثیر موضوع", "pdfTitlePage": "صفحهٔ عنوان", "pdfIncludeTitlePage": "افزودن صفحهٔ عنوان", "pdfTitlePageNote": "این صفحه سربرگ، پابرگ یا شمارهٔ صفحهٔ قابل مشاهده ندارد.", @@ -1227,7 +1292,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "موضوعات", + "toc": "فهرست مطالب", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1272,7 +1337,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "موضوع هم‌سطح جدید", + "newSiblingTopic": "موضوع جدید", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -2932,7 +2997,7 @@ "newInstance": "نمونهٔ جدید", "newTocLibrary": "کتابخانهٔ جدید فهرست مطالب", "editInstance": "ویرایش نمونه", - "openTocFile": "باز کردن فایل فهرست مطالب", + "openTocFile": "باز کردن پروندهٔ فهرست مطالب", "createInstance": "ایجاد نمونه", "createTocLibrary": "ایجاد کتابخانهٔ فهرست مطالب", "instanceContent": "محتوا", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e8764803..8afbe257 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Sujet à partir d’un modèle...", + "tocSaveAsTemplate": "Enregistrer comme modèle", + "tocTemplateDialog": "Créer un sujet à partir d’un modèle", + "tocTemplatesEditor": "Modèles de fichiers et de code", + "tocEditTemplates": "Modifier les modèles...", + "tocCreateCustomTemplate": "Créer un modèle personnalisé...", + "tocTemplateCreated": "Modèle créé", + "tocTemplateDefault": "Par défaut", + "tocTemplateCustom": "Personnalisés", + "tocTemplateFilename": "Nom du fichier :", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Internes", + "tocTemplateUnnamed": "Sans nom", + "tocTemplateNew": "Nouveau modèle...", + "tocTemplateName": "Nom :", + "tocTemplateExtension": "Extension :", + "tocTemplateLoadError": "Impossible de charger les modèles. Les modèles existants n’ont pas été modifiés.", + "tocTemplateSaveError": "Impossible d’enregistrer les modèles. Vos modifications sont toujours disponibles ici.", + "tocTemplateConflict": "Les modèles ont changé dans une autre fenêtre. Annulez et rouvrez l’éditeur.", + "tocTemplateDuplicateName": "Un modèle portant ce nom et cette extension existe déjà.", + "tocTemplateFormat": "Format :", + "tocTemplateReset": "Réinitialiser", + "tocTemplateRetry": "Réessayer", + "tocRemoveElementsDialog": "Retirer {count} éléments de la table des matières", + "tocRemoveElementsRetainFiles": "Les fichiers source associés à ces éléments de la table des matières ne seront pas supprimés.", + "tocOk": "OK", + "tocSelectExistingTopic": "Sélectionner un sujet à ajouter à l’instance actuelle", + "tocGroupName": "Nom du groupe", + "tocMoveLineUp": "Déplacer la ligne vers le haut", + "tocMoveLineDown": "Déplacer la ligne vers le bas", + "tocEmptyMdTopic": "Sujet MD vide", + "tocEmptyXmlTopic": "Sujet XML vide", + "tocCopySpecial": "Copie spéciale", + "tocCopyFileName": "Nom du fichier du sujet '{name}'", + "tocCopyFilePath": "Chemin du fichier du sujet", + "tocCopyTopicTitle": "Titre du sujet '{title}'", + "tocCopyElementId": "ID de l’élément de la table des matières '{id}'", + "tocCopyFileNameUnavailable": "Nom du fichier du sujet", + "tocCopyTopicTitleUnavailable": "Titre du sujet", + "tocCopyElementIdUnavailable": "ID de l’élément de la table des matières", + "tocPreviewTopic": "Aperçu du sujet", + "tocEditTitleAction": "Modifier le titre...", + "tocEditTitleDialog": "Modifier le titre", + "tocRemoveElementAction": "Retirer l’élément de la table des matières...", + "tocSetHomePage": "Définir comme page d’accueil", + "tocGoToElement": "Accéder à l’élément de la table des matières dans « {fileName} »", + "tocSynchronize": "Synchroniser la table des matières et l’éditeur", + "tocEmptyGroupAction": "Groupe vide", + "tocNewEmptyGroup": "Nouveau groupe vide", + "tocGroupAction": "Grouper", + "tocNewGroup": "Nouveau groupe", + "tocSortChildren": "Trier les sujets enfants par ordre alphabétique", + "tocLinkTopicFiles": "Lier des fichiers de sujets à la table des matières...", + "tocTitleField": "Titre dans la table des matières:", + "tocTopicTitleField": "Titre du sujet:", + "tocAdvancedSettings": "Paramètres avancés", + "tocInstanceTitleField": "Titre pour « {instanceId} »:", + "tocOnlyTitleField": "Titre propre à la table des matières:", + "tocFindUsages": "Rechercher", + "tocRefactorMenu": "Refactoriser", + "tocSafeDelete": "Suppression sécurisée", + "tocDuplicate": "Dupliquer", + "tocDuplicateFilename": "Nom du fichier du sujet:", + "tocDuplicateDialog": "Dupliquer le sujet", "pdfTitlePage": "Page de titre", "pdfIncludeTitlePage": "Inclure une page de titre", "pdfTitlePageNote": "Cette page ne comporte ni en-tête, ni pied de page, ni numéro de page visible.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Sujets", + "toc": "Table des matières", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nouveau sujet de même niveau", + "newSiblingTopic": "Nouveau sujet", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -2890,7 +2955,7 @@ "newInstance": "Nouvelle instance", "newTocLibrary": "Nouvelle bibliothèque de sommaire", "editInstance": "Modifier l’instance", - "openTocFile": "Ouvrir le fichier de sommaire", + "openTocFile": "Ouvrir le fichier de table des matières", "createInstance": "Créer une instance", "createTocLibrary": "Créer une bibliothèque de sommaire", "instanceContent": "Contenu", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 832121b2..ed507d2c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "टेम्पलेट से विषय...", + "tocSaveAsTemplate": "टेम्पलेट के रूप में सहेजें", + "tocTemplateDialog": "टेम्पलेट से विषय बनाएँ", + "tocTemplatesEditor": "फ़ाइल और कोड टेम्पलेट", + "tocEditTemplates": "टेम्पलेट संपादित करें...", + "tocCreateCustomTemplate": "कस्टम टेम्पलेट बनाएँ...", + "tocTemplateCreated": "टेम्पलेट बनाया गया", + "tocTemplateDefault": "डिफ़ॉल्ट", + "tocTemplateCustom": "कस्टम", + "tocTemplateFilename": "फ़ाइल का नाम:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "आंतरिक", + "tocTemplateUnnamed": "बिना नाम", + "tocTemplateNew": "नया टेम्पलेट...", + "tocTemplateName": "नाम:", + "tocTemplateExtension": "एक्सटेंशन:", + "tocTemplateLoadError": "टेम्पलेट लोड नहीं हो सके। मौजूदा टेम्पलेट बदले नहीं गए हैं।", + "tocTemplateSaveError": "टेम्पलेट सहेजे नहीं जा सके। आपके संपादन अभी भी यहाँ उपलब्ध हैं।", + "tocTemplateConflict": "टेम्पलेट दूसरी विंडो में बदल गए हैं। रद्द करें और संपादक फिर से खोलें।", + "tocTemplateDuplicateName": "इस नाम और एक्सटेंशन वाला टेम्पलेट पहले से मौजूद है।", + "tocTemplateFormat": "प्रारूप:", + "tocTemplateReset": "रीसेट करें", + "tocTemplateRetry": "फिर से कोशिश करें", + "tocRemoveElementsDialog": "विषय-सूची के {count} तत्व हटाएँ", + "tocRemoveElementsRetainFiles": "इन विषय-सूची तत्वों से जुड़ी स्रोत फ़ाइलें नहीं हटाई जाएँगी।", + "tocOk": "ठीक है", + "tocSelectExistingTopic": "वर्तमान इंस्टेंस में जोड़ने के लिए विषय चुनें", + "tocGroupName": "समूह का नाम", + "tocMoveLineUp": "पंक्ति ऊपर ले जाएँ", + "tocMoveLineDown": "पंक्ति नीचे ले जाएँ", + "tocEmptyMdTopic": "खाली MD विषय", + "tocEmptyXmlTopic": "खाली XML विषय", + "tocCopySpecial": "विशेष कॉपी", + "tocCopyFileName": "विषय फ़ाइल का नाम '{name}'", + "tocCopyFilePath": "विषय फ़ाइल का पथ", + "tocCopyTopicTitle": "विषय का शीर्षक '{title}'", + "tocCopyElementId": "विषय-सूची तत्व की ID '{id}'", + "tocCopyFileNameUnavailable": "विषय फ़ाइल का नाम", + "tocCopyTopicTitleUnavailable": "विषय का शीर्षक", + "tocCopyElementIdUnavailable": "विषय-सूची तत्व की ID", + "tocPreviewTopic": "विषय का पूर्वावलोकन", + "tocEditTitleAction": "शीर्षक संपादित करें...", + "tocEditTitleDialog": "शीर्षक संपादित करें", + "tocRemoveElementAction": "विषय-सूची तत्व हटाएँ...", + "tocSetHomePage": "मुखपृष्ठ के रूप में सेट करें", + "tocGoToElement": "'{fileName}' में विषय-सूची तत्व पर जाएँ", + "tocSynchronize": "विषय-सूची और संपादक सिंक्रनाइज़ करें", + "tocEmptyGroupAction": "खाली समूह", + "tocNewEmptyGroup": "नया खाली समूह", + "tocGroupAction": "समूह बनाएँ", + "tocNewGroup": "नया समूह", + "tocSortChildren": "उपविषयों को वर्णानुक्रम में क्रमबद्ध करें", + "tocLinkTopicFiles": "विषय फ़ाइलों को विषय-सूची से लिंक करें...", + "tocTitleField": "विषय-सूची का शीर्षक:", + "tocTopicTitleField": "विषय का शीर्षक:", + "tocAdvancedSettings": "उन्नत सेटिंग्स", + "tocInstanceTitleField": "'{instanceId}' के लिए शीर्षक:", + "tocOnlyTitleField": "केवल विषय-सूची का शीर्षक:", + "tocFindUsages": "खोजें", + "tocRefactorMenu": "रिफ़ैक्टर", + "tocSafeDelete": "सुरक्षित रूप से हटाएँ", + "tocDuplicate": "प्रतिलिपि बनाएँ", + "tocDuplicateFilename": "विषय फ़ाइल का नाम:", + "tocDuplicateDialog": "विषय की प्रतिलिपि बनाएँ", "pdfTitlePage": "शीर्षक पृष्ठ", "pdfIncludeTitlePage": "शीर्षक पृष्ठ शामिल करें", "pdfTitlePageNote": "इस पृष्ठ पर शीर्षलेख, पादलेख या पृष्ठ संख्या नहीं दिखती है।", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "विषय", + "toc": "विषय-सूची", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "समान स्तर पर नया विषय", + "newSiblingTopic": "नया विषय", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 79ade7f0..6cd041b1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Topik dari Templat...", + "tocSaveAsTemplate": "Simpan sebagai Templat", + "tocTemplateDialog": "Buat Topik dari Templat", + "tocTemplatesEditor": "Templat File dan Kode", + "tocEditTemplates": "Edit templat...", + "tocCreateCustomTemplate": "Buat templat khusus...", + "tocTemplateCreated": "Templat dibuat", + "tocTemplateDefault": "Bawaan", + "tocTemplateCustom": "Khusus", + "tocTemplateFilename": "Nama file:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Internal", + "tocTemplateUnnamed": "Tanpa nama", + "tocTemplateNew": "Templat baru...", + "tocTemplateName": "Nama:", + "tocTemplateExtension": "Ekstensi:", + "tocTemplateLoadError": "Tidak dapat memuat templat. Templat yang ada tidak diubah.", + "tocTemplateSaveError": "Tidak dapat menyimpan templat. Edit Anda masih tersedia di sini.", + "tocTemplateConflict": "Templat berubah di jendela lain. Batalkan dan buka kembali editor.", + "tocTemplateDuplicateName": "Templat dengan nama dan ekstensi ini sudah ada.", + "tocTemplateFormat": "Format:", + "tocTemplateReset": "Atur ulang", + "tocTemplateRetry": "Coba lagi", + "tocRemoveElementsDialog": "Hapus {count} Elemen Daftar Isi", + "tocRemoveElementsRetainFiles": "Berkas sumber yang terkait dengan elemen daftar isi ini tidak akan dihapus.", + "tocOk": "OK", + "tocSelectExistingTopic": "Pilih topik untuk ditambahkan ke instans saat ini", + "tocGroupName": "Nama grup", + "tocMoveLineUp": "Pindahkan baris ke atas", + "tocMoveLineDown": "Pindahkan baris ke bawah", + "tocEmptyMdTopic": "Topik MD kosong", + "tocEmptyXmlTopic": "Topik XML kosong", + "tocCopySpecial": "Salin khusus", + "tocCopyFileName": "Nama berkas topik '{name}'", + "tocCopyFilePath": "Jalur berkas topik", + "tocCopyTopicTitle": "Judul topik '{title}'", + "tocCopyElementId": "ID elemen daftar isi '{id}'", + "tocCopyFileNameUnavailable": "Nama berkas topik", + "tocCopyTopicTitleUnavailable": "Judul topik", + "tocCopyElementIdUnavailable": "ID elemen daftar isi", + "tocPreviewTopic": "Pratinjau topik", + "tocEditTitleAction": "Edit judul...", + "tocEditTitleDialog": "Edit judul", + "tocRemoveElementAction": "Hapus elemen daftar isi...", + "tocSetHomePage": "Jadikan halaman utama", + "tocGoToElement": "Buka elemen daftar isi di '{fileName}'", + "tocSynchronize": "Sinkronkan daftar isi dan editor", + "tocEmptyGroupAction": "Grup kosong", + "tocNewEmptyGroup": "Grup kosong baru", + "tocGroupAction": "Kelompokkan", + "tocNewGroup": "Grup baru", + "tocSortChildren": "Urutkan subtopik berdasarkan abjad", + "tocLinkTopicFiles": "Tautkan berkas topik ke daftar isi...", + "tocTitleField": "Judul daftar isi:", + "tocTopicTitleField": "Judul topik:", + "tocAdvancedSettings": "Pengaturan lanjutan", + "tocInstanceTitleField": "Judul untuk '{instanceId}':", + "tocOnlyTitleField": "Judul khusus daftar isi:", + "tocFindUsages": "Cari", + "tocRefactorMenu": "Refaktor", + "tocSafeDelete": "Hapus dengan aman", + "tocDuplicate": "Duplikat", + "tocDuplicateFilename": "Nama berkas topik:", + "tocDuplicateDialog": "Duplikat topik", "pdfTitlePage": "Halaman judul", "pdfIncludeTitlePage": "Sertakan halaman judul", "pdfTitlePageNote": "Halaman ini tidak memiliki header, footer, atau nomor halaman yang terlihat.", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Topik", + "toc": "Daftar Isi", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Topik saudara baru", + "newSiblingTopic": "Topik baru", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -3862,7 +3927,7 @@ "@editInstance": { "description": "Action and dialog title for editing a Writerside instance." }, - "openTocFile": "Buka file TOC", + "openTocFile": "Buka berkas daftar isi", "@openTocFile": { "description": "Action that opens the selected Writerside instance tree file." }, diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 017c05f0..d6b87057 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Argomento da modello...", + "tocSaveAsTemplate": "Salva come modello", + "tocTemplateDialog": "Crea argomento da modello", + "tocTemplatesEditor": "Modelli di file e codice", + "tocEditTemplates": "Modifica modelli...", + "tocCreateCustomTemplate": "Crea modello personalizzato...", + "tocTemplateCreated": "Modello creato", + "tocTemplateDefault": "Predefiniti", + "tocTemplateCustom": "Personalizzati", + "tocTemplateFilename": "Nome file:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Interni", + "tocTemplateUnnamed": "Senza nome", + "tocTemplateNew": "Nuovo modello...", + "tocTemplateName": "Nome:", + "tocTemplateExtension": "Estensione:", + "tocTemplateLoadError": "Impossibile caricare i modelli. I modelli esistenti non sono stati modificati.", + "tocTemplateSaveError": "Impossibile salvare i modelli. Le modifiche sono ancora disponibili qui.", + "tocTemplateConflict": "I modelli sono cambiati in un’altra finestra. Annulla e riapri l’editor.", + "tocTemplateDuplicateName": "Esiste già un modello con questo nome ed estensione.", + "tocTemplateFormat": "Formato:", + "tocTemplateReset": "Ripristina", + "tocTemplateRetry": "Riprova", + "tocRemoveElementsDialog": "Rimuovi {count} elementi dell’indice", + "tocRemoveElementsRetainFiles": "I file sorgente associati a questi elementi dell’indice non verranno eliminati.", + "tocOk": "OK", + "tocSelectExistingTopic": "Seleziona un argomento da aggiungere all’istanza corrente", + "tocGroupName": "Nome del gruppo", + "tocMoveLineUp": "Sposta riga in alto", + "tocMoveLineDown": "Sposta riga in basso", + "tocEmptyMdTopic": "Argomento MD vuoto", + "tocEmptyXmlTopic": "Argomento XML vuoto", + "tocCopySpecial": "Copia speciale", + "tocCopyFileName": "Nome del file dell’argomento '{name}'", + "tocCopyFilePath": "Percorso del file dell’argomento", + "tocCopyTopicTitle": "Titolo dell’argomento '{title}'", + "tocCopyElementId": "ID dell’elemento del sommario '{id}'", + "tocCopyFileNameUnavailable": "Nome del file dell’argomento", + "tocCopyTopicTitleUnavailable": "Titolo dell’argomento", + "tocCopyElementIdUnavailable": "ID dell’elemento del sommario", + "tocPreviewTopic": "Anteprima argomento", + "tocEditTitleAction": "Modifica titolo...", + "tocEditTitleDialog": "Modifica titolo", + "tocRemoveElementAction": "Rimuovi elemento del sommario...", + "tocSetHomePage": "Imposta come pagina iniziale", + "tocGoToElement": "Vai all’elemento del sommario in '{fileName}'", + "tocSynchronize": "Sincronizza sommario ed editor", + "tocEmptyGroupAction": "Gruppo vuoto", + "tocNewEmptyGroup": "Nuovo gruppo vuoto", + "tocGroupAction": "Raggruppa", + "tocNewGroup": "Nuovo gruppo", + "tocSortChildren": "Ordina alfabeticamente gli argomenti secondari", + "tocLinkTopicFiles": "Collega file di argomenti al sommario...", + "tocTitleField": "Titolo nel sommario:", + "tocTopicTitleField": "Titolo dell’argomento:", + "tocAdvancedSettings": "Impostazioni avanzate", + "tocInstanceTitleField": "Titolo per '{instanceId}':", + "tocOnlyTitleField": "Titolo solo nel sommario:", + "tocFindUsages": "Trova", + "tocRefactorMenu": "Refactoring", + "tocSafeDelete": "Eliminazione sicura", + "tocDuplicate": "Duplica", + "tocDuplicateFilename": "Nome del file dell’argomento:", + "tocDuplicateDialog": "Duplica argomento", "pdfTitlePage": "Frontespizio", "pdfIncludeTitlePage": "Includi frontespizio", "pdfTitlePageNote": "Questa pagina non ha intestazione, piè di pagina o numero di pagina visibile.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Argomenti", + "toc": "Sommario", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nuovo argomento allo stesso livello", + "newSiblingTopic": "Nuovo argomento", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2106dfeb..d2f85a53 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "テンプレートからトピック...", + "tocSaveAsTemplate": "テンプレートとして保存", + "tocTemplateDialog": "テンプレートからトピックを作成", + "tocTemplatesEditor": "ファイルおよびコードテンプレート", + "tocEditTemplates": "テンプレートを編集...", + "tocCreateCustomTemplate": "カスタムテンプレートを作成...", + "tocTemplateCreated": "テンプレートを作成しました", + "tocTemplateDefault": "デフォルト", + "tocTemplateCustom": "カスタム", + "tocTemplateFilename": "ファイル名:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "内部", + "tocTemplateUnnamed": "無題", + "tocTemplateNew": "新規テンプレート...", + "tocTemplateName": "名前:", + "tocTemplateExtension": "拡張子:", + "tocTemplateLoadError": "テンプレートを読み込めませんでした。既存のテンプレートは変更されていません。", + "tocTemplateSaveError": "テンプレートを保存できませんでした。編集内容はここに保持されています。", + "tocTemplateConflict": "別のウィンドウでテンプレートが変更されました。キャンセルしてエディターを開き直してください。", + "tocTemplateDuplicateName": "この名前と拡張子のテンプレートは既に存在します。", + "tocTemplateFormat": "形式:", + "tocTemplateReset": "リセット", + "tocTemplateRetry": "再試行", + "tocRemoveElementsDialog": "目次要素 {count} 個を削除", + "tocRemoveElementsRetainFiles": "これらの目次要素に関連付けられたソースファイルは削除されません。", + "tocOk": "OK", + "tocSelectExistingTopic": "現在のインスタンスに追加するトピックを選択", + "tocGroupName": "グループ名", + "tocMoveLineUp": "行を上に移動", + "tocMoveLineDown": "行を下に移動", + "tocEmptyMdTopic": "空の MD トピック", + "tocEmptyXmlTopic": "空の XML トピック", + "tocCopySpecial": "特殊コピー", + "tocCopyFileName": "トピックのファイル名 '{name}'", + "tocCopyFilePath": "トピックのファイルパス", + "tocCopyTopicTitle": "トピックのタイトル '{title}'", + "tocCopyElementId": "目次要素 ID '{id}'", + "tocCopyFileNameUnavailable": "トピックのファイル名", + "tocCopyTopicTitleUnavailable": "トピックのタイトル", + "tocCopyElementIdUnavailable": "目次要素 ID", + "tocPreviewTopic": "トピックをプレビュー", + "tocEditTitleAction": "タイトルを編集...", + "tocEditTitleDialog": "タイトルを編集", + "tocRemoveElementAction": "目次要素を削除...", + "tocSetHomePage": "ホームページに設定", + "tocGoToElement": "「{fileName}」の目次要素に移動", + "tocSynchronize": "目次とエディターを同期", + "tocEmptyGroupAction": "空のグループ", + "tocNewEmptyGroup": "新しい空のグループ", + "tocGroupAction": "グループ化", + "tocNewGroup": "新しいグループ", + "tocSortChildren": "子トピックをアルファベット順に並べ替え", + "tocLinkTopicFiles": "トピックファイルを目次にリンク...", + "tocTitleField": "目次タイトル:", + "tocTopicTitleField": "トピックのタイトル:", + "tocAdvancedSettings": "詳細設定", + "tocInstanceTitleField": "「{instanceId}」のタイトル:", + "tocOnlyTitleField": "目次専用タイトル:", + "tocFindUsages": "検索", + "tocRefactorMenu": "リファクタリング", + "tocSafeDelete": "安全な削除", + "tocDuplicate": "複製", + "tocDuplicateFilename": "トピックのファイル名:", + "tocDuplicateDialog": "トピックを複製", "pdfTitlePage": "タイトルページ", "pdfIncludeTitlePage": "タイトルページを含める", "pdfTitlePageNote": "このページにはヘッダー、フッター、ページ番号は表示されません。", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "トピック", + "toc": "目次", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "新しい兄弟トピック", + "newSiblingTopic": "新しいトピック", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1030ff5d..f32ccc23 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "템플릿에서 토픽...", + "tocSaveAsTemplate": "템플릿으로 저장", + "tocTemplateDialog": "템플릿에서 토픽 만들기", + "tocTemplatesEditor": "파일 및 코드 템플릿", + "tocEditTemplates": "템플릿 편집...", + "tocCreateCustomTemplate": "사용자 지정 템플릿 만들기...", + "tocTemplateCreated": "템플릿 생성됨", + "tocTemplateDefault": "기본", + "tocTemplateCustom": "사용자 지정", + "tocTemplateFilename": "파일 이름:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "내부", + "tocTemplateUnnamed": "이름 없음", + "tocTemplateNew": "새 템플릿...", + "tocTemplateName": "이름:", + "tocTemplateExtension": "확장자:", + "tocTemplateLoadError": "템플릿을 불러올 수 없습니다. 기존 템플릿은 변경되지 않았습니다.", + "tocTemplateSaveError": "템플릿을 저장할 수 없습니다. 편집 내용은 여기에 유지됩니다.", + "tocTemplateConflict": "다른 창에서 템플릿이 변경되었습니다. 취소하고 편집기를 다시 여세요.", + "tocTemplateDuplicateName": "이 이름과 확장자를 가진 템플릿이 이미 있습니다.", + "tocTemplateFormat": "형식:", + "tocTemplateReset": "초기화", + "tocTemplateRetry": "다시 시도", + "tocRemoveElementsDialog": "목차 요소 {count}개 제거", + "tocRemoveElementsRetainFiles": "이 목차 요소와 연결된 소스 파일은 삭제되지 않습니다.", + "tocOk": "확인", + "tocSelectExistingTopic": "현재 인스턴스에 추가할 토픽 선택", + "tocGroupName": "그룹 이름", + "tocMoveLineUp": "줄 위로 이동", + "tocMoveLineDown": "줄 아래로 이동", + "tocEmptyMdTopic": "빈 MD 토픽", + "tocEmptyXmlTopic": "빈 XML 토픽", + "tocCopySpecial": "특수 복사", + "tocCopyFileName": "토픽 파일 이름 '{name}'", + "tocCopyFilePath": "토픽 파일 경로", + "tocCopyTopicTitle": "토픽 제목 '{title}'", + "tocCopyElementId": "목차 요소 ID '{id}'", + "tocCopyFileNameUnavailable": "토픽 파일 이름", + "tocCopyTopicTitleUnavailable": "토픽 제목", + "tocCopyElementIdUnavailable": "목차 요소 ID", + "tocPreviewTopic": "토픽 미리보기", + "tocEditTitleAction": "제목 편집...", + "tocEditTitleDialog": "제목 편집", + "tocRemoveElementAction": "목차 요소 제거...", + "tocSetHomePage": "홈 페이지로 설정", + "tocGoToElement": "'{fileName}'의 목차 요소로 이동", + "tocSynchronize": "목차와 편집기 동기화", + "tocEmptyGroupAction": "빈 그룹", + "tocNewEmptyGroup": "새 빈 그룹", + "tocGroupAction": "그룹화", + "tocNewGroup": "새 그룹", + "tocSortChildren": "하위 토픽을 알파벳순으로 정렬", + "tocLinkTopicFiles": "토픽 파일을 목차에 연결...", + "tocTitleField": "목차 제목:", + "tocTopicTitleField": "토픽 제목:", + "tocAdvancedSettings": "고급 설정", + "tocInstanceTitleField": "'{instanceId}'의 제목:", + "tocOnlyTitleField": "목차 전용 제목:", + "tocFindUsages": "찾기", + "tocRefactorMenu": "리팩터링", + "tocSafeDelete": "안전하게 삭제", + "tocDuplicate": "복제", + "tocDuplicateFilename": "토픽 파일 이름:", + "tocDuplicateDialog": "토픽 복제", "pdfTitlePage": "제목 페이지", "pdfIncludeTitlePage": "제목 페이지 포함", "pdfTitlePageNote": "이 페이지에는 머리글, 바닥글 또는 페이지 번호가 표시되지 않습니다.", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "토픽", + "toc": "목차", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "새 형제 토픽", + "newSiblingTopic": "새 토픽", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 6939d0d8..e74d4b7e 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Emne fra mal...", + "tocSaveAsTemplate": "Lagre som mal", + "tocTemplateDialog": "Opprett emne fra mal", + "tocTemplatesEditor": "Fil- og kodemaler", + "tocEditTemplates": "Rediger maler...", + "tocCreateCustomTemplate": "Opprett egendefinert mal...", + "tocTemplateCreated": "Mal opprettet", + "tocTemplateDefault": "Standard", + "tocTemplateCustom": "Egendefinerte", + "tocTemplateFilename": "Filnavn:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Interne", + "tocTemplateUnnamed": "Uten navn", + "tocTemplateNew": "Ny mal...", + "tocTemplateName": "Navn:", + "tocTemplateExtension": "Filtype:", + "tocTemplateLoadError": "Kunne ikke laste maler. Eksisterende maler er ikke endret.", + "tocTemplateSaveError": "Kunne ikke lagre maler. Endringene dine er fortsatt tilgjengelige her.", + "tocTemplateConflict": "Malene ble endret i et annet vindu. Avbryt og åpne redigereren på nytt.", + "tocTemplateDuplicateName": "En mal med dette navnet og denne filtypen finnes allerede.", + "tocTemplateFormat": "Format:", + "tocTemplateReset": "Tilbakestill", + "tocTemplateRetry": "Prøv igjen", + "tocRemoveElementsDialog": "Fjern {count} innholdsfortegnelseselementer", + "tocRemoveElementsRetainFiles": "Kildefilene som er knyttet til disse innholdsfortegnelseselementene, blir ikke slettet.", + "tocOk": "OK", + "tocSelectExistingTopic": "Velg emne som skal legges til i gjeldende instans", + "tocGroupName": "Gruppenavn", + "tocMoveLineUp": "Flytt linje opp", + "tocMoveLineDown": "Flytt linje ned", + "tocEmptyMdTopic": "Tomt MD-emne", + "tocEmptyXmlTopic": "Tomt XML-emne", + "tocCopySpecial": "Spesiell kopiering", + "tocCopyFileName": "Emnefilnavn '{name}'", + "tocCopyFilePath": "Emnefilbane", + "tocCopyTopicTitle": "Emnetittel '{title}'", + "tocCopyElementId": "ID for innholdsfortegnelseselement '{id}'", + "tocCopyFileNameUnavailable": "Emnefilnavn", + "tocCopyTopicTitleUnavailable": "Emnetittel", + "tocCopyElementIdUnavailable": "ID for innholdsfortegnelseselement", + "tocPreviewTopic": "Forhåndsvis emne", + "tocEditTitleAction": "Rediger tittel...", + "tocEditTitleDialog": "Rediger tittel", + "tocRemoveElementAction": "Fjern innholdsfortegnelseselement...", + "tocSetHomePage": "Angi som startside", + "tocGoToElement": "Gå til innholdsfortegnelseselement i '{fileName}'", + "tocSynchronize": "Synkroniser innholdsfortegnelse og redigering", + "tocEmptyGroupAction": "Tom gruppe", + "tocNewEmptyGroup": "Ny tom gruppe", + "tocGroupAction": "Grupper", + "tocNewGroup": "Ny gruppe", + "tocSortChildren": "Sorter underemner alfabetisk", + "tocLinkTopicFiles": "Koble emnefiler til innholdsfortegnelsen...", + "tocTitleField": "Tittel i innholdsfortegnelsen:", + "tocTopicTitleField": "Emnetittel:", + "tocAdvancedSettings": "Avanserte innstillinger", + "tocInstanceTitleField": "Tittel for '{instanceId}':", + "tocOnlyTitleField": "Tittel kun for innholdsfortegnelsen:", + "tocFindUsages": "Finn", + "tocRefactorMenu": "Refaktorer", + "tocSafeDelete": "Sikker sletting", + "tocDuplicate": "Dupliser", + "tocDuplicateFilename": "Emnefilnavn:", + "tocDuplicateDialog": "Dupliser emne", "pdfTitlePage": "Tittelside", "pdfIncludeTitlePage": "Ta med tittelside", "pdfTitlePageNote": "Denne siden har ingen topptekst, bunntekst eller synlig sidetall.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Emner", + "toc": "Innholdsfortegnelse", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nytt emne på samme nivå", + "newSiblingTopic": "Nytt emne", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 5471aa44..da2b3b27 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Onderwerp uit sjabloon...", + "tocSaveAsTemplate": "Opslaan als sjabloon", + "tocTemplateDialog": "Onderwerp uit sjabloon maken", + "tocTemplatesEditor": "Bestands- en codesjablonen", + "tocEditTemplates": "Sjablonen bewerken...", + "tocCreateCustomTemplate": "Aangepast sjabloon maken...", + "tocTemplateCreated": "Sjabloon gemaakt", + "tocTemplateDefault": "Standaard", + "tocTemplateCustom": "Aangepast", + "tocTemplateFilename": "Bestandsnaam:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Intern", + "tocTemplateUnnamed": "Naamloos", + "tocTemplateNew": "Nieuw sjabloon...", + "tocTemplateName": "Naam:", + "tocTemplateExtension": "Extensie:", + "tocTemplateLoadError": "Sjablonen konden niet worden geladen. Bestaande sjablonen zijn niet gewijzigd.", + "tocTemplateSaveError": "Sjablonen konden niet worden opgeslagen. Uw wijzigingen zijn hier nog beschikbaar.", + "tocTemplateConflict": "Sjablonen zijn in een ander venster gewijzigd. Annuleer en open de editor opnieuw.", + "tocTemplateDuplicateName": "Er bestaat al een sjabloon met deze naam en extensie.", + "tocTemplateFormat": "Indeling:", + "tocTemplateReset": "Herstellen", + "tocTemplateRetry": "Opnieuw proberen", + "tocRemoveElementsDialog": "{count} inhoudsopgave-elementen verwijderen", + "tocRemoveElementsRetainFiles": "De bronbestanden die aan deze inhoudsopgave-elementen zijn gekoppeld, worden niet verwijderd.", + "tocOk": "OK", + "tocSelectExistingTopic": "Onderwerp selecteren om aan de huidige instantie toe te voegen", + "tocGroupName": "Groepsnaam", + "tocMoveLineUp": "Regel omhoog verplaatsen", + "tocMoveLineDown": "Regel omlaag verplaatsen", + "tocEmptyMdTopic": "Leeg MD-onderwerp", + "tocEmptyXmlTopic": "Leeg XML-onderwerp", + "tocCopySpecial": "Speciaal kopiëren", + "tocCopyFileName": "Onderwerpbestandsnaam '{name}'", + "tocCopyFilePath": "Onderwerpbestandspad", + "tocCopyTopicTitle": "Onderwerptitel '{title}'", + "tocCopyElementId": "ID van inhoudsopgave-element '{id}'", + "tocCopyFileNameUnavailable": "Onderwerpbestandsnaam", + "tocCopyTopicTitleUnavailable": "Onderwerptitel", + "tocCopyElementIdUnavailable": "ID van inhoudsopgave-element", + "tocPreviewTopic": "Onderwerpvoorbeeld", + "tocEditTitleAction": "Titel bewerken...", + "tocEditTitleDialog": "Titel bewerken", + "tocRemoveElementAction": "Inhoudsopgave-element verwijderen...", + "tocSetHomePage": "Als startpagina instellen", + "tocGoToElement": "Naar inhoudsopgave-element in '{fileName}' gaan", + "tocSynchronize": "Inhoudsopgave en editor synchroniseren", + "tocEmptyGroupAction": "Lege groep", + "tocNewEmptyGroup": "Nieuwe lege groep", + "tocGroupAction": "Groeperen", + "tocNewGroup": "Nieuwe groep", + "tocSortChildren": "Onderliggende onderwerpen alfabetisch sorteren", + "tocLinkTopicFiles": "Onderwerpbestanden aan inhoudsopgave koppelen...", + "tocTitleField": "Titel in inhoudsopgave:", + "tocTopicTitleField": "Onderwerptitel:", + "tocAdvancedSettings": "Geavanceerde instellingen", + "tocInstanceTitleField": "Titel voor '{instanceId}':", + "tocOnlyTitleField": "Titel alleen voor inhoudsopgave:", + "tocFindUsages": "Zoeken", + "tocRefactorMenu": "Refactoren", + "tocSafeDelete": "Veilig verwijderen", + "tocDuplicate": "Dupliceren", + "tocDuplicateFilename": "Onderwerpbestandsnaam:", + "tocDuplicateDialog": "Onderwerp dupliceren", "pdfTitlePage": "Titelpagina", "pdfIncludeTitlePage": "Titelpagina opnemen", "pdfTitlePageNote": "Deze pagina heeft geen koptekst, voettekst of zichtbaar paginanummer.", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Onderwerpen", + "toc": "Inhoudsopgave", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nieuw naastliggend onderwerp", + "newSiblingTopic": "Nieuw onderwerp", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -3862,7 +3927,7 @@ "@editInstance": { "description": "Action and dialog title for editing a Writerside instance." }, - "openTocFile": "TOC-bestand openen", + "openTocFile": "Inhoudsopgavebestand openen", "@openTocFile": { "description": "Action that opens the selected Writerside instance tree file." }, diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a07f5916..409aaa6b 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Temat z szablonu...", + "tocSaveAsTemplate": "Zapisz jako szablon", + "tocTemplateDialog": "Utwórz temat z szablonu", + "tocTemplatesEditor": "Szablony plików i kodu", + "tocEditTemplates": "Edytuj szablony...", + "tocCreateCustomTemplate": "Utwórz własny szablon...", + "tocTemplateCreated": "Utworzono szablon", + "tocTemplateDefault": "Domyślne", + "tocTemplateCustom": "Własne", + "tocTemplateFilename": "Nazwa pliku:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Wewnętrzne", + "tocTemplateUnnamed": "Bez nazwy", + "tocTemplateNew": "Nowy szablon...", + "tocTemplateName": "Nazwa:", + "tocTemplateExtension": "Rozszerzenie:", + "tocTemplateLoadError": "Nie można wczytać szablonów. Istniejące szablony nie zostały zmienione.", + "tocTemplateSaveError": "Nie można zapisać szablonów. Twoje zmiany są nadal dostępne tutaj.", + "tocTemplateConflict": "Szablony zmieniono w innym oknie. Anuluj i ponownie otwórz edytor.", + "tocTemplateDuplicateName": "Szablon o tej nazwie i rozszerzeniu już istnieje.", + "tocTemplateFormat": "Format:", + "tocTemplateReset": "Przywróć", + "tocTemplateRetry": "Ponów", + "tocRemoveElementsDialog": "Usuń {count} elementów spisu treści", + "tocRemoveElementsRetainFiles": "Pliki źródłowe powiązane z tymi elementami spisu treści nie zostaną usunięte.", + "tocOk": "OK", + "tocSelectExistingTopic": "Wybierz temat do dodania do bieżącej instancji", + "tocGroupName": "Nazwa grupy", + "tocMoveLineUp": "Przenieś wiersz w górę", + "tocMoveLineDown": "Przenieś wiersz w dół", + "tocEmptyMdTopic": "Pusty temat MD", + "tocEmptyXmlTopic": "Pusty temat XML", + "tocCopySpecial": "Kopiowanie specjalne", + "tocCopyFileName": "Nazwa pliku tematu '{name}'", + "tocCopyFilePath": "Ścieżka pliku tematu", + "tocCopyTopicTitle": "Tytuł tematu '{title}'", + "tocCopyElementId": "ID elementu spisu treści '{id}'", + "tocCopyFileNameUnavailable": "Nazwa pliku tematu", + "tocCopyTopicTitleUnavailable": "Tytuł tematu", + "tocCopyElementIdUnavailable": "ID elementu spisu treści", + "tocPreviewTopic": "Podgląd tematu", + "tocEditTitleAction": "Edytuj tytuł...", + "tocEditTitleDialog": "Edytuj tytuł", + "tocRemoveElementAction": "Usuń element spisu treści...", + "tocSetHomePage": "Ustaw jako stronę główną", + "tocGoToElement": "Przejdź do elementu spisu treści w „{fileName}”", + "tocSynchronize": "Synchronizuj spis treści i edytor", + "tocEmptyGroupAction": "Pusta grupa", + "tocNewEmptyGroup": "Nowa pusta grupa", + "tocGroupAction": "Grupuj", + "tocNewGroup": "Nowa grupa", + "tocSortChildren": "Sortuj tematy podrzędne alfabetycznie", + "tocLinkTopicFiles": "Połącz pliki tematów ze spisem treści...", + "tocTitleField": "Tytuł w spisie treści:", + "tocTopicTitleField": "Tytuł tematu:", + "tocAdvancedSettings": "Ustawienia zaawansowane", + "tocInstanceTitleField": "Tytuł dla „{instanceId}”:", + "tocOnlyTitleField": "Tytuł tylko w spisie treści:", + "tocFindUsages": "Znajdź", + "tocRefactorMenu": "Refaktoryzacja", + "tocSafeDelete": "Bezpieczne usuwanie", + "tocDuplicate": "Duplikuj", + "tocDuplicateFilename": "Nazwa pliku tematu:", + "tocDuplicateDialog": "Duplikuj temat", "pdfTitlePage": "Strona tytułowa", "pdfIncludeTitlePage": "Dołącz stronę tytułową", "pdfTitlePageNote": "Ta strona nie zawiera nagłówka, stopki ani widocznego numeru strony.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Tematy", + "toc": "Spis treści", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Nowy temat równorzędny", + "newSiblingTopic": "Nowy temat", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cea8ffdd..6ea67a61 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Tópico a partir de modelo...", + "tocSaveAsTemplate": "Guardar como modelo", + "tocTemplateDialog": "Criar tópico a partir de modelo", + "tocTemplatesEditor": "Modelos de ficheiros e código", + "tocEditTemplates": "Editar modelos...", + "tocCreateCustomTemplate": "Criar modelo personalizado...", + "tocTemplateCreated": "Modelo criado", + "tocTemplateDefault": "Predefinidos", + "tocTemplateCustom": "Personalizados", + "tocTemplateFilename": "Nome do ficheiro:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Internos", + "tocTemplateUnnamed": "Sem nome", + "tocTemplateNew": "Novo modelo...", + "tocTemplateName": "Nome:", + "tocTemplateExtension": "Extensão:", + "tocTemplateLoadError": "Não foi possível carregar os modelos. Os modelos existentes não foram alterados.", + "tocTemplateSaveError": "Não foi possível guardar os modelos. As alterações continuam disponíveis aqui.", + "tocTemplateConflict": "Os modelos foram alterados noutra janela. Cancele e reabra o editor.", + "tocTemplateDuplicateName": "Já existe um modelo com este nome e extensão.", + "tocTemplateFormat": "Formato:", + "tocTemplateReset": "Repor", + "tocTemplateRetry": "Tentar novamente", + "tocRemoveElementsDialog": "Remover {count} elementos do índice", + "tocRemoveElementsRetainFiles": "Os ficheiros de origem associados a estes elementos do índice não serão eliminados.", + "tocOk": "OK", + "tocSelectExistingTopic": "Selecionar tópico para adicionar à instância atual", + "tocGroupName": "Nome do grupo", + "tocMoveLineUp": "Mover linha para cima", + "tocMoveLineDown": "Mover linha para baixo", + "tocEmptyMdTopic": "Tópico MD vazio", + "tocEmptyXmlTopic": "Tópico XML vazio", + "tocCopySpecial": "Cópia especial", + "tocCopyFileName": "Nome do ficheiro do tópico '{name}'", + "tocCopyFilePath": "Caminho do ficheiro do tópico", + "tocCopyTopicTitle": "Título do tópico '{title}'", + "tocCopyElementId": "ID do elemento do índice '{id}'", + "tocCopyFileNameUnavailable": "Nome do ficheiro do tópico", + "tocCopyTopicTitleUnavailable": "Título do tópico", + "tocCopyElementIdUnavailable": "ID do elemento do índice", + "tocPreviewTopic": "Pré-visualizar tópico", + "tocEditTitleAction": "Editar título...", + "tocEditTitleDialog": "Editar título", + "tocRemoveElementAction": "Remover elemento do índice...", + "tocSetHomePage": "Definir como página inicial", + "tocGoToElement": "Ir para o elemento do índice em '{fileName}'", + "tocSynchronize": "Sincronizar índice e editor", + "tocEmptyGroupAction": "Grupo vazio", + "tocNewEmptyGroup": "Novo grupo vazio", + "tocGroupAction": "Agrupar", + "tocNewGroup": "Novo grupo", + "tocSortChildren": "Ordenar subtópicos alfabeticamente", + "tocLinkTopicFiles": "Ligar ficheiros de tópicos ao índice...", + "tocTitleField": "Título no índice:", + "tocTopicTitleField": "Título do tópico:", + "tocAdvancedSettings": "Definições avançadas", + "tocInstanceTitleField": "Título para '{instanceId}':", + "tocOnlyTitleField": "Título exclusivo do índice:", + "tocFindUsages": "Localizar", + "tocRefactorMenu": "Refatorizar", + "tocSafeDelete": "Eliminação segura", + "tocDuplicate": "Duplicar", + "tocDuplicateFilename": "Nome do ficheiro do tópico:", + "tocDuplicateDialog": "Duplicar tópico", "pdfTitlePage": "Página de título", "pdfIncludeTitlePage": "Incluir página de título", "pdfTitlePageNote": "Esta página não tem cabeçalho, rodapé nem número de página visível.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Tópicos", + "toc": "Índice", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Novo tópico no mesmo nível", + "newSiblingTopic": "Novo tópico", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index f55acc93..79bfa3b5 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Tópico a partir de modelo...", + "tocSaveAsTemplate": "Salvar como modelo", + "tocTemplateDialog": "Criar tópico a partir de modelo", + "tocTemplatesEditor": "Modelos de arquivos e código", + "tocEditTemplates": "Editar modelos...", + "tocCreateCustomTemplate": "Criar modelo personalizado...", + "tocTemplateCreated": "Modelo criado", + "tocTemplateDefault": "Padrão", + "tocTemplateCustom": "Personalizados", + "tocTemplateFilename": "Nome do arquivo:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Internos", + "tocTemplateUnnamed": "Sem nome", + "tocTemplateNew": "Novo modelo...", + "tocTemplateName": "Nome:", + "tocTemplateExtension": "Extensão:", + "tocTemplateLoadError": "Não foi possível carregar os modelos. Os modelos existentes não foram alterados.", + "tocTemplateSaveError": "Não foi possível salvar os modelos. Suas alterações continuam disponíveis aqui.", + "tocTemplateConflict": "Os modelos foram alterados em outra janela. Cancele e reabra o editor.", + "tocTemplateDuplicateName": "Já existe um modelo com este nome e extensão.", + "tocTemplateFormat": "Formato:", + "tocTemplateReset": "Redefinir", + "tocTemplateRetry": "Tentar novamente", + "tocRemoveElementsDialog": "Remover {count} elementos do sumário", + "tocRemoveElementsRetainFiles": "Os arquivos de origem associados a estes elementos do sumário não serão excluídos.", + "tocOk": "OK", + "tocSelectExistingTopic": "Selecionar tópico para adicionar à instância atual", + "tocGroupName": "Nome do grupo", + "tocMoveLineUp": "Mover linha para cima", + "tocMoveLineDown": "Mover linha para baixo", + "tocEmptyMdTopic": "Tópico MD vazio", + "tocEmptyXmlTopic": "Tópico XML vazio", + "tocCopySpecial": "Cópia especial", + "tocCopyFileName": "Nome do arquivo do tópico '{name}'", + "tocCopyFilePath": "Caminho do arquivo do tópico", + "tocCopyTopicTitle": "Título do tópico '{title}'", + "tocCopyElementId": "ID do elemento do índice '{id}'", + "tocCopyFileNameUnavailable": "Nome do arquivo do tópico", + "tocCopyTopicTitleUnavailable": "Título do tópico", + "tocCopyElementIdUnavailable": "ID do elemento do índice", + "tocPreviewTopic": "Pré-visualizar tópico", + "tocEditTitleAction": "Editar título...", + "tocEditTitleDialog": "Editar título", + "tocRemoveElementAction": "Remover elemento do índice...", + "tocSetHomePage": "Definir como página inicial", + "tocGoToElement": "Ir para o elemento do índice em '{fileName}'", + "tocSynchronize": "Sincronizar índice e editor", + "tocEmptyGroupAction": "Grupo vazio", + "tocNewEmptyGroup": "Novo grupo vazio", + "tocGroupAction": "Agrupar", + "tocNewGroup": "Novo grupo", + "tocSortChildren": "Ordenar subtópicos alfabeticamente", + "tocLinkTopicFiles": "Vincular arquivos de tópicos ao índice...", + "tocTitleField": "Título no índice:", + "tocTopicTitleField": "Título do tópico:", + "tocAdvancedSettings": "Configurações avançadas", + "tocInstanceTitleField": "Título para '{instanceId}':", + "tocOnlyTitleField": "Título exclusivo do índice:", + "tocFindUsages": "Localizar", + "tocRefactorMenu": "Refatorar", + "tocSafeDelete": "Exclusão segura", + "tocDuplicate": "Duplicar", + "tocDuplicateFilename": "Nome do arquivo do tópico:", + "tocDuplicateDialog": "Duplicar tópico", "pdfTitlePage": "Página de título", "pdfIncludeTitlePage": "Incluir página de título", "pdfTitlePageNote": "Esta página não tem cabeçalho, rodapé nem número de página visível.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Tópicos", + "toc": "Índice", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Novo tópico no mesmo nível", + "newSiblingTopic": "Novo tópico", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7662d59c..852e73c0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Тема из шаблона...", + "tocSaveAsTemplate": "Сохранить как шаблон", + "tocTemplateDialog": "Создать тему из шаблона", + "tocTemplatesEditor": "Шаблоны файлов и кода", + "tocEditTemplates": "Изменить шаблоны...", + "tocCreateCustomTemplate": "Создать пользовательский шаблон...", + "tocTemplateCreated": "Шаблон создан", + "tocTemplateDefault": "Стандартные", + "tocTemplateCustom": "Пользовательские", + "tocTemplateFilename": "Имя файла:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Внутренние", + "tocTemplateUnnamed": "Без имени", + "tocTemplateNew": "Новый шаблон...", + "tocTemplateName": "Имя:", + "tocTemplateExtension": "Расширение:", + "tocTemplateLoadError": "Не удалось загрузить шаблоны. Существующие шаблоны не изменены.", + "tocTemplateSaveError": "Не удалось сохранить шаблоны. Ваши изменения остались в редакторе.", + "tocTemplateConflict": "Шаблоны изменены в другом окне. Отмените изменения и снова откройте редактор.", + "tocTemplateDuplicateName": "Шаблон с таким именем и расширением уже существует.", + "tocTemplateFormat": "Формат:", + "tocTemplateReset": "Сбросить", + "tocTemplateRetry": "Повторить", + "tocRemoveElementsDialog": "Удалить {count} элементов оглавления", + "tocRemoveElementsRetainFiles": "Исходные файлы, связанные с этими элементами оглавления, не будут удалены.", + "tocOk": "ОК", + "tocSelectExistingTopic": "Выберите тему для добавления в текущий экземпляр", + "tocGroupName": "Название группы", + "tocMoveLineUp": "Переместить строку вверх", + "tocMoveLineDown": "Переместить строку вниз", + "tocEmptyMdTopic": "Пустая тема MD", + "tocEmptyXmlTopic": "Пустая тема XML", + "tocCopySpecial": "Специальное копирование", + "tocCopyFileName": "Имя файла темы '{name}'", + "tocCopyFilePath": "Путь к файлу темы", + "tocCopyTopicTitle": "Заголовок темы '{title}'", + "tocCopyElementId": "ID элемента оглавления '{id}'", + "tocCopyFileNameUnavailable": "Имя файла темы", + "tocCopyTopicTitleUnavailable": "Заголовок темы", + "tocCopyElementIdUnavailable": "ID элемента оглавления", + "tocPreviewTopic": "Предпросмотр темы", + "tocEditTitleAction": "Изменить заголовок...", + "tocEditTitleDialog": "Изменить заголовок", + "tocRemoveElementAction": "Удалить элемент оглавления...", + "tocSetHomePage": "Назначить домашней страницей", + "tocGoToElement": "Перейти к элементу оглавления в «{fileName}»", + "tocSynchronize": "Синхронизировать оглавление и редактор", + "tocEmptyGroupAction": "Пустая группа", + "tocNewEmptyGroup": "Новая пустая группа", + "tocGroupAction": "Сгруппировать", + "tocNewGroup": "Новая группа", + "tocSortChildren": "Сортировать дочерние темы по алфавиту", + "tocLinkTopicFiles": "Связать файлы тем с оглавлением...", + "tocTitleField": "Заголовок в оглавлении:", + "tocTopicTitleField": "Заголовок темы:", + "tocAdvancedSettings": "Дополнительные настройки", + "tocInstanceTitleField": "Заголовок для «{instanceId}»:", + "tocOnlyTitleField": "Заголовок только для оглавления:", + "tocFindUsages": "Поиск", + "tocRefactorMenu": "Рефакторинг", + "tocSafeDelete": "Безопасное удаление", + "tocDuplicate": "Дублировать", + "tocDuplicateFilename": "Имя файла темы:", + "tocDuplicateDialog": "Дублировать тему", "pdfTitlePage": "Титульная страница", "pdfIncludeTitlePage": "Добавить титульную страницу", "pdfTitlePageNote": "На этой странице нет колонтитулов и видимого номера страницы.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Темы", + "toc": "Оглавление", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Новая тема на том же уровне", + "newSiblingTopic": "Новая тема", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 2d6330c6..590f1686 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Şablondan Konu...", + "tocSaveAsTemplate": "Şablon Olarak Kaydet", + "tocTemplateDialog": "Şablondan Konu Oluştur", + "tocTemplatesEditor": "Dosya ve Kod Şablonları", + "tocEditTemplates": "Şablonları düzenle...", + "tocCreateCustomTemplate": "Özel şablon oluştur...", + "tocTemplateCreated": "Şablon oluşturuldu", + "tocTemplateDefault": "Varsayılan", + "tocTemplateCustom": "Özel", + "tocTemplateFilename": "Dosya adı:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Dahili", + "tocTemplateUnnamed": "Adsız", + "tocTemplateNew": "Yeni şablon...", + "tocTemplateName": "Ad:", + "tocTemplateExtension": "Uzantı:", + "tocTemplateLoadError": "Şablonlar yüklenemedi. Mevcut şablonlar değiştirilmedi.", + "tocTemplateSaveError": "Şablonlar kaydedilemedi. Düzenlemeleriniz burada duruyor.", + "tocTemplateConflict": "Şablonlar başka bir pencerede değiştirildi. İptal edip düzenleyiciyi yeniden açın.", + "tocTemplateDuplicateName": "Bu ada ve uzantıya sahip bir şablon zaten var.", + "tocTemplateFormat": "Biçim:", + "tocTemplateReset": "Sıfırla", + "tocTemplateRetry": "Yeniden dene", + "tocRemoveElementsDialog": "{count} İçindekiler Öğesini Kaldır", + "tocRemoveElementsRetainFiles": "Bu içindekiler öğeleriyle ilişkili kaynak dosyalar silinmeyecek.", + "tocOk": "Tamam", + "tocSelectExistingTopic": "Geçerli örneğe eklenecek konuyu seçin", + "tocGroupName": "Grup adı", + "tocMoveLineUp": "Satırı yukarı taşı", + "tocMoveLineDown": "Satırı aşağı taşı", + "tocEmptyMdTopic": "Boş MD konusu", + "tocEmptyXmlTopic": "Boş XML konusu", + "tocCopySpecial": "Özel kopyala", + "tocCopyFileName": "Konu dosyası adı '{name}'", + "tocCopyFilePath": "Konu dosyası yolu", + "tocCopyTopicTitle": "Konu başlığı '{title}'", + "tocCopyElementId": "İçindekiler öğesi kimliği '{id}'", + "tocCopyFileNameUnavailable": "Konu dosyası adı", + "tocCopyTopicTitleUnavailable": "Konu başlığı", + "tocCopyElementIdUnavailable": "İçindekiler öğesi kimliği", + "tocPreviewTopic": "Konuyu önizle", + "tocEditTitleAction": "Başlığı düzenle...", + "tocEditTitleDialog": "Başlığı düzenle", + "tocRemoveElementAction": "İçindekiler öğesini kaldır...", + "tocSetHomePage": "Ana sayfa olarak ayarla", + "tocGoToElement": "'{fileName}' içindeki içindekiler öğesine git", + "tocSynchronize": "İçindekiler ve düzenleyiciyi eşitle", + "tocEmptyGroupAction": "Boş grup", + "tocNewEmptyGroup": "Yeni boş grup", + "tocGroupAction": "Grupla", + "tocNewGroup": "Yeni grup", + "tocSortChildren": "Alt konuları alfabetik sırala", + "tocLinkTopicFiles": "Konu dosyalarını içindekilere bağla...", + "tocTitleField": "İçindekiler başlığı:", + "tocTopicTitleField": "Konu başlığı:", + "tocAdvancedSettings": "Gelişmiş ayarlar", + "tocInstanceTitleField": "'{instanceId}' için başlık:", + "tocOnlyTitleField": "Yalnızca içindekiler başlığı:", + "tocFindUsages": "Bul", + "tocRefactorMenu": "Yeniden düzenle", + "tocSafeDelete": "Güvenli sil", + "tocDuplicate": "Çoğalt", + "tocDuplicateFilename": "Konu dosyası adı:", + "tocDuplicateDialog": "Konuyu çoğalt", "pdfTitlePage": "Başlık sayfası", "pdfIncludeTitlePage": "Başlık sayfası ekle", "pdfTitlePageNote": "Bu sayfada üstbilgi, altbilgi veya görünür sayfa numarası bulunmaz.", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Konular", + "toc": "İçindekiler", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Yeni kardeş konu", + "newSiblingTopic": "Yeni konu", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, @@ -3862,7 +3927,7 @@ "@editInstance": { "description": "Action and dialog title for editing a Writerside instance." }, - "openTocFile": "TOC dosyasını aç", + "openTocFile": "İçindekiler dosyasını aç", "@openTocFile": { "description": "Action that opens the selected Writerside instance tree file." }, diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8b327d31..00c277c1 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Тема з шаблону...", + "tocSaveAsTemplate": "Зберегти як шаблон", + "tocTemplateDialog": "Створити тему з шаблону", + "tocTemplatesEditor": "Шаблони файлів і коду", + "tocEditTemplates": "Редагувати шаблони...", + "tocCreateCustomTemplate": "Створити власний шаблон...", + "tocTemplateCreated": "Шаблон створено", + "tocTemplateDefault": "Стандартні", + "tocTemplateCustom": "Власні", + "tocTemplateFilename": "Ім’я файлу:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Внутрішні", + "tocTemplateUnnamed": "Без назви", + "tocTemplateNew": "Новий шаблон...", + "tocTemplateName": "Назва:", + "tocTemplateExtension": "Розширення:", + "tocTemplateLoadError": "Не вдалося завантажити шаблони. Наявні шаблони не змінено.", + "tocTemplateSaveError": "Не вдалося зберегти шаблони. Ваші зміни залишилися в редакторі.", + "tocTemplateConflict": "Шаблони змінено в іншому вікні. Скасуйте та знову відкрийте редактор.", + "tocTemplateDuplicateName": "Шаблон із такою назвою та розширенням уже існує.", + "tocTemplateFormat": "Формат:", + "tocTemplateReset": "Скинути", + "tocTemplateRetry": "Повторити", + "tocRemoveElementsDialog": "Вилучити {count} елементів змісту", + "tocRemoveElementsRetainFiles": "Вихідні файли, пов’язані з цими елементами змісту, не буде видалено.", + "tocOk": "OK", + "tocSelectExistingTopic": "Виберіть тему для додавання до поточного екземпляра", + "tocGroupName": "Назва групи", + "tocMoveLineUp": "Перемістити рядок угору", + "tocMoveLineDown": "Перемістити рядок униз", + "tocEmptyMdTopic": "Порожня тема MD", + "tocEmptyXmlTopic": "Порожня тема XML", + "tocCopySpecial": "Спеціальне копіювання", + "tocCopyFileName": "Назва файлу теми '{name}'", + "tocCopyFilePath": "Шлях до файлу теми", + "tocCopyTopicTitle": "Заголовок теми '{title}'", + "tocCopyElementId": "ID елемента змісту '{id}'", + "tocCopyFileNameUnavailable": "Назва файлу теми", + "tocCopyTopicTitleUnavailable": "Заголовок теми", + "tocCopyElementIdUnavailable": "ID елемента змісту", + "tocPreviewTopic": "Попередній перегляд теми", + "tocEditTitleAction": "Редагувати заголовок...", + "tocEditTitleDialog": "Редагувати заголовок", + "tocRemoveElementAction": "Вилучити елемент змісту...", + "tocSetHomePage": "Зробити домашньою сторінкою", + "tocGoToElement": "Перейти до елемента змісту в «{fileName}»", + "tocSynchronize": "Синхронізувати зміст і редактор", + "tocEmptyGroupAction": "Порожня група", + "tocNewEmptyGroup": "Нова порожня група", + "tocGroupAction": "Згрупувати", + "tocNewGroup": "Нова група", + "tocSortChildren": "Сортувати дочірні теми за абеткою", + "tocLinkTopicFiles": "Пов’язати файли тем зі змістом...", + "tocTitleField": "Заголовок у змісті:", + "tocTopicTitleField": "Заголовок теми:", + "tocAdvancedSettings": "Додаткові налаштування", + "tocInstanceTitleField": "Заголовок для «{instanceId}»:", + "tocOnlyTitleField": "Заголовок лише для змісту:", + "tocFindUsages": "Пошук", + "tocRefactorMenu": "Рефакторинг", + "tocSafeDelete": "Безпечне видалення", + "tocDuplicate": "Дублювати", + "tocDuplicateFilename": "Назва файлу теми:", + "tocDuplicateDialog": "Дублювати тему", "pdfTitlePage": "Титульна сторінка", "pdfIncludeTitlePage": "Додати титульну сторінку", "pdfTitlePageNote": "На цій сторінці немає колонтитулів і видимого номера сторінки.", @@ -1221,7 +1286,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Теми", + "toc": "Зміст", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1265,7 +1330,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Нова тема на тому самому рівні", + "newSiblingTopic": "Нова тема", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index def0820e..e650b099 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "Chủ đề từ mẫu...", + "tocSaveAsTemplate": "Lưu thành mẫu", + "tocTemplateDialog": "Tạo chủ đề từ mẫu", + "tocTemplatesEditor": "Mẫu tệp và mã", + "tocEditTemplates": "Chỉnh sửa mẫu...", + "tocCreateCustomTemplate": "Tạo mẫu tùy chỉnh...", + "tocTemplateCreated": "Đã tạo mẫu", + "tocTemplateDefault": "Mặc định", + "tocTemplateCustom": "Tùy chỉnh", + "tocTemplateFilename": "Tên tệp:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "Nội bộ", + "tocTemplateUnnamed": "Chưa đặt tên", + "tocTemplateNew": "Mẫu mới...", + "tocTemplateName": "Tên:", + "tocTemplateExtension": "Phần mở rộng:", + "tocTemplateLoadError": "Không thể tải mẫu. Các mẫu hiện có không bị thay đổi.", + "tocTemplateSaveError": "Không thể lưu mẫu. Các chỉnh sửa của bạn vẫn còn ở đây.", + "tocTemplateConflict": "Mẫu đã thay đổi trong cửa sổ khác. Hãy hủy và mở lại trình chỉnh sửa.", + "tocTemplateDuplicateName": "Đã có mẫu với tên và phần mở rộng này.", + "tocTemplateFormat": "Định dạng:", + "tocTemplateReset": "Đặt lại", + "tocTemplateRetry": "Thử lại", + "tocRemoveElementsDialog": "Xóa {count} phần tử mục lục", + "tocRemoveElementsRetainFiles": "Các tệp nguồn liên kết với những phần tử mục lục này sẽ không bị xóa.", + "tocOk": "OK", + "tocSelectExistingTopic": "Chọn chủ đề để thêm vào phiên bản hiện tại", + "tocGroupName": "Tên nhóm", + "tocMoveLineUp": "Di chuyển dòng lên", + "tocMoveLineDown": "Di chuyển dòng xuống", + "tocEmptyMdTopic": "Chủ đề MD trống", + "tocEmptyXmlTopic": "Chủ đề XML trống", + "tocCopySpecial": "Sao chép đặc biệt", + "tocCopyFileName": "Tên tệp chủ đề '{name}'", + "tocCopyFilePath": "Đường dẫn tệp chủ đề", + "tocCopyTopicTitle": "Tiêu đề chủ đề '{title}'", + "tocCopyElementId": "ID phần tử mục lục '{id}'", + "tocCopyFileNameUnavailable": "Tên tệp chủ đề", + "tocCopyTopicTitleUnavailable": "Tiêu đề chủ đề", + "tocCopyElementIdUnavailable": "ID phần tử mục lục", + "tocPreviewTopic": "Xem trước chủ đề", + "tocEditTitleAction": "Sửa tiêu đề...", + "tocEditTitleDialog": "Sửa tiêu đề", + "tocRemoveElementAction": "Xóa phần tử mục lục...", + "tocSetHomePage": "Đặt làm trang chủ", + "tocGoToElement": "Đi đến phần tử mục lục trong '{fileName}'", + "tocSynchronize": "Đồng bộ mục lục và trình soạn thảo", + "tocEmptyGroupAction": "Nhóm trống", + "tocNewEmptyGroup": "Nhóm trống mới", + "tocGroupAction": "Nhóm lại", + "tocNewGroup": "Nhóm mới", + "tocSortChildren": "Sắp xếp chủ đề con theo bảng chữ cái", + "tocLinkTopicFiles": "Liên kết tệp chủ đề với mục lục...", + "tocTitleField": "Tiêu đề mục lục:", + "tocTopicTitleField": "Tiêu đề chủ đề:", + "tocAdvancedSettings": "Cài đặt nâng cao", + "tocInstanceTitleField": "Tiêu đề cho '{instanceId}':", + "tocOnlyTitleField": "Tiêu đề chỉ dành cho mục lục:", + "tocFindUsages": "Tìm", + "tocRefactorMenu": "Tái cấu trúc", + "tocSafeDelete": "Xóa an toàn", + "tocDuplicate": "Nhân bản", + "tocDuplicateFilename": "Tên tệp chủ đề:", + "tocDuplicateDialog": "Nhân bản chủ đề", "pdfTitlePage": "Trang tiêu đề", "pdfIncludeTitlePage": "Bao gồm trang tiêu đề", "pdfTitlePageNote": "Trang này không có đầu trang, chân trang hoặc số trang hiển thị.", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Chủ đề", + "toc": "Mục lục", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "Chủ đề cùng cấp mới", + "newSiblingTopic": "Chủ đề mới", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e9a5a6d7..23767628 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "从模板创建主题...", + "tocSaveAsTemplate": "保存为模板", + "tocTemplateDialog": "从模板创建主题", + "tocTemplatesEditor": "文件和代码模板", + "tocEditTemplates": "编辑模板...", + "tocCreateCustomTemplate": "创建自定义模板...", + "tocTemplateCreated": "已创建模板", + "tocTemplateDefault": "默认", + "tocTemplateCustom": "自定义", + "tocTemplateFilename": "文件名:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "内部", + "tocTemplateUnnamed": "未命名", + "tocTemplateNew": "新建模板...", + "tocTemplateName": "名称:", + "tocTemplateExtension": "扩展名:", + "tocTemplateLoadError": "无法加载模板。现有模板未更改。", + "tocTemplateSaveError": "无法保存模板。您的编辑内容仍保留在此处。", + "tocTemplateConflict": "模板已在其他窗口中更改。请取消并重新打开编辑器。", + "tocTemplateDuplicateName": "已存在相同名称和扩展名的模板。", + "tocTemplateFormat": "格式:", + "tocTemplateReset": "重置", + "tocTemplateRetry": "重试", + "tocRemoveElementsDialog": "移除 {count} 个目录元素", + "tocRemoveElementsRetainFiles": "不会删除与这些目录元素关联的源文件。", + "tocOk": "确定", + "tocSelectExistingTopic": "选择要添加到当前实例的主题", + "tocGroupName": "组名", + "tocMoveLineUp": "上移行", + "tocMoveLineDown": "下移行", + "tocEmptyMdTopic": "空 MD 主题", + "tocEmptyXmlTopic": "空 XML 主题", + "tocCopySpecial": "特殊复制", + "tocCopyFileName": "主题文件名 '{name}'", + "tocCopyFilePath": "主题文件路径", + "tocCopyTopicTitle": "主题标题 '{title}'", + "tocCopyElementId": "目录元素 ID '{id}'", + "tocCopyFileNameUnavailable": "主题文件名", + "tocCopyTopicTitleUnavailable": "主题标题", + "tocCopyElementIdUnavailable": "目录元素 ID", + "tocPreviewTopic": "预览主题", + "tocEditTitleAction": "编辑标题...", + "tocEditTitleDialog": "编辑标题", + "tocRemoveElementAction": "移除目录元素...", + "tocSetHomePage": "设为主页", + "tocGoToElement": "转到“{fileName}”中的目录元素", + "tocSynchronize": "同步目录与编辑器", + "tocEmptyGroupAction": "空组", + "tocNewEmptyGroup": "新建空组", + "tocGroupAction": "分组", + "tocNewGroup": "新建组", + "tocSortChildren": "按字母顺序排列子主题", + "tocLinkTopicFiles": "将主题文件链接到目录...", + "tocTitleField": "目录标题:", + "tocTopicTitleField": "主题标题:", + "tocAdvancedSettings": "高级设置", + "tocInstanceTitleField": "“{instanceId}”的标题:", + "tocOnlyTitleField": "仅用于目录的标题:", + "tocFindUsages": "查找", + "tocRefactorMenu": "重构", + "tocSafeDelete": "安全删除", + "tocDuplicate": "创建副本", + "tocDuplicateFilename": "主题文件名:", + "tocDuplicateDialog": "创建主题副本", "pdfTitlePage": "标题页", "pdfIncludeTitlePage": "包含标题页", "pdfTitlePageNote": "此页不显示页眉、页脚或页码。", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "主题", + "toc": "目录", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "新建同级主题", + "newSiblingTopic": "新建主题", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index c64e30c6..c2a812c0 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1,4 +1,69 @@ { + "tocTopicFromTemplate": "从模板创建主题...", + "tocSaveAsTemplate": "保存为模板", + "tocTemplateDialog": "从模板创建主题", + "tocTemplatesEditor": "文件和代码模板", + "tocEditTemplates": "编辑模板...", + "tocCreateCustomTemplate": "创建自定义模板...", + "tocTemplateCreated": "已创建模板", + "tocTemplateDefault": "默认", + "tocTemplateCustom": "自定义", + "tocTemplateFilename": "文件名:", + "tocTemplateMarkdown": "Markdown (.md)", + "tocTemplateXml": "XML (.topic)", + "tocTemplatesInternal": "内部", + "tocTemplateUnnamed": "未命名", + "tocTemplateNew": "新建模板...", + "tocTemplateName": "名称:", + "tocTemplateExtension": "扩展名:", + "tocTemplateLoadError": "无法加载模板。现有模板未更改。", + "tocTemplateSaveError": "无法保存模板。您的编辑内容仍保留在此处。", + "tocTemplateConflict": "模板已在其他窗口中更改。请取消并重新打开编辑器。", + "tocTemplateDuplicateName": "已存在相同名称和扩展名的模板。", + "tocTemplateFormat": "格式:", + "tocTemplateReset": "重置", + "tocTemplateRetry": "重试", + "tocRemoveElementsDialog": "移除 {count} 个目录元素", + "tocRemoveElementsRetainFiles": "不会删除与这些目录元素关联的源文件。", + "tocOk": "确定", + "tocSelectExistingTopic": "选择要添加到当前实例的主题", + "tocGroupName": "组名", + "tocMoveLineUp": "上移行", + "tocMoveLineDown": "下移行", + "tocEmptyMdTopic": "空 MD 主题", + "tocEmptyXmlTopic": "空 XML 主题", + "tocCopySpecial": "特殊复制", + "tocCopyFileName": "主题文件名 '{name}'", + "tocCopyFilePath": "主题文件路径", + "tocCopyTopicTitle": "主题标题 '{title}'", + "tocCopyElementId": "目录元素 ID '{id}'", + "tocCopyFileNameUnavailable": "主题文件名", + "tocCopyTopicTitleUnavailable": "主题标题", + "tocCopyElementIdUnavailable": "目录元素 ID", + "tocPreviewTopic": "预览主题", + "tocEditTitleAction": "编辑标题...", + "tocEditTitleDialog": "编辑标题", + "tocRemoveElementAction": "移除目录元素...", + "tocSetHomePage": "设为主页", + "tocGoToElement": "转到“{fileName}”中的目录元素", + "tocSynchronize": "同步目录与编辑器", + "tocEmptyGroupAction": "空组", + "tocNewEmptyGroup": "新建空组", + "tocGroupAction": "分组", + "tocNewGroup": "新建组", + "tocSortChildren": "按字母顺序排列子主题", + "tocLinkTopicFiles": "将主题文件链接到目录...", + "tocTitleField": "目录标题:", + "tocTopicTitleField": "主题标题:", + "tocAdvancedSettings": "高级设置", + "tocInstanceTitleField": "“{instanceId}”的标题:", + "tocOnlyTitleField": "仅用于目录的标题:", + "tocFindUsages": "查找", + "tocRefactorMenu": "重构", + "tocSafeDelete": "安全删除", + "tocDuplicate": "创建副本", + "tocDuplicateFilename": "主题文件名:", + "tocDuplicateDialog": "创建主题副本", "pdfTitlePage": "标题页", "pdfIncludeTitlePage": "包含标题页", "pdfTitlePageNote": "此页不显示页眉、页脚或页码。", @@ -1397,7 +1462,7 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "主题", + "toc": "目录", "@toc": { "description": "Writerside Topics sidebar tab label." }, @@ -1445,7 +1510,7 @@ "@newChildTopic": { "description": "Create new child Writerside topic action label." }, - "newSiblingTopic": "新建同级主题", + "newSiblingTopic": "新建主题", "@newSiblingTopic": { "description": "Create new sibling Writerside topic action label." }, diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1af1f5c7..052f166c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -139,6 +139,396 @@ abstract class AppLocalizations { Locale('zh', 'CN'), ]; + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Topic from Template...'** + String get tocTopicFromTemplate; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Save as Template'** + String get tocSaveAsTemplate; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Create Topic from Template'** + String get tocTemplateDialog; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'File and Code Templates'** + String get tocTemplatesEditor; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Edit templates...'** + String get tocEditTemplates; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Create custom template...'** + String get tocCreateCustomTemplate; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Template created'** + String get tocTemplateCreated; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Default'** + String get tocTemplateDefault; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Custom'** + String get tocTemplateCustom; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Filename:'** + String get tocTemplateFilename; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Markdown (.md)'** + String get tocTemplateMarkdown; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'XML (.topic)'** + String get tocTemplateXml; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Internal'** + String get tocTemplatesInternal; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Unnamed'** + String get tocTemplateUnnamed; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'New template...'** + String get tocTemplateNew; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Name:'** + String get tocTemplateName; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Extension:'** + String get tocTemplateExtension; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Could not load templates. Existing templates have not been changed.'** + String get tocTemplateLoadError; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Could not save templates. Your edits are still available here.'** + String get tocTemplateSaveError; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Templates changed in another window. Cancel and reopen the editor before editing again.'** + String get tocTemplateConflict; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'A template with this name and extension already exists.'** + String get tocTemplateDuplicateName; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Format:'** + String get tocTemplateFormat; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Reset'** + String get tocTemplateReset; + + /// Writerside template dialog or user-template storage feedback. + /// + /// In en, this message translates to: + /// **'Retry'** + String get tocTemplateRetry; + + /// Compact removal confirmation title; counts selected elements, not promoted descendants. + /// + /// In en, this message translates to: + /// **'Remove {count} TOC Elements'** + String tocRemoveElementsDialog(int count); + + /// Original compact group/multiple TOC removal explanation. + /// + /// In en, this message translates to: + /// **'The source files associated with these TOC elements won\'t be deleted.'** + String get tocRemoveElementsRetainFiles; + + /// No description provided for @tocOk. + /// + /// In en, this message translates to: + /// **'OK'** + String get tocOk; + + /// No description provided for @tocSelectExistingTopic. + /// + /// In en, this message translates to: + /// **'Select Topic to Add to the Current Instance'** + String get tocSelectExistingTopic; + + /// No description provided for @tocGroupName. + /// + /// In en, this message translates to: + /// **'Group Name'** + String get tocGroupName; + + /// No description provided for @tocMoveLineUp. + /// + /// In en, this message translates to: + /// **'Move Line Up'** + String get tocMoveLineUp; + + /// No description provided for @tocMoveLineDown. + /// + /// In en, this message translates to: + /// **'Move Line Down'** + String get tocMoveLineDown; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Empty MD Topic'** + String get tocEmptyMdTopic; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Empty XML Topic'** + String get tocEmptyXmlTopic; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Copy Special'** + String get tocCopySpecial; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic File Name \'{name}\''** + String tocCopyFileName(String name); + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic File Path'** + String get tocCopyFilePath; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic Title \'{title}\''** + String tocCopyTopicTitle(String title); + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'TOC Element ID \'{id}\''** + String tocCopyElementId(String id); + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic File Name'** + String get tocCopyFileNameUnavailable; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic Title'** + String get tocCopyTopicTitleUnavailable; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'TOC Element ID'** + String get tocCopyElementIdUnavailable; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Preview Topic'** + String get tocPreviewTopic; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Edit Title...'** + String get tocEditTitleAction; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Edit Title'** + String get tocEditTitleDialog; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Remove TOC Element...'** + String get tocRemoveElementAction; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Set as Home Page'** + String get tocSetHomePage; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Go to TOC Element in \'{fileName}\''** + String tocGoToElement(String fileName); + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Synchronize TOC and Editor'** + String get tocSynchronize; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Empty Group'** + String get tocEmptyGroupAction; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'New Empty Group'** + String get tocNewEmptyGroup; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Group'** + String get tocGroupAction; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'New Group'** + String get tocNewGroup; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Sort Child Topics Alphabetically'** + String get tocSortChildren; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Link Topic Files to TOC...'** + String get tocLinkTopicFiles; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'TOC title:'** + String get tocTitleField; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic title:'** + String get tocTopicTitleField; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Advanced Settings'** + String get tocAdvancedSettings; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Title for \'{instanceId}\':'** + String tocInstanceTitleField(String instanceId); + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'TOC-only title:'** + String get tocOnlyTitleField; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Find'** + String get tocFindUsages; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Refactor'** + String get tocRefactorMenu; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Safe Delete'** + String get tocSafeDelete; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Duplicate'** + String get tocDuplicate; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Topic Filename:'** + String get tocDuplicateFilename; + + /// Writerside Table of Contents command or dialog text. + /// + /// In en, this message translates to: + /// **'Duplicate Topic'** + String get tocDuplicateDialog; + /// PDF-specific title-page settings group. /// /// In en, this message translates to: @@ -2224,7 +2614,7 @@ abstract class AppLocalizations { /// Writerside Topics sidebar tab label. /// /// In en, this message translates to: - /// **'Topics'** + /// **'Table of Contents'** String get toc; /// Tooltip for the Writerside TOC action menu button. @@ -2284,7 +2674,7 @@ abstract class AppLocalizations { /// Create new sibling Writerside topic action label. /// /// In en, this message translates to: - /// **'New Sibling Topic'** + /// **'New Topic'** String get newSiblingTopic; /// Context-menu action that renames a Writerside topic file. @@ -5289,7 +5679,7 @@ abstract class AppLocalizations { /// Action that opens the selected Writerside instance tree file. /// /// In en, this message translates to: - /// **'Open TOC file'** + /// **'Open TOC File'** String get openTocFile; /// Dialog title for creating a Writerside instance. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 8000d323..ba835ae5 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get tocTopicFromTemplate => 'موضوع من قالب...'; + + @override + String get tocSaveAsTemplate => 'حفظ كقالب'; + + @override + String get tocTemplateDialog => 'إنشاء موضوع من قالب'; + + @override + String get tocTemplatesEditor => 'قوالب الملفات والشيفرة'; + + @override + String get tocEditTemplates => 'تحرير القوالب...'; + + @override + String get tocCreateCustomTemplate => 'إنشاء قالب مخصص...'; + + @override + String get tocTemplateCreated => 'تم إنشاء القالب'; + + @override + String get tocTemplateDefault => 'افتراضي'; + + @override + String get tocTemplateCustom => 'مخصص'; + + @override + String get tocTemplateFilename => 'اسم الملف:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'داخلي'; + + @override + String get tocTemplateUnnamed => 'بلا اسم'; + + @override + String get tocTemplateNew => 'قالب جديد...'; + + @override + String get tocTemplateName => 'الاسم:'; + + @override + String get tocTemplateExtension => 'الامتداد:'; + + @override + String get tocTemplateLoadError => + 'تعذر تحميل القوالب. لم تتغير القوالب الموجودة.'; + + @override + String get tocTemplateSaveError => + 'تعذر حفظ القوالب. لا تزال تعديلاتك متاحة هنا.'; + + @override + String get tocTemplateConflict => + 'تغيرت القوالب في نافذة أخرى. ألغِ وأعد فتح المحرر.'; + + @override + String get tocTemplateDuplicateName => + 'يوجد بالفعل قالب بهذا الاسم والامتداد.'; + + @override + String get tocTemplateFormat => 'التنسيق:'; + + @override + String get tocTemplateReset => 'إعادة تعيين'; + + @override + String get tocTemplateRetry => 'إعادة المحاولة'; + + @override + String tocRemoveElementsDialog(int count) { + return 'إزالة $count من عناصر جدول المحتويات'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'لن تُحذف ملفات المصدر المرتبطة بعناصر جدول المحتويات هذه.'; + + @override + String get tocOk => 'موافق'; + + @override + String get tocSelectExistingTopic => + 'اختر موضوعًا لإضافته إلى النسخة الحالية'; + + @override + String get tocGroupName => 'اسم المجموعة'; + + @override + String get tocMoveLineUp => 'نقل السطر لأعلى'; + + @override + String get tocMoveLineDown => 'نقل السطر لأسفل'; + + @override + String get tocEmptyMdTopic => 'موضوع MD فارغ'; + + @override + String get tocEmptyXmlTopic => 'موضوع XML فارغ'; + + @override + String get tocCopySpecial => 'نسخ خاص'; + + @override + String tocCopyFileName(String name) { + return 'اسم ملف الموضوع \'$name\''; + } + + @override + String get tocCopyFilePath => 'مسار ملف الموضوع'; + + @override + String tocCopyTopicTitle(String title) { + return 'عنوان الموضوع \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'معرّف عنصر جدول المحتويات \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'اسم ملف الموضوع'; + + @override + String get tocCopyTopicTitleUnavailable => 'عنوان الموضوع'; + + @override + String get tocCopyElementIdUnavailable => 'معرّف عنصر جدول المحتويات'; + + @override + String get tocPreviewTopic => 'معاينة الموضوع'; + + @override + String get tocEditTitleAction => 'تحرير العنوان...'; + + @override + String get tocEditTitleDialog => 'تحرير العنوان'; + + @override + String get tocRemoveElementAction => 'إزالة عنصر جدول المحتويات...'; + + @override + String get tocSetHomePage => 'تعيين كصفحة رئيسية'; + + @override + String tocGoToElement(String fileName) { + return 'الانتقال إلى عنصر جدول المحتويات في \'$fileName\''; + } + + @override + String get tocSynchronize => 'مزامنة جدول المحتويات والمحرر'; + + @override + String get tocEmptyGroupAction => 'مجموعة فارغة'; + + @override + String get tocNewEmptyGroup => 'مجموعة فارغة جديدة'; + + @override + String get tocGroupAction => 'تجميع'; + + @override + String get tocNewGroup => 'مجموعة جديدة'; + + @override + String get tocSortChildren => 'ترتيب المواضيع الفرعية أبجديًا'; + + @override + String get tocLinkTopicFiles => 'ربط ملفات المواضيع بجدول المحتويات...'; + + @override + String get tocTitleField => 'عنوان جدول المحتويات:'; + + @override + String get tocTopicTitleField => 'عنوان الموضوع:'; + + @override + String get tocAdvancedSettings => 'إعدادات متقدمة'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'عنوان \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'عنوان خاص بجدول المحتويات:'; + + @override + String get tocFindUsages => 'بحث'; + + @override + String get tocRefactorMenu => 'إعادة الهيكلة'; + + @override + String get tocSafeDelete => 'حذف آمن'; + + @override + String get tocDuplicate => 'تكرار'; + + @override + String get tocDuplicateFilename => 'اسم ملف الموضوع:'; + + @override + String get tocDuplicateDialog => 'تكرار الموضوع'; + @override String get pdfTitlePage => 'صفحة العنوان'; @@ -1157,7 +1370,7 @@ class AppLocalizationsAr extends AppLocalizations { String get files => 'ملفات'; @override - String get toc => 'موضوعات'; + String get toc => 'جدول المحتويات'; @override String get tocActions => 'إجراءات جدول المحتويات'; @@ -1199,7 +1412,7 @@ class AppLocalizationsAr extends AppLocalizations { String get newChildTopic => 'موضوع فرعي جديد'; @override - String get newSiblingTopic => 'موضوع جديد على المستوى نفسه'; + String get newSiblingTopic => 'موضوع جديد'; @override String get renameTopicFile => 'إعادة تسمية ملف الموضوع'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a9a015b5..b1733486 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -10,6 +10,221 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get tocTopicFromTemplate => 'Thema aus Vorlage...'; + + @override + String get tocSaveAsTemplate => 'Als Vorlage speichern'; + + @override + String get tocTemplateDialog => 'Thema aus Vorlage erstellen'; + + @override + String get tocTemplatesEditor => 'Datei- und Codevorlagen'; + + @override + String get tocEditTemplates => 'Vorlagen bearbeiten...'; + + @override + String get tocCreateCustomTemplate => 'Eigene Vorlage erstellen...'; + + @override + String get tocTemplateCreated => 'Vorlage erstellt'; + + @override + String get tocTemplateDefault => 'Standard'; + + @override + String get tocTemplateCustom => 'Benutzerdefiniert'; + + @override + String get tocTemplateFilename => 'Dateiname:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Intern'; + + @override + String get tocTemplateUnnamed => 'Unbenannt'; + + @override + String get tocTemplateNew => 'Neue Vorlage...'; + + @override + String get tocTemplateName => 'Name:'; + + @override + String get tocTemplateExtension => 'Erweiterung:'; + + @override + String get tocTemplateLoadError => + 'Vorlagen konnten nicht geladen werden. Bestehende Vorlagen wurden nicht geändert.'; + + @override + String get tocTemplateSaveError => + 'Vorlagen konnten nicht gespeichert werden. Ihre Änderungen sind hier noch verfügbar.'; + + @override + String get tocTemplateConflict => + 'Vorlagen wurden in einem anderen Fenster geändert. Brechen Sie ab und öffnen Sie den Editor erneut.'; + + @override + String get tocTemplateDuplicateName => + 'Eine Vorlage mit diesem Namen und dieser Erweiterung existiert bereits.'; + + @override + String get tocTemplateFormat => 'Format:'; + + @override + String get tocTemplateReset => 'Zurücksetzen'; + + @override + String get tocTemplateRetry => 'Erneut versuchen'; + + @override + String tocRemoveElementsDialog(int count) { + return '$count Inhaltsverzeichniselemente entfernen'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Die mit diesen Inhaltsverzeichniselementen verknüpften Quelldateien werden nicht gelöscht.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Thema zum Hinzufügen zur aktuellen Instanz auswählen'; + + @override + String get tocGroupName => 'Gruppenname'; + + @override + String get tocMoveLineUp => 'Zeile nach oben verschieben'; + + @override + String get tocMoveLineDown => 'Zeile nach unten verschieben'; + + @override + String get tocEmptyMdTopic => 'Leeres MD-Thema'; + + @override + String get tocEmptyXmlTopic => 'Leeres XML-Thema'; + + @override + String get tocCopySpecial => 'Speziell kopieren'; + + @override + String tocCopyFileName(String name) { + return 'Themendateiname \'$name\''; + } + + @override + String get tocCopyFilePath => 'Themendateipfad'; + + @override + String tocCopyTopicTitle(String title) { + return 'Thementitel \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID des Inhaltsverzeichniselements \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Themendateiname'; + + @override + String get tocCopyTopicTitleUnavailable => 'Thementitel'; + + @override + String get tocCopyElementIdUnavailable => 'ID des Inhaltsverzeichniselements'; + + @override + String get tocPreviewTopic => 'Thema in Vorschau anzeigen'; + + @override + String get tocEditTitleAction => 'Titel bearbeiten...'; + + @override + String get tocEditTitleDialog => 'Titel bearbeiten'; + + @override + String get tocRemoveElementAction => 'Inhaltsverzeichniselement entfernen...'; + + @override + String get tocSetHomePage => 'Als Startseite festlegen'; + + @override + String tocGoToElement(String fileName) { + return 'Zum Inhaltsverzeichniselement in \'$fileName\' wechseln'; + } + + @override + String get tocSynchronize => 'Inhaltsverzeichnis und Editor synchronisieren'; + + @override + String get tocEmptyGroupAction => 'Leere Gruppe'; + + @override + String get tocNewEmptyGroup => 'Neue leere Gruppe'; + + @override + String get tocGroupAction => 'Gruppieren'; + + @override + String get tocNewGroup => 'Neue Gruppe'; + + @override + String get tocSortChildren => 'Untergeordnete Themen alphabetisch sortieren'; + + @override + String get tocLinkTopicFiles => + 'Themendateien mit Inhaltsverzeichnis verknüpfen...'; + + @override + String get tocTitleField => 'Inhaltsverzeichnistitel:'; + + @override + String get tocTopicTitleField => 'Thementitel:'; + + @override + String get tocAdvancedSettings => 'Erweiterte Einstellungen'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Titel für \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => + 'Nur im Inhaltsverzeichnis verwendeter Titel:'; + + @override + String get tocFindUsages => 'Suchen'; + + @override + String get tocRefactorMenu => 'Refaktorieren'; + + @override + String get tocSafeDelete => 'Sicher löschen'; + + @override + String get tocDuplicate => 'Duplizieren'; + + @override + String get tocDuplicateFilename => 'Themendateiname:'; + + @override + String get tocDuplicateDialog => 'Thema duplizieren'; + @override String get pdfTitlePage => 'Titelseite'; @@ -1176,7 +1391,7 @@ class AppLocalizationsDe extends AppLocalizations { String get files => 'Dateien'; @override - String get toc => 'Themen'; + String get toc => 'Inhaltsverzeichnis'; @override String get tocActions => 'Inhaltsverzeichnisaktionen'; @@ -1214,7 +1429,7 @@ class AppLocalizationsDe extends AppLocalizations { String get newChildTopic => 'Neues untergeordnetes Thema'; @override - String get newSiblingTopic => 'Neues Thema auf gleicher Ebene'; + String get newSiblingTopic => 'Neues Thema'; @override String get renameTopicFile => 'Themendatei umbenennen'; @@ -3162,7 +3377,7 @@ class AppLocalizationsDe extends AppLocalizations { String get editInstance => 'Instanz bearbeiten'; @override - String get openTocFile => 'TOC-Datei öffnen'; + String get openTocFile => 'Inhaltsverzeichnisdatei öffnen'; @override String get createInstance => 'Instanz erstellen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e2e99d9a..823c1847 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get tocTopicFromTemplate => 'Topic from Template...'; + + @override + String get tocSaveAsTemplate => 'Save as Template'; + + @override + String get tocTemplateDialog => 'Create Topic from Template'; + + @override + String get tocTemplatesEditor => 'File and Code Templates'; + + @override + String get tocEditTemplates => 'Edit templates...'; + + @override + String get tocCreateCustomTemplate => 'Create custom template...'; + + @override + String get tocTemplateCreated => 'Template created'; + + @override + String get tocTemplateDefault => 'Default'; + + @override + String get tocTemplateCustom => 'Custom'; + + @override + String get tocTemplateFilename => 'Filename:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internal'; + + @override + String get tocTemplateUnnamed => 'Unnamed'; + + @override + String get tocTemplateNew => 'New template...'; + + @override + String get tocTemplateName => 'Name:'; + + @override + String get tocTemplateExtension => 'Extension:'; + + @override + String get tocTemplateLoadError => + 'Could not load templates. Existing templates have not been changed.'; + + @override + String get tocTemplateSaveError => + 'Could not save templates. Your edits are still available here.'; + + @override + String get tocTemplateConflict => + 'Templates changed in another window. Cancel and reopen the editor before editing again.'; + + @override + String get tocTemplateDuplicateName => + 'A template with this name and extension already exists.'; + + @override + String get tocTemplateFormat => 'Format:'; + + @override + String get tocTemplateReset => 'Reset'; + + @override + String get tocTemplateRetry => 'Retry'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Remove $count TOC Elements'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'The source files associated with these TOC elements won\'t be deleted.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Select Topic to Add to the Current Instance'; + + @override + String get tocGroupName => 'Group Name'; + + @override + String get tocMoveLineUp => 'Move Line Up'; + + @override + String get tocMoveLineDown => 'Move Line Down'; + + @override + String get tocEmptyMdTopic => 'Empty MD Topic'; + + @override + String get tocEmptyXmlTopic => 'Empty XML Topic'; + + @override + String get tocCopySpecial => 'Copy Special'; + + @override + String tocCopyFileName(String name) { + return 'Topic File Name \'$name\''; + } + + @override + String get tocCopyFilePath => 'Topic File Path'; + + @override + String tocCopyTopicTitle(String title) { + return 'Topic Title \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'TOC Element ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Topic File Name'; + + @override + String get tocCopyTopicTitleUnavailable => 'Topic Title'; + + @override + String get tocCopyElementIdUnavailable => 'TOC Element ID'; + + @override + String get tocPreviewTopic => 'Preview Topic'; + + @override + String get tocEditTitleAction => 'Edit Title...'; + + @override + String get tocEditTitleDialog => 'Edit Title'; + + @override + String get tocRemoveElementAction => 'Remove TOC Element...'; + + @override + String get tocSetHomePage => 'Set as Home Page'; + + @override + String tocGoToElement(String fileName) { + return 'Go to TOC Element in \'$fileName\''; + } + + @override + String get tocSynchronize => 'Synchronize TOC and Editor'; + + @override + String get tocEmptyGroupAction => 'Empty Group'; + + @override + String get tocNewEmptyGroup => 'New Empty Group'; + + @override + String get tocGroupAction => 'Group'; + + @override + String get tocNewGroup => 'New Group'; + + @override + String get tocSortChildren => 'Sort Child Topics Alphabetically'; + + @override + String get tocLinkTopicFiles => 'Link Topic Files to TOC...'; + + @override + String get tocTitleField => 'TOC title:'; + + @override + String get tocTopicTitleField => 'Topic title:'; + + @override + String get tocAdvancedSettings => 'Advanced Settings'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Title for \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'TOC-only title:'; + + @override + String get tocFindUsages => 'Find'; + + @override + String get tocRefactorMenu => 'Refactor'; + + @override + String get tocSafeDelete => 'Safe Delete'; + + @override + String get tocDuplicate => 'Duplicate'; + + @override + String get tocDuplicateFilename => 'Topic Filename:'; + + @override + String get tocDuplicateDialog => 'Duplicate Topic'; + @override String get pdfTitlePage => 'Title page'; @@ -1156,7 +1369,7 @@ class AppLocalizationsEn extends AppLocalizations { String get files => 'Files'; @override - String get toc => 'Topics'; + String get toc => 'Table of Contents'; @override String get tocActions => 'TOC actions'; @@ -1194,7 +1407,7 @@ class AppLocalizationsEn extends AppLocalizations { String get newChildTopic => 'New Child Topic'; @override - String get newSiblingTopic => 'New Sibling Topic'; + String get newSiblingTopic => 'New Topic'; @override String get renameTopicFile => 'Rename Topic File'; @@ -3132,7 +3345,7 @@ class AppLocalizationsEn extends AppLocalizations { String get editInstance => 'Edit instance'; @override - String get openTocFile => 'Open TOC file'; + String get openTocFile => 'Open TOC File'; @override String get createInstance => 'Create instance'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 3ecdadc0..396ff134 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get tocTopicFromTemplate => 'Tema desde plantilla...'; + + @override + String get tocSaveAsTemplate => 'Guardar como plantilla'; + + @override + String get tocTemplateDialog => 'Crear tema desde plantilla'; + + @override + String get tocTemplatesEditor => 'Plantillas de archivo y código'; + + @override + String get tocEditTemplates => 'Editar plantillas...'; + + @override + String get tocCreateCustomTemplate => 'Crear plantilla personalizada...'; + + @override + String get tocTemplateCreated => 'Plantilla creada'; + + @override + String get tocTemplateDefault => 'Predeterminadas'; + + @override + String get tocTemplateCustom => 'Personalizadas'; + + @override + String get tocTemplateFilename => 'Nombre de archivo:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internas'; + + @override + String get tocTemplateUnnamed => 'Sin nombre'; + + @override + String get tocTemplateNew => 'Nueva plantilla...'; + + @override + String get tocTemplateName => 'Nombre:'; + + @override + String get tocTemplateExtension => 'Extensión:'; + + @override + String get tocTemplateLoadError => + 'No se pudieron cargar las plantillas. No se han cambiado las existentes.'; + + @override + String get tocTemplateSaveError => + 'No se pudieron guardar las plantillas. Los cambios siguen disponibles aquí.'; + + @override + String get tocTemplateConflict => + 'Las plantillas cambiaron en otra ventana. Cancela y vuelve a abrir el editor.'; + + @override + String get tocTemplateDuplicateName => + 'Ya existe una plantilla con este nombre y extensión.'; + + @override + String get tocTemplateFormat => 'Formato:'; + + @override + String get tocTemplateReset => 'Restablecer'; + + @override + String get tocTemplateRetry => 'Reintentar'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Eliminar $count elementos del índice'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'No se eliminarán los archivos fuente asociados a estos elementos del índice.'; + + @override + String get tocOk => 'Aceptar'; + + @override + String get tocSelectExistingTopic => + 'Seleccionar un tema para añadir a la instancia actual'; + + @override + String get tocGroupName => 'Nombre del grupo'; + + @override + String get tocMoveLineUp => 'Mover línea hacia arriba'; + + @override + String get tocMoveLineDown => 'Mover línea hacia abajo'; + + @override + String get tocEmptyMdTopic => 'Tema MD vacío'; + + @override + String get tocEmptyXmlTopic => 'Tema XML vacío'; + + @override + String get tocCopySpecial => 'Copia especial'; + + @override + String tocCopyFileName(String name) { + return 'Nombre del archivo del tema \'$name\''; + } + + @override + String get tocCopyFilePath => 'Ruta del archivo del tema'; + + @override + String tocCopyTopicTitle(String title) { + return 'Título del tema \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID del elemento del índice \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nombre del archivo del tema'; + + @override + String get tocCopyTopicTitleUnavailable => 'Título del tema'; + + @override + String get tocCopyElementIdUnavailable => 'ID del elemento del índice'; + + @override + String get tocPreviewTopic => 'Vista previa del tema'; + + @override + String get tocEditTitleAction => 'Editar título...'; + + @override + String get tocEditTitleDialog => 'Editar título'; + + @override + String get tocRemoveElementAction => 'Quitar elemento del índice...'; + + @override + String get tocSetHomePage => 'Establecer como página de inicio'; + + @override + String tocGoToElement(String fileName) { + return 'Ir al elemento del índice en \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sincronizar índice y editor'; + + @override + String get tocEmptyGroupAction => 'Grupo vacío'; + + @override + String get tocNewEmptyGroup => 'Nuevo grupo vacío'; + + @override + String get tocGroupAction => 'Agrupar'; + + @override + String get tocNewGroup => 'Nuevo grupo'; + + @override + String get tocSortChildren => 'Ordenar temas secundarios alfabéticamente'; + + @override + String get tocLinkTopicFiles => 'Vincular archivos de temas al índice...'; + + @override + String get tocTitleField => 'Título del índice:'; + + @override + String get tocTopicTitleField => 'Título del tema:'; + + @override + String get tocAdvancedSettings => 'Configuración avanzada'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Título para \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Título exclusivo del índice:'; + + @override + String get tocFindUsages => 'Buscar'; + + @override + String get tocRefactorMenu => 'Refactorizar'; + + @override + String get tocSafeDelete => 'Eliminación segura'; + + @override + String get tocDuplicate => 'Duplicar'; + + @override + String get tocDuplicateFilename => 'Nombre del archivo del tema:'; + + @override + String get tocDuplicateDialog => 'Duplicar tema'; + @override String get pdfTitlePage => 'Portada'; @@ -1176,7 +1389,7 @@ class AppLocalizationsEs extends AppLocalizations { String get files => 'Archivos'; @override - String get toc => 'Temas'; + String get toc => 'Tabla de contenidos'; @override String get tocActions => 'Acciones del índice'; @@ -1214,7 +1427,7 @@ class AppLocalizationsEs extends AppLocalizations { String get newChildTopic => 'Nuevo subtema'; @override - String get newSiblingTopic => 'Nuevo tema del mismo nivel'; + String get newSiblingTopic => 'Nuevo tema'; @override String get renameTopicFile => 'Renombrar archivo del tema'; @@ -3167,7 +3380,7 @@ class AppLocalizationsEs extends AppLocalizations { String get editInstance => 'Editar instancia'; @override - String get openTocFile => 'Abrir archivo de TOC'; + String get openTocFile => 'Abrir archivo del índice'; @override String get createInstance => 'Crear instancia'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 11489004..559b5d73 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get tocTopicFromTemplate => 'Teema mallist...'; + + @override + String get tocSaveAsTemplate => 'Salvesta mallina'; + + @override + String get tocTemplateDialog => 'Loo teema mallist'; + + @override + String get tocTemplatesEditor => 'Faili- ja koodimallid'; + + @override + String get tocEditTemplates => 'Muuda malle...'; + + @override + String get tocCreateCustomTemplate => 'Loo kohandatud mall...'; + + @override + String get tocTemplateCreated => 'Mall loodud'; + + @override + String get tocTemplateDefault => 'Vaikimisi'; + + @override + String get tocTemplateCustom => 'Kohandatud'; + + @override + String get tocTemplateFilename => 'Failinimi:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Sisemised'; + + @override + String get tocTemplateUnnamed => 'Nimetu'; + + @override + String get tocTemplateNew => 'Uus mall...'; + + @override + String get tocTemplateName => 'Nimi:'; + + @override + String get tocTemplateExtension => 'Laiend:'; + + @override + String get tocTemplateLoadError => + 'Malle ei saanud laadida. Olemasolevaid malle pole muudetud.'; + + @override + String get tocTemplateSaveError => + 'Malle ei saanud salvestada. Muudatused on siin endiselt alles.'; + + @override + String get tocTemplateConflict => + 'Malle muudeti teises aknas. Tühista ja ava redaktor uuesti.'; + + @override + String get tocTemplateDuplicateName => + 'Selle nime ja laiendiga mall on juba olemas.'; + + @override + String get tocTemplateFormat => 'Vorming:'; + + @override + String get tocTemplateReset => 'Lähtesta'; + + @override + String get tocTemplateRetry => 'Proovi uuesti'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Eemalda $count sisukorraelementi'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Nende sisukorraelementidega seotud lähtefaile ei kustutata.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Vali praegusesse eksemplari lisatav teema'; + + @override + String get tocGroupName => 'Rühma nimi'; + + @override + String get tocMoveLineUp => 'Liiguta rida üles'; + + @override + String get tocMoveLineDown => 'Liiguta rida alla'; + + @override + String get tocEmptyMdTopic => 'Tühi MD-teema'; + + @override + String get tocEmptyXmlTopic => 'Tühi XML-teema'; + + @override + String get tocCopySpecial => 'Erikopeerimine'; + + @override + String tocCopyFileName(String name) { + return 'Teemafaili nimi \'$name\''; + } + + @override + String get tocCopyFilePath => 'Teemafaili tee'; + + @override + String tocCopyTopicTitle(String title) { + return 'Teema pealkiri \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'Sisukorra elemendi ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Teemafaili nimi'; + + @override + String get tocCopyTopicTitleUnavailable => 'Teema pealkiri'; + + @override + String get tocCopyElementIdUnavailable => 'Sisukorra elemendi ID'; + + @override + String get tocPreviewTopic => 'Teema eelvaade'; + + @override + String get tocEditTitleAction => 'Muuda pealkirja...'; + + @override + String get tocEditTitleDialog => 'Muuda pealkirja'; + + @override + String get tocRemoveElementAction => 'Eemalda sisukorra element...'; + + @override + String get tocSetHomePage => 'Määra avaleheks'; + + @override + String tocGoToElement(String fileName) { + return 'Mine sisukorra elemendi juurde failis \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sünkrooni sisukord ja redaktor'; + + @override + String get tocEmptyGroupAction => 'Tühi rühm'; + + @override + String get tocNewEmptyGroup => 'Uus tühi rühm'; + + @override + String get tocGroupAction => 'Rühmita'; + + @override + String get tocNewGroup => 'Uus rühm'; + + @override + String get tocSortChildren => 'Sordi alamteemad tähestikuliselt'; + + @override + String get tocLinkTopicFiles => 'Lingi teemafailid sisukorraga...'; + + @override + String get tocTitleField => 'Sisukorra pealkiri:'; + + @override + String get tocTopicTitleField => 'Teema pealkiri:'; + + @override + String get tocAdvancedSettings => 'Täpsemad seaded'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Pealkiri eksemplarile \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Ainult sisukorra pealkiri:'; + + @override + String get tocFindUsages => 'Otsi'; + + @override + String get tocRefactorMenu => 'Refaktoreeri'; + + @override + String get tocSafeDelete => 'Turvaline kustutamine'; + + @override + String get tocDuplicate => 'Dubleeri'; + + @override + String get tocDuplicateFilename => 'Teemafaili nimi:'; + + @override + String get tocDuplicateDialog => 'Dubleeri teema'; + @override String get pdfTitlePage => 'Tiitelleht'; @@ -1160,7 +1373,7 @@ class AppLocalizationsEt extends AppLocalizations { String get files => 'Failid'; @override - String get toc => 'Teemad'; + String get toc => 'Sisukord'; @override String get tocActions => 'Sisukorra toimingud'; @@ -1198,7 +1411,7 @@ class AppLocalizationsEt extends AppLocalizations { String get newChildTopic => 'Uus alamteema'; @override - String get newSiblingTopic => 'Uus samatasemeline teema'; + String get newSiblingTopic => 'Uus teema'; @override String get renameTopicFile => 'Nimeta teemafail ümber'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index e4bc18c9..8d62c5c2 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get tocTopicFromTemplate => 'موضوع از الگو...'; + + @override + String get tocSaveAsTemplate => 'ذخیره به‌عنوان الگو'; + + @override + String get tocTemplateDialog => 'ایجاد موضوع از الگو'; + + @override + String get tocTemplatesEditor => 'الگوهای فایل و کد'; + + @override + String get tocEditTemplates => 'ویرایش الگوها...'; + + @override + String get tocCreateCustomTemplate => 'ایجاد الگوی سفارشی...'; + + @override + String get tocTemplateCreated => 'الگو ایجاد شد'; + + @override + String get tocTemplateDefault => 'پیش‌فرض'; + + @override + String get tocTemplateCustom => 'سفارشی'; + + @override + String get tocTemplateFilename => 'نام فایل:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'داخلی'; + + @override + String get tocTemplateUnnamed => 'بی‌نام'; + + @override + String get tocTemplateNew => 'الگوی جدید...'; + + @override + String get tocTemplateName => 'نام:'; + + @override + String get tocTemplateExtension => 'پسوند:'; + + @override + String get tocTemplateLoadError => + 'بارگیری الگوها ممکن نشد. الگوهای موجود تغییر نکرده‌اند.'; + + @override + String get tocTemplateSaveError => + 'ذخیره الگوها ممکن نشد. ویرایش‌های شما همچنان اینجا هستند.'; + + @override + String get tocTemplateConflict => + 'الگوها در پنجره دیگری تغییر کرده‌اند. لغو کنید و ویرایشگر را دوباره باز کنید.'; + + @override + String get tocTemplateDuplicateName => + 'الگویی با این نام و پسوند از قبل وجود دارد.'; + + @override + String get tocTemplateFormat => 'قالب:'; + + @override + String get tocTemplateReset => 'بازنشانی'; + + @override + String get tocTemplateRetry => 'تلاش مجدد'; + + @override + String tocRemoveElementsDialog(int count) { + return 'حذف $count عنصر فهرست مطالب'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'فایل‌های منبع مرتبط با این عناصر فهرست مطالب حذف نخواهند شد.'; + + @override + String get tocOk => 'تأیید'; + + @override + String get tocSelectExistingTopic => + 'موضوعی برای افزودن به نمونهٔ کنونی انتخاب کنید'; + + @override + String get tocGroupName => 'نام گروه'; + + @override + String get tocMoveLineUp => 'انتقال سطر به بالا'; + + @override + String get tocMoveLineDown => 'انتقال سطر به پایین'; + + @override + String get tocEmptyMdTopic => 'موضوع MD خالی'; + + @override + String get tocEmptyXmlTopic => 'موضوع XML خالی'; + + @override + String get tocCopySpecial => 'کپی ویژه'; + + @override + String tocCopyFileName(String name) { + return 'نام پروندهٔ موضوع \'$name\''; + } + + @override + String get tocCopyFilePath => 'مسیر پروندهٔ موضوع'; + + @override + String tocCopyTopicTitle(String title) { + return 'عنوان موضوع \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'شناسهٔ عنصر فهرست مطالب \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'نام پروندهٔ موضوع'; + + @override + String get tocCopyTopicTitleUnavailable => 'عنوان موضوع'; + + @override + String get tocCopyElementIdUnavailable => 'شناسهٔ عنصر فهرست مطالب'; + + @override + String get tocPreviewTopic => 'پیش‌نمایش موضوع'; + + @override + String get tocEditTitleAction => 'ویرایش عنوان...'; + + @override + String get tocEditTitleDialog => 'ویرایش عنوان'; + + @override + String get tocRemoveElementAction => 'حذف عنصر فهرست مطالب...'; + + @override + String get tocSetHomePage => 'تنظیم به‌عنوان صفحهٔ اصلی'; + + @override + String tocGoToElement(String fileName) { + return 'رفتن به عنصر فهرست مطالب در «$fileName»'; + } + + @override + String get tocSynchronize => 'همگام‌سازی فهرست مطالب و ویرایشگر'; + + @override + String get tocEmptyGroupAction => 'گروه خالی'; + + @override + String get tocNewEmptyGroup => 'گروه خالی جدید'; + + @override + String get tocGroupAction => 'گروه‌بندی'; + + @override + String get tocNewGroup => 'گروه جدید'; + + @override + String get tocSortChildren => 'مرتب‌سازی الفبایی زیرموضوع‌ها'; + + @override + String get tocLinkTopicFiles => 'پیوند پرونده‌های موضوع به فهرست مطالب...'; + + @override + String get tocTitleField => 'عنوان فهرست مطالب:'; + + @override + String get tocTopicTitleField => 'عنوان موضوع:'; + + @override + String get tocAdvancedSettings => 'تنظیمات پیشرفته'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'عنوان برای «$instanceId»:'; + } + + @override + String get tocOnlyTitleField => 'عنوان مختص فهرست مطالب:'; + + @override + String get tocFindUsages => 'یافتن'; + + @override + String get tocRefactorMenu => 'بازآرایی'; + + @override + String get tocSafeDelete => 'حذف ایمن'; + + @override + String get tocDuplicate => 'تکثیر'; + + @override + String get tocDuplicateFilename => 'نام پروندهٔ موضوع:'; + + @override + String get tocDuplicateDialog => 'تکثیر موضوع'; + @override String get pdfTitlePage => 'صفحهٔ عنوان'; @@ -1180,7 +1393,7 @@ class AppLocalizationsFa extends AppLocalizations { String get files => 'فایل‌ها'; @override - String get toc => 'موضوعات'; + String get toc => 'فهرست مطالب'; @override String get tocActions => 'عملیات فهرست مطالب'; @@ -1222,7 +1435,7 @@ class AppLocalizationsFa extends AppLocalizations { String get newChildTopic => 'موضوع فرزند جدید'; @override - String get newSiblingTopic => 'موضوع هم‌سطح جدید'; + String get newSiblingTopic => 'موضوع جدید'; @override String get renameTopicFile => 'تغییر نام فایل موضوع'; @@ -3180,7 +3393,7 @@ class AppLocalizationsFa extends AppLocalizations { String get editInstance => 'ویرایش نمونه'; @override - String get openTocFile => 'باز کردن فایل فهرست مطالب'; + String get openTocFile => 'باز کردن پروندهٔ فهرست مطالب'; @override String get createInstance => 'ایجاد نمونه'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 16ba3ee7..45816b0c 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -10,6 +10,224 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get tocTopicFromTemplate => 'Sujet à partir d’un modèle...'; + + @override + String get tocSaveAsTemplate => 'Enregistrer comme modèle'; + + @override + String get tocTemplateDialog => 'Créer un sujet à partir d’un modèle'; + + @override + String get tocTemplatesEditor => 'Modèles de fichiers et de code'; + + @override + String get tocEditTemplates => 'Modifier les modèles...'; + + @override + String get tocCreateCustomTemplate => 'Créer un modèle personnalisé...'; + + @override + String get tocTemplateCreated => 'Modèle créé'; + + @override + String get tocTemplateDefault => 'Par défaut'; + + @override + String get tocTemplateCustom => 'Personnalisés'; + + @override + String get tocTemplateFilename => 'Nom du fichier :'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internes'; + + @override + String get tocTemplateUnnamed => 'Sans nom'; + + @override + String get tocTemplateNew => 'Nouveau modèle...'; + + @override + String get tocTemplateName => 'Nom :'; + + @override + String get tocTemplateExtension => 'Extension :'; + + @override + String get tocTemplateLoadError => + 'Impossible de charger les modèles. Les modèles existants n’ont pas été modifiés.'; + + @override + String get tocTemplateSaveError => + 'Impossible d’enregistrer les modèles. Vos modifications sont toujours disponibles ici.'; + + @override + String get tocTemplateConflict => + 'Les modèles ont changé dans une autre fenêtre. Annulez et rouvrez l’éditeur.'; + + @override + String get tocTemplateDuplicateName => + 'Un modèle portant ce nom et cette extension existe déjà.'; + + @override + String get tocTemplateFormat => 'Format :'; + + @override + String get tocTemplateReset => 'Réinitialiser'; + + @override + String get tocTemplateRetry => 'Réessayer'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Retirer $count éléments de la table des matières'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Les fichiers source associés à ces éléments de la table des matières ne seront pas supprimés.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Sélectionner un sujet à ajouter à l’instance actuelle'; + + @override + String get tocGroupName => 'Nom du groupe'; + + @override + String get tocMoveLineUp => 'Déplacer la ligne vers le haut'; + + @override + String get tocMoveLineDown => 'Déplacer la ligne vers le bas'; + + @override + String get tocEmptyMdTopic => 'Sujet MD vide'; + + @override + String get tocEmptyXmlTopic => 'Sujet XML vide'; + + @override + String get tocCopySpecial => 'Copie spéciale'; + + @override + String tocCopyFileName(String name) { + return 'Nom du fichier du sujet \'$name\''; + } + + @override + String get tocCopyFilePath => 'Chemin du fichier du sujet'; + + @override + String tocCopyTopicTitle(String title) { + return 'Titre du sujet \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID de l’élément de la table des matières \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nom du fichier du sujet'; + + @override + String get tocCopyTopicTitleUnavailable => 'Titre du sujet'; + + @override + String get tocCopyElementIdUnavailable => + 'ID de l’élément de la table des matières'; + + @override + String get tocPreviewTopic => 'Aperçu du sujet'; + + @override + String get tocEditTitleAction => 'Modifier le titre...'; + + @override + String get tocEditTitleDialog => 'Modifier le titre'; + + @override + String get tocRemoveElementAction => + 'Retirer l’élément de la table des matières...'; + + @override + String get tocSetHomePage => 'Définir comme page d’accueil'; + + @override + String tocGoToElement(String fileName) { + return 'Accéder à l’élément de la table des matières dans « $fileName »'; + } + + @override + String get tocSynchronize => + 'Synchroniser la table des matières et l’éditeur'; + + @override + String get tocEmptyGroupAction => 'Groupe vide'; + + @override + String get tocNewEmptyGroup => 'Nouveau groupe vide'; + + @override + String get tocGroupAction => 'Grouper'; + + @override + String get tocNewGroup => 'Nouveau groupe'; + + @override + String get tocSortChildren => + 'Trier les sujets enfants par ordre alphabétique'; + + @override + String get tocLinkTopicFiles => + 'Lier des fichiers de sujets à la table des matières...'; + + @override + String get tocTitleField => 'Titre dans la table des matières:'; + + @override + String get tocTopicTitleField => 'Titre du sujet:'; + + @override + String get tocAdvancedSettings => 'Paramètres avancés'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Titre pour « $instanceId »:'; + } + + @override + String get tocOnlyTitleField => 'Titre propre à la table des matières:'; + + @override + String get tocFindUsages => 'Rechercher'; + + @override + String get tocRefactorMenu => 'Refactoriser'; + + @override + String get tocSafeDelete => 'Suppression sécurisée'; + + @override + String get tocDuplicate => 'Dupliquer'; + + @override + String get tocDuplicateFilename => 'Nom du fichier du sujet:'; + + @override + String get tocDuplicateDialog => 'Dupliquer le sujet'; + @override String get pdfTitlePage => 'Page de titre'; @@ -1174,7 +1392,7 @@ class AppLocalizationsFr extends AppLocalizations { String get files => 'Fichiers'; @override - String get toc => 'Sujets'; + String get toc => 'Table des matières'; @override String get tocActions => 'Actions sur la table des matières'; @@ -1212,7 +1430,7 @@ class AppLocalizationsFr extends AppLocalizations { String get newChildTopic => 'Nouveau sous-sujet'; @override - String get newSiblingTopic => 'Nouveau sujet de même niveau'; + String get newSiblingTopic => 'Nouveau sujet'; @override String get renameTopicFile => 'Renommer le fichier du sujet'; @@ -3162,7 +3380,7 @@ class AppLocalizationsFr extends AppLocalizations { String get editInstance => 'Modifier l’instance'; @override - String get openTocFile => 'Ouvrir le fichier de sommaire'; + String get openTocFile => 'Ouvrir le fichier de table des matières'; @override String get createInstance => 'Créer une instance'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 75c873b3..9a79aaef 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get tocTopicFromTemplate => 'टेम्पलेट से विषय...'; + + @override + String get tocSaveAsTemplate => 'टेम्पलेट के रूप में सहेजें'; + + @override + String get tocTemplateDialog => 'टेम्पलेट से विषय बनाएँ'; + + @override + String get tocTemplatesEditor => 'फ़ाइल और कोड टेम्पलेट'; + + @override + String get tocEditTemplates => 'टेम्पलेट संपादित करें...'; + + @override + String get tocCreateCustomTemplate => 'कस्टम टेम्पलेट बनाएँ...'; + + @override + String get tocTemplateCreated => 'टेम्पलेट बनाया गया'; + + @override + String get tocTemplateDefault => 'डिफ़ॉल्ट'; + + @override + String get tocTemplateCustom => 'कस्टम'; + + @override + String get tocTemplateFilename => 'फ़ाइल का नाम:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'आंतरिक'; + + @override + String get tocTemplateUnnamed => 'बिना नाम'; + + @override + String get tocTemplateNew => 'नया टेम्पलेट...'; + + @override + String get tocTemplateName => 'नाम:'; + + @override + String get tocTemplateExtension => 'एक्सटेंशन:'; + + @override + String get tocTemplateLoadError => + 'टेम्पलेट लोड नहीं हो सके। मौजूदा टेम्पलेट बदले नहीं गए हैं।'; + + @override + String get tocTemplateSaveError => + 'टेम्पलेट सहेजे नहीं जा सके। आपके संपादन अभी भी यहाँ उपलब्ध हैं।'; + + @override + String get tocTemplateConflict => + 'टेम्पलेट दूसरी विंडो में बदल गए हैं। रद्द करें और संपादक फिर से खोलें।'; + + @override + String get tocTemplateDuplicateName => + 'इस नाम और एक्सटेंशन वाला टेम्पलेट पहले से मौजूद है।'; + + @override + String get tocTemplateFormat => 'प्रारूप:'; + + @override + String get tocTemplateReset => 'रीसेट करें'; + + @override + String get tocTemplateRetry => 'फिर से कोशिश करें'; + + @override + String tocRemoveElementsDialog(int count) { + return 'विषय-सूची के $count तत्व हटाएँ'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'इन विषय-सूची तत्वों से जुड़ी स्रोत फ़ाइलें नहीं हटाई जाएँगी।'; + + @override + String get tocOk => 'ठीक है'; + + @override + String get tocSelectExistingTopic => + 'वर्तमान इंस्टेंस में जोड़ने के लिए विषय चुनें'; + + @override + String get tocGroupName => 'समूह का नाम'; + + @override + String get tocMoveLineUp => 'पंक्ति ऊपर ले जाएँ'; + + @override + String get tocMoveLineDown => 'पंक्ति नीचे ले जाएँ'; + + @override + String get tocEmptyMdTopic => 'खाली MD विषय'; + + @override + String get tocEmptyXmlTopic => 'खाली XML विषय'; + + @override + String get tocCopySpecial => 'विशेष कॉपी'; + + @override + String tocCopyFileName(String name) { + return 'विषय फ़ाइल का नाम \'$name\''; + } + + @override + String get tocCopyFilePath => 'विषय फ़ाइल का पथ'; + + @override + String tocCopyTopicTitle(String title) { + return 'विषय का शीर्षक \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'विषय-सूची तत्व की ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'विषय फ़ाइल का नाम'; + + @override + String get tocCopyTopicTitleUnavailable => 'विषय का शीर्षक'; + + @override + String get tocCopyElementIdUnavailable => 'विषय-सूची तत्व की ID'; + + @override + String get tocPreviewTopic => 'विषय का पूर्वावलोकन'; + + @override + String get tocEditTitleAction => 'शीर्षक संपादित करें...'; + + @override + String get tocEditTitleDialog => 'शीर्षक संपादित करें'; + + @override + String get tocRemoveElementAction => 'विषय-सूची तत्व हटाएँ...'; + + @override + String get tocSetHomePage => 'मुखपृष्ठ के रूप में सेट करें'; + + @override + String tocGoToElement(String fileName) { + return '\'$fileName\' में विषय-सूची तत्व पर जाएँ'; + } + + @override + String get tocSynchronize => 'विषय-सूची और संपादक सिंक्रनाइज़ करें'; + + @override + String get tocEmptyGroupAction => 'खाली समूह'; + + @override + String get tocNewEmptyGroup => 'नया खाली समूह'; + + @override + String get tocGroupAction => 'समूह बनाएँ'; + + @override + String get tocNewGroup => 'नया समूह'; + + @override + String get tocSortChildren => 'उपविषयों को वर्णानुक्रम में क्रमबद्ध करें'; + + @override + String get tocLinkTopicFiles => 'विषय फ़ाइलों को विषय-सूची से लिंक करें...'; + + @override + String get tocTitleField => 'विषय-सूची का शीर्षक:'; + + @override + String get tocTopicTitleField => 'विषय का शीर्षक:'; + + @override + String get tocAdvancedSettings => 'उन्नत सेटिंग्स'; + + @override + String tocInstanceTitleField(String instanceId) { + return '\'$instanceId\' के लिए शीर्षक:'; + } + + @override + String get tocOnlyTitleField => 'केवल विषय-सूची का शीर्षक:'; + + @override + String get tocFindUsages => 'खोजें'; + + @override + String get tocRefactorMenu => 'रिफ़ैक्टर'; + + @override + String get tocSafeDelete => 'सुरक्षित रूप से हटाएँ'; + + @override + String get tocDuplicate => 'प्रतिलिपि बनाएँ'; + + @override + String get tocDuplicateFilename => 'विषय फ़ाइल का नाम:'; + + @override + String get tocDuplicateDialog => 'विषय की प्रतिलिपि बनाएँ'; + @override String get pdfTitlePage => 'शीर्षक पृष्ठ'; @@ -1156,7 +1369,7 @@ class AppLocalizationsHi extends AppLocalizations { String get files => 'फ़ाइलें'; @override - String get toc => 'विषय'; + String get toc => 'विषय-सूची'; @override String get tocActions => 'विषय-सूची संबंधी कार्रवाइयाँ'; @@ -1194,7 +1407,7 @@ class AppLocalizationsHi extends AppLocalizations { String get newChildTopic => 'नया उप-विषय'; @override - String get newSiblingTopic => 'समान स्तर पर नया विषय'; + String get newSiblingTopic => 'नया विषय'; @override String get renameTopicFile => 'विषय फ़ाइल का नाम बदलें'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index f6aaaece..84004aaa 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsId extends AppLocalizations { AppLocalizationsId([String locale = 'id']) : super(locale); + @override + String get tocTopicFromTemplate => 'Topik dari Templat...'; + + @override + String get tocSaveAsTemplate => 'Simpan sebagai Templat'; + + @override + String get tocTemplateDialog => 'Buat Topik dari Templat'; + + @override + String get tocTemplatesEditor => 'Templat File dan Kode'; + + @override + String get tocEditTemplates => 'Edit templat...'; + + @override + String get tocCreateCustomTemplate => 'Buat templat khusus...'; + + @override + String get tocTemplateCreated => 'Templat dibuat'; + + @override + String get tocTemplateDefault => 'Bawaan'; + + @override + String get tocTemplateCustom => 'Khusus'; + + @override + String get tocTemplateFilename => 'Nama file:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internal'; + + @override + String get tocTemplateUnnamed => 'Tanpa nama'; + + @override + String get tocTemplateNew => 'Templat baru...'; + + @override + String get tocTemplateName => 'Nama:'; + + @override + String get tocTemplateExtension => 'Ekstensi:'; + + @override + String get tocTemplateLoadError => + 'Tidak dapat memuat templat. Templat yang ada tidak diubah.'; + + @override + String get tocTemplateSaveError => + 'Tidak dapat menyimpan templat. Edit Anda masih tersedia di sini.'; + + @override + String get tocTemplateConflict => + 'Templat berubah di jendela lain. Batalkan dan buka kembali editor.'; + + @override + String get tocTemplateDuplicateName => + 'Templat dengan nama dan ekstensi ini sudah ada.'; + + @override + String get tocTemplateFormat => 'Format:'; + + @override + String get tocTemplateReset => 'Atur ulang'; + + @override + String get tocTemplateRetry => 'Coba lagi'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Hapus $count Elemen Daftar Isi'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Berkas sumber yang terkait dengan elemen daftar isi ini tidak akan dihapus.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Pilih topik untuk ditambahkan ke instans saat ini'; + + @override + String get tocGroupName => 'Nama grup'; + + @override + String get tocMoveLineUp => 'Pindahkan baris ke atas'; + + @override + String get tocMoveLineDown => 'Pindahkan baris ke bawah'; + + @override + String get tocEmptyMdTopic => 'Topik MD kosong'; + + @override + String get tocEmptyXmlTopic => 'Topik XML kosong'; + + @override + String get tocCopySpecial => 'Salin khusus'; + + @override + String tocCopyFileName(String name) { + return 'Nama berkas topik \'$name\''; + } + + @override + String get tocCopyFilePath => 'Jalur berkas topik'; + + @override + String tocCopyTopicTitle(String title) { + return 'Judul topik \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID elemen daftar isi \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nama berkas topik'; + + @override + String get tocCopyTopicTitleUnavailable => 'Judul topik'; + + @override + String get tocCopyElementIdUnavailable => 'ID elemen daftar isi'; + + @override + String get tocPreviewTopic => 'Pratinjau topik'; + + @override + String get tocEditTitleAction => 'Edit judul...'; + + @override + String get tocEditTitleDialog => 'Edit judul'; + + @override + String get tocRemoveElementAction => 'Hapus elemen daftar isi...'; + + @override + String get tocSetHomePage => 'Jadikan halaman utama'; + + @override + String tocGoToElement(String fileName) { + return 'Buka elemen daftar isi di \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sinkronkan daftar isi dan editor'; + + @override + String get tocEmptyGroupAction => 'Grup kosong'; + + @override + String get tocNewEmptyGroup => 'Grup kosong baru'; + + @override + String get tocGroupAction => 'Kelompokkan'; + + @override + String get tocNewGroup => 'Grup baru'; + + @override + String get tocSortChildren => 'Urutkan subtopik berdasarkan abjad'; + + @override + String get tocLinkTopicFiles => 'Tautkan berkas topik ke daftar isi...'; + + @override + String get tocTitleField => 'Judul daftar isi:'; + + @override + String get tocTopicTitleField => 'Judul topik:'; + + @override + String get tocAdvancedSettings => 'Pengaturan lanjutan'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Judul untuk \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Judul khusus daftar isi:'; + + @override + String get tocFindUsages => 'Cari'; + + @override + String get tocRefactorMenu => 'Refaktor'; + + @override + String get tocSafeDelete => 'Hapus dengan aman'; + + @override + String get tocDuplicate => 'Duplikat'; + + @override + String get tocDuplicateFilename => 'Nama berkas topik:'; + + @override + String get tocDuplicateDialog => 'Duplikat topik'; + @override String get pdfTitlePage => 'Halaman judul'; @@ -1162,7 +1375,7 @@ class AppLocalizationsId extends AppLocalizations { String get files => 'File'; @override - String get toc => 'Topik'; + String get toc => 'Daftar Isi'; @override String get tocActions => 'Tindakan TOC'; @@ -1200,7 +1413,7 @@ class AppLocalizationsId extends AppLocalizations { String get newChildTopic => 'Topik anak baru'; @override - String get newSiblingTopic => 'Topik saudara baru'; + String get newSiblingTopic => 'Topik baru'; @override String get renameTopicFile => 'Ganti nama file topik'; @@ -3141,7 +3354,7 @@ class AppLocalizationsId extends AppLocalizations { String get editInstance => 'Ubah instance'; @override - String get openTocFile => 'Buka file TOC'; + String get openTocFile => 'Buka berkas daftar isi'; @override String get createInstance => 'Buat instance'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index eb39509e..159e8b2e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -10,6 +10,220 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get tocTopicFromTemplate => 'Argomento da modello...'; + + @override + String get tocSaveAsTemplate => 'Salva come modello'; + + @override + String get tocTemplateDialog => 'Crea argomento da modello'; + + @override + String get tocTemplatesEditor => 'Modelli di file e codice'; + + @override + String get tocEditTemplates => 'Modifica modelli...'; + + @override + String get tocCreateCustomTemplate => 'Crea modello personalizzato...'; + + @override + String get tocTemplateCreated => 'Modello creato'; + + @override + String get tocTemplateDefault => 'Predefiniti'; + + @override + String get tocTemplateCustom => 'Personalizzati'; + + @override + String get tocTemplateFilename => 'Nome file:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Interni'; + + @override + String get tocTemplateUnnamed => 'Senza nome'; + + @override + String get tocTemplateNew => 'Nuovo modello...'; + + @override + String get tocTemplateName => 'Nome:'; + + @override + String get tocTemplateExtension => 'Estensione:'; + + @override + String get tocTemplateLoadError => + 'Impossibile caricare i modelli. I modelli esistenti non sono stati modificati.'; + + @override + String get tocTemplateSaveError => + 'Impossibile salvare i modelli. Le modifiche sono ancora disponibili qui.'; + + @override + String get tocTemplateConflict => + 'I modelli sono cambiati in un’altra finestra. Annulla e riapri l’editor.'; + + @override + String get tocTemplateDuplicateName => + 'Esiste già un modello con questo nome ed estensione.'; + + @override + String get tocTemplateFormat => 'Formato:'; + + @override + String get tocTemplateReset => 'Ripristina'; + + @override + String get tocTemplateRetry => 'Riprova'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Rimuovi $count elementi dell’indice'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'I file sorgente associati a questi elementi dell’indice non verranno eliminati.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Seleziona un argomento da aggiungere all’istanza corrente'; + + @override + String get tocGroupName => 'Nome del gruppo'; + + @override + String get tocMoveLineUp => 'Sposta riga in alto'; + + @override + String get tocMoveLineDown => 'Sposta riga in basso'; + + @override + String get tocEmptyMdTopic => 'Argomento MD vuoto'; + + @override + String get tocEmptyXmlTopic => 'Argomento XML vuoto'; + + @override + String get tocCopySpecial => 'Copia speciale'; + + @override + String tocCopyFileName(String name) { + return 'Nome del file dell’argomento \'$name\''; + } + + @override + String get tocCopyFilePath => 'Percorso del file dell’argomento'; + + @override + String tocCopyTopicTitle(String title) { + return 'Titolo dell’argomento \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID dell’elemento del sommario \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nome del file dell’argomento'; + + @override + String get tocCopyTopicTitleUnavailable => 'Titolo dell’argomento'; + + @override + String get tocCopyElementIdUnavailable => 'ID dell’elemento del sommario'; + + @override + String get tocPreviewTopic => 'Anteprima argomento'; + + @override + String get tocEditTitleAction => 'Modifica titolo...'; + + @override + String get tocEditTitleDialog => 'Modifica titolo'; + + @override + String get tocRemoveElementAction => 'Rimuovi elemento del sommario...'; + + @override + String get tocSetHomePage => 'Imposta come pagina iniziale'; + + @override + String tocGoToElement(String fileName) { + return 'Vai all’elemento del sommario in \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sincronizza sommario ed editor'; + + @override + String get tocEmptyGroupAction => 'Gruppo vuoto'; + + @override + String get tocNewEmptyGroup => 'Nuovo gruppo vuoto'; + + @override + String get tocGroupAction => 'Raggruppa'; + + @override + String get tocNewGroup => 'Nuovo gruppo'; + + @override + String get tocSortChildren => + 'Ordina alfabeticamente gli argomenti secondari'; + + @override + String get tocLinkTopicFiles => 'Collega file di argomenti al sommario...'; + + @override + String get tocTitleField => 'Titolo nel sommario:'; + + @override + String get tocTopicTitleField => 'Titolo dell’argomento:'; + + @override + String get tocAdvancedSettings => 'Impostazioni avanzate'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Titolo per \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Titolo solo nel sommario:'; + + @override + String get tocFindUsages => 'Trova'; + + @override + String get tocRefactorMenu => 'Refactoring'; + + @override + String get tocSafeDelete => 'Eliminazione sicura'; + + @override + String get tocDuplicate => 'Duplica'; + + @override + String get tocDuplicateFilename => 'Nome del file dell’argomento:'; + + @override + String get tocDuplicateDialog => 'Duplica argomento'; + @override String get pdfTitlePage => 'Frontespizio'; @@ -1171,7 +1385,7 @@ class AppLocalizationsIt extends AppLocalizations { String get files => 'File'; @override - String get toc => 'Argomenti'; + String get toc => 'Sommario'; @override String get tocActions => 'Azioni dell\'indice'; @@ -1209,7 +1423,7 @@ class AppLocalizationsIt extends AppLocalizations { String get newChildTopic => 'Nuovo sottoargomento'; @override - String get newSiblingTopic => 'Nuovo argomento allo stesso livello'; + String get newSiblingTopic => 'Nuovo argomento'; @override String get renameTopicFile => 'Rinomina il file dell\'argomento'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index f69d95fb..1a200e6c 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -10,6 +10,214 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get tocTopicFromTemplate => 'テンプレートからトピック...'; + + @override + String get tocSaveAsTemplate => 'テンプレートとして保存'; + + @override + String get tocTemplateDialog => 'テンプレートからトピックを作成'; + + @override + String get tocTemplatesEditor => 'ファイルおよびコードテンプレート'; + + @override + String get tocEditTemplates => 'テンプレートを編集...'; + + @override + String get tocCreateCustomTemplate => 'カスタムテンプレートを作成...'; + + @override + String get tocTemplateCreated => 'テンプレートを作成しました'; + + @override + String get tocTemplateDefault => 'デフォルト'; + + @override + String get tocTemplateCustom => 'カスタム'; + + @override + String get tocTemplateFilename => 'ファイル名:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => '内部'; + + @override + String get tocTemplateUnnamed => '無題'; + + @override + String get tocTemplateNew => '新規テンプレート...'; + + @override + String get tocTemplateName => '名前:'; + + @override + String get tocTemplateExtension => '拡張子:'; + + @override + String get tocTemplateLoadError => 'テンプレートを読み込めませんでした。既存のテンプレートは変更されていません。'; + + @override + String get tocTemplateSaveError => 'テンプレートを保存できませんでした。編集内容はここに保持されています。'; + + @override + String get tocTemplateConflict => + '別のウィンドウでテンプレートが変更されました。キャンセルしてエディターを開き直してください。'; + + @override + String get tocTemplateDuplicateName => 'この名前と拡張子のテンプレートは既に存在します。'; + + @override + String get tocTemplateFormat => '形式:'; + + @override + String get tocTemplateReset => 'リセット'; + + @override + String get tocTemplateRetry => '再試行'; + + @override + String tocRemoveElementsDialog(int count) { + return '目次要素 $count 個を削除'; + } + + @override + String get tocRemoveElementsRetainFiles => 'これらの目次要素に関連付けられたソースファイルは削除されません。'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => '現在のインスタンスに追加するトピックを選択'; + + @override + String get tocGroupName => 'グループ名'; + + @override + String get tocMoveLineUp => '行を上に移動'; + + @override + String get tocMoveLineDown => '行を下に移動'; + + @override + String get tocEmptyMdTopic => '空の MD トピック'; + + @override + String get tocEmptyXmlTopic => '空の XML トピック'; + + @override + String get tocCopySpecial => '特殊コピー'; + + @override + String tocCopyFileName(String name) { + return 'トピックのファイル名 \'$name\''; + } + + @override + String get tocCopyFilePath => 'トピックのファイルパス'; + + @override + String tocCopyTopicTitle(String title) { + return 'トピックのタイトル \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return '目次要素 ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'トピックのファイル名'; + + @override + String get tocCopyTopicTitleUnavailable => 'トピックのタイトル'; + + @override + String get tocCopyElementIdUnavailable => '目次要素 ID'; + + @override + String get tocPreviewTopic => 'トピックをプレビュー'; + + @override + String get tocEditTitleAction => 'タイトルを編集...'; + + @override + String get tocEditTitleDialog => 'タイトルを編集'; + + @override + String get tocRemoveElementAction => '目次要素を削除...'; + + @override + String get tocSetHomePage => 'ホームページに設定'; + + @override + String tocGoToElement(String fileName) { + return '「$fileName」の目次要素に移動'; + } + + @override + String get tocSynchronize => '目次とエディターを同期'; + + @override + String get tocEmptyGroupAction => '空のグループ'; + + @override + String get tocNewEmptyGroup => '新しい空のグループ'; + + @override + String get tocGroupAction => 'グループ化'; + + @override + String get tocNewGroup => '新しいグループ'; + + @override + String get tocSortChildren => '子トピックをアルファベット順に並べ替え'; + + @override + String get tocLinkTopicFiles => 'トピックファイルを目次にリンク...'; + + @override + String get tocTitleField => '目次タイトル:'; + + @override + String get tocTopicTitleField => 'トピックのタイトル:'; + + @override + String get tocAdvancedSettings => '詳細設定'; + + @override + String tocInstanceTitleField(String instanceId) { + return '「$instanceId」のタイトル:'; + } + + @override + String get tocOnlyTitleField => '目次専用タイトル:'; + + @override + String get tocFindUsages => '検索'; + + @override + String get tocRefactorMenu => 'リファクタリング'; + + @override + String get tocSafeDelete => '安全な削除'; + + @override + String get tocDuplicate => '複製'; + + @override + String get tocDuplicateFilename => 'トピックのファイル名:'; + + @override + String get tocDuplicateDialog => 'トピックを複製'; + @override String get pdfTitlePage => 'タイトルページ'; @@ -1120,7 +1328,7 @@ class AppLocalizationsJa extends AppLocalizations { String get files => 'ファイル'; @override - String get toc => 'トピック'; + String get toc => '目次'; @override String get tocActions => '目次の操作'; @@ -1158,7 +1366,7 @@ class AppLocalizationsJa extends AppLocalizations { String get newChildTopic => '新しい子トピック'; @override - String get newSiblingTopic => '新しい兄弟トピック'; + String get newSiblingTopic => '新しいトピック'; @override String get renameTopicFile => 'トピックファイルの名前を変更'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index e36ecf67..8feae61c 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -10,6 +10,213 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get tocTopicFromTemplate => '템플릿에서 토픽...'; + + @override + String get tocSaveAsTemplate => '템플릿으로 저장'; + + @override + String get tocTemplateDialog => '템플릿에서 토픽 만들기'; + + @override + String get tocTemplatesEditor => '파일 및 코드 템플릿'; + + @override + String get tocEditTemplates => '템플릿 편집...'; + + @override + String get tocCreateCustomTemplate => '사용자 지정 템플릿 만들기...'; + + @override + String get tocTemplateCreated => '템플릿 생성됨'; + + @override + String get tocTemplateDefault => '기본'; + + @override + String get tocTemplateCustom => '사용자 지정'; + + @override + String get tocTemplateFilename => '파일 이름:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => '내부'; + + @override + String get tocTemplateUnnamed => '이름 없음'; + + @override + String get tocTemplateNew => '새 템플릿...'; + + @override + String get tocTemplateName => '이름:'; + + @override + String get tocTemplateExtension => '확장자:'; + + @override + String get tocTemplateLoadError => '템플릿을 불러올 수 없습니다. 기존 템플릿은 변경되지 않았습니다.'; + + @override + String get tocTemplateSaveError => '템플릿을 저장할 수 없습니다. 편집 내용은 여기에 유지됩니다.'; + + @override + String get tocTemplateConflict => '다른 창에서 템플릿이 변경되었습니다. 취소하고 편집기를 다시 여세요.'; + + @override + String get tocTemplateDuplicateName => '이 이름과 확장자를 가진 템플릿이 이미 있습니다.'; + + @override + String get tocTemplateFormat => '형식:'; + + @override + String get tocTemplateReset => '초기화'; + + @override + String get tocTemplateRetry => '다시 시도'; + + @override + String tocRemoveElementsDialog(int count) { + return '목차 요소 $count개 제거'; + } + + @override + String get tocRemoveElementsRetainFiles => '이 목차 요소와 연결된 소스 파일은 삭제되지 않습니다.'; + + @override + String get tocOk => '확인'; + + @override + String get tocSelectExistingTopic => '현재 인스턴스에 추가할 토픽 선택'; + + @override + String get tocGroupName => '그룹 이름'; + + @override + String get tocMoveLineUp => '줄 위로 이동'; + + @override + String get tocMoveLineDown => '줄 아래로 이동'; + + @override + String get tocEmptyMdTopic => '빈 MD 토픽'; + + @override + String get tocEmptyXmlTopic => '빈 XML 토픽'; + + @override + String get tocCopySpecial => '특수 복사'; + + @override + String tocCopyFileName(String name) { + return '토픽 파일 이름 \'$name\''; + } + + @override + String get tocCopyFilePath => '토픽 파일 경로'; + + @override + String tocCopyTopicTitle(String title) { + return '토픽 제목 \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return '목차 요소 ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => '토픽 파일 이름'; + + @override + String get tocCopyTopicTitleUnavailable => '토픽 제목'; + + @override + String get tocCopyElementIdUnavailable => '목차 요소 ID'; + + @override + String get tocPreviewTopic => '토픽 미리보기'; + + @override + String get tocEditTitleAction => '제목 편집...'; + + @override + String get tocEditTitleDialog => '제목 편집'; + + @override + String get tocRemoveElementAction => '목차 요소 제거...'; + + @override + String get tocSetHomePage => '홈 페이지로 설정'; + + @override + String tocGoToElement(String fileName) { + return '\'$fileName\'의 목차 요소로 이동'; + } + + @override + String get tocSynchronize => '목차와 편집기 동기화'; + + @override + String get tocEmptyGroupAction => '빈 그룹'; + + @override + String get tocNewEmptyGroup => '새 빈 그룹'; + + @override + String get tocGroupAction => '그룹화'; + + @override + String get tocNewGroup => '새 그룹'; + + @override + String get tocSortChildren => '하위 토픽을 알파벳순으로 정렬'; + + @override + String get tocLinkTopicFiles => '토픽 파일을 목차에 연결...'; + + @override + String get tocTitleField => '목차 제목:'; + + @override + String get tocTopicTitleField => '토픽 제목:'; + + @override + String get tocAdvancedSettings => '고급 설정'; + + @override + String tocInstanceTitleField(String instanceId) { + return '\'$instanceId\'의 제목:'; + } + + @override + String get tocOnlyTitleField => '목차 전용 제목:'; + + @override + String get tocFindUsages => '찾기'; + + @override + String get tocRefactorMenu => '리팩터링'; + + @override + String get tocSafeDelete => '안전하게 삭제'; + + @override + String get tocDuplicate => '복제'; + + @override + String get tocDuplicateFilename => '토픽 파일 이름:'; + + @override + String get tocDuplicateDialog => '토픽 복제'; + @override String get pdfTitlePage => '제목 페이지'; @@ -1118,7 +1325,7 @@ class AppLocalizationsKo extends AppLocalizations { String get files => '파일'; @override - String get toc => '토픽'; + String get toc => '목차'; @override String get tocActions => '목차 작업'; @@ -1156,7 +1363,7 @@ class AppLocalizationsKo extends AppLocalizations { String get newChildTopic => '새 하위 토픽'; @override - String get newSiblingTopic => '새 형제 토픽'; + String get newSiblingTopic => '새 토픽'; @override String get renameTopicFile => '토픽 파일 이름 바꾸기'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index cc4e4e95..9f9ca234 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -10,6 +10,220 @@ import 'app_localizations.dart'; class AppLocalizationsNb extends AppLocalizations { AppLocalizationsNb([String locale = 'nb']) : super(locale); + @override + String get tocTopicFromTemplate => 'Emne fra mal...'; + + @override + String get tocSaveAsTemplate => 'Lagre som mal'; + + @override + String get tocTemplateDialog => 'Opprett emne fra mal'; + + @override + String get tocTemplatesEditor => 'Fil- og kodemaler'; + + @override + String get tocEditTemplates => 'Rediger maler...'; + + @override + String get tocCreateCustomTemplate => 'Opprett egendefinert mal...'; + + @override + String get tocTemplateCreated => 'Mal opprettet'; + + @override + String get tocTemplateDefault => 'Standard'; + + @override + String get tocTemplateCustom => 'Egendefinerte'; + + @override + String get tocTemplateFilename => 'Filnavn:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Interne'; + + @override + String get tocTemplateUnnamed => 'Uten navn'; + + @override + String get tocTemplateNew => 'Ny mal...'; + + @override + String get tocTemplateName => 'Navn:'; + + @override + String get tocTemplateExtension => 'Filtype:'; + + @override + String get tocTemplateLoadError => + 'Kunne ikke laste maler. Eksisterende maler er ikke endret.'; + + @override + String get tocTemplateSaveError => + 'Kunne ikke lagre maler. Endringene dine er fortsatt tilgjengelige her.'; + + @override + String get tocTemplateConflict => + 'Malene ble endret i et annet vindu. Avbryt og åpne redigereren på nytt.'; + + @override + String get tocTemplateDuplicateName => + 'En mal med dette navnet og denne filtypen finnes allerede.'; + + @override + String get tocTemplateFormat => 'Format:'; + + @override + String get tocTemplateReset => 'Tilbakestill'; + + @override + String get tocTemplateRetry => 'Prøv igjen'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Fjern $count innholdsfortegnelseselementer'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Kildefilene som er knyttet til disse innholdsfortegnelseselementene, blir ikke slettet.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Velg emne som skal legges til i gjeldende instans'; + + @override + String get tocGroupName => 'Gruppenavn'; + + @override + String get tocMoveLineUp => 'Flytt linje opp'; + + @override + String get tocMoveLineDown => 'Flytt linje ned'; + + @override + String get tocEmptyMdTopic => 'Tomt MD-emne'; + + @override + String get tocEmptyXmlTopic => 'Tomt XML-emne'; + + @override + String get tocCopySpecial => 'Spesiell kopiering'; + + @override + String tocCopyFileName(String name) { + return 'Emnefilnavn \'$name\''; + } + + @override + String get tocCopyFilePath => 'Emnefilbane'; + + @override + String tocCopyTopicTitle(String title) { + return 'Emnetittel \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID for innholdsfortegnelseselement \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Emnefilnavn'; + + @override + String get tocCopyTopicTitleUnavailable => 'Emnetittel'; + + @override + String get tocCopyElementIdUnavailable => + 'ID for innholdsfortegnelseselement'; + + @override + String get tocPreviewTopic => 'Forhåndsvis emne'; + + @override + String get tocEditTitleAction => 'Rediger tittel...'; + + @override + String get tocEditTitleDialog => 'Rediger tittel'; + + @override + String get tocRemoveElementAction => 'Fjern innholdsfortegnelseselement...'; + + @override + String get tocSetHomePage => 'Angi som startside'; + + @override + String tocGoToElement(String fileName) { + return 'Gå til innholdsfortegnelseselement i \'$fileName\''; + } + + @override + String get tocSynchronize => 'Synkroniser innholdsfortegnelse og redigering'; + + @override + String get tocEmptyGroupAction => 'Tom gruppe'; + + @override + String get tocNewEmptyGroup => 'Ny tom gruppe'; + + @override + String get tocGroupAction => 'Grupper'; + + @override + String get tocNewGroup => 'Ny gruppe'; + + @override + String get tocSortChildren => 'Sorter underemner alfabetisk'; + + @override + String get tocLinkTopicFiles => 'Koble emnefiler til innholdsfortegnelsen...'; + + @override + String get tocTitleField => 'Tittel i innholdsfortegnelsen:'; + + @override + String get tocTopicTitleField => 'Emnetittel:'; + + @override + String get tocAdvancedSettings => 'Avanserte innstillinger'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Tittel for \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Tittel kun for innholdsfortegnelsen:'; + + @override + String get tocFindUsages => 'Finn'; + + @override + String get tocRefactorMenu => 'Refaktorer'; + + @override + String get tocSafeDelete => 'Sikker sletting'; + + @override + String get tocDuplicate => 'Dupliser'; + + @override + String get tocDuplicateFilename => 'Emnefilnavn:'; + + @override + String get tocDuplicateDialog => 'Dupliser emne'; + @override String get pdfTitlePage => 'Tittelside'; @@ -1162,7 +1376,7 @@ class AppLocalizationsNb extends AppLocalizations { String get files => 'Filer'; @override - String get toc => 'Emner'; + String get toc => 'Innholdsfortegnelse'; @override String get tocActions => 'Handlinger for innholdsfortegnelsen'; @@ -1200,7 +1414,7 @@ class AppLocalizationsNb extends AppLocalizations { String get newChildTopic => 'Nytt underemne'; @override - String get newSiblingTopic => 'Nytt emne på samme nivå'; + String get newSiblingTopic => 'Nytt emne'; @override String get renameTopicFile => 'Gi emnefilen nytt navn'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 33f6bfd2..189c23fa 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -10,6 +10,221 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String get tocTopicFromTemplate => 'Onderwerp uit sjabloon...'; + + @override + String get tocSaveAsTemplate => 'Opslaan als sjabloon'; + + @override + String get tocTemplateDialog => 'Onderwerp uit sjabloon maken'; + + @override + String get tocTemplatesEditor => 'Bestands- en codesjablonen'; + + @override + String get tocEditTemplates => 'Sjablonen bewerken...'; + + @override + String get tocCreateCustomTemplate => 'Aangepast sjabloon maken...'; + + @override + String get tocTemplateCreated => 'Sjabloon gemaakt'; + + @override + String get tocTemplateDefault => 'Standaard'; + + @override + String get tocTemplateCustom => 'Aangepast'; + + @override + String get tocTemplateFilename => 'Bestandsnaam:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Intern'; + + @override + String get tocTemplateUnnamed => 'Naamloos'; + + @override + String get tocTemplateNew => 'Nieuw sjabloon...'; + + @override + String get tocTemplateName => 'Naam:'; + + @override + String get tocTemplateExtension => 'Extensie:'; + + @override + String get tocTemplateLoadError => + 'Sjablonen konden niet worden geladen. Bestaande sjablonen zijn niet gewijzigd.'; + + @override + String get tocTemplateSaveError => + 'Sjablonen konden niet worden opgeslagen. Uw wijzigingen zijn hier nog beschikbaar.'; + + @override + String get tocTemplateConflict => + 'Sjablonen zijn in een ander venster gewijzigd. Annuleer en open de editor opnieuw.'; + + @override + String get tocTemplateDuplicateName => + 'Er bestaat al een sjabloon met deze naam en extensie.'; + + @override + String get tocTemplateFormat => 'Indeling:'; + + @override + String get tocTemplateReset => 'Herstellen'; + + @override + String get tocTemplateRetry => 'Opnieuw proberen'; + + @override + String tocRemoveElementsDialog(int count) { + return '$count inhoudsopgave-elementen verwijderen'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'De bronbestanden die aan deze inhoudsopgave-elementen zijn gekoppeld, worden niet verwijderd.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Onderwerp selecteren om aan de huidige instantie toe te voegen'; + + @override + String get tocGroupName => 'Groepsnaam'; + + @override + String get tocMoveLineUp => 'Regel omhoog verplaatsen'; + + @override + String get tocMoveLineDown => 'Regel omlaag verplaatsen'; + + @override + String get tocEmptyMdTopic => 'Leeg MD-onderwerp'; + + @override + String get tocEmptyXmlTopic => 'Leeg XML-onderwerp'; + + @override + String get tocCopySpecial => 'Speciaal kopiëren'; + + @override + String tocCopyFileName(String name) { + return 'Onderwerpbestandsnaam \'$name\''; + } + + @override + String get tocCopyFilePath => 'Onderwerpbestandspad'; + + @override + String tocCopyTopicTitle(String title) { + return 'Onderwerptitel \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID van inhoudsopgave-element \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Onderwerpbestandsnaam'; + + @override + String get tocCopyTopicTitleUnavailable => 'Onderwerptitel'; + + @override + String get tocCopyElementIdUnavailable => 'ID van inhoudsopgave-element'; + + @override + String get tocPreviewTopic => 'Onderwerpvoorbeeld'; + + @override + String get tocEditTitleAction => 'Titel bewerken...'; + + @override + String get tocEditTitleDialog => 'Titel bewerken'; + + @override + String get tocRemoveElementAction => 'Inhoudsopgave-element verwijderen...'; + + @override + String get tocSetHomePage => 'Als startpagina instellen'; + + @override + String tocGoToElement(String fileName) { + return 'Naar inhoudsopgave-element in \'$fileName\' gaan'; + } + + @override + String get tocSynchronize => 'Inhoudsopgave en editor synchroniseren'; + + @override + String get tocEmptyGroupAction => 'Lege groep'; + + @override + String get tocNewEmptyGroup => 'Nieuwe lege groep'; + + @override + String get tocGroupAction => 'Groeperen'; + + @override + String get tocNewGroup => 'Nieuwe groep'; + + @override + String get tocSortChildren => + 'Onderliggende onderwerpen alfabetisch sorteren'; + + @override + String get tocLinkTopicFiles => + 'Onderwerpbestanden aan inhoudsopgave koppelen...'; + + @override + String get tocTitleField => 'Titel in inhoudsopgave:'; + + @override + String get tocTopicTitleField => 'Onderwerptitel:'; + + @override + String get tocAdvancedSettings => 'Geavanceerde instellingen'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Titel voor \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Titel alleen voor inhoudsopgave:'; + + @override + String get tocFindUsages => 'Zoeken'; + + @override + String get tocRefactorMenu => 'Refactoren'; + + @override + String get tocSafeDelete => 'Veilig verwijderen'; + + @override + String get tocDuplicate => 'Dupliceren'; + + @override + String get tocDuplicateFilename => 'Onderwerpbestandsnaam:'; + + @override + String get tocDuplicateDialog => 'Onderwerp dupliceren'; + @override String get pdfTitlePage => 'Titelpagina'; @@ -1163,7 +1378,7 @@ class AppLocalizationsNl extends AppLocalizations { String get files => 'Bestanden'; @override - String get toc => 'Onderwerpen'; + String get toc => 'Inhoudsopgave'; @override String get tocActions => 'TOC-acties'; @@ -1201,7 +1416,7 @@ class AppLocalizationsNl extends AppLocalizations { String get newChildTopic => 'Nieuw onderliggend onderwerp'; @override - String get newSiblingTopic => 'Nieuw naastliggend onderwerp'; + String get newSiblingTopic => 'Nieuw onderwerp'; @override String get renameTopicFile => 'Onderwerpbestand hernoemen'; @@ -3146,7 +3361,7 @@ class AppLocalizationsNl extends AppLocalizations { String get editInstance => 'Instantie bewerken'; @override - String get openTocFile => 'TOC-bestand openen'; + String get openTocFile => 'Inhoudsopgavebestand openen'; @override String get createInstance => 'Instantie maken'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 3ece7ff9..e70f1951 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get tocTopicFromTemplate => 'Temat z szablonu...'; + + @override + String get tocSaveAsTemplate => 'Zapisz jako szablon'; + + @override + String get tocTemplateDialog => 'Utwórz temat z szablonu'; + + @override + String get tocTemplatesEditor => 'Szablony plików i kodu'; + + @override + String get tocEditTemplates => 'Edytuj szablony...'; + + @override + String get tocCreateCustomTemplate => 'Utwórz własny szablon...'; + + @override + String get tocTemplateCreated => 'Utworzono szablon'; + + @override + String get tocTemplateDefault => 'Domyślne'; + + @override + String get tocTemplateCustom => 'Własne'; + + @override + String get tocTemplateFilename => 'Nazwa pliku:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Wewnętrzne'; + + @override + String get tocTemplateUnnamed => 'Bez nazwy'; + + @override + String get tocTemplateNew => 'Nowy szablon...'; + + @override + String get tocTemplateName => 'Nazwa:'; + + @override + String get tocTemplateExtension => 'Rozszerzenie:'; + + @override + String get tocTemplateLoadError => + 'Nie można wczytać szablonów. Istniejące szablony nie zostały zmienione.'; + + @override + String get tocTemplateSaveError => + 'Nie można zapisać szablonów. Twoje zmiany są nadal dostępne tutaj.'; + + @override + String get tocTemplateConflict => + 'Szablony zmieniono w innym oknie. Anuluj i ponownie otwórz edytor.'; + + @override + String get tocTemplateDuplicateName => + 'Szablon o tej nazwie i rozszerzeniu już istnieje.'; + + @override + String get tocTemplateFormat => 'Format:'; + + @override + String get tocTemplateReset => 'Przywróć'; + + @override + String get tocTemplateRetry => 'Ponów'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Usuń $count elementów spisu treści'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Pliki źródłowe powiązane z tymi elementami spisu treści nie zostaną usunięte.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Wybierz temat do dodania do bieżącej instancji'; + + @override + String get tocGroupName => 'Nazwa grupy'; + + @override + String get tocMoveLineUp => 'Przenieś wiersz w górę'; + + @override + String get tocMoveLineDown => 'Przenieś wiersz w dół'; + + @override + String get tocEmptyMdTopic => 'Pusty temat MD'; + + @override + String get tocEmptyXmlTopic => 'Pusty temat XML'; + + @override + String get tocCopySpecial => 'Kopiowanie specjalne'; + + @override + String tocCopyFileName(String name) { + return 'Nazwa pliku tematu \'$name\''; + } + + @override + String get tocCopyFilePath => 'Ścieżka pliku tematu'; + + @override + String tocCopyTopicTitle(String title) { + return 'Tytuł tematu \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID elementu spisu treści \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nazwa pliku tematu'; + + @override + String get tocCopyTopicTitleUnavailable => 'Tytuł tematu'; + + @override + String get tocCopyElementIdUnavailable => 'ID elementu spisu treści'; + + @override + String get tocPreviewTopic => 'Podgląd tematu'; + + @override + String get tocEditTitleAction => 'Edytuj tytuł...'; + + @override + String get tocEditTitleDialog => 'Edytuj tytuł'; + + @override + String get tocRemoveElementAction => 'Usuń element spisu treści...'; + + @override + String get tocSetHomePage => 'Ustaw jako stronę główną'; + + @override + String tocGoToElement(String fileName) { + return 'Przejdź do elementu spisu treści w „$fileName”'; + } + + @override + String get tocSynchronize => 'Synchronizuj spis treści i edytor'; + + @override + String get tocEmptyGroupAction => 'Pusta grupa'; + + @override + String get tocNewEmptyGroup => 'Nowa pusta grupa'; + + @override + String get tocGroupAction => 'Grupuj'; + + @override + String get tocNewGroup => 'Nowa grupa'; + + @override + String get tocSortChildren => 'Sortuj tematy podrzędne alfabetycznie'; + + @override + String get tocLinkTopicFiles => 'Połącz pliki tematów ze spisem treści...'; + + @override + String get tocTitleField => 'Tytuł w spisie treści:'; + + @override + String get tocTopicTitleField => 'Tytuł tematu:'; + + @override + String get tocAdvancedSettings => 'Ustawienia zaawansowane'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Tytuł dla „$instanceId”:'; + } + + @override + String get tocOnlyTitleField => 'Tytuł tylko w spisie treści:'; + + @override + String get tocFindUsages => 'Znajdź'; + + @override + String get tocRefactorMenu => 'Refaktoryzacja'; + + @override + String get tocSafeDelete => 'Bezpieczne usuwanie'; + + @override + String get tocDuplicate => 'Duplikuj'; + + @override + String get tocDuplicateFilename => 'Nazwa pliku tematu:'; + + @override + String get tocDuplicateDialog => 'Duplikuj temat'; + @override String get pdfTitlePage => 'Strona tytułowa'; @@ -1178,7 +1391,7 @@ class AppLocalizationsPl extends AppLocalizations { String get files => 'Pliki'; @override - String get toc => 'Tematy'; + String get toc => 'Spis treści'; @override String get tocActions => 'Działania dotyczące spisu treści'; @@ -1219,7 +1432,7 @@ class AppLocalizationsPl extends AppLocalizations { String get newChildTopic => 'Nowy temat podrzędny'; @override - String get newSiblingTopic => 'Nowy temat równorzędny'; + String get newSiblingTopic => 'Nowy temat'; @override String get renameTopicFile => 'Zmień nazwę pliku tematu'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 173388b1..7f45c601 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; + + @override + String get tocSaveAsTemplate => 'Guardar como modelo'; + + @override + String get tocTemplateDialog => 'Criar tópico a partir de modelo'; + + @override + String get tocTemplatesEditor => 'Modelos de ficheiros e código'; + + @override + String get tocEditTemplates => 'Editar modelos...'; + + @override + String get tocCreateCustomTemplate => 'Criar modelo personalizado...'; + + @override + String get tocTemplateCreated => 'Modelo criado'; + + @override + String get tocTemplateDefault => 'Predefinidos'; + + @override + String get tocTemplateCustom => 'Personalizados'; + + @override + String get tocTemplateFilename => 'Nome do ficheiro:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internos'; + + @override + String get tocTemplateUnnamed => 'Sem nome'; + + @override + String get tocTemplateNew => 'Novo modelo...'; + + @override + String get tocTemplateName => 'Nome:'; + + @override + String get tocTemplateExtension => 'Extensão:'; + + @override + String get tocTemplateLoadError => + 'Não foi possível carregar os modelos. Os modelos existentes não foram alterados.'; + + @override + String get tocTemplateSaveError => + 'Não foi possível guardar os modelos. As alterações continuam disponíveis aqui.'; + + @override + String get tocTemplateConflict => + 'Os modelos foram alterados noutra janela. Cancele e reabra o editor.'; + + @override + String get tocTemplateDuplicateName => + 'Já existe um modelo com este nome e extensão.'; + + @override + String get tocTemplateFormat => 'Formato:'; + + @override + String get tocTemplateReset => 'Repor'; + + @override + String get tocTemplateRetry => 'Tentar novamente'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Remover $count elementos do índice'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Os ficheiros de origem associados a estes elementos do índice não serão eliminados.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Selecionar tópico para adicionar à instância atual'; + + @override + String get tocGroupName => 'Nome do grupo'; + + @override + String get tocMoveLineUp => 'Mover linha para cima'; + + @override + String get tocMoveLineDown => 'Mover linha para baixo'; + + @override + String get tocEmptyMdTopic => 'Tópico MD vazio'; + + @override + String get tocEmptyXmlTopic => 'Tópico XML vazio'; + + @override + String get tocCopySpecial => 'Cópia especial'; + + @override + String tocCopyFileName(String name) { + return 'Nome do ficheiro do tópico \'$name\''; + } + + @override + String get tocCopyFilePath => 'Caminho do ficheiro do tópico'; + + @override + String tocCopyTopicTitle(String title) { + return 'Título do tópico \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID do elemento do índice \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nome do ficheiro do tópico'; + + @override + String get tocCopyTopicTitleUnavailable => 'Título do tópico'; + + @override + String get tocCopyElementIdUnavailable => 'ID do elemento do índice'; + + @override + String get tocPreviewTopic => 'Pré-visualizar tópico'; + + @override + String get tocEditTitleAction => 'Editar título...'; + + @override + String get tocEditTitleDialog => 'Editar título'; + + @override + String get tocRemoveElementAction => 'Remover elemento do índice...'; + + @override + String get tocSetHomePage => 'Definir como página inicial'; + + @override + String tocGoToElement(String fileName) { + return 'Ir para o elemento do índice em \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sincronizar índice e editor'; + + @override + String get tocEmptyGroupAction => 'Grupo vazio'; + + @override + String get tocNewEmptyGroup => 'Novo grupo vazio'; + + @override + String get tocGroupAction => 'Agrupar'; + + @override + String get tocNewGroup => 'Novo grupo'; + + @override + String get tocSortChildren => 'Ordenar subtópicos alfabeticamente'; + + @override + String get tocLinkTopicFiles => 'Ligar ficheiros de tópicos ao índice...'; + + @override + String get tocTitleField => 'Título no índice:'; + + @override + String get tocTopicTitleField => 'Título do tópico:'; + + @override + String get tocAdvancedSettings => 'Definições avançadas'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Título para \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Título exclusivo do índice:'; + + @override + String get tocFindUsages => 'Localizar'; + + @override + String get tocRefactorMenu => 'Refatorizar'; + + @override + String get tocSafeDelete => 'Eliminação segura'; + + @override + String get tocDuplicate => 'Duplicar'; + + @override + String get tocDuplicateFilename => 'Nome do ficheiro do tópico:'; + + @override + String get tocDuplicateDialog => 'Duplicar tópico'; + @override String get pdfTitlePage => 'Página de título'; @@ -1170,7 +1383,7 @@ class AppLocalizationsPt extends AppLocalizations { String get files => 'Ficheiros'; @override - String get toc => 'Tópicos'; + String get toc => 'Índice'; @override String get tocActions => 'Ações do sumário'; @@ -1208,7 +1421,7 @@ class AppLocalizationsPt extends AppLocalizations { String get newChildTopic => 'Novo subtópico'; @override - String get newSiblingTopic => 'Novo tópico no mesmo nível'; + String get newSiblingTopic => 'Novo tópico'; @override String get renameTopicFile => 'Renomear ficheiro do tópico'; @@ -4294,6 +4507,219 @@ class AppLocalizationsPt extends AppLocalizations { class AppLocalizationsPtBr extends AppLocalizationsPt { AppLocalizationsPtBr() : super('pt_BR'); + @override + String get tocTopicFromTemplate => 'Tópico a partir de modelo...'; + + @override + String get tocSaveAsTemplate => 'Salvar como modelo'; + + @override + String get tocTemplateDialog => 'Criar tópico a partir de modelo'; + + @override + String get tocTemplatesEditor => 'Modelos de arquivos e código'; + + @override + String get tocEditTemplates => 'Editar modelos...'; + + @override + String get tocCreateCustomTemplate => 'Criar modelo personalizado...'; + + @override + String get tocTemplateCreated => 'Modelo criado'; + + @override + String get tocTemplateDefault => 'Padrão'; + + @override + String get tocTemplateCustom => 'Personalizados'; + + @override + String get tocTemplateFilename => 'Nome do arquivo:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Internos'; + + @override + String get tocTemplateUnnamed => 'Sem nome'; + + @override + String get tocTemplateNew => 'Novo modelo...'; + + @override + String get tocTemplateName => 'Nome:'; + + @override + String get tocTemplateExtension => 'Extensão:'; + + @override + String get tocTemplateLoadError => + 'Não foi possível carregar os modelos. Os modelos existentes não foram alterados.'; + + @override + String get tocTemplateSaveError => + 'Não foi possível salvar os modelos. Suas alterações continuam disponíveis aqui.'; + + @override + String get tocTemplateConflict => + 'Os modelos foram alterados em outra janela. Cancele e reabra o editor.'; + + @override + String get tocTemplateDuplicateName => + 'Já existe um modelo com este nome e extensão.'; + + @override + String get tocTemplateFormat => 'Formato:'; + + @override + String get tocTemplateReset => 'Redefinir'; + + @override + String get tocTemplateRetry => 'Tentar novamente'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Remover $count elementos do sumário'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Os arquivos de origem associados a estes elementos do sumário não serão excluídos.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Selecionar tópico para adicionar à instância atual'; + + @override + String get tocGroupName => 'Nome do grupo'; + + @override + String get tocMoveLineUp => 'Mover linha para cima'; + + @override + String get tocMoveLineDown => 'Mover linha para baixo'; + + @override + String get tocEmptyMdTopic => 'Tópico MD vazio'; + + @override + String get tocEmptyXmlTopic => 'Tópico XML vazio'; + + @override + String get tocCopySpecial => 'Cópia especial'; + + @override + String tocCopyFileName(String name) { + return 'Nome do arquivo do tópico \'$name\''; + } + + @override + String get tocCopyFilePath => 'Caminho do arquivo do tópico'; + + @override + String tocCopyTopicTitle(String title) { + return 'Título do tópico \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID do elemento do índice \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Nome do arquivo do tópico'; + + @override + String get tocCopyTopicTitleUnavailable => 'Título do tópico'; + + @override + String get tocCopyElementIdUnavailable => 'ID do elemento do índice'; + + @override + String get tocPreviewTopic => 'Pré-visualizar tópico'; + + @override + String get tocEditTitleAction => 'Editar título...'; + + @override + String get tocEditTitleDialog => 'Editar título'; + + @override + String get tocRemoveElementAction => 'Remover elemento do índice...'; + + @override + String get tocSetHomePage => 'Definir como página inicial'; + + @override + String tocGoToElement(String fileName) { + return 'Ir para o elemento do índice em \'$fileName\''; + } + + @override + String get tocSynchronize => 'Sincronizar índice e editor'; + + @override + String get tocEmptyGroupAction => 'Grupo vazio'; + + @override + String get tocNewEmptyGroup => 'Novo grupo vazio'; + + @override + String get tocGroupAction => 'Agrupar'; + + @override + String get tocNewGroup => 'Novo grupo'; + + @override + String get tocSortChildren => 'Ordenar subtópicos alfabeticamente'; + + @override + String get tocLinkTopicFiles => 'Vincular arquivos de tópicos ao índice...'; + + @override + String get tocTitleField => 'Título no índice:'; + + @override + String get tocTopicTitleField => 'Título do tópico:'; + + @override + String get tocAdvancedSettings => 'Configurações avançadas'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Título para \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Título exclusivo do índice:'; + + @override + String get tocFindUsages => 'Localizar'; + + @override + String get tocRefactorMenu => 'Refatorar'; + + @override + String get tocSafeDelete => 'Exclusão segura'; + + @override + String get tocDuplicate => 'Duplicar'; + + @override + String get tocDuplicateFilename => 'Nome do arquivo do tópico:'; + + @override + String get tocDuplicateDialog => 'Duplicar tópico'; + @override String get pdfTitlePage => 'Página de título'; @@ -5455,7 +5881,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get files => 'Arquivos'; @override - String get toc => 'Tópicos'; + String get toc => 'Índice'; @override String get tocActions => 'Ações do sumário'; @@ -5493,7 +5919,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get newChildTopic => 'Novo subtópico'; @override - String get newSiblingTopic => 'Novo tópico no mesmo nível'; + String get newSiblingTopic => 'Novo tópico'; @override String get renameTopicFile => 'Renomear arquivo do tópico'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index f38fae5f..2d513ffa 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get tocTopicFromTemplate => 'Тема из шаблона...'; + + @override + String get tocSaveAsTemplate => 'Сохранить как шаблон'; + + @override + String get tocTemplateDialog => 'Создать тему из шаблона'; + + @override + String get tocTemplatesEditor => 'Шаблоны файлов и кода'; + + @override + String get tocEditTemplates => 'Изменить шаблоны...'; + + @override + String get tocCreateCustomTemplate => 'Создать пользовательский шаблон...'; + + @override + String get tocTemplateCreated => 'Шаблон создан'; + + @override + String get tocTemplateDefault => 'Стандартные'; + + @override + String get tocTemplateCustom => 'Пользовательские'; + + @override + String get tocTemplateFilename => 'Имя файла:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Внутренние'; + + @override + String get tocTemplateUnnamed => 'Без имени'; + + @override + String get tocTemplateNew => 'Новый шаблон...'; + + @override + String get tocTemplateName => 'Имя:'; + + @override + String get tocTemplateExtension => 'Расширение:'; + + @override + String get tocTemplateLoadError => + 'Не удалось загрузить шаблоны. Существующие шаблоны не изменены.'; + + @override + String get tocTemplateSaveError => + 'Не удалось сохранить шаблоны. Ваши изменения остались в редакторе.'; + + @override + String get tocTemplateConflict => + 'Шаблоны изменены в другом окне. Отмените изменения и снова откройте редактор.'; + + @override + String get tocTemplateDuplicateName => + 'Шаблон с таким именем и расширением уже существует.'; + + @override + String get tocTemplateFormat => 'Формат:'; + + @override + String get tocTemplateReset => 'Сбросить'; + + @override + String get tocTemplateRetry => 'Повторить'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Удалить $count элементов оглавления'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Исходные файлы, связанные с этими элементами оглавления, не будут удалены.'; + + @override + String get tocOk => 'ОК'; + + @override + String get tocSelectExistingTopic => + 'Выберите тему для добавления в текущий экземпляр'; + + @override + String get tocGroupName => 'Название группы'; + + @override + String get tocMoveLineUp => 'Переместить строку вверх'; + + @override + String get tocMoveLineDown => 'Переместить строку вниз'; + + @override + String get tocEmptyMdTopic => 'Пустая тема MD'; + + @override + String get tocEmptyXmlTopic => 'Пустая тема XML'; + + @override + String get tocCopySpecial => 'Специальное копирование'; + + @override + String tocCopyFileName(String name) { + return 'Имя файла темы \'$name\''; + } + + @override + String get tocCopyFilePath => 'Путь к файлу темы'; + + @override + String tocCopyTopicTitle(String title) { + return 'Заголовок темы \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID элемента оглавления \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Имя файла темы'; + + @override + String get tocCopyTopicTitleUnavailable => 'Заголовок темы'; + + @override + String get tocCopyElementIdUnavailable => 'ID элемента оглавления'; + + @override + String get tocPreviewTopic => 'Предпросмотр темы'; + + @override + String get tocEditTitleAction => 'Изменить заголовок...'; + + @override + String get tocEditTitleDialog => 'Изменить заголовок'; + + @override + String get tocRemoveElementAction => 'Удалить элемент оглавления...'; + + @override + String get tocSetHomePage => 'Назначить домашней страницей'; + + @override + String tocGoToElement(String fileName) { + return 'Перейти к элементу оглавления в «$fileName»'; + } + + @override + String get tocSynchronize => 'Синхронизировать оглавление и редактор'; + + @override + String get tocEmptyGroupAction => 'Пустая группа'; + + @override + String get tocNewEmptyGroup => 'Новая пустая группа'; + + @override + String get tocGroupAction => 'Сгруппировать'; + + @override + String get tocNewGroup => 'Новая группа'; + + @override + String get tocSortChildren => 'Сортировать дочерние темы по алфавиту'; + + @override + String get tocLinkTopicFiles => 'Связать файлы тем с оглавлением...'; + + @override + String get tocTitleField => 'Заголовок в оглавлении:'; + + @override + String get tocTopicTitleField => 'Заголовок темы:'; + + @override + String get tocAdvancedSettings => 'Дополнительные настройки'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Заголовок для «$instanceId»:'; + } + + @override + String get tocOnlyTitleField => 'Заголовок только для оглавления:'; + + @override + String get tocFindUsages => 'Поиск'; + + @override + String get tocRefactorMenu => 'Рефакторинг'; + + @override + String get tocSafeDelete => 'Безопасное удаление'; + + @override + String get tocDuplicate => 'Дублировать'; + + @override + String get tocDuplicateFilename => 'Имя файла темы:'; + + @override + String get tocDuplicateDialog => 'Дублировать тему'; + @override String get pdfTitlePage => 'Титульная страница'; @@ -1176,7 +1389,7 @@ class AppLocalizationsRu extends AppLocalizations { String get files => 'Файлы'; @override - String get toc => 'Темы'; + String get toc => 'Оглавление'; @override String get tocActions => 'Действия с оглавлением'; @@ -1216,7 +1429,7 @@ class AppLocalizationsRu extends AppLocalizations { String get newChildTopic => 'Новая дочерняя тема'; @override - String get newSiblingTopic => 'Новая тема на том же уровне'; + String get newSiblingTopic => 'Новая тема'; @override String get renameTopicFile => 'Переименовать файл темы'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index d7478790..67bb5210 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -10,6 +10,218 @@ import 'app_localizations.dart'; class AppLocalizationsTr extends AppLocalizations { AppLocalizationsTr([String locale = 'tr']) : super(locale); + @override + String get tocTopicFromTemplate => 'Şablondan Konu...'; + + @override + String get tocSaveAsTemplate => 'Şablon Olarak Kaydet'; + + @override + String get tocTemplateDialog => 'Şablondan Konu Oluştur'; + + @override + String get tocTemplatesEditor => 'Dosya ve Kod Şablonları'; + + @override + String get tocEditTemplates => 'Şablonları düzenle...'; + + @override + String get tocCreateCustomTemplate => 'Özel şablon oluştur...'; + + @override + String get tocTemplateCreated => 'Şablon oluşturuldu'; + + @override + String get tocTemplateDefault => 'Varsayılan'; + + @override + String get tocTemplateCustom => 'Özel'; + + @override + String get tocTemplateFilename => 'Dosya adı:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Dahili'; + + @override + String get tocTemplateUnnamed => 'Adsız'; + + @override + String get tocTemplateNew => 'Yeni şablon...'; + + @override + String get tocTemplateName => 'Ad:'; + + @override + String get tocTemplateExtension => 'Uzantı:'; + + @override + String get tocTemplateLoadError => + 'Şablonlar yüklenemedi. Mevcut şablonlar değiştirilmedi.'; + + @override + String get tocTemplateSaveError => + 'Şablonlar kaydedilemedi. Düzenlemeleriniz burada duruyor.'; + + @override + String get tocTemplateConflict => + 'Şablonlar başka bir pencerede değiştirildi. İptal edip düzenleyiciyi yeniden açın.'; + + @override + String get tocTemplateDuplicateName => + 'Bu ada ve uzantıya sahip bir şablon zaten var.'; + + @override + String get tocTemplateFormat => 'Biçim:'; + + @override + String get tocTemplateReset => 'Sıfırla'; + + @override + String get tocTemplateRetry => 'Yeniden dene'; + + @override + String tocRemoveElementsDialog(int count) { + return '$count İçindekiler Öğesini Kaldır'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Bu içindekiler öğeleriyle ilişkili kaynak dosyalar silinmeyecek.'; + + @override + String get tocOk => 'Tamam'; + + @override + String get tocSelectExistingTopic => 'Geçerli örneğe eklenecek konuyu seçin'; + + @override + String get tocGroupName => 'Grup adı'; + + @override + String get tocMoveLineUp => 'Satırı yukarı taşı'; + + @override + String get tocMoveLineDown => 'Satırı aşağı taşı'; + + @override + String get tocEmptyMdTopic => 'Boş MD konusu'; + + @override + String get tocEmptyXmlTopic => 'Boş XML konusu'; + + @override + String get tocCopySpecial => 'Özel kopyala'; + + @override + String tocCopyFileName(String name) { + return 'Konu dosyası adı \'$name\''; + } + + @override + String get tocCopyFilePath => 'Konu dosyası yolu'; + + @override + String tocCopyTopicTitle(String title) { + return 'Konu başlığı \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'İçindekiler öğesi kimliği \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Konu dosyası adı'; + + @override + String get tocCopyTopicTitleUnavailable => 'Konu başlığı'; + + @override + String get tocCopyElementIdUnavailable => 'İçindekiler öğesi kimliği'; + + @override + String get tocPreviewTopic => 'Konuyu önizle'; + + @override + String get tocEditTitleAction => 'Başlığı düzenle...'; + + @override + String get tocEditTitleDialog => 'Başlığı düzenle'; + + @override + String get tocRemoveElementAction => 'İçindekiler öğesini kaldır...'; + + @override + String get tocSetHomePage => 'Ana sayfa olarak ayarla'; + + @override + String tocGoToElement(String fileName) { + return '\'$fileName\' içindeki içindekiler öğesine git'; + } + + @override + String get tocSynchronize => 'İçindekiler ve düzenleyiciyi eşitle'; + + @override + String get tocEmptyGroupAction => 'Boş grup'; + + @override + String get tocNewEmptyGroup => 'Yeni boş grup'; + + @override + String get tocGroupAction => 'Grupla'; + + @override + String get tocNewGroup => 'Yeni grup'; + + @override + String get tocSortChildren => 'Alt konuları alfabetik sırala'; + + @override + String get tocLinkTopicFiles => 'Konu dosyalarını içindekilere bağla...'; + + @override + String get tocTitleField => 'İçindekiler başlığı:'; + + @override + String get tocTopicTitleField => 'Konu başlığı:'; + + @override + String get tocAdvancedSettings => 'Gelişmiş ayarlar'; + + @override + String tocInstanceTitleField(String instanceId) { + return '\'$instanceId\' için başlık:'; + } + + @override + String get tocOnlyTitleField => 'Yalnızca içindekiler başlığı:'; + + @override + String get tocFindUsages => 'Bul'; + + @override + String get tocRefactorMenu => 'Yeniden düzenle'; + + @override + String get tocSafeDelete => 'Güvenli sil'; + + @override + String get tocDuplicate => 'Çoğalt'; + + @override + String get tocDuplicateFilename => 'Konu dosyası adı:'; + + @override + String get tocDuplicateDialog => 'Konuyu çoğalt'; + @override String get pdfTitlePage => 'Başlık sayfası'; @@ -1163,7 +1375,7 @@ class AppLocalizationsTr extends AppLocalizations { String get files => 'Dosyalar'; @override - String get toc => 'Konular'; + String get toc => 'İçindekiler'; @override String get tocActions => 'İçindekiler eylemleri'; @@ -1201,7 +1413,7 @@ class AppLocalizationsTr extends AppLocalizations { String get newChildTopic => 'Yeni alt konu'; @override - String get newSiblingTopic => 'Yeni kardeş konu'; + String get newSiblingTopic => 'Yeni konu'; @override String get renameTopicFile => 'Konu dosyasını yeniden adlandır'; @@ -3133,7 +3345,7 @@ class AppLocalizationsTr extends AppLocalizations { String get editInstance => 'Örneği düzenle'; @override - String get openTocFile => 'TOC dosyasını aç'; + String get openTocFile => 'İçindekiler dosyasını aç'; @override String get createInstance => 'Örnek oluştur'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 633a32cc..3dd44633 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsUk extends AppLocalizations { AppLocalizationsUk([String locale = 'uk']) : super(locale); + @override + String get tocTopicFromTemplate => 'Тема з шаблону...'; + + @override + String get tocSaveAsTemplate => 'Зберегти як шаблон'; + + @override + String get tocTemplateDialog => 'Створити тему з шаблону'; + + @override + String get tocTemplatesEditor => 'Шаблони файлів і коду'; + + @override + String get tocEditTemplates => 'Редагувати шаблони...'; + + @override + String get tocCreateCustomTemplate => 'Створити власний шаблон...'; + + @override + String get tocTemplateCreated => 'Шаблон створено'; + + @override + String get tocTemplateDefault => 'Стандартні'; + + @override + String get tocTemplateCustom => 'Власні'; + + @override + String get tocTemplateFilename => 'Ім’я файлу:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Внутрішні'; + + @override + String get tocTemplateUnnamed => 'Без назви'; + + @override + String get tocTemplateNew => 'Новий шаблон...'; + + @override + String get tocTemplateName => 'Назва:'; + + @override + String get tocTemplateExtension => 'Розширення:'; + + @override + String get tocTemplateLoadError => + 'Не вдалося завантажити шаблони. Наявні шаблони не змінено.'; + + @override + String get tocTemplateSaveError => + 'Не вдалося зберегти шаблони. Ваші зміни залишилися в редакторі.'; + + @override + String get tocTemplateConflict => + 'Шаблони змінено в іншому вікні. Скасуйте та знову відкрийте редактор.'; + + @override + String get tocTemplateDuplicateName => + 'Шаблон із такою назвою та розширенням уже існує.'; + + @override + String get tocTemplateFormat => 'Формат:'; + + @override + String get tocTemplateReset => 'Скинути'; + + @override + String get tocTemplateRetry => 'Повторити'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Вилучити $count елементів змісту'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Вихідні файли, пов’язані з цими елементами змісту, не буде видалено.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Виберіть тему для додавання до поточного екземпляра'; + + @override + String get tocGroupName => 'Назва групи'; + + @override + String get tocMoveLineUp => 'Перемістити рядок угору'; + + @override + String get tocMoveLineDown => 'Перемістити рядок униз'; + + @override + String get tocEmptyMdTopic => 'Порожня тема MD'; + + @override + String get tocEmptyXmlTopic => 'Порожня тема XML'; + + @override + String get tocCopySpecial => 'Спеціальне копіювання'; + + @override + String tocCopyFileName(String name) { + return 'Назва файлу теми \'$name\''; + } + + @override + String get tocCopyFilePath => 'Шлях до файлу теми'; + + @override + String tocCopyTopicTitle(String title) { + return 'Заголовок теми \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID елемента змісту \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Назва файлу теми'; + + @override + String get tocCopyTopicTitleUnavailable => 'Заголовок теми'; + + @override + String get tocCopyElementIdUnavailable => 'ID елемента змісту'; + + @override + String get tocPreviewTopic => 'Попередній перегляд теми'; + + @override + String get tocEditTitleAction => 'Редагувати заголовок...'; + + @override + String get tocEditTitleDialog => 'Редагувати заголовок'; + + @override + String get tocRemoveElementAction => 'Вилучити елемент змісту...'; + + @override + String get tocSetHomePage => 'Зробити домашньою сторінкою'; + + @override + String tocGoToElement(String fileName) { + return 'Перейти до елемента змісту в «$fileName»'; + } + + @override + String get tocSynchronize => 'Синхронізувати зміст і редактор'; + + @override + String get tocEmptyGroupAction => 'Порожня група'; + + @override + String get tocNewEmptyGroup => 'Нова порожня група'; + + @override + String get tocGroupAction => 'Згрупувати'; + + @override + String get tocNewGroup => 'Нова група'; + + @override + String get tocSortChildren => 'Сортувати дочірні теми за абеткою'; + + @override + String get tocLinkTopicFiles => 'Пов’язати файли тем зі змістом...'; + + @override + String get tocTitleField => 'Заголовок у змісті:'; + + @override + String get tocTopicTitleField => 'Заголовок теми:'; + + @override + String get tocAdvancedSettings => 'Додаткові налаштування'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Заголовок для «$instanceId»:'; + } + + @override + String get tocOnlyTitleField => 'Заголовок лише для змісту:'; + + @override + String get tocFindUsages => 'Пошук'; + + @override + String get tocRefactorMenu => 'Рефакторинг'; + + @override + String get tocSafeDelete => 'Безпечне видалення'; + + @override + String get tocDuplicate => 'Дублювати'; + + @override + String get tocDuplicateFilename => 'Назва файлу теми:'; + + @override + String get tocDuplicateDialog => 'Дублювати тему'; + @override String get pdfTitlePage => 'Титульна сторінка'; @@ -1183,7 +1396,7 @@ class AppLocalizationsUk extends AppLocalizations { String get files => 'Файли'; @override - String get toc => 'Теми'; + String get toc => 'Зміст'; @override String get tocActions => 'Дії зі змістом'; @@ -1223,7 +1436,7 @@ class AppLocalizationsUk extends AppLocalizations { String get newChildTopic => 'Нова дочірня тема'; @override - String get newSiblingTopic => 'Нова тема на тому самому рівні'; + String get newSiblingTopic => 'Нова тема'; @override String get renameTopicFile => 'Перейменувати файл теми'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 488f2185..d7f8f667 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -10,6 +10,219 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get tocTopicFromTemplate => 'Chủ đề từ mẫu...'; + + @override + String get tocSaveAsTemplate => 'Lưu thành mẫu'; + + @override + String get tocTemplateDialog => 'Tạo chủ đề từ mẫu'; + + @override + String get tocTemplatesEditor => 'Mẫu tệp và mã'; + + @override + String get tocEditTemplates => 'Chỉnh sửa mẫu...'; + + @override + String get tocCreateCustomTemplate => 'Tạo mẫu tùy chỉnh...'; + + @override + String get tocTemplateCreated => 'Đã tạo mẫu'; + + @override + String get tocTemplateDefault => 'Mặc định'; + + @override + String get tocTemplateCustom => 'Tùy chỉnh'; + + @override + String get tocTemplateFilename => 'Tên tệp:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => 'Nội bộ'; + + @override + String get tocTemplateUnnamed => 'Chưa đặt tên'; + + @override + String get tocTemplateNew => 'Mẫu mới...'; + + @override + String get tocTemplateName => 'Tên:'; + + @override + String get tocTemplateExtension => 'Phần mở rộng:'; + + @override + String get tocTemplateLoadError => + 'Không thể tải mẫu. Các mẫu hiện có không bị thay đổi.'; + + @override + String get tocTemplateSaveError => + 'Không thể lưu mẫu. Các chỉnh sửa của bạn vẫn còn ở đây.'; + + @override + String get tocTemplateConflict => + 'Mẫu đã thay đổi trong cửa sổ khác. Hãy hủy và mở lại trình chỉnh sửa.'; + + @override + String get tocTemplateDuplicateName => + 'Đã có mẫu với tên và phần mở rộng này.'; + + @override + String get tocTemplateFormat => 'Định dạng:'; + + @override + String get tocTemplateReset => 'Đặt lại'; + + @override + String get tocTemplateRetry => 'Thử lại'; + + @override + String tocRemoveElementsDialog(int count) { + return 'Xóa $count phần tử mục lục'; + } + + @override + String get tocRemoveElementsRetainFiles => + 'Các tệp nguồn liên kết với những phần tử mục lục này sẽ không bị xóa.'; + + @override + String get tocOk => 'OK'; + + @override + String get tocSelectExistingTopic => + 'Chọn chủ đề để thêm vào phiên bản hiện tại'; + + @override + String get tocGroupName => 'Tên nhóm'; + + @override + String get tocMoveLineUp => 'Di chuyển dòng lên'; + + @override + String get tocMoveLineDown => 'Di chuyển dòng xuống'; + + @override + String get tocEmptyMdTopic => 'Chủ đề MD trống'; + + @override + String get tocEmptyXmlTopic => 'Chủ đề XML trống'; + + @override + String get tocCopySpecial => 'Sao chép đặc biệt'; + + @override + String tocCopyFileName(String name) { + return 'Tên tệp chủ đề \'$name\''; + } + + @override + String get tocCopyFilePath => 'Đường dẫn tệp chủ đề'; + + @override + String tocCopyTopicTitle(String title) { + return 'Tiêu đề chủ đề \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return 'ID phần tử mục lục \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => 'Tên tệp chủ đề'; + + @override + String get tocCopyTopicTitleUnavailable => 'Tiêu đề chủ đề'; + + @override + String get tocCopyElementIdUnavailable => 'ID phần tử mục lục'; + + @override + String get tocPreviewTopic => 'Xem trước chủ đề'; + + @override + String get tocEditTitleAction => 'Sửa tiêu đề...'; + + @override + String get tocEditTitleDialog => 'Sửa tiêu đề'; + + @override + String get tocRemoveElementAction => 'Xóa phần tử mục lục...'; + + @override + String get tocSetHomePage => 'Đặt làm trang chủ'; + + @override + String tocGoToElement(String fileName) { + return 'Đi đến phần tử mục lục trong \'$fileName\''; + } + + @override + String get tocSynchronize => 'Đồng bộ mục lục và trình soạn thảo'; + + @override + String get tocEmptyGroupAction => 'Nhóm trống'; + + @override + String get tocNewEmptyGroup => 'Nhóm trống mới'; + + @override + String get tocGroupAction => 'Nhóm lại'; + + @override + String get tocNewGroup => 'Nhóm mới'; + + @override + String get tocSortChildren => 'Sắp xếp chủ đề con theo bảng chữ cái'; + + @override + String get tocLinkTopicFiles => 'Liên kết tệp chủ đề với mục lục...'; + + @override + String get tocTitleField => 'Tiêu đề mục lục:'; + + @override + String get tocTopicTitleField => 'Tiêu đề chủ đề:'; + + @override + String get tocAdvancedSettings => 'Cài đặt nâng cao'; + + @override + String tocInstanceTitleField(String instanceId) { + return 'Tiêu đề cho \'$instanceId\':'; + } + + @override + String get tocOnlyTitleField => 'Tiêu đề chỉ dành cho mục lục:'; + + @override + String get tocFindUsages => 'Tìm'; + + @override + String get tocRefactorMenu => 'Tái cấu trúc'; + + @override + String get tocSafeDelete => 'Xóa an toàn'; + + @override + String get tocDuplicate => 'Nhân bản'; + + @override + String get tocDuplicateFilename => 'Tên tệp chủ đề:'; + + @override + String get tocDuplicateDialog => 'Nhân bản chủ đề'; + @override String get pdfTitlePage => 'Trang tiêu đề'; @@ -1155,7 +1368,7 @@ class AppLocalizationsVi extends AppLocalizations { String get files => 'Tệp'; @override - String get toc => 'Chủ đề'; + String get toc => 'Mục lục'; @override String get tocActions => 'Thao tác với mục lục'; @@ -1193,7 +1406,7 @@ class AppLocalizationsVi extends AppLocalizations { String get newChildTopic => 'Chủ đề con mới'; @override - String get newSiblingTopic => 'Chủ đề cùng cấp mới'; + String get newSiblingTopic => 'Chủ đề mới'; @override String get renameTopicFile => 'Đổi tên tệp chủ đề'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 495d6472..0e0bc900 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -10,6 +10,213 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get tocTopicFromTemplate => '从模板创建主题...'; + + @override + String get tocSaveAsTemplate => '保存为模板'; + + @override + String get tocTemplateDialog => '从模板创建主题'; + + @override + String get tocTemplatesEditor => '文件和代码模板'; + + @override + String get tocEditTemplates => '编辑模板...'; + + @override + String get tocCreateCustomTemplate => '创建自定义模板...'; + + @override + String get tocTemplateCreated => '已创建模板'; + + @override + String get tocTemplateDefault => '默认'; + + @override + String get tocTemplateCustom => '自定义'; + + @override + String get tocTemplateFilename => '文件名:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => '内部'; + + @override + String get tocTemplateUnnamed => '未命名'; + + @override + String get tocTemplateNew => '新建模板...'; + + @override + String get tocTemplateName => '名称:'; + + @override + String get tocTemplateExtension => '扩展名:'; + + @override + String get tocTemplateLoadError => '无法加载模板。现有模板未更改。'; + + @override + String get tocTemplateSaveError => '无法保存模板。您的编辑内容仍保留在此处。'; + + @override + String get tocTemplateConflict => '模板已在其他窗口中更改。请取消并重新打开编辑器。'; + + @override + String get tocTemplateDuplicateName => '已存在相同名称和扩展名的模板。'; + + @override + String get tocTemplateFormat => '格式:'; + + @override + String get tocTemplateReset => '重置'; + + @override + String get tocTemplateRetry => '重试'; + + @override + String tocRemoveElementsDialog(int count) { + return '移除 $count 个目录元素'; + } + + @override + String get tocRemoveElementsRetainFiles => '不会删除与这些目录元素关联的源文件。'; + + @override + String get tocOk => '确定'; + + @override + String get tocSelectExistingTopic => '选择要添加到当前实例的主题'; + + @override + String get tocGroupName => '组名'; + + @override + String get tocMoveLineUp => '上移行'; + + @override + String get tocMoveLineDown => '下移行'; + + @override + String get tocEmptyMdTopic => '空 MD 主题'; + + @override + String get tocEmptyXmlTopic => '空 XML 主题'; + + @override + String get tocCopySpecial => '特殊复制'; + + @override + String tocCopyFileName(String name) { + return '主题文件名 \'$name\''; + } + + @override + String get tocCopyFilePath => '主题文件路径'; + + @override + String tocCopyTopicTitle(String title) { + return '主题标题 \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return '目录元素 ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => '主题文件名'; + + @override + String get tocCopyTopicTitleUnavailable => '主题标题'; + + @override + String get tocCopyElementIdUnavailable => '目录元素 ID'; + + @override + String get tocPreviewTopic => '预览主题'; + + @override + String get tocEditTitleAction => '编辑标题...'; + + @override + String get tocEditTitleDialog => '编辑标题'; + + @override + String get tocRemoveElementAction => '移除目录元素...'; + + @override + String get tocSetHomePage => '设为主页'; + + @override + String tocGoToElement(String fileName) { + return '转到“$fileName”中的目录元素'; + } + + @override + String get tocSynchronize => '同步目录与编辑器'; + + @override + String get tocEmptyGroupAction => '空组'; + + @override + String get tocNewEmptyGroup => '新建空组'; + + @override + String get tocGroupAction => '分组'; + + @override + String get tocNewGroup => '新建组'; + + @override + String get tocSortChildren => '按字母顺序排列子主题'; + + @override + String get tocLinkTopicFiles => '将主题文件链接到目录...'; + + @override + String get tocTitleField => '目录标题:'; + + @override + String get tocTopicTitleField => '主题标题:'; + + @override + String get tocAdvancedSettings => '高级设置'; + + @override + String tocInstanceTitleField(String instanceId) { + return '“$instanceId”的标题:'; + } + + @override + String get tocOnlyTitleField => '仅用于目录的标题:'; + + @override + String get tocFindUsages => '查找'; + + @override + String get tocRefactorMenu => '重构'; + + @override + String get tocSafeDelete => '安全删除'; + + @override + String get tocDuplicate => '创建副本'; + + @override + String get tocDuplicateFilename => '主题文件名:'; + + @override + String get tocDuplicateDialog => '创建主题副本'; + @override String get pdfTitlePage => '标题页'; @@ -1105,7 +1312,7 @@ class AppLocalizationsZh extends AppLocalizations { String get files => '文件'; @override - String get toc => '主题'; + String get toc => '目录'; @override String get tocActions => '目录操作'; @@ -1143,7 +1350,7 @@ class AppLocalizationsZh extends AppLocalizations { String get newChildTopic => '新建子主题'; @override - String get newSiblingTopic => '新建同级主题'; + String get newSiblingTopic => '新建主题'; @override String get renameTopicFile => '重命名主题文件'; @@ -4041,6 +4248,213 @@ class AppLocalizationsZh extends AppLocalizations { class AppLocalizationsZhCn extends AppLocalizationsZh { AppLocalizationsZhCn() : super('zh_CN'); + @override + String get tocTopicFromTemplate => '从模板创建主题...'; + + @override + String get tocSaveAsTemplate => '保存为模板'; + + @override + String get tocTemplateDialog => '从模板创建主题'; + + @override + String get tocTemplatesEditor => '文件和代码模板'; + + @override + String get tocEditTemplates => '编辑模板...'; + + @override + String get tocCreateCustomTemplate => '创建自定义模板...'; + + @override + String get tocTemplateCreated => '已创建模板'; + + @override + String get tocTemplateDefault => '默认'; + + @override + String get tocTemplateCustom => '自定义'; + + @override + String get tocTemplateFilename => '文件名:'; + + @override + String get tocTemplateMarkdown => 'Markdown (.md)'; + + @override + String get tocTemplateXml => 'XML (.topic)'; + + @override + String get tocTemplatesInternal => '内部'; + + @override + String get tocTemplateUnnamed => '未命名'; + + @override + String get tocTemplateNew => '新建模板...'; + + @override + String get tocTemplateName => '名称:'; + + @override + String get tocTemplateExtension => '扩展名:'; + + @override + String get tocTemplateLoadError => '无法加载模板。现有模板未更改。'; + + @override + String get tocTemplateSaveError => '无法保存模板。您的编辑内容仍保留在此处。'; + + @override + String get tocTemplateConflict => '模板已在其他窗口中更改。请取消并重新打开编辑器。'; + + @override + String get tocTemplateDuplicateName => '已存在相同名称和扩展名的模板。'; + + @override + String get tocTemplateFormat => '格式:'; + + @override + String get tocTemplateReset => '重置'; + + @override + String get tocTemplateRetry => '重试'; + + @override + String tocRemoveElementsDialog(int count) { + return '移除 $count 个目录元素'; + } + + @override + String get tocRemoveElementsRetainFiles => '不会删除与这些目录元素关联的源文件。'; + + @override + String get tocOk => '确定'; + + @override + String get tocSelectExistingTopic => '选择要添加到当前实例的主题'; + + @override + String get tocGroupName => '组名'; + + @override + String get tocMoveLineUp => '上移行'; + + @override + String get tocMoveLineDown => '下移行'; + + @override + String get tocEmptyMdTopic => '空 MD 主题'; + + @override + String get tocEmptyXmlTopic => '空 XML 主题'; + + @override + String get tocCopySpecial => '特殊复制'; + + @override + String tocCopyFileName(String name) { + return '主题文件名 \'$name\''; + } + + @override + String get tocCopyFilePath => '主题文件路径'; + + @override + String tocCopyTopicTitle(String title) { + return '主题标题 \'$title\''; + } + + @override + String tocCopyElementId(String id) { + return '目录元素 ID \'$id\''; + } + + @override + String get tocCopyFileNameUnavailable => '主题文件名'; + + @override + String get tocCopyTopicTitleUnavailable => '主题标题'; + + @override + String get tocCopyElementIdUnavailable => '目录元素 ID'; + + @override + String get tocPreviewTopic => '预览主题'; + + @override + String get tocEditTitleAction => '编辑标题...'; + + @override + String get tocEditTitleDialog => '编辑标题'; + + @override + String get tocRemoveElementAction => '移除目录元素...'; + + @override + String get tocSetHomePage => '设为主页'; + + @override + String tocGoToElement(String fileName) { + return '转到“$fileName”中的目录元素'; + } + + @override + String get tocSynchronize => '同步目录与编辑器'; + + @override + String get tocEmptyGroupAction => '空组'; + + @override + String get tocNewEmptyGroup => '新建空组'; + + @override + String get tocGroupAction => '分组'; + + @override + String get tocNewGroup => '新建组'; + + @override + String get tocSortChildren => '按字母顺序排列子主题'; + + @override + String get tocLinkTopicFiles => '将主题文件链接到目录...'; + + @override + String get tocTitleField => '目录标题:'; + + @override + String get tocTopicTitleField => '主题标题:'; + + @override + String get tocAdvancedSettings => '高级设置'; + + @override + String tocInstanceTitleField(String instanceId) { + return '“$instanceId”的标题:'; + } + + @override + String get tocOnlyTitleField => '仅用于目录的标题:'; + + @override + String get tocFindUsages => '查找'; + + @override + String get tocRefactorMenu => '重构'; + + @override + String get tocSafeDelete => '安全删除'; + + @override + String get tocDuplicate => '创建副本'; + + @override + String get tocDuplicateFilename => '主题文件名:'; + + @override + String get tocDuplicateDialog => '创建主题副本'; + @override String get pdfTitlePage => '标题页'; @@ -5136,7 +5550,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get files => '文件'; @override - String get toc => '主题'; + String get toc => '目录'; @override String get tocActions => '目录操作'; @@ -5174,7 +5588,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get newChildTopic => '新建子主题'; @override - String get newSiblingTopic => '新建同级主题'; + String get newSiblingTopic => '新建主题'; @override String get renameTopicFile => '重命名主题文件'; diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index ad18fd0d..438f023e 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1321,7 +1321,8 @@ final class BusyMarkMenuSession { } } -/// Presents a menu through GTK on Linux and a themed Flutter route elsewhere. +/// Presents menus, including nested submenus, through the desktop toolkit. +/// A themed fallback is only used when no native host is available. Future showBusyMarkMenu({ required BuildContext context, required List> items, @@ -1358,6 +1359,7 @@ Future showBusyMarkMenu({ entries: nativeEntries, focusFirst: focusFirst, preferAbove: preferAbove, + textDirection: Directionality.of(context), ); if (presentation.dismissed) { return null; @@ -1369,6 +1371,40 @@ Future showBusyMarkMenu({ if (!context.mounted) { return null; } + if (itemSnapshot.any((item) => item is BusyMarkSubmenuItem)) { + final navigator = Navigator.of(context, rootNavigator: true); + if (!navigator.mounted || navigator.overlay?.mounted != true) return null; + final previousFocus = FocusManager.instance.primaryFocus; + final overlay = navigator.overlay?.context.findRenderObject(); + final localAnchor = overlay is RenderBox + ? Rect.fromPoints( + overlay.globalToLocal(anchor.topLeft), + overlay.globalToLocal(anchor.bottomRight), + ) + : anchor; + final route = _BusyMarkNestedMenuRoute( + anchor: localAnchor, + items: itemSnapshot, + themes: InheritedTheme.capture(from: context, to: navigator.context), + direction: Directionality.of(context), + focusFirst: focusFirst, + routeKey: presentation._fallbackRouteKey, + dismissLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + ); + final result = navigator.push(route); + WidgetsBinding.instance.addPostFrameCallback( + (_) => presentation._captureFallbackRoute(), + ); + try { + final value = await result; + return presentation.dismissed ? null : value; + } finally { + presentation._releaseFallbackRoute(); + if (previousFocus?.context?.mounted == true) { + previousFocus!.requestFocus(); + } + } + } final selection = await _showBusyMarkFallbackMenu( context: context, anchor: anchor, @@ -1407,6 +1443,16 @@ List? _busyMarkNativeMenuEntries( selected: item.trailingCheck && item.checked, ), ); + } else if (item is BusyMarkSubmenuItem) { + final children = _busyMarkNativeMenuEntries(item.items); + if (children == null) return null; + entries.add( + NativeMenuEntry.submenu( + label: item.label, + enabled: item.enabled, + children: children, + ), + ); } else if (item is PopupMenuDivider) { entries.add(const NativeMenuEntry.separator()); } else { @@ -1417,14 +1463,27 @@ List? _busyMarkNativeMenuEntries( } T? _busyMarkMenuValueAt(List> items, int? index) { - if (index == null || index < 0 || index >= items.length) { - return null; - } - final item = items[index]; - if (item is! BusyMarkPopupMenuItem || !item.enabled) { + if (index == null || index < 0) return null; + // GTK and Dart use the same preorder, including headings and separators. + // Only enabled leaves (with enabled ancestors) can return an action. + var position = 0; + T? visit(List> entries, bool ancestorsEnabled) { + for (final item in entries) { + final current = position++; + if (item is BusyMarkSubmenuItem) { + final value = visit(item.items, ancestorsEnabled && item.enabled); + if (value != null) return value; + } else if (current == index && + ancestorsEnabled && + item is BusyMarkPopupMenuItem && + item.enabled) { + return item.menuValue; + } + } return null; } - return item.menuValue; + + return visit(items, true); } Future _showBusyMarkFallbackMenu({ @@ -1523,6 +1582,155 @@ Future showBusyMarkContextMenu( ); } +/// A real nested menu; its label is never a selectable command. +class BusyMarkSubmenuItem extends PopupMenuEntry { + const BusyMarkSubmenuItem({ + super.key, + required this.label, + required this.items, + this.enabled = true, + }); + final String label; + final List> items; + final bool enabled; + @override + double get height => kMinInteractiveDimension; + @override + bool represents(T? value) => false; + @override + State> createState() => _BusyMarkSubmenuItemState(); +} + +class _BusyMarkSubmenuItemState extends State> { + @override + Widget build(BuildContext context) => Text(widget.label); +} + +class _BusyMarkNestedMenuRoute extends PopupRoute { + _BusyMarkNestedMenuRoute({ + required this.anchor, + required this.items, + required this.themes, + required this.direction, + required this.routeKey, + required this.dismissLabel, + required this.focusFirst, + }); + final bool focusFirst; + final Rect anchor; + final List> items; + final CapturedThemes themes; + final TextDirection direction; + final GlobalKey routeKey; + final String dismissLabel; + @override + Color? get barrierColor => null; + @override + bool get barrierDismissible => true; + @override + String? get barrierLabel => dismissLabel; + @override + Duration get transitionDuration => Duration.zero; + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => themes.wrap( + Directionality( + textDirection: direction, + child: _BusyMarkNestedMenu(route: this), + ), + ); +} + +class _BusyMarkNestedMenu extends StatefulWidget { + const _BusyMarkNestedMenu({required this.route}); + final _BusyMarkNestedMenuRoute route; + @override + State<_BusyMarkNestedMenu> createState() => _BusyMarkNestedMenuState(); +} + +class _BusyMarkNestedMenuState extends State<_BusyMarkNestedMenu> { + final _controller = MenuController(); + final _firstFocus = FocusNode(debugLabel: 'First nested menu item'); + bool _selected = false; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _controller.open(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.route.focusFirst) _firstFocus.requestFocus(); + }); + }); + } + + @override + void dispose() { + _firstFocus.dispose(); + super.dispose(); + } + + List _children( + List> items, { + bool root = false, + }) => [ + for (final item in items) + if (item is BusyMarkSubmenuItem) + SubmenuButton( + focusNode: root && identical(item, items.first) ? _firstFocus : null, + menuChildren: item.enabled ? _children(item.items) : const [], + child: Text(item.label), + ) + else if (item is BusyMarkPopupMenuItem) + MenuItemButton( + focusNode: root && identical(item, items.first) ? _firstFocus : null, + closeOnActivate: false, + onPressed: item.enabled + ? () { + _selected = true; + Navigator.of(context).pop(item.menuValue); + } + : null, + leadingIcon: item.icon == null + ? null + : Icon( + item.icon, + size: BusyMarkSizes.iconSm, + color: item.iconColor, + ), + trailingIcon: item.shortcut == null ? null : Text(item.shortcut!), + child: Text(item.label), + ) + else if (item is PopupMenuDivider) + const Divider(height: BusyMarkSpacing.sm), + ]; + + @override + Widget build(BuildContext context) => Stack( + children: [ + Positioned( + left: widget.route.anchor.left, + top: widget.route.anchor.bottom, + child: MenuAnchor( + key: widget.route.routeKey, + controller: _controller, + consumeOutsideTap: true, + onClose: () { + if (!_selected && mounted && widget.route.isCurrent) { + Navigator.of(context).pop(); + } + }, + menuChildren: _children(widget.route.items, root: true), + child: const SizedBox.shrink(), + ), + ), + ], + ); +} + class BusyMarkPopupMenuItem extends PopupMenuItem { BusyMarkPopupMenuItem({ super.key, diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index 33766699..0497da16 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -177,7 +177,9 @@ class BusyMarkCommandRegistry { bool _isContextual(BusyMarkCommand command) { return command.scope == BusyMarkCommandScope.editor || - command.scope == BusyMarkCommandScope.textEditing; + command.scope == BusyMarkCommandScope.textEditing || + command.id == BusyMarkCommandIds.treeMoveLineUp || + command.id == BusyMarkCommandIds.treeMoveLineDown; } bool shortcutAccepts(String id, KeyEvent event, HardwareKeyboard keyboard) { @@ -280,6 +282,8 @@ abstract final class BusyMarkCommandIds { static const localHistory = 'history.local'; static const findLocalHistory = 'history.findLocal'; static const treeDeleteSelection = 'tree.deleteSelection'; + static const treeMoveLineUp = 'tree.moveLineUp'; + static const treeMoveLineDown = 'tree.moveLineDown'; } abstract final class BusyMarkCommandCatalog { @@ -300,7 +304,9 @@ abstract final class BusyMarkCommandCatalog { }) { final contextual = scope == BusyMarkCommandScope.editor || - scope == BusyMarkCommandScope.textEditing; + scope == BusyMarkCommandScope.textEditing || + id == BusyMarkCommandIds.treeMoveLineUp || + id == BusyMarkCommandIds.treeMoveLineDown; return BusyMarkCommand( id: id, label: label, @@ -357,6 +363,34 @@ abstract final class BusyMarkCommandCatalog { shortcut: BusyMarkTextEditingShortcuts.definitions[action], description: (context) => _textDescription(context, action), ), + command( + id: BusyMarkCommandIds.treeMoveLineUp, + label: (context) => context.l10n.tocMoveLineUp, + category: (context) => context.l10n.toc, + scope: BusyMarkCommandScope.tree, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+Home', + activator: SingleActivator( + LogicalKeyboardKey.home, + control: true, + shift: true, + ), + ), + ), + command( + id: BusyMarkCommandIds.treeMoveLineDown, + label: (context) => context.l10n.tocMoveLineDown, + category: (context) => context.l10n.toc, + scope: BusyMarkCommandScope.tree, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+End', + activator: SingleActivator( + LogicalKeyboardKey.end, + control: true, + shift: true, + ), + ), + ), for (final action in BusyMarkEditorShortcutAction.values) command( id: 'editor.${action.name}', diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index efb63f34..ba2f4429 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -22,6 +22,99 @@ abstract final class SourceCommands { static const int defaultIndentWidth = 2; + /// Moves complete physical lines in full-source coordinates. Line separators + /// belong to their positions, retaining CRLF and a missing final newline. + static TextEditingValue moveLines( + TextEditingValue value, { + required bool down, + }) { + final selection = value.selection.isValid + ? TextSelection( + baseOffset: value.selection.baseOffset.clamp(0, value.text.length), + extentOffset: value.selection.extentOffset.clamp( + 0, + value.text.length, + ), + affinity: value.selection.affinity, + isDirectional: value.selection.isDirectional, + ) + : TextSelection.collapsed(offset: value.text.length); + final text = value.text; + final starts = [0, for (final match in '\n'.allMatches(text)) match.end]; + int lineAt(int offset) { + for (var i = starts.length - 1; i >= 0; i--) { + if (offset >= starts[i]) return i; + } + return 0; + } + + final first = lineAt(selection.start); + final last = lineAt( + selection.isCollapsed + ? selection.end + : math.max(selection.start, selection.end - 1), + ); + if ((!down && first == 0) || (down && last == starts.length - 1)) { + return value; + } + final contents = []; + final endings = []; + for (var i = 0; i < starts.length; i++) { + final end = i + 1 < starts.length ? starts[i + 1] : text.length; + final raw = text.substring(starts[i], end); + final ending = raw.endsWith('\r\n') + ? '\r\n' + : raw.endsWith('\n') + ? '\n' + : ''; + contents.add(raw.substring(0, raw.length - ending.length)); + endings.add(ending); + } + final order = List.generate(contents.length, (index) => index); + if (down) { + final next = order.removeAt(last + 1); + order.insert(first, next); + } else { + final previous = order.removeAt(first - 1); + order.insert(last, previous); + } + final result = StringBuffer(); + final newStarts = {}; + for (var i = 0; i < order.length; i++) { + newStarts[order[i]] = result.length; + result.write(contents[order[i]]); + result.write(endings[i]); + } + int shifted(int offset) { + final originalLine = lineAt(offset); + // A selection ending at the next line start remains a line selection. + if (!selection.isCollapsed && + offset == selection.end && + offset == starts[originalLine] && + originalLine > last) { + final destination = order.indexOf(last); + return newStarts[last]! + + contents[last].length + + endings[destination].length; + } + return newStarts[originalLine]! + + math.min( + offset - starts[originalLine], + contents[originalLine].length, + ); + } + + return TextEditingValue( + text: result.toString(), + selection: TextSelection( + baseOffset: shifted(selection.baseOffset), + extentOffset: shifted(selection.extentOffset), + affinity: selection.affinity, + isDirectional: selection.isDirectional, + ), + ); + } + static TextEditingValue insertTab( TextEditingValue value, { int indentWidth = defaultIndentWidth, diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 29e8f7b5..d6266e6a 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -316,8 +316,16 @@ class BusyMarkSourceEditorState extends State { } void scrollToLine(int line) { + scrollToOffset(_textOffsetForLine(_controller.fullText, line)); + } + + /// Reveals a full-document offset, including a destination inside a fold. + void scrollToOffset(int offset) { + final textOffset = offset.clamp(0, _controller.fullText.length); + final line = + '\n'.allMatches(_controller.fullText.substring(0, textOffset)).length + + 1; _unfoldSourceLine(line); - final textOffset = _textOffsetForLine(_controller.fullText, line); _focusNode.requestFocus(); _controller.fullSelection = TextSelection.collapsed(offset: textOffset); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -383,6 +391,28 @@ class BusyMarkSourceEditorState extends State { }); } + void _moveSourceLines({required bool down}) { + final before = _fullEditingValue(); + final after = SourceCommands.moveLines(before, down: down); + if (after == before) return; + var start = 0; + while (start < before.text.length && + start < after.text.length && + before.text.codeUnitAt(start) == after.text.codeUnitAt(start)) { + start++; + } + var end = before.text.length; + var nextEnd = after.text.length; + while (end > start && + nextEnd > start && + before.text.codeUnitAt(end - 1) == after.text.codeUnitAt(nextEnd - 1)) { + end--; + nextEnd--; + } + _unfoldSourceRange(start, end); + _applyFullEditingValue(after); + } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; @@ -434,6 +464,22 @@ class BusyMarkSourceEditorState extends State { final commands = BusyMarkCommandRegistryScope.read(context) ?? BusyMarkCommandCatalog.metadata; + if (widget.filePath?.toLowerCase().endsWith('.tree') == true) { + final up = commands.shortcutAccepts( + BusyMarkCommandIds.treeMoveLineUp, + event, + keyboard, + ); + final down = commands.shortcutAccepts( + BusyMarkCommandIds.treeMoveLineDown, + event, + keyboard, + ); + if (up || down) { + _moveSourceLines(down: down); + return KeyEventResult.handled; + } + } if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { widget.onOpenSearch(); return KeyEventResult.handled; @@ -582,12 +628,31 @@ class BusyMarkSourceEditorState extends State { isCommandEnabled: (commandId) => !_hasActiveComposition && (commandId.startsWith('editor.') || + (widget.filePath?.toLowerCase().endsWith( + '.tree', + ) == + true && + { + BusyMarkCommandIds.treeMoveLineUp, + BusyMarkCommandIds.treeMoveLineDown, + }.contains(commandId)) || { BusyMarkCommandIds.textCopy, BusyMarkCommandIds.textCut, BusyMarkCommandIds.textPaste, }.contains(commandId)), onCommand: (commandId) { + if (commandId == + BusyMarkCommandIds.treeMoveLineUp || + commandId == + BusyMarkCommandIds.treeMoveLineDown) { + _moveSourceLines( + down: + commandId == + BusyMarkCommandIds.treeMoveLineDown, + ); + return; + } if (commandId == BusyMarkCommandIds.textCopy) { unawaited(_copyOrCutSource(cut: false)); return; diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index 8a2351e8..090de857 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -28,7 +28,19 @@ final class NativeMenuEntry { this.enabled = true, this.checkable = false, this.selected = false, - }) : separator = false; + }) : separator = false, + children = null; + + const NativeMenuEntry.submenu({ + required this.label, + required List this.children, + this.enabled = true, + }) : iconName = null, + iconColorArgb = null, + shortcut = null, + checkable = false, + selected = false, + separator = false; const NativeMenuEntry.separator() : label = '', @@ -38,7 +50,8 @@ final class NativeMenuEntry { enabled = false, checkable = false, selected = false, - separator = true; + separator = true, + children = null; final String label; final String? iconName; @@ -48,6 +61,7 @@ final class NativeMenuEntry { final bool checkable; final bool selected; final bool separator; + final List? children; Map _toPlatformMap() { return { @@ -59,6 +73,8 @@ final class NativeMenuEntry { 'checkable': checkable, 'selected': selected, 'separator': separator, + if (children != null) + 'children': [for (final child in children!) child._toPlatformMap()], }; } } @@ -90,6 +106,7 @@ class NativeMenuService { required List entries, bool focusFirst = false, bool preferAbove = false, + TextDirection? textDirection, }) async { try { final selectedIndex = await _channel.invokeMethod('show', { @@ -103,6 +120,7 @@ class NativeMenuService { 'entries': [for (final entry in entries) entry._toPlatformMap()], 'focusFirst': focusFirst, 'preferredPosition': preferAbove ? 'top' : 'bottom', + if (textDirection != null) 'textDirection': textDirection.name, }); return NativeMenuResult.available(selectedIndex: selectedIndex); } on MissingPluginException { diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 84124933..c7781dbb 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -84,6 +84,8 @@ import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_model.dart'; import '../../writerside/writerside_project.dart'; import '../../writerside/writerside_toc_editor.dart'; +import '../../writerside/writerside_toc_presentation.dart'; +import '../../writerside/writerside_toc_navigation.dart'; import '../../writerside/writerside_topic_creator.dart'; import '../../writerside/writerside_topic_removal_service.dart'; import '../../writerside/writerside_video.dart'; @@ -98,6 +100,13 @@ import '../workspace_safety.dart'; import '../workspace_tabs.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; +import 'writerside_toc_dialogs.dart'; +import 'writerside_template_dialogs.dart'; +import '../../writerside/writerside_template_service.dart'; +import '../../writerside/writerside_parsers.dart'; +import '../../writerside/writerside_document_resolver.dart'; +import '../../writerside/writerside_document_renderer.dart'; +import '../../writerside/writerside_title_editor.dart'; final _problemsPresentationProvider = NotifierProvider<_ProblemsPresentation, bool>(_ProblemsPresentation.new); @@ -424,10 +433,19 @@ class _OutlineViewportTarget { } class _SourceNavigationTarget { - const _SourceNavigationTarget({required this.filePath, required this.line}); + const _SourceNavigationTarget({ + required this.filePath, + required this.line, + this.tocPath, + this.tocIdentity, + this.xmlChildren = false, + }); final String filePath; final int line; + final List? tocPath; + final WritersideTocNodeIdentity? tocIdentity; + final bool xmlChildren; } class _WorkspaceSearchState { @@ -2408,11 +2426,14 @@ class _SidebarState extends ConsumerState<_Sidebar> { Future _runWritersideTopicRemoval( BuildContext context, _WritersideTopicRemovalTarget target, { - bool updateUsagesAutomatically = false, + bool updateUsagesAutomatically = true, String? redirectTopicPath, bool applyIfUnused = false, }) async { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + target.topicPath, + if (target.treePath != null) target.treePath!, + ]) || !mounted || !context.mounted) { return null; @@ -2469,11 +2490,33 @@ class _SidebarState extends ConsumerState<_Sidebar> { }); return null; } - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || + final affectedPaths = { + target.topicPath, + if (target.treePath != null) target.treePath!, + if (decision.redirectTarget != null) decision.redirectTarget!.treePath, + for (final usage in analysis.usages) + if (usage.relevant) usage.filePath, + }; + final dirtyInputs = ref + .read(workspaceControllerProvider) + .dirtyBuffers + .any((buffer) => affectedPaths.contains(buffer.filePath)); + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, affectedPaths) || !mounted || !context.mounted) { return null; } + if (dirtyInputs) { + // Saving a usage changes the analysis fingerprint. Reanalyze and review + // the new result; never apply the pre-save deletion plan. + return _runWritersideTopicRemoval( + context, + target, + updateUsagesAutomatically: decision.updateUsagesAutomatically, + redirectTopicPath: decision.redirectTarget?.topicPath, + applyIfUnused: applyIfUnused, + ); + } final result = await controller.applyWritersideTopicRemoval( WritersideTopicRemovalRequest( analysis: analysis, @@ -3603,6 +3646,7 @@ class _WritersideTopicRemovalDialog extends StatefulWidget { class _WritersideTopicRemovalDialogState extends State<_WritersideTopicRemovalDialog> { late bool _updateUsagesAutomatically; + bool _safeDelete = true; WritersideTopicRedirectTarget? _redirectTarget; WritersideTopicRemovalAnalysis get _analysis => widget.analysis; @@ -3611,7 +3655,9 @@ class _WritersideTopicRemovalDialogState if (_analysis.blockingUsages.isEmpty) { return true; } - return _updateUsagesAutomatically && _analysis.canUpdateUsagesAutomatically; + return _safeDelete && + _updateUsagesAutomatically && + _analysis.canUpdateUsagesAutomatically; } @override @@ -3630,7 +3676,7 @@ class _WritersideTopicRemovalDialogState final relevantUsages = _analysis.relevantUsages; final title = removeFromInstance ? context.l10n.removeTocElement - : context.l10n.safeDeleteTopicFile; + : context.l10n.delete; final topicLabel = _analysis.topicTitle?.trim().isNotEmpty == true ? _analysis.topicTitle!.trim() : _analysis.topicFileName; @@ -3657,7 +3703,7 @@ class _WritersideTopicRemovalDialogState BusyMarkDialogButton( label: removeFromInstance ? context.l10n.removeAction - : context.l10n.deleteTopicFile, + : context.l10n.tocOk, icon: removeFromInstance ? BusyMarkGlyphs.outdentFor(Directionality.of(context)) : BusyMarkGlyphs.delete, @@ -3693,69 +3739,25 @@ class _WritersideTopicRemovalDialogState kind: BusyMarkStatusKind.warning, ), ], - BusyMarkGroupedList( - title: context.l10n.topicUsagesCount(relevantUsages.length), - description: relevantUsages.isEmpty - ? context.l10n.noBreakingTopicUsages - : context.l10n.topicUsagesFound, - filled: true, - children: [ - for (final kind in WritersideTopicUsageKind.values) - if (_usageCount(relevantUsages, kind) > 0) - BusyMarkActionRow( - title: _writersideTopicUsageKindLabel(context, kind), - subtitle: context.l10n.usageCount( - _usageCount(relevantUsages, kind), - ), - leading: Icon(_writersideTopicUsageKindIcon(kind)), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.refactoringOptions, - filled: true, - children: [ - BusyMarkSwitchRow( - title: context.l10n.updateUsagesAutomatically, - subtitle: _analysis.canUpdateUsagesAutomatically - ? context.l10n.updateUsagesAutomaticallyDescription - : context.l10n.manualUsageUpdatesRequired, - leading: const Icon(BusyMarkGlyphs.edit), - value: _updateUsagesAutomatically, - enabled: _analysis.canUpdateUsagesAutomatically, - onChanged: (value) { - setState(() => _updateUsagesAutomatically = value); - }, - ), - if (_analysis.redirectTargets.isNotEmpty) - BusyMarkSwitchRow( - title: context.l10n.setRedirectTo, - subtitle: _redirectTarget == null - ? context.l10n.noRedirectDescription - : _redirectTarget!.label, - leading: const Icon(BusyMarkGlyphs.link), + if (removeFromInstance) + Row( + children: [ + Checkbox( value: _redirectTarget != null, - onChanged: (value) { - setState(() { - _redirectTarget = value - ? _analysis.redirectTargets.first - : null; - }); - }, + onChanged: _analysis.redirectTargets.isEmpty + ? null + : (value) => setState( + () => _redirectTarget = value == true + ? _analysis.redirectTargets.first + : null, + ), ), - ], - ), - if (_redirectTarget != null) ...[ - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.redirectTarget, - leading: const Icon(BusyMarkGlyphs.link), - trailing: BusyMarkPopupSelector( + Text(context.l10n.setRedirectTo), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: BusyMarkPopupSelector( value: _redirectTarget, - label: _redirectTarget!.label, + label: _redirectTarget?.label ?? context.l10n.redirectTarget, tooltip: context.l10n.redirectTarget, options: [ for (final target in _analysis.redirectTargets) @@ -3764,21 +3766,49 @@ class _WritersideTopicRemovalDialogState label: target.label, ), ], - onSelected: (value) { - setState(() => _redirectTarget = value); - }, + onSelected: (value) => + setState(() => _redirectTarget = value), ), ), ], ), - const SizedBox(height: BusyMarkSpacing.xs), - Text( - '${_analysis.oldWebFileName} → ${_redirectTarget!.topicFileName}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMarkSurfaceColors.of(context).mutedForeground, - ), + if (!removeFromInstance) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(context.l10n.tocSafeDelete), + value: _safeDelete, + onChanged: (value) => setState(() { + _safeDelete = value ?? true; + if (!_safeDelete) { + _updateUsagesAutomatically = false; + _redirectTarget = null; + } + }), + ), + if (removeFromInstance || _safeDelete) + Row( + children: [ + Checkbox( + value: _updateUsagesAutomatically, + onChanged: _analysis.canUpdateUsagesAutomatically + ? (value) => setState( + () => _updateUsagesAutomatically = value ?? false, + ) + : null, + ), + Flexible(child: Text(context.l10n.updateUsagesAutomatically)), + const SizedBox(width: BusyMarkSpacing.md), + Flexible( + child: Text( + context.l10n.topicUsagesCount(relevantUsages.length), + style: TextStyle( + color: BusyMarkSurfaceColors.of(context).mutedForeground, + ), + ), + ), + ], ), - ], if (!_canApply) ...[ const SizedBox(height: BusyMarkSpacing.md), BusyMarkStatusBox( @@ -3843,7 +3873,7 @@ class _WritersideTopicUsagesSidebar extends StatelessWidget { const SizedBox(width: BusyMarkSpacing.sm), Expanded( child: Text( - context.l10n.usagesOfTopic(analysis.topicFileName), + context.l10n.tocFindUsages, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -3917,13 +3947,6 @@ class _WritersideTopicUsagesSidebar extends StatelessWidget { } } -int _usageCount( - Iterable usages, - WritersideTopicUsageKind kind, -) { - return usages.where((usage) => usage.kind == kind).length; -} - String _writersideTopicUsageKindLabel( BuildContext context, WritersideTopicUsageKind kind, @@ -4487,19 +4510,28 @@ Future<_FileTreeAction?> _showFileTreeMenu( icon: BusyMarkGlyphs.paste, enabled: showPaste, ), - BusyMarkPopupMenuItem( - value: _FileTreeAction.delete, - label: safeDeleteTopic - ? context.l10n.safeDeleteTopicFile - : context.l10n.delete, - icon: BusyMarkGlyphs.delete, - shortcut: - (BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds - .treeDeleteSelection] - ?.shortcut - ?.label, - ), + if (safeDeleteTopic) + BusyMarkSubmenuItem( + label: context.l10n.tocRefactorMenu, + items: [ + BusyMarkPopupMenuItem( + value: _FileTreeAction.delete, + label: context.l10n.tocSafeDelete, + ), + ], + ) + else + BusyMarkPopupMenuItem( + value: _FileTreeAction.delete, + label: context.l10n.delete, + icon: BusyMarkGlyphs.delete, + shortcut: + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection] + ?.shortcut + ?.label, + ), const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( value: _FileTreeAction.copyName, @@ -4729,6 +4761,7 @@ class _SidebarRowSurface extends StatelessWidget { this.focusNode, this.onKeyEvent, this.onTap, + this.onDoubleTap, this.onSecondaryTapUp, this.onHoverChanged, this.onFocusChange, @@ -4741,6 +4774,7 @@ class _SidebarRowSurface extends StatelessWidget { final FocusNode? focusNode; final FocusOnKeyEventCallback? onKeyEvent; final VoidCallback? onTap; + final VoidCallback? onDoubleTap; final GestureTapUpCallback? onSecondaryTapUp; final ValueChanged? onHoverChanged; final ValueChanged? onFocusChange; @@ -4766,6 +4800,7 @@ class _SidebarRowSurface extends StatelessWidget { ? busyMarkRowHoverColor(context) : BusyMarkLinuxPalette.transparent, onTap: enabled ? onTap : null, + onDoubleTap: enabled ? onDoubleTap : null, onSecondaryTapUp: enabled ? onSecondaryTapUp : null, onFocusChange: onFocusChange, child: SizedBox( @@ -4785,8 +4820,10 @@ class _SidebarTreeRow extends StatefulWidget { super.key, required this.title, required this.depth, - required this.icon, + this.icon, this.leading, + this.trailing, + this.tooltip, this.hasChildren = false, this.expanded = false, this.selected = false, @@ -4796,13 +4833,16 @@ class _SidebarTreeRow extends StatefulWidget { this.vcsColor, this.onToggle, this.onTap, + this.onDoubleTap, this.onMenuRequested, }); final String title; final int depth; - final IconData icon; + final IconData? icon; final Widget? leading; + final Widget? trailing; + final String? tooltip; final bool hasChildren; final bool expanded; final bool selected; @@ -4812,6 +4852,7 @@ class _SidebarTreeRow extends StatefulWidget { final BusyMarkVcsFileColor? vcsColor; final VoidCallback? onToggle; final VoidCallback? onTap; + final VoidCallback? onDoubleTap; final _SidebarTreeMenuRequest? onMenuRequested; @override @@ -4873,12 +4914,13 @@ class _SidebarTreeRowState extends State<_SidebarTreeRow> { final expanderGap = widget.compactHierarchyIndent ? 0.0 : BusyMarkSpacing.xs; - return _SidebarRowSurface( + final surface = _SidebarRowSurface( enabled: widget.enabled, clickable: clickable, selected: widget.selected, onKeyEvent: _handleKeyEvent, onTap: widget.onTap, + onDoubleTap: widget.onDoubleTap, onSecondaryTapUp: widget.onMenuRequested == null ? null : (details) => @@ -4916,19 +4958,21 @@ class _SidebarTreeRowState extends State<_SidebarTreeRow> { : const SizedBox.shrink(), ), SizedBox(width: expanderGap), - SizedBox.square( - dimension: BusyMarkSizes.sidebarTreeControl, - child: Center( - child: - widget.leading ?? - Icon( - widget.icon, - size: BusyMarkSizes.iconSm, - color: foreground, - ), + if (widget.leading != null || widget.icon != null) ...[ + SizedBox.square( + dimension: BusyMarkSizes.sidebarTreeControl, + child: Center( + child: + widget.leading ?? + Icon( + widget.icon, + size: BusyMarkSizes.iconSm, + color: foreground, + ), + ), ), - ), - const SizedBox(width: BusyMarkSpacing.sm), + const SizedBox(width: BusyMarkSpacing.sm), + ], Expanded( child: Text( widget.title, @@ -4937,6 +4981,7 @@ class _SidebarTreeRowState extends State<_SidebarTreeRow> { style: titleStyle, ), ), + if (widget.trailing != null) widget.trailing!, if (widget.onMenuRequested != null) SizedBox( width: BusyMarkSizes.iconButton, @@ -4960,6 +5005,9 @@ class _SidebarTreeRowState extends State<_SidebarTreeRow> { ], ), ); + return widget.tooltip == null || widget.tooltip!.isEmpty + ? surface + : Tooltip(message: widget.tooltip!, child: surface); } } @@ -5290,11 +5338,36 @@ class _TocTabState extends ConsumerState<_TocTab> { String? _selectedInstanceTreePath; late Set _expandedNodeKeys; late final FocusNode _treeFocusNode; - final _selectedRowMenuKey = GlobalKey<_SidebarTreeRowState>(); + final _rowMenuKeys = >{}; + final _rowMenuPositions = {}; + final _pointerModifiers = {}; String? _selectedNodePathKey; Set _selectedNodePathKeys = {}; String? _selectionAnchorPathKey; List<_TocTreeClipboardEntry> _cutEntries = []; + WritersideTocPresenter? _presenter; + final _tocScroll = ItemScrollController(); + final _tocPositions = ItemPositionsListener.create(); + final _tocViewportKey = GlobalKey(); + Timer? _dragScrollTimer; + Timer? _dragExpandTimer; + String? _dropPath; + int _dropZone = 0; + bool _syncFromTree = false; + + void _rememberSyncFocus() { + final focused = FocusManager.instance.primaryFocus?.context; + if (focused == null || + focused.findAncestorWidgetOfExactType<_TocHeader>() != null) { + return; + } + if (_treeFocusNode.hasFocus) { + _syncFromTree = true; + } else if (focused.findAncestorWidgetOfExactType<_EditorPreviewSplit>() != + null) { + _syncFromTree = false; + } + } @override void initState() { @@ -5311,10 +5384,14 @@ class _TocTabState extends ConsumerState<_TocTab> { treePath: _selectedInstanceTreePath, ); _treeFocusNode = FocusNode(debugLabel: 'BusyMark topics tree'); + FocusManager.instance.addListener(_rememberSyncFocus); } @override void dispose() { + _dragScrollTimer?.cancel(); + _dragExpandTimer?.cancel(); + FocusManager.instance.removeListener(_rememberSyncFocus); _treeFocusNode.dispose(); super.dispose(); } @@ -5423,6 +5500,18 @@ class _TocTabState extends ConsumerState<_TocTab> { final instance = _tocInstanceForTreePath(module, _selectedInstanceTreePath) ?? _defaultWritersideInstance(module); + final origins = + widget.workspace.writersideProject?.modulesByOrigin ?? + const {}; + if (!identical(_presenter?.module, module) || + !identical(_presenter?.instance, instance) || + !identical(_presenter?.modulesByOrigin, origins)) { + _presenter = WritersideTocPresenter( + module: module, + instance: instance, + modulesByOrigin: origins, + ); + } final appSettings = ref.watch(appSettingsControllerProvider); final instanceColors = {}; for (var index = 0; index < module.instances.length; index++) { @@ -5439,6 +5528,19 @@ class _TocTabState extends ConsumerState<_TocTab> { instance.navigationTocRoots, _expandedNodeKeys, ); + // The positioned list owns index-keyed viewport children. Keep row keys + // stable during pointer selection, but never reparent a GlobalKey across + // those viewport children when expansion changes a row's visible index. + final visibleKeys = {for (final entry in entries) entry.pathKey}; + _rowMenuKeys.removeWhere((key, _) => !visibleKeys.contains(key)); + _rowMenuPositions.removeWhere((key, _) => !visibleKeys.contains(key)); + for (var i = 0; i < entries.length; i++) { + final key = entries[i].pathKey; + if (_rowMenuPositions[key] != i) { + _rowMenuPositions[key] = i; + _rowMenuKeys[key] = GlobalKey<_SidebarTreeRowState>(); + } + } _TocTreeEntry? selectedEntry; for (final entry in entries) { if (entry.pathKey == _selectedNodePathKey) { @@ -5492,14 +5594,18 @@ class _TocTabState extends ConsumerState<_TocTab> { _ShowSelectedSidebarTreeMenuIntent: CallbackAction<_ShowSelectedSidebarTreeMenuIntent>( onInvoke: (_) { - _selectedRowMenuKey.currentState?.showMenuFromKeyboard(); + _rowMenuKeys[_selectedNodePathKey]?.currentState + ?.showMenuFromKeyboard(); return null; }, ), }, child: Focus( focusNode: _treeFocusNode, - child: ListView.builder( + child: ScrollablePositionedList.builder( + key: _tocViewportKey, + itemScrollController: _tocScroll, + itemPositionsListener: _tocPositions, padding: BusyMarkInsets.sidebarList, itemCount: entries.length + 1, itemBuilder: (context, index) { @@ -5567,12 +5673,27 @@ class _TocTabState extends ConsumerState<_TocTab> { ); } }, - onCreateTopic: () => _showCreateTopicDialog( + onCreateTopic: (choice) => _createTocItem( context, instanceTreePath: instance.sourceTreePath, - placement: WritersideTopicCreatePlacement.root, - referenceEntry: null, + placement: + selectedEntry?.canEditStructureIn( + instance.sourceTreePath, + ) == + true + ? WritersideTopicCreatePlacement.sibling + : WritersideTopicCreatePlacement.root, + referenceEntry: + selectedEntry?.canEditStructureIn( + instance.sourceTreePath, + ) == + true + ? selectedEntry + : null, + choice: choice, ), + onSynchronize: () => + _synchronizeToc(context, instance, selectedEntry), onCreateInstance: () => _showInstanceEditor( context, BusyMarkWritersideInstanceDialogMode.create, @@ -5597,14 +5718,15 @@ class _TocTabState extends ConsumerState<_TocTab> { final key = entry.pathKey; final expanded = _expandedNodeKeys.contains(key); final hasChildren = node.children.isNotEmpty; - final topicReference = node.topicReference; - final writersideTopic = - topicReference == null || node.origin != null - ? null - : module.topicByReference(topicReference); + final presentation = _presenter!.present(node); + final writersideTopic = presentation.topic; final topicPath = writersideTopic?.filePath; final rawLabel = _tocNodeLabel(context, node); - final label = _tocNodeDisplayLabel(context, node); + final label = presentation.label.isEmpty + ? _tocNodeDisplayLabel(context, node) + : writersideTopic != null || node.tocTitle != null + ? presentation.label + : busyMarkLtrIsolateFor(context, presentation.label); final selected = _selectedNodePathKeys.isNotEmpty ? _selectedNodePathKeys.contains(entry.pathKey) : _selectedNodePathKey == null @@ -5653,7 +5775,7 @@ class _TocTabState extends ConsumerState<_TocTab> { ]; unawaited( _showTopicContextMenu( - context, + this.context, instanceTreePath: instance.sourceTreePath, entry: entry, selectedEntries: selectedEntries.isEmpty @@ -5669,53 +5791,217 @@ class _TocTabState extends ConsumerState<_TocTab> { ); } - return KeyedSubtree( + final row = KeyedSubtree( key: ValueKey('workspace-sidebar-toc-row-${entry.pathKey}'), - child: _SidebarTreeRow( - key: entry.pathKey == _selectedNodePathKey - ? _selectedRowMenuKey - : null, - title: label, - enabled: true, - selected: selected, - depth: entry.depth, - icon: node.includeResolutionError != null - ? BusyMarkGlyphs.error - : node.workInProgress - ? BusyMarkGlyphs.warning - : node.href != null - ? BusyMarkGlyphs.externalLink - : BusyMarkGlyphs.document, - hasChildren: hasChildren, - expanded: expanded, - muted: node.hidden, - compactHierarchyIndent: true, - onToggle: hasChildren ? toggle : null, - onTap: topicPath != null - ? () async { - final modifiers = selectEntry(); - if (modifiers.control || modifiers.shift) { - return; - } - await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(topicPath); - if (mounted) { - _clearGitDetailSelection(ref); + child: Listener( + onPointerDown: (event) { + if (event.buttons == kPrimaryMouseButton) { + // Let the trailing action button keep its own focus and + // gesture until it opens the menu. Row selection on + // pointer-down is only needed for a possible row drag. + final box = _rowMenuKeys[key]?.currentContext + ?.findRenderObject(); + if (box is RenderBox) { + final x = box.globalToLocal(event.position).dx; + final onActions = + Directionality.of(context) == TextDirection.rtl + ? x < BusyMarkSizes.iconButton + : x > box.size.width - BusyMarkSizes.iconButton; + if (onActions) return; + } + final modifiers = _treeSelectionModifiers(); + // A press on the current multi-selection may start a + // group drag; collapse it only after an ordinary tap. + if (selected && + _selectedNodePathKeys.length > 1 && + !modifiers.control && + !modifiers.shift) { + _treeFocusNode.requestFocus(); + _pointerModifiers[key] = modifiers; + } else { + _pointerModifiers[key] = selectEntry(); + } + } + }, + child: _SidebarTreeRow( + key: _rowMenuKeys[key], + title: label, + enabled: true, + selected: selected, + depth: entry.depth, + tooltip: presentation.tooltip, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (presentation.home) + const Icon( + BusyMarkGlyphs.home, + size: BusyMarkSizes.iconSm, + ), + if (presentation.empty) const Text('[]'), + if (presentation.included) + const Icon( + BusyMarkGlyphs.link, + size: BusyMarkSizes.iconSm, + ), + if (presentation.external) + const Icon( + BusyMarkGlyphs.externalLink, + size: BusyMarkSizes.iconSm, + ), + if (presentation.unresolved) + const Icon( + BusyMarkGlyphs.error, + size: BusyMarkSizes.iconSm, + ), + ], + ), + hasChildren: hasChildren, + expanded: expanded, + muted: node.hidden, + compactHierarchyIndent: true, + onToggle: hasChildren ? toggle : null, + onDoubleTap: () async { + final modifiers = + _pointerModifiers.remove(key) ?? + _treeSelectionModifiers(); + if (modifiers.control || modifiers.shift) return; + if (topicPath != null) { + await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(topicPath); + } else if (node.href == null) { + await _goToTocElement(context, entry); + } + }, + onTap: topicPath != null + ? () async { + final modifiers = + _pointerModifiers.remove(key) ?? selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } + if (_selectedNodePathKeys.length > 1) selectEntry(); + await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(topicPath); + if (mounted) { + _clearGitDetailSelection(ref); + } } - } - : hasChildren - ? () { - final modifiers = selectEntry(); - if (modifiers.control || modifiers.shift) { - return; + : hasChildren + ? () { + final modifiers = + _pointerModifiers.remove(key) ?? selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } + if (_selectedNodePathKeys.length > 1) selectEntry(); + toggle(); } - toggle(); - } - : () { - selectEntry(); - }, - onMenuRequested: showMenu, + : () { + if (_selectedNodePathKey != entry.pathKey) { + selectEntry(); + } + }, + onMenuRequested: showMenu, + ), + ), + ); + if (!entry.canEditStructureIn(instance.sourceTreePath)) { + return row; + } + final dragEntries = _topLevelTocEntries( + selected + ? entries + .where( + (item) => + _selectedNodePathKeys.contains(item.pathKey) || + (_selectedNodePathKeys.isEmpty && + item.pathKey == _selectedNodePathKey), + ) + .toList() + : [entry], + ); + final payload = _tocDragPayload( + instance.sourceTreePath, + dragEntries, + ); + return DragTarget<_TocDragPayload>( + onWillAcceptWithDetails: (details) { + final accepted = _canDrop( + details.data, + instance.sourceTreePath, + entry, + ); + if (accepted) { + _updateTocDrop(context, details.offset, entry, instance); + } + return accepted; + }, + onMove: (details) { + if (_canDrop(details.data, instance.sourceTreePath, entry)) { + _updateTocDrop(context, details.offset, entry, instance); + } + }, + onLeave: (_) => _clearTocDrop(), + onAcceptWithDetails: (details) { + final zone = _dropZone; + _clearTocDrop(); + unawaited( + _dropTocEntries(context, details.data, entry, zone), + ); + }, + builder: (context, candidates, rejected) => DecoratedBox( + decoration: BoxDecoration( + color: _dropPath == key && _dropZone == 0 + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12) + : null, + border: _dropPath != key + ? null + : Border( + top: _dropZone < 0 + ? BorderSide( + color: Theme.of( + context, + ).colorScheme.primary, + width: 2, + ) + : BorderSide.none, + bottom: _dropZone > 0 + ? BorderSide( + color: Theme.of( + context, + ).colorScheme.primary, + width: 2, + ) + : BorderSide.none, + ), + ), + child: payload == null + ? row + : Draggable<_TocDragPayload>( + data: payload, + dragAnchorStrategy: pointerDragAnchorStrategy, + allowedButtonsFilter: (buttons) => + buttons == kPrimaryMouseButton, + feedback: Material( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + dragEntries.length == 1 + ? label + : '${dragEntries.length}', + ), + ), + ), + childWhenDragging: Opacity(opacity: 0.4, child: row), + onDragEnd: (_) => _clearTocDrop(), + child: row, + ), ), ); }, @@ -5770,12 +6056,13 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } - final label = _tocNodeLabel(context, entry.node); - final confirmed = await _confirmRemoveTocEntry(context, ref, name: label); + final confirmed = await _confirmRemoveTocEntry(context, ref); if (!confirmed || !context.mounted || !mounted) { return; } - final canRemove = await saveOrConfirmSafeToChangeActiveFile(context, ref); + final canRemove = await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]); if (!canRemove || !mounted || !context.mounted || @@ -5834,14 +6121,17 @@ class _TocTabState extends ConsumerState<_TocTab> { ), ); } - final labels = entries - .map((entry) => _tocNodeLabel(context, entry.node)) - .join(', '); - final confirmed = await _confirmRemoveTocEntry(context, ref, name: labels); + final confirmed = await _confirmRemoveTocEntry( + context, + ref, + count: entries.length, + ); if (!confirmed || !context.mounted || !mounted) { return; } - final canRemove = await saveOrConfirmSafeToChangeActiveFile(context, ref); + final canRemove = await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]); if (!canRemove || !context.mounted || !mounted) { return; } @@ -5979,7 +6269,34 @@ class _TocTabState extends ConsumerState<_TocTab> { multipleSelection: selectedEntries.length > 1, canRefineSelection: canRefineSelection, canEditSelection: canEditSelection, + canSetHome: + _tocInstanceForTreePath( + widget.workspace.writersideModule!, + instanceTreePath, + )?.isLibrary == + false, + topicReference: entry.node.topicReference, + topicTitle: topic?.title, + tocId: entry.node.id, + treeFileName: p.basename(entry.node.sourceTreePath ?? instanceTreePath), + canGroup: + canEditSelection && + selectedEntries.length > 1 && + selectedEntries.every( + (selected) => _sameTocPath( + selected.editPath!.sublist(0, selected.editPath!.length - 1), + selectedEntries.first.editPath!.sublist( + 0, + selectedEntries.first.editPath!.length - 1, + ), + ), + ), + canSort: + canEditStructure && + entry.node.children.length > 1 && + entry.node.childEntries.every((item) => item is TocNode), ); + if (mounted) _treeFocusNode.requestFocus(); if (!mounted || !context.mounted || action == null) { return; } @@ -5989,6 +6306,220 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } switch (action) { + case _TocTreeAction.saveTemplate: + if (topic == null) return; + try { + final buffer = ref + .read(workspaceControllerProvider) + .bufferForPath(topic.filePath); + final currentTopic = buffer == null + ? topic + : topic.format == WritersideTopicFormat.xml + ? const WritersideTopicParser().parseXml( + filePath: topic.filePath, + source: buffer.text, + topicsRoot: topic.topicRoot, + ) + : const WritersideTopicParser().parseMarkdown( + filePath: topic.filePath, + source: buffer.text, + topicsRoot: topic.topicRoot, + ); + final module = widget.workspace.writersideModule!; + final title = const WritersideDocumentResolver() + .resolve( + currentTopic.document, + WritersideResolveContext( + module: module, + topic: currentTopic, + instance: _tocInstanceForTreePath(module, instanceTreePath), + modulesByOrigin: + widget.workspace.writersideProject?.modulesByOrigin ?? + const {}, + ), + ) + .title; + await ref + .read(writersideTemplateServiceProvider) + .saveTopic(topic: currentTopic, contextualTitle: title); + if (!mounted || !context.mounted) return; + BusyMarkToastOverlay.show( + context, + message: context.l10n.tocTemplateCreated, + actionLabel: context.l10n.tocEditTemplates, + onAction: () => showBusyMarkModalDialog( + context, + builder: (_) => const WritersideTemplatesEditor(), + ), + ); + } on Object { + if (mounted && context.mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.tocTemplateSaveError, + ); + } + } + case _TocTreeAction.newTemplate: + case _TocTreeAction.newChildTemplate: + await _createTocItem( + context, + instanceTreePath: instanceTreePath, + placement: action == _TocTreeAction.newChildTemplate + ? WritersideTopicCreatePlacement.child + : WritersideTopicCreatePlacement.sibling, + referenceEntry: entry, + choice: _TocCreationChoice.template, + ); + case _TocTreeAction.editTitle: + if (rawNode == null || topic == null) return; + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + topic.filePath, + instanceTreePath, + ]) || + !mounted || + !context.mounted) { + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + final session = await controller.prepareWritersideTitleEdit( + treePath: instanceTreePath, + tocPath: entry.editPath!, + identity: WritersideTocNodeIdentity.fromNode(rawNode), + ); + if (session == null || !mounted || !context.mounted) return; + final edit = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTitleDialog(session: session), + ); + if (edit != null && mounted) { + await controller.editWritersideTitles(session, edit); + } + case _TocTreeAction.duplicate: + if (topic == null || rawNode == null) return; + final name = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocDuplicateDialog, + label: context.l10n.tocDuplicateFilename, + initialValue: p.basenameWithoutExtension(topic.fileName), + validate: (name) { + if (name.trim().isEmpty) return context.l10n.fileNameRequired; + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(name)) { + return context.l10n.useIdentifierCharacters; + } + if (widget.workspace.writersideModule!.topics.any( + (candidate) => candidate.id == name, + )) { + return context.l10n.topicIdAlreadyExists; + } + return null; + }, + ), + ); + if (name == null || !mounted || !context.mounted) return; + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + topic.filePath, + ]) || + !mounted) { + return; + } + await ref + .read(workspaceControllerProvider.notifier) + .duplicateWritersideTopic( + treePath: instanceTreePath, + tocPath: entry.editPath!, + identity: WritersideTocNodeIdentity.fromNode(rawNode), + topicPath: topic.filePath, + expectedSource: topic.document.source, + newName: name, + ); + case _TocTreeAction.emptyGroup: + case _TocTreeAction.emptyChildGroup: + case _TocTreeAction.linkTopics: + case _TocTreeAction.linkChildTopics: + await _createTocItem( + context, + instanceTreePath: instanceTreePath, + placement: + action == _TocTreeAction.emptyChildGroup || + action == _TocTreeAction.linkChildTopics + ? WritersideTopicCreatePlacement.child + : WritersideTopicCreatePlacement.sibling, + referenceEntry: entry, + choice: + action == _TocTreeAction.emptyGroup || + action == _TocTreeAction.emptyChildGroup + ? _TocCreationChoice.emptyGroup + : _TocCreationChoice.link, + ); + case _TocTreeAction.group: + final sources = []; + for (final selected in selectedEntries) { + if (!_tocTreeEntryStillMatches( + widget.workspace, + instanceTreePath, + selected, + )) { + return; + } + final raw = _rawTocNodeForEntry( + widget.workspace, + instanceTreePath, + selected, + ); + if (raw == null || !selected.canEditStructureIn(instanceTreePath)) { + return; + } + sources.add( + WritersideTocMoveEntry( + sourcePath: selected.editPath!, + sourceIdentity: WritersideTocNodeIdentity.fromNode(raw), + ), + ); + } + final title = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocNewGroup, + label: context.l10n.tocGroupName, + enterOnly: true, + ), + ); + if (title == null || !mounted || !context.mounted) return; + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]) || + !mounted) { + return; + } + _selectMutation( + await ref + .read(workspaceControllerProvider.notifier) + .groupWritersideTocElements( + treePath: instanceTreePath, + entries: sources, + title: title, + ), + ); + case _TocTreeAction.sortChildren: + if (rawNode == null) return; + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]) || + !mounted) { + return; + } + _selectMutation( + await ref + .read(workspaceControllerProvider.notifier) + .sortWritersideTocChildren( + treePath: instanceTreePath, + nodePath: entry.editPath!, + identity: WritersideTocNodeIdentity.fromNode(rawNode), + ), + ); case _TocTreeAction.copy: await _copyTocEntries(selectedEntries); case _TocTreeAction.refineWithAi: @@ -6004,6 +6535,7 @@ class _TocTabState extends ConsumerState<_TocTab> { ); } case _TocTreeAction.newSiblingTopic: + case _TocTreeAction.newSiblingXmlTopic: if (!canEditStructure) { return; } @@ -6012,8 +6544,12 @@ class _TocTabState extends ConsumerState<_TocTab> { instanceTreePath: instanceTreePath, placement: WritersideTopicCreatePlacement.sibling, referenceEntry: entry, + format: action == _TocTreeAction.newSiblingXmlTopic + ? WritersideTopicFormat.xml + : WritersideTopicFormat.markdown, ); case _TocTreeAction.newChildTopic: + case _TocTreeAction.newChildXmlTopic: if (!canEditStructure) { return; } @@ -6022,7 +6558,59 @@ class _TocTabState extends ConsumerState<_TocTab> { instanceTreePath: instanceTreePath, placement: WritersideTopicCreatePlacement.child, referenceEntry: entry, + format: action == _TocTreeAction.newChildXmlTopic + ? WritersideTopicFormat.xml + : WritersideTopicFormat.markdown, + ); + case _TocTreeAction.preview: + if (topicPath == null) return; + final moduleId = widget.workspace.writersideProject?.activeModuleId; + final instance = _tocInstanceForTreePath( + widget.workspace.writersideModule!, + instanceTreePath, ); + final controller = ref.read(workspaceControllerProvider.notifier); + if (!await controller.openActiveFile(topicPath) || !mounted) return; + if (moduleId != null && instance != null) { + await controller.selectWritersideContext( + moduleId: moduleId, + instanceId: instance.id, + ); + } + if (!mounted) return; + controller.updateActiveEditorMode(DocumentViewModePreference.preview); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + case _TocTreeAction.goToElement: + await _goToTocElement(context, entry); + case _TocTreeAction.copyTopicReference: + if (entry.node.topicReference case final value?) { + await Clipboard.setData(ClipboardData(text: value)); + } + case _TocTreeAction.copyTopicTitle: + if (topic?.title case final value?) { + await Clipboard.setData(ClipboardData(text: value)); + } + case _TocTreeAction.copyTocId: + if (entry.node.id case final value?) { + await Clipboard.setData(ClipboardData(text: value)); + } + case _TocTreeAction.setHome: + if (rawNode == null || topic == null) return; + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]) || + !mounted) { + return; + } + await ref + .read(workspaceControllerProvider.notifier) + .setWritersideHomePage( + treePath: instanceTreePath, + nodePath: entry.editPath!, + expectedIdentity: WritersideTocNodeIdentity.fromNode(rawNode), + ); case _TocTreeAction.rename: final path = topicPath; if (path == null) { @@ -6235,14 +6823,104 @@ class _TocTabState extends ConsumerState<_TocTab> { } } - Future _openInstanceTree(BuildContext context, String treePath) async { + Future _openInstanceTree(BuildContext context, String treePath) async { final canContinue = await saveOrConfirmSafeToChangeActiveFile(context, ref); if (!canContinue || !mounted || !context.mounted) { - return; + return false; } - await ref + final opened = await ref .read(workspaceControllerProvider.notifier) .openActiveFile(treePath); + if (!mounted || !opened) return false; + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + return true; + } + + Future _goToTocElement( + BuildContext context, + _TocTreeEntry entry, + ) async { + final owner = entry.node.sourceTreePath; + final path = entry.node.sourceXmlPath ?? entry.node.sourceTocPath; + if (owner == null || path == null) return; + final raw = _rawTocNodeForEntry(widget.workspace, owner, entry); + if (raw == null) return; + final identity = WritersideTocNodeIdentity.fromNode(raw); + if (!await _openInstanceTree(context, owner)) return; + if (!mounted || + ref.read(workspaceControllerProvider).workspace?.activeFilePath != + owner) { + return; + } + ref + .read(_sourceNavigationTargetProvider.notifier) + .set( + _SourceNavigationTarget( + filePath: owner, + line: raw.span.startLine, + tocPath: path, + tocIdentity: identity, + xmlChildren: entry.node.sourceXmlPath != null, + ), + ); + } + + Future _synchronizeToc( + BuildContext context, + WritersideInstance instance, + _TocTreeEntry? selected, + ) async { + if (_syncFromTree && selected != null) { + final topic = _presenter?.present(selected.node).topic; + if (topic == null || + widget.workspace.activeFilePath?.endsWith('.tree') == true) { + await _goToTocElement(context, selected); + } else { + await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(topic.filePath); + } + return; + } + final active = ref.read(workspaceControllerProvider).activeBuffer; + final file = active?.filePath; + if (active == null || file == null) return; + final key = file.toLowerCase().endsWith('.tree') + ? writersideTocEditorSyncKey( + file, + active.text, + active.editorState.selection.extentOffset, + ) + : p.basename(file); + if (key == null) return; + final match = writersideTocBreadthFirstPath( + instance.navigationTocRoots, + (node) => node.topicReference == key, + ); + if (match == null) return; + final path = match.join('/'); + setState(() { + for (var depth = 1; depth < match.length; depth++) { + _expandedNodeKeys.add(match.take(depth).join('/')); + } + _selectedNodePathKey = path; + _selectedNodePathKeys = {path}; + _selectionAnchorPathKey = path; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_tocScroll.isAttached) return; + final visible = _visibleTocTreeEntries( + instance.navigationTocRoots, + _expandedNodeKeys, + ); + final index = visible.indexWhere((entry) => entry.pathKey == path); + if (index >= 0) _tocScroll.jumpTo(index: index + 1); + }); } Future _showCreateTopicDialog( @@ -6250,8 +6928,11 @@ class _TocTabState extends ConsumerState<_TocTab> { required String instanceTreePath, required WritersideTopicCreatePlacement placement, required _TocTreeEntry? referenceEntry, + WritersideTopicFormat format = WritersideTopicFormat.markdown, }) async { - final canContinue = await saveOrConfirmSafeToChangeActiveFile(context, ref); + final canContinue = await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]); if (!canContinue || !mounted || !context.mounted) { return; } @@ -6266,14 +6947,14 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } final headerBar = ref.read(linuxHeaderBarServiceProvider); - await showBusyMarkModalEditorDialog( + await showBusyMarkModalDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, - maxWidth: BusyMarkSizes.dialogWide, builder: (dialogContext) => _CreateWritersideTopicDialog( workspace: widget.workspace, instanceTreePath: instanceTreePath, placement: placement, + initialFormat: format, referencePath: referenceEntry?.editPath, referenceTopic: referenceEntry?.node.topicFileName, referenceIdentity: rawReference == null @@ -6285,6 +6966,382 @@ class _TocTabState extends ConsumerState<_TocTab> { ), ); } + + void _selectMutation(WritersideTocMutationResult? result) { + if (!mounted || result?.entryPath == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final module = ref + .read(workspaceControllerProvider) + .workspace + ?.writersideModule; + final instance = module == null + ? null + : _tocInstanceForTreePath(module, result!.treePath); + if (instance == null) return; + final destinations = >[]; + final sourcePaths = result!.entryPaths.isEmpty + ? [result.entryPath!] + : result.entryPaths; + void visit(List nodes, List parent) { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + final path = [...parent, i]; + if (node.sourceTreePath == result.treePath && + node.sourceTocPath != null && + sourcePaths.any( + (source) => _sameTocPath(node.sourceTocPath!, source), + )) { + destinations.add(path); + } + visit(node.children, path); + } + } + + visit(instance.navigationTocRoots, []); + if (destinations.isEmpty) return; + final path = destinations.first; + setState(() { + _selectedNodePathKey = path.join('/'); + _selectedNodePathKeys = { + for (final item in destinations) item.join('/'), + }; + _selectionAnchorPathKey = _selectedNodePathKey; + for (final item in destinations) { + for (var i = 1; i <= item.length; i++) { + _expandedNodeKeys.add(item.take(i).join('/')); + } + } + }); + _treeFocusNode.requestFocus(); + }); + } + + _TocDragPayload? _tocDragPayload( + String treePath, + List<_TocTreeEntry> entries, + ) { + if (entries.isEmpty) return null; + final sources = []; + for (final entry in entries) { + if (!entry.canEditStructureIn(treePath)) return null; + final raw = _rawTocNodeForEntry(widget.workspace, treePath, entry); + if (raw == null) return null; + sources.add( + WritersideTocMoveEntry( + sourcePath: entry.editPath!, + sourceIdentity: WritersideTocNodeIdentity.fromNode(raw), + ), + ); + } + return _TocDragPayload(treePath, sources); + } + + bool _canDrop(_TocDragPayload data, String treePath, _TocTreeEntry entry) => + data.treePath == treePath && + entry.canEditStructureIn(treePath) && + !data.sources.any( + (source) => + _sameTocPath(source.sourcePath, entry.editPath!) || + _tocPathContains(source.sourcePath, entry.editPath!), + ); + + void _clearTocDrop() { + _dragScrollTimer?.cancel(); + _dragExpandTimer?.cancel(); + if (mounted && _dropPath != null) setState(() => _dropPath = null); + } + + void _updateTocDrop( + BuildContext rowContext, + Offset position, + _TocTreeEntry entry, + WritersideInstance instance, + ) { + final box = _rowMenuKeys[entry.pathKey]?.currentContext?.findRenderObject(); + if (box is! RenderBox) return; + final fraction = box.globalToLocal(position).dy / box.size.height; + final zone = fraction < 0.25 + ? -1 + : fraction > 0.75 + ? 1 + : 0; + if (_dropPath != entry.pathKey || _dropZone != zone) { + _dragExpandTimer?.cancel(); + setState(() { + _dropPath = entry.pathKey; + _dropZone = zone; + }); + if (zone == 0 && + entry.node.children.isNotEmpty && + !_expandedNodeKeys.contains(entry.pathKey)) { + _dragExpandTimer = Timer(const Duration(milliseconds: 650), () { + if (mounted) setState(() => _expandedNodeKeys.add(entry.pathKey)); + }); + } + } + _dragScrollTimer?.cancel(); + final viewport = _tocViewportKey.currentContext?.findRenderObject(); + if (viewport is! RenderBox) return; + final y = viewport.globalToLocal(position).dy; + final direction = y < 42 + ? -1 + : y > viewport.size.height - 42 + ? 1 + : 0; + if (direction == 0) return; + _dragScrollTimer = Timer.periodic(const Duration(milliseconds: 120), (_) { + if (!mounted || !_tocScroll.isAttached) return; + final positions = + _tocPositions.itemPositions.value + .where( + (item) => item.itemTrailingEdge > 0 && item.itemLeadingEdge < 1, + ) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + if (positions.isEmpty) return; + final count = + _visibleTocTreeEntries( + instance.navigationTocRoots, + _expandedNodeKeys, + ).length + + 1; + final next = (positions.first.index + direction).clamp(0, count - 1); + _tocScroll.jumpTo(index: next); + }); + } + + Future _dropTocEntries( + BuildContext context, + _TocDragPayload data, + _TocTreeEntry target, + int zone, + ) async { + if (!_canDrop(data, _selectedInstanceTreePath ?? data.treePath, target)) { + return; + } + final raw = _rawTocNodeForEntry(widget.workspace, data.treePath, target); + if (raw == null) return; + final identity = WritersideTocNodeIdentity.fromNode(raw); + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + data.treePath, + ]) || + !mounted) { + return; + } + _selectMutation( + await ref + .read(workspaceControllerProvider.notifier) + .dragWritersideTocEntries( + treePath: data.treePath, + request: WritersideTocBatchMoveRequest( + sources: data.sources, + placement: zone == 0 + ? WritersideTopicCreatePlacement.child + : WritersideTopicCreatePlacement.sibling, + beforeReference: zone < 0, + referencePath: target.editPath, + referenceIdentity: identity, + ), + ), + ); + } + + Future _createTocItem( + BuildContext context, { + required String instanceTreePath, + required WritersideTopicCreatePlacement placement, + required _TocTreeEntry? referenceEntry, + required _TocCreationChoice choice, + }) async { + if (choice == _TocCreationChoice.markdown || + choice == _TocCreationChoice.xml) { + await _showCreateTopicDialog( + context, + instanceTreePath: instanceTreePath, + placement: placement, + referenceEntry: referenceEntry, + format: choice == _TocCreationChoice.xml + ? WritersideTopicFormat.xml + : WritersideTopicFormat.markdown, + ); + return; + } + final raw = referenceEntry == null + ? null + : _rawTocNodeForEntry( + widget.workspace, + instanceTreePath, + referenceEntry, + ); + if (referenceEntry != null && + (raw == null || !referenceEntry.canEditStructureIn(instanceTreePath))) { + return; + } + final identity = raw == null + ? null + : WritersideTocNodeIdentity.fromNode(raw); + if (choice == _TocCreationChoice.template) { + final module = widget.workspace.writersideModule!; + final instance = _tocInstanceForTreePath(module, instanceTreePath)!; + final previewScroll = ItemScrollController(); + await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTemplateDialog( + existingIds: module.topics.map((topic) => topic.id).toSet(), + previewBuilder: (context, template, title, filename) { + final source = WritersideTemplateService.generate( + template, + title: title, + id: filename, + ); + final path = p.join( + module.rootPath, + module.config.topicsDir, + '$filename.${template.extension}', + ); + final topic = template.format == WritersideTopicFormat.xml + ? const WritersideTopicParser().parseXml( + filePath: path, + source: source, + topicsRoot: p.dirname(path), + ) + : const WritersideTopicParser().parseMarkdown( + filePath: path, + source: source, + topicsRoot: p.dirname(path), + ); + final resolved = const WritersideDocumentResolver().resolve( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: + widget.workspace.writersideProject?.modulesByOrigin ?? + const {}, + ), + ); + final preview = const BusyMarkPreviewBuilder().build( + const WritersideDocumentRenderer().toBusyDocument( + resolved.document, + title: resolved.title ?? title, + includeTitleHeading: true, + ), + ); + return _PreviewPane( + preview: preview, + workspace: null, + controller: previewScroll, + documentLayout: BusyMarkDocumentLayoutSpec.splitPreview, + ); + }, + onCreate: (template, title, filename) async { + if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]) || + !mounted || + !context.mounted) { + return context.l10n.createWritersideTopicFailed; + } + final source = WritersideTemplateService.generate( + template, + title: title, + id: filename, + ); + final created = await ref + .read(workspaceControllerProvider.notifier) + .createWritersideTopic( + WritersideTopicCreateRequest( + title: title, + fileName: '$filename.${template.extension}', + format: template.format, + placement: placement, + referenceTocPath: referenceEntry?.editPath, + referenceTopic: raw?.topicReference, + referenceTocIdentity: identity, + ), + initialSource: source, + instanceTreePath: instanceTreePath, + ); + if (created) return null; + if (!context.mounted) return ''; + final message = ref.read(workspaceControllerProvider).message; + return message == null + ? context.l10n.createWritersideTopicFailed + : localizeWorkspaceMessage(context, message); + }, + ), + ); + return; + } + String? title; + WritersideTopic? topic; + if (choice == _TocCreationChoice.emptyGroup) { + title = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocNewEmptyGroup, + label: context.l10n.tocTitleField, + ), + ); + if (title == null) return; + } else { + final module = widget.workspace.writersideModule!; + final instance = _tocInstanceForTreePath(module, instanceTreePath)!; + final presenter = WritersideTocPresenter( + module: module, + instance: instance, + modulesByOrigin: + widget.workspace.writersideProject?.modulesByOrigin ?? const {}, + ); + final members = instance.navigationTocRoots + .expand((node) => node.flatten()) + .map((node) => presenter.present(node).topic?.filePath) + .nonNulls + .toSet(); + topic = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideExistingTopicPicker( + topics: [ + for (final topic in module.topics) + if (!members.contains(topic.filePath)) topic, + ], + ), + ); + if (topic == null) return; + } + if (!mounted || + !context.mounted || + !await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]) || + !mounted) { + return; + } + _selectMutation( + await ref + .read(workspaceControllerProvider.notifier) + .insertWritersideTocElement( + treePath: instanceTreePath, + request: WritersideTocInsertRequest( + placement: placement, + referencePath: referenceEntry?.editPath, + referenceIdentity: identity, + tocTitle: title, + topicReference: topic?.fileName, + ), + expectedTopicPath: topic?.filePath, + expectedTopicSource: topic?.document.source, + ), + ); + } +} + +class _TocDragPayload { + const _TocDragPayload(this.treePath, this.sources); + final String treePath; + final List sources; } class _TocTreeClipboardEntry { @@ -6319,9 +7376,28 @@ class _RemoveSelectedTocEntryIntent extends Intent { } enum _TocTreeAction { + newTemplate, + newChildTemplate, + saveTemplate, copy, newSiblingTopic, newChildTopic, + newSiblingXmlTopic, + newChildXmlTopic, + preview, + goToElement, + copyTopicReference, + copyTopicTitle, + copyTocId, + setHome, + duplicate, + editTitle, + emptyGroup, + emptyChildGroup, + linkTopics, + linkChildTopics, + group, + sortChildren, rename, cut, refineWithAi, @@ -6347,12 +7423,171 @@ Future<_TocTreeAction?> _showTocTreeMenu( required bool multipleSelection, required bool canRefineSelection, required bool canEditSelection, + required bool canSetHome, + required String? topicReference, + required String? topicTitle, + required String? tocId, + required String treeFileName, + required bool canGroup, + required bool canSort, }) { - return _showSidebarTreeMenu<_TocTreeAction>( - context, - position, + // Keep the same menu layout and keyboard behavior when selection changes + // between a nested single-item menu and a flat multiple-selection menu. + return showBusyMarkMenu<_TocTreeAction>( + context: context, + anchorPoint: position, items: [ + if (!multipleSelection) ...[ + BusyMarkSubmenuItem( + label: context.l10n.newTopic, + enabled: canEditStructure, + items: [ + BusyMarkPopupMenuItem( + value: _TocTreeAction.newSiblingTopic, + label: context.l10n.tocEmptyMdTopic, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newSiblingXmlTopic, + label: context.l10n.tocEmptyXmlTopic, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.emptyGroup, + label: context.l10n.tocEmptyGroupAction, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newTemplate, + label: context.l10n.tocTopicFromTemplate, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.linkTopics, + label: context.l10n.tocLinkTopicFiles, + ), + ], + ), + BusyMarkSubmenuItem( + label: context.l10n.newChildTopic, + enabled: canEditStructure, + items: [ + BusyMarkPopupMenuItem( + value: _TocTreeAction.newChildTopic, + label: context.l10n.tocEmptyMdTopic, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newChildXmlTopic, + label: context.l10n.tocEmptyXmlTopic, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.emptyChildGroup, + label: context.l10n.tocEmptyGroupAction, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newChildTemplate, + label: context.l10n.tocTopicFromTemplate, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.linkChildTopics, + label: context.l10n.tocLinkTopicFiles, + ), + ], + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.duplicate, + label: context.l10n.tocDuplicate, + enabled: hasTopicFile && canEditStructure, + ), + BusyMarkSubmenuItem( + label: context.l10n.tocCopySpecial, + items: [ + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyTopicReference, + label: topicReference == null + ? context.l10n.tocCopyFileNameUnavailable + : context.l10n.tocCopyFileName(topicReference), + enabled: topicReference?.trim().isNotEmpty ?? false, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyPath, + label: context.l10n.tocCopyFilePath, + enabled: hasTopicFile, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyTopicTitle, + label: topicTitle == null + ? context.l10n.tocCopyTopicTitleUnavailable + : context.l10n.tocCopyTopicTitle(topicTitle), + enabled: topicTitle?.trim().isNotEmpty ?? false, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyTocId, + label: tocId == null + ? context.l10n.tocCopyElementIdUnavailable + : context.l10n.tocCopyElementId(tocId), + enabled: tocId?.trim().isNotEmpty ?? false, + ), + ], + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.preview, + label: context.l10n.tocPreviewTopic, + enabled: hasTopicFile, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.editTitle, + label: context.l10n.tocEditTitleAction, + enabled: hasTopicFile && canEditStructure, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.removeFromToc, + label: context.l10n.tocRemoveElementAction, + enabled: canEditStructure, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.setHome, + label: context.l10n.tocSetHomePage, + enabled: canSetHome && hasTopicFile && canEditStructure, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.saveTemplate, + label: context.l10n.tocSaveAsTemplate, + enabled: hasTopicFile, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.goToElement, + label: context.l10n.tocGoToElement(treeFileName), + ), + if (canSort) + BusyMarkPopupMenuItem( + value: _TocTreeAction.sortChildren, + label: context.l10n.tocSortChildren, + ), + const PopupMenuDivider(), + ], if (multipleSelection) ...[ + BusyMarkPopupMenuItem( + value: _TocTreeAction.group, + label: context.l10n.tocGroupAction, + enabled: canGroup, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.removeSelection, + label: context.l10n.removeTocElements, + icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), + shortcut: + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection] + ?.shortcut + ?.label, + enabled: canEditSelection, + ), + const PopupMenuDivider(), BusyMarkPopupMenuItem( value: _TocTreeAction.copy, label: context.l10n.copy, @@ -6371,18 +7606,6 @@ Future<_TocTreeAction?> _showTocTreeMenu( enabled: canRefineSelection, ), const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _TocTreeAction.removeSelection, - label: context.l10n.removeTocElements, - icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), - shortcut: - (BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds - .treeDeleteSelection] - ?.shortcut - ?.label, - enabled: canEditSelection, - ), ] else ...[ BusyMarkPopupMenuItem( value: _TocTreeAction.copy, @@ -6407,19 +7630,6 @@ Future<_TocTreeAction?> _showTocTreeMenu( enabled: hasTopicFile, ), const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _TocTreeAction.newSiblingTopic, - label: context.l10n.newSiblingTopic, - icon: BusyMarkGlyphs.newDocument, - enabled: canEditStructure, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.newChildTopic, - label: context.l10n.newChildTopic, - icon: BusyMarkGlyphs.tree, - enabled: canEditStructure, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( value: _TocTreeAction.rename, label: context.l10n.renameTopicFile, @@ -6444,18 +7654,6 @@ Future<_TocTreeAction?> _showTocTreeMenu( icon: BusyMarkGlyphs.tree, enabled: showPaste, ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.removeFromToc, - label: context.l10n.removeTocElement, - icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), - shortcut: - (BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds - .treeDeleteSelection] - ?.shortcut - ?.label, - enabled: canEditStructure, - ), const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( value: _TocTreeAction.openInFiles, @@ -6540,14 +7738,35 @@ TocNode? _rawTocNodeForEntry( _TocTreeEntry entry, ) { final editPath = entry.editPath; - if (editPath == null) { - return null; + for (final module in { + if (workspace.writersideModule != null) workspace.writersideModule!, + ...?workspace.writersideProject?.modulesByOrigin.values, + }) { + final instance = _tocInstanceForTreePath(module, treePath); + if (instance == null) continue; + if (editPath != null) return _tocNodeAtPath(instance.tocRoots, editPath); + final xmlPath = entry.node.sourceXmlPath; + if (xmlPath == null) return null; + Iterable authored(Iterable entries) sync* { + for (final item in entries) { + if (item is TocNode) { + yield item; + yield* authored(item.childEntries); + } else if (item is WritersideTocSnippet) { + yield* authored(item.entries); + } + } + } + + return authored(instance.treeEntries) + .where( + (node) => + node.sourceXmlPath != null && + _sameTocPath(node.sourceXmlPath!, xmlPath), + ) + .firstOrNull; } - final module = workspace.writersideModule; - final instance = module == null - ? null - : _tocInstanceForTreePath(module, treePath); - return instance == null ? null : _tocNodeAtPath(instance.tocRoots, editPath); + return null; } bool _sameTocNodeAttributes(TocNode first, TocNode second) { @@ -6735,14 +7954,16 @@ String _tocNodeFingerprint(TocNode node) { Future _confirmRemoveTocEntry( BuildContext context, WidgetRef ref, { - required String name, + int count = 1, }) async { final headerBar = ref.read(linuxHeaderBarServiceProvider); final confirmed = await showBusyMarkModalDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, builder: (context) => BusyMarkDialogShell( - title: context.l10n.confirmRemoveFromTocTitle, + title: count == 1 + ? context.l10n.removeTocElement + : context.l10n.tocRemoveElementsDialog(count), maxWidth: BusyMarkSizes.dialog, actions: [ BusyMarkDialogButton( @@ -6750,20 +7971,19 @@ Future _confirmRemoveTocEntry( onPressed: () => Navigator.pop(context, false), ), BusyMarkDialogButton( - label: context.l10n.removeFromToc, + label: context.l10n.removeAction, icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), destructive: true, onPressed: () => Navigator.pop(context, true), ), ], - children: [Text(context.l10n.confirmRemoveFromTocMessage(name))], + children: [Text(context.l10n.tocRemoveElementsRetainFiles)], ), ); return confirmed ?? false; } enum _TocHeaderAction { - newTopic, newInstance, newLibrary, editInstance, @@ -6771,6 +7991,8 @@ enum _TocHeaderAction { export, } +enum _TocCreationChoice { markdown, xml, emptyGroup, template, link } + class _TocHeader extends StatelessWidget { const _TocHeader({ required this.modules, @@ -6781,6 +8003,7 @@ class _TocHeader extends StatelessWidget { required this.onSelectModule, required this.onSelectInstance, required this.onCreateTopic, + required this.onSynchronize, required this.onCreateInstance, required this.onCreateLibrary, required this.onEditInstance, @@ -6796,7 +8019,8 @@ class _TocHeader extends StatelessWidget { final Map instanceColors; final ValueChanged onSelectModule; final ValueChanged onSelectInstance; - final VoidCallback onCreateTopic; + final ValueChanged<_TocCreationChoice> onCreateTopic; + final VoidCallback onSynchronize; final VoidCallback onCreateInstance; final VoidCallback onCreateLibrary; final VoidCallback onEditInstance; @@ -6865,8 +8089,43 @@ class _TocHeader extends StatelessWidget { selectedInstance: selectedInstance, instanceColors: instanceColors, onSelected: onSelectInstance, + onOpenTocFile: onOpenTocFile, ), ), + BusyMarkHeaderPopupMenuButton<_TocCreationChoice>( + key: const ValueKey('workspace-sidebar-new-topic-menu'), + tooltip: context.l10n.newTopic, + icon: BusyMarkGlyphs.add, + itemBuilder: (context) => [ + BusyMarkPopupMenuItem( + value: _TocCreationChoice.markdown, + label: context.l10n.tocEmptyMdTopic, + ), + BusyMarkPopupMenuItem( + value: _TocCreationChoice.xml, + label: context.l10n.tocEmptyXmlTopic, + ), + BusyMarkPopupMenuItem( + value: _TocCreationChoice.emptyGroup, + label: context.l10n.tocEmptyGroupAction, + ), + BusyMarkPopupMenuItem( + value: _TocCreationChoice.template, + label: context.l10n.tocTopicFromTemplate, + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocCreationChoice.link, + label: context.l10n.tocLinkTopicFiles, + ), + ], + onSelected: onCreateTopic, + ), + BusyMarkCompactIconButton( + tooltip: context.l10n.tocSynchronize, + icon: BusyMarkGlyphs.refresh, + onPressed: onSynchronize, + ), BusyMarkHeaderPopupMenuButton<_TocHeaderAction>( key: const ValueKey('workspace-sidebar-toc-menu'), tooltip: context.l10n.tocActions, @@ -6875,12 +8134,6 @@ class _TocHeader extends StatelessWidget { borderRadius: BusyMarkRadius.nativeHeaderButton, highlightWhenOpen: false, itemBuilder: (context) => [ - BusyMarkPopupMenuItem( - value: _TocHeaderAction.newTopic, - label: context.l10n.newTopic, - icon: BusyMarkGlyphs.newDocument, - ), - const PopupMenuDivider(), BusyMarkPopupMenuItem( value: _TocHeaderAction.newInstance, label: context.l10n.newInstance, @@ -6912,8 +8165,6 @@ class _TocHeader extends StatelessWidget { ], onSelected: (action) { switch (action) { - case _TocHeaderAction.newTopic: - onCreateTopic(); case _TocHeaderAction.newInstance: onCreateInstance(); case _TocHeaderAction.newLibrary: @@ -6943,12 +8194,14 @@ class _WritersideInstanceSelector extends StatelessWidget { required this.selectedInstance, required this.instanceColors, required this.onSelected, + required this.onOpenTocFile, }); final List instances; final WritersideInstance selectedInstance; final Map instanceColors; final ValueChanged onSelected; + final VoidCallback onOpenTocFile; @override Widget build(BuildContext context) { @@ -6991,6 +8244,15 @@ class _WritersideInstanceSelector extends StatelessWidget { clickable: selectorEnabled, focusNode: trigger.focusNode, onTap: trigger.onPressed, + onKeyEvent: (_, event) { + if (!isBusyMarkContextMenuKeyEvent(event)) { + return KeyEventResult.ignored; + } + _showInstanceMenu(context, _sidebarTreeMenuAnchor(context)); + return KeyEventResult.handled; + }, + onSecondaryTapUp: (details) => + _showInstanceMenu(context, details.globalPosition), child: Row( children: [ const SizedBox(width: BusyMarkSpacing.headerInset), @@ -7037,6 +8299,17 @@ class _WritersideInstanceSelector extends StatelessWidget { ), ); } + + Future _showInstanceMenu(BuildContext context, Offset position) async { + final open = await showBusyMarkContextMenu( + context, + position, + items: [ + BusyMarkPopupMenuItem(value: true, label: context.l10n.openTocFile), + ], + ); + if (open == true) onOpenTocFile(); + } } WritersideInstanceIconColor _effectiveInstanceIconColor( @@ -7067,6 +8340,7 @@ class _CreateWritersideTopicDialog extends ConsumerStatefulWidget { required this.referenceTopic, required this.referenceIdentity, required this.referenceLabel, + this.initialFormat = WritersideTopicFormat.markdown, }); final Workspace workspace; @@ -7076,6 +8350,7 @@ class _CreateWritersideTopicDialog extends ConsumerStatefulWidget { final String? referenceTopic; final WritersideTocNodeIdentity? referenceIdentity; final String? referenceLabel; + final WritersideTopicFormat initialFormat; @override ConsumerState<_CreateWritersideTopicDialog> createState() => @@ -7098,10 +8373,12 @@ class _CreateWritersideTopicDialogState void initState() { super.initState(); _placement = widget.placement; + _format = widget.initialFormat; _titleController = TextEditingController() ..addListener(_handleTitleChanged); - _fileNameController = TextEditingController(text: 'new-topic.md') - ..addListener(_handleFileNameChanged); + _fileNameController = TextEditingController( + text: 'new-topic${_extensionFor(_format)}', + )..addListener(_handleFileNameChanged); } @override @@ -7128,77 +8405,41 @@ class _CreateWritersideTopicDialogState final canCreate = !_creating && titleError == null && fileNameError == null; return PopScope( canPop: !_creating, - child: BusyMarkModalEditorScaffold( - title: _dialogTitle(context), - cancelLabel: context.l10n.cancel, - saveLabel: context.l10n.create, - onCancel: () => Navigator.pop(context), - cancelEnabled: !_creating, - onSave: canCreate ? _submit : null, - saving: _creating, + child: BusyMarkDialogShell( + title: context.l10n.newTopic, + maxWidth: BusyMarkSizes.dialog, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: _creating ? null : () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.tocOk, + onPressed: canCreate ? _submit : null, + ), + ], children: [ - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkGroupedTextEntry( - label: context.l10n.topicTitle, - controller: _titleController, - autofocus: true, - textInputAction: TextInputAction.next, - errorText: titleError, - ), - BusyMarkGroupedTextEntry( - label: context.l10n.fileName, - controller: _fileNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.done, - errorText: fileNameError, - onSubmitted: (_) { - if (canCreate) { - _submit(); - } - }, - ), - ], + TextField( + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: context.l10n.tocTopicTitleField, + errorText: titleError, + ), ), - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkComboRow( - title: context.l10n.topicPlacement, - subtitle: - _placement != WritersideTopicCreatePlacement.root && - widget.referenceLabel != null - ? widget.referenceLabel - : null, - leading: const Icon(BusyMarkGlyphs.tree), - values: [ - WritersideTopicCreatePlacement.root, - if (widget.referencePath != null) - WritersideTopicCreatePlacement.sibling, - if (widget.referencePath != null) - WritersideTopicCreatePlacement.child, - ], - selected: _placement, - labelFor: (value) => _placementLabel(context, value), - enabled: !_creating, - onSelected: (value) { - setState(() => _placement = value); - }, - ), - BusyMarkComboRow( - title: context.l10n.file, - leading: const Icon(BusyMarkGlyphs.document), - values: WritersideTopicFormat.values, - selected: _format, - labelFor: (value) => switch (value) { - WritersideTopicFormat.markdown => context.l10n.markdown, - WritersideTopicFormat.xml => context.l10n.xml, - }, - enabled: !_creating, - onSelected: _setFormat, - ), - ], + const SizedBox(height: BusyMarkSpacing.md), + TextField( + controller: _fileNameController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: context.l10n.tocDuplicateFilename, + errorText: fileNameError, + ), + onSubmitted: (_) { + if (canCreate) _submit(); + }, ), if (_creationError != null) ...[ const SizedBox(height: BusyMarkSpacing.md), @@ -7207,48 +8448,11 @@ class _CreateWritersideTopicDialogState kind: BusyMarkStatusKind.error, ), ], - BusyMarkGroupedList( - title: context.l10n.location, - filled: true, - children: [ - YaruListTile.square( - title: Directionality( - textDirection: TextDirection.ltr, - child: SelectableText( - _targetPath, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMarkSurfaceColors.of(context).foreground, - ), - ), - ), - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.lg), ], ), ); } - String _dialogTitle(BuildContext context) { - return _placement == WritersideTopicCreatePlacement.child - ? context.l10n.newChildTopic - : _placement == WritersideTopicCreatePlacement.sibling - ? context.l10n.newSiblingTopic - : context.l10n.newTopic; - } - - String _placementLabel( - BuildContext context, - WritersideTopicCreatePlacement placement, - ) { - return switch (placement) { - WritersideTopicCreatePlacement.root => context.l10n.tocRoot, - WritersideTopicCreatePlacement.sibling => context.l10n.afterSelectedTopic, - WritersideTopicCreatePlacement.child => context.l10n.insideSelectedTopic, - }; - } - String? _titleError(BuildContext context) { if (_titleController.text.trim().isEmpty) { return context.l10n.topicTitleRequired; @@ -7287,22 +8491,6 @@ class _CreateWritersideTopicDialogState return null; } - String get _targetPath { - final module = widget.workspace.writersideModule; - var topicsDir = module?.config.topicsDir ?? 'topics'; - if (_placement != WritersideTopicCreatePlacement.root && - widget.referenceTopic != null && - module != null) { - final topicRoot = module - .topicByReference(widget.referenceTopic!) - ?.topicRoot; - if (topicRoot != null) { - topicsDir = p.relative(topicRoot, from: module.rootPath); - } - } - return p.join(topicsDir, _effectiveFileName); - } - String get _effectiveFileName { final value = _fileNameController.text.trim(); if (p.extension(value).isEmpty) { @@ -7330,29 +8518,6 @@ class _CreateWritersideTopicDialogState setState(() {}); } - void _setFormat(WritersideTopicFormat value) { - final previousExtension = _extensionFor(_format); - _format = value; - final nextExtension = _extensionFor(value); - if (!_fileNameEdited) { - _syncingFileName = true; - _fileNameController.text = - '${_slugTopicName(_titleController.text)}$nextExtension'; - _syncingFileName = false; - } else { - final text = _fileNameController.text.trim(); - if (p.extension(text).toLowerCase() == previousExtension) { - _syncingFileName = true; - _fileNameController.text = - '${p.basenameWithoutExtension(text)}' - '$nextExtension'; - _syncingFileName = false; - } - } - _creationError = null; - setState(() {}); - } - Future _submit() async { if (_creating || _titleError(context) != null || @@ -10337,7 +11502,21 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } final editor = _sourceEditorKey.currentState; if (editor == null) return; - editor.scrollToLine(sourceTarget.line); + if (sourceTarget.tocPath != null && sourceTarget.tocIdentity != null) { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null) return; + final span = writersideTocSourceSpan( + filePath: sourceTarget.filePath, + source: buffer.text, + path: sourceTarget.tocPath!, + identity: sourceTarget.tocIdentity!, + xmlChildren: sourceTarget.xmlChildren, + ); + if (span == null) return; + editor.scrollToOffset(span.startOffset); + } else { + editor.scrollToLine(sourceTarget.line); + } _lastSourceNavigationTarget = sourceTarget; }); } diff --git a/lib/src/workspace/presentation/writerside_template_dialogs.dart b/lib/src/workspace/presentation/writerside_template_dialogs.dart new file mode 100644 index 00000000..bb16b6b7 --- /dev/null +++ b/lib/src/workspace/presentation/writerside_template_dialogs.dart @@ -0,0 +1,723 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/busymark_dialogs.dart'; +import '../../app/localization.dart'; +import '../../writerside/writerside_template_service.dart'; + +typedef WritersideTemplatePreviewBuilder = + Widget Function( + BuildContext context, + WritersideTemplate template, + String title, + String filename, + ); + +class WritersideTemplateDialog extends ConsumerStatefulWidget { + const WritersideTemplateDialog({ + super.key, + required this.previewBuilder, + required this.onCreate, + required this.existingIds, + }); + final WritersideTemplatePreviewBuilder previewBuilder; + final Future Function( + WritersideTemplate template, + String title, + String filename, + ) + onCreate; + final Set existingIds; + + @override + ConsumerState createState() => + _TemplateDialogState(); +} + +class _TemplateDialogState extends ConsumerState { + final _search = TextEditingController(); + final _title = TextEditingController(); + final _filename = TextEditingController(); + List? _templates; + WritersideTemplate? _selected; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final templates = await ref + .read(writersideTemplateServiceProvider) + .catalog(); + if (!mounted) return; + setState(() { + _templates = templates; + _error = null; + final next = + templates.where((item) => item.id == _selected?.id).firstOrNull ?? + (templates.where((item) => item.category == 'default').toList() + ..sort((a, b) => a.name.compareTo(b.name))) + .firstOrNull; + if (next != null && next.id != _selected?.id) { + _select(next); + } else { + _selected = next; + } + }); + } on Object { + if (mounted) setState(() => _error = context.l10n.tocTemplateLoadError); + } + } + + void _select(WritersideTemplate template) { + _selected = template; + _title.text = template.name; + final base = WritersideTemplateService.filenameFromTitle(template.name); + var filename = base; + for (var index = 1; widget.existingIds.contains(filename); index++) { + filename = '$base-$index'; + } + _filename.text = filename; + _error = null; + } + + @override + void dispose() { + _search.dispose(); + _title.dispose(); + _filename.dispose(); + super.dispose(); + } + + String? get _filenameError { + final value = _filename.text.trim(); + if (value.isEmpty) return context.l10n.fileNameRequired; + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(value)) { + return context.l10n.useIdentifierCharacters; + } + if (widget.existingIds.contains(value)) { + return context.l10n.topicIdAlreadyExists; + } + return null; + } + + Future _edit({bool create = false}) async { + await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTemplatesEditor( + createNew: create, + selectedId: _selected?.category == 'tgdp' ? null : _selected?.id, + ), + ); + if (mounted) await _load(); + } + + Future _create() async { + if (_busy || + _selected == null || + _filenameError != null || + _title.text.trim().isEmpty) { + return; + } + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + error = await widget.onCreate( + _selected!, + _title.text.trim(), + _filename.text.trim(), + ); + } on Object { + if (mounted) error = context.l10n.createWritersideTopicFailed; + } + if (!mounted) return; + if (error == null) { + Navigator.pop(context); + } else { + setState(() { + _busy = false; + _error = error; + }); + } + } + + @override + Widget build(BuildContext context) { + final selected = _selected; + final groups = {}; + for (final template in _templates ?? []) { + if (template.name.toLowerCase().contains(_search.text.toLowerCase())) { + groups.putIfAbsent(template.groupKey, () => template); + } + } + return PopScope( + canPop: !_busy, + child: BusyMarkDialogShell( + title: context.l10n.tocTemplateDialog, + maxWidth: 1060, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: _busy ? null : () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.create, + suggested: true, + onPressed: + !_busy && + selected != null && + _filenameError == null && + _title.text.trim().isNotEmpty + ? _create + : null, + ), + ], + children: [ + SizedBox( + height: (MediaQuery.sizeOf(context).height * .65).clamp(280, 630), + child: AbsorbPointer( + absorbing: _busy, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 230, + child: Column( + children: [ + TextField( + key: const ValueKey('template-search'), + controller: _search, + decoration: InputDecoration( + hintText: context.l10n.search, + ), + onChanged: (_) => setState(() {}), + ), + Expanded( + child: ListView( + children: [ + for (final category in [ + 'default', + 'custom', + 'tgdp', + ]) + ExpansionTile( + key: ValueKey( + 'template-category-$category-${_search.text.isNotEmpty}', + ), + initiallyExpanded: + category != 'tgdp' || + _search.text.isNotEmpty, + title: Text( + _categoryLabel(context, category), + ), + children: [ + for (final template + in (groups.values + .where( + (entry) => + entry.category == category, + ) + .toList() + ..sort( + (a, b) => a.name.compareTo(b.name), + ))) + ListTile( + dense: true, + key: ValueKey( + 'template-${template.id}', + ), + selected: + selected?.groupKey == + template.groupKey, + title: Text(template.name), + onTap: () => + setState(() => _select(template)), + ), + if (category == 'custom') + TextButton( + onPressed: () => _edit(create: true), + child: Text( + context.l10n.tocCreateCustomTemplate, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const VerticalDivider(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_templates == null && _error == null) + const LinearProgressIndicator(), + if (selected != null) ...[ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('template-title'), + controller: _title, + decoration: InputDecoration( + labelText: context.l10n.tocTopicTitleField, + errorText: _title.text.trim().isEmpty + ? context.l10n.topicTitleRequired + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + TextButton( + onPressed: selected.url == null + ? _edit + : () => launchUrl( + Uri.parse(selected.url!), + mode: LaunchMode.externalApplication, + ), + child: Text( + selected.url == null + ? context.l10n.tocEditTemplates + : context.l10n.source, + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('template-filename'), + controller: _filename, + textDirection: TextDirection.ltr, + decoration: InputDecoration( + labelText: context.l10n.tocTemplateFilename, + suffixText: '.${selected.extension}', + errorText: _filenameError, + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _create(), + ), + const SizedBox(height: 8), + Row( + children: [ + Text(context.l10n.tocTemplateFormat), + const SizedBox(width: 12), + for (final extension in ['md', 'topic']) + YaruRadioButton( + value: extension, + groupValue: selected.extension, + title: Text( + extension == 'md' + ? context.l10n.tocTemplateMarkdown + : context.l10n.tocTemplateXml, + ), + onChanged: + !_templates!.any( + (entry) => + entry.groupKey == + selected.groupKey && + entry.extension == extension, + ) + ? null + : (value) => setState(() { + _selected = _templates!.firstWhere( + (entry) => + entry.groupKey == + selected.groupKey && + entry.extension == value, + ); + }), + ), + ], + ), + const Divider(), + Expanded( + child: ClipRect( + child: widget.previewBuilder( + context, + selected, + _title.text, + _filename.text, + ), + ), + ), + ], + if (_error != null) + BusyMarkStatusBox( + message: _error!, + kind: BusyMarkStatusKind.error, + ), + if (_templates == null && _error != null) + TextButton( + onPressed: _load, + child: Text(context.l10n.tocTemplateRetry), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +String _categoryLabel(BuildContext context, String category) => + switch (category) { + 'default' => context.l10n.tocTemplateDefault, + 'custom' => context.l10n.tocTemplateCustom, + _ => 'The Good Docs Project', + }; + +/// A scoped adaptation of File and Code Templates, not an IDE-wide settings +/// clone. Files edits user templates; Internal edits built-in source overrides. +/// All fields are staged until OK. Cancel never publishes edits or deletions. +class WritersideTemplatesEditor extends ConsumerStatefulWidget { + const WritersideTemplatesEditor({ + super.key, + this.createNew = false, + this.selectedId, + }); + final bool createNew; + final String? selectedId; + @override + ConsumerState createState() => + _TemplatesEditorState(); +} + +class _TemplatesEditorState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabs; + WritersideTemplateSnapshot? _snapshot; + List _base = []; + List _draft = []; + WritersideTemplate? _selected; + final _name = TextEditingController(); + final _source = TextEditingController(); + bool _internal = false; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this); + _load(); + } + + Future _load() async { + try { + final service = ref.read(writersideTemplateServiceProvider); + final snapshot = await service.read(); + final base = await service.bundled(); + if (!mounted) return; + setState(() { + _snapshot = snapshot; + _base = base; + _draft = [...snapshot.entries]; + _error = null; + final selected = _all + .where((entry) => entry.id == widget.selectedId) + .firstOrNull; + _internal = !widget.createNew && selected?.category == 'default'; + _tabs.index = _internal ? 1 : 0; + _select(selected ?? _visible.firstOrNull); + if (widget.createNew) _add(); + }); + } on Object { + if (mounted) setState(() => _error = context.l10n.tocTemplateLoadError); + } + } + + List get _all => + WritersideTemplateService.effectiveCatalog(_base, _draft); + List get _visible => + _all + .where( + (entry) => entry.category == (_internal ? 'default' : 'custom'), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + void _select(WritersideTemplate? entry) { + _selected = entry; + _name.text = entry?.name ?? ''; + _source.text = entry?.source ?? ''; + } + + void _update({String? extension}) { + final selected = _selected; + if (selected == null) return; + final next = selected.copyWith( + name: _name.text, + source: _source.text, + extension: extension, + ); + _draft.removeWhere((entry) => entry.id == next.id); + _draft.add(next); + _selected = next; + _error = null; + } + + void _add({WritersideTemplate? copy}) { + final entry = WritersideTemplate( + id: const Uuid().v4(), + name: WritersideTemplateService.uniqueName( + copy?.name ?? context.l10n.tocTemplateUnnamed, + _all.map((entry) => entry.name), + ), + category: 'custom', + extension: copy?.extension ?? 'md', + source: copy?.source ?? '', + ); + _internal = false; + _tabs.index = 0; + _draft.add(entry); + _select(entry); + } + + String? get _validationError { + final names = {}; + for (final entry in _all) { + if (entry.name.trim().isEmpty) return context.l10n.fileNameRequired; + if (!names.add('${entry.category}:${entry.name}.${entry.extension}')) { + return context.l10n.tocTemplateDuplicateName; + } + } + return null; + } + + Future _save() async { + if (_snapshot == null || _busy || _validationError != null) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await ref + .read(writersideTemplateServiceProvider) + .save(_snapshot!, _draft); + if (mounted) Navigator.pop(context); + } on WritersideTemplateConflict { + if (mounted) setState(() => _error = context.l10n.tocTemplateConflict); + } on Object { + if (mounted) setState(() => _error = context.l10n.tocTemplateSaveError); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _name.dispose(); + _source.dispose(); + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy, + child: BusyMarkDialogShell( + title: context.l10n.tocTemplatesEditor, + maxWidth: 1000, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: _busy ? null : () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.tocOk, + suggested: true, + onPressed: _snapshot != null && !_busy && _validationError == null + ? _save + : null, + ), + ], + children: [ + SizedBox( + height: (MediaQuery.sizeOf(context).height * .6).clamp(260, 600), + child: AbsorbPointer( + absorbing: _busy, + child: Column( + children: [ + Row( + children: [ + SizedBox( + width: 240, + child: YaruTabBar( + tabController: _tabs, + tabs: [ + YaruTab(label: context.l10n.files), + YaruTab(label: context.l10n.tocTemplatesInternal), + ], + onTap: (index) => setState(() { + _internal = index == 1; + _select(_visible.firstOrNull); + }), + ), + ), + const Spacer(), + TextButton( + onPressed: _snapshot == null + ? null + : () => setState(() => _add()), + child: Text(context.l10n.tocTemplateNew), + ), + TextButton( + onPressed: _selected == null + ? null + : () => setState(() => _add(copy: _selected)), + child: Text(context.l10n.tocDuplicate), + ), + TextButton( + onPressed: _selected == null + ? null + : () => setState(() { + _draft.removeWhere( + (entry) => entry.id == _selected!.id, + ); + _select( + _internal + ? _base.firstWhere( + (entry) => entry.id == _selected!.id, + ) + : _visible.firstOrNull, + ); + }), + child: Text( + _internal + ? context.l10n.tocTemplateReset + : context.l10n.delete, + ), + ), + ], + ), + const Divider(), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 230, + child: ListView( + children: [ + for (final entry in _visible) + ListTile( + dense: true, + selected: entry.id == _selected?.id, + title: Text('${entry.name}.${entry.extension}'), + onTap: () => setState(() => _select(entry)), + ), + ], + ), + ), + const VerticalDivider(), + Expanded( + child: _selected == null + ? const SizedBox.shrink() + : Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey( + 'template-editor-name', + ), + controller: _name, + readOnly: _internal, + decoration: InputDecoration( + labelText: + context.l10n.tocTemplateName, + ), + onChanged: (_) => setState(_update), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 110, + child: BusyMarkPopupSelector( + key: ValueKey( + 'template-extension-${_selected!.id}-${_selected!.extension}', + ), + value: _selected!.extension, + label: _selected!.extension, + tooltip: + context.l10n.tocTemplateExtension, + enabled: !_internal, + fullWidth: true, + options: [ + for (final ext in ['md', 'topic']) + BusyMarkPopupSelectorOption( + value: ext, + label: ext, + ), + ], + onSelected: (value) => setState( + () => _update(extension: value), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + key: const ValueKey( + 'template-editor-source', + ), + controller: _source, + expands: true, + minLines: null, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + textDirection: TextDirection.ltr, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + ), + decoration: InputDecoration( + labelText: context.l10n.source, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(_update), + ), + ), + ], + ), + ), + ], + ), + ), + if (_error ?? _validationError case final error?) + BusyMarkStatusBox( + message: error, + kind: BusyMarkStatusKind.error, + ), + if (_snapshot == null && _error == null) + const LinearProgressIndicator(), + ], + ), + ), + ), + ], + ), + ); +} diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart new file mode 100644 index 00000000..5a401e81 --- /dev/null +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../writerside/writerside_model.dart'; +import '../../writerside/writerside_title_editor.dart'; +import '../workspace_service.dart'; + +class WritersideTitleDialog extends StatefulWidget { + const WritersideTitleDialog({super.key, required this.session}); + final WritersideTitleEditSession session; + @override + State createState() => _WritersideTitleDialogState(); +} + +class _WritersideTitleDialogState extends State { + late final _title = TextEditingController( + text: widget.session.topic.title ?? '', + ); + late final _instance = TextEditingController( + text: + widget.session.topic.titleOverrides + .where((override) => override.instance == widget.session.instanceId) + .firstOrNull + ?.title ?? + '', + ); + late final _toc = TextEditingController( + text: widget.session.identity.tocTitle ?? '', + ); + late final _original = (_title.text, _instance.text, _toc.text); + bool _advanced = false; + @override + void initState() { + super.initState(); + // Evaluate all originals before the first user edit, including fields in + // the initially collapsed advanced area. + _original; + for (final controller in [_title, _instance, _toc]) { + controller.addListener(_changed); + } + } + + void _changed() => setState(() {}); + @override + void dispose() { + _title.dispose(); + _instance.dispose(); + _toc.dispose(); + super.dispose(); + } + + void _submit() { + if (_title.text.trim().isEmpty) return; + Navigator.pop( + context, + WritersideTitleEdit( + title: _title.text == _original.$1 ? null : _title.text, + instanceTitle: _instance.text == _original.$2 ? null : _instance.text, + tocTitle: _toc.text == _original.$3 ? null : _toc.text, + ), + ); + } + + Widget _field( + String label, + TextEditingController controller, { + String? hint, + bool autofocus = false, + }) => Padding( + padding: const EdgeInsets.only(bottom: BusyMarkSpacing.md), + child: TextField( + controller: controller, + autofocus: autofocus, + decoration: InputDecoration(labelText: label, hintText: hint), + onSubmitted: (_) => _submit(), + ), + ); + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.tocEditTitleDialog, + maxWidth: BusyMarkSizes.dialog, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.tocOk, + suggested: true, + onPressed: _title.text.trim().isEmpty ? null : _submit, + ), + ], + children: [ + _field(context.l10n.tocTopicTitleField, _title, autofocus: true), + TextButton( + onPressed: () => setState(() => _advanced = !_advanced), + child: Row( + children: [ + Icon(_advanced ? BusyMarkGlyphs.upArrow : BusyMarkGlyphs.downArrow), + Text(context.l10n.tocAdvancedSettings), + ], + ), + ), + if (_advanced) ...[ + _field( + context.l10n.tocInstanceTitleField(widget.session.instanceId), + _instance, + hint: _title.text, + ), + _field( + context.l10n.tocOnlyTitleField, + _toc, + hint: _instance.text.isEmpty ? _title.text : _instance.text, + ), + ], + ], + ); +} + +class WritersideTocTextDialog extends StatefulWidget { + const WritersideTocTextDialog({ + super.key, + required this.title, + required this.label, + this.initialValue = '', + this.enterOnly = false, + this.validate, + }); + final String title; + final String label; + final String initialValue; + final bool enterOnly; + final String? Function(String)? validate; + @override + State createState() => + _WritersideTocTextDialogState(); +} + +class _WritersideTocTextDialogState extends State { + late final _controller = TextEditingController(text: widget.initialValue); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String? get _error => widget.validate?.call(_controller.text); + void _submit() { + if (_controller.text.trim().isNotEmpty && _error == null) { + Navigator.pop(context, _controller.text); + } + } + + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: widget.title, + maxWidth: BusyMarkSizes.dialog, + actions: widget.enterOnly + ? const [] + : [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.tocOk, + suggested: true, + onPressed: _controller.text.trim().isEmpty || _error != null + ? null + : _submit, + ), + ], + children: [ + TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration(labelText: widget.label, errorText: _error), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(), + ), + ], + ); +} + +class WritersideExistingTopicPicker extends StatefulWidget { + const WritersideExistingTopicPicker({super.key, required this.topics}); + final List topics; + @override + State createState() => + _WritersideExistingTopicPickerState(); +} + +class _WritersideExistingTopicPickerState + extends State { + String _query = ''; + int _selected = 0; + final _scroll = ScrollController(); + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final topics = widget.topics + .where( + (topic) => + topic.fileName.toLowerCase().contains(_query.toLowerCase()), + ) + .toList(); + return BusyMarkDialogShell( + title: context.l10n.tocSelectExistingTopic, + maxWidth: BusyMarkSizes.dialog, + children: [ + Focus( + onKeyEvent: (_, event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key != LogicalKeyboardKey.arrowDown && + key != LogicalKeyboardKey.arrowUp) { + return KeyEventResult.ignored; + } + if (topics.isNotEmpty) { + setState( + () => _selected = + (_selected + (key == LogicalKeyboardKey.arrowDown ? 1 : -1)) + .clamp(0, topics.length - 1), + ); + if (_scroll.hasClients) { + _scroll.jumpTo( + (_selected * 48.0 - 240).clamp( + 0, + _scroll.position.maxScrollExtent, + ), + ); + } + } + return KeyEventResult.handled; + }, + child: TextField( + autofocus: true, + decoration: InputDecoration(hintText: context.l10n.search), + onChanged: (value) => setState(() { + _query = value; + _selected = 0; + }), + onSubmitted: (_) { + if (topics.isNotEmpty) Navigator.pop(context, topics[_selected]); + }, + ), + ), + SizedBox( + height: 300, + child: ListView( + controller: _scroll, + itemExtent: 48, + children: [ + for (var index = 0; index < topics.length; index++) + ListTile( + selected: index == _selected, + title: Text(topics[index].fileName), + onTap: () => Navigator.pop(context, topics[index]), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 2c5d2708..344460e4 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -9,6 +9,7 @@ import 'package:path/path.dart' as p; import '../app/app_settings.dart'; import '../comparison/source_comparison.dart'; import '../core/debug_log.dart'; +import '../core/busymark_exception.dart'; import '../core/diagnostic.dart'; import '../core/source_span.dart'; import '../core/path_utils.dart' show isTextDocumentationPath; @@ -23,6 +24,7 @@ import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_topic_removal_service.dart'; import '../writerside/writerside_topic_creator.dart'; import '../writerside/writerside_toc_editor.dart'; +import '../writerside/writerside_title_editor.dart'; import 'document_buffer.dart'; import 'recovery_persistence.dart'; import 'session_persistence.dart'; @@ -214,6 +216,8 @@ class WorkspaceController extends Notifier { var _editRevision = 0; var _activeDocumentRevision = 0; var _workspaceRefreshRevision = 0; + var _workspaceFileOperationDepth = 0; + final _deferredFileMonitorEvents = []; var _untitledSequence = 0; final _intentionallyRemovedPaths = {}; late Future _recoveryStart; @@ -703,6 +707,10 @@ class WorkspaceController extends Notifier { Future _handleFileMonitorEvent(WorkspaceFileMonitorEvent event) async { if (!ref.mounted) return; + if (_workspaceFileOperationDepth > 0) { + _deferredFileMonitorEvents.add(event); + return; + } final matching = state.documentBuffers.where((buffer) { final path = buffer.filePath; return path != null && @@ -729,7 +737,7 @@ class WorkspaceController extends Notifier { if (!ref.mounted) return; // Our own filesystem writes also emit notifications. A background // refresh must not invalidate an operation already loading its result. - if (state.isLoading) { + if (state.isLoading || _workspaceFileOperationDepth > 0) { _scheduleMonitoredWorkspaceRefresh(); return; } @@ -1327,90 +1335,24 @@ class WorkspaceController extends Notifier { Future createWritersideTopic( WritersideTopicCreateRequest request, { String? instanceTreePath, - }) async { - final workspace = state.workspace; - if (workspace == null) { - return false; - } - _cancelPendingDerivedRefresh(); - _cancelAllAutoSaves(); - final operationRevision = _invalidateActiveDocumentOperations(); - _resetSaveTracking(); - state = state.copyWith(isLoading: true, clearMessage: true); - try { - final nextWorkspace = await _service.createWritersideTopic( - workspace, - request, - instanceTreePath: instanceTreePath, - ); - final active = nextWorkspace.activeFilePath; - final load = active == null - ? null - : await _service.loadTextWithSnapshot(active); - final text = load?.text ?? ''; - final loadedWorkspace = load == null - ? nextWorkspace - : nextWorkspace.copyWith(activeFileSnapshot: load.snapshot); - final openFilePaths = _retainedOpenFileTabPaths( - current: workspace, - refreshed: loadedWorkspace, - activeFilePath: active, - ); - final tabbedWorkspace = loadedWorkspace.copyWith( - openFilePaths: openFilePaths, - ); - if (!_isCurrentActiveDocumentOperation(operationRevision)) { - return false; - } - final existing = active == null ? null : state.bufferForPath(active); - final buffer = - existing ?? - (load == null || active == null - ? null - : _fileBuffer( - active, - load, - mode: _settingsController.state.documentViewMode, - )); - final buffers = buffer == null - ? state.documentBuffers - : existing != null - ? state.documentBuffers - : [...state.documentBuffers, buffer]; - state = WorkspaceState( - workspace: tabbedWorkspace, - activeText: text, - preview: _safePreview(tabbedWorkspace, text), - documentBuffers: buffers, - activeBufferId: buffer?.id, - ); - if (buffer != null && existing == null) { - unawaited(_localHistory.observeOpened(buffer)); - } - _recordActivePreviewRevision(); - await _startMonitoring(tabbedWorkspace); - _schedulePersistence(); - _resetSaveTracking(); - return true; - } on Object catch (error, stackTrace) { - busyMarkDebugLogError( - '[BusyMark] Create Writerside topic failed', - error, - stackTrace, - context: {'title': request.title, 'file name': request.fileName}, - ); - if (_isCurrentActiveDocumentOperation(operationRevision)) { - state = state.copyWith( - isLoading: false, - message: WorkspaceMessage( - WorkspaceMessageCode.createWritersideTopicFailed, - error: error, - ), - ); - } - return false; - } - } + String? initialSource, + }) => _runWorkspaceFileOperation((workspace) async { + final paths = instanceTreePath == null + ? workspace.writersideModule!.instances + .map((instance) => instance.sourceTreePath) + .toList() + : [instanceTreePath]; + _requireCleanAffectedFiles(workspace, paths); + final created = await _service.createWritersideTopic( + workspace, + request, + instanceTreePath: instanceTreePath, + initialSource: initialSource, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, paths), + ); + return created.activeFilePath; + }); Future?> discoverWritersideMarkdownImport(String sourceDirectoryPath) async { @@ -1670,6 +1612,7 @@ class WorkspaceController extends Notifier { WritersideTocNodeIdentity? referenceIdentity, }) { return _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); await _service.moveWritersideTocEntry( workspace, treePath: treePath, @@ -1678,9 +1621,205 @@ class WorkspaceController extends Notifier { referencePath: referencePath, sourceIdentity: sourceIdentity, referenceIdentity: referenceIdentity, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), + ); + return null; + }); + } + + void _requireCleanAffectedFiles(Workspace workspace, Iterable paths) { + if (state.workspace?.id != workspace.id || + paths.any( + (path) => state.dirtyBuffers.any( + (buffer) => + buffer.filePath != null && p.equals(path, buffer.filePath!), + ), + )) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + } + + Future prepareWritersideTitleEdit({ + required String treePath, + required List tocPath, + required WritersideTocNodeIdentity identity, + }) async { + final workspace = state.workspace; + if (workspace == null) return null; + try { + return await _service.prepareWritersideTitleEdit( + workspace, + treePath: treePath, + tocPath: tocPath, + identity: identity, + ); + } on Object catch (error) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: error, + ), + ); + return null; + } + } + + Future duplicateWritersideTopic({ + required String treePath, + required List tocPath, + required WritersideTocNodeIdentity identity, + required String topicPath, + required String expectedSource, + required String newName, + }) => _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath, topicPath]); + return _service.duplicateWritersideTopic( + workspace, + treePath: treePath, + tocPath: tocPath, + identity: identity, + topicPath: topicPath, + expectedSource: expectedSource, + newName: newName, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath, topicPath]), + ); + }); + + Future editWritersideTitles( + WritersideTitleEditSession session, + WritersideTitleEdit edit, + ) => _runWorkspaceFileOperation((workspace) async { + void validate() => _requireCleanAffectedFiles(workspace, [ + session.topic.filePath, + session.treePath, + ]); + validate(); + await _service.editWritersideTitles( + session, + edit, + validateBeforeCommit: validate, + onCommitted: (writes, snapshots) { + state = state.copyWith( + documentBuffers: [ + for (final buffer in state.documentBuffers) + if (writes[buffer.filePath] case final write?) + buffer.copyWith( + text: write.text, + lastSavedText: write.text, + dirty: false, + diskSnapshot: snapshots[buffer.filePath], + revision: buffer.revision + 1, + ) + else + buffer, + ], + ); + }, + ); + return null; + }); + + Future insertWritersideTocElement({ + required String treePath, + required WritersideTocInsertRequest request, + String? expectedTopicPath, + String? expectedTopicSource, + }) async { + WritersideTocMutationResult? result; + final succeeded = await _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); + result = await _service.insertWritersideTocElement( + workspace, + treePath: treePath, + request: request, + expectedTopicPath: expectedTopicPath, + expectedTopicSource: expectedTopicSource, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), ); return null; }); + return succeeded ? result : null; + } + + Future groupWritersideTocElements({ + required String treePath, + required List entries, + required String title, + }) async { + WritersideTocMutationResult? result; + final succeeded = await _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); + result = await _service.groupWritersideTocElements( + workspace, + treePath: treePath, + entries: entries, + title: title, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), + ); + return null; + }); + return succeeded ? result : null; + } + + Future sortWritersideTocChildren({ + required String treePath, + required List nodePath, + required WritersideTocNodeIdentity identity, + }) async { + WritersideTocMutationResult? result; + final succeeded = await _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); + result = await _service.sortWritersideTocChildren( + workspace, + treePath: treePath, + nodePath: nodePath, + identity: identity, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), + ); + return null; + }); + return succeeded ? result : null; + } + + Future setWritersideHomePage({ + required String treePath, + required List nodePath, + required WritersideTocNodeIdentity expectedIdentity, + }) => _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); + await _service.setWritersideHomePage( + workspace, + treePath: treePath, + nodePath: nodePath, + expectedIdentity: expectedIdentity, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), + ); + return null; + }); + + Future dragWritersideTocEntries({ + required String treePath, + required WritersideTocBatchMoveRequest request, + }) async { + WritersideTocMutationResult? result; + final success = await _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); + result = await _service.dragWritersideTocEntries( + workspace, + treePath: treePath, + request: request, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), + ); + return null; + }); + return success ? result : null; } Future moveWritersideTocEntries({ @@ -1691,6 +1830,7 @@ class WorkspaceController extends Notifier { WritersideTocNodeIdentity? referenceIdentity, }) { return _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); await _service.moveWritersideTocEntries( workspace, treePath: treePath, @@ -1698,6 +1838,8 @@ class WorkspaceController extends Notifier { placement: placement, referencePath: referencePath, referenceIdentity: referenceIdentity, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), ); return null; }); @@ -1709,11 +1851,14 @@ class WorkspaceController extends Notifier { WritersideTocNodeIdentity? expectedIdentity, }) { return _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); await _service.removeWritersideTocEntry( workspace, treePath: treePath, nodePath: nodePath, expectedIdentity: expectedIdentity, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), ); return null; }); @@ -1724,10 +1869,13 @@ class WorkspaceController extends Notifier { required List requests, }) { return _runWorkspaceFileOperation((workspace) async { + _requireCleanAffectedFiles(workspace, [treePath]); await _service.removeWritersideTocEntries( workspace, treePath: treePath, requests: requests, + validateBeforePublish: () async => + _requireCleanAffectedFiles(workspace, [treePath]), ); return null; }); @@ -1803,35 +1951,17 @@ class WorkspaceController extends Notifier { Future applyWritersideTopicRemoval( WritersideTopicRemovalRequest request, ) async { - final workspace = state.workspace; - if (workspace == null) { - return null; - } - try { - final result = await _service.applyWritersideTopicRemoval( + WritersideTopicRemovalResult? result; + final success = await _runWorkspaceFileOperation((workspace) async { + result = await _service.applyWritersideTopicRemoval( workspace, request, - ); - if (!await refreshWorkspaceFromDiskPreservingOpenTabs()) { - return null; - } - return result; - } on Object catch (error, stackTrace) { - busyMarkDebugLogError( - '[BusyMark] Writerside topic removal failed', - error, - stackTrace, - context: {'root': busyMarkLogPath(workspace.rootPath)}, - ); - state = state.copyWith( - isLoading: false, - message: WorkspaceMessage( - WorkspaceMessageCode.fileOperationFailed, - error: error, - ), + validateBeforeCommit: (paths) => + _requireCleanAffectedFiles(workspace, paths), ); return null; - } + }); + return success ? result : null; } Future closeOpenFileTab(String path) => _closeOpenFileTabNow(path); @@ -4307,6 +4437,7 @@ class WorkspaceController extends Notifier { if (workspace == null) { return false; } + _workspaceFileOperationDepth++; try { final preferredActivePath = await operation(workspace); final refreshed = await refreshWorkspaceFromDiskPreservingOpenTabs(); @@ -4332,6 +4463,22 @@ class WorkspaceController extends Notifier { ), ); return false; + } finally { + _workspaceFileOperationDepth--; + if (_workspaceFileOperationDepth == 0 && ref.mounted) { + final pending = List.of( + _deferredFileMonitorEvents, + ); + _deferredFileMonitorEvents.clear(); + // Observe notifications only after reconciliation/opening completes. + // Own writes then match the refreshed snapshots, while unrelated + // external changes retain their normal monitoring behavior. + unawaited(() async { + for (final event in pending) { + await _handleFileMonitorEvent(event); + } + }()); + } } } @@ -5026,24 +5173,6 @@ bool _supportsOpenFileTabs(Workspace workspace) { }; } -List _retainedOpenFileTabPaths({ - required Workspace current, - required Workspace refreshed, - required String? activeFilePath, -}) { - final availablePaths = { - for (final file in refreshed.files) file.absolutePath, - }; - final retained = [ - for (final path in current.openFilePaths) - if (availablePaths.contains(path)) path, - ]; - if (activeFilePath == null || retained.contains(activeFilePath)) { - return retained; - } - return [...retained, activeFilePath]; -} - String? _remapMovedPath(String? path, String source, String target) { if (path == null) { return null; diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index 7c0a3690..e1fb1f20 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -35,6 +35,25 @@ Future confirmSafeToContinue(BuildContext context, WidgetRef ref) async { ); } +/// Covers every affected tab, including inactive dirty tree/topic buffers. +Future confirmSafeToChangeWorkspaceFiles( + BuildContext context, + WidgetRef ref, + Iterable paths, +) { + final affected = paths.toList(); + return _confirmUnsavedChanges( + context, + ref, + dirtyBufferIds: [ + for (final buffer in ref.read(workspaceControllerProvider).dirtyBuffers) + if (buffer.filePath != null && + affected.any((path) => p.equals(path, buffer.filePath!))) + buffer.id, + ], + ); +} + Future confirmSafeToCloseActiveDocument( BuildContext context, WidgetRef ref, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 2bf96a54..6caf1e51 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -28,6 +28,29 @@ import '../writerside/writerside_topic_file_editor.dart'; import '../writerside/writerside_topic_removal_service.dart'; import 'workspace_model.dart'; import 'text_format_metadata.dart'; +import '../writerside/writerside_toc_presentation.dart'; +import '../writerside/writerside_title_editor.dart'; +import '../writerside/writerside_parsers.dart'; +import '../writerside/writerside_document_parser.dart'; + +class WritersideTitleEditSession { + const WritersideTitleEditSession({ + required this.topic, + required this.instanceId, + required this.treePath, + required this.tocPath, + required this.identity, + required this.topicLoad, + required this.treeLoad, + }); + final WritersideTopic topic; + final String instanceId; + final String treePath; + final List tocPath; + final WritersideTocNodeIdentity identity; + final WorkspaceFileLoad topicLoad; + final WorkspaceFileLoad treeLoad; +} class WorkspaceBatchTextWrite { const WorkspaceBatchTextWrite({ @@ -199,6 +222,8 @@ class WorkspaceService { Workspace workspace, WritersideTopicCreateRequest request, { String? instanceTreePath, + String? initialSource, + Future Function()? validateBeforePublish, }) async { if (workspace.kind != WorkspaceKind.writersideModule || workspace.writersideModule == null) { @@ -230,6 +255,8 @@ class WorkspaceService { existingTopicIds: {for (final topic in module.topics) topic.id}, ), request, + initialSource: initialSource, + validateBeforePublish: validateBeforePublish, ); return _openWriterside(module.rootPath, activeFilePath: result.topicPath); } @@ -263,6 +290,7 @@ class WorkspaceService { required List? referencePath, WritersideTocNodeIdentity? sourceIdentity, WritersideTocNodeIdentity? referenceIdentity, + Future Function()? validateBeforePublish, }) async { final module = await _currentWritersideModule(workspace); final instance = _writersideInstanceForTree(module, treePath); @@ -278,6 +306,374 @@ class WorkspaceService { sourceIdentity: sourceIdentity, referenceIdentity: referenceIdentity, ), + validateBeforePublish: validateBeforePublish, + ); + } + + Future setWritersideHomePage( + Workspace workspace, { + required String treePath, + required List nodePath, + required WritersideTocNodeIdentity expectedIdentity, + Future Function()? validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + final reference = expectedIdentity.topicFileName; + final topic = reference == null ? null : module.topicByReference(reference); + if (instance.isLibrary || + topic == null || + !await File(topic.filePath).exists()) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + await writersideTocEditor.setHomePage( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + nodePath: nodePath, + expectedIdentity: expectedIdentity, + topicReference: reference!, + validateBeforePublish: () async { + await validateBeforePublish?.call(); + if (!await File(topic.filePath).exists()) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + }, + ); + } + + Future prepareWritersideTitleEdit( + Workspace workspace, { + required String treePath, + required List tocPath, + required WritersideTocNodeIdentity identity, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + final reference = identity.topicFileName; + final topic = reference == null ? null : module.topicByReference(reference); + if (topic == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final topicLoad = await loadTextWithSnapshot(topic.filePath); + final treeLoad = await loadTextWithSnapshot(treePath); + final parsed = topic.format == WritersideTopicFormat.xml + ? const WritersideTopicParser().parseXml( + filePath: topic.filePath, + source: topicLoad.text, + topicsRoot: topic.topicRoot, + ) + : const WritersideTopicParser().parseMarkdown( + filePath: topic.filePath, + source: topicLoad.text, + topicsRoot: topic.topicRoot, + ); + return WritersideTitleEditSession( + topic: parsed, + instanceId: instance.id, + treePath: treePath, + tocPath: List.unmodifiable(tocPath), + identity: identity, + topicLoad: topicLoad, + treeLoad: treeLoad, + ); + } + + Future duplicateWritersideTopic( + Workspace workspace, { + required String treePath, + required List tocPath, + required WritersideTocNodeIdentity identity, + required String topicPath, + required String expectedSource, + required String newName, + required Future Function() validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + final topic = module.topics + .where((topic) => p.equals(topic.filePath, topicPath)) + .singleOrNull; + if (topic == null || topic.document.source != expectedSource) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + var source = expectedSource; + if (topic.format == WritersideTopicFormat.xml) { + final root = const WritersideDocumentParser() + .parseXml(filePath: topicPath, source: source) + .rootElement; + final idSpan = root?.attributeSpans['id']; + if (idSpan == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + source = source.replaceRange( + idSpan.startOffset, + idSpan.endOffset, + p.basenameWithoutExtension(newName), + ); + } + final anchor = await captureCanonicalDirectoryAnchor(module.rootPath); + Future validate() async { + await validateBeforePublish(); + final original = await resolveAnchoredPath( + anchor, + topicPath, + allowRoot: false, + ); + if (original.type != FileSystemEntityType.file || + await File(original.path).readAsString() != expectedSource) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + } + + final result = await writersideTopicCreator.create( + WritersideTopicCreateTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + topicsRootDir: p.relative(p.dirname(topicPath), from: module.rootPath), + existingTopicIds: module.topics.map((topic) => topic.id).toSet(), + ), + WritersideTopicCreateRequest( + title: topic.title ?? topic.id, + fileName: newName, + format: topic.format, + placement: WritersideTopicCreatePlacement.sibling, + referenceTocPath: tocPath, + referenceTocIdentity: identity, + ), + initialSource: source, + validateBeforePublish: validate, + ); + return result.topicPath; + } + + Future> editWritersideTitles( + WritersideTitleEditSession session, + WritersideTitleEdit edit, { + required void Function() validateBeforeCommit, + required void Function( + Map, + Map, + ) + onCommitted, + }) async { + final result = const WritersideTitleEditor().prepare( + topic: session.topic, + instanceId: session.instanceId, + treePath: session.treePath, + treeSource: session.treeLoad.text, + tocPath: session.tocPath, + tocIdentity: session.identity, + edit: edit, + ); + // Both inputs are checked even if only one field/file changes. + for (final entry in { + session.topic.filePath: session.topicLoad, + session.treePath: session.treeLoad, + }.entries) { + if ((await fileSnapshot(entry.key)).differsFrom(entry.value.snapshot)) { + throw WorkspaceBatchWriteConflict(entry.key); + } + } + final writes = { + if (result.topicSource != session.topicLoad.text) + session.topic.filePath: WorkspaceBatchTextWrite( + path: session.topic.filePath, + text: result.topicSource, + expectedSnapshot: session.topicLoad.snapshot, + format: session.topicLoad.format, + ), + if (result.treeSource != session.treeLoad.text) + session.treePath: WorkspaceBatchTextWrite( + path: session.treePath, + text: result.treeSource, + expectedSnapshot: session.treeLoad.snapshot, + format: session.treeLoad.format, + ), + }; + await saveFormattedTextBatch( + writes.values.toList(), + validateBeforeCommit: () { + validateBeforeCommit(); + for (final entry in { + session.topic.filePath: session.topicLoad, + session.treePath: session.treeLoad, + }.entries) { + if (!writes.containsKey(entry.key)) { + final file = File(entry.key); + if (_snapshotFromBytes( + file.statSync(), + file.readAsBytesSync(), + ).differsFrom(entry.value.snapshot)) { + throw WorkspaceBatchWriteConflict(entry.key); + } + } + } + }, + onCommitted: (snapshots) => onCommitted(writes, snapshots), + ); + return writes; + } + + Future insertWritersideTocElement( + Workspace workspace, { + required String treePath, + required WritersideTocInsertRequest request, + String? expectedTopicPath, + String? expectedTopicSource, + Future Function()? validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + Future validate() async { + await validateBeforePublish?.call(); + if (request.topicReference != null) { + final latest = await _currentWritersideModule(workspace); + final currentInstance = _writersideInstanceForTree(latest, treePath); + final topic = latest.topicByReference(request.topicReference!); + final presenter = WritersideTocPresenter( + module: latest, + instance: currentInstance, + modulesByOrigin: + workspace.writersideProject?.modulesByOrigin ?? const {}, + ); + if (topic == null || + expectedTopicPath == null || + !p.equals(topic.filePath, expectedTopicPath) || + topic.document.source != expectedTopicSource || + !await File(topic.filePath).exists() || + currentInstance.navigationTocRoots + .expand((node) => node.flatten()) + .any( + (node) => + presenter.present(node).topic?.filePath == topic.filePath, + )) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + } + } + + await validate(); + return writersideTocEditor.insertElement( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + request, + validateBeforePublish: validate, + ); + } + + Future groupWritersideTocElements( + Workspace workspace, { + required String treePath, + required List entries, + required String title, + Future Function()? validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + return writersideTocEditor.groupElements( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + entries: entries, + title: title, + validateBeforePublish: validateBeforePublish, + ); + } + + Future sortWritersideTocChildren( + Workspace workspace, { + required String treePath, + required List nodePath, + required WritersideTocNodeIdentity identity, + Future Function()? validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + var children = instance.tocRoots; + TocNode? node; + for (final index in nodePath) { + if (index < 0 || index >= children.length) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + node = children[index]; + children = node.children; + } + if (node == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final presenter = WritersideTocPresenter( + module: module, + instance: instance, + modulesByOrigin: workspace.writersideProject?.modulesByOrigin ?? const {}, + ); + final labels = [ + for (final child in children) presenter.present(child).label, + ]; + final permutation = List.generate(children.length, (index) => index) + ..sort((a, b) { + final comparison = labels[a].compareTo(labels[b]); + return comparison == 0 ? a.compareTo(b) : comparison; + }); + Future validate() async { + await validateBeforePublish?.call(); + final current = await _currentWritersideModule(workspace); + final currentInstance = _writersideInstanceForTree(current, treePath); + var currentChildren = currentInstance.tocRoots; + for (final index in nodePath) { + if (index < 0 || index >= currentChildren.length) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + currentChildren = currentChildren[index].children; + } + final currentPresenter = WritersideTocPresenter( + module: current, + instance: currentInstance, + modulesByOrigin: + workspace.writersideProject?.modulesByOrigin ?? const {}, + ); + if (currentChildren.length != labels.length || + List.generate(labels.length, (index) => index).any( + (index) => + currentPresenter.present(currentChildren[index]).label != + labels[index], + )) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + await validateBeforePublish?.call(); + } + + return writersideTocEditor.reorderChildren( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + nodePath: nodePath, + identity: identity, + permutation: permutation, + validateBeforePublish: validate, + ); + } + + Future dragWritersideTocEntries( + Workspace workspace, { + required String treePath, + required WritersideTocBatchMoveRequest request, + required Future Function() validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + return writersideTocEditor.moveSubtrees( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + request, + validateBeforePublish: validateBeforePublish, ); } @@ -288,6 +684,7 @@ class WorkspaceService { required WritersideTopicCreatePlacement placement, required List? referencePath, WritersideTocNodeIdentity? referenceIdentity, + Future Function()? validateBeforePublish, }) async { final module = await _currentWritersideModule(workspace); final instance = _writersideInstanceForTree(module, treePath); @@ -302,6 +699,7 @@ class WorkspaceService { referencePath: referencePath, referenceIdentity: referenceIdentity, ), + validateBeforePublish: validateBeforePublish, ); } @@ -310,6 +708,7 @@ class WorkspaceService { required String treePath, required List nodePath, WritersideTocNodeIdentity? expectedIdentity, + Future Function()? validateBeforePublish, }) async { final module = await _currentWritersideModule(workspace); final instance = _writersideInstanceForTree(module, treePath); @@ -320,6 +719,7 @@ class WorkspaceService { ), nodePath, expectedIdentity: expectedIdentity, + validateBeforePublish: validateBeforePublish, ); } @@ -327,6 +727,7 @@ class WorkspaceService { Workspace workspace, { required String treePath, required List requests, + Future Function()? validateBeforePublish, }) async { final module = await _currentWritersideModule(workspace); final instance = _writersideInstanceForTree(module, treePath); @@ -336,6 +737,7 @@ class WorkspaceService { treePath: instance.sourceTreePath, ), requests, + validateBeforePublish: validateBeforePublish, ); } @@ -393,13 +795,17 @@ class WorkspaceService { Future applyWritersideTopicRemoval( Workspace workspace, - WritersideTopicRemovalRequest request, - ) async { + WritersideTopicRemovalRequest request, { + void Function(Iterable)? validateBeforeCommit, + }) async { final module = await _currentWritersideModule(workspace); if (!p.equals(module.rootPath, request.analysis.moduleRoot)) { throw const BusyMarkException('writerside.topic.module-not-open'); } - return writersideTopicRemovalService.apply(request); + return writersideTopicRemovalService.apply( + request, + validateBeforeCommit: validateBeforeCommit, + ); } WritersideModule _writersideModule(Workspace workspace) { @@ -695,8 +1101,10 @@ class WorkspaceService { /// files are atomically exchanged with their targets; if a later exchange /// fails, already-exchanged files are rolled back. Future> saveFormattedTextBatch( - List writes, - ) async { + List writes, { + void Function()? validateBeforeCommit, + void Function(Map)? onCommitted, + }) async { if (writes.isEmpty) { return const {}; } @@ -755,6 +1163,7 @@ class WorkspaceService { } } for (final write in staged) { + validateBeforeCommit?.call(); final error = LinuxAtomicFileApi.instance.exchange( write.stagedFile.absolute.path, write.target.absolute.path, @@ -784,14 +1193,18 @@ class WorkspaceService { } committed.add(write); await _afterBatchFileCommit?.call(write.target.path, committed.length); + validateBeforeCommit?.call(); } - return { + final snapshots = { for (final write in staged) write.requestPath: _snapshotFromBytes( await write.target.stat(), write.bytes, ), }; + validateBeforeCommit?.call(); + onCommitted?.call(snapshots); + return snapshots; } on Object catch (error, stackTrace) { final conflicts = [ if (error case WorkspaceBatchPartialApplicationConflict partial) diff --git a/lib/src/writerside/writerside_document_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index f3060b83..c10f6cce 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -91,6 +91,27 @@ class ResolvedWritersideDocument { class WritersideDocumentResolver { const WritersideDocumentResolver(); + /// Navigation-only attributes share the renderer's condition and variable + /// engine, without manufacturing a document for groups and external links. + String resolveNavigationTitle( + String value, { + required WritersideModule module, + required WritersideInstance instance, + WritersideTopic? topic, + }) => _interpolateWritersideVariables(value, { + for (final variable in module.variables) + if (_matchesWritersideInstance( + variable.instanceCondition, + module, + instance, + )) + variable.name: variable.value, + 'instance': instance.name, + 'instance-lowercase': instance.name.toLowerCase(), + 'currentId': instance.id, + 'thisTopic': topic?.id ?? '', + }); + ResolvedWritersideDocument resolve( WritersideDocument document, WritersideResolveContext context, @@ -890,30 +911,7 @@ class _ResolveState { } bool _matchesInstance(String? condition, WritersideModule module) { - final normalized = condition?.trim() ?? ''; - final negated = normalized.startsWith('!'); - final tokens = _tokens(negated ? normalized.substring(1) : normalized); - if (tokens.isEmpty) { - return true; - } - final matches = tokens.any((token) => _instanceTokenMatches(token, module)); - if (negated) { - return !matches; - } - return matches; - } - - bool _instanceTokenMatches(String token, WritersideModule module) { - final instance = context.instance; - if (instance == null) { - return false; - } - if (token.startsWith('@')) { - return module.instanceGroups?.groups[token.substring(1)]?.instanceIds - .contains(instance.id) ?? - false; - } - return token == instance.id; + return _matchesWritersideInstance(condition, module, context.instance); } bool _ignoreVariablesFor(WritersideElementNode element, bool inherited) { @@ -1068,47 +1066,33 @@ class _ResolveState { required bool ignore, Set resolving = const {}, }) { - if (ignore || !value.contains('%')) { - return value.replaceAll('%', '%'); - } - return value - .replaceAllMapped(RegExp(r'%(\\)?([A-Za-z_][A-Za-z0-9_.-]*)%'), ( - match, - ) { - final name = match.group(2)!; - if (match.group(1) != null) { - return '%$name%'; - } - final replacement = variables[name]; - if (replacement != null && !resolving.contains(name)) { - return _interpolate( - replacement, - variables, - node, - ignore: false, - resolving: {...resolving, name}, - ); - } - final key = '${node.span.filePath}:${node.span.startOffset}:$name'; - if (_unresolvedVariables.add(key) && - context.module.variablesAvailable) { - diagnostics.add( - Diagnostic( - code: 'writerside.variable.unresolved', - severity: DiagnosticSeverity.warning, - filePath: node.span.filePath, - args: {'name': name}, - sourceSpan: node.span, - ), - ); - } - return match.group(0)!; - }) - .replaceAll('%', '%'); + return _interpolateWritersideVariables( + value, + variables, + ignore: ignore, + resolving: resolving, + onUnresolved: (name) { + final key = '${node.span.filePath}:${node.span.startOffset}:$name'; + if (_unresolvedVariables.add(key) && + context.module.variablesAvailable) { + diagnostics.add( + Diagnostic( + code: 'writerside.variable.unresolved', + severity: DiagnosticSeverity.warning, + filePath: node.span.filePath, + args: {'name': name}, + sourceSpan: node.span, + ), + ); + } + }, + ); } String? titleFor(WritersideDocument document) { - final root = document.rootElement; + final root = document.format == WritersideDocumentFormat.xmlTopic + ? document.rootElement + : null; final titleScope = root?.children ?? document.nodes; final conditional = titleScope .whereType() @@ -1152,6 +1136,54 @@ class _ResolveState { } } +bool _matchesWritersideInstance( + String? condition, + WritersideModule module, + WritersideInstance? instance, +) { + final normalized = condition?.trim() ?? ''; + final negated = normalized.startsWith('!'); + final tokens = _tokens(negated ? normalized.substring(1) : normalized); + if (tokens.isEmpty) return true; + final matches = + instance != null && + tokens.any( + (token) => token.startsWith('@') + ? module.instanceGroups?.groups[token.substring(1)]?.instanceIds + .contains(instance.id) ?? + false + : token == instance.id, + ); + return negated ? !matches : matches; +} + +String _interpolateWritersideVariables( + String value, + Map variables, { + bool ignore = false, + Set resolving = const {}, + void Function(String)? onUnresolved, +}) { + if (ignore || !value.contains('%')) return value.replaceAll('%', '%'); + return value + .replaceAllMapped(RegExp(r'%(\\)?([A-Za-z_][A-Za-z0-9_.-]*)%'), (match) { + final name = match.group(2)!; + if (match.group(1) != null) return '%$name%'; + final replacement = variables[name]; + if (replacement != null && !resolving.contains(name)) { + return _interpolateWritersideVariables( + replacement, + variables, + resolving: {...resolving, name}, + onUnresolved: onUnresolved, + ); + } + onUnresolved?.call(name); + return match.group(0)!; + }) + .replaceAll('%', '%'); +} + Set _tokens(String? value) => { for (final token in value?.split(',') ?? const []) if (token.trim().isNotEmpty) token.trim(), diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index d4ae425a..8c92d76b 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -267,6 +267,7 @@ class TocNode extends WritersideTreeEntry { this.entries = const [], this.sourceTreePath, this.sourceTocPath, + this.sourceXmlPath, this.included = false, this.includeFrom, this.includeElementId, @@ -294,6 +295,10 @@ class TocNode extends WritersideTreeEntry { final List entries; final String? sourceTreePath; final List? sourceTocPath; + + /// Exact XML-element child indexes, including snippet ancestors. Navigation + /// only: this does not grant structural mutation rights to included nodes. + final List? sourceXmlPath; final bool included; final String? includeFrom; final String? includeElementId; diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index 6f35381d..dd1e057e 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -660,7 +660,19 @@ class WritersideTreeParser { XmlElement parent, { required List? tocParentPath, required List diagnostics, + Map? sourceSpans, }) { + if (sourceSpans == null) { + final authored = [parent, ...parent.descendants.whereType()]; + final semantic = const WritersideDocumentParser() + .parseXml(filePath: filePath, source: source) + .elements + .toList(); + sourceSpans = { + for (var i = 0; i < authored.length && i < semantic.length; i++) + authored[i]: semantic[i].span, + }; + } final result = []; var tocIndex = 0; for (final child in parent.childElements) { @@ -676,13 +688,16 @@ class WritersideTreeParser { child, tocPath: tocPath, diagnostics: diagnostics, + sourceSpans: sourceSpans, ), ); tocIndex++; case 'include': final from = _trimmedAttribute(child, 'from'); final elementId = _trimmedAttribute(child, 'element-id'); - final span = _elementSpan(filePath, source, 'include', elementId); + final span = + sourceSpans[child] ?? + _elementSpan(filePath, source, 'include', elementId); if (from == null || elementId == null) { diagnostics.add( Diagnostic( @@ -706,7 +721,9 @@ class WritersideTreeParser { ); case 'snippet': final id = _trimmedAttribute(child, 'id'); - final span = _elementSpan(filePath, source, 'snippet', id); + final span = + sourceSpans[child] ?? + _elementSpan(filePath, source, 'snippet', id); if (id == null) { diagnostics.add( Diagnostic( @@ -729,6 +746,7 @@ class WritersideTreeParser { child, tocParentPath: null, diagnostics: diagnostics, + sourceSpans: sourceSpans, ), span: span, ), @@ -744,6 +762,7 @@ class WritersideTreeParser { XmlElement element, { required List? tocPath, required List diagnostics, + required Map sourceSpans, }) { final topic = _trimmedAttribute(element, 'topic'); final reference = _trimmedAttribute(element, 'ref'); @@ -753,12 +772,9 @@ class WritersideTreeParser { element, 'target-for-accept-web-file-names', ); - final span = _elementSpan( - filePath, - source, - 'toc-element', - topic ?? reference, - ); + final span = + sourceSpans[element] ?? + _elementSpan(filePath, source, 'toc-element', topic ?? reference); if ((reference == null) != (referenceInstance == null)) { diagnostics.add( Diagnostic( @@ -791,7 +807,15 @@ class WritersideTreeParser { element, tocParentPath: tocPath, diagnostics: diagnostics, + sourceSpans: sourceSpans, ); + final xmlPath = []; + var current = element; + while (current.parent is XmlElement) { + final parent = current.parent! as XmlElement; + xmlPath.insert(0, parent.childElements.toList().indexOf(current)); + current = parent; + } return TocNode( topicFileName: topic, referenceTopicFileName: reference, @@ -814,6 +838,7 @@ class WritersideTreeParser { children: entries.whereType().toList(), sourceTreePath: filePath, sourceTocPath: tocPath, + sourceXmlPath: List.unmodifiable(xmlPath), span: span, ); } diff --git a/lib/src/writerside/writerside_template_service.dart b/lib/src/writerside/writerside_template_service.dart new file mode 100644 index 00000000..dc8e94cd --- /dev/null +++ b/lib/src/writerside/writerside_template_service.dart @@ -0,0 +1,443 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/atomic_file_writer.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/markdown_parser.dart'; +import 'writerside_document.dart'; +import 'writerside_model.dart'; +import 'writerside_parsers.dart'; + +final writersideTemplateServiceProvider = Provider( + (ref) => WritersideTemplateService(), +); + +/// One source variant, not a generated topic. The default collection groups +/// Markdown/XML variants by name; custom templates retain their own identity. +class WritersideTemplate { + const WritersideTemplate({ + required this.id, + required this.name, + required this.category, + required this.extension, + required this.source, + this.url, + this.description, + }); + final String id; + final String name; + final String category; + final String extension; + final String source; + final String? url; + final String? description; + WritersideTopicFormat get format => extension == 'topic' + ? WritersideTopicFormat.xml + : WritersideTopicFormat.markdown; + String get groupKey => category == 'default' ? '$category:$name' : id; + WritersideTemplate copyWith({ + String? name, + String? extension, + String? source, + }) => WritersideTemplate( + id: id, + name: name ?? this.name, + category: category, + extension: extension ?? this.extension, + source: source ?? this.source, + url: url, + description: description, + ); + Map toJson() => { + 'id': id, + 'name': name, + 'category': category, + 'extension': extension, + 'source': source, + if (url != null) 'url': url, + if (description != null) 'description': description, + }; + factory WritersideTemplate.fromJson(Map data) { + final result = WritersideTemplate( + id: data['id'] as String, + name: data['name'] as String, + category: data['category'] as String, + extension: data['extension'] as String, + source: data['source'] as String, + url: data['url'] as String?, + description: data['description'] as String?, + ); + if (result.id.isEmpty || + result.name.trim().isEmpty || + !['md', 'topic'].contains(result.extension) || + !['default', 'custom', 'tgdp'].contains(result.category)) { + throw const FormatException('Invalid Writerside template record'); + } + return result; + } +} + +class WritersideTemplateSnapshot { + const WritersideTemplateSnapshot(this.entries, this.revision); + final List entries; + final String? revision; +} + +class WritersideTemplateConflict implements Exception { + const WritersideTemplateConflict(); +} + +/// User-approved application-support integration. The project is never used as +/// a template database. Reads do not create a store, and a corrupt/stale store +/// cannot be silently replaced. Publication is atomic and serialized across +/// services in this isolate and cooperating application processes. +class WritersideTemplateService { + WritersideTemplateService({this.storagePath, this.loadBundledSource}); + final String? storagePath; + final Future Function()? loadBundledSource; + Future>? _bundled; + static final _writes = >{}; + + Future> bundled() => _bundled ??= () async { + final source = + await (loadBundledSource?.call() ?? + rootBundle.loadString('assets/writerside/templates.json')); + return List.unmodifiable( + (jsonDecode(source) as List).map( + (value) => WritersideTemplate.fromJson(value as Map), + ), + ); + }(); + + Future _path() async => + storagePath ?? + p.join( + (await getApplicationSupportDirectory()).path, + 'writerside', + 'templates.json', + ); + + Future read() async { + final path = await _path(); + final type = await FileSystemEntity.type(path, followLinks: false); + if (type == FileSystemEntityType.notFound) { + return const WritersideTemplateSnapshot([], null); + } + if (type != FileSystemEntityType.file) { + throw FileSystemException('Template store is not a regular file', path); + } + final raw = await File(path).readAsString(); + final data = jsonDecode(raw) as Map; + if (data['version'] != 1) { + throw const FormatException('Unsupported Writerside template store'); + } + final entries = (data['templates'] as List) + .map( + (item) => WritersideTemplate.fromJson(item as Map), + ) + .toList(); + _validate(entries); + return WritersideTemplateSnapshot(List.unmodifiable(entries), raw); + } + + Future> catalog() async { + final base = await bundled(); + final stored = await read(); + return effectiveCatalog(base, stored.entries); + } + + static List effectiveCatalog( + List base, + List stored, + ) => [ + for (final template in base) + stored.where((entry) => entry.id == template.id).firstOrNull ?? template, + ...stored.where((entry) => entry.category == 'custom'), + ]; + + Future save( + WritersideTemplateSnapshot expected, + List entries, + ) async { + _validate(entries); + await _locked(() async { + if ((await read()).revision != expected.revision) { + throw const WritersideTemplateConflict(); + } + await _publish(entries); + }); + } + + Future saveTopic({ + required WritersideTopic topic, + required String? contextualTitle, + }) => _locked(() async { + final snapshot = await read(); + final name = uniqueName( + 'Writerside_${p.basenameWithoutExtension(topic.fileName)}', + snapshot.entries.map((entry) => entry.name), + ); + final template = WritersideTemplate( + id: const Uuid().v4(), + name: name, + category: 'custom', + extension: topic.format == WritersideTopicFormat.xml ? 'topic' : 'md', + source: prepareSavedSource( + source: topic.document.source, + title: contextualTitle, + id: topic.format == WritersideTopicFormat.xml + ? topic.document.rootElement?.attributes['id'] + : null, + format: topic.format, + ), + ); + await _publish([...snapshot.entries, template]); + return template; + }); + + static String uniqueName(String base, Iterable names) { + final occupied = names.toSet(); + var name = base; + for (var index = 1; occupied.contains(name); index++) { + name = '$base ($index)'; + } + return name; + } + + static void _validate(List entries) { + final ids = {}; + final names = {}; + for (final entry in entries) { + WritersideTemplate.fromJson(entry.toJson()); + if (entry.category == 'tgdp' || + !ids.add(entry.id) || + !names.add('${entry.name}.${entry.extension}')) { + throw const FormatException('Duplicate or invalid user template'); + } + } + } + + Future _publish(List entries) async { + _validate(entries); + await const AtomicFileWriter().writeBytes( + await _path(), + utf8.encode( + jsonEncode({ + 'version': 1, + 'templates': entries.map((entry) => entry.toJson()).toList(), + }), + ), + overwrite: true, + ); + } + + Future _locked(Future Function() action) async { + final path = p.normalize(p.absolute(await _path())); + final previous = _writes[path] ?? Future.value(); + final completed = Completer(); + _writes[path] = completed.future; + await previous; + RandomAccessFile? lock; + try { + await Directory(p.dirname(path)).create(recursive: true); + final lockPath = '$path.lock'; + final type = await FileSystemEntity.type(lockPath, followLinks: false); + if (type != FileSystemEntityType.file && + type != FileSystemEntityType.notFound) { + throw FileSystemException('Unsafe template lock', lockPath); + } + lock = await File(lockPath).open(mode: FileMode.append); + await lock.lock(FileLock.blockingExclusive); + return await action(); + } finally { + await lock?.close(); + completed.complete(); + if (identical(_writes[path], completed.future)) _writes.remove(path); + } + } + + /// Installed LocalFileTemplateProvider uses simultaneous literal replacement, + /// NOT Velocity evaluation. Values containing another token are not expanded. + static String generate( + WritersideTemplate template, { + required String title, + required String id, + }) { + if (template.category == 'tgdp') return _adaptTgdp(template, title); + final escapedTitle = template.format == WritersideTopicFormat.xml + ? _escapeXml10(title) + : title; + return template.source.replaceAllMapped( + RegExp(r'\$\{(TITLE|ID)\}'), + (match) => match[1] == 'TITLE' ? escapedTitle : id, + ); + } + + static String filenameFromTitle(String title) => title + .replaceAll(RegExp(r'[!-/:-@\[-`{-~ ]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + + /// Faithful to TemplateFromTopicAction, including its first-match substring + /// replacement and Markdown pattern order. It does not rewrite every XML + /// attribute semantically, invent placeholders, or change the source topic. + static String prepareSavedSource({ + required String source, + String? title, + String? id, + required WritersideTopicFormat format, + }) { + var result = source; + for (final (variable, value) in [('title', title), ('id', id)]) { + if (value == null) continue; + final escaped = RegExp.escape(value); + final patterns = format == WritersideTopicFormat.xml + ? [variable + r'(\s+)?=(\s+)?"' + escaped] + : [ + r'\((\s+)?' + + variable + + r'(\s+)?:(\s+)?$' + + escaped + + r'(\s+)?\)', + r'#(\s+)?' + escaped, + r'(\s+)?---(\s+)?(name|title):(\s+)?' + escaped + r'(\s+)?---', + escaped + r'(\s+)?===', + ]; + for (final pattern in patterns) { + final match = RegExp(pattern).firstMatch(result); + if (match == null) continue; + final token = '\${${variable.toUpperCase()}}'; + result = result.replaceAll( + match[0]!, + format == WritersideTopicFormat.xml + ? '$variable="$token' + : '# $token', + ); + break; + } + } + return result; + } + + static String _escapeXml10(String text) => + String.fromCharCodes( + text.runes.where( + (rune) => + rune == 9 || + rune == 10 || + rune == 13 || + (rune >= 0x20 && rune <= 0xd7ff) || + (rune >= 0xe000 && rune <= 0xfffd) || + (rune >= 0x10000 && rune <= 0x10ffff), + ), + ) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAllMapped( + RegExp('[\u007f-\u0084\u0086-\u009f]'), + (match) => '&#${match[0]!.codeUnitAt(0)};', + ); + + static String _adaptTgdp(WritersideTemplate template, String title) { + var source = template.source.replaceAllMapped(RegExp(r'\{[^}]+\}'), ( + match, + ) { + final text = match[0]!; + return RegExp(r'^\{\s*([\w-]+="[^"]*"\s*)+\}$').hasMatch(text.trim()) + ? text + : '{(${text.substring(1, text.length - 1)})}'; + }); + // Use the Markdown parser's source ranges, not a search for heading text + // that might also occur inside a code fence. + final parsed = const MarkdownParser().parse( + filePath: 'template.md', + source: source, + validateLocalReferences: false, + ); + final edits = <({int start, int end, String value})>[]; + final occupied = {}; + for (final (destination, span) in [ + for (final link in parsed.links) (link.destination, link.span), + for (final image in parsed.images) (image.destination, image.span), + ]) { + final uri = Uri.tryParse(destination); + if (uri == null || uri.hasScheme || destination.startsWith('#')) { + continue; + } + final raw = source.substring(span.startOffset, span.endOffset); + // Reference spans identify blocks, not individual destinations. Locate + // only inline destination syntax, never matching label/prose text, and + // consume each occurrence once when a block repeats a link or image. + final code = RegExp(r'(`+).*?\1', dotAll: true).allMatches(raw).toList(); + final matches = RegExp( + r'\]\([ \t]*)\s])', + ).allMatches(raw); + int? offset; + for (final match in matches) { + final candidate = match.end - destination.length; + if (!occupied.contains(span.startOffset + candidate) && + !code.any( + (range) => match.start >= range.start && match.start < range.end, + )) { + offset = candidate; + break; + } + } + if (offset == null) continue; + occupied.add(span.startOffset + offset); + edits.add(( + start: span.startOffset + offset, + end: span.startOffset + offset + destination.length, + value: Uri.parse(template.url!).resolveUri(uri).toString(), + )); + } + final topic = const WritersideTopicParser().parseMarkdown( + filePath: 'template.md', + source: source, + topicsRoot: '.', + ); + final heading = topic.document.nodes + .whereType() + .where( + (node) => + node.block.kind == BusyBlockKind.heading && + node.block.attributes['level'] == '1', + ) + .firstOrNull; + if (heading != null) { + edits.removeWhere( + (edit) => + edit.start >= heading.span.startOffset && + edit.start < heading.span.endOffset, + ); + final raw = source.substring( + heading.span.startOffset, + heading.span.endOffset, + ); + edits.add(( + start: heading.span.startOffset, + end: heading.span.endOffset, + value: + '# $title${raw.endsWith('\r\n') + ? '\r\n' + : raw.endsWith('\n') + ? '\n' + : ''}', + )); + } + edits.sort((a, b) => b.start.compareTo(a.start)); + for (final edit in edits) { + source = source.replaceRange(edit.start, edit.end, edit.value); + } + return source; + } +} diff --git a/lib/src/writerside/writerside_title_editor.dart b/lib/src/writerside/writerside_title_editor.dart new file mode 100644 index 00000000..58fa6b4a --- /dev/null +++ b/lib/src/writerside/writerside_title_editor.dart @@ -0,0 +1,279 @@ +import 'package:xml/xml.dart'; + +import '../core/busymark_exception.dart'; +import '../markdown/busymark_document.dart'; +import 'writerside_document.dart'; +import 'writerside_document_parser.dart'; +import 'writerside_model.dart'; +import 'writerside_toc_navigation.dart'; +import 'writerside_topic_creator.dart'; + +class WritersideTitleEdit { + const WritersideTitleEdit({this.title, this.instanceTitle, this.tocTitle}); + + /// Null leaves the source untouched; blank override values remove overrides. + final String? title; + final String? instanceTitle; + final String? tocTitle; +} + +class WritersideTitleEditResult { + const WritersideTitleEditResult({ + required this.topicSource, + required this.treeSource, + }); + final String topicSource; + final String treeSource; +} + +/// Prepares source-range edits. Publication belongs to the workspace's guarded +/// multi-file transaction, so a dialog can never publish half a title change. +class WritersideTitleEditor { + const WritersideTitleEditor(); + + WritersideTitleEditResult prepare({ + required WritersideTopic topic, + required String instanceId, + required String treePath, + required String treeSource, + required List tocPath, + required WritersideTocNodeIdentity tocIdentity, + required WritersideTitleEdit edit, + }) { + final document = topic.document; + if (!document.isWellFormed) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + if (writersideTocSourceSpan( + filePath: treePath, + source: treeSource, + path: tocPath, + identity: tocIdentity, + ) == + null) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + final source = document.source; + final changes = <({int start, int end, String text})>[]; + final title = edit.title; + if (title != null) { + if (title.trim().isEmpty || + title.contains('\n') || + title.contains('\r')) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + if (topic.format == WritersideTopicFormat.xml) { + final root = document.rootElement!; + final span = root.attributeSpans['title']; + if (span != null) { + changes.add(( + start: span.startOffset, + end: span.endOffset, + text: _attribute(title), + )); + } else { + final offset = + source.indexOf(root.qualifiedName, root.span.startOffset) + + root.qualifiedName.length; + changes.add(( + start: offset, + end: offset, + text: ' title="${_attribute(title)}"', + )); + } + } else { + // Front-matter titles are a BusyMark Markdown extension, not the + // Writerside H1 representation edited by this action. Do not change an + // H1 while leaving a higher-precedence authored title unchanged. + if (document.nodes.whereType().any( + (node) => + node.block.kind == BusyBlockKind.frontMatter && + node.block.attributes.containsKey('title'), + )) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final heading = document.nodes + .whereType() + .where( + (node) => + node.block.kind == BusyBlockKind.heading && + node.block.attributes['level'] == '1', + ) + .firstOrNull; + if (heading == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + // Only the parsed top-level heading is replaced, never fenced text or + // another heading with the same spelling. Preserve explicit attributes. + final raw = source.substring( + heading.span.startOffset, + heading.span.endOffset, + ); + final attributes = RegExp( + r'\s+(\{[^\r\n]*\})\s*$', + ).firstMatch(raw.trimRight())?.group(1); + final ending = raw.endsWith('\r\n') + ? '\r\n' + : raw.endsWith('\n') + ? '\n' + : ''; + changes.add(( + start: heading.span.startOffset, + end: heading.span.endOffset, + text: + '# ${_markdownText(title)}${attributes == null ? '' : ' $attributes'}$ending', + )); + } + } + if (edit.instanceTitle != null) { + final scope = topic.format == WritersideTopicFormat.xml + ? document.rootElement!.children + : document.nodes; + final overrides = scope + .whereType() + .where( + (node) => + node.name == 'title' && + node.attributes['instance'] == instanceId, + ) + .toList(); + if (overrides.length > 1) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final value = edit.instanceTitle!; + final replacement = value.trim().isEmpty + ? '' + : XmlElement( + XmlName.parts('title'), + [XmlAttribute(XmlName.parts('instance'), instanceId)], + [XmlText(value)], + ).toXmlString(); + if (overrides.isNotEmpty) { + final node = overrides.single; + changes.add(( + start: node.span.startOffset, + end: node.span.endOffset, + text: replacement, + )); + } else if (replacement.isNotEmpty) { + final offset = topic.format == WritersideTopicFormat.xml + ? _openingEnd(source, document.rootElement!.span.startOffset) + 1 + : document.nodes + .whereType() + .where( + (node) => + node.block.kind == BusyBlockKind.heading && + node.block.attributes['level'] == '1', + ) + .firstOrNull + ?.span + .endOffset ?? + 0; + final newline = source.contains('\r\n') ? '\r\n' : '\n'; + if (topic.format == WritersideTopicFormat.xml && + source[offset - 2] == '/') { + changes.add(( + start: offset - 2, + end: offset, + text: + '>$newline$replacement$newline', + )); + } else { + changes.add(( + start: offset, + end: offset, + text: '$newline$replacement$newline', + )); + } + } + } + changes.sort((a, b) => b.start.compareTo(a.start)); + var topicSource = source; + for (final change in changes) { + topicSource = topicSource.replaceRange( + change.start, + change.end, + change.text, + ); + } + + var resultTree = treeSource; + if (edit.tocTitle != null) { + final span = writersideTocSourceSpan( + filePath: treePath, + source: treeSource, + path: tocPath, + identity: tocIdentity, + ); + if (span == null) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } + final fragment = treeSource.substring(span.startOffset, span.endOffset); + final parsed = const WritersideDocumentParser().parseXml( + filePath: treePath, + source: fragment, + ); + final root = parsed.rootElement!; + final valueSpan = root.attributeSpans['toc-title']; + final value = edit.tocTitle!; + if (valueSpan != null) { + if (value.trim().isEmpty) { + final attributeStart = fragment.lastIndexOf( + 'toc-title', + valueSpan.startOffset, + ); + resultTree = treeSource.replaceRange( + span.startOffset + attributeStart, + span.startOffset + valueSpan.endOffset + 1, + '', + ); + } else { + resultTree = treeSource.replaceRange( + span.startOffset + valueSpan.startOffset, + span.startOffset + valueSpan.endOffset, + _attribute(value), + ); + } + } else if (value.trim().isNotEmpty) { + final offset = span.startOffset + ' text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + static String _markdownText(String text) => text + .replaceAll('&', '&') + .replaceAllMapped( + RegExp(r'[\\`*_\[\]<>#{}]'), + (match) => '\\${match[0]}', + ); + + static int _openingEnd(String source, int start) { + int? quote; + for (var i = start; i < source.length; i++) { + final character = source.codeUnitAt(i); + if (quote != null) { + if (character == quote) quote = null; + } else if (character == 34 || character == 39) { + quote = character; + } else if (character == 62) { + return i; + } + } + throw const BusyMarkException('writerside.toc.path-invalid'); + } +} diff --git a/lib/src/writerside/writerside_toc_editor.dart b/lib/src/writerside/writerside_toc_editor.dart index 016b936b..ab2eeed4 100644 --- a/lib/src/writerside/writerside_toc_editor.dart +++ b/lib/src/writerside/writerside_toc_editor.dart @@ -58,21 +58,28 @@ class WritersideTocBatchMoveRequest { required this.placement, this.referencePath, this.referenceIdentity, + this.beforeReference = false, }); final List sources; final WritersideTopicCreatePlacement placement; final List? referencePath; final WritersideTocNodeIdentity? referenceIdentity; + final bool beforeReference; } class WritersideTocMutationResult { - const WritersideTocMutationResult({required this.treePath, this.entryPath}); + const WritersideTocMutationResult({ + required this.treePath, + this.entryPath, + this.entryPaths = const [], + }); final String treePath; /// The moved entry's structural path after a move, or `null` after removal. final List? entryPath; + final List> entryPaths; } class WritersideTocRemovalRequest { @@ -85,6 +92,21 @@ class WritersideTocRemovalRequest { final WritersideTocNodeIdentity? expectedIdentity; } +class WritersideTocInsertRequest { + const WritersideTocInsertRequest({ + required this.placement, + this.referencePath, + this.referenceIdentity, + this.topicReference, + this.tocTitle, + }); + final WritersideTopicCreatePlacement placement; + final List? referencePath; + final WritersideTocNodeIdentity? referenceIdentity; + final String? topicReference; + final String? tocTitle; +} + /// Performs structural Writerside TOC mutations inside a guarded module root. class WritersideTocEditor { const WritersideTocEditor({ @@ -98,10 +120,166 @@ class WritersideTocEditor { /// concurrent writer without weakening the publication guard. final Future Function(String treePath)? _beforeTreePublish; + Future insertElement( + WritersideTocEditTarget target, + WritersideTocInsertRequest request, { + Future Function()? validateBeforePublish, + }) async { + if ((request.topicReference == null) == (request.tocTitle == null) || + (request.topicReference ?? request.tocTitle)!.trim().isEmpty) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final session = await _load(target); + XmlElement? reference; + if (request.placement != WritersideTopicCreatePlacement.root) { + final path = request.referencePath; + if (path == null || request.referenceIdentity == null) { + throw const BusyMarkException('writerside.toc.destination-required'); + } + _validatePath(path, role: 'destination'); + reference = _elementAtPath(session.root, path, role: 'destination'); + if (!request.referenceIdentity!.matches(reference)) { + throw _invalidPath(path, role: 'destination'); + } + } else if (request.referencePath != null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final element = XmlElement(XmlName.parts('toc-element'), [ + if (request.topicReference != null) + XmlAttribute(XmlName.parts('topic'), request.topicReference!), + if (request.tocTitle != null) + XmlAttribute(XmlName.parts('toc-title'), request.tocTitle!), + ]); + switch (request.placement) { + case WritersideTopicCreatePlacement.root: + session.root.children.add(element); + case WritersideTopicCreatePlacement.sibling: + _insertAfter(reference!, element); + case WritersideTopicCreatePlacement.child: + reference!.children.add(element); + } + await _write(session, validateBeforePublish: validateBeforePublish); + return WritersideTocMutationResult( + treePath: session.treePath, + entryPath: _pathOfElement(session.root, element), + ); + } + + /// Build 2026.07.8925 enables Group only for at least two siblings. + Future groupElements( + WritersideTocEditTarget target, { + required List entries, + required String title, + Future Function()? validateBeforePublish, + }) async { + if (entries.length < 2 || title.isEmpty) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final session = await _load(target); + final elements = []; + for (final entry in entries) { + _validatePath(entry.sourcePath, role: 'source'); + final element = _elementAtPath( + session.root, + entry.sourcePath, + role: 'source', + ); + if (entry.sourceIdentity == null || + !entry.sourceIdentity!.matches(element) || + elements.contains(element) || + (elements.isNotEmpty && + !identical(elements.first.parent, element.parent))) { + throw _invalidPath(entry.sourcePath, role: 'source'); + } + elements.add(element); + } + final parent = elements.first.parent! as XmlElement; + elements.sort( + (a, b) => + parent.children.indexOf(a).compareTo(parent.children.indexOf(b)), + ); + final group = XmlElement(XmlName.parts('toc-element'), [ + XmlAttribute(XmlName.parts('toc-title'), title), + ]); + parent.children.insert(parent.children.indexOf(elements.first), group); + for (final element in elements) { + parent.children.remove(element); + group.children.add(element); + } + await _write(session, validateBeforePublish: validateBeforePublish); + return WritersideTocMutationResult( + treePath: session.treePath, + entryPath: _pathOfElement(session.root, group), + ); + } + + Future reorderChildren( + WritersideTocEditTarget target, { + required List nodePath, + required WritersideTocNodeIdentity identity, + required List permutation, + Future Function()? validateBeforePublish, + }) async { + _validatePath(nodePath, role: 'source'); + final session = await _load(target); + 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. + if (children.any((element) => element.name.local != 'toc-element') || + permutation.length != children.length || + permutation.toSet().length != children.length || + permutation.any((index) => index < 0 || index >= children.length)) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final slots = [ + for (var i = 0; i < parent.children.length; i++) + if (parent.children[i] is XmlElement) i, + ]; + for (final index in slots.reversed) { + parent.children.removeAt(index); + } + for (var i = 0; i < slots.length; i++) { + parent.children.insert(slots[i], children[permutation[i]]); + } + await _write(session, validateBeforePublish: validateBeforePublish); + return WritersideTocMutationResult( + treePath: session.treePath, + entryPath: nodePath, + ); + } + + Future setHomePage( + WritersideTocEditTarget target, { + required List nodePath, + required WritersideTocNodeIdentity expectedIdentity, + required String topicReference, + Future Function()? validateBeforePublish, + }) async { + _validatePath(nodePath, role: 'source'); + final session = await _load(target); + final element = _elementAtPath(session.root, nodePath, role: 'source'); + if (!expectedIdentity.matches(element) || + element.getAttribute('topic') != topicReference || + topicReference.isEmpty || + element.getAttribute('href') != null || + session.root.getAttribute('is-library') == 'true') { + throw _invalidPath(nodePath, role: 'source'); + } + session.root.setAttribute('start-page', topicReference); + await _write(session, validateBeforePublish: validateBeforePublish); + return WritersideTocMutationResult( + treePath: session.treePath, + entryPath: nodePath, + ); + } + Future moveSubtree( WritersideTocEditTarget target, - WritersideTocMoveRequest request, - ) async { + WritersideTocMoveRequest request, { + Future Function()? validateBeforePublish, + }) async { _validatePath(request.sourcePath, role: 'source'); final referencePath = request.referencePath; if (request.placement == WritersideTopicCreatePlacement.root) { @@ -158,7 +336,7 @@ class WritersideTocEditor { if (movedPath == null) { throw const BusyMarkException('writerside.toc.move-invalid-target'); } - await _write(session); + await _write(session, validateBeforePublish: validateBeforePublish); return WritersideTocMutationResult( treePath: session.treePath, entryPath: movedPath, @@ -168,8 +346,13 @@ class WritersideTocEditor { /// Moves several complete subtrees as one ordered group. Future moveSubtrees( WritersideTocEditTarget target, - WritersideTocBatchMoveRequest request, - ) async { + WritersideTocBatchMoveRequest request, { + Future Function()? validateBeforePublish, + }) async { + if (request.beforeReference && + request.placement != WritersideTopicCreatePlacement.sibling) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } if (request.sources.isEmpty) { throw const BusyMarkException('writerside.toc.path-invalid'); } @@ -249,7 +432,10 @@ class WritersideTocEditor { if (index < 0) { throw const BusyMarkException('writerside.toc.move-invalid-target'); } - parent.children.insertAll(index + 1, elements); + parent.children.insertAll( + index + (request.beforeReference ? 0 : 1), + elements, + ); case WritersideTopicCreatePlacement.child: reference!.children.addAll(elements); } @@ -257,10 +443,13 @@ class WritersideTocEditor { if (firstPath == null) { throw const BusyMarkException('writerside.toc.move-invalid-target'); } - await _write(session); + await _write(session, validateBeforePublish: validateBeforePublish); return WritersideTocMutationResult( treePath: session.treePath, entryPath: firstPath, + entryPaths: [ + for (final element in elements) _pathOfElement(session.root, element)!, + ], ); } @@ -272,6 +461,7 @@ class WritersideTocEditor { WritersideTocEditTarget target, List entryPath, { WritersideTocNodeIdentity? expectedIdentity, + Future Function()? validateBeforePublish, }) async { _validatePath(entryPath, role: 'source'); final session = await _load(target); @@ -294,7 +484,7 @@ class WritersideTocEditor { parent.children.removeAt(rawIndex); parent.children.insertAll(rawIndex, promotedChildren); - await _write(session); + await _write(session, validateBeforePublish: validateBeforePublish); return WritersideTocMutationResult(treePath: session.treePath); } @@ -305,8 +495,9 @@ class WritersideTocEditor { /// the tree snapshot the user selected. Future removeEntries( WritersideTocEditTarget target, - List requests, - ) async { + List requests, { + Future Function()? validateBeforePublish, + }) async { if (requests.isEmpty) { throw const BusyMarkException('writerside.toc.path-invalid'); } @@ -358,7 +549,7 @@ class WritersideTocEditor { parent.children.removeAt(rawIndex); parent.children.insertAll(rawIndex, promotedChildren); } - await _write(session); + await _write(session, validateBeforePublish: validateBeforePublish); return WritersideTocMutationResult(treePath: session.treePath); } @@ -404,7 +595,10 @@ class WritersideTocEditor { ); } - Future _write(_TocEditSession session) async { + Future _write( + _TocEditSession session, { + Future Function()? validateBeforePublish, + }) async { final checkedTree = await _treePath(session.anchor, session.treePath); await _ensureTreeUnchanged(session, checkedTree); final targetStat = await File(checkedTree.path).stat(); @@ -416,6 +610,7 @@ class WritersideTocEditor { ); await _copyFileMode(targetStat, temporary); await _beforeTreePublish?.call(checkedTree.path); + await validateBeforePublish?.call(); final publishTarget = await _treePath(session.anchor, session.treePath); await _ensureTreeUnchanged(session, publishTarget); diff --git a/lib/src/writerside/writerside_toc_navigation.dart b/lib/src/writerside/writerside_toc_navigation.dart new file mode 100644 index 00000000..ed60ec76 --- /dev/null +++ b/lib/src/writerside/writerside_toc_navigation.dart @@ -0,0 +1,91 @@ +import 'package:xml/xml.dart'; + +import '../core/source_span.dart'; +import 'writerside_document.dart'; +import 'writerside_document_parser.dart'; +import 'writerside_model.dart'; +import 'writerside_topic_creator.dart'; + +/// Installed 2026.07.8925 uses PLAIN_BFS.first, not depth-first traversal, +/// when explicitly synchronizing the current instance with a topic editor. +List? writersideTocBreadthFirstPath( + List roots, + bool Function(TocNode) matches, +) { + final queue = <({TocNode node, List path})>[ + for (var i = 0; i < roots.length; i++) (node: roots[i], path: [i]), + ]; + for (var index = 0; index < queue.length; index++) { + final item = queue[index]; + if (matches(item.node)) return item.path; + for (var child = 0; child < item.node.children.length; child++) { + queue.add((node: item.node.children[child], path: [...item.path, child])); + } + } + return null; +} + +/// The installed tree-editor synchronization action reads the nearest XML +/// tag's explicit id and passes it to the current tree's topic-key matcher. +/// An element without an id does not initiate synchronization in that build. +String? writersideTocEditorSyncKey(String filePath, String source, int offset) { + final document = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!document.isWellFormed) return null; + WritersideElementNode? nearest; + for (final element in document.elements) { + if (element.span.startOffset <= offset && + offset < element.span.endOffset && + (nearest == null || + element.span.endOffset - element.span.startOffset < + nearest.span.endOffset - nearest.span.startOffset)) { + nearest = element; + } + } + return nearest?.name == 'toc-element' ? nearest?.attributes['id'] : null; +} + +/// Re-resolves an exact authored element against the buffer being revealed. +/// A changed identity is rejected instead of navigating to a different node. +SourceSpan? writersideTocSourceSpan({ + required String filePath, + required String source, + required List path, + required WritersideTocNodeIdentity identity, + bool xmlChildren = false, +}) { + if (path.isEmpty) return null; + final document = const WritersideDocumentParser().parseXml( + filePath: filePath, + source: source, + ); + if (!document.isWellFormed || + document.rootElement?.name != 'instance-profile') { + return null; + } + List tocChildren(WritersideElementNode node) => node + .children + .whereType() + .where((child) => xmlChildren || child.name == 'toc-element') + .toList(); + var children = tocChildren(document.rootElement!); + for (var depth = 0; depth < path.length; depth++) { + final index = path[depth]; + if (index < 0 || index >= children.length) return null; + final node = children[index]; + if (depth == path.length - 1) { + try { + final element = XmlDocument.parse( + source.substring(node.span.startOffset, node.span.endOffset), + ).rootElement; + return identity.matches(element) ? node.span : null; + } on XmlParserException { + return null; + } + } + children = tocChildren(node); + } + return null; +} diff --git a/lib/src/writerside/writerside_toc_presentation.dart b/lib/src/writerside/writerside_toc_presentation.dart new file mode 100644 index 00000000..936e9253 --- /dev/null +++ b/lib/src/writerside/writerside_toc_presentation.dart @@ -0,0 +1,106 @@ +import 'package:path/path.dart' as p; + +import 'writerside_document_resolver.dart'; +import 'writerside_model.dart'; + +/// Navigation text and status are independent of source/clipboard identity. +class WritersideTocPresentation { + const WritersideTocPresentation({ + required this.label, + required this.tooltip, + required this.topic, + required this.home, + required this.empty, + required this.included, + required this.external, + required this.unresolved, + required this.hidden, + }); + + final String label; + final String tooltip; + final WritersideTopic? topic; + final bool home; + final bool empty; + final bool included; + final bool external; + final bool unresolved; + final bool hidden; +} + +/// One cache per immutable module/instance snapshot. Uses the same conditional +/// and variable resolution as preview and export, including origin modules. +class WritersideTocPresenter { + WritersideTocPresenter({ + required this.module, + required this.instance, + this.modulesByOrigin = const {}, + }); + + final WritersideModule module; + final WritersideInstance instance; + final Map modulesByOrigin; + final _titles = {}; + final _rows = {}; + + WritersideTocPresentation present( + TocNode node, + ) => _rows.putIfAbsent(node, () { + final owner = node.origin == null ? module : modulesByOrigin[node.origin]; + final reference = node.topicReference; + final topic = reference == null ? null : owner?.topicByReference(reference); + final title = topic == null || owner == null + ? null + : _titles.putIfAbsent( + topic, + () => const WritersideDocumentResolver() + .resolve( + topic.document, + WritersideResolveContext( + module: owner, + topic: topic, + instance: instance, + modulesByOrigin: modulesByOrigin, + ), + ) + .title, + ); + final unresolved = node.includeResolutionError != null; + final external = node.href != null; + final homeTopic = instance.startPage == null + ? null + : module.topicByReference(instance.startPage!); + return WritersideTocPresentation( + label: node.tocTitle == null + ? title ?? reference ?? node.href ?? node.id ?? '' + : const WritersideDocumentResolver().resolveNavigationTitle( + node.tocTitle!, + module: owner ?? module, + instance: instance, + topic: topic, + ), + tooltip: [ + if (external) + node.href! + else if (topic != null) + topic.fileName + else if (reference != null) + reference, + if (node.included) + node.includeFrom ?? + p.basename(node.sourceTreePath ?? node.span.filePath), + if (unresolved) node.includeResolutionError!, + ].join('\n'), + topic: topic, + home: + topic != null && + homeTopic != null && + p.equals(topic.filePath, homeTopic.filePath), + empty: reference == null && !external && !unresolved && !node.included, + included: node.included && !unresolved, + external: external, + unresolved: unresolved, + hidden: node.hidden, + ); + }); +} diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 58ef5f12..018a28e1 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -177,8 +177,10 @@ class WritersideTopicCreator { Future create( WritersideTopicCreateTarget target, - WritersideTopicCreateRequest request, - ) async { + WritersideTopicCreateRequest request, { + String? initialSource, + Future Function()? validateBeforePublish, + }) async { final rootPath = normalizePath(target.rootPath); final CanonicalPathAnchor rootAnchor; try { @@ -225,6 +227,26 @@ class WritersideTopicCreator { if (title.isEmpty) { throw const BusyMarkException('writerside.topic.title-required'); } + if (initialSource != null && request.format == WritersideTopicFormat.xml) { + // A customized template is untrusted authoring input. Validate it before + // any directory/file publication, just like the filename and TOC identity. + XmlDocument templateDocument; + try { + templateDocument = XmlDocument.parse(initialSource); + if (templateDocument.rootElement.name.local != 'topic') { + throw const FormatException('Expected a topic root'); + } + } on Object { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final sourceId = templateDocument.rootElement.getAttribute('id'); + if (sourceId != null && target.existingTopicIds.contains(sourceId)) { + throw BusyMarkException( + 'writerside.topic.id-exists', + args: {'topicId': sourceId}, + ); + } + } final topicResolution = await _topicTargetPath( rootAnchor, @@ -257,9 +279,11 @@ class WritersideTopicCreator { if (createdTopicsRoot.type != FileSystemEntityType.directory) { throw const BusyMarkException('writerside.topic.topics-root-unsafe'); } - final topicSource = _topicSource(request.format, topicId, title); + final topicSource = + initialSource ?? _topicSource(request.format, topicId, title); var topicCreated = false; try { + await validateBeforePublish?.call(); await _writeNewFile(rootAnchor, topicPath, topicSource); topicCreated = true; await _replaceTreeAtomically( @@ -267,6 +291,7 @@ class WritersideTopicCreator { treePath, updatedTree, expectedCurrentSource: treeSource, + validateBeforePublish: validateBeforePublish, ); } on Object { if (topicCreated) { @@ -534,6 +559,7 @@ class WritersideTopicCreator { String path, String source, { required String expectedCurrentSource, + Future Function()? validateBeforePublish, }) async { final tree = await _treePath(anchor, path); await _ensureTreeUnchanged(tree, expectedCurrentSource); @@ -543,6 +569,7 @@ class WritersideTopicCreator { await temporary.writeAsString(source, flush: true); await _copyFileMode(targetStat, temporary); await _beforeTreePublish?.call(tree.path); + await validateBeforePublish?.call(); final publishTarget = await _treePath(anchor, path); await _ensureTreeUnchanged(publishTarget, expectedCurrentSource); diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index 0af6612b..5bdec2d2 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -338,8 +338,9 @@ class WritersideTopicRemovalService { } Future apply( - WritersideTopicRemovalRequest request, - ) async { + WritersideTopicRemovalRequest request, { + void Function(Iterable)? validateBeforeCommit, + }) async { final analysis = request.analysis; final snapshot = await _snapshot(analysis.moduleRoot, analysis.topicPath); if (snapshot.fingerprint != analysis.fingerprint) { @@ -615,6 +616,11 @@ class WritersideTopicRemovalService { ), ]; final applied = <_SourceEdit>[]; + final affected = { + ...edits.map((edit) => edit.path), + analysis.topicPath, + }; + void validate() => validateBeforeCommit?.call(affected); try { for (final edit in edits) { await _replaceAtomically( @@ -622,15 +628,18 @@ class WritersideTopicRemovalService { edit.path, edit.updated, expected: edit.original, + validateBeforeCommit: validate, ); applied.add(edit); } await _ensureExpectedState(snapshot, edits); + validate(); if (analysis.mode == WritersideTopicRemovalMode.safeDeleteFile) { await _deleteExpectedFile( snapshot.anchor, snapshot.topic.filePath, expected: snapshot.sources[snapshot.topic.filePath]!, + validateBeforeCommit: validate, ); } } on Object catch (error, stackTrace) { @@ -1487,6 +1496,7 @@ class WritersideTopicRemovalService { String path, String source, { required String expected, + void Function()? validateBeforeCommit, }) async { final resolution = await resolveAnchoredPath( anchor, @@ -1540,6 +1550,7 @@ class WritersideTopicRemovalService { args: {'path': path}, ); } + validateBeforeCommit?.call(); final atomicApi = LinuxAtomicFileApi.instance; if (atomicApi.isAvailable) { final exchangeError = atomicApi.exchange(temporary.path, checked.path); @@ -1603,6 +1614,7 @@ class WritersideTopicRemovalService { CanonicalPathAnchor anchor, String path, { required String expected, + void Function()? validateBeforeCommit, }) async { final resolution = await resolveAnchoredPath( anchor, @@ -1630,6 +1642,7 @@ class WritersideTopicRemovalService { continue; } try { + validateBeforeCommit?.call(); quarantined = await File(resolution.path).rename(candidate.path); break; } on FileSystemException { @@ -1659,6 +1672,7 @@ class WritersideTopicRemovalService { args: {'path': path}, ); } + validateBeforeCommit?.call(); await quarantined.delete(); quarantined = null; } on Object catch (error, stackTrace) { diff --git a/lib/src/writerside/writerside_tree_resolver.dart b/lib/src/writerside/writerside_tree_resolver.dart index d9df4d41..209b62f3 100644 --- a/lib/src/writerside/writerside_tree_resolver.dart +++ b/lib/src/writerside/writerside_tree_resolver.dart @@ -394,6 +394,7 @@ class WritersideTreeResolver { entries: node.entries, sourceTreePath: sourceTreePath, sourceTocPath: node.sourceTocPath, + sourceXmlPath: node.sourceXmlPath, included: included, span: node.span, ); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 5c34a766..c20919cf 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2504,72 +2504,29 @@ static GIcon* create_native_menu_icon(const gchar* icon_name, FlValue* entry) { return g_themed_icon_new(icon_name); } -static void show_native_menu(NativeMenuHandlerData* data, - FlMethodCall* method_call, - FlValue* args) { - if (data->view == nullptr || !gtk_widget_get_realized(data->view) || - gtk_widget_get_window(data->view) == nullptr) { - fl_method_call_respond_error(method_call, "unavailable", - "The native menu host is unavailable.", - nullptr, nullptr); - return; - } - GdkRectangle anchor = {}; - gint64 session_id = 0; - if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || - !parse_native_menu_anchor(args, &anchor)) { - respond_native_menu_argument_error( - method_call, - "sessionId must be positive and anchor must contain finite geometry."); - return; - } - - GtkWidget* toplevel = gtk_widget_get_toplevel(data->view); - GdkWindow* rect_window = - GTK_IS_WINDOW(toplevel) ? gtk_widget_get_window(toplevel) : nullptr; - GdkRectangle window_anchor = anchor; - if (rect_window == nullptr || - !gtk_widget_translate_coordinates( - data->view, toplevel, anchor.x, anchor.y, &window_anchor.x, - &window_anchor.y)) { - fl_method_call_respond_error( - method_call, "unavailable", - "GTK could not translate the menu anchor into window coordinates.", - nullptr, nullptr); - return; - } - - FlValue* entries = fl_value_lookup_string(args, "entries"); - gboolean focus_first = FALSE; - const gchar* preferred_position_arg = - fl_lookup_string_arg(args, "preferredPosition"); - GtkPositionType preferred_position = GTK_POS_BOTTOM; - if (g_strcmp0(preferred_position_arg, "top") == 0) { - preferred_position = GTK_POS_TOP; - } else if (preferred_position_arg != nullptr && - g_strcmp0(preferred_position_arg, "bottom") != 0) { - respond_native_menu_argument_error( - method_call, "preferredPosition must be top or bottom."); - return; - } - if (entries == nullptr || +static gboolean validate_native_menu_entries(FlValue* entries, + FlMethodCall* method_call, + guint depth, + size_t* entry_count) { + if (depth > 8 || entries == nullptr || fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || fl_value_get_length(entries) == 0 || - fl_value_get_length(entries) > static_cast(G_MAXINT) || - !fl_lookup_optional_bool_with_default(args, "focusFirst", FALSE, - &focus_first)) { - respond_native_menu_argument_error( - method_call, - "entries must be non-empty and focusFirst must be boolean."); - return; + fl_value_get_length(entries) > 4096 - *entry_count) { + respond_native_menu_argument_error(method_call, "Invalid or excessive submenu entries."); + return FALSE; } - + *entry_count += fl_value_get_length(entries); size_t command_count = 0; size_t checkable_run_selected_count = 0; gboolean checkable_run_has_disabled_entry = FALSE; gboolean in_checkable_run = FALSE; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); + if (entry == nullptr || fl_value_get_type(entry) != FL_VALUE_TYPE_MAP) { + respond_native_menu_argument_error(method_call, "Menu entries must be maps."); + return FALSE; + } + FlValue* children = fl_value_lookup_string(entry, "children"); FlValue* icon_color = entry == nullptr ? nullptr : fl_value_lookup_string(entry, "iconColor"); @@ -2595,11 +2552,18 @@ static void show_native_menu(NativeMenuHandlerData* data, fl_value_get_int(icon_color) < 0 || fl_value_get_int(icon_color) > static_cast(G_MAXUINT32))) || - (selected && !checkable)) { + (selected && !checkable) || + (children != nullptr && (separator || checkable)) || + (fl_value_lookup_string(entry, "shortcut") != nullptr && + fl_lookup_string_arg(entry, "shortcut") == nullptr)) { respond_native_menu_argument_error( method_call, "entries must contain valid command or separator presentation."); - return; + return FALSE; + } + if (children != nullptr && + !validate_native_menu_entries(children, method_call, depth + 1, entry_count)) { + return FALSE; } if (!separator) { command_count++; @@ -2622,7 +2586,7 @@ static void show_native_menu(NativeMenuHandlerData* data, method_call, "single-choice groups allow at most one selected entry and require " "enabled entries."); - return; + return FALSE; } in_checkable_run = FALSE; } @@ -2633,41 +2597,33 @@ static void show_native_menu(NativeMenuHandlerData* data, method_call, "single-choice groups allow at most one selected entry and require " "enabled entries."); - return; + return FALSE; } if (command_count == 0) { respond_native_menu_argument_error(method_call, "entries must contain a command."); - return; - } - - if (data->active != nullptr) { - native_menu_session_dispose(data->active); + return FALSE; } - auto* session = g_new0(NativeMenuSession, 1); - session->owner = data; - session->id = session_id; - session->entry_count = fl_value_get_length(entries); - session->pending_selected_index = -1; - session->method_call = - FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); - session->action_group = g_simple_action_group_new(); - session->model = g_menu_new(); - data->active = session; + return TRUE; +} +static void build_native_menu_model(FlValue* entries, + NativeMenuSession* session, + GMenu* model, + size_t* next_index, + gboolean ancestors_enabled) { GMenu* section = g_menu_new(); guint section_length = 0; auto flush_section = [&]() { if (section_length > 0) { - g_menu_append_section(session->model, nullptr, G_MENU_MODEL(section)); + g_menu_append_section(model, nullptr, G_MENU_MODEL(section)); } g_object_unref(section); section = g_menu_new(); section_length = 0; }; - guint checkable_group_index = 0; for (size_t index = 0; index < fl_value_get_length(entries);) { FlValue* entry = fl_value_get_list_value(entries, index); gboolean separator = FALSE; @@ -2676,9 +2632,11 @@ static void show_native_menu(NativeMenuHandlerData* data, if (separator) { flush_section(); index++; + (*next_index)++; continue; } + const size_t entry_index = (*next_index)++; const gchar* label = fl_lookup_string_arg(entry, "label"); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); @@ -2691,6 +2649,17 @@ static void show_native_menu(NativeMenuHandlerData* data, fl_lookup_optional_bool_with_default(entry, "selected", FALSE, &selected); + FlValue* children = fl_value_lookup_string(entry, "children"); + if (children != nullptr) { + g_autoptr(GMenu) submenu = g_menu_new(); + build_native_menu_model(children, session, submenu, next_index, + ancestors_enabled && enabled); + g_autoptr(GMenuItem) item = g_menu_item_new_submenu(label, G_MENU_MODEL(submenu)); + g_menu_append_item(section, item); + section_length++; + index++; + continue; + } if (checkable) { const size_t run_start = index; size_t run_end = run_start; @@ -2711,16 +2680,17 @@ static void show_native_menu(NativeMenuHandlerData* data, run_entry, "selected", FALSE, &run_selected); if (run_selected) { g_free(selected_target); - selected_target = g_strdup_printf("%zu", run_end); + selected_target = g_strdup_printf("%zu", entry_index + run_end - run_start); } run_end++; } g_autofree gchar* group_action_name = - g_strdup_printf("select-group-%u", checkable_group_index++); + g_strdup_printf("select-group-%zu", entry_index); GSimpleAction* group_action = g_simple_action_new_stateful( group_action_name, G_VARIANT_TYPE_STRING, g_variant_new_string(selected_target)); + g_simple_action_set_enabled(group_action, ancestors_enabled); g_signal_connect(group_action, "activate", G_CALLBACK(native_menu_selection_activated_cb), session); @@ -2735,7 +2705,7 @@ static void show_native_menu(NativeMenuHandlerData* data, const gchar* run_icon = fl_lookup_string_arg(run_entry, "icon"); const gchar* run_shortcut = fl_lookup_string_arg(run_entry, "shortcut"); - g_autofree gchar* target = g_strdup_printf("%zu", run_index); + g_autofree gchar* target = g_strdup_printf("%zu", entry_index + run_index - run_start); g_autoptr(GMenuItem) item = g_menu_item_new(run_label, nullptr); g_menu_item_set_action_and_target_value( item, detailed_group_action, g_variant_new_string(target)); @@ -2751,15 +2721,16 @@ static void show_native_menu(NativeMenuHandlerData* data, section_length++; } g_object_unref(group_action); + *next_index += run_end - run_start - 1; index = run_end; continue; } - g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); + g_autofree gchar* action_name = g_strdup_printf("select-%zu", entry_index); GSimpleAction* action = g_simple_action_new(action_name, nullptr); - g_simple_action_set_enabled(action, enabled); + g_simple_action_set_enabled(action, ancestors_enabled && enabled); g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, - GINT_TO_POINTER(static_cast(index) + 1)); + GINT_TO_POINTER(static_cast(entry_index) + 1)); g_signal_connect(action, "activate", G_CALLBACK(native_menu_action_activated_cb), session); g_action_map_add_action(G_ACTION_MAP(session->action_group), @@ -2783,6 +2754,146 @@ static void show_native_menu(NativeMenuHandlerData* data, flush_section(); g_object_unref(section); +} + + +// GtkMenu's model adapter does not bind submenu-heading sensitivity to an +// action. Apply availability to those actual GTK widgets, in model order. +// Sections can coalesce separators, so separators never consume a command row. +static gboolean set_native_submenu_availability(GtkWidget* menu, + FlValue* entries, + gboolean ancestors_enabled) { + GList* rows = gtk_container_get_children(GTK_CONTAINER(menu)); + GList* row = rows; + gboolean valid = TRUE; + for (size_t index = 0; index < fl_value_get_length(entries); index++) { + FlValue* entry = fl_value_get_list_value(entries, index); + gboolean separator = FALSE; + gboolean enabled = TRUE; + fl_lookup_optional_bool_with_default(entry, "separator", FALSE, &separator); + if (separator) continue; + while (row != nullptr && GTK_IS_SEPARATOR_MENU_ITEM(row->data)) row = row->next; + if (row == nullptr || !GTK_IS_MENU_ITEM(row->data)) { + valid = FALSE; + break; + } + fl_lookup_optional_bool_with_default(entry, "enabled", TRUE, &enabled); + FlValue* children = fl_value_lookup_string(entry, "children"); + if (children != nullptr) { + GtkWidget* submenu = gtk_menu_item_get_submenu(GTK_MENU_ITEM(row->data)); + gtk_widget_set_sensitive(GTK_WIDGET(row->data), ancestors_enabled && enabled); + if (submenu == nullptr || !GTK_IS_MENU(submenu) || + !set_native_submenu_availability(submenu, children, ancestors_enabled && enabled)) { + valid = FALSE; + break; + } + } + row = row->next; + } + g_list_free(rows); + return valid; +} + +static void set_native_menu_direction(GtkWidget* widget, gpointer data) { + gtk_widget_set_direction(widget, static_cast(GPOINTER_TO_INT(data))); + if (GTK_IS_MENU_ITEM(widget)) { + GtkWidget* submenu = gtk_menu_item_get_submenu(GTK_MENU_ITEM(widget)); + if (submenu != nullptr) set_native_menu_direction(submenu, data); + } + if (GTK_IS_CONTAINER(widget)) { + gtk_container_foreach(GTK_CONTAINER(widget), set_native_menu_direction, data); + } +} + +static void show_native_menu(NativeMenuHandlerData* data, + FlMethodCall* method_call, + FlValue* args) { + if (data->view == nullptr || !gtk_widget_get_realized(data->view) || + gtk_widget_get_window(data->view) == nullptr) { + fl_method_call_respond_error(method_call, "unavailable", + "The native menu host is unavailable.", + nullptr, nullptr); + return; + } + GdkRectangle anchor = {}; + gint64 session_id = 0; + if (!fl_lookup_positive_int64_arg(args, "sessionId", &session_id) || + !parse_native_menu_anchor(args, &anchor)) { + respond_native_menu_argument_error( + method_call, + "sessionId must be positive and anchor must contain finite geometry."); + return; + } + + GtkWidget* toplevel = gtk_widget_get_toplevel(data->view); + GdkWindow* rect_window = + GTK_IS_WINDOW(toplevel) ? gtk_widget_get_window(toplevel) : nullptr; + GdkRectangle window_anchor = anchor; + if (rect_window == nullptr || + !gtk_widget_translate_coordinates( + data->view, toplevel, anchor.x, anchor.y, &window_anchor.x, + &window_anchor.y)) { + fl_method_call_respond_error( + method_call, "unavailable", + "GTK could not translate the menu anchor into window coordinates.", + nullptr, nullptr); + return; + } + + FlValue* entries = fl_value_lookup_string(args, "entries"); + const gchar* direction_arg = fl_lookup_string_arg(args, "textDirection"); + if (fl_value_lookup_string(args, "textDirection") != nullptr && + g_strcmp0(direction_arg, "ltr") != 0 && + g_strcmp0(direction_arg, "rtl") != 0) { + respond_native_menu_argument_error(method_call, "textDirection must be ltr or rtl."); + return; + } + const GtkTextDirection direction = direction_arg == nullptr + ? gtk_widget_get_direction(data->view) + : (g_strcmp0(direction_arg, "rtl") == 0 ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR); + gboolean focus_first = FALSE; + const gchar* preferred_position_arg = + fl_lookup_string_arg(args, "preferredPosition"); + GtkPositionType preferred_position = GTK_POS_BOTTOM; + if (g_strcmp0(preferred_position_arg, "top") == 0) { + preferred_position = GTK_POS_TOP; + } else if (preferred_position_arg != nullptr && + g_strcmp0(preferred_position_arg, "bottom") != 0) { + respond_native_menu_argument_error( + method_call, "preferredPosition must be top or bottom."); + return; + } + if (entries == nullptr || + fl_value_get_type(entries) != FL_VALUE_TYPE_LIST || + fl_value_get_length(entries) == 0 || + fl_value_get_length(entries) > static_cast(G_MAXINT) || + !fl_lookup_optional_bool_with_default(args, "focusFirst", FALSE, + &focus_first)) { + respond_native_menu_argument_error( + method_call, + "entries must be non-empty and focusFirst must be boolean."); + return; + } + + size_t entry_count = 0; + if (!validate_native_menu_entries(entries, method_call, 0, &entry_count)) return; + if (data->active != nullptr) { + native_menu_session_dispose(data->active); + } + + auto* session = g_new0(NativeMenuSession, 1); + session->owner = data; + session->id = session_id; + session->entry_count = entry_count; + session->pending_selected_index = -1; + session->method_call = + FL_METHOD_CALL(g_object_ref(G_OBJECT(method_call))); + session->action_group = g_simple_action_group_new(); + session->model = g_menu_new(); + data->active = session; + + size_t next_index = 0; + build_native_menu_model(entries, session, session->model, &next_index, TRUE); // GtkPopover maps as a Wayland subsurface whose frame callback can stall // while Flutter's parent surface is idle. GtkMenu maps as an independent // native xdg_popup instead. @@ -2800,12 +2911,22 @@ static void show_native_menu(NativeMenuHandlerData* data, } g_object_ref_sink(session->menu); gtk_menu_attach_to_widget(GTK_MENU(session->menu), data->view, nullptr); + set_native_menu_direction(session->menu, GINT_TO_POINTER(direction)); gtk_widget_show_all(session->menu); + if (!set_native_submenu_availability(session->menu, entries, TRUE)) { + fl_method_call_respond_error(method_call, "invalid-menu", + "GTK menu rows do not match the menu model.", + nullptr, nullptr); + g_clear_object(&session->method_call); + native_menu_session_dispose(session); + return; + } session->deactivate_signal_id = g_signal_connect( session->menu, "deactivate", G_CALLBACK(native_menu_deactivate_cb), session); const gboolean open_above = preferred_position == GTK_POS_TOP; + const gboolean rtl = direction == GTK_TEXT_DIR_RTL; g_object_set(session->menu, "anchor-hints", GDK_ANCHOR_FLIP_Y | GDK_ANCHOR_SLIDE | GDK_ANCHOR_RESIZE, nullptr); @@ -2818,8 +2939,10 @@ static void show_native_menu(NativeMenuHandlerData* data, // directly instead of a hidden proxy whose GTK allocation is deferred. gtk_menu_popup_at_rect( GTK_MENU(session->menu), rect_window, &window_anchor, - open_above ? GDK_GRAVITY_NORTH_WEST : GDK_GRAVITY_SOUTH_WEST, - open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, + open_above ? (rtl ? GDK_GRAVITY_NORTH_EAST : GDK_GRAVITY_NORTH_WEST) + : (rtl ? GDK_GRAVITY_SOUTH_EAST : GDK_GRAVITY_SOUTH_WEST), + open_above ? (rtl ? GDK_GRAVITY_SOUTH_EAST : GDK_GRAVITY_SOUTH_WEST) + : (rtl ? GDK_GRAVITY_NORTH_EAST : GDK_GRAVITY_NORTH_WEST), data->trigger_event); if (focus_first) { gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE); diff --git a/pubspec.yaml b/pubspec.yaml index c4fa0a0c..770406e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,3 +47,4 @@ flutter: - assets/branding/busymark_logo.svg - assets/export/html.css - assets/export/markdown.typ + - assets/writerside/templates.json diff --git a/test/fixtures/writerside/toc_ui/api.tree b/test/fixtures/writerside/toc_ui/api.tree new file mode 100644 index 00000000..8f9b5d5d --- /dev/null +++ b/test/fixtures/writerside/toc_ui/api.tree @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/toc_ui/guide.tree b/test/fixtures/writerside/toc_ui/guide.tree new file mode 100644 index 00000000..17735805 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/guide.tree @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/test/fixtures/writerside/toc_ui/library.tree b/test/fixtures/writerside/toc_ui/library.tree new file mode 100644 index 00000000..4ef9cca7 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/library.tree @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/writerside/toc_ui/topics/details.topic b/test/fixtures/writerside/toc_ui/topics/details.topic new file mode 100644 index 00000000..999d86d3 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/details.topic @@ -0,0 +1,4 @@ + + XML guide title +

    Details of this documentation project.

    +
    diff --git a/test/fixtures/writerside/toc_ui/topics/hidden.md b/test/fixtures/writerside/toc_ui/topics/hidden.md new file mode 100644 index 00000000..e9181313 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/hidden.md @@ -0,0 +1,3 @@ +# Hidden authored topic + +This topic stays visible to authors. diff --git a/test/fixtures/writerside/toc_ui/topics/home.md b/test/fixtures/writerside/toc_ui/topics/home.md new file mode 100644 index 00000000..fb86eef6 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/home.md @@ -0,0 +1,3 @@ +# Welcome to %product% + +Read [the details](details.topic) and [the reusable topic](reused.md). diff --git a/test/fixtures/writerside/toc_ui/topics/reused.md b/test/fixtures/writerside/toc_ui/topics/reused.md new file mode 100644 index 00000000..59120f33 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/reused.md @@ -0,0 +1,6 @@ +# Authored reusable title + +Guide-specific title +API-specific title + +Shared content that must survive title edits. diff --git a/test/fixtures/writerside/toc_ui/topics/review.md b/test/fixtures/writerside/toc_ui/topics/review.md new file mode 100644 index 00000000..71a42eeb --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/review.md @@ -0,0 +1,3 @@ +# Usage review + +Read [details](details.topic). diff --git a/test/fixtures/writerside/toc_ui/topics/shared.md b/test/fixtures/writerside/toc_ui/topics/shared.md new file mode 100644 index 00000000..95204593 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/shared.md @@ -0,0 +1,3 @@ +# Included source topic + +Shared navigation comes from the library tree. diff --git a/test/fixtures/writerside/toc_ui/topics/unlinked.md b/test/fixtures/writerside/toc_ui/topics/unlinked.md new file mode 100644 index 00000000..7ebed0f3 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/topics/unlinked.md @@ -0,0 +1,3 @@ +# Available topic + +Link this existing file without changing its bytes. diff --git a/test/fixtures/writerside/toc_ui/v.list b/test/fixtures/writerside/toc_ui/v.list new file mode 100644 index 00000000..2c4ace6f --- /dev/null +++ b/test/fixtures/writerside/toc_ui/v.list @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/toc_ui/writerside.cfg b/test/fixtures/writerside/toc_ui/writerside.cfg new file mode 100644 index 00000000..78305a53 --- /dev/null +++ b/test/fixtures/writerside/toc_ui/writerside.cfg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 1cf5a2e2..3276ee32 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -48,6 +48,7 @@ import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_toc_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; @@ -1131,6 +1132,8 @@ void main() { ...BusyMarkTreeShortcuts.definitions.values.map( (definition) => definition.label, ), + 'Ctrl+Shift+Home', + 'Ctrl+Shift+End', }; final shortcutRowFinder = find.descendant( of: find.byType(BusyMarkInformationalDialog), @@ -1851,47 +1854,47 @@ void main() { await tester.pump(const Duration(milliseconds: 800)); }); - testWidgets('Topics defaults creation to root and exposes file-style menu', ( - tester, - ) async { - const yaruWindowChannel = MethodChannel('yaru_window'); - const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(yaruWindowChannel, (call) async { - if (call.method == 'state') { - return {}; - } - return null; - }); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(yaruWindowEventsChannel, (_) async => null); - addTearDown(() { + testWidgets( + 'Table of Contents creates from selection and exposes nested Writerside actions', + (tester) async { + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - ..setMockMethodCallHandler(yaruWindowChannel, null) - ..setMockMethodCallHandler(yaruWindowEventsChannel, null); - }); - final binding = TestWidgetsFlutterBinding.ensureInitialized(); - binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; - addTearDown(() { - binding.platformDispatcher.defaultRouteNameTestValue = '/'; - }); - final root = Directory.systemTemp.createTempSync( - 'busymark-topics-sidebar-', - ); - addTearDown(() { - if (root.existsSync()) { - root.deleteSync(recursive: true); - } - }); - Directory(p.join(root.path, 'topics')).createSync(); - File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + .setMockMethodCallHandler(yaruWindowChannel, (call) async { + if (call.method == 'state') { + return {}; + } + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(yaruWindowEventsChannel, (_) async => null); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(yaruWindowChannel, null) + ..setMockMethodCallHandler(yaruWindowEventsChannel, null); + }); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown(() { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + }); + final root = Directory.systemTemp.createTempSync( + 'busymark-topics-sidebar-', + ); + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + Directory(p.join(root.path, 'topics')).createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' '''); - File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' @@ -1900,528 +1903,624 @@ void main() { '''); - File(p.join(root.path, 'api.tree')).writeAsStringSync(''' + File(p.join(root.path, 'api.tree')).writeAsStringSync(''' '''); - File( - p.join(root.path, 'topics', 'parent.md'), - ).writeAsStringSync('# Parent\n'); - File( - p.join(root.path, 'topics', 'nested.md'), - ).writeAsStringSync('# Nested\n'); - File( - p.join(root.path, 'topics', 'loose.md'), - ).writeAsStringSync('# Loose\n'); - File( - p.join(root.path, 'topics', 'target.md'), - ).writeAsStringSync('# Target\n'); - File(p.join(root.path, 'topics', 'api.md')).writeAsStringSync('# API\n'); - final sharedModule = Directory(p.join(root.path, 'shared'))..createSync(); - Directory(p.join(sharedModule.path, 'topics')).createSync(); - File(p.join(sharedModule.path, 'writerside.cfg')).writeAsStringSync(''' + File( + p.join(root.path, 'topics', 'parent.md'), + ).writeAsStringSync('# Parent\n'); + File( + p.join(root.path, 'topics', 'nested.md'), + ).writeAsStringSync('# Nested\n'); + File( + p.join(root.path, 'topics', 'loose.md'), + ).writeAsStringSync('# Loose\n'); + File( + p.join(root.path, 'topics', 'target.md'), + ).writeAsStringSync('# Target\n'); + File(p.join(root.path, 'topics', 'api.md')).writeAsStringSync('# API\n'); + final sharedModule = Directory(p.join(root.path, 'shared'))..createSync(); + Directory(p.join(sharedModule.path, 'topics')).createSync(); + File(p.join(sharedModule.path, 'writerside.cfg')).writeAsStringSync(''' '''); - File(p.join(sharedModule.path, 'shared.tree')).writeAsStringSync(''' + File(p.join(sharedModule.path, 'shared.tree')).writeAsStringSync(''' '''); - File( - p.join(sharedModule.path, 'topics', 'shared.md'), - ).writeAsStringSync('# Shared\n'); - final workspace = (await tester.runAsync( - () => const WorkspaceService().openPath(root.path), - ))!; - final workspaceState = WorkspaceState( - workspace: workspace, - activeText: '# Nested\n', - ); - final controller = _MutableWorkspaceController(workspaceState); - final container = ProviderContainer( - overrides: [ - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - workspaceControllerProvider.overrideWith(() => controller), - ], - ); - addTearDown(container.dispose); - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - await tester.pump(); - for (var index = 0; index < 10; index += 1) { - await tester.pump(const Duration(milliseconds: 100)); - } + File( + p.join(sharedModule.path, 'topics', 'shared.md'), + ).writeAsStringSync('# Shared\n'); + final workspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + final workspaceState = WorkspaceState( + workspace: workspace, + activeText: '# Nested\n', + ); + final controller = _MutableWorkspaceController(workspaceState); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); - Future openPopup( - Finder anchor, { - int buttons = kPrimaryButton, - }) async { - await tester.tap(anchor, buttons: buttons); - await tester.pumpAndSettle(); - } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var index = 0; index < 10; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } - Finder popupMenuItem(String label) => find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && widget.label == label, - ); + Future openPopup( + Finder anchor, { + int buttons = kPrimaryButton, + }) async { + await tester.tap(anchor, buttons: buttons); + await tester.pumpAndSettle(); + } - await openPopup(find.byTooltip(l10n.sidebarViewMenu)); - await tester.tap(find.text(l10n.files)); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('target.md')); - await tester.pump(const Duration(milliseconds: 200)); - final filesDeleteHandled = await tester.sendKeyDownEvent( - LogicalKeyboardKey.delete, - ); - expect(filesDeleteHandled, isTrue); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); - expect( - controller.analyzedRemovalMode, - WritersideTopicRemovalMode.safeDeleteFile, - ); - expect(find.text(l10n.safeDeleteTopicFile), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 200)); + Finder popupMenuItem(String label) => find.byWidgetPredicate( + (widget) => + (widget is BusyMarkPopupMenuItem && + widget.label == label) || + (widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == label) || + (widget is SubmenuButton && + widget.child is Text && + (widget.child! as Text).data == label), + ); + + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); + await tester.tap(find.text(l10n.files)); + await tester.pumpAndSettle(); + await tester.tap(find.text('target.md')); + await tester.pump(const Duration(milliseconds: 200)); + final filesDeleteHandled = await tester.sendKeyDownEvent( + LogicalKeyboardKey.delete, + ); + expect(filesDeleteHandled, isTrue); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + expect( + controller.analyzedRemovalMode, + WritersideTopicRemovalMode.safeDeleteFile, + ); + expect(find.text(l10n.delete), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pump(const Duration(milliseconds: 200)); - await openPopup(find.byTooltip(l10n.sidebarViewMenu)); - await tester.tap(find.text(l10n.toc)); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); + await tester.tap(find.text(l10n.toc)); + await tester.pumpAndSettle(); - final instanceSelector = find.byKey( - const ValueKey('writerside-instance-selector'), - ); - expect(instanceSelector, findsOneWidget); - final instanceSelectorTrigger = find.byKey( - const ValueKey('writerside-instance-selector-trigger'), - ); - expect(instanceSelectorTrigger, findsOneWidget); - expect( - find.descendant( - of: instanceSelector, - matching: find.byType(FilledButton), - ), - findsNothing, - ); - final instanceSelectorSurface = find.descendant( - of: instanceSelectorTrigger, - matching: find.byType(Material), - ); - final instanceSelectorInkWell = find.descendant( - of: instanceSelectorTrigger, - matching: find.byType(InkWell), - ); - expect(instanceSelectorSurface, findsOneWidget); - expect(instanceSelectorInkWell, findsOneWidget); - expect( - tester.widget(instanceSelectorSurface).color, - BusyMarkLinuxPalette.transparent, - ); - final instanceSelectorRect = tester.getRect(instanceSelectorTrigger); - final tocHeaderRowRect = tester.getRect( - find.byKey(const ValueKey('workspace-sidebar-first-content')), - ); - final firstTocRow = find.byKey( - const ValueKey('workspace-sidebar-toc-row-0'), - ); - final firstTocRowRect = tester.getRect(firstTocRow); - final firstTocRowSurface = find.descendant( - of: firstTocRow, - matching: find.byType(Material), - ); - final firstTocRowInkWell = find.descendant( - of: firstTocRow, - matching: find.byType(InkWell), - ); - expect(firstTocRowSurface, findsOneWidget); - expect(firstTocRowInkWell, findsOneWidget); - expect( - tester.widget(instanceSelectorSurface).borderRadius, - tester.widget(firstTocRowSurface).borderRadius, - ); - expect( - tester.widget(instanceSelectorInkWell).hoverColor, - tester.widget(firstTocRowInkWell).hoverColor, - ); - final primaryIconRect = tester.getRect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), - matching: find.byType(Icon), - ), - ); - final instanceIconRect = tester.getRect( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - final firstTocIconRect = tester.getRect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - matching: find.byIcon(BusyMarkGlyphs.document), - ), - ); - expect(instanceIconRect.left, primaryIconRect.left); - expect(firstTocIconRect.left, greaterThan(primaryIconRect.left)); - expect( - firstTocIconRect.left - primaryIconRect.left, - lessThanOrEqualTo(BusyMarkSpacing.smPlus), - ); - expect(tocHeaderRowRect.left, firstTocRowRect.left); - expect(tocHeaderRowRect.right, firstTocRowRect.right); - expect(instanceSelectorRect.left, tocHeaderRowRect.left); - expect(instanceSelectorRect.right, lessThan(tocHeaderRowRect.right)); - final primaryMenuRect = tester.getRect( - find.byTooltip(l10n.sidebarViewMenu), - ); - final tocMenuRect = tester.getRect( - find.descendant( + final instanceSelector = find.byKey( + const ValueKey('writerside-instance-selector'), + ); + expect(instanceSelector, findsOneWidget); + final instanceSelectorTrigger = find.byKey( + const ValueKey('writerside-instance-selector-trigger'), + ); + expect(instanceSelectorTrigger, findsOneWidget); + expect( + find.descendant( + of: instanceSelector, + matching: find.byType(FilledButton), + ), + findsNothing, + ); + final instanceSelectorSurface = find.descendant( + of: instanceSelectorTrigger, + matching: find.byType(Material), + ); + final instanceSelectorInkWell = find.descendant( + of: instanceSelectorTrigger, + matching: find.byType(InkWell), + ); + expect(instanceSelectorSurface, findsOneWidget); + expect(instanceSelectorInkWell, findsOneWidget); + expect( + tester.widget(instanceSelectorSurface).color, + BusyMarkLinuxPalette.transparent, + ); + final instanceSelectorRect = tester.getRect(instanceSelectorTrigger); + final tocHeaderRowRect = tester.getRect( + find.byKey(const ValueKey('workspace-sidebar-first-content')), + ); + final firstTocRow = find.byKey( + const ValueKey('workspace-sidebar-toc-row-0'), + ); + final firstTocRowRect = tester.getRect(firstTocRow); + final firstTocRowSurface = find.descendant( + of: firstTocRow, + matching: find.byType(Material), + ); + final firstTocRowInkWell = find.descendant( + of: firstTocRow, + matching: find.byType(InkWell), + ); + expect(firstTocRowSurface, findsOneWidget); + expect(firstTocRowInkWell, findsOneWidget); + expect( + tester.widget(instanceSelectorSurface).borderRadius, + tester.widget(firstTocRowSurface).borderRadius, + ); + expect( + tester.widget(instanceSelectorInkWell).hoverColor, + tester.widget(firstTocRowInkWell).hoverColor, + ); + final primaryIconRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), + matching: find.byType(Icon), + ), + ); + final instanceIconRect = tester.getRect( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + matching: find.byIcon(BusyMarkGlyphs.document), + ), + findsNothing, + ); + expect(instanceIconRect.left, primaryIconRect.left); + expect(tocHeaderRowRect.left, firstTocRowRect.left); + expect(tocHeaderRowRect.right, firstTocRowRect.right); + expect(instanceSelectorRect.left, tocHeaderRowRect.left); + expect(instanceSelectorRect.right, lessThan(tocHeaderRowRect.right)); + final primaryMenuRect = tester.getRect( + find.byTooltip(l10n.sidebarViewMenu), + ); + final tocMenuRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), + matching: find.byType(IconButton), + ), + ); + expect(instanceSelectorRect.height, tocMenuRect.height); + expect(instanceSelectorRect.center.dy, tocMenuRect.center.dy); + expect(instanceSelectorRect.height, firstTocRowRect.height); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + ), + ); + await tester.pump(); + final tocRowMenuRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + matching: find.byType(IconButton), + ), + ); + expect(tocMenuRect.center.dx, primaryMenuRect.center.dx); + expect(tocRowMenuRect.center.dx, primaryMenuRect.center.dx); + await mouse.moveTo(Offset.zero); + await tester.pump(); + expect(find.text(l10n.instances), findsNothing); + expect( + find.byKey(const ValueKey('writerside-instance-guide')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('writerside-instance-api')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('writerside-module-selector')), + findsOneWidget, + ); + await openPopup(find.byKey(const ValueKey('writerside-module-selector'))); + await tester.tap(find.text('shared-docs')); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.selectedWritersideModuleId, 'shared-docs'); + final guideIcon = tester.widget( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + await openPopup(instanceSelector); + final apiInstance = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label.contains('API Reference'), + ); + final guideInstance = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label.contains('Guide'), + ); + expect(apiInstance, findsOneWidget); + expect(guideInstance, findsOneWidget); + final guideOptionIcon = tester.widget( + find.descendant( + of: guideInstance, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + final apiOptionIcon = tester.widget( + find.descendant( + of: apiInstance, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect(guideOptionIcon.color, guideIcon.color); + expect(apiOptionIcon.color, isNotNull); + expect(apiOptionIcon.color, isNot(guideOptionIcon.color)); + await tester.tap(apiInstance); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.selectedWritersideInstanceId, 'api'); + final apiIcon = tester.widget( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect(guideIcon.color, isNotNull); + expect(apiIcon.color, isNotNull); + expect(guideIcon.color, isNot(apiIcon.color)); + await openPopup(instanceSelector); + expect(guideInstance, findsOneWidget); + await tester.tap(guideInstance); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Nested entry'), findsOneWidget); + expect(find.byTooltip(l10n.tocActions), findsOneWidget); + expect(find.byTooltip(l10n.newTopic), findsOneWidget); + expect(find.byTooltip(l10n.newChildTopic), findsNothing); + final tocMenuButton = find.descendant( of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), matching: find.byType(IconButton), - ), - ); - expect(instanceSelectorRect.height, tocMenuRect.height); - expect(instanceSelectorRect.center.dy, tocMenuRect.center.dy); - expect(instanceSelectorRect.height, firstTocRowRect.height); - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(mouse.removePointer); - await mouse.addPointer(location: Offset.zero); - await mouse.moveTo( - tester.getCenter( - find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - ), - ); - await tester.pump(); - final tocRowMenuRect = tester.getRect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - matching: find.byType(IconButton), - ), - ); - expect(tocMenuRect.center.dx, primaryMenuRect.center.dx); - expect(tocRowMenuRect.center.dx, primaryMenuRect.center.dx); - expect(find.text(l10n.instances), findsNothing); - expect( - find.byKey(const ValueKey('writerside-instance-guide')), - findsNothing, - ); - expect(find.byKey(const ValueKey('writerside-instance-api')), findsNothing); - expect( - find.byKey(const ValueKey('writerside-module-selector')), - findsOneWidget, - ); - await openPopup(find.byKey(const ValueKey('writerside-module-selector'))); - await tester.tap(find.text('shared-docs')); - await tester.pump(const Duration(milliseconds: 200)); - expect(controller.selectedWritersideModuleId, 'shared-docs'); - final guideIcon = tester.widget( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - await openPopup(instanceSelector); - final apiInstance = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label.contains('API Reference'), - ); - final guideInstance = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label.contains('Guide'), - ); - expect(apiInstance, findsOneWidget); - expect(guideInstance, findsOneWidget); - final guideOptionIcon = tester.widget( - find.descendant( - of: guideInstance, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - final apiOptionIcon = tester.widget( - find.descendant( - of: apiInstance, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - expect(guideOptionIcon.color, guideIcon.color); - expect(apiOptionIcon.color, isNotNull); - expect(apiOptionIcon.color, isNot(guideOptionIcon.color)); - await tester.tap(apiInstance); - await tester.pump(const Duration(milliseconds: 200)); - expect(controller.selectedWritersideInstanceId, 'api'); - final apiIcon = tester.widget( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - expect(guideIcon.color, isNotNull); - expect(apiIcon.color, isNotNull); - expect(guideIcon.color, isNot(apiIcon.color)); - await openPopup(instanceSelector); - expect(guideInstance, findsOneWidget); - await tester.tap(guideInstance); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.text('Nested entry'), findsOneWidget); - expect(find.byTooltip(l10n.tocActions), findsOneWidget); - expect(find.byTooltip(l10n.newTopic), findsNothing); - expect(find.byTooltip(l10n.newChildTopic), findsNothing); - final tocMenuButton = find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), - matching: find.byType(IconButton), - ); - expect(tester.widget(tocMenuButton).isSelected, isFalse); - await openPopup(find.byTooltip(l10n.tocActions)); - expect(tester.widget(tocMenuButton).isSelected, isFalse); - expect(find.text(l10n.newTopic), findsOneWidget); - expect(find.text(l10n.newInstance), findsOneWidget); - expect(find.text(l10n.newTocLibrary), findsOneWidget); - expect(find.text(l10n.editInstance), findsOneWidget); - expect(find.text(l10n.openTocFile), findsOneWidget); - expect(find.text(l10n.export), findsOneWidget); - await tester.tap(find.text(l10n.editInstance)); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text(l10n.instanceOutputSettings), findsOneWidget); - expect( - find.byKey(const ValueKey('writerside-instance-name')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-id')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-version')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-web-path')), - findsOneWidget, - ); - expect(find.text(l10n.allowSearchEngineIndexing), findsOneWidget); - expect(find.text(l10n.offlineArtifact), findsOneWidget); - expect(find.text(l10n.instanceAppearance), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 300)); + ); + expect(tester.widget(tocMenuButton).isSelected, isFalse); + await openPopup(find.byTooltip(l10n.tocActions)); + expect(tester.widget(tocMenuButton).isSelected, isFalse); + expect(find.text(l10n.newTopic), findsNothing); + expect(find.text(l10n.newInstance), findsOneWidget); + expect(find.text(l10n.newTocLibrary), findsOneWidget); + expect(find.text(l10n.editInstance), findsOneWidget); + expect(find.text(l10n.openTocFile), findsOneWidget); + expect(find.text(l10n.export), findsOneWidget); + await tester.tap(find.text(l10n.editInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.instanceOutputSettings), findsOneWidget); + expect( + find.byKey(const ValueKey('writerside-instance-name')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-id')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-version')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-web-path')), + findsOneWidget, + ); + expect(find.text(l10n.allowSearchEngineIndexing), findsOneWidget); + expect(find.text(l10n.offlineArtifact), findsOneWidget); + expect(find.text(l10n.instanceAppearance), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await openPopup(find.byTooltip(l10n.tocActions)); - await tester.tap(find.text(l10n.newInstance)); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text(l10n.createInstance), findsOneWidget); - expect(find.text(l10n.emptyInstance), findsOneWidget); - await tester.tap(find.text(l10n.emptyInstance)); - await tester.pumpAndSettle(); - expect(find.text(l10n.markdownFiles), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - expect(find.text(l10n.instanceAppearance), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.byTooltip(l10n.tocActions)); + await tester.tap(find.text(l10n.newInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.createInstance), findsOneWidget); + expect(find.text(l10n.emptyInstance), findsOneWidget); + await tester.tap(find.text(l10n.emptyInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.markdownFiles), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text(l10n.instanceAppearance), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await openPopup(find.byTooltip(l10n.tocActions)); - await tester.tap(find.text(l10n.newTopic)); - await tester.pump(const Duration(milliseconds: 300)); + await openPopup(find.byTooltip(l10n.newTopic)); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); - expect(find.text(l10n.topicPlacement), findsOneWidget); - expect(find.text(l10n.tocRoot), findsOneWidget); - expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); - expect(find.byType(BusyMarkGroupedTextEntry), findsNWidgets(2)); - expect( - find.byWidgetPredicate( - (widget) => widget is BusyMarkComboRow, - ), - findsOneWidget, - ); - expect( - find.byWidgetPredicate( - (widget) => widget is BusyMarkComboRow, - ), - findsOneWidget, - ); - expect(find.byType(BusyMarkDialogShell), findsNothing); - expect(find.byType(SegmentedButton), findsNothing); + expect(find.text(l10n.newTopic), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(TextField), + ), + findsNWidgets(2), + ); + expect(find.text(l10n.topicPlacement), findsNothing); + await tester.tap(find.text(l10n.tocOk)); + await tester.pumpAndSettle(); - await tester.tap( - find.descendant( - of: find.byType(BusyMarkEditorHeader), - matching: find.widgetWithText(ElevatedButton, l10n.create), - ), - ); - await tester.pump(const Duration(milliseconds: 300)); + expect(controller.createdTopicRequest, isNotNull); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.sibling, + ); + expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); + expect(controller.createdTopicTreePath, p.join(root.path, 'guide.tree')); - expect(controller.createdTopicRequest, isNotNull); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.root, - ); - expect(controller.createdTopicRequest!.referenceTocPath, isNull); - expect(controller.createdTopicRequest!.referenceTopic, isNull); - expect(controller.createdTopicTreePath, p.join(root.path, 'guide.tree')); + await openPopup(instanceSelector); + await tester.tap(apiInstance); + await tester.pumpAndSettle(); + expect(find.text('API'), findsOneWidget); - await openPopup(instanceSelector); - await tester.tap(apiInstance); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('api.md'), findsOneWidget); + await openPopup(find.byTooltip(l10n.newTopic)); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.tocOk)); + await tester.pumpAndSettle(); + expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); - await openPopup(find.byTooltip(l10n.tocActions)); - expect(find.text(l10n.newTopic), findsOneWidget); - await tester.tap(find.text(l10n.newTopic)); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap( - find.descendant( - of: find.byType(BusyMarkEditorHeader), - matching: find.widgetWithText(ElevatedButton, l10n.create), - ), - ); - await tester.pump(const Duration(milliseconds: 300)); - expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); + await openPopup(instanceSelector); + await tester.tap(guideInstance); + await tester.pumpAndSettle(); - await openPopup(instanceSelector); - await tester.tap(guideInstance); - await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Nested entry')); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + expect( + controller.analyzedRemovalMode, + WritersideTopicRemovalMode.removeFromInstance, + ); + expect(find.text(l10n.removeTocElement), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pump(const Duration(milliseconds: 200)); - await tester.tap(find.text('Nested entry')); - await tester.pump(const Duration(milliseconds: 200)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); - expect( - controller.analyzedRemovalMode, - WritersideTopicRemovalMode.removeFromInstance, - ); - expect(find.text(l10n.removeTocElement), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + for (final label in [ + l10n.copy, + l10n.aiRefineWithAi, + l10n.newSiblingTopic, + l10n.newChildTopic, + l10n.renameTopicFile, + l10n.cut, + l10n.pasteAfterTopic, + l10n.pasteAsChildTopic, + l10n.tocRemoveElementAction, + l10n.copyName, + l10n.copyPath, + l10n.openInFiles, + l10n.addToGit, + l10n.fileHistory, + ]) { + expect(popupMenuItem(label), findsOneWidget); + } + expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); + expect(popupMenuItem(l10n.delete), findsNothing); - for (final label in [ - l10n.copy, - l10n.aiRefineWithAi, - l10n.newSiblingTopic, - l10n.newChildTopic, - l10n.renameTopicFile, - l10n.cut, - l10n.pasteAfterTopic, - l10n.pasteAsChildTopic, - l10n.removeTocElement, - l10n.copyName, - l10n.copyPath, - l10n.openInFiles, - l10n.addToGit, - l10n.fileHistory, - ]) { - expect(popupMenuItem(label), findsOneWidget); - } - expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); - expect(popupMenuItem(l10n.delete), findsNothing); + final previousMode = container + .read(appSettingsControllerProvider) + .documentViewMode; + await tester.tap(find.text(l10n.tocPreviewTopic)); + await tester.pumpAndSettle(); + expect(controller.openedFilePath, p.join(root.path, 'topics/nested.md')); + expect( + controller.requestedEditorMode, + DocumentViewModePreference.preview, + ); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(previousMode); + await tester.pumpAndSettle(); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.tap(find.text(l10n.newChildTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.tocEmptyXmlTopic)); + await tester.pumpAndSettle(); + expect(find.text(l10n.newTopic), findsOneWidget); + await tester.tap(find.text(l10n.tocOk)); + await tester.pumpAndSettle(); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); + expect(controller.createdTopicRequest!.referenceTocIdentity, isNotNull); - await tester.tap(find.text(l10n.newChildTopic)); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text(l10n.insideSelectedTopic), findsOneWidget); - await tester.tap( - find.descendant( - of: find.byType(BusyMarkEditorHeader), - matching: find.widgetWithText(ElevatedButton, l10n.create), - ), - ); - await tester.pump(const Duration(milliseconds: 300)); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.child, - ); - expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); - expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); - expect(controller.createdTopicRequest!.referenceTocIdentity, isNotNull); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.tap(find.text(l10n.newSiblingTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); + expect(find.text(l10n.newTopic), findsOneWidget); + await tester.tap(find.text(l10n.tocOk)); + await tester.pumpAndSettle(); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.sibling, + ); + expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.tap(find.text(l10n.newSiblingTopic)); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text(l10n.afterSelectedTopic), findsOneWidget); - await tester.tap( - find.descendant( - of: find.byType(BusyMarkEditorHeader), - matching: find.widgetWithText(ElevatedButton, l10n.create), - ), - ); - await tester.pump(const Duration(milliseconds: 300)); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.sibling, - ); - expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + // Exercise actual row drag recognizers, including the pointer-relative + // child/before zones and the selection captured for a multi-item drag. + Future dragBetween(Finder source, Offset destination) async { + final gesture = await tester.startGesture( + tester.getCenter(source), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveBy(const Offset(22, 0)); + await tester.pump(); + await gesture.moveTo(destination); + await tester.pump(const Duration(milliseconds: 700)); + await gesture.up(); + await tester.pumpAndSettle(); + } - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); - await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-2'))); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - await openPopup(find.text('target.md'), buttons: kSecondaryButton); - for (final label in [ - l10n.copy, - l10n.cut, - l10n.aiRefineWithAi, - l10n.removeTocElements, - ]) { - expect(popupMenuItem(label), findsOneWidget); - } - expect(popupMenuItem(l10n.delete), findsNothing); - expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); - expect(find.text(l10n.newSiblingTopic), findsNothing); - expect(find.text(l10n.copyName), findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await dragBetween( + find.text('Loose'), + tester.getCenter(find.text('Target')), + ); + expect(controller.dragRequest!.sources.single.sourcePath, [1]); + expect(controller.dragRequest!.referencePath, [2]); + expect( + controller.dragRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + controller.dragRequest = null; + await dragBetween( + find.text('Parent'), + tester.getCenter(find.text('Nested entry')), + ); + expect( + controller.dragRequest, + isNull, + reason: 'Ancestor cannot enter its descendant', + ); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.text('Loose')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + final parentRow = find.byKey( + const ValueKey('workspace-sidebar-toc-row-0'), + ); + await tester.tap( + find.descendant(of: parentRow, matching: find.byType(AnimatedRotation)), + ); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + expect(find.text('Nested entry'), findsNothing); + await dragBetween(find.text('Loose'), tester.getCenter(parentRow)); + expect( + find.text('Nested entry'), + findsOneWidget, + reason: 'Hover expands a collapsed destination during the drag', + ); + expect( + controller.dragRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.text('Loose')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + await dragBetween( + find.text('Loose'), + tester.getTopLeft(parentRow) + const Offset(80, 3), + ); + expect(controller.dragRequest!.sources.map((entry) => entry.sourcePath), [ + [1], + [2], + ]); + expect(controller.dragRequest!.referencePath, [0]); + expect(controller.dragRequest!.beforeReference, isTrue); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); - await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-2'))); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - await openPopup(find.text('target.md'), buttons: kSecondaryButton); - expect(find.text(l10n.aiRefineWithAi), findsOneWidget); - expect(find.text(l10n.newSiblingTopic), findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + for (final label in [ + l10n.copy, + l10n.cut, + l10n.aiRefineWithAi, + l10n.removeTocElements, + ]) { + expect(popupMenuItem(label), findsOneWidget); + } + expect(popupMenuItem(l10n.delete), findsNothing); + expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); + expect(find.text(l10n.newSiblingTopic), findsNothing); + expect(find.text(l10n.copyName), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + expect(find.text(l10n.aiRefineWithAi), findsOneWidget); + expect(find.text(l10n.newSiblingTopic), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.cut)); - await tester.pump(const Duration(milliseconds: 300)); - await openPopup(find.text('parent.md'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.pasteAfterTopic && - widget.enabled, - ), - findsOneWidget, - ); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - File( - p.join(root.path, 'topics', 'inserted.md'), - ).writeAsStringSync('# Inserted\n'); - File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await openPopup(find.text('Parent'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAfterTopic && + widget.onPressed != null, + ), + findsOneWidget, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + File( + p.join(root.path, 'topics', 'inserted.md'), + ).writeAsStringSync('# Inserted\n'); + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' @@ -2431,125 +2530,199 @@ void main() { '''); - final refreshedWorkspace = (await tester.runAsync( - () => const WorkspaceService().openPath(root.path), - ))!; - controller.replaceWorkspace(refreshedWorkspace); - await tester.pump(const Duration(milliseconds: 300)); + final refreshedWorkspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + controller.replaceWorkspace(refreshedWorkspace); + await tester.pumpAndSettle(); - await openPopup(find.text('parent.md'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( + await openPopup(find.text('Parent'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAfterTopic && + widget.onPressed == null, + ), + findsOneWidget, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await openPopup(find.text('Loose'), buttons: kSecondaryButton); + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAsChildTopic && + widget.onPressed != null, + ), + findsOneWidget, + ); + await tester.ensureVisible(find.text(l10n.pasteAsChildTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.pasteAsChildTopic)); + await tester.pumpAndSettle(); + + expect( + controller.movedTopicPlacement, + WritersideTopicCreatePlacement.child, + ); + expect(controller.movedTopicSourcePath, [1]); + expect(controller.movedTopicReferencePath, [2]); + expect(find.text('Loose'), findsOneWidget); + final movedRoots = controller + .state + .workspace! + .writersideModule! + .instances + .first + .tocRoots; + final targetNode = movedRoots.singleWhere( + (node) => node.topicFileName == 'target.md', + ); + expect(targetNode.children.single.topicFileName, 'loose.md'); + + await openPopup(find.byTooltip(l10n.mainMenu)); + final mainMenuExportItem = find.byWidgetPredicate( (widget) => widget is BusyMarkPopupMenuItem && - widget.label == l10n.pasteAfterTopic && - !widget.enabled, - ), - findsOneWidget, - ); + widget.label == l10n.export, + ); + expect(mainMenuExportItem, findsOneWidget); + expect( + tester + .widget>(mainMenuExportItem) + .enabled, + isTrue, + ); + await tester.tap(mainMenuExportItem); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } + } + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await openPopup(find.text('loose.md'), buttons: kSecondaryButton); - await tester.tap(find.text(l10n.cut)); - await tester.pump(const Duration(milliseconds: 300)); - await openPopup(find.text('target.md'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( + await openPopup(find.byTooltip(l10n.tocActions)); + final exportItem = find.byWidgetPredicate( (widget) => widget is BusyMarkPopupMenuItem && - widget.label == l10n.pasteAsChildTopic && - widget.enabled, - ), - findsOneWidget, - ); - await tester.tap(find.text(l10n.pasteAsChildTopic)); - await tester.pump(const Duration(milliseconds: 300)); - - expect( - controller.movedTopicPlacement, - WritersideTopicCreatePlacement.child, - ); - expect(controller.movedTopicSourcePath, [1]); - expect(controller.movedTopicReferencePath, [2]); - expect(find.text('loose.md'), findsOneWidget); - final movedRoots = - controller.state.workspace!.writersideModule!.instances.first.tocRoots; - final targetNode = movedRoots.singleWhere( - (node) => node.topicFileName == 'target.md', - ); - expect(targetNode.children.single.topicFileName, 'loose.md'); - - await openPopup(find.byTooltip(l10n.mainMenu)); - final mainMenuExportItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.export, - ); - expect(mainMenuExportItem, findsOneWidget); - expect( - tester.widget>(mainMenuExportItem).enabled, - isTrue, - ); - await tester.tap(mainMenuExportItem); - for (var index = 0; index < 20; index++) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { - break; + widget.label == l10n.export, + ); + expect(exportItem, findsOneWidget); + expect( + tester.widget>(exportItem).enabled, + isTrue, + ); + await tester.tap(exportItem); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } } - } - expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkComboRow), findsOneWidget); + expect(find.byType(SegmentedButton), findsNothing); + expect(find.text(l10n.pdfPageSize), findsOneWidget); + expect(find.text(l10n.pdfIncludePageNumbers), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await openPopup(find.byTooltip(l10n.tocActions)); - final exportItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.export, - ); - expect(exportItem, findsOneWidget); - expect( - tester.widget>(exportItem).enabled, - isTrue, - ); - await tester.tap(exportItem); - for (var index = 0; index < 20; index++) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { - break; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyE); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } } - } - expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); - expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); - expect(find.byType(BusyMarkComboRow), findsOneWidget); - expect(find.byType(SegmentedButton), findsNothing); - expect(find.text(l10n.pdfPageSize), findsOneWidget); - expect(find.text(l10n.pdfIncludePageNumbers), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + final formatRow = tester.widget>( + find.byType(BusyMarkComboRow), + ); + expect(formatRow.title, l10n.exportFormat); + expect(formatRow.values, ExportFormat.values); + expect(formatRow.selected, ExportFormat.pdf); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + await openPopup(instanceSelectorTrigger, buttons: kSecondaryButton); + await tester.tap(find.text(l10n.openTocFile)); + await tester.pumpAndSettle(); + expect(controller.openedFilePath, p.join(root.path, 'guide.tree')); + expect(controller.requestedEditorMode, DocumentViewModePreference.source); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyE); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - for (var index = 0; index < 20; index++) { + File(p.join(root.path, 'guide.tree')).writeAsStringSync( + '' + '' + '${[for (var i = 0; i < 40; i++) ''].join()}' + '' + '', + ); + controller.replaceWorkspace( + (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!, + ); + await tester.pumpAndSettle(); + controller.openedFilePath = null; + await tester.tap(find.text('Drag source')); await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { - break; + await tester.tap(find.text('Drag source')); + await tester.pumpAndSettle(); + expect( + controller.openedFilePath, + p.join(root.path, 'guide.tree'), + reason: 'An empty-group double-click opens its source tree', + ); + final viewport = tester.getRect( + find.ancestor( + of: find.text('Drag source'), + matching: find.byType(ScrollablePositionedList), + ), + ); + final edge = Offset(viewport.center.dx, viewport.bottom - 16); + final distant = find.text('Distant destination').hitTestable(); + expect(distant, findsNothing); + final scrollDrag = await tester.startGesture( + tester.getCenter(find.text('Drag source')), + kind: PointerDeviceKind.mouse, + ); + await scrollDrag.moveBy(const Offset(22, 0)); + await tester.pump(); + for (var step = 0; step < 60 && distant.evaluate().isEmpty; step++) { + await scrollDrag.moveTo(edge + Offset(step.isEven ? 0 : 1, 0)); + await tester.pump(const Duration(milliseconds: 140)); } - } - expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); - final formatRow = tester.widget>( - find.byType(BusyMarkComboRow), - ); - expect(formatRow.title, l10n.exportFormat); - expect(formatRow.values, ExportFormat.values); - expect(formatRow.selected, ExportFormat.pdf); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); - }); + expect( + distant, + findsOneWidget, + reason: + 'Edge autoscroll reaches a destination outside the initial viewport', + ); + await scrollDrag.moveTo(tester.getCenter(distant)); + await tester.pump(const Duration(milliseconds: 20)); + await scrollDrag.up(); + await tester.pumpAndSettle(); + expect(controller.dragRequest!.sources.single.sourcePath, [0]); + expect(controller.dragRequest!.referencePath, [41]); + }, + ); testWidgets('tab keyboard shortcuts move and close editor tabs', ( tester, @@ -4164,6 +4337,7 @@ void main() { final selectedTopicActions = find.byTooltip(l10n.actions); expect(selectedTopicActions, findsOneWidget); await tester.tap(selectedTopicActions); + await tester.pump(kDoubleTapTimeout); await tester.pumpAndSettle(); expect(find.text(l10n.newSiblingTopic), findsOneWidget); expect(find.text(l10n.copyPath), findsOneWidget); @@ -4588,7 +4762,7 @@ void main() { '''); File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' - + '''); @@ -4634,11 +4808,32 @@ void main() { await tester.tap(hrefRow, buttons: kSecondaryButton); await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(ar.copyName)); + await tester.pumpAndSettle(); await tester.tap(find.text(ar.copyName)); await tester.pumpAndSettle(); expect(clipboardText, href); expect(clipboardText, isNot(contains(RegExp('[\u2066-\u2069]')))); + + for (final (label, expected) in [ + (ar.tocCopyFileName('start.md'), 'start.md'), + (ar.tocCopyFilePath, p.join(root.path, 'topics/start.md')), + (ar.tocCopyTopicTitle('Start'), 'Start'), + (ar.tocCopyElementId('nav-start'), 'nav-start'), + ]) { + await tester.tap( + find.text('Navigation label'), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(ar.tocCopySpecial)); + await tester.pumpAndSettle(); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(clipboardText, expected); + expect(clipboardText, isNot(contains(RegExp('[\u2066-\u2069]')))); + } }); testWidgets('source undo cannot restore saved text from previous tab', ( @@ -10239,6 +10434,23 @@ class _MutableWorkspaceController extends WorkspaceController { String? selectedWritersideModuleId; String? selectedWritersideInstanceId; String? openedFilePath; + DocumentViewModePreference? requestedEditorMode; + + @override + void updateActiveEditorMode(DocumentViewModePreference mode) { + requestedEditorMode = mode; + } + + WritersideTocBatchMoveRequest? dragRequest; + + @override + Future dragWritersideTocEntries({ + required String treePath, + required WritersideTocBatchMoveRequest request, + }) async { + dragRequest = request; + return null; + } @override WorkspaceState build() => initialState; @@ -10267,6 +10479,7 @@ class _MutableWorkspaceController extends WorkspaceController { Future createWritersideTopic( WritersideTopicCreateRequest request, { String? instanceTreePath, + String? initialSource, }) async { createdTopicRequest = request; createdTopicTreePath = instanceTreePath; diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 46168b8b..2e5453d5 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -1258,6 +1258,9 @@ const _nativeGtkUserFacingPatterns = <_LiteralPattern>[ ]; const _sharedEnglishMatches = { + 'tocTemplateMarkdown', // Format names and literal file extensions. + 'tocTemplateXml', + 'tocOk', // Standard confirmation caption in the reviewed locales. // Standard field labels also used verbatim in French and German. 'pdfTitlePageVersion', 'pdfTitlePageDate', @@ -1310,6 +1313,8 @@ const _sharedEnglishMatches = { const _localeSpecificEnglishMatches = >{ 'de': { + 'tocTemplateName', // Name and Format are also German words. + 'tocTemplateFormat', 'exportLayout', 'aboutWebsite', 'editor', @@ -1361,8 +1366,14 @@ const _localeSpecificEnglishMatches = >{ 'foldKindTag', 'gitCommit', }, - 'nb': {'systemTheme', 'systemLanguage', 'gitCommit', 'instanceStatus'}, - 'pl': {'folder', 'foldKindTag', 'aiModel'}, + 'nb': { + 'systemTheme', + 'systemLanguage', + 'gitCommit', + 'instanceStatus', + 'tocTemplateFormat', + }, + 'pl': {'folder', 'foldKindTag', 'aiModel', 'tocTemplateFormat'}, 'pt_BR': { 'exportLayout', 'editor', @@ -1379,6 +1390,8 @@ const _localeSpecificEnglishMatches = >{ 'ja': {'gitFetch', 'gitCommit', 'pdfPageSizeLetter'}, 'ko': {'gitDiff', 'gitFetch', 'gitCommit', 'pdfPageSizeLetter'}, 'id': { + 'tocTemplatesInternal', // Reviewed Indonesian technical terms. + 'tocTemplateFormat', 'editor', 'file', 'folder', diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 27369bdf..dfee8651 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -281,7 +281,10 @@ void main() { expect(native, contains('GMenu* main_menu_model;')); expect(native, contains('GSimpleActionGroup* header_action_group;')); expect(native, contains('rebuild_main_menu_model')); - expect(native, isNot(contains('g_menu_item_new_submenu'))); + final mainMenuBuilder = RegExp( + r'static void rebuild_main_menu_model[\s\S]*?(?=\nstatic )', + ).firstMatch(native)!.group(0)!; + expect(mainMenuBuilder, isNot(contains('g_menu_item_new_submenu'))); expect(native, contains('localized_label_or(labels, "export", "")')); expect(native, contains('"header.keyboard-shortcuts"')); expect(native, contains('"header.export"')); diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index 212f3eb2..b2b4f4fc 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -654,11 +654,17 @@ class _PausedBatchWorkspaceService extends WorkspaceService { @override Future> saveFormattedTextBatch( - List writes, - ) async { + List writes, { + void Function()? validateBeforeCommit, + void Function(Map)? onCommitted, + }) async { batchStarted.complete(); await _release.future; - return super.saveFormattedTextBatch(writes); + return super.saveFormattedTextBatch( + writes, + validateBeforeCommit: validateBeforeCommit, + onCommitted: onCommitted, + ); } } diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 40f4ff1f..7babe3b1 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -263,26 +263,21 @@ void main() { ).readAsStringSync(); final createTopicDialog = RegExp( r'class _CreateWritersideTopicDialogState[\s\S]*?' - r' String _dialogTitle', + r' String\? _titleError', ).firstMatch(workspace)!.group(0)!; expect(workspace, contains('BusyMarkPushButton.standardIcon(')); expect(workspace, isNot(contains('FilledButton.icon('))); - expect(createTopicDialog, contains('BusyMarkModalEditorScaffold(')); - expect( - RegExp( - r'BusyMarkGroupedTextEntry\(', - ).allMatches(createTopicDialog).length, - 2, - ); + expect(createTopicDialog, contains('BusyMarkDialogShell(')); + expect(RegExp(r'TextField\(').allMatches(createTopicDialog).length, 2); expect( RegExp(r'BusyMarkComboRow<').allMatches(createTopicDialog).length, - 2, + 0, ); - expect(createTopicDialog, isNot(contains('BusyMarkDialogShell('))); + expect(createTopicDialog, contains('context.l10n.newTopic')); expect(createTopicDialog, isNot(contains('SegmentedButton<'))); expect(createTopicDialog, isNot(contains('BusyMarkFloatingTextEntry'))); - expect(createTopicDialog, isNot(contains('InputDecoration('))); + expect(createTopicDialog, contains('InputDecoration(')); }); test('shared row hover delegates to Yaru interaction state', () { @@ -1242,7 +1237,7 @@ void main() { ); expect( workspace, - contains('BusyMarkComboRow'), + isNot(contains('BusyMarkComboRow')), ); final selector = RegExp( r'class BusyMarkPopupSelector[\s\S]*?' @@ -1336,7 +1331,7 @@ void main() { expect(welcome, contains('BusyMarkModalEditorScaffold(')); expect(RegExp(r'BusyMarkGroupedTextEntry\(').allMatches(welcome).length, 5); expect(workspace, contains('showBusyMarkModalEditorDialog(')); - expect(workspace, contains('showBusyMarkModalEditorDialog(')); + expect(workspace, contains('showBusyMarkModalDialog(')); expect(editor, contains('showBusyMarkModalEditorDialog(')); expect(welcome, isNot(contains('BusyMarkFloatingTextEntry'))); expect(workspace, isNot(contains('BusyMarkFloatingTextEntry'))); @@ -1415,7 +1410,7 @@ void main() { expect(workspace, contains('class _WritersideTopicUsagesSidebar')); expect(workspace, contains('analyzeWritersideTopicRemoval(')); expect(workspace, contains('applyWritersideTopicRemoval(')); - expect(workspace, contains('context.l10n.safeDeleteTopicFile')); + expect(workspace, contains('context.l10n.tocSafeDelete')); expect(workspace, contains('context.l10n.removeTocElement')); expect(workspace, contains('label: context.l10n.newFile')); expect(workspace, contains('label: context.l10n.rename')); @@ -1470,8 +1465,10 @@ void main() { expect(tocHeader, contains('tooltip: context.l10n.tocActions')); expect(tocHeader, contains('icon: BusyMarkGlyphs.menuVertical')); expect(tocHeader, contains('highlightWhenOpen: false')); - expect(tocHeader, contains('label: context.l10n.newTopic')); - expect(tocHeader, isNot(contains('BusyMarkHeaderIconButton'))); + expect(tocHeader, contains('tooltip: context.l10n.newTopic')); + expect(tocHeader, contains('label: context.l10n.tocEmptyMdTopic')); + expect(tocHeader, contains('label: context.l10n.tocEmptyXmlTopic')); + expect(tocHeader, contains('BusyMarkCompactIconButton')); expect(tocHeader, isNot(contains('context.l10n.newChildTopic'))); expect(tocHeader, isNot(contains('onCreateChildTopic'))); expect(workspace, contains('_TocTreeAction.newChildTopic')); diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 712ebeb0..ef8c9f0d 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -19,6 +19,7 @@ import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -27,6 +28,106 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + test( + 'TOC writes reject an inactive dirty tree without discarding either buffer', + () async { + final root = await Directory.systemTemp.createTemp('busymark-toc-dirty-'); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + final tree = File(p.join(root.path, 'g.tree')); + const original = + ''; + await tree.writeAsString(original); + final topic = File(p.join(root.path, 'topics/a.md')); + await topic.writeAsString('# Original\n'); + final harness = await _createControllerHarness( + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(tree.path); + controller.updateActiveText('$original\n'); + await controller.openActiveFile(topic.path); + final result = await controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Blocked', + fileName: 'blocked.md', + format: WritersideTopicFormat.markdown, + ), + instanceTreePath: tree.path, + ); + expect(result, isFalse); + expect(await tree.readAsString(), original); + expect( + await File(p.join(root.path, 'topics/blocked.md')).exists(), + isFalse, + ); + expect( + harness.controller.state.bufferForPath(tree.path)!.text, + contains('unsaved'), + ); + expect( + harness.controller.state.bufferForPath(tree.path)!.isDirty, + isTrue, + ); + expect(harness.controller.state.activeBuffer?.filePath, topic.path); + }, + ); + + test( + 'TOC creation defers its monitor events until the new document is open', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-toc-monitor-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'g.tree')).writeAsString( + '', + ); + await File( + p.join(root.path, 'topics/a.md'), + ).writeAsString('# Original\n'); + final monitor = _ControlledFileMonitor(); + final service = _TocCreationMonitorService(monitor); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.controller.openPath(root.path); + final result = await harness.controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Created', + fileName: 'created.md', + format: WritersideTopicFormat.markdown, + ), + ); + expect(result, isTrue); + expect( + harness.controller.state.activeBuffer?.filePath, + p.join(root.path, 'topics/created.md'), + ); + expect( + harness.controller.state.documentBuffers.map( + (buffer) => p.basename(buffer.filePath!), + ), + containsAll(['a.md', 'created.md']), + ); + await Future.delayed(const Duration(milliseconds: 400)); + expect( + harness.controller.state.activeBuffer?.filePath, + p.join(root.path, 'topics/created.md'), + ); + }, + ); + test('Validate publishes a valid table containing an escaped pipe', () async { final root = await Directory.systemTemp.createTemp( 'busymark-table-validation-', @@ -3685,6 +3786,37 @@ class _ControlledFileMonitor extends WorkspaceFileMonitor { Future dispose() => _events.close(); } +class _TocCreationMonitorService extends WorkspaceService { + _TocCreationMonitorService(this.monitor); + final _ControlledFileMonitor monitor; + @override + Future createWritersideTopic( + Workspace workspace, + WritersideTopicCreateRequest request, { + String? instanceTreePath, + String? initialSource, + Future Function()? validateBeforePublish, + }) async { + final result = await super.createWritersideTopic( + workspace, + request, + instanceTreePath: instanceTreePath, + validateBeforePublish: validateBeforePublish, + initialSource: initialSource, + ); + monitor.emit(workspace.rootPath); + return result; + } + + @override + Future loadTextWithSnapshot(String path) async { + if (path.endsWith('/created.md')) { + await Future.delayed(const Duration(milliseconds: 400)); + } + return super.loadTextWithSnapshot(path); + } +} + class _GatedRefreshWorkspaceService extends WorkspaceService { Completer? gate; var refreshStarts = 0; diff --git a/test/src/writerside_template_dialogs_test.dart b/test/src/writerside_template_dialogs_test.dart new file mode 100644 index 00000000..4d146baf --- /dev/null +++ b/test/src/writerside_template_dialogs_test.dart @@ -0,0 +1,259 @@ +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/workspace/presentation/writerside_template_dialogs.dart'; +import 'package:busymark/src/writerside/writerside_template_service.dart'; +import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + late WritersideTemplateService service; + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler( + const MethodChannel('yaru_window'), + (call) async => call.method == 'state' ? {} : null, + ) + ..setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + (_) async => null, + ); + root = await Directory.systemTemp.createTemp('busymark-template-dialog-'); + service = WritersideTemplateService( + storagePath: p.join(root.path, 'templates.json'), + loadBundledSource: () => + File('assets/writerside/templates.json').readAsString(), + ); + }); + tearDown(() => root.delete(recursive: true)); + + Future show( + WidgetTester tester, + Widget dialog, { + TextDirection direction = TextDirection.ltr, + }) async { + tester.view.physicalSize = const Size(1300, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + writersideTemplateServiceProvider.overrideWithValue(service), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + Directionality(textDirection: direction, child: dialog), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await settle(tester); + } + + for (final direction in TextDirection.values) { + testWidgets( + 'creation selects real variants, validates and submits in $direction', + (tester) async { + String? created; + await show( + tester, + WritersideTemplateDialog( + existingIds: {'existing'}, + previewBuilder: (context, template, title, filename) => + SingleChildScrollView( + child: Text( + WritersideTemplateService.generate( + template, + title: title, + id: filename, + ), + ), + ), + onCreate: (template, title, filename) async { + created = WritersideTemplateService.generate( + template, + title: title, + id: filename, + ); + return null; + }, + ), + direction: direction, + ); + expect(find.text('Create Topic from Template'), findsOneWidget); + await tester.tap(find.text('XML (.topic)')); + await tester.enterText( + find.byKey(const ValueKey('template-title')), + 'A & B', + ); + await tester.enterText( + find.byKey(const ValueKey('template-filename')), + 'existing', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + expect(created, isNull); + await tester.enterText( + find.byKey(const ValueKey('template-filename')), + '../bad', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + expect(created, isNull); + await tester.enterText( + find.byKey(const ValueKey('template-filename')), + 'from-template', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + await settle(tester); + expect(created, contains('title="A & B" id="from-template"')); + expect( + created, + contains('A How-to article is an action-oriented type of document.'), + ); + expect(find.text('Create Topic from Template'), findsNothing); + }, + ); + } + + testWidgets('Files editor stages new content, cancels and persists on OK', ( + tester, + ) async { + await show(tester, const WritersideTemplatesEditor(createNew: true)); + await tester.enterText( + find.byKey(const ValueKey('template-editor-name')), + 'My template', + ); + await tester.enterText( + find.byKey(const ValueKey('template-editor-source')), + '# \${TITLE}\nCustom body', + ); + await tester.tap(find.text('Cancel')); + await settle(tester); + expect((await tester.runAsync(service.read))!.entries, isEmpty); + await tester.tap(find.text('Open')); + await settle(tester); + await tester.enterText( + find.byKey(const ValueKey('template-editor-name')), + 'My template', + ); + await tester.enterText( + find.byKey(const ValueKey('template-editor-source')), + '# \${TITLE}\nCustom body', + ); + await tester.tap(find.text('OK')); + await settle(tester); + final saved = (await tester.runAsync(service.read))!.entries; + expect( + saved, + hasLength(1), + reason: tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .join('\n'), + ); + final stored = saved.single; + expect(stored.name, 'My template'); + expect(stored.source, '# \${TITLE}\nCustom body'); + expect(find.text('File and Code Templates'), findsNothing); + }); + + testWidgets( + 'save-as-template appears under Custom; edit, duplicate and delete preserve cancel', + (tester) async { + final topic = const WritersideTopicParser().parseMarkdown( + filePath: '/topic.md', + source: '# Authored\nBody', + topicsRoot: '/', + ); + await tester.runAsync( + () => service.saveTopic(topic: topic, contextualTitle: 'Authored'), + ); + await show(tester, const WritersideTemplatesEditor()); + expect(find.text('Writerside_topic.md'), findsOneWidget); + await tester.tap(find.text('Duplicate')); + await tester.pumpAndSettle(); + expect(find.text('Writerside_topic (1).md'), findsOneWidget); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.text('Writerside_topic (1).md'), findsNothing); + await tester.enterText( + find.byKey(const ValueKey('template-editor-source')), + 'unsaved', + ); + await tester.tap(find.text('Cancel')); + await settle(tester); + expect( + (await tester.runAsync(service.read))!.entries.single.source, + '# \${TITLE}\nBody', + ); + }, + ); + + testWidgets('stale settings reject OK and retain draft for recovery', ( + tester, + ) async { + await show(tester, const WritersideTemplatesEditor(createNew: true)); + await tester.enterText( + find.byKey(const ValueKey('template-editor-name')), + 'Draft', + ); + await tester.enterText( + find.byKey(const ValueKey('template-editor-source')), + '# draft', + ); + await tester.runAsync( + () => service.saveTopic( + topic: const WritersideTopicParser().parseMarkdown( + filePath: '/other.md', + source: '# Other', + topicsRoot: '/', + ), + contextualTitle: 'Other', + ), + ); + await tester.tap(find.text('OK')); + await settle(tester); + expect( + find.text( + 'Templates changed in another window. Cancel and reopen the editor before editing again.', + ), + findsOneWidget, + ); + expect(find.text('# draft'), findsOneWidget); + expect( + (await tester.runAsync(service.read))!.entries.single.name, + 'Writerside_other', + ); + }); +} + +Future settle(WidgetTester tester) async { + // The real store uses async filesystem calls, not a synchronous fake. + for (var i = 0; i < 30; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.pumpAndSettle(); +} diff --git a/test/src/writerside_template_service_test.dart b/test/src/writerside_template_service_test.dart new file mode 100644 index 00000000..21cb769f --- /dev/null +++ b/test/src/writerside_template_service_test.dart @@ -0,0 +1,368 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/writerside/writerside_template_service.dart'; +import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late WritersideTemplateService service; + setUp(() async { + root = await Directory.systemTemp.createTemp('busymark-templates-test-'); + service = WritersideTemplateService( + storagePath: p.join(root.path, 'support/templates.json'), + loadBundledSource: () => + File('assets/writerside/templates.json').readAsString(), + ); + }); + tearDown(() => root.delete(recursive: true)); + + test( + 'real installed catalog has six default families and 22 bundled TGDP topics', + () async { + final templates = await service.catalog(); + expect( + templates + .where((entry) => entry.category == 'default') + .map((entry) => entry.name) + .toSet(), + { + 'How to', + 'Overview', + 'Reference', + 'Section Starting Page', + 'Starter', + 'Tutorial', + }, + ); + expect( + templates.where((entry) => entry.category == 'default'), + hasLength(10), + ); + expect( + templates.where((entry) => entry.category == 'tgdp'), + hasLength(22), + ); + expect(await Directory(p.join(root.path, 'support')).exists(), isFalse); + final resources = + jsonDecode( + await File('assets/writerside/templates.json').readAsString(), + ) + as List; + for (final resource in resources) { + expect( + sha256.convert(utf8.encode(resource['source'] as String)).toString(), + resource['sha256'], + reason: resource['resource'] as String, + ); + } + for (final template in templates) { + final generated = WritersideTemplateService.generate( + template, + title: 'New title', + id: 'new-topic', + ); + if (template.extension == 'topic') { + final xml = XmlDocument.parse(generated); + expect(xml.rootElement.getAttribute('id'), 'new-topic'); + expect(xml.rootElement.getAttribute('title'), 'New title'); + } else { + expect(generated, contains('# New title')); + } + if (template.category == 'default') { + expect(generated, isNot(contains(r'${TITLE}'))); + } + } + }, + ); + + test( + 'literal substitution is simultaneous, case-sensitive, XML-only escaped', + () { + const md = WritersideTemplate( + id: 'test', + name: 'Test', + category: 'custom', + extension: 'md', + source: r'${TITLE} ${ID} ${OTHER} $TITLE #set($x=1)', + ); + expect( + WritersideTemplateService.generate( + md, + title: r'${ID} & *title*', + id: 'new', + ), + r'${ID} & *title* new ${OTHER} $TITLE #set($x=1)', + ); + final xml = md.copyWith( + extension: 'topic', + source: '', + ); + expect( + WritersideTemplateService.generate( + xml, + title: 'A & "B" \u0000\u007f\u0085\u009f', + id: 'new', + ), + '', + ); + expect( + WritersideTemplateService.filenameFromTitle(' Hello, world! A_B '), + 'Hello-world-A-B', + ); + }, + ); + + test('save source matches installed first-match substring behavior', () { + expect( + WritersideTemplateService.prepareSavedSource( + source: '# Guide\n# Guide extra\n', + title: 'Guide', + format: WritersideTopicFormat.markdown, + ), + '# \${TITLE}\n# \${TITLE} extra\n', + ); + expect( + WritersideTemplateService.prepareSavedSource( + source: 'Guide\n===\nBody', + title: 'Guide', + format: WritersideTopicFormat.markdown, + ), + '# \${TITLE}\nBody', + ); + expect( + WritersideTemplateService.prepareSavedSource( + source: '

    ', + title: 'Guide', + id: 'old', + format: WritersideTopicFormat.xml, + ), + '

    ', + ); + expect( + WritersideTemplateService.prepareSavedSource( + source: '# Base\nContext', + title: 'Context', + format: WritersideTopicFormat.markdown, + ), + '# Base\nContext', + ); + }); + + test('TGDP brace escaping preserves attribute blocks and rewrites relative links', () { + const template = WritersideTemplate( + id: 'sample', + name: 'Sample', + category: 'tgdp', + extension: 'md', + url: + 'https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/template_how-to.md', + source: + '# Old\n\n{Project} {style="note"}\n\n[Guide](guide_how-to.md) [Anchor](#here) [Web](https://example.com)\n', + ); + final result = WritersideTemplateService.generate( + template, + title: 'New', + id: 'new', + ); + expect(result, startsWith('# New\n')); + expect(result, contains('{(Project)} {style="note"}')); + expect( + result, + contains( + '(https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/guide_how-to.md)', + ), + ); + expect(result, contains('[Anchor](#here) [Web](https://example.com)')); + final repeated = WritersideTemplateService.generate( + template.copyWith( + source: + '# Title\n\n`[Code](same.md)` [same.md](same.md) [Again](same.md) ![same.md](same.md)\n', + ), + title: 'New', + id: 'new', + ); + expect( + repeated, + '# New\n\n`[Code](same.md)` [same.md](https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/same.md) [Again](https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/same.md) ![same.md](https://gitlab.com/tgdp/templates/-/blob/v1.2.0/how-to/same.md)\n', + ); + }); + + test( + 'save, unique naming, restart, cancellation snapshot and stale edit rejection', + () async { + final topic = const WritersideTopicParser().parseXml( + filePath: '/project/topics/guide.topic', + source: '

    Keep body

    ', + topicsRoot: '/project/topics', + ); + final first = await service.saveTopic( + topic: topic, + contextualTitle: 'Guide', + ); + expect(first.name, 'Writerside_guide'); + final snapshot = await service.read(); + final second = await service.saveTopic( + topic: topic, + contextualTitle: 'Guide', + ); + expect(second.name, 'Writerside_guide (1)'); + await expectLater( + service.save(snapshot, []), + throwsA(isA()), + ); + final reopened = WritersideTemplateService( + storagePath: service.storagePath, + ); + expect((await reopened.read()).entries, hasLength(2)); + final latest = await service.read(); + await service.save(latest, [ + first.copyWith( + name: 'Renamed', + source: '

    Edited

    ', + ), + ]); + expect((await reopened.read()).entries.single.name, 'Renamed'); + expect( + topic.document.source, + '

    Keep body

    ', + ); + }, + ); + + test('concurrent saves serialize without losing templates', () async { + final topic = const WritersideTopicParser().parseMarkdown( + filePath: '/guide.md', + source: '# Guide', + topicsRoot: '/', + ); + final other = WritersideTemplateService(storagePath: service.storagePath); + await Future.wait([ + service.saveTopic(topic: topic, contextualTitle: 'Guide'), + other.saveTopic(topic: topic, contextualTitle: 'Guide'), + ]); + expect((await service.read()).entries.map((entry) => entry.name).toSet(), { + 'Writerside_guide', + 'Writerside_guide (1)', + }); + }); + + test('corrupt and symbolic-link stores are never replaced', () async { + final path = service.storagePath!; + await File(path).parent.create(recursive: true); + await File(path).writeAsString('{broken'); + await expectLater(service.read(), throwsFormatException); + await expectLater( + service.save(const WritersideTemplateSnapshot([], null), []), + throwsFormatException, + ); + expect(await File(path).readAsString(), '{broken'); + await File(path).delete(); + final target = File(p.join(root.path, 'keep.json')); + await target.writeAsString('keep'); + await Link(path).create(target.path); + await expectLater(service.read(), throwsA(isA())); + expect(await target.readAsString(), 'keep'); + }); + + test( + 'builtin overrides persist independently and reset reveals original source', + () async { + final original = (await service.bundled()).first; + await service.save(await service.read(), [ + original.copyWith(source: '# \${TITLE}\nEdited'), + ]); + expect((await service.catalog()).first.source, '# \${TITLE}\nEdited'); + await service.save(await service.read(), []); + expect((await service.catalog()).first.source, original.source); + }, + ); + + test( + 'template bytes are published in the guarded creation transaction and rolled back on conflict', + () async { + final tree = File(p.join(root.path, 'guide.tree')); + await tree.writeAsString( + '', + ); + final template = (await service.bundled()).first; + final content = WritersideTemplateService.generate( + template, + title: 'New', + id: 'new', + ); + final target = WritersideTopicCreateTarget( + rootPath: root.path, + treePath: tree.path, + topicsRootDir: 'topics', + existingTopicIds: {}, + ); + const request = WritersideTopicCreateRequest( + title: 'New', + fileName: 'new.md', + format: WritersideTopicFormat.markdown, + placement: WritersideTopicCreatePlacement.root, + ); + var checks = 0; + await expectLater( + const WritersideTopicCreator().create( + target, + request, + initialSource: content, + validateBeforePublish: () async { + if (++checks == 2) throw StateError('dirty inactive tree'); + }, + ), + throwsStateError, + ); + expect(await File(p.join(root.path, 'topics/new.md')).exists(), isFalse); + expect(await tree.readAsString(), isNot(contains('new.md'))); + final result = await const WritersideTopicCreator().create( + target, + request, + initialSource: content, + ); + expect(await File(result.topicPath).readAsString(), content); + expect(await tree.readAsString(), contains('new.md')); + }, + ); + + test( + 'invalid XML templates and colliding root IDs never publish a topic or TOC edit', + () async { + final tree = File(p.join(root.path, 'guide.tree')); + const original = ''; + await tree.writeAsString(original); + final target = WritersideTopicCreateTarget( + rootPath: root.path, + treePath: tree.path, + topicsRootDir: 'topics', + existingTopicIds: {'occupied'}, + ); + for (final source in ['', '']) { + await expectLater( + const WritersideTopicCreator().create( + target, + const WritersideTopicCreateRequest( + title: 'New', + fileName: 'new.topic', + format: WritersideTopicFormat.xml, + ), + initialSource: source, + ), + throwsA(isA()), + ); + expect(await tree.readAsString(), original); + expect(await Directory(p.join(root.path, 'topics')).exists(), isFalse); + } + }, + ); +} diff --git a/test/src/writerside_toc_actions_test.dart b/test/src/writerside_toc_actions_test.dart new file mode 100644 index 00000000..38dde97d --- /dev/null +++ b/test/src/writerside_toc_actions_test.dart @@ -0,0 +1,413 @@ +import 'dart:io'; + +import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/editor/source/source_commands.dart'; +import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:busymark/src/writerside/writerside_title_editor.dart'; +import 'package:busymark/src/writerside/writerside_toc_editor.dart'; +import 'package:busymark/src/writerside/writerside_toc_navigation.dart'; +import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +void main() { + const parser = WritersideTopicParser(); + const titleEditor = WritersideTitleEditor(); + const identity = WritersideTocNodeIdentity( + hidden: false, + topicFileName: 'topic.md', + id: 'second', + ); + const tree = + ''; + + test('source navigation distinguishes repeated references on one line', () { + final span = writersideTocSourceSpan( + filePath: '/guide.tree', + source: tree, + path: [1], + identity: identity, + )!; + expect( + span.startOffset, + tree.indexOf(''; + final instance = const WritersideTreeParser().parse('/g.tree', source); + expect( + writersideTocBreadthFirstPath( + instance.tocRoots, + (node) => node.topicReference == 'same.md', + ), + [1], + ); + expect( + writersideTocEditorSyncKey( + '/g.tree', + source, + source.indexOf('id="custom"'), + ), + 'custom', + ); + expect( + writersideTocEditorSyncKey( + '/g.tree', + source, + source.indexOf('topic="same.md"'), + ), + isNull, + ); + }, + ); + + test( + 'Markdown title edits preserve other instances, content and occurrence', + () { + const source = + '# Authored {id="keep"}\n\nOther\n\nBody **unchanged**.\n'; + final topic = parser.parseMarkdown( + filePath: '/topics/topic.md', + source: source, + topicsRoot: '/topics', + ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit( + title: 'New *title* ', + instanceTitle: 'Instance & title', + tocTitle: 'Navigation "title"', + ), + ); + expect( + result.topicSource, + contains('# New \\*title\\* \\ {id="keep"}'), + ); + expect( + result.topicSource, + contains('Other'), + ); + expect(result.topicSource, contains('Body **unchanged**.\n')); + expect( + parser + .parseMarkdown( + filePath: '/topics/topic.md', + source: result.topicSource, + topicsRoot: '/topics', + ) + .title, + 'New *title* ', + ); + expect( + result.topicSource, + contains('Instance & title'), + ); + final elements = XmlDocument.parse( + result.treeSource, + ).findAllElements('toc-element').toList(); + expect(elements.first.getAttribute('toc-title'), isNull); + expect(elements.last.getAttribute('toc-title'), 'Navigation "title"'); + }, + ); + + test('front-matter title cannot be silently shadowed by an H1 edit', () { + final topic = parser.parseMarkdown( + filePath: '/topics/topic.md', + source: '---\ntitle: Front matter\n---\n# Heading\n', + topicsRoot: '/topics', + ); + expect( + () => titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit(title: 'New'), + ), + throwsA(isA()), + ); + }); + + test( + 'Markdown title round trip preserves ampersands and entity spellings', + () { + final topic = parser.parseMarkdown( + filePath: '/topics/topic.md', + source: '# Heading\n', + topicsRoot: '/topics', + ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit(title: 'A & B & C'), + ); + expect( + parser + .parseMarkdown( + filePath: '/topics/topic.md', + source: result.topicSource, + topicsRoot: '/topics', + ) + .title, + 'A & B & C', + ); + }, + ); + + test('unchanged inherited values do not add overrides', () { + const source = '# Authored\n\nBody\n'; + final topic = parser.parseMarkdown( + filePath: '/topics/topic.md', + source: source, + topicsRoot: '/topics', + ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit(), + ); + expect(result.topicSource, source); + expect(result.treeSource, tree); + }); + + test('XML title edits escape attributes and expand self-closing root', () { + const source = ''; + final topic = parser.parseXml( + filePath: '/topics/topic.topic', + source: source, + topicsRoot: '/topics', + ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit( + title: 'New " & ', + instanceTitle: 'Scoped <title>', + ), + ); + final root = XmlDocument.parse(result.topicSource).rootElement; + expect(root.getAttribute('id'), 'keep'); + expect(root.getAttribute('title'), 'New " & <title>'); + expect(root.getElement('title')!.innerText, 'Scoped <title>'); + }); + + test('clearing overrides removes only the selected instance and occurrence', () { + const source = + '<topic id="keep" title="Base"><title instance="guide">GuideOther

    Body

    '; + final topic = parser.parseXml( + filePath: '/topics/topic.topic', + source: source, + topicsRoot: '/topics', + ); + const withTitle = + ''; + const selected = WritersideTocNodeIdentity( + hidden: false, + topicFileName: 'topic.md', + id: 'second', + tocTitle: 'Navigation', + ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: withTitle, + tocPath: [0], + tocIdentity: selected, + edit: const WritersideTitleEdit(instanceTitle: '', tocTitle: ''), + ); + expect( + result.topicSource, + 'Other

    Body

    ', + ); + expect( + XmlDocument.parse( + result.treeSource, + ).findAllElements('toc-element').single.getAttribute('toc-title'), + isNull, + ); + }); + + test( + 'before-drop preserves complete subtrees and returns all moved paths', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-toc-actions-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File('${root.path}/guide.tree'); + await file.writeAsString( + '', + ); + final result = await const WritersideTocEditor().moveSubtrees( + WritersideTocEditTarget(rootPath: root.path, treePath: file.path), + const WritersideTocBatchMoveRequest( + sources: [ + WritersideTocMoveEntry(sourcePath: [1]), + WritersideTocMoveEntry(sourcePath: [2]), + ], + placement: WritersideTopicCreatePlacement.sibling, + referencePath: [0], + beforeReference: true, + ), + ); + final xml = XmlDocument.parse(await file.readAsString()); + expect(xml.rootElement.childElements.map((e) => e.getAttribute('id')), [ + 'b', + 'c', + 'a', + ]); + expect( + xml.rootElement.childElements.first.getAttribute('hidden'), + 'true', + ); + expect( + xml + .findAllElements('toc-element') + .where((e) => e.getAttribute('id') == 'nested'), + hasLength(1), + ); + expect(result.entryPaths, [ + [0], + [1], + ]); + }, + ); + + test( + 'grouping uses source order and rejects cross-parent selections', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-toc-actions-', + ); + addTearDown(() => root.delete(recursive: true)); + final file = File('${root.path}/guide.tree'); + const source = + ''; + await file.writeAsString(source); + final target = WritersideTocEditTarget( + rootPath: root.path, + treePath: file.path, + ); + await const WritersideTocEditor().groupElements( + target, + title: 'Group & name', + entries: const [ + WritersideTocMoveEntry( + sourcePath: [1], + sourceIdentity: WritersideTocNodeIdentity(hidden: false, id: 'b'), + ), + WritersideTocMoveEntry( + sourcePath: [0], + sourceIdentity: WritersideTocNodeIdentity(hidden: false, id: 'a'), + ), + ], + ); + final xml = XmlDocument.parse(await file.readAsString()); + final group = xml.rootElement.childElements.single; + expect(group.getAttribute('toc-title'), 'Group & name'); + expect(group.childElements.map((e) => e.getAttribute('id')), ['a', 'b']); + final before = await file.readAsString(); + await expectLater( + const WritersideTocEditor().groupElements( + target, + title: 'Invalid', + entries: const [ + WritersideTocMoveEntry( + sourcePath: [0, 0], + sourceIdentity: WritersideTocNodeIdentity(hidden: false, id: 'a'), + ), + WritersideTocMoveEntry( + sourcePath: [0], + sourceIdentity: WritersideTocNodeIdentity( + hidden: false, + tocTitle: 'Group & name', + ), + ), + ], + ), + throwsA(isA()), + ); + expect(await file.readAsString(), before); + }, + ); + + test('line moves retain CRLF, caret, reversed selection and boundaries', () { + const value = TextEditingValue( + text: 'first\r\nsecond\r\nthird', + selection: TextSelection.collapsed(offset: 9), + ); + final moved = SourceCommands.moveLines(value, down: false); + expect(moved.text, 'second\r\nfirst\r\nthird'); + expect(moved.selection.baseOffset, 2); + expect(SourceCommands.moveLines(moved, down: true), value); + const reversed = TextEditingValue( + text: 'a\nb\nc\nd', + selection: TextSelection(baseOffset: 6, extentOffset: 2), + ); + final block = SourceCommands.moveLines(reversed, down: false); + expect(block.text, 'b\nc\na\nd'); + expect( + block.selection, + const TextSelection(baseOffset: 4, extentOffset: 0), + ); + const start = TextEditingValue( + text: 'a\nb', + selection: TextSelection.collapsed(offset: 0), + ); + expect( + identical(SourceCommands.moveLines(start, down: false), start), + isTrue, + ); + const end = TextEditingValue( + text: 'a\nb', + selection: TextSelection.collapsed(offset: 3), + ); + expect(identical(SourceCommands.moveLines(end, down: true), end), isTrue); + }); +} diff --git a/test/src/writerside_toc_menu_test.dart b/test/src/writerside_toc_menu_test.dart new file mode 100644 index 00000000..b30ebbc9 --- /dev/null +++ b/test/src/writerside_toc_menu_test.dart @@ -0,0 +1,263 @@ +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/workspace/presentation/writerside_toc_dialogs.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('existing-topic picker filters names and selects by keyboard', ( + tester, + ) async { + final topics = [ + for (final name in ['alpha.md', 'beta.md', 'gamma.md']) + const WritersideTopicParser().parseMarkdown( + filePath: '/topics/$name', + source: '# $name\n', + topicsRoot: '/topics', + ), + ]; + WritersideTopic? chosen; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + child: const Text('Link'), + onPressed: () async => chosen = await showDialog( + context: context, + builder: (_) => WritersideExistingTopicPicker(topics: topics), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(chosen?.fileName, 'beta.md'); + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'GAM'); + await tester.pumpAndSettle(); + expect(find.text('alpha.md'), findsNothing); + expect(find.text('gamma.md'), findsOneWidget); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(chosen?.fileName, 'gamma.md'); + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(chosen, isNull); + }); + + for (final direction in TextDirection.values) { + testWidgets('nested menus use the native host in $direction', ( + tester, + ) async { + MethodCall? call; + int? response; + String? selected; + const channel = MethodChannel(nativeMenuChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + value, + ) async { + call = value; + return response; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: direction, + child: Builder( + builder: (context) => TextButton( + child: const Text('Native menu'), + onPressed: () async { + selected = await showBusyMarkMenu( + context: context, + focusFirst: true, + items: [ + BusyMarkSubmenuItem( + label: 'New Topic', + items: [ + BusyMarkPopupMenuItem(value: 'md', label: 'Markdown'), + const PopupMenuDivider(), + BusyMarkSubmenuItem( + label: 'Formats', + items: [ + BusyMarkPopupMenuItem( + value: 'disabled', + label: 'Disabled', + enabled: false, + ), + BusyMarkPopupMenuItem(value: 'xml', label: 'XML'), + ], + ), + ], + ), + BusyMarkSubmenuItem( + label: 'Unavailable', + enabled: false, + items: [ + BusyMarkPopupMenuItem( + value: 'ancestor-disabled', + label: 'Child', + ), + ], + ), + BusyMarkPopupMenuItem(value: 'extra', label: 'Extra'), + const PopupMenuDivider(), + ], + ); + }, + ), + ), + ), + ), + ); + for (final index in [1, 5, 8, 0, 2, 3, 4, 6, 7, 9, 10, -1, null]) { + response = index; + await tester.tap(find.text('Native menu')); + await tester.pumpAndSettle(); + expect(selected, {1: 'md', 5: 'xml', 8: 'extra'}[index]); + expect(find.byType(MenuAnchor), findsNothing); + expect(find.text('New Topic'), findsNothing); + } + final args = call!.arguments as Map; + expect(args['textDirection'], direction.name); + expect(args['focusFirst'], isTrue); + final entries = args['entries'] as List; + expect(entries.length, 4); + expect((entries[0] as Map)['children'], hasLength(3)); + expect( + ((entries[0] as Map)['children'] as List)[2]['children'], + hasLength(2), + ); + expect((entries[1] as Map)['enabled'], isFalse); + }); + + testWidgets( + 'unavailable-host fallback supports keyboard and Escape in $direction', + (tester) async { + String? selected; + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: direction, + child: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + focusNode: focus, + onPressed: () async { + selected = await showBusyMarkMenu( + context: context, + anchorPoint: const Offset(400, 200), + focusFirst: selected == null, + items: [ + BusyMarkSubmenuItem( + label: 'New Topic', + items: [ + BusyMarkPopupMenuItem( + value: 'md', + label: 'Empty MD Topic', + ), + BusyMarkPopupMenuItem( + value: 'xml', + label: 'Empty XML Topic', + ), + ], + ), + BusyMarkSubmenuItem( + label: 'New Child Topic', + items: [ + BusyMarkPopupMenuItem( + value: 'child', + label: 'Child XML', + ), + ], + ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: 'extra', + label: 'Extra', + ), + for (var i = 0; i < 25; i++) + BusyMarkPopupMenuItem( + value: 'extra$i', + label: 'Extra $i', + ), + ], + ); + }, + child: const Text('Open menu'), + ), + ), + ), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.text('New Topic'), findsOneWidget); + await tester.sendKeyEvent( + direction == TextDirection.ltr + ? LogicalKeyboardKey.arrowRight + : LogicalKeyboardKey.arrowLeft, + ); + await tester.pumpAndSettle(); + expect(find.text('Empty MD Topic'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(selected, 'xml'); + expect(find.text('New Topic'), findsNothing); + expect(focus.hasFocus, isTrue); + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('New Child Topic')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Child XML')); + await tester.pumpAndSettle(); + expect(selected, 'child'); + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + if (find.text('Empty XML Topic').evaluate().isEmpty) { + await tester.tap(find.text('New Topic')); + await tester.pumpAndSettle(); + } + await tester.tap(find.text('Empty XML Topic')); + await tester.pumpAndSettle(); + expect(selected, 'xml'); + expect(find.text('New Topic'), findsNothing); + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(selected, isNull); + expect(find.text('New Topic'), findsNothing); + expect(focus.hasFocus, isTrue); + }, + ); + } +} diff --git a/test/src/writerside_toc_source_widget_test.dart b/test/src/writerside_toc_source_widget_test.dart new file mode 100644 index 00000000..7f2eb75d --- /dev/null +++ b/test/src/writerside_toc_source_widget_test.dart @@ -0,0 +1,110 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/l10n/generated/app_localizations_en.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/source/source_search.dart'; +import 'package:busymark/src/editor/source_highlighter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'tree exact reveal unfolds and line movement is one undoable full-source edit', + (tester) async { + const original = + '\r\n \r\n \r\n \r\n \r\n'; + var text = original; + var changes = 0; + TextEditingValue? undo; + TextEditingValue? redo; + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => BusyMarkSourceEditor( + key: key, + text: text, + filePath: '/project/guide.tree', + language: SourceSyntaxLanguage.xml, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (_, _) {}, + onTransactionalChanged: + (value, _, previousSelection, selection, _) { + undo = TextEditingValue( + text: text, + selection: previousSelection, + ); + redo = TextEditingValue( + text: value, + selection: selection, + ); + changes++; + setState(() => text = value); + }, + onUndo: () { + final result = undo; + if (result != null) setState(() => text = result.text); + return result; + }, + onRedo: () { + final result = redo; + if (result != null) setState(() => text = result.text); + return result; + }, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + final en = AppLocalizationsEn(); + await tester.tap(find.byTooltip(en.collapseKind(en.foldKindTag)).first); + await tester.pumpAndSettle(); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + expect(controller.text, isNot(contains('topic="b.md"'))); + final offset = original.indexOf(' fixture() async { + final root = await Directory.systemTemp.createTemp( + 'busymark-toc-workspace-', + ); + addTearDown(() => root.delete(recursive: true)); + final source = Directory('test/fixtures/writerside/toc_ui'); + await for (final item in source.list(recursive: true)) { + final destination = p.join( + root.path, + p.relative(item.path, from: source.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); + } + } + return root; + } + + test( + 'row titles, markers and included source identity use selected context', + () async { + final root = await fixture(); + final workspace = await const WorkspaceService().openPath(root.path); + final module = workspace.writersideModule!; + final guide = module.instances.firstWhere( + (instance) => instance.id == 'guide', + ); + final api = module.instances.firstWhere( + (instance) => instance.id == 'api', + ); + final presenter = WritersideTocPresenter(module: module, instance: guide); + final rows = guide.navigationTocRoots + .expand((node) => node.flatten()) + .map(presenter.present) + .toList(); + expect(rows.first.label, 'Welcome to BusyMark'); + expect(rows.first.home, isTrue); + expect( + rows.singleWhere((row) => row.topic?.id == 'details').label, + 'XML guide title', + ); + expect( + rows.singleWhere((row) => row.topic?.id == 'reused').label, + 'Navigation-only title', + ); + expect( + rows.singleWhere((row) => row.topic?.id == 'hidden').hidden, + isTrue, + ); + expect( + rows.singleWhere((row) => row.label == 'Empty group').empty, + isTrue, + ); + expect( + rows.singleWhere((row) => row.external).tooltip, + 'https://www.jetbrains.com/writerside/', + ); + final included = guide.navigationTocRoots + .expand((node) => node.flatten()) + .singleWhere((node) => node.topicFileName == 'shared.md'); + expect(presenter.present(included).included, isTrue); + expect(included.canEditStructure, isFalse); + expect(included.sourceTocPath, isNull); + expect(included.sourceXmlPath, [0, 0]); + final ownerSource = await File(included.sourceTreePath!).readAsString(); + final span = writersideTocSourceSpan( + filePath: included.sourceTreePath!, + source: ownerSource, + path: included.sourceXmlPath!, + xmlChildren: true, + identity: WritersideTocNodeIdentity.fromNode(included), + ); + expect(span!.startOffset, ownerSource.indexOf('()), + ); + expect(await File(tree).readAsString(), before); + }, + ); + + test( + 'home reassignment preserves other instances and unrelated attributes', + () async { + final root = await fixture(); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final guide = workspace.writersideModule!.instances.firstWhere( + (instance) => instance.id == 'guide', + ); + final apiFile = File(p.join(root.path, 'api.tree')); + final apiBefore = await apiFile.readAsString(); + await service.setWritersideHomePage( + workspace, + treePath: guide.sourceTreePath, + nodePath: [1, 0], + expectedIdentity: WritersideTocNodeIdentity.fromNode( + guide.tocRoots[1].children[0], + ), + ); + final xml = XmlDocument.parse( + await File(guide.sourceTreePath).readAsString(), + ).rootElement; + expect(xml.getAttribute('start-page'), 'details.topic'); + expect(xml.getAttribute('name'), 'User Guide'); + expect(await apiFile.readAsString(), apiBefore); + }, + ); + + test( + 'title transaction rolls back both files on a concurrent dirty-buffer guard', + () async { + final root = await fixture(); + var dirty = false; + final service = WorkspaceService( + afterBatchFileCommit: (_, count) async { + if (count == 1) dirty = true; + }, + ); + final workspace = await service.openPath(root.path); + final guide = workspace.writersideModule!.instances.firstWhere( + (instance) => instance.id == 'guide', + ); + final session = await service.prepareWritersideTitleEdit( + workspace, + treePath: guide.sourceTreePath, + tocPath: [0], + identity: WritersideTocNodeIdentity.fromNode(guide.tocRoots.first), + ); + var committed = false; + await expectLater( + service.editWritersideTitles( + session, + const WritersideTitleEdit( + title: 'Changed', + tocTitle: 'Changed navigation', + ), + validateBeforeCommit: () { + if (dirty) throw StateError('dirty buffer'); + }, + onCommitted: (_, _) => committed = true, + ), + throwsStateError, + ); + expect(committed, isFalse); + expect( + await File(session.topic.filePath).readAsString(), + session.topicLoad.text, + ); + expect( + await File(session.treePath).readAsString(), + session.treeLoad.text, + ); + }, + ); + + test( + 'XML duplicate copies source, changes only root ID, adds basic sibling', + () async { + final root = await fixture(); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final guide = workspace.writersideModule!.instances.firstWhere( + (instance) => instance.id == 'guide', + ); + final topic = workspace.writersideModule!.topicByReference( + 'details.topic', + )!; + final path = await service.duplicateWritersideTopic( + workspace, + treePath: guide.sourceTreePath, + tocPath: [1, 0], + identity: WritersideTocNodeIdentity.fromNode( + guide.tocRoots[1].children.first, + ), + topicPath: topic.filePath, + expectedSource: topic.document.source, + newName: 'details-copy', + validateBeforePublish: () async {}, + ); + expect(await File(topic.filePath).readAsString(), topic.document.source); + expect( + await File(path).readAsString(), + topic.document.source.replaceFirst('id="details"', 'id="details-copy"'), + ); + final element = + XmlDocument.parse(await File(guide.sourceTreePath).readAsString()) + .findAllElements('toc-element') + .singleWhere( + (e) => e.getAttribute('topic') == 'details-copy.topic', + ); + expect(element.attributes, hasLength(1)); + expect(element.childElements, isEmpty); + }, + ); + + test( + 'sort revalidates title dependencies before publishing the tree', + () async { + final root = await fixture(); + final changed = File(p.join(root.path, 'topics/hidden.md')); + final service = WorkspaceService( + writersideTocEditor: WritersideTocEditor( + beforeTreePublish: (_) async { + await changed.writeAsString('# Z changed concurrently\n'); + }, + ), + ); + final workspace = await service.openPath(root.path); + final guide = workspace.writersideModule!.instances.firstWhere( + (instance) => instance.id == 'guide', + ); + final before = await File(guide.sourceTreePath).readAsString(); + await expectLater( + service.sortWritersideTocChildren( + workspace, + treePath: guide.sourceTreePath, + nodePath: [1], + identity: WritersideTocNodeIdentity.fromNode(guide.tocRoots[1]), + ), + throwsA(isA()), + ); + expect(await File(guide.sourceTreePath).readAsString(), before); + expect(await changed.readAsString(), '# Z changed concurrently\n'); + }, + ); + + test( + 'safe deletion rechecks dirty-buffer guard and rolls back earlier edits', + () async { + final root = await fixture(); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final topic = workspace.writersideModule!.topicByReference('reused.md')!; + final originals = {}; + await for (final entity in root.list(recursive: true)) { + if (entity is File) { + originals[entity.path] = await entity.readAsString(); + } + } + final analysis = await service.analyzeWritersideTopicRemoval( + workspace, + topicPath: topic.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + treePath: p.join(root.path, 'guide.tree'), + nodePath: [1, 1], + ); + var checks = 0; + await expectLater( + service.applyWritersideTopicRemoval( + workspace, + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + validateBeforeCommit: (paths) { + expect(paths, contains(p.join(root.path, 'guide.tree'))); + if (++checks > 1) throw StateError('became dirty'); + }, + ), + throwsStateError, + ); + expect(checks, greaterThan(1)); + for (final entry in originals.entries) { + expect(await File(entry.key).readAsString(), entry.value); + } + }, + ); +} diff --git a/tools/linux_native_menu_probe.py b/tools/linux_native_menu_probe.py new file mode 100644 index 00000000..e22e2d62 --- /dev/null +++ b/tools/linux_native_menu_probe.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +"""GTK accessibility/X11 driver for the disposable TOC acceptance app. + +Run only inside the probe's dedicated Xvfb and D-Bus session. It targets the +given process, never another BusyMark window or the user's desktop. +""" +import ctypes +import json +import os +import sys +import time + +import gi + +gi.require_version("Atspi", "2.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Atspi, Gdk # noqa: E402 + + +def app_for_pid(pid): + desktop = Atspi.get_desktop(0) + for index in range(desktop.get_child_count()): + app = desktop.get_child_at_index(index) + if app.get_process_id() == pid: + return app + raise RuntimeError(f"No accessible application for probe pid {pid}") + + +def walk(node, depth=0): + if depth > 30: + return + yield node + for index in range(node.get_child_count()): + yield from walk(node.get_child_at_index(index), depth + 1) + + +def key(name): + x11 = ctypes.CDLL("libX11.so.6") + xtst = ctypes.CDLL("libXtst.so.6") + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + 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] + xtst.XTestFakeKeyEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_int, ctypes.c_ulong] + display = x11.XOpenDisplay(None) + if not display: + raise RuntimeError("Probe X11 display unavailable") + code = x11.XKeysymToKeycode(display, Gdk.keyval_from_name(name)) + for pressed in (1, 0): + xtst.XTestFakeKeyEvent(display, code, pressed, 0) + x11.XFlush(display) + x11.XCloseDisplay(display) + + +def main(): + if os.environ.get("BUSYMARK_NATIVE_PROBE") != "1": + raise RuntimeError("Use an isolated probe session, not the user's desktop") + if os.environ.get("GDK_BACKEND") != "x11": + raise RuntimeError("Explicit GDK_BACKEND=x11 is required for the isolated display") + pid, command, *args = sys.argv[1:] + app = app_for_pid(int(pid)) + if 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 == "inspect": + print(json.dumps([ + {"name": node.get_name(), "role": node.get_role_name(), + "showing": node.get_state_set().contains(Atspi.StateType.SHOWING), + "enabled": node.get_state_set().contains(Atspi.StateType.ENABLED)} + for node in walk(app) + ], ensure_ascii=False)) + elif command == "choose": + matches = [node for node in walk(app) + if node.get_name() == args[0] + and node.get_role() in (Atspi.Role.MENU, Atspi.Role.MENU_ITEM, + Atspi.Role.RADIO_MENU_ITEM, Atspi.Role.CHECK_MENU_ITEM) + and node.get_state_set().contains(Atspi.StateType.SHOWING)] + if len(matches) != 1: + raise RuntimeError(f"Expected one visible native menu item {args[0]!r}, got {len(matches)}") + item = matches[0] + if not item.get_state_set().contains(Atspi.StateType.ENABLED): + raise RuntimeError(f"Native menu item disabled: {args[0]}") + submenu = item.get_role() == Atspi.Role.MENU + action = item.get_action_iface() + if not action.do_action(0): + raise RuntimeError(f"Could not activate native menu item: {args[0]}") + time.sleep(0.3) + print("submenu" if submenu else "command") + else: + raise RuntimeError(f"Unknown probe command {command}") + + +if __name__ == "__main__": + main() diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart new file mode 100644 index 00000000..f0e2c2a8 --- /dev/null +++ b/tools/writerside_toc_visual_smoke.dart @@ -0,0 +1,1032 @@ +// Native Linux interaction probe. All mutations use a disposable fixture copy +// and production widgets/controllers; screenshots are not mockups. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/startup_path.dart'; +import 'package:busymark/src/app/system_accent.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/platform/native_menu_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'; +import 'package:xml/xml.dart'; + +Future main(List arguments) async { + WidgetsFlutterBinding.ensureInitialized(); + if (arguments.length != 2) throw ArgumentError('FIXTURE OUTPUT_DIRECTORY'); + final root = await Directory.systemTemp.createTemp('busymark-toc-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); + } + } + final output = await Directory( + p.absolute(arguments[1]), + ).create(recursive: true); + await windowManager.ensureInitialized(); + await LinuxHeaderBarService.instance.initialize(); + final settings = AppSettings.defaults().copyWith( + localeTag: 'en', + documentViewMode: DocumentViewModePreference.source, + autoSave: false, + 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; + + Future _native(String command, [String? argument]) async { + final result = await Process.run('/usr/bin/python3', [ + p.absolute('tools/linux_native_menu_probe.py'), + '$pid', + command, + ?argument, + ]); + 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)); + } + } + + @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, + 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 { + 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); + } + + XmlDocument get _tree => XmlDocument.parse( + File(p.join(widget.root.path, 'guide.tree')).readAsStringSync(), + ); + Future _menu(String title) async { + await _tap(_text(title), buttons: kSecondaryMouseButton); + _nativeMenuOpen = true; + } + + Future _fill(String label, String value) async { + final element = _elements( + (widget) => widget is TextField && widget.decoration?.labelText == label, + ).single; + await _tap(element); + final field = element.widget as TextField; + field.controller!.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + field.onChanged?.call(value); + await _pause(); + } + + Future _create( + String anchor, + String submenu, + String format, + String title, + String filename, + ) async { + await _menu(anchor); + await _tapLabel(submenu); + await _capture('08-creation-menu'); + await _tapLabel(format); + await _fill('Topic title:', title); + await _fill('Topic Filename:', filename); + await _capture('09-new-topic'); + await _tapLabel('OK'); + await _until( + () => _elements( + (widget) => + widget.runtimeType.toString() == '_CreateWritersideTopicDialog', + ).isEmpty, + 'Creation dialog closes after success', + ); + _check( + await File(p.join(widget.root.path, 'topics', filename)).exists(), + 'Created $filename through $submenu / $format', + ); + } + + Future _drag(String source, String destination) async { + final startBox = _text(source).findRenderObject()! as RenderBox; + final endBox = _text(destination).findRenderObject()! as RenderBox; + final start = startBox.localToGlobal(startBox.size.center(Offset.zero)); + final end = endBox.localToGlobal(endBox.size.center(Offset.zero)); + final pointer = ++_pointer; + GestureBinding.instance.handlePointerEvent( + PointerDownEvent( + pointer: pointer, + position: start, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + for (var step = 1; step <= 12; step++) { + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + pointer: pointer, + position: Offset.lerp(start, end, step / 12)!, + delta: (end - start) / 12, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + await Future.delayed(const Duration(milliseconds: 70)); + } + await _capture('10-drag-feedback'); + GestureBinding.instance.handlePointerEvent( + PointerUpEvent( + pointer: pointer, + position: end, + kind: PointerDeviceKind.mouse, + ), + ); + await _pause(); + await _pause(); + } + + Future _run() async { + Object? failure; + try { + for ( + var i = 0; + i < 120 && ref.read(workspaceControllerProvider).workspace == null; + i++ + ) { + await _pause(); + } + final controller = ref.read(workspaceControllerProvider.notifier); + for ( + var i = 0; + i < 30 && + _elements( + (widget) => widget.runtimeType.toString() == '_Sidebar', + ).isEmpty; + i++ + ) { + await _pause(); + } + await _pause(); + await _verifyNativeMenus(); + await _tapLabel('Welcome to BusyMark'); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.digit2, LogicalKeyboardKey.digit2); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + await _pause(); + _check( + _elements( + (widget) => widget.runtimeType.toString() == '_TocTab', + ).isNotEmpty, + 'Table of Contents pane visible', + ); + await _capture('01-table-of-contents'); + await _menu('Welcome to BusyMark'); + await _capture('02-context-and-creation-menu'); + await _tapLabel('Edit Title...'); + await _tapLabel('Advanced Settings'); + await _capture('03-edit-title'); + await _tapLabel('Cancel'); + await _menu('Welcome to BusyMark'); + await _tapLabel('Preview Topic'); + await _until( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .mode == + DocumentViewModePreference.preview && + _elements( + (widget) => widget.runtimeType.toString() == '_PreviewPane', + ).isNotEmpty, + 'Selected topic preview is visible', + ); + _check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'topics/home.md'), + 'Preview Topic opens the selected topic', + ); + await _capture('13-selected-topic-preview'); + await _menu('Welcome to BusyMark'); + await _tapLabel("Go to TOC Element in 'guide.tree'"); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'guide.tree'), + 'Guide source navigation completes', + ); + _check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'guide.tree'), + 'Exact source navigation opens guide.tree', + ); + await _capture('04-source-navigation'); + await _menu('Included source topic'); + await _tapLabel("Go to TOC Element in 'library.tree'"); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'library.tree'), + 'Included source navigation completes', + ); + _check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'library.tree'), + 'Included snippet navigation opens its owner', + ); + await _capture('05-included-source'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/home.md'), + ); + controller.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await _pause(); + await _menu('XML guide title'); + await _key(PhysicalKeyboardKey.escape, LogicalKeyboardKey.escape); + await _tap( + _elements( + (widget) => + widget is Tooltip && + widget.message == 'Synchronize TOC and Editor', + ).single, + ); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + p.join(widget.root.path, 'topics/details.topic'), + 'Tree-to-editor synchronization opens the selected topic', + ); + _checks.add( + 'Tree-to-editor synchronization preserves pre-toolbar focus direction', + ); + await _menu('Welcome to BusyMark'); + await _key(PhysicalKeyboardKey.escape, LogicalKeyboardKey.escape); + await _tap(_elements((widget) => widget is EditableText).last); + await _tap( + _elements( + (widget) => + widget is Tooltip && + widget.message == 'Synchronize TOC and Editor', + ).single, + ); + await _pause(); + var selectedXml = false; + _text('XML guide title').visitAncestorElements((element) { + if (element.widget.runtimeType.toString() == '_SidebarTreeRow') { + selectedXml = (element.widget as dynamic).selected as bool; + return false; + } + return true; + }); + _check( + selectedXml, + 'Editor-to-tree synchronization restores the active topic selection', + ); + await _capture('14-synchronized-selection'); + await controller.openActiveFile( + p.join(widget.root.path, 'topics/review.md'), + ); + await _pause(); + await _menu('Usage review'); + await _tapLabel('Remove TOC Element...'); + await _capture('06-removal'); + if (_elements( + (widget) => widget is Text && widget.data == 'Review Usages', + ).isNotEmpty) { + await _tapLabel('Review Usages'); + await _capture('07-find-review'); + await _tap( + _elements( + (widget) => widget is Tooltip && widget.message == 'Back', + ).last, + ); + } else { + await _tapLabel('Cancel'); + } + _checks.add( + 'Production context menu, title dialog and removal dialog rendered', + ); + await _create( + 'Welcome to BusyMark', + 'New Topic', + 'Empty MD Topic', + 'Native new Markdown', + 'native-new.md', + ); + await _create( + 'Native new Markdown', + 'New Child Topic', + 'Empty XML Topic', + 'Native new XML', + 'native-child.topic', + ); + await _menu('Native new Markdown'); + await _tapLabel('New Topic'); + await _tapLabel('Link Topic Files to TOC...'); + final unlinked = File(p.join(widget.root.path, 'topics/unlinked.md')); + final original = await unlinked.readAsString(); + await _tapLabel('unlinked.md'); + await _until( + () => _tree + .findAllElements('toc-element') + .any((node) => node.getAttribute('topic') == 'unlinked.md'), + 'Link completes', + ); + _check( + await unlinked.readAsString() == original, + 'Linked existing topic without changing source bytes', + ); + await _until( + () => + !ref.read(workspaceControllerProvider).isLoading && + _elements( + (widget) => widget is Text && widget.data == 'Available topic', + ).isNotEmpty, + 'Linked topic appears in refreshed navigation', + ); + await _pause(); + await _menu('Workspace'); + await _tapLabel('Sort Child Topics Alphabetically'); + await _until( + () => + _tree + .findAllElements('toc-element') + .singleWhere( + (node) => node.getAttribute('toc-title') == 'Workspace', + ) + .childElements + .first + .getAttribute('topic') == + 'hidden.md', + 'Sort completes', + ); + var tree = XmlDocument.parse( + await File(p.join(widget.root.path, 'guide.tree')).readAsString(), + ); + final workspaceGroup = tree + .findAllElements('toc-element') + .singleWhere((node) => node.getAttribute('toc-title') == 'Workspace'); + _check( + workspaceGroup.childElements + .map((node) => node.getAttribute('topic')) + .join(',') == + 'hidden.md,reused.md,details.topic', + 'Shallow contextual case-sensitive alphabetical sorting', + ); + await _menu('XML guide title'); + await _tapLabel('Set as Home Page'); + await _until( + () => _tree.rootElement.getAttribute('start-page') == 'details.topic', + 'Home-page write completes', + ); + tree = XmlDocument.parse( + await File(p.join(widget.root.path, 'guide.tree')).readAsString(), + ); + _check( + tree.rootElement.getAttribute('start-page') == 'details.topic', + 'Home-page action updates selected instance', + ); + await _tapLabel('Native new Markdown'); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + await _tapLabel('Available topic'); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + await _drag('Native new Markdown', 'Empty group'); + await _until( + () => _tree + .findAllElements('toc-element') + .singleWhere( + (node) => node.getAttribute('toc-title') == 'Empty group', + ) + .childElements + .any((node) => node.getAttribute('topic') == 'native-new.md'), + 'Drag completes', + ); + tree = XmlDocument.parse( + await File(p.join(widget.root.path, 'guide.tree')).readAsString(), + ); + final group = tree + .findAllElements('toc-element') + .singleWhere( + (node) => node.getAttribute('toc-title') == 'Empty group', + ); + _check( + group + .findAllElements('toc-element') + .any((node) => node.getAttribute('topic') == 'native-child.topic'), + 'Pointer drag retains complete subtree', + ); + _check( + group.childElements.any( + (node) => node.getAttribute('topic') == 'unlinked.md', + ), + 'Multi-selection drag moves both selected entries', + ); + await _capture('11-after-actions'); + await _tapLabel('Hidden authored topic'); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + await _tapLabel('Navigation-only title'); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + await _menu('Navigation-only title'); + await _tapLabel('Group'); + await _fill('Group Name', 'Native group'); + await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + await _until( + () => _tree + .findAllElements('toc-element') + .any((node) => node.getAttribute('toc-title') == 'Native group'), + 'Group completes', + ); + tree = XmlDocument.parse( + await File(p.join(widget.root.path, 'guide.tree')).readAsString(), + ); + _check( + tree + .findAllElements('toc-element') + .singleWhere( + (node) => node.getAttribute('toc-title') == 'Native group', + ) + .childElements + .length == + 2, + 'Group Enter wraps the selected source subtrees', + ); + await _menu('Empty group'); + await _tapLabel('New Topic'); + await _tapLabel('Empty Group'); + await _fill('TOC title:', 'Native empty group'); + await _tapLabel('OK'); + await _until( + () => _tree + .findAllElements('toc-element') + .any( + (node) => node.getAttribute('toc-title') == 'Native empty group', + ), + 'Empty Group creation completes', + ); + _check( + _tree + .findAllElements('toc-element') + .singleWhere( + (node) => + node.getAttribute('toc-title') == 'Native empty group', + ) + .getAttribute('topic') == + null, + 'Empty Group creates no topic reference', + ); + await _menu('Welcome to BusyMark'); + await _tapLabel('New Topic'); + await _tapLabel('Topic from Template...'); + await _until( + () => _elements( + (widget) => + widget is TextField && + widget.key == const ValueKey('template-title'), + ).isNotEmpty, + 'Template catalog loads', + ); + await _capture('15-template-dialog'); + await _tapLabel('XML (.topic)'); + await _fill('Topic title:', 'Native template'); + await _fill('Filename:', 'native-template'); + await _capture('16-template-xml-preview'); + await _tapLabel('Create'); + final templateFile = File( + p.join(widget.root.path, 'topics/native-template.topic'), + ); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + templateFile.path, + 'Template creation opens generated topic', + ); + _check( + (await templateFile.readAsString()).contains( + 'A How-to article is an action-oriented type of document.', + ), + 'Template creates actual bundled content in one transaction', + ); + await _menu('Native template'); + await _tapLabel('Save as Template'); + final templates = ref.read(writersideTemplateServiceProvider); + await _until( + () => File( + p.join(widget.output.path, 'support/templates.json'), + ).existsSync(), + 'Save as Template publishes application-support metadata', + ); + _check( + (await templates.read()).entries.single.name == + 'Writerside_native-template', + 'Save as Template uses installed automatic name', + ); + _check( + !(await Directory(widget.root.path).list(recursive: true).toList()).any( + (entity) => p.basename(entity.path) == 'templates.json', + ), + 'User template metadata stays outside project', + ); + await _menu('Native template'); + await _tapLabel('New Topic'); + await _tapLabel('Topic from Template...'); + await _until( + () => _elements( + (widget) => + widget is Text && widget.data == 'Writerside_native-template', + ).isNotEmpty, + 'Saved template appears in Custom', + ); + await _tapLabel('Writerside_native-template'); + await _tapLabel('Edit templates...'); + await _until( + () => _elements( + (widget) => + widget is TextField && + widget.key == const ValueKey('template-editor-source'), + ).isNotEmpty, + 'File and Code Templates loads', + ); + await _fill('Name:', 'Reusable guide'); + await _fill( + 'Source', + '

    Edited template content

    ', + ); + await _capture('17-file-and-code-templates'); + await _tapLabel('OK'); + await _until( + () => _elements( + (widget) => widget is Text && widget.data == 'Reusable guide', + ).isNotEmpty, + 'Edited template catalog refreshes', + ); + await _tapLabel('Reusable guide'); + await _fill('Topic title:', 'Reused template'); + await _fill('Filename:', 'native-template-reused'); + await _capture('18-custom-template-preview'); + await _tapLabel('Create'); + final reusedTemplate = File( + p.join(widget.root.path, 'topics/native-template-reused.topic'), + ); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + reusedTemplate.path, + 'Edited template creates another topic', + ); + _check( + await reusedTemplate.readAsString() == + '

    Edited template content

    ', + 'Saved edited template substitutes title and ID in generated source', + ); + _check( + (await templateFile.readAsString()).contains('A How-to article'), + 'Editing a template never rewrites its source topic', + ); + await _menu('XML guide title'); + await _tapLabel('Duplicate'); + await _fill('Topic Filename:', 'native-duplicate'); + await _tapLabel('OK'); + final duplicate = File( + p.join(widget.root.path, 'topics/native-duplicate.topic'), + ); + await _until( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + duplicate.path, + 'Duplicate opens its new document', + ); + _check( + await duplicate.exists(), + 'Duplicate creates source file from the selected XML topic', + ); + _check( + XmlDocument.parse( + await duplicate.readAsString(), + ).rootElement.getAttribute('id') == + 'native-duplicate', + 'Duplicate assigns new XML root ID', + ); + await _tap(_elements((widget) => widget is EditableText).last); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.digit1, LogicalKeyboardKey.digit1); + _keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + await _until( + () => _elements( + (widget) => widget.runtimeType.toString() == '_FilesTab', + ).isNotEmpty, + 'Files sidebar is selected', + ); + await _menu('native-duplicate.topic'); + await _tapLabel('Refactor'); + await _tapLabel('Safe Delete'); + await _capture('12-safe-delete'); + await _tapLabel('OK'); + await _until(() => !duplicate.existsSync(), 'Safe Delete completes'); + _check( + !await duplicate.exists(), + 'Files Refactor / Safe Delete removes the fixture copy through analysis', + ); + } on Object catch (error, stack) { + failure = error; + stderr.writeln('$error\n$stack'); + await _capture('failure'); + } + 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); + } + + Future _verifyNativeMenus() async { + const service = NativeMenuService(); + const entries = [ + NativeMenuEntry.submenu( + label: 'Unavailable', + enabled: false, + children: [NativeMenuEntry.command(label: 'Blocked child')], + ), + NativeMenuEntry.submenu( + label: 'New Topic', + children: [ + NativeMenuEntry.command(label: 'Markdown'), + NativeMenuEntry.separator(), + NativeMenuEntry.submenu( + label: 'XML formats', + children: [NativeMenuEntry.command(label: 'XML topic')], + ), + ], + ), + NativeMenuEntry.separator(), + NativeMenuEntry.command(label: 'Extra'), + ]; + for (final direction in TextDirection.values) { + final previousFocus = FocusManager.instance.primaryFocus; + final pending = service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(600, 180, 80, 24), + entries: entries, + focusFirst: true, + textDirection: direction, + ); + await _pause(); + final tree = jsonDecode(await _native('inspect')) as List; + _check( + tree.any( + (dynamic row) => + row['name'] == 'Unavailable' && row['enabled'] == false, + ), + 'GTK disables submenu headings in ${direction.name}', + ); + await _native('key', direction == TextDirection.ltr ? 'Right' : 'Left'); + await _pause(); + await _native('key', 'Down'); + await _native('key', direction == TextDirection.ltr ? 'Right' : 'Left'); + await _capture('19-native-keyboard-${direction.name}'); + await _native('key', 'Return'); + final result = await pending.timeout(const Duration(seconds: 5)); + _check( + result.available && result.selectedIndex == 6, + 'GTK two-level keyboard traversal returns the exact leaf in ${direction.name}', + ); + await _pause(); + _check( + FocusManager.instance.primaryFocus == previousFocus, + 'GTK returns focus without replacing the Flutter focus in ${direction.name}', + ); + final cancel = service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(600, 180, 80, 24), + entries: entries, + focusFirst: true, + textDirection: direction, + ); + await _pause(); + await _native('key', 'Escape'); + _check( + (await cancel.timeout(const Duration(seconds: 5))).selectedIndex == + null, + 'GTK Escape dismisses without an action in ${direction.name}', + ); + } + final first = NativeMenuSession(); + final second = NativeMenuSession(); + final retired = service.show( + session: first, + anchor: Rect.zero, + entries: entries, + ); + await _pause(); + final current = service.show( + session: second, + anchor: Rect.zero, + entries: entries, + ); + await _pause(); + _check( + (await retired).selectedIndex == null && !await service.dismiss(first), + 'A retired native menu cannot dismiss a newer menu', + ); + await service.dismiss(second); + _check( + (await current).selectedIndex == null, + 'Native menu session dismissal returns cancellation', + ); + final radio = service.show( + session: NativeMenuSession(), + anchor: const Rect.fromLTWH(600, 180, 80, 24), + focusFirst: true, + entries: const [ + NativeMenuEntry.command( + label: 'First format', + checkable: true, + selected: true, + ), + NativeMenuEntry.command(label: 'Second format', checkable: true), + ], + ); + await _pause(); + await _native('key', 'Down'); + await _native('key', 'Return'); + _check( + (await radio.timeout(const Duration(seconds: 5))).selectedIndex == 1, + 'Existing flat native single-choice selectors still return their row index', + ); + } +} + +class _Settings implements LocalSettingsStore { + _Settings(this.value); + Map value; + @override + Future> load() async => value; + @override + Future save(Map json) async { + value = json; + } +} From 7023934220a3af46bc8d02bc4ab080e901610ef9 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 15:45:55 -0700 Subject: [PATCH 03/35] Enhance Writerside TOC handling: add `initializeWritersideFirstTopicHomePage`, refine tree-to-editor synchronization, fix topic creation preview mode, and improve template uniqueness validation. --- .../writerside-toc-implementation.md | 40 ++ .../presentation/workspace_screen.dart | 21 +- lib/src/workspace/workspace_controller.dart | 19 +- .../writerside_template_service.dart | 2 +- lib/src/writerside/writerside_toc_editor.dart | 3 + .../writerside/writerside_topic_creator.dart | 29 +- .../src/writerside_template_dialogs_test.dart | 58 +++ .../src/writerside_template_service_test.dart | 52 +++ test/src/writerside_toc_regressions_test.dart | 414 ++++++++++++++++++ test/src/writerside_toc_workspace_test.dart | 117 +++++ 10 files changed, 731 insertions(+), 24 deletions(-) create mode 100644 test/src/writerside_toc_regressions_test.dart diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md index bbe016ff..a4c436f9 100644 --- a/docs/development/writerside-toc-implementation.md +++ b/docs/development/writerside-toc-implementation.md @@ -269,3 +269,43 @@ Inspected Linux evidence: [template editor](/tmp/busymark-gtk-toc-final-acceptance/17-file-and-code-templates.png), [custom template reuse](/tmp/busymark-gtk-toc-final-acceptance/18-custom-template-preview.png). The disposable source results remain in `/tmp/busymark-toc-native-SZKSLU`. + +## Review corrections — 2026-09-14 + +Applied to the clean implementation commit `f0ef33f`, without changing GTK +menus or the approved Writerside build/template-storage decisions: + +| Reported defect | Correction | Regression coverage | +| --- | --- | --- | +| Tree-to-editor synchronization depended on the previous document's extension | `_synchronizeToc` opens the selected entry's resolved topic; only topic-less entries use element-source navigation. | `writerside_toc_regressions_test.dart`: open a `.tree` editor, right-select a topic, dismiss the menu, synchronize; then synchronize a topic-less group. | +| Qualified topic references did not synchronize | Resolve each occurrence through the existing module-aware presenter and compare its topic file path with the active file, keeping breadth-first selection. The separately documented `.tree` ID rule is unchanged. | Same widget suite: `guides/install.md`, another `elsewhere/install.md`, a deeper occurrence, and two root occurrences; the first breadth-first matching file wins. | +| Linking the first existing topic omitted `start-page` | Creation and linking share `initializeWritersideFirstTopicHomePage`, applied before insertion within the guarded tree mutation. | `writerside_toc_workspace_test.dart`: empty instance, groups only, existing home, library, empty-group insertion, existing nested topic, and concurrent publication; linked source bytes remain unchanged. | +| Template editor and storage disagreed on uniqueness | Storage validates category/name/extension, matching the editor. Same-category duplicates remain invalid. | `writerside_template_service_test.dart` and `writerside_template_dialogs_test.dart`: custom `Starter.md` and Internal override saved in both orders, persisted and reloaded. | +| Topic creation inherited preview-only mode | Successful `createWritersideTopic` makes only the new preview-only buffer editable, within the controller's file-operation boundary. Existing editable modes, other tabs, and the global preference remain unchanged. | `writerside_toc_regressions_test.dart`: actual Preview Topic followed by Empty MD, Empty XML, and template-dialog creation; verify source editor, template body, retained dirty content, and all existing tab modes. | + +The new full-workspace widget tests use real temporary project files and the +production controller/services. Only desktop host/theme and persistence services +are isolated. Their framework menu fallback is test-only; these checks do not +replace or claim a new recording of the native GTK evidence above. + +Review verification (Flutter 3.47.2 / Dart 3.13.2): + +- Full `flutter test --no-pub --concurrency=2 --file-reporter + json:/tmp/busymark-toc-review-full.json`: exit 0, **1,921 passed, 58 optional + integration skips**. This includes all 16 new regressions. +- Documented focused suite: exit 0, **416 passed, 1 optional Typst skip**; + `/tmp/busymark-toc-review-focused.json`. +- `flutter analyze --no-pub`: exit 0, no issues. +- `dart format --output=none --set-exit-if-changed` on all nine changed/new Dart + files: exit 0, no changes. `flutter gen-l10n` and `git diff --check`: exit 0. +- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**; + `/tmp/busymark-toc-review-builder.log`. +- `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; + `/tmp/busymark-toc-review-linux-build.log`. The bundle targets the normal app. + +An earlier full-suite attempt emitted an asynchronous local-history file-monitor +teardown error before the user interrupted verification. The isolated +`workspace rename settles a failed first-save promotion` test passed on rerun; +the completed full-suite rerun above also passed. No unrelated local-history or +monitoring behavior was changed to hide that failure. Interrupted processes and +temporary logs were lost, so completed verification was rerun after resuming. diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index c7781dbb..207d21a5 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -6877,8 +6877,7 @@ class _TocTabState extends ConsumerState<_TocTab> { ) async { if (_syncFromTree && selected != null) { final topic = _presenter?.present(selected.node).topic; - if (topic == null || - widget.workspace.activeFilePath?.endsWith('.tree') == true) { + if (topic == null) { await _goToTocElement(context, selected); } else { await ref @@ -6890,18 +6889,22 @@ class _TocTabState extends ConsumerState<_TocTab> { final active = ref.read(workspaceControllerProvider).activeBuffer; final file = active?.filePath; if (active == null || file == null) return; - final key = file.toLowerCase().endsWith('.tree') + final treeEditor = file.toLowerCase().endsWith('.tree'); + final key = treeEditor ? writersideTocEditorSyncKey( file, active.text, active.editorState.selection.extentOffset, ) - : p.basename(file); - if (key == null) return; - final match = writersideTocBreadthFirstPath( - instance.navigationTocRoots, - (node) => node.topicReference == key, - ); + : null; + if (treeEditor && key == null) return; + final match = writersideTocBreadthFirstPath(instance.navigationTocRoots, ( + node, + ) { + if (treeEditor) return node.topicReference == key; + final topic = _presenter?.present(node).topic; + return topic != null && p.equals(topic.filePath, file); + }); if (match == null) return; final path = match.join('/'); setState(() { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 344460e4..03e0b4eb 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1352,7 +1352,7 @@ class WorkspaceController extends Notifier { _requireCleanAffectedFiles(workspace, paths), ); return created.activeFilePath; - }); + }, openForEditing: true); Future?> discoverWritersideMarkdownImport(String sourceDirectoryPath) async { @@ -4431,8 +4431,9 @@ class WorkspaceController extends Notifier { } Future _runWorkspaceFileOperation( - Future Function(Workspace workspace) operation, - ) async { + Future Function(Workspace workspace) operation, { + bool openForEditing = false, + }) async { final workspace = state.workspace; if (workspace == null) { return false; @@ -4445,7 +4446,17 @@ class WorkspaceController extends Notifier { return false; } if (preferredActivePath != null) { - return await _openActiveFile(preferredActivePath); + final opened = await _openActiveFile(preferredActivePath); + if (opened && + openForEditing && + state.activeBuffer?.filePath == preferredActivePath && + state.activeBuffer?.editorState.mode == + DocumentViewModePreference.preview) { + // Creation opens an editable document without changing the global + // preference or the view modes of any other open buffers. + updateActiveEditorMode(DocumentViewModePreference.source); + } + return opened; } return true; } on Object catch (error, stackTrace) { diff --git a/lib/src/writerside/writerside_template_service.dart b/lib/src/writerside/writerside_template_service.dart index dc8e94cd..efa6960b 100644 --- a/lib/src/writerside/writerside_template_service.dart +++ b/lib/src/writerside/writerside_template_service.dart @@ -218,7 +218,7 @@ class WritersideTemplateService { WritersideTemplate.fromJson(entry.toJson()); if (entry.category == 'tgdp' || !ids.add(entry.id) || - !names.add('${entry.name}.${entry.extension}')) { + !names.add('${entry.category}:${entry.name}.${entry.extension}')) { throw const FormatException('Duplicate or invalid user template'); } } diff --git a/lib/src/writerside/writerside_toc_editor.dart b/lib/src/writerside/writerside_toc_editor.dart index ab2eeed4..406edb4a 100644 --- a/lib/src/writerside/writerside_toc_editor.dart +++ b/lib/src/writerside/writerside_toc_editor.dart @@ -144,6 +144,9 @@ class WritersideTocEditor { } else if (request.referencePath != null) { throw const BusyMarkException('writerside.toc.path-invalid'); } + if (request.topicReference case final topicReference?) { + initializeWritersideFirstTopicHomePage(session.root, topicReference); + } final element = XmlElement(XmlName.parts('toc-element'), [ if (request.topicReference != null) XmlAttribute(XmlName.parts('topic'), request.topicReference!), diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 018a28e1..99e0af96 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -10,6 +10,24 @@ import 'writerside_model.dart'; enum WritersideTopicCreatePlacement { root, sibling, child } +/// Shared by creation and linking, before inserting the first topic into a +/// guarded tree snapshot. Empty groups do not count as existing topics. +void initializeWritersideFirstTopicHomePage( + XmlElement root, + String topicReference, +) { + final isFirstTopic = !root.descendants.whereType().any( + (element) => + element.name.local == 'toc-element' && + element.getAttribute('topic')?.trim().isNotEmpty == true, + ); + if (isFirstTopic && + root.getAttribute('start-page') == null && + root.getAttribute('is-library') != 'true') { + root.setAttribute('start-page', topicReference); + } +} + /// Semantic identity of a TOC subtree captured when the user selects it. /// /// Structural index paths can become stale when another writer inserts or @@ -375,16 +393,7 @@ class WritersideTopicCreator { final element = XmlElement(XmlName.parts('toc-element'), [ XmlAttribute(XmlName.parts('topic'), topicFileName), ]); - final isFirstTopic = !root.descendants.whereType().any( - (element) => - element.name.local == 'toc-element' && - element.getAttribute('topic')?.trim().isNotEmpty == true, - ); - if (isFirstTopic && - root.getAttribute('start-page') == null && - root.getAttribute('is-library') != 'true') { - root.setAttribute('start-page', topicFileName); - } + initializeWritersideFirstTopicHomePage(root, topicFileName); if (request.placement == WritersideTopicCreatePlacement.root) { root.children.add(element); return _treeXml(document); diff --git a/test/src/writerside_template_dialogs_test.dart b/test/src/writerside_template_dialogs_test.dart index 4d146baf..0e857858 100644 --- a/test/src/writerside_template_dialogs_test.dart +++ b/test/src/writerside_template_dialogs_test.dart @@ -245,6 +245,64 @@ void main() { 'Writerside_other', ); }); + + for (final customFirst in [true, false]) { + testWidgets( + 'Files and Internal Starter.md both save (custom first: $customFirst)', + (tester) async { + final builtin = (await tester.runAsync(service.bundled))!.firstWhere( + (entry) => entry.name == 'Starter' && entry.extension == 'md', + ); + const custom = WritersideTemplate( + id: 'custom-starter', + name: 'Starter', + category: 'custom', + extension: 'md', + source: '# Custom', + ); + await tester.runAsync( + () async => service.save(await service.read(), [ + customFirst + ? custom + : builtin.copyWith(source: '# Internal override'), + ]), + ); + await show( + tester, + WritersideTemplatesEditor( + createNew: !customFirst, + selectedId: customFirst ? builtin.id : null, + ), + ); + if (!customFirst) { + await tester.enterText( + find.byKey(const ValueKey('template-editor-name')), + 'Starter', + ); + } + await tester.enterText( + find.byKey(const ValueKey('template-editor-source')), + customFirst ? '# Internal override' : '# Custom', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await settle(tester); + expect(find.text('File and Code Templates'), findsNothing); + final entries = (await tester.runAsync(service.read))!.entries; + expect( + { + for (final entry in entries) + '${entry.category}:${entry.name}.${entry.extension}': + entry.source, + }, + { + 'default:Starter.md': '# Internal override', + 'custom:Starter.md': '# Custom', + }, + ); + }, + ); + } } Future settle(WidgetTester tester) async { diff --git a/test/src/writerside_template_service_test.dart b/test/src/writerside_template_service_test.dart index 21cb769f..657b0af3 100644 --- a/test/src/writerside_template_service_test.dart +++ b/test/src/writerside_template_service_test.dart @@ -255,6 +255,58 @@ void main() { }); }); + for (final customFirst in [true, false]) { + test( + 'custom and Internal templates may share a name (custom first: $customFirst)', + () async { + final builtin = (await service.bundled()).firstWhere( + (entry) => entry.name == 'Starter' && entry.extension == 'md', + ); + const custom = WritersideTemplate( + id: 'custom-starter', + name: 'Starter', + category: 'custom', + extension: 'md', + source: '# Custom', + ); + final overridden = builtin.copyWith(source: '# Internal override'); + await service.save(await service.read(), [ + customFirst ? custom : overridden, + ]); + await service.save( + await service.read(), + customFirst ? [custom, overridden] : [overridden, custom], + ); + final reloaded = WritersideTemplateService( + storagePath: p.join(root.path, 'support/templates.json'), + loadBundledSource: () => + File('assets/writerside/templates.json').readAsString(), + ); + final entries = (await reloaded.catalog()).where( + (entry) => entry.name == 'Starter' && entry.extension == 'md', + ); + expect( + {for (final entry in entries) entry.category: entry.source}, + {'default': '# Internal override', 'custom': '# Custom'}, + ); + await expectLater( + service.save(await service.read(), [ + custom, + const WritersideTemplate( + id: 'second-custom', + name: 'Starter', + category: 'custom', + extension: 'md', + source: '# Duplicate', + ), + ]), + throwsFormatException, + ); + expect((await service.read()).entries, hasLength(2)); + }, + ); + } + test('corrupt and symbolic-link stores are never replaced', () async { final path = service.storagePath!; await File(path).parent.create(recursive: true); diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart new file mode 100644 index 00000000..e76dcc26 --- /dev/null +++ b/test/src/writerside_toc_regressions_test.dart @@ -0,0 +1,414 @@ +import 'dart:io'; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/editor/source/source_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/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/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + late ProviderContainer container; + WorkspaceController controller() => + container.read(workspaceControllerProvider.notifier); + String topicPath(String relative) => p.join(root.path, 'topics', relative); + + setUp(() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + for (final channel in ['yaru_window', 'yaru_window/events']) { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(channel), + (call) async => call.method == 'state' ? {} : null, + ); + } + root = Directory.systemTemp.createTempSync('busymark-toc-regression-'); + for (final name in [ + 'guides/install.md', + 'elsewhere/install.md', + 'other.md', + ]) { + final file = File(topicPath(name)); + file.parent.createSync(recursive: true); + file.writeAsStringSync( + '# ${name == 'other.md' ? 'Other topic' : name}\n\nBody\n', + ); + } + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + + + + + + + +'''); + container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(_FallbackHeader()), + systemAccentColorProvider.overrideWith( + (ref) => Stream.value(busyMarkDefaultAccentColor), + ), + localSettingsStoreProvider.overrideWithValue(_Settings()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + writersideTemplateServiceProvider.overrideWithValue( + WritersideTemplateService( + storagePath: p.join(root.path, 'test-support/templates.json'), + loadBundledSource: () => + File('assets/writerside/templates.json').readAsString(), + ), + ), + ], + ); + }); + tearDown(() async { + container.dispose(); + for (final channel in ['yaru_window', 'yaru_window/events']) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MethodChannel(channel), null); + } + await root.delete(recursive: true); + }); + + Future start(WidgetTester tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown(() { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/'; + }); + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.runAsync(() async { + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + await controller().openPath(root.path); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _settle(tester); + expect(container.read(workspaceControllerProvider).workspace, isNotNull); + await tester.tap(find.byTooltip('Sidebar view')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Table of Contents')); + await tester.pumpAndSettle(); + } + + Future selectWithoutOpening(WidgetTester tester, String path) async { + await tester.tap( + find.byKey(ValueKey('workspace-sidebar-toc-row-$path')), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + } + + bool selected(WidgetTester tester, String path) => + (tester.widget( + find.descendant( + of: find.byKey( + ValueKey('workspace-sidebar-toc-row-$path'), + ), + matching: find.byWidgetPredicate( + (widget) => + widget.runtimeType.toString() == '_SidebarTreeRow', + ), + ), + ) + as dynamic) + .selected + as bool; + + testWidgets( + 'tree-to-editor sync opens selected topic even with a .tree editor active', + (tester) async { + await start(tester); + final treePath = p.join(root.path, 'guide.tree'); + await tester.runAsync(() => controller().openActiveFile(treePath)); + await _settle(tester); + await selectWithoutOpening(tester, '2'); + expect(selected(tester, '2'), isTrue); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'BusyMark topics tree', + ); + expect( + container.read(workspaceControllerProvider).activeBuffer!.filePath, + treePath, + ); + await tester.tap(find.byTooltip('Synchronize TOC and Editor')); + await _settle( + tester, + until: () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath == + topicPath('guides/install.md'), + ); + expect( + container.read(workspaceControllerProvider).activeBuffer!.filePath, + topicPath('guides/install.md'), + ); + // A topic-less group still uses element source navigation. + await selectWithoutOpening(tester, '1'); + await tester.tap(find.byTooltip('Synchronize TOC and Editor')); + await _settle( + tester, + until: () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath == + treePath, + ); + expect( + container.read(workspaceControllerProvider).activeBuffer!.filePath, + treePath, + ); + }, + ); + + testWidgets( + 'editor-to-TOC sync resolves qualified identities and chooses breadth-first occurrence', + (tester) async { + await start(tester); + final path = topicPath('guides/install.md'); + await tester.runAsync(() => controller().openActiveFile(path)); + await _settle(tester); + await selectWithoutOpening(tester, '0'); + expect(selected(tester, '0'), isTrue); + expect( + container.read(workspaceControllerProvider).activeBuffer!.filePath, + path, + ); + await tester.tap( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(EditableText), + ) + .first, + ); + await tester.pump(); + await tester.tap(find.byTooltip('Synchronize TOC and Editor')); + await _settle(tester); + expect(selected(tester, '2'), isTrue); + expect(selected(tester, '0'), isFalse); + expect(selected(tester, '3'), isFalse); + expect(selected(tester, '4'), isFalse); + expect( + container.read(workspaceControllerProvider).activeBuffer!.filePath, + path, + ); + }, + ); + + for (final choice in [ + 'Empty MD Topic', + 'Empty XML Topic', + 'Topic from Template...', + ]) { + testWidgets( + '$choice creates an editable document after Preview Topic, preserving other tabs', + (tester) async { + await start(tester); + final other = topicPath('guides/install.md'); + await tester.runAsync(() => controller().openActiveFile(other)); + controller().updateActiveEditorMode(DocumentViewModePreference.split); + controller().updateActiveText('# Unrelated unsaved document\n'); + await tester.runAsync( + () => controller().openActiveFile(topicPath('other.md')), + ); + await _settle(tester); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Preview Topic')); + await _settle( + tester, + until: () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .mode == + DocumentViewModePreference.preview, + ); + final before = container.read(workspaceControllerProvider); + expect( + before.activeBuffer!.editorState.mode, + DocumentViewModePreference.preview, + ); + expect( + container.read(appSettingsControllerProvider).documentViewMode, + DocumentViewModePreference.preview, + ); + final modes = { + for (final buffer in before.documentBuffers) + buffer.id: buffer.editorState.mode, + }; + + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('New Topic')); + await tester.pumpAndSettle(); + await tester.tap(find.text(choice)); + await _settle(tester); + final template = choice == 'Topic from Template...'; + if (template) { + await tester.enterText( + find.byKey(const ValueKey('template-title')), + 'Created topic', + ); + await tester.enterText( + find.byKey(const ValueKey('template-filename')), + 'created-topic', + ); + } else { + await tester.enterText( + find.widgetWithText(TextField, 'Topic title:'), + 'Created topic', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Topic Filename:'), + 'created-topic', + ); + } + await tester.pumpAndSettle(); + await tester.runAsync( + () => tester.tap(find.text(template ? 'Create' : 'OK')), + ); + final extension = choice == 'Empty XML Topic' ? 'topic' : 'md'; + await _settle( + tester, + until: () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath == + topicPath('created-topic.$extension') || + container.read(workspaceControllerProvider).message != null, + ); + final after = container.read(workspaceControllerProvider); + expect( + after.activeBuffer!.filePath, + topicPath('created-topic.$extension'), + reason: tester + .widgetList(find.byType(Text)) + .map((text) => text.data) + .whereType() + .join('\n'), + ); + expect( + after.activeBuffer!.editorState.mode, + DocumentViewModePreference.source, + ); + expect(find.byType(BusyMarkSourceEditor), findsOneWidget); + expect( + after.documentBuffers, + hasLength(before.documentBuffers.length + 1), + ); + for (final buffer in after.documentBuffers.where( + (buffer) => modes.containsKey(buffer.id), + )) { + expect(buffer.editorState.mode, modes[buffer.id]); + } + expect( + after.bufferForPath(other)!.text, + '# Unrelated unsaved document\n', + ); + expect(after.bufferForPath(other)!.isDirty, isTrue); + expect( + container.read(appSettingsControllerProvider).documentViewMode, + DocumentViewModePreference.preview, + ); + if (template) { + expect( + after.activeText, + contains( + 'A How-to article is an action-oriented type of document.', + ), + ); + } + }, + ); + } +} + +Future _settle(WidgetTester tester, {bool Function()? until}) async { + for (var i = 0; i < (until == null ? 30 : 300); i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 15)), + ); + await tester.pump(const Duration(milliseconds: 100)); + if (until?.call() == true) break; + } + if (until != null) { + expect( + until(), + isTrue, + reason: 'Timed out waiting for workspace operation', + ); + } + await tester.pumpAndSettle(); +} + +class _Settings implements LocalSettingsStore { + Map value = AppSettings.defaults() + .copyWith( + localeTag: 'en', + autoSave: false, + reopenPreviousWorkspaceOnStartup: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + @override + Future> load() async => value; + @override + Future save(Map json) async { + value = json; + } +} + +class _FallbackHeader extends LinuxHeaderBarService { + _FallbackHeader() + : super(channel: const MethodChannel('test.busymark/toc-header')); + @override + bool get isAvailable => false; + @override + bool get usesNativeHeaderBar => false; + @override + Stream get actions => const Stream.empty(); +} diff --git a/test/src/writerside_toc_workspace_test.dart b/test/src/writerside_toc_workspace_test.dart index 3a3319aa..80d6adca 100644 --- a/test/src/writerside_toc_workspace_test.dart +++ b/test/src/writerside_toc_workspace_test.dart @@ -166,6 +166,123 @@ void main() { }, ); + for (final scenario in [ + ( + name: 'empty instance', + attributes: '', + children: '', + topic: true, + home: 'unlinked.md', + ), + ( + name: 'groups only', + attributes: '', + children: '', + topic: true, + home: 'unlinked.md', + ), + ( + name: 'existing home', + attributes: ' start-page="home.md"', + children: '', + topic: true, + home: 'home.md', + ), + ( + name: 'library', + attributes: ' is-library="true"', + children: '', + topic: true, + home: null, + ), + ( + name: 'empty group insertion', + attributes: '', + children: '', + topic: false, + home: null, + ), + ( + name: 'existing nested topic', + attributes: '', + children: + '', + topic: true, + home: null, + ), + ]) { + test( + 'linking initializes the first-topic home page: ${scenario.name}', + () async { + final root = await fixture(); + final tree = File(p.join(root.path, 'guide.tree')); + await tree.writeAsString( + '${scenario.children}', + ); + const service = WorkspaceService(); + final workspace = await service.openPath(root.path); + final topic = workspace.writersideModule!.topicByReference( + 'unlinked.md', + )!; + final bytes = await File(topic.filePath).readAsBytes(); + await service.insertWritersideTocElement( + workspace, + treePath: tree.path, + request: WritersideTocInsertRequest( + placement: WritersideTopicCreatePlacement.root, + topicReference: scenario.topic ? 'unlinked.md' : null, + tocTitle: scenario.topic ? null : 'Empty group', + ), + expectedTopicPath: scenario.topic ? topic.filePath : null, + expectedTopicSource: scenario.topic ? topic.document.source : null, + ); + final xml = XmlDocument.parse(await tree.readAsString()).rootElement; + expect(xml.getAttribute('start-page'), scenario.home); + expect(xml.getAttribute('name'), 'Guide'); + expect(await File(topic.filePath).readAsBytes(), bytes); + expect( + xml.childElements.last.getAttribute( + scenario.topic ? 'topic' : 'toc-title', + ), + scenario.topic ? 'unlinked.md' : 'Empty group', + ); + }, + ); + } + + test( + 'first linked topic and home assignment share the same publication guard', + () async { + final root = await fixture(); + final tree = File(p.join(root.path, 'guide.tree')); + await tree.writeAsString(''); + const concurrent = ''; + final service = WorkspaceService( + writersideTocEditor: WritersideTocEditor( + beforeTreePublish: (_) => tree.writeAsString(concurrent), + ), + ); + final workspace = await service.openPath(root.path); + final topic = workspace.writersideModule!.topicByReference( + 'unlinked.md', + )!; + await expectLater( + service.insertWritersideTocElement( + workspace, + treePath: tree.path, + request: const WritersideTocInsertRequest( + placement: WritersideTopicCreatePlacement.root, + topicReference: 'unlinked.md', + ), + expectedTopicPath: topic.filePath, + expectedTopicSource: topic.document.source, + ), + throwsA(isA()), + ); + expect(await tree.readAsString(), concurrent); + }, + ); + test( 'title transaction rolls back both files on a concurrent dirty-buffer guard', () async { From fd1854434a09cd05eb5f116ffa738c8f32caab41 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 16:32:14 -0700 Subject: [PATCH 04/35] Fixed Edit Title defects and dialog-copy gaps. --- .../writerside-toc-implementation.md | 70 ++- lib/l10n/app_ar.arb | 9 +- lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 14 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_et.arb | 9 +- lib/l10n/app_fa.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_hi.arb | 9 +- lib/l10n/app_id.arb | 9 +- lib/l10n/app_it.arb | 9 +- lib/l10n/app_ja.arb | 9 +- lib/l10n/app_ko.arb | 9 +- lib/l10n/app_nb.arb | 9 +- lib/l10n/app_nl.arb | 9 +- lib/l10n/app_pl.arb | 9 +- lib/l10n/app_pt.arb | 9 +- lib/l10n/app_pt_BR.arb | 9 +- lib/l10n/app_ru.arb | 9 +- lib/l10n/app_tr.arb | 7 +- lib/l10n/app_uk.arb | 9 +- lib/l10n/app_vi.arb | 9 +- lib/l10n/app_zh.arb | 9 +- lib/l10n/app_zh_CN.arb | 9 +- lib/l10n/generated/app_localizations.dart | 26 +- lib/l10n/generated/app_localizations_ar.dart | 17 +- lib/l10n/generated/app_localizations_de.dart | 17 +- lib/l10n/generated/app_localizations_en.dart | 17 +- lib/l10n/generated/app_localizations_es.dart | 17 +- lib/l10n/generated/app_localizations_et.dart | 17 +- lib/l10n/generated/app_localizations_fa.dart | 17 +- lib/l10n/generated/app_localizations_fr.dart | 17 +- lib/l10n/generated/app_localizations_hi.dart | 17 +- lib/l10n/generated/app_localizations_id.dart | 17 +- lib/l10n/generated/app_localizations_it.dart | 17 +- lib/l10n/generated/app_localizations_ja.dart | 17 +- lib/l10n/generated/app_localizations_ko.dart | 17 +- lib/l10n/generated/app_localizations_nb.dart | 17 +- lib/l10n/generated/app_localizations_nl.dart | 17 +- lib/l10n/generated/app_localizations_pl.dart | 17 +- lib/l10n/generated/app_localizations_pt.dart | 34 +- lib/l10n/generated/app_localizations_ru.dart | 17 +- lib/l10n/generated/app_localizations_tr.dart | 15 +- lib/l10n/generated/app_localizations_uk.dart | 17 +- lib/l10n/generated/app_localizations_vi.dart | 17 +- lib/l10n/generated/app_localizations_zh.dart | 32 +- .../presentation/writerside_toc_dialogs.dart | 58 +- .../writerside_document_parser.dart | 1 + lib/src/writerside/writerside_parsers.dart | 23 +- .../writerside/writerside_title_editor.dart | 39 +- .../writerside/writerside_toc_navigation.dart | 5 +- .../writerside/writerside_topic_creator.dart | 43 +- .../writerside_title_regressions_test.dart | 498 ++++++++++++++++++ test/src/writerside_toc_regressions_test.dart | 36 ++ tools/writerside_toc_visual_smoke.dart | 19 + 55 files changed, 1237 insertions(+), 178 deletions(-) create mode 100644 test/src/writerside_title_regressions_test.dart diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md index a4c436f9..d815f836 100644 --- a/docs/development/writerside-toc-implementation.md +++ b/docs/development/writerside-toc-implementation.md @@ -1,8 +1,10 @@ # Writerside Table of Contents implementation record -Status: implemented, including native GTK submenus and the approved template -workflow. This is **not a claim of complete IDE, website, or pixel-perfect -Writerside parity**; retained safety and integration differences are listed below. +Status: authoring workflows implemented, including native GTK submenus and the +approved template workflow; exact-original UI coverage remains incomplete. +The two unidentified icon-only toolbar controls are still unimplemented. +This is **not a claim of complete IDE, website, or pixel-perfect Writerside +parity**; retained safety and integration differences are listed below. ## Baseline and decisions @@ -309,3 +311,65 @@ teardown error before the user interrupted verification. The isolated the completed full-suite rerun above also passed. No unrelated local-history or monitoring behavior was changed to hide that failure. Interrupted processes and temporary logs were lost, so completed verification was rerun after resuming. + +## Edit Title and dialog-copy review — 2026-09-14 + +This follow-up starts from clean commit `7023934`; the five earlier corrections +remain intact. No GTK menu implementation or approved adaptation was replaced. + +| Finding | Correction and regression evidence | +| --- | --- | +| Literal newlines inside quoted XML attributes had no source range | `_attributeSpans` now matches multiline values. `WritersideTitleEditor` refuses to treat an existing attribute with a missing range as absent, and validates the resulting full XML topic/tree (including duplicate attribute names) before `WorkspaceService` can stage either write. Generated Markdown title elements are also XML-validated. | +| CRLF attributes differed between raw tree identities and normalized editor buffers | Title editing opts into line-ending-normalized identity comparison. Raw structural mutations retain exact matching, and the original file snapshots are still checked before publication. Changed semantic content remains a conflict. | +| Markdown instance titles exposed and re-escaped entity spellings | `_topicTitleOverrides` reads top-level parsed semantic title elements, not raw-source matches. XML entities are decoded once by the parser; the writer still escapes text. Fenced examples are excluded and single-quoted instance attributes work. | +| Removal and advanced title dialog copy differed | English removal now uses `Set redirect to:`, `{count} usages found.`, and the pictured confirmation paragraph. Advanced title settings show the two inheritance explanations beneath their fields and a working documentation link. All 23 maintained ARBs were updated and localizations regenerated. | + +`writerside_title_regressions_test.dart` covers LF/CRLF and both quote styles, +changing and clearing overrides, preserved unrelated attributes/content, +missing/corrupt ranges with neither file published, duplicate XML attributes, +semantic title extraction, and actual save–reopen–edit dialog workflows. It also +checks unchanged OK and documentation-link activation. The live removal dialog +copy and cancellation are checked in `writerside_toc_regressions_test.dart`. + +Copy evidence: the official [instance-title screenshot](https://resources.jetbrains.com/help/img/writerside/edit_title_instance_specific.png), +[TOC-title screenshot](https://resources.jetbrains.com/help/img/writerside/edit_title_toc_title.png), +and [removal screenshot](https://resources.jetbrains.com/help/img/writerside/remove_topic_dialog.png), +cross-checked against installed 2026.07.8925 `EditTitleDialog.comment.*` and +`RemoveTocElementDialog.*` bundle entries. The usage-count period follows the +specified screenshot (the installed bundle omits it). The documentation link +opens the current [Topics page](https://www.jetbrains.com/help/writerside/topics.html), +which contains title inheritance and overrides; the installed dialog's older +`changing-topic-title.html` URL was not retrievable. + +This corrects the reported functional defects and specified copy gaps, not all +original-product presentation differences. The two unidentified icon-only +toolbar controls remain unimplemented/unverified; no guessed controls or names +were added. The existing removal promotion, mixed-XML sorting restriction, +BusyMark preview renderer, approved template editor/storage and Find sidebar +remain explicitly documented adaptations. Full original-Writerside parity is +not claimed. Translations are BusyMark translations, not verified original +JetBrains locale strings. + +Verification for this follow-up (Flutter 3.47.2 / Dart 3.13.2): + +- Full `flutter test --no-pub --concurrency=2 --file-reporter json:/tmp/busymark-title-full.json`: + exit 0, **1,938 passed, 58 optional integration skips**; 17 new regressions. +- Documented Writerside/source/controller/export suite, with `--concurrency=2`: + exit 0, **433 passed, 1 optional Typst skip**; + `/tmp/busymark-title-focused-suite.json`. +- `flutter analyze --no-pub`: exit 0, no issues. Localization generation, + formatting checks on nine non-generated Dart files, and `git diff --check`: + exit 0. +- `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**; + `/tmp/busymark-title-builder.log`. +- Native Linux harness: exit 0, **36 checks passed, 20 captures**; + [result](/tmp/busymark-title-native-review/result.json). The production + [advanced title dialog](/tmp/busymark-title-native-review/03-edit-title.png) + and [removal dialog](/tmp/busymark-title-native-review/06-removal.png) were + visually inspected. Real GTK menus and their keyboard/focus tests passed. + The private desktop emitted portal, synthetic-popup and D-Bus teardown + diagnostics; this is not a warning-free desktop claim. Only the disposable + `/tmp/busymark-toc-native-ZIOBPT` fixture was mutated. +- `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; + `/tmp/busymark-title-linux-build.log`. The bundle was restored to the normal + application after the harness run. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index d5949733..0b3186d9 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "إعدادات متقدمة", "tocInstanceTitleField": "عنوان '{instanceId}':", "tocOnlyTitleField": "عنوان خاص بجدول المحتويات:", + "tocInstanceTitleExplanation": "يُستخدم للمثيل الحالي فقط. يُورث من عنوان الموضوع افتراضيًا.", + "tocOnlyTitleExplanation": "يُستخدم في جدول المحتويات فقط. يُورث افتراضيًا من عنوان الموضوع أو من العنوان الخاص بالمثيل إن وُجد. تجد شرحًا للعناوين", + "tocTitleDocumentationLink": "هنا", "tocFindUsages": "بحث", "tocRefactorMenu": "إعادة الهيكلة", "tocSafeDelete": "حذف آمن", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "أزِل «⁨{topic}⁩» من المثيل المحدد. سيُحتفظ بملف الموضوع.", + "topicRemovalSummary": "هل تريد إزالة عنصر جدول المحتويات «⁨{topic}⁩»؟ لن يُحذف الملف المصدر المرتبط بجدول المحتويات.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "الاستخدامات ({count})", + "topicUsagesCount": "تم العثور على {count} من مواضع الاستخدام.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "تعيين إعادة التوجيه إلى", + "setRedirectTo": "تعيين إعادة التوجيه إلى:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 09e85971..a811e010 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Erweiterte Einstellungen", "tocInstanceTitleField": "Titel für '{instanceId}':", "tocOnlyTitleField": "Nur im Inhaltsverzeichnis verwendeter Titel:", + "tocInstanceTitleExplanation": "Wird nur für die aktuelle Instanz verwendet. Standardmäßig vom Thementitel übernommen.", + "tocOnlyTitleExplanation": "Wird nur im Inhaltsverzeichnis verwendet. Standardmäßig vom Thementitel oder, falls gesetzt, vom instanzspezifischen Titel übernommen. Erläuterungen zu Titeln finden Sie", + "tocTitleDocumentationLink": "hier", "tocFindUsages": "Suchen", "tocRefactorMenu": "Refaktorieren", "tocSafeDelete": "Sicher löschen", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "„{topic}“ aus der ausgewählten Instanz entfernen. Die Themendatei bleibt erhalten.", + "topicRemovalSummary": "Inhaltsverzeichniseintrag „{topic}“ entfernen? Die zugehörige Quelldatei wird nicht gelöscht.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Verwendungen ({count})", + "topicUsagesCount": "{count} Verwendungen gefunden.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Weiterleiten an", + "setRedirectTo": "Weiterleiten an:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 846c6e5d..9c7b3386 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -120,6 +120,12 @@ "tocInstanceTitleField": "Title for '{instanceId}':", "@tocInstanceTitleField": {"description":"Writerside Table of Contents command or dialog text.","placeholders":{"instanceId":{"type":"String"}}}, "tocOnlyTitleField": "TOC-only title:", + "tocInstanceTitleExplanation": "Used for the current instance only. By default, inherited from topic title.", + "tocOnlyTitleExplanation": "Used in TOC only. By default, inherited from the topic title or instance-specific title if set. Titles are explained", + "tocTitleDocumentationLink": "here", + "@tocInstanceTitleExplanation": {"description": "Visible inheritance explanation below the instance-specific title field."}, + "@tocOnlyTitleExplanation": {"description": "Visible TOC-only title inheritance explanation, followed by the documentation link."}, + "@tocTitleDocumentationLink": {"description": "Link following the title explanation, opening Writerside's title documentation."}, "@tocOnlyTitleField": {"description":"Writerside Table of Contents command or dialog text."}, "tocFindUsages": "Find", "@tocFindUsages": {"description":"Writerside Table of Contents command or dialog text."}, @@ -925,7 +931,7 @@ "@deleteTopicFile": {"description": "Action that deletes a Writerside topic source file."}, "removeAction": "Remove", "@removeAction": {"description": "Short action label for removing an item without deleting its source file."}, - "topicRemovalSummary": "Remove “{topic}” from the selected instance. The topic file will be kept.", + "topicRemovalSummary": "Remove TOC element '{topic}'? The source file associated with the TOC won't be deleted.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": {"topic": {"type": "String"}} @@ -942,9 +948,9 @@ }, "topicIsStartPageRemovalWarning": "This topic is used as an instance start page. Review its usages and assign another start page before continuing.", "@topicIsStartPageRemovalWarning": {"description": "Warning that blocks deletion of a Writerside start-page topic."}, - "topicUsagesCount": "Usages ({count})", + "topicUsagesCount": "{count} usages found.", "@topicUsagesCount": { - "description": "Heading with the number of Writerside topic usages.", + "description": "Sentence reporting the number of Writerside topic usages in the removal dialog.", "placeholders": {"count": {"type": "int"}} }, "noBreakingTopicUsages": "No references that would be broken were found.", @@ -972,7 +978,7 @@ "@updateUsagesAutomaticallyDescription": {"description": "Description of automatic Writerside usage updates."}, "manualUsageUpdatesRequired": "Some usages require manual changes before this refactoring.", "@manualUsageUpdatesRequired": {"description": "Message when some Writerside usages cannot be updated automatically."}, - "setRedirectTo": "Set redirect to", + "setRedirectTo": "Set redirect to:", "@setRedirectTo": {"description": "Option to redirect an old Writerside page to another topic."}, "noRedirectDescription": "Do not redirect the old published page.", "@noRedirectDescription": {"description": "Description when no Writerside page redirect is selected."}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4ba0b351..8fe51789 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Configuración avanzada", "tocInstanceTitleField": "Título para '{instanceId}':", "tocOnlyTitleField": "Título exclusivo del índice:", + "tocInstanceTitleExplanation": "Se usa solo para la instancia actual. De forma predeterminada, se hereda del título del tema.", + "tocOnlyTitleExplanation": "Se usa solo en el índice. De forma predeterminada, se hereda del título del tema o del título específico de la instancia, si se ha definido. Los títulos se explican", + "tocTitleDocumentationLink": "aquí", "tocFindUsages": "Buscar", "tocRefactorMenu": "Refactorizar", "tocSafeDelete": "Eliminación segura", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Quita «{topic}» de la instancia seleccionada. Se conservará el archivo del tema.", + "topicRemovalSummary": "¿Quitar el elemento «{topic}» del índice? No se eliminará el archivo fuente asociado al índice.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Usos ({count})", + "topicUsagesCount": "Se encontraron {count} usos.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Redirigir a", + "setRedirectTo": "Redirigir a:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 73c6c78e..de39ea19 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Täpsemad seaded", "tocInstanceTitleField": "Pealkiri eksemplarile '{instanceId}':", "tocOnlyTitleField": "Ainult sisukorra pealkiri:", + "tocInstanceTitleExplanation": "Kasutatakse ainult praeguse eksemplari jaoks. Vaikimisi päritakse teema pealkirjast.", + "tocOnlyTitleExplanation": "Kasutatakse ainult sisukorras. Vaikimisi päritakse teema pealkirjast või eksemplaripõhisest pealkirjast, kui see on määratud. Pealkirjade selgitused leiate", + "tocTitleDocumentationLink": "siit", "tocFindUsages": "Otsi", "tocRefactorMenu": "Refaktoreeri", "tocSafeDelete": "Turvaline kustutamine", @@ -1508,7 +1511,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "Eemalda „{topic}” valitud eksemplarist. Teemafail säilitatakse.", + "topicRemovalSummary": "Kas eemaldada sisukorra element „{topic}”? Sisukorraga seotud lähtefaili ei kustutata.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1539,7 +1542,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "Kasutuskohad ({count})", + "topicUsagesCount": "Leiti {count} kasutuskohta.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1597,7 +1600,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "Määra ümbersuunamine", + "setRedirectTo": "Määra ümbersuunamine:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3f91e034..2109e0d1 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "تنظیمات پیشرفته", "tocInstanceTitleField": "عنوان برای «{instanceId}»:", "tocOnlyTitleField": "عنوان مختص فهرست مطالب:", + "tocInstanceTitleExplanation": "فقط برای نمونهٔ فعلی استفاده می‌شود. به‌طور پیش‌فرض از عنوان موضوع به ارث می‌رسد.", + "tocOnlyTitleExplanation": "فقط در فهرست مطالب استفاده می‌شود. به‌طور پیش‌فرض از عنوان موضوع یا، در صورت تنظیم، عنوان مختص نمونه به ارث می‌رسد. توضیح عنوان‌ها را ببینید:", + "tocTitleDocumentationLink": "اینجا", "tocFindUsages": "یافتن", "tocRefactorMenu": "بازآرایی", "tocSafeDelete": "حذف ایمن", @@ -2615,7 +2618,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "«⁨{topic}⁩» را از نمونهٔ انتخاب‌شده حذف کنید. فایل موضوع نگه داشته می‌شود.", + "topicRemovalSummary": "عنصر فهرست مطالب «⁨{topic}⁩» حذف شود؟ فایل منبع مرتبط با فهرست مطالب حذف نخواهد شد.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2647,7 +2650,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "موارد استفاده ({count})", + "topicUsagesCount": "{count} مورد استفاده پیدا شد.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2707,7 +2710,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "تنظیم تغییر مسیر به", + "setRedirectTo": "تنظیم تغییر مسیر به:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8afbe257..d993e86b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Paramètres avancés", "tocInstanceTitleField": "Titre pour « {instanceId} »:", "tocOnlyTitleField": "Titre propre à la table des matières:", + "tocInstanceTitleExplanation": "Utilisé uniquement pour l’instance actuelle. Hérité par défaut du titre du sujet.", + "tocOnlyTitleExplanation": "Utilisé uniquement dans la table des matières. Hérité par défaut du titre du sujet ou du titre propre à l’instance, s’il est défini. Les titres sont expliqués", + "tocTitleDocumentationLink": "ici", "tocFindUsages": "Rechercher", "tocRefactorMenu": "Refactoriser", "tocSafeDelete": "Suppression sécurisée", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Retirez « {topic} » de l’instance sélectionnée. Le fichier du sujet sera conservé.", + "topicRemovalSummary": "Supprimer l’élément « {topic} » de la table des matières ? Le fichier source associé ne sera pas supprimé.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Utilisations ({count})", + "topicUsagesCount": "{count} utilisations trouvées.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Rediriger vers", + "setRedirectTo": "Rediriger vers :", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ed507d2c..39a3626d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "उन्नत सेटिंग्स", "tocInstanceTitleField": "'{instanceId}' के लिए शीर्षक:", "tocOnlyTitleField": "केवल विषय-सूची का शीर्षक:", + "tocInstanceTitleExplanation": "केवल वर्तमान इंस्टेंस के लिए उपयोग होता है। डिफ़ॉल्ट रूप से विषय के शीर्षक से प्राप्त होता है।", + "tocOnlyTitleExplanation": "केवल विषय-सूची में उपयोग होता है। डिफ़ॉल्ट रूप से विषय के शीर्षक या, यदि सेट हो, इंस्टेंस-विशिष्ट शीर्षक से प्राप्त होता है। शीर्षकों का विवरण पढ़ें:", + "tocTitleDocumentationLink": "यहाँ", "tocFindUsages": "खोजें", "tocRefactorMenu": "रिफ़ैक्टर", "tocSafeDelete": "सुरक्षित रूप से हटाएँ", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "“{topic}” को चुने गए इंस्टेंस से हटाएँ। विषय फ़ाइल रखी जाएगी।", + "topicRemovalSummary": "विषय-सूची से “{topic}” तत्व हटाएँ? विषय-सूची से जुड़ी स्रोत फ़ाइल नहीं हटाई जाएगी।", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "उपयोग ({count})", + "topicUsagesCount": "{count} उपयोग मिले।", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "इस पर रीडायरेक्ट करें", + "setRedirectTo": "इस पर रीडायरेक्ट करें:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6cd041b1..0737fbf9 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Pengaturan lanjutan", "tocInstanceTitleField": "Judul untuk '{instanceId}':", "tocOnlyTitleField": "Judul khusus daftar isi:", + "tocInstanceTitleExplanation": "Hanya digunakan untuk instance saat ini. Secara default, diwarisi dari judul topik.", + "tocOnlyTitleExplanation": "Hanya digunakan dalam daftar isi. Secara default, diwarisi dari judul topik atau judul khusus instance jika ditetapkan. Penjelasan judul tersedia", + "tocTitleDocumentationLink": "di sini", "tocFindUsages": "Cari", "tocRefactorMenu": "Refaktor", "tocSafeDelete": "Hapus dengan aman", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "Hapus “{topic}” dari instance yang dipilih. File topik akan disimpan.", + "topicRemovalSummary": "Hapus elemen daftar isi “{topic}”? File sumber yang terkait dengan daftar isi tidak akan dihapus.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "Penggunaan ({count})", + "topicUsagesCount": "{count} penggunaan ditemukan.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "Alihkan ke", + "setRedirectTo": "Alihkan ke:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d6b87057..51ac5993 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Impostazioni avanzate", "tocInstanceTitleField": "Titolo per '{instanceId}':", "tocOnlyTitleField": "Titolo solo nel sommario:", + "tocInstanceTitleExplanation": "Usato solo per l’istanza corrente. Per impostazione predefinita, viene ereditato dal titolo dell’argomento.", + "tocOnlyTitleExplanation": "Usato solo nel sommario. Per impostazione predefinita, viene ereditato dal titolo dell’argomento o da quello specifico dell’istanza, se impostato. I titoli sono spiegati", + "tocTitleDocumentationLink": "qui", "tocFindUsages": "Trova", "tocRefactorMenu": "Refactoring", "tocSafeDelete": "Eliminazione sicura", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Rimuovi «{topic}» dall’istanza selezionata. Il file dell’argomento verrà conservato.", + "topicRemovalSummary": "Rimuovere l’elemento «{topic}» dal sommario? Il file sorgente associato al sommario non verrà eliminato.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Utilizzi ({count})", + "topicUsagesCount": "{count} utilizzi trovati.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Reindirizza a", + "setRedirectTo": "Reindirizza a:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d2f85a53..9644b8a0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "詳細設定", "tocInstanceTitleField": "「{instanceId}」のタイトル:", "tocOnlyTitleField": "目次専用タイトル:", + "tocInstanceTitleExplanation": "現在のインスタンスにのみ使用されます。既定ではトピックのタイトルを継承します。", + "tocOnlyTitleExplanation": "目次にのみ使用されます。既定ではトピックのタイトル、または設定されている場合はインスタンス固有のタイトルを継承します。タイトルの説明は", + "tocTitleDocumentationLink": "こちら", "tocFindUsages": "検索", "tocRefactorMenu": "リファクタリング", "tocSafeDelete": "安全な削除", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "選択したインスタンスから「{topic}」を削除します。トピックファイルは保持されます。", + "topicRemovalSummary": "目次要素「{topic}」を削除しますか?目次に関連付けられたソースファイルは削除されません。", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "使用箇所({count})", + "topicUsagesCount": "{count} 件の使用箇所が見つかりました。", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "リダイレクト先を設定", + "setRedirectTo": "リダイレクト先を設定:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index f32ccc23..8e75a4e3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "고급 설정", "tocInstanceTitleField": "'{instanceId}'의 제목:", "tocOnlyTitleField": "목차 전용 제목:", + "tocInstanceTitleExplanation": "현재 인스턴스에만 사용됩니다. 기본적으로 토픽 제목을 상속합니다.", + "tocOnlyTitleExplanation": "목차에만 사용됩니다. 기본적으로 토픽 제목 또는 설정된 인스턴스별 제목을 상속합니다. 제목에 대한 설명:", + "tocTitleDocumentationLink": "여기", "tocFindUsages": "찾기", "tocRefactorMenu": "리팩터링", "tocSafeDelete": "안전하게 삭제", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "선택한 인스턴스에서 “{topic}”을(를) 제거합니다. 토픽 파일은 유지됩니다.", + "topicRemovalSummary": "목차 요소 “{topic}”을(를) 제거하시겠습니까? 목차에 연결된 소스 파일은 삭제되지 않습니다.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "사용처 ({count})", + "topicUsagesCount": "사용처 {count}개를 찾았습니다.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "다음으로 리디렉션 설정", + "setRedirectTo": "다음으로 리디렉션 설정:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index e74d4b7e..3d7550f4 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Avanserte innstillinger", "tocInstanceTitleField": "Tittel for '{instanceId}':", "tocOnlyTitleField": "Tittel kun for innholdsfortegnelsen:", + "tocInstanceTitleExplanation": "Brukes bare for den gjeldende instansen. Arves som standard fra emnetittelen.", + "tocOnlyTitleExplanation": "Brukes bare i innholdsfortegnelsen. Arves som standard fra emnetittelen eller den instansspesifikke tittelen hvis den er angitt. Titler er forklart", + "tocTitleDocumentationLink": "her", "tocFindUsages": "Finn", "tocRefactorMenu": "Refaktorer", "tocSafeDelete": "Sikker sletting", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Fjern «{topic}» fra den valgte instansen. Emnefilen beholdes.", + "topicRemovalSummary": "Fjerne innholdsfortegnelseselementet «{topic}»? Den tilknyttede kildefilen blir ikke slettet.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Bruk ({count})", + "topicUsagesCount": "{count} brukssteder funnet.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Omdiriger til", + "setRedirectTo": "Omdiriger til:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index da2b3b27..a252cd11 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Geavanceerde instellingen", "tocInstanceTitleField": "Titel voor '{instanceId}':", "tocOnlyTitleField": "Titel alleen voor inhoudsopgave:", + "tocInstanceTitleExplanation": "Alleen gebruikt voor de huidige instantie. Standaard overgenomen van de onderwerptitel.", + "tocOnlyTitleExplanation": "Alleen gebruikt in de inhoudsopgave. Standaard overgenomen van de onderwerptitel of de instantiespecifieke titel, indien ingesteld. Titels worden uitgelegd", + "tocTitleDocumentationLink": "hier", "tocFindUsages": "Zoeken", "tocRefactorMenu": "Refactoren", "tocSafeDelete": "Veilig verwijderen", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "Verwijder “{topic}” uit de geselecteerde instantie. Het onderwerpbestand wordt bewaard.", + "topicRemovalSummary": "Inhoudsopgave-element ‘{topic}’ verwijderen? Het bijbehorende bronbestand wordt niet verwijderd.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "Gebruik ({count})", + "topicUsagesCount": "{count} gebruikslocaties gevonden.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "Omleiding instellen naar", + "setRedirectTo": "Omleiding instellen naar:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 409aaa6b..c801993b 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Ustawienia zaawansowane", "tocInstanceTitleField": "Tytuł dla „{instanceId}”:", "tocOnlyTitleField": "Tytuł tylko w spisie treści:", + "tocInstanceTitleExplanation": "Używany tylko w bieżącej instancji. Domyślnie dziedziczony z tytułu tematu.", + "tocOnlyTitleExplanation": "Używany tylko w spisie treści. Domyślnie dziedziczony z tytułu tematu lub tytułu instancji, jeśli został ustawiony. Objaśnienia tytułów znajdziesz", + "tocTitleDocumentationLink": "tutaj", "tocFindUsages": "Znajdź", "tocRefactorMenu": "Refaktoryzacja", "tocSafeDelete": "Bezpieczne usuwanie", @@ -2602,7 +2605,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Usuń „{topic}” z wybranej instancji. Plik tematu zostanie zachowany.", + "topicRemovalSummary": "Usunąć element spisu treści „{topic}”? Plik źródłowy powiązany ze spisem treści nie zostanie usunięty.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2633,7 +2636,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Użycia ({count})", + "topicUsagesCount": "Znalezione użycia: {count}.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2691,7 +2694,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Ustaw przekierowanie do", + "setRedirectTo": "Ustaw przekierowanie do:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6ea67a61..026f8fb6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Definições avançadas", "tocInstanceTitleField": "Título para '{instanceId}':", "tocOnlyTitleField": "Título exclusivo do índice:", + "tocInstanceTitleExplanation": "Utilizado apenas na instância atual. Por predefinição, é herdado do título do tópico.", + "tocOnlyTitleExplanation": "Utilizado apenas no índice. Por predefiniçã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", + "tocTitleDocumentationLink": "aqui", "tocFindUsages": "Localizar", "tocRefactorMenu": "Refatorizar", "tocSafeDelete": "Eliminação segura", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Remova “{topic}” da instância selecionada. O ficheiro do tópico será mantido.", + "topicRemovalSummary": "Remover o elemento «{topic}» do índice? O ficheiro de origem associado ao índice não será eliminado.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Usos ({count})", + "topicUsagesCount": "{count} utilizações encontradas.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Redirecionar para", + "setRedirectTo": "Redirecionar para:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 79bfa3b5..5449e11d 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Configurações avançadas", "tocInstanceTitleField": "Título para '{instanceId}':", "tocOnlyTitleField": "Título exclusivo do índice:", + "tocInstanceTitleExplanation": "Usado apenas na instância atual. Por padrão, é herdado do título do tópico.", + "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", + "tocTitleDocumentationLink": "aqui", "tocFindUsages": "Localizar", "tocRefactorMenu": "Refatorar", "tocSafeDelete": "Exclusão segura", @@ -2576,7 +2579,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Remova “{topic}” da instância selecionada. O arquivo do tópico será mantido.", + "topicRemovalSummary": "Remover o elemento “{topic}” do índice? O arquivo-fonte associado ao índice não será excluído.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2607,7 +2610,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Usos ({count})", + "topicUsagesCount": "{count} usos encontrados.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2665,7 +2668,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Redirecionar para", + "setRedirectTo": "Redirecionar para:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 852e73c0..a295437b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Дополнительные настройки", "tocInstanceTitleField": "Заголовок для «{instanceId}»:", "tocOnlyTitleField": "Заголовок только для оглавления:", + "tocInstanceTitleExplanation": "Используется только для текущего экземпляра. По умолчанию наследуется от заголовка темы.", + "tocOnlyTitleExplanation": "Используется только в оглавлении. По умолчанию наследуется от заголовка темы или заголовка экземпляра, если он задан. О заголовках рассказано", + "tocTitleDocumentationLink": "здесь", "tocFindUsages": "Поиск", "tocRefactorMenu": "Рефакторинг", "tocSafeDelete": "Безопасное удаление", @@ -2602,7 +2605,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Удалить «{topic}» из выбранного экземпляра. Файл темы будет сохранён.", + "topicRemovalSummary": "Удалить элемент оглавления «{topic}»? Связанный с оглавлением исходный файл не будет удалён.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2633,7 +2636,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Использования ({count})", + "topicUsagesCount": "Найдено использований: {count}.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2691,7 +2694,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Перенаправить на", + "setRedirectTo": "Перенаправить на:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 590f1686..91a65d6a 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Gelişmiş ayarlar", "tocInstanceTitleField": "'{instanceId}' için başlık:", "tocOnlyTitleField": "Yalnızca içindekiler başlığı:", + "tocInstanceTitleExplanation": "Yalnızca geçerli örnek için kullanılır. Varsayılan olarak konu başlığından devralınır.", + "tocOnlyTitleExplanation": "Yalnızca içindekilerde kullanılır. Varsayılan olarak konu başlığından veya ayarlanmışsa örneğe özgü başlıktan devralınır. Başlıklar hakkında açıklama:", + "tocTitleDocumentationLink": "burada", "tocFindUsages": "Bul", "tocRefactorMenu": "Yeniden düzenle", "tocSafeDelete": "Güvenli sil", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "Seçili örnekten “{topic}” konusunu kaldır. Konu dosyası korunur.", + "topicRemovalSummary": "“{topic}” içindekiler öğesi kaldırılsın mı? İçindekiler ile ilişkili kaynak dosya silinmeyecek.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "Kullanımlar ({count})", + "topicUsagesCount": "{count} kullanım bulundu.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 00c277c1..c40a4d0d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Додаткові налаштування", "tocInstanceTitleField": "Заголовок для «{instanceId}»:", "tocOnlyTitleField": "Заголовок лише для змісту:", + "tocInstanceTitleExplanation": "Використовується лише для поточного екземпляра. За замовчуванням успадковується від заголовка теми.", + "tocOnlyTitleExplanation": "Використовується лише у змісті. За замовчуванням успадковується від заголовка теми або заголовка екземпляра, якщо його задано. Пояснення заголовків наведено", + "tocTitleDocumentationLink": "тут", "tocFindUsages": "Пошук", "tocRefactorMenu": "Рефакторинг", "tocSafeDelete": "Безпечне видалення", @@ -2602,7 +2605,7 @@ "@removeAction": { "description": "Generic remove action label." }, - "topicRemovalSummary": "Вилучити «{topic}» із вибраного екземпляра. Файл теми буде збережено.", + "topicRemovalSummary": "Вилучити елемент змісту «{topic}»? Пов’язаний зі змістом вихідний файл не буде видалено.", "@topicRemovalSummary": { "description": "Summary of removing a topic from one TOC while keeping its file.", "placeholders": { @@ -2633,7 +2636,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning shown when the topic is used as an instance start page." }, - "topicUsagesCount": "Використання ({count})", + "topicUsagesCount": "Знайдено використань: {count}.", "@topicUsagesCount": { "description": "Number of discovered topic usages.", "placeholders": { @@ -2691,7 +2694,7 @@ "@manualUsageUpdatesRequired": { "description": "Message shown when usages require manual updates." }, - "setRedirectTo": "Переспрямувати на", + "setRedirectTo": "Переспрямувати на:", "@setRedirectTo": { "description": "Option to redirect the removed topic URL to another topic." }, diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index e650b099..0d3bbbb2 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "Cài đặt nâng cao", "tocInstanceTitleField": "Tiêu đề cho '{instanceId}':", "tocOnlyTitleField": "Tiêu đề chỉ dành cho mục lục:", + "tocInstanceTitleExplanation": "Chỉ dùng cho thực thể hiện tại. Theo mặc định, kế thừa từ tiêu đề chủ đề.", + "tocOnlyTitleExplanation": "Chỉ dùng trong mục lục. Theo mặc định, kế thừa từ tiêu đề chủ đề hoặc tiêu đề riêng của thực thể nếu đã đặt. Phần giải thích về tiêu đề có", + "tocTitleDocumentationLink": "tại đây", "tocFindUsages": "Tìm", "tocRefactorMenu": "Tái cấu trúc", "tocSafeDelete": "Xóa an toàn", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "Xóa “{topic}” khỏi thực thể đã chọn. Tệp chủ đề sẽ được giữ lại.", + "topicRemovalSummary": "Xóa phần tử mục lục “{topic}”? Tệp nguồn được liên kết với mục lục sẽ không bị xóa.", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "Các tham chiếu ({count})", + "topicUsagesCount": "Tìm thấy {count} lượt sử dụng.", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "Đặt chuyển hướng đến", + "setRedirectTo": "Đặt chuyển hướng đến:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 23767628..013d3f43 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "高级设置", "tocInstanceTitleField": "“{instanceId}”的标题:", "tocOnlyTitleField": "仅用于目录的标题:", + "tocInstanceTitleExplanation": "仅用于当前实例。默认继承主题标题。", + "tocOnlyTitleExplanation": "仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见", + "tocTitleDocumentationLink": "此处", "tocFindUsages": "查找", "tocRefactorMenu": "重构", "tocSafeDelete": "安全删除", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "从选中的实例中移除“{topic}”。主题文件将被保留。", + "topicRemovalSummary": "移除目录元素“{topic}”?与目录关联的源文件不会被删除。", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "使用情况({count})", + "topicUsagesCount": "找到 {count} 处使用。", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "设置重定向到", + "setRedirectTo": "设置重定向到:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index c2a812c0..116e0b8a 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -58,6 +58,9 @@ "tocAdvancedSettings": "高级设置", "tocInstanceTitleField": "“{instanceId}”的标题:", "tocOnlyTitleField": "仅用于目录的标题:", + "tocInstanceTitleExplanation": "仅用于当前实例。默认继承主题标题。", + "tocOnlyTitleExplanation": "仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见", + "tocTitleDocumentationLink": "此处", "tocFindUsages": "查找", "tocRefactorMenu": "重构", "tocSafeDelete": "安全删除", @@ -1596,7 +1599,7 @@ "@removeAction": { "description": "Short action label for removing an item without deleting its source file." }, - "topicRemovalSummary": "从选中的实例中移除“{topic}”。主题文件将被保留。", + "topicRemovalSummary": "移除目录元素“{topic}”?与目录关联的源文件不会被删除。", "@topicRemovalSummary": { "description": "Summary in the dialog for removing a topic from one Writerside instance.", "placeholders": { @@ -1627,7 +1630,7 @@ "@topicIsStartPageRemovalWarning": { "description": "Warning that blocks deletion of a Writerside start-page topic." }, - "topicUsagesCount": "使用情况({count})", + "topicUsagesCount": "找到 {count} 处使用。", "@topicUsagesCount": { "description": "Heading with the number of Writerside topic usages.", "placeholders": { @@ -1685,7 +1688,7 @@ "@manualUsageUpdatesRequired": { "description": "Message when some Writerside usages cannot be updated automatically." }, - "setRedirectTo": "设置重定向到", + "setRedirectTo": "设置重定向到:", "@setRedirectTo": { "description": "Option to redirect an old Writerside page to another topic." }, diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 052f166c..b9f46c94 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -493,6 +493,24 @@ abstract class AppLocalizations { /// **'TOC-only title:'** String get tocOnlyTitleField; + /// Visible inheritance explanation below the instance-specific title field. + /// + /// In en, this message translates to: + /// **'Used for the current instance only. By default, inherited from topic title.'** + String get tocInstanceTitleExplanation; + + /// Visible TOC-only title inheritance explanation, followed by the documentation link. + /// + /// In en, this message translates to: + /// **'Used in TOC only. By default, inherited from the topic title or instance-specific title if set. Titles are explained'** + String get tocOnlyTitleExplanation; + + /// Link following the title explanation, opening Writerside's title documentation. + /// + /// In en, this message translates to: + /// **'here'** + String get tocTitleDocumentationLink; + /// Writerside Table of Contents command or dialog text. /// /// In en, this message translates to: @@ -2788,7 +2806,7 @@ abstract class AppLocalizations { /// Summary in the dialog for removing a topic from one Writerside instance. /// /// In en, this message translates to: - /// **'Remove “{topic}” from the selected instance. The topic file will be kept.'** + /// **'Remove TOC element \'{topic}\'? The source file associated with the TOC won\'t be deleted.'** String topicRemovalSummary(String topic); /// Summary in the Writerside safe-delete dialog. @@ -2809,10 +2827,10 @@ abstract class AppLocalizations { /// **'This topic is used as an instance start page. Review its usages and assign another start page before continuing.'** String get topicIsStartPageRemovalWarning; - /// Heading with the number of Writerside topic usages. + /// Sentence reporting the number of Writerside topic usages in the removal dialog. /// /// In en, this message translates to: - /// **'Usages ({count})'** + /// **'{count} usages found.'** String topicUsagesCount(int count); /// Safe-delete message when no breaking references were found. @@ -2884,7 +2902,7 @@ abstract class AppLocalizations { /// Option to redirect an old Writerside page to another topic. /// /// In en, this message translates to: - /// **'Set redirect to'** + /// **'Set redirect to:'** String get setRedirectTo; /// Description when no Writerside page redirect is selected. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index ba835ae5..d0092f7e 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -205,6 +205,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get tocOnlyTitleField => 'عنوان خاص بجدول المحتويات:'; + @override + String get tocInstanceTitleExplanation => + 'يُستخدم للمثيل الحالي فقط. يُورث من عنوان الموضوع افتراضيًا.'; + + @override + String get tocOnlyTitleExplanation => + 'يُستخدم في جدول المحتويات فقط. يُورث افتراضيًا من عنوان الموضوع أو من العنوان الخاص بالمثيل إن وُجد. تجد شرحًا للعناوين'; + + @override + String get tocTitleDocumentationLink => 'هنا'; + @override String get tocFindUsages => 'بحث'; @@ -1474,7 +1485,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'أزِل «⁨$topic⁩» من المثيل المحدد. سيُحتفظ بملف الموضوع.'; + return 'هل تريد إزالة عنصر جدول المحتويات «⁨$topic⁩»؟ لن يُحذف الملف المصدر المرتبط بجدول المحتويات.'; } @override @@ -1503,7 +1514,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'الاستخدامات ($count)'; + return 'تم العثور على $count من مواضع الاستخدام.'; } @override @@ -1555,7 +1566,7 @@ class AppLocalizationsAr extends AppLocalizations { 'تتطلب بعض الاستخدامات تغييرات يدوية قبل إعادة الهيكلة هذه.'; @override - String get setRedirectTo => 'تعيين إعادة التوجيه إلى'; + String get setRedirectTo => 'تعيين إعادة التوجيه إلى:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index b1733486..2cdfa95d 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -207,6 +207,17 @@ class AppLocalizationsDe extends AppLocalizations { String get tocOnlyTitleField => 'Nur im Inhaltsverzeichnis verwendeter Titel:'; + @override + String get tocInstanceTitleExplanation => + 'Wird nur für die aktuelle Instanz verwendet. Standardmäßig vom Thementitel übernommen.'; + + @override + String get tocOnlyTitleExplanation => + 'Wird nur im Inhaltsverzeichnis verwendet. Standardmäßig vom Thementitel oder, falls gesetzt, vom instanzspezifischen Titel übernommen. Erläuterungen zu Titeln finden Sie'; + + @override + String get tocTitleDocumentationLink => 'hier'; + @override String get tocFindUsages => 'Suchen'; @@ -1492,7 +1503,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '„$topic“ aus der ausgewählten Instanz entfernen. Die Themendatei bleibt erhalten.'; + return 'Inhaltsverzeichniseintrag „$topic“ entfernen? Die zugehörige Quelldatei wird nicht gelöscht.'; } @override @@ -1518,7 +1529,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Verwendungen ($count)'; + return '$count Verwendungen gefunden.'; } @override @@ -1568,7 +1579,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Einige Verwendungen müssen vor diesem Refactoring manuell geändert werden.'; @override - String get setRedirectTo => 'Weiterleiten an'; + String get setRedirectTo => 'Weiterleiten an:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 823c1847..8b72fa3e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -205,6 +205,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tocOnlyTitleField => 'TOC-only title:'; + @override + String get tocInstanceTitleExplanation => + 'Used for the current instance only. By default, inherited from topic title.'; + + @override + String get tocOnlyTitleExplanation => + 'Used in TOC only. By default, inherited from the topic title or instance-specific title if set. Titles are explained'; + + @override + String get tocTitleDocumentationLink => 'here'; + @override String get tocFindUsages => 'Find'; @@ -1469,7 +1480,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Remove “$topic” from the selected instance. The topic file will be kept.'; + return 'Remove TOC element \'$topic\'? The source file associated with the TOC won\'t be deleted.'; } @override @@ -1494,7 +1505,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Usages ($count)'; + return '$count usages found.'; } @override @@ -1543,7 +1554,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Some usages require manual changes before this refactoring.'; @override - String get setRedirectTo => 'Set redirect to'; + String get setRedirectTo => 'Set redirect to:'; @override String get noRedirectDescription => 'Do not redirect the old published page.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 396ff134..a20a24af 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -205,6 +205,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tocOnlyTitleField => 'Título exclusivo del índice:'; + @override + String get tocInstanceTitleExplanation => + 'Se usa solo para la instancia actual. De forma predeterminada, se hereda del título del tema.'; + + @override + String get tocOnlyTitleExplanation => + 'Se usa solo en el índice. De forma predeterminada, se hereda del título del tema o del título específico de la instancia, si se ha definido. Los títulos se explican'; + + @override + String get tocTitleDocumentationLink => 'aquí'; + @override String get tocFindUsages => 'Buscar'; @@ -1490,7 +1501,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Quita «$topic» de la instancia seleccionada. Se conservará el archivo del tema.'; + return '¿Quitar el elemento «$topic» del índice? No se eliminará el archivo fuente asociado al índice.'; } @override @@ -1515,7 +1526,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Usos ($count)'; + return 'Se encontraron $count usos.'; } @override @@ -1564,7 +1575,7 @@ class AppLocalizationsEs extends AppLocalizations { 'Algunos usos requieren cambios manuales antes de esta refactorización.'; @override - String get setRedirectTo => 'Redirigir a'; + String get setRedirectTo => 'Redirigir a:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 559b5d73..398b11e2 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -205,6 +205,17 @@ class AppLocalizationsEt extends AppLocalizations { @override String get tocOnlyTitleField => 'Ainult sisukorra pealkiri:'; + @override + String get tocInstanceTitleExplanation => + 'Kasutatakse ainult praeguse eksemplari jaoks. Vaikimisi päritakse teema pealkirjast.'; + + @override + String get tocOnlyTitleExplanation => + 'Kasutatakse ainult sisukorras. Vaikimisi päritakse teema pealkirjast või eksemplaripõhisest pealkirjast, kui see on määratud. Pealkirjade selgitused leiate'; + + @override + String get tocTitleDocumentationLink => 'siit'; + @override String get tocFindUsages => 'Otsi'; @@ -1473,7 +1484,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Eemalda „$topic” valitud eksemplarist. Teemafail säilitatakse.'; + return 'Kas eemaldada sisukorra element „$topic”? Sisukorraga seotud lähtefaili ei kustutata.'; } @override @@ -1498,7 +1509,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Kasutuskohad ($count)'; + return 'Leiti $count kasutuskohta.'; } @override @@ -1547,7 +1558,7 @@ class AppLocalizationsEt extends AppLocalizations { 'Mõnda kasutuskohta tuleb enne refaktoreerimist käsitsi muuta.'; @override - String get setRedirectTo => 'Määra ümbersuunamine'; + String get setRedirectTo => 'Määra ümbersuunamine:'; @override String get noRedirectDescription => 'Ära suuna vana avaldatud lehte ümber.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 8d62c5c2..191f69d1 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -205,6 +205,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String get tocOnlyTitleField => 'عنوان مختص فهرست مطالب:'; + @override + String get tocInstanceTitleExplanation => + 'فقط برای نمونهٔ فعلی استفاده می‌شود. به‌طور پیش‌فرض از عنوان موضوع به ارث می‌رسد.'; + + @override + String get tocOnlyTitleExplanation => + 'فقط در فهرست مطالب استفاده می‌شود. به‌طور پیش‌فرض از عنوان موضوع یا، در صورت تنظیم، عنوان مختص نمونه به ارث می‌رسد. توضیح عنوان‌ها را ببینید:'; + + @override + String get tocTitleDocumentationLink => 'اینجا'; + @override String get tocFindUsages => 'یافتن'; @@ -1497,7 +1508,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '«⁨$topic⁩» را از نمونهٔ انتخاب‌شده حذف کنید. فایل موضوع نگه داشته می‌شود.'; + return 'عنصر فهرست مطالب «⁨$topic⁩» حذف شود؟ فایل منبع مرتبط با فهرست مطالب حذف نخواهد شد.'; } @override @@ -1530,7 +1541,7 @@ class AppLocalizationsFa extends AppLocalizations { intl.NumberFormat.decimalPattern(localeName); final String countString = countNumberFormat.format(count); - return 'موارد استفاده ($countString)'; + return '$countString مورد استفاده پیدا شد.'; } @override @@ -1582,7 +1593,7 @@ class AppLocalizationsFa extends AppLocalizations { 'برخی موارد استفاده پیش از این بازآرایی به تغییر دستی نیاز دارند.'; @override - String get setRedirectTo => 'تنظیم تغییر مسیر به'; + String get setRedirectTo => 'تنظیم تغییر مسیر به:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 45816b0c..97bf619b 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -210,6 +210,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tocOnlyTitleField => 'Titre propre à la table des matières:'; + @override + String get tocInstanceTitleExplanation => + 'Utilisé uniquement pour l’instance actuelle. Hérité par défaut du titre du sujet.'; + + @override + String get tocOnlyTitleExplanation => + 'Utilisé uniquement dans la table des matières. Hérité par défaut du titre du sujet ou du titre propre à l’instance, s’il est défini. Les titres sont expliqués'; + + @override + String get tocTitleDocumentationLink => 'ici'; + @override String get tocFindUsages => 'Rechercher'; @@ -1494,7 +1505,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Retirez « $topic » de l’instance sélectionnée. Le fichier du sujet sera conservé.'; + return 'Supprimer l’élément « $topic » de la table des matières ? Le fichier source associé ne sera pas supprimé.'; } @override @@ -1519,7 +1530,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Utilisations ($count)'; + return '$count utilisations trouvées.'; } @override @@ -1569,7 +1580,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Certaines utilisations nécessitent des modifications manuelles avant cette refactorisation.'; @override - String get setRedirectTo => 'Rediriger vers'; + String get setRedirectTo => 'Rediriger vers :'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9a79aaef..936d3d68 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -205,6 +205,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tocOnlyTitleField => 'केवल विषय-सूची का शीर्षक:'; + @override + String get tocInstanceTitleExplanation => + 'केवल वर्तमान इंस्टेंस के लिए उपयोग होता है। डिफ़ॉल्ट रूप से विषय के शीर्षक से प्राप्त होता है।'; + + @override + String get tocOnlyTitleExplanation => + 'केवल विषय-सूची में उपयोग होता है। डिफ़ॉल्ट रूप से विषय के शीर्षक या, यदि सेट हो, इंस्टेंस-विशिष्ट शीर्षक से प्राप्त होता है। शीर्षकों का विवरण पढ़ें:'; + + @override + String get tocTitleDocumentationLink => 'यहाँ'; + @override String get tocFindUsages => 'खोजें'; @@ -1469,7 +1480,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '“$topic” को चुने गए इंस्टेंस से हटाएँ। विषय फ़ाइल रखी जाएगी।'; + return 'विषय-सूची से “$topic” तत्व हटाएँ? विषय-सूची से जुड़ी स्रोत फ़ाइल नहीं हटाई जाएगी।'; } @override @@ -1494,7 +1505,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'उपयोग ($count)'; + return '$count उपयोग मिले।'; } @override @@ -1542,7 +1553,7 @@ class AppLocalizationsHi extends AppLocalizations { 'इस रीफ़ैक्टरिंग से पहले कुछ उपयोगों को मैन्युअल रूप से बदलना होगा।'; @override - String get setRedirectTo => 'इस पर रीडायरेक्ट करें'; + String get setRedirectTo => 'इस पर रीडायरेक्ट करें:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 84004aaa..3c9adf82 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -205,6 +205,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get tocOnlyTitleField => 'Judul khusus daftar isi:'; + @override + String get tocInstanceTitleExplanation => + 'Hanya digunakan untuk instance saat ini. Secara default, diwarisi dari judul topik.'; + + @override + String get tocOnlyTitleExplanation => + 'Hanya digunakan dalam daftar isi. Secara default, diwarisi dari judul topik atau judul khusus instance jika ditetapkan. Penjelasan judul tersedia'; + + @override + String get tocTitleDocumentationLink => 'di sini'; + @override String get tocFindUsages => 'Cari'; @@ -1475,7 +1486,7 @@ class AppLocalizationsId extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Hapus “$topic” dari instance yang dipilih. File topik akan disimpan.'; + return 'Hapus elemen daftar isi “$topic”? File sumber yang terkait dengan daftar isi tidak akan dihapus.'; } @override @@ -1500,7 +1511,7 @@ class AppLocalizationsId extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Penggunaan ($count)'; + return '$count penggunaan ditemukan.'; } @override @@ -1548,7 +1559,7 @@ class AppLocalizationsId extends AppLocalizations { 'Beberapa penggunaan memerlukan perubahan manual sebelum pemfaktoran ulang ini.'; @override - String get setRedirectTo => 'Alihkan ke'; + String get setRedirectTo => 'Alihkan ke:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 159e8b2e..798b7e64 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -206,6 +206,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tocOnlyTitleField => 'Titolo solo nel sommario:'; + @override + String get tocInstanceTitleExplanation => + 'Usato solo per l’istanza corrente. Per impostazione predefinita, viene ereditato dal titolo dell’argomento.'; + + @override + String get tocOnlyTitleExplanation => + 'Usato solo nel sommario. Per impostazione predefinita, viene ereditato dal titolo dell’argomento o da quello specifico dell’istanza, se impostato. I titoli sono spiegati'; + + @override + String get tocTitleDocumentationLink => 'qui'; + @override String get tocFindUsages => 'Trova'; @@ -1486,7 +1497,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Rimuovi «$topic» dall’istanza selezionata. Il file dell’argomento verrà conservato.'; + return 'Rimuovere l’elemento «$topic» dal sommario? Il file sorgente associato al sommario non verrà eliminato.'; } @override @@ -1512,7 +1523,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Utilizzi ($count)'; + return '$count utilizzi trovati.'; } @override @@ -1562,7 +1573,7 @@ class AppLocalizationsIt extends AppLocalizations { 'Alcuni utilizzi richiedono modifiche manuali prima di questo refactoring.'; @override - String get setRedirectTo => 'Reindirizza a'; + String get setRedirectTo => 'Reindirizza a:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 1a200e6c..96cd6aad 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -200,6 +200,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tocOnlyTitleField => '目次専用タイトル:'; + @override + String get tocInstanceTitleExplanation => + '現在のインスタンスにのみ使用されます。既定ではトピックのタイトルを継承します。'; + + @override + String get tocOnlyTitleExplanation => + '目次にのみ使用されます。既定ではトピックのタイトル、または設定されている場合はインスタンス固有のタイトルを継承します。タイトルの説明は'; + + @override + String get tocTitleDocumentationLink => 'こちら'; + @override String get tocFindUsages => '検索'; @@ -1428,7 +1439,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '選択したインスタンスから「$topic」を削除します。トピックファイルは保持されます。'; + return '目次要素「$topic」を削除しますか?目次に関連付けられたソースファイルは削除されません。'; } @override @@ -1453,7 +1464,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String topicUsagesCount(int count) { - return '使用箇所($count)'; + return '$count 件の使用箇所が見つかりました。'; } @override @@ -1499,7 +1510,7 @@ class AppLocalizationsJa extends AppLocalizations { String get manualUsageUpdatesRequired => 'このリファクタリングの前に手動で変更が必要な使用箇所があります。'; @override - String get setRedirectTo => 'リダイレクト先を設定'; + String get setRedirectTo => 'リダイレクト先を設定:'; @override String get noRedirectDescription => '以前公開されたページをリダイレクトしない'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 8feae61c..36d6f832 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -199,6 +199,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tocOnlyTitleField => '목차 전용 제목:'; + @override + String get tocInstanceTitleExplanation => + '현재 인스턴스에만 사용됩니다. 기본적으로 토픽 제목을 상속합니다.'; + + @override + String get tocOnlyTitleExplanation => + '목차에만 사용됩니다. 기본적으로 토픽 제목 또는 설정된 인스턴스별 제목을 상속합니다. 제목에 대한 설명:'; + + @override + String get tocTitleDocumentationLink => '여기'; + @override String get tocFindUsages => '찾기'; @@ -1425,7 +1436,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '선택한 인스턴스에서 “$topic”을(를) 제거합니다. 토픽 파일은 유지됩니다.'; + return '목차 요소 “$topic”을(를) 제거하시겠습니까? 목차에 연결된 소스 파일은 삭제되지 않습니다.'; } @override @@ -1450,7 +1461,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String topicUsagesCount(int count) { - return '사용처 ($count)'; + return '사용처 $count개를 찾았습니다.'; } @override @@ -1496,7 +1507,7 @@ class AppLocalizationsKo extends AppLocalizations { String get manualUsageUpdatesRequired => '일부 사용처는 이 리팩터링 전에 수동으로 변경해야 합니다.'; @override - String get setRedirectTo => '다음으로 리디렉션 설정'; + String get setRedirectTo => '다음으로 리디렉션 설정:'; @override String get noRedirectDescription => '이전에 게시된 페이지를 리디렉션하지 마세요.'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 9f9ca234..8e4b0023 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -206,6 +206,17 @@ class AppLocalizationsNb extends AppLocalizations { @override String get tocOnlyTitleField => 'Tittel kun for innholdsfortegnelsen:'; + @override + String get tocInstanceTitleExplanation => + 'Brukes bare for den gjeldende instansen. Arves som standard fra emnetittelen.'; + + @override + String get tocOnlyTitleExplanation => + 'Brukes bare i innholdsfortegnelsen. Arves som standard fra emnetittelen eller den instansspesifikke tittelen hvis den er angitt. Titler er forklart'; + + @override + String get tocTitleDocumentationLink => 'her'; + @override String get tocFindUsages => 'Finn'; @@ -1476,7 +1487,7 @@ class AppLocalizationsNb extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Fjern «$topic» fra den valgte instansen. Emnefilen beholdes.'; + return 'Fjerne innholdsfortegnelseselementet «$topic»? Den tilknyttede kildefilen blir ikke slettet.'; } @override @@ -1501,7 +1512,7 @@ class AppLocalizationsNb extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Bruk ($count)'; + return '$count brukssteder funnet.'; } @override @@ -1550,7 +1561,7 @@ class AppLocalizationsNb extends AppLocalizations { 'Noe bruk må endres manuelt før denne refaktoreringen.'; @override - String get setRedirectTo => 'Omdiriger til'; + String get setRedirectTo => 'Omdiriger til:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 189c23fa..50d9ff0a 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -207,6 +207,17 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tocOnlyTitleField => 'Titel alleen voor inhoudsopgave:'; + @override + String get tocInstanceTitleExplanation => + 'Alleen gebruikt voor de huidige instantie. Standaard overgenomen van de onderwerptitel.'; + + @override + String get tocOnlyTitleExplanation => + 'Alleen gebruikt in de inhoudsopgave. Standaard overgenomen van de onderwerptitel of de instantiespecifieke titel, indien ingesteld. Titels worden uitgelegd'; + + @override + String get tocTitleDocumentationLink => 'hier'; + @override String get tocFindUsages => 'Zoeken'; @@ -1478,7 +1489,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Verwijder “$topic” uit de geselecteerde instantie. Het onderwerpbestand wordt bewaard.'; + return 'Inhoudsopgave-element ‘$topic’ verwijderen? Het bijbehorende bronbestand wordt niet verwijderd.'; } @override @@ -1504,7 +1515,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Gebruik ($count)'; + return '$count gebruikslocaties gevonden.'; } @override @@ -1553,7 +1564,7 @@ class AppLocalizationsNl extends AppLocalizations { 'Voor sommige verwijzingen zijn handmatige wijzigingen vereist voordat deze refactoring plaatsvindt.'; @override - String get setRedirectTo => 'Omleiding instellen naar'; + String get setRedirectTo => 'Omleiding instellen naar:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index e70f1951..9d4f7100 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -205,6 +205,17 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tocOnlyTitleField => 'Tytuł tylko w spisie treści:'; + @override + String get tocInstanceTitleExplanation => + 'Używany tylko w bieżącej instancji. Domyślnie dziedziczony z tytułu tematu.'; + + @override + String get tocOnlyTitleExplanation => + 'Używany tylko w spisie treści. Domyślnie dziedziczony z tytułu tematu lub tytułu instancji, jeśli został ustawiony. Objaśnienia tytułów znajdziesz'; + + @override + String get tocTitleDocumentationLink => 'tutaj'; + @override String get tocFindUsages => 'Znajdź'; @@ -1494,7 +1505,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Usuń „$topic” z wybranej instancji. Plik tematu zostanie zachowany.'; + return 'Usunąć element spisu treści „$topic”? Plik źródłowy powiązany ze spisem treści nie zostanie usunięty.'; } @override @@ -1522,7 +1533,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Użycia ($count)'; + return 'Znalezione użycia: $count.'; } @override @@ -1573,7 +1584,7 @@ class AppLocalizationsPl extends AppLocalizations { 'Niektóre użycia wymagają ręcznych zmian przed tą refaktoryzacją.'; @override - String get setRedirectTo => 'Ustaw przekierowanie do'; + String get setRedirectTo => 'Ustaw przekierowanie do:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 7f45c601..7ce5d496 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -205,6 +205,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tocOnlyTitleField => 'Título exclusivo do índice:'; + @override + String get tocInstanceTitleExplanation => + 'Utilizado apenas na instância atual. Por predefinição, é herdado do título do tópico.'; + + @override + String get tocOnlyTitleExplanation => + 'Utilizado apenas no índice. Por predefiniçã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 tocTitleDocumentationLink => 'aqui'; + @override String get tocFindUsages => 'Localizar'; @@ -1484,7 +1495,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Remova “$topic” da instância selecionada. O ficheiro do tópico será mantido.'; + return 'Remover o elemento «$topic» do índice? O ficheiro de origem associado ao índice não será eliminado.'; } @override @@ -1509,7 +1520,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Usos ($count)'; + return '$count utilizações encontradas.'; } @override @@ -1558,7 +1569,7 @@ class AppLocalizationsPt extends AppLocalizations { 'Alguns usos exigem alterações manuais antes desta refatoração.'; @override - String get setRedirectTo => 'Redirecionar para'; + String get setRedirectTo => 'Redirecionar para:'; @override String get noRedirectDescription => @@ -4702,6 +4713,17 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get tocOnlyTitleField => 'Título exclusivo do índice:'; + @override + String get tocInstanceTitleExplanation => + 'Usado apenas na instância atual. Por padrão, é herdado do título do tópico.'; + + @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'; + + @override + String get tocTitleDocumentationLink => 'aqui'; + @override String get tocFindUsages => 'Localizar'; @@ -5982,7 +6004,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String topicRemovalSummary(String topic) { - return 'Remova “$topic” da instância selecionada. O arquivo do tópico será mantido.'; + return 'Remover o elemento “$topic” do índice? O arquivo-fonte associado ao índice não será excluído.'; } @override @@ -6007,7 +6029,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String topicUsagesCount(int count) { - return 'Usos ($count)'; + return '$count usos encontrados.'; } @override @@ -6056,7 +6078,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 'Alguns usos exigem alterações manuais antes desta refatoração.'; @override - String get setRedirectTo => 'Redirecionar para'; + String get setRedirectTo => 'Redirecionar para:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 2d513ffa..6a639b91 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -205,6 +205,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tocOnlyTitleField => 'Заголовок только для оглавления:'; + @override + String get tocInstanceTitleExplanation => + 'Используется только для текущего экземпляра. По умолчанию наследуется от заголовка темы.'; + + @override + String get tocOnlyTitleExplanation => + 'Используется только в оглавлении. По умолчанию наследуется от заголовка темы или заголовка экземпляра, если он задан. О заголовках рассказано'; + + @override + String get tocTitleDocumentationLink => 'здесь'; + @override String get tocFindUsages => 'Поиск'; @@ -1491,7 +1502,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Удалить «$topic» из выбранного экземпляра. Файл темы будет сохранён.'; + return 'Удалить элемент оглавления «$topic»? Связанный с оглавлением исходный файл не будет удалён.'; } @override @@ -1518,7 +1529,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Использования ($count)'; + return 'Найдено использований: $count.'; } @override @@ -1569,7 +1580,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Некоторые использования необходимо изменить вручную перед этим рефакторингом.'; @override - String get setRedirectTo => 'Перенаправить на'; + String get setRedirectTo => 'Перенаправить на:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 67bb5210..b94bb7db 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -204,6 +204,17 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tocOnlyTitleField => 'Yalnızca içindekiler başlığı:'; + @override + String get tocInstanceTitleExplanation => + 'Yalnızca geçerli örnek için kullanılır. Varsayılan olarak konu başlığından devralınır.'; + + @override + String get tocOnlyTitleExplanation => + 'Yalnızca içindekilerde kullanılır. Varsayılan olarak konu başlığından veya ayarlanmışsa örneğe özgü başlıktan devralınır. Başlıklar hakkında açıklama:'; + + @override + String get tocTitleDocumentationLink => 'burada'; + @override String get tocFindUsages => 'Bul'; @@ -1475,7 +1486,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Seçili örnekten “$topic” konusunu kaldır. Konu dosyası korunur.'; + return '“$topic” içindekiler öğesi kaldırılsın mı? İçindekiler ile ilişkili kaynak dosya silinmeyecek.'; } @override @@ -1500,7 +1511,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Kullanımlar ($count)'; + return '$count kullanım bulundu.'; } @override diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 3dd44633..461faf33 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -205,6 +205,17 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tocOnlyTitleField => 'Заголовок лише для змісту:'; + @override + String get tocInstanceTitleExplanation => + 'Використовується лише для поточного екземпляра. За замовчуванням успадковується від заголовка теми.'; + + @override + String get tocOnlyTitleExplanation => + 'Використовується лише у змісті. За замовчуванням успадковується від заголовка теми або заголовка екземпляра, якщо його задано. Пояснення заголовків наведено'; + + @override + String get tocTitleDocumentationLink => 'тут'; + @override String get tocFindUsages => 'Пошук'; @@ -1498,7 +1509,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Вилучити «$topic» із вибраного екземпляра. Файл теми буде збережено.'; + return 'Вилучити елемент змісту «$topic»? Пов’язаний зі змістом вихідний файл не буде видалено.'; } @override @@ -1525,7 +1536,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Використання ($count)'; + return 'Знайдено використань: $count.'; } @override @@ -1576,7 +1587,7 @@ class AppLocalizationsUk extends AppLocalizations { 'Деякі використання потрібно змінити вручну перед цим рефакторингом.'; @override - String get setRedirectTo => 'Переспрямувати на'; + String get setRedirectTo => 'Переспрямувати на:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index d7f8f667..751469ab 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -205,6 +205,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String get tocOnlyTitleField => 'Tiêu đề chỉ dành cho mục lục:'; + @override + String get tocInstanceTitleExplanation => + 'Chỉ dùng cho thực thể hiện tại. Theo mặc định, kế thừa từ tiêu đề chủ đề.'; + + @override + String get tocOnlyTitleExplanation => + 'Chỉ dùng trong mục lục. Theo mặc định, kế thừa từ tiêu đề chủ đề hoặc tiêu đề riêng của thực thể nếu đã đặt. Phần giải thích về tiêu đề có'; + + @override + String get tocTitleDocumentationLink => 'tại đây'; + @override String get tocFindUsages => 'Tìm'; @@ -1468,7 +1479,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return 'Xóa “$topic” khỏi thực thể đã chọn. Tệp chủ đề sẽ được giữ lại.'; + return 'Xóa phần tử mục lục “$topic”? Tệp nguồn được liên kết với mục lục sẽ không bị xóa.'; } @override @@ -1493,7 +1504,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String topicUsagesCount(int count) { - return 'Các tham chiếu ($count)'; + return 'Tìm thấy $count lượt sử dụng.'; } @override @@ -1542,7 +1553,7 @@ class AppLocalizationsVi extends AppLocalizations { 'Một số cách sử dụng cần được thay đổi thủ công trước khi thực hiện tái cấu trúc này.'; @override - String get setRedirectTo => 'Đặt chuyển hướng đến'; + String get setRedirectTo => 'Đặt chuyển hướng đến:'; @override String get noRedirectDescription => diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 0e0bc900..aa44b7d8 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -199,6 +199,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tocOnlyTitleField => '仅用于目录的标题:'; + @override + String get tocInstanceTitleExplanation => '仅用于当前实例。默认继承主题标题。'; + + @override + String get tocOnlyTitleExplanation => + '仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见'; + + @override + String get tocTitleDocumentationLink => '此处'; + @override String get tocFindUsages => '查找'; @@ -1412,7 +1422,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String topicRemovalSummary(String topic) { - return '从选中的实例中移除“$topic”。主题文件将被保留。'; + return '移除目录元素“$topic”?与目录关联的源文件不会被删除。'; } @override @@ -1437,7 +1447,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String topicUsagesCount(int count) { - return '使用情况($count)'; + return '找到 $count 处使用。'; } @override @@ -1482,7 +1492,7 @@ class AppLocalizationsZh extends AppLocalizations { String get manualUsageUpdatesRequired => '执行此重构前,某些使用情况需要手动更改。'; @override - String get setRedirectTo => '设置重定向到'; + String get setRedirectTo => '设置重定向到:'; @override String get noRedirectDescription => '不重定向旧的已发布页面。'; @@ -4437,6 +4447,16 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get tocOnlyTitleField => '仅用于目录的标题:'; + @override + String get tocInstanceTitleExplanation => '仅用于当前实例。默认继承主题标题。'; + + @override + String get tocOnlyTitleExplanation => + '仅用于目录。默认继承主题标题;如果设置了实例专用标题,则继承该标题。标题的说明见'; + + @override + String get tocTitleDocumentationLink => '此处'; + @override String get tocFindUsages => '查找'; @@ -5650,7 +5670,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String topicRemovalSummary(String topic) { - return '从选中的实例中移除“$topic”。主题文件将被保留。'; + return '移除目录元素“$topic”?与目录关联的源文件不会被删除。'; } @override @@ -5675,7 +5695,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String topicUsagesCount(int count) { - return '使用情况($count)'; + return '找到 $count 处使用。'; } @override @@ -5720,7 +5740,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get manualUsageUpdatesRequired => '执行此重构前,某些使用情况需要手动更改。'; @override - String get setRedirectTo => '设置重定向到'; + String get setRedirectTo => '设置重定向到:'; @override String get noRedirectDescription => '不重定向旧的已发布页面。'; diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index 5a401e81..cdb86341 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; @@ -68,14 +69,29 @@ class _WritersideTitleDialogState extends State { String label, TextEditingController controller, { String? hint, + Widget? explanation, bool autofocus = false, }) => Padding( padding: const EdgeInsets.only(bottom: BusyMarkSpacing.md), - child: TextField( - controller: controller, - autofocus: autofocus, - decoration: InputDecoration(labelText: label, hintText: hint), - onSubmitted: (_) => _submit(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: autofocus, + decoration: InputDecoration(labelText: label, hintText: hint), + onSubmitted: (_) => _submit(), + ), + if (explanation != null) ...[ + const SizedBox(height: BusyMarkSpacing.xs), + DefaultTextStyle.merge( + style: TextStyle( + color: BusyMarkSurfaceColors.of(context).mutedForeground, + ), + child: explanation, + ), + ], + ], ), ); @override @@ -109,11 +125,43 @@ class _WritersideTitleDialogState extends State { context.l10n.tocInstanceTitleField(widget.session.instanceId), _instance, hint: _title.text, + explanation: Text(context.l10n.tocInstanceTitleExplanation), ), _field( context.l10n.tocOnlyTitleField, _toc, hint: _instance.text.isEmpty ? _title.text : _instance.text, + explanation: Text.rich( + TextSpan( + children: [ + TextSpan(text: '${context.l10n.tocOnlyTitleExplanation} '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => launchUrl( + Uri.parse( + 'https://www.jetbrains.com/help/writerside/topics.html', + ), + mode: LaunchMode.externalApplication, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.tocTitleDocumentationLink), + const SizedBox(width: BusyMarkSpacing.xs), + const Icon(BusyMarkGlyphs.externalLink, size: 14), + ], + ), + ), + ), + ], + ), + ), ), ], ], diff --git a/lib/src/writerside/writerside_document_parser.dart b/lib/src/writerside/writerside_document_parser.dart index 8f4898ca..bda103fa 100644 --- a/lib/src/writerside/writerside_document_parser.dart +++ b/lib/src/writerside/writerside_document_parser.dart @@ -411,6 +411,7 @@ Map _attributeSpans({ final result = {}; final matches = RegExp( r'''([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])(.*?)\2''', + dotAll: true, ).allMatches(openingSource); for (final match in matches) { final quoteOffset = openingSource.indexOf( diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index dd1e057e..eb421483 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -1120,7 +1120,7 @@ class WritersideTopicParser { span: element.span, ), ]; - final titleOverrides = _topicTitleOverrides(source); + final titleOverrides = _topicTitleOverrides(document); final videos = []; void collectVideos(Iterable blocks) { for (final block in blocks) { @@ -1455,16 +1455,19 @@ List _writersideMarkdownDiagnostics(List diagnostics) { ]; } -List _topicTitleOverrides(String source) { +List _topicTitleOverrides( + WritersideDocument document, +) { return [ - for (final match in RegExp( - r']*\binstance="([^"]+)")[^>]*>(.*?)', - dotAll: true, - ).allMatches(source)) - WritersideTopicTitleOverride( - instance: match.group(1)!.trim(), - title: match.group(2)!.trim(), - ), + // 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()) + if (element.name == 'title') + if (_trimmedOrNull(element.attributes['instance']) case final instance?) + WritersideTopicTitleOverride( + instance: instance, + title: element.plainText.trim(), + ), ]; } diff --git a/lib/src/writerside/writerside_title_editor.dart b/lib/src/writerside/writerside_title_editor.dart index 58fa6b4a..c84251df 100644 --- a/lib/src/writerside/writerside_title_editor.dart +++ b/lib/src/writerside/writerside_title_editor.dart @@ -1,6 +1,7 @@ import 'package:xml/xml.dart'; import '../core/busymark_exception.dart'; +import '../core/source_span.dart'; import '../markdown/busymark_document.dart'; import 'writerside_document.dart'; import 'writerside_document_parser.dart'; @@ -49,6 +50,7 @@ class WritersideTitleEditor { source: treeSource, path: tocPath, identity: tocIdentity, + normalizedText: true, ) == null) { throw const BusyMarkException('writerside.toc.tree-changed'); @@ -64,7 +66,7 @@ class WritersideTitleEditor { } if (topic.format == WritersideTopicFormat.xml) { final root = document.rootElement!; - final span = root.attributeSpans['title']; + final span = _attributeSpan(root, 'title'); if (span != null) { changes.add(( start: span.startOffset, @@ -148,6 +150,7 @@ class WritersideTitleEditor { [XmlAttribute(XmlName.parts('instance'), instanceId)], [XmlText(value)], ).toXmlString(); + if (replacement.isNotEmpty) _validateXml(replacement); if (overrides.isNotEmpty) { final node = overrides.single; changes.add(( @@ -204,6 +207,7 @@ class WritersideTitleEditor { source: treeSource, path: tocPath, identity: tocIdentity, + normalizedText: true, ); if (span == null) { throw const BusyMarkException('writerside.toc.tree-changed'); @@ -214,7 +218,7 @@ class WritersideTitleEditor { source: fragment, ); final root = parsed.rootElement!; - final valueSpan = root.attributeSpans['toc-title']; + final valueSpan = _attributeSpan(root, 'toc-title'); final value = edit.tocTitle!; if (valueSpan != null) { if (value.trim().isEmpty) { @@ -243,12 +247,43 @@ class WritersideTitleEditor { ); } } + // Validate both generated XML documents before the caller stages any + // writes. The fragment parser intentionally permits non-document input. + if (topic.format == WritersideTopicFormat.xml) { + _validateXml(topicSource); + } + _validateXml(resultTree); return WritersideTitleEditResult( topicSource: topicSource, treeSource: resultTree, ); } + static SourceSpan? _attributeSpan(WritersideElementNode node, String name) { + final span = node.attributeSpans[name]; + if (span == null && node.attributes.containsKey(name)) { + // A missing source range is not evidence of an absent attribute. + throw const BusyMarkException('writerside.toc.path-invalid'); + } + return span; + } + + static void _validateXml(String source) { + try { + final document = XmlDocument.parse(source); + for (final element in document.descendants.whereType()) { + final names = {}; + for (final attribute in element.attributes) { + if (!names.add(attribute.name)) { + throw const FormatException('Duplicate XML attribute'); + } + } + } + } on Object { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + } + static String _attribute(String text) => text .replaceAll('&', '&') .replaceAll('<', '<') diff --git a/lib/src/writerside/writerside_toc_navigation.dart b/lib/src/writerside/writerside_toc_navigation.dart index ed60ec76..7e07fca4 100644 --- a/lib/src/writerside/writerside_toc_navigation.dart +++ b/lib/src/writerside/writerside_toc_navigation.dart @@ -55,6 +55,7 @@ SourceSpan? writersideTocSourceSpan({ required List path, required WritersideTocNodeIdentity identity, bool xmlChildren = false, + bool normalizedText = false, }) { if (path.isEmpty) return null; final document = const WritersideDocumentParser().parseXml( @@ -80,7 +81,9 @@ SourceSpan? writersideTocSourceSpan({ final element = XmlDocument.parse( source.substring(node.span.startOffset, node.span.endOffset), ).rootElement; - return identity.matches(element) ? node.span : null; + return identity.matches(element, normalizedText: normalizedText) + ? node.span + : null; } on XmlParserException { return null; } diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 99e0af96..946293a7 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -91,22 +91,30 @@ class WritersideTocNodeIdentity { final bool workInProgress; final List children; - bool matches(XmlElement element) { + bool matches(XmlElement element, {bool normalizedText = false}) { + // Editor buffers normalize CRLF/CR to LF; raw disk mutations keep exact + // identity matching. Snapshot checks still protect the original bytes. + String? value(String? text) => normalizedText + ? text?.replaceAll('\r\n', '\n').replaceAll('\r', '\n') + : text; + bool attribute(String name, String? expected) => + value(element.getAttribute(name)) == value(expected); if (element.name.local != 'toc-element' || - element.getAttribute('topic') != topicFileName || - element.getAttribute('ref') != referenceTopicFileName || - element.getAttribute('in') != referenceInstanceId || - element.getAttribute('href') != href || - element.getAttribute('toc-title') != tocTitle || - element.getAttribute('id') != id || - element.getAttribute('accepts-web-file-names') != acceptsWebFileNames || - element.getAttribute('accepts-web-file-names-ref') != - acceptsWebFileNamesRef || - element.getAttribute('target-for-accept-web-file-names') != - targetForAcceptWebFileNames || - element.getAttribute('instance') != instanceCondition || - element.getAttribute('filter') != customFilter || - element.getAttribute('origin') != origin || + !attribute('topic', topicFileName) || + !attribute('ref', referenceTopicFileName) || + !attribute('in', referenceInstanceId) || + !attribute('href', href) || + !attribute('toc-title', tocTitle) || + !attribute('id', id) || + !attribute('accepts-web-file-names', acceptsWebFileNames) || + !attribute('accepts-web-file-names-ref', acceptsWebFileNamesRef) || + !attribute( + 'target-for-accept-web-file-names', + targetForAcceptWebFileNames, + ) || + !attribute('instance', instanceCondition) || + !attribute('filter', customFilter) || + !attribute('origin', origin) || (element.getAttribute('wip') == 'true') != workInProgress || (element.getAttribute('hidden') == 'true') != hidden) { return false; @@ -118,7 +126,10 @@ class WritersideTocNodeIdentity { return false; } for (var index = 0; index < children.length; index += 1) { - if (!children[index].matches(elementChildren[index])) { + if (!children[index].matches( + elementChildren[index], + normalizedText: normalizedText, + )) { return false; } } diff --git a/test/src/writerside_title_regressions_test.dart b/test/src/writerside_title_regressions_test.dart new file mode 100644 index 00000000..38120496 --- /dev/null +++ b/test/src/writerside_title_regressions_test.dart @@ -0,0 +1,498 @@ +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/core/source_span.dart'; +import 'package:busymark/src/workspace/presentation/writerside_toc_dialogs.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +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_topic_creator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +void main() { + const service = WorkspaceService(); + + setUp(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final name in ['yaru_window', 'yaru_window/events']) { + messenger.setMockMethodCallHandler( + MethodChannel(name), + (call) async => call.method == 'state' ? {} : null, + ); + } + }); + tearDown(() { + for (final name in ['yaru_window', 'yaru_window/events']) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(MethodChannel(name), null); + } + }); + + Future fixture( + String source, { + String extension = 'md', + String? tree, + }) async { + final root = await Directory.systemTemp.createTemp( + 'busymark-title-review-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File( + p.join(root.path, 'topics/topic.$extension'), + ).writeAsString(source); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'guide.tree')).writeAsString( + tree ?? + '', + ); + return root; + } + + Future session(Directory root) async { + final workspace = await service.openPath(root.path); + final instance = workspace.writersideModule!.instances.single; + return service.prepareWritersideTitleEdit( + workspace, + treePath: instance.sourceTreePath, + tocPath: [0], + identity: WritersideTocNodeIdentity.fromNode(instance.tocRoots.first), + ); + } + + Future save( + WritersideTitleEditSession session, + WritersideTitleEdit edit, + ) async { + await service.editWritersideTitles( + session, + edit, + validateBeforeCommit: () {}, + onCommitted: (_, _) {}, + ); + } + + for (final newline in ['\n', '\r\n']) { + for (final quote in ['"', "'"]) { + test( + 'multiline quoted titles change and clear safely (${newline.length}, $quote)', + () async { + final source = + '' + '$newlineOld${newline}instance' + '$newlineOther & title' + '$newline

    Unchanged

    $newline
    '; + final tree = + '$newline' + ''; + final root = await fixture(source, extension: 'topic', tree: tree); + final first = await session(root); + final span = + first.topic.document.rootElement!.attributeSpans['title']!; + expect( + first.topicLoad.text.substring(span.startOffset, span.endOffset), + 'Old\nbase', + ); + final rawSpan = const WritersideDocumentParser() + .parseXml(filePath: first.topic.filePath, source: source) + .rootElement! + .attributeSpans['title']!; + expect( + source.substring(rawSpan.startOffset, rawSpan.endOffset), + 'Old${newline}base', + ); + await save( + first, + const WritersideTitleEdit( + title: 'New & "base"', + instanceTitle: 'New & instance', + tocTitle: 'New & navigation', + ), + ); + final changed = await session(root); + final xml = XmlDocument.parse(changed.topicLoad.text).rootElement; + final toc = XmlDocument.parse( + changed.treeLoad.text, + ).findAllElements('toc-element').single; + expect( + xml.attributes.where((a) => a.name.local == 'title'), + hasLength(1), + ); + expect(xml.getAttribute('title'), 'New & "base"'); + expect(xml.getAttribute('audience'), 'keep'); + expect( + xml.getElement('p')!.toXmlString(), + '

    Unchanged

    ', + ); + expect(xml.getElement('title')!.innerText, 'New & instance'); + expect( + changed.topicLoad.text, + contains('Other & title'), + ); + expect( + toc.attributes.where((a) => a.name.local == 'toc-title'), + hasLength(1), + ); + expect(toc.getAttribute('toc-title'), 'New & navigation'); + expect(toc.getAttribute('hidden'), 'true'); + expect(toc.getAttribute('audience'), 'keep'); + await save( + changed, + const WritersideTitleEdit(instanceTitle: '', tocTitle: ''), + ); + final cleared = await session(root); + expect(cleared.topic.titleOverrides.map((title) => title.instance), [ + 'other', + ]); + expect(cleared.identity.tocTitle, isNull); + expect(cleared.topicLoad.text, contains('audience="keep"')); + expect( + cleared.treeLoad.text, + contains('hidden="true" audience="keep"'), + ); + expect(cleared.topic.title, 'New & "base"'); + if (newline == '\r\n') { + expect( + (await File( + cleared.topic.filePath, + ).readAsString()).replaceAll('\r\n', ''), + isNot(contains('\n')), + ); + expect( + (await File( + cleared.treePath, + ).readAsString()).replaceAll('\r\n', ''), + isNot(contains('\n')), + ); + } + }, + ); + + test( + 'clearing a multiline TOC override removes the existing attribute (${newline.length}, $quote)', + () async { + final root = await fixture( + '# Base\n', + tree: + '', + ); + final first = await session(root); + await save(first, const WritersideTitleEdit(tocTitle: '')); + final cleared = await session(root); + expect(cleared.identity.tocTitle, isNull); + expect(cleared.identity.hidden, isTrue); + expect(cleared.topicLoad.text, first.topicLoad.text); + }, + ); + } + } + + test( + 'normalized title identity still rejects changed content and raw matching stays exact', + () { + const identity = WritersideTocNodeIdentity( + hidden: false, + tocTitle: 'Old\r\nnavigation', + ); + final same = XmlDocument.parse( + '', + ).rootElement; + final changed = XmlDocument.parse( + '', + ).rootElement; + expect(identity.matches(same), isFalse); + expect(identity.matches(same, normalizedText: true), isTrue); + expect(identity.matches(changed, normalizedText: true), isFalse); + }, + ); + + for (final missing in [true, false]) { + test( + 'title transaction rejects ${missing ? 'missing' : 'corrupt'} attribute ranges before publication', + () async { + final root = await fixture( + '

    Body

    ', + extension: 'topic', + ); + final original = await session(root); + final topic = + WritersideTopicParser( + documentParser: _BrokenTitleSpanParser(missing: missing), + ).parseXml( + filePath: original.topic.filePath, + source: original.topicLoad.text, + ); + final broken = WritersideTitleEditSession( + topic: topic, + instanceId: original.instanceId, + treePath: original.treePath, + tocPath: original.tocPath, + identity: original.identity, + topicLoad: original.topicLoad, + treeLoad: original.treeLoad, + ); + var reachedPublication = false; + await expectLater( + service.editWritersideTitles( + broken, + const WritersideTitleEdit( + title: 'Changed', + tocTitle: 'Changed navigation', + ), + validateBeforeCommit: () => reachedPublication = true, + onCommitted: (_, _) => reachedPublication = true, + ), + throwsA(isA()), + ); + expect(reachedPublication, isFalse); + expect( + await File(original.topic.filePath).readAsString(), + original.topicLoad.text, + ); + expect( + await File(original.treePath).readAsString(), + original.treeLoad.text, + ); + }, + ); + } + + test( + 'title publication rejects duplicate attributes in resulting XML', + () async { + final root = await fixture( + '', + extension: 'topic', + ); + final original = await session(root); + await expectLater( + save( + original, + const WritersideTitleEdit(title: 'Changed', tocTitle: 'Changed'), + ), + throwsA(isA()), + ); + expect( + await File(original.topic.filePath).readAsString(), + original.topicLoad.text, + ); + expect( + await File(original.treePath).readAsString(), + original.treeLoad.text, + ); + }, + ); + + for (final quote in ['"', "'"]) { + test( + 'semantic Markdown instance titles ignore fenced examples and decode once ($quote)', + () { + final topic = const WritersideTopicParser().parseMarkdown( + filePath: '/topics/topic.md', + topicsRoot: '/topics', + source: + '# Base\n\n```xml\nWrong & example\n```\n\n' + 'A & B &amp; C\n', + ); + expect(topic.titleOverrides, hasLength(1)); + expect(topic.titleOverrides.single.instance, 'guide'); + expect(topic.titleOverrides.single.title, 'A & B & C'); + }, + ); + } + + Future showTitle( + WidgetTester tester, + WritersideTitleEditSession current, { + String? value, + bool inspectHelp = false, + }) async { + WritersideTitleEdit? edit; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async => + edit = await showDialog( + context: context, + builder: (_) => WritersideTitleDialog(session: current), + ), + child: const Text('Edit'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + 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 ?? + '', + ); + if (inspectHelp) { + expect( + find.text( + 'Used for the current instance only. By default, inherited from topic title.', + ), + findsOneWidget, + ); + expect( + find.textContaining('Used in TOC only.', findRichText: true), + findsOneWidget, + ); + final link = find.widgetWithText(TextButton, 'here'); + expect(link, findsOneWidget); + MethodCall? launched; + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + launched = call; + return true; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.tap(link); + await tester.pumpAndSettle(); + expect( + launched?.arguments['url'], + 'https://www.jetbrains.com/help/writerside/topics.html', + ); + } + if (value != null) await tester.enterText(field, value); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + return edit; + } + + testWidgets( + 'instance title save, reopen, unchanged OK, and edit keep one escaping layer', + (tester) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const example = + '```xml\nFenced & example\n```'; + final root = (await tester.runAsync( + () => fixture('# Base\n\n$example\n'), + ))!; + var current = (await tester.runAsync(() => session(root)))!; + expect(current.topic.titleOverrides, isEmpty); + final first = (await showTitle( + tester, + current, + value: 'A & B', + inspectHelp: true, + ))!; + expect(first.instanceTitle, 'A & B'); + await tester.runAsync(() => save(current, first)); + current = (await tester.runAsync(() => session(root)))!; + expect( + current.topicLoad.text, + contains('A & B'), + ); + expect(current.topic.titleOverrides.single.title, 'A & B'); + final unchanged = (await showTitle(tester, current))!; + expect(unchanged.instanceTitle, isNull); + final next = (await showTitle(tester, current, value: 'A & B updated'))!; + await tester.runAsync(() => save(current, next)); + current = (await tester.runAsync(() => session(root)))!; + expect( + current.topicLoad.text, + contains('A & B updated'), + ); + expect(current.topicLoad.text, isNot(contains('&amp; B'))); + expect(current.topicLoad.text, contains(example)); + expect(current.topic.titleOverrides.single.title, 'A & B updated'); + }, + ); + + testWidgets( + 'single-quoted Markdown override reopens decoded and edits without touching example', + (tester) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const example = "~~~xml\nExample\n~~~"; + final root = (await tester.runAsync( + () => fixture( + "# Base\n\n$example\n\nA & B\n", + ), + ))!; + 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'))!; + await tester.runAsync(() => save(current, edit)); + final updated = (await tester.runAsync(() => session(root)))!; + expect(updated.topic.titleOverrides.single.title, 'A & B updated'); + expect(updated.topicLoad.text, contains(example)); + }, + ); +} + +class _BrokenTitleSpanParser extends WritersideDocumentParser { + const _BrokenTitleSpanParser({required this.missing}); + final bool missing; + @override + WritersideDocument parseXml({ + required String filePath, + required String source, + }) { + final parsed = super.parseXml(filePath: filePath, source: source); + final root = parsed.rootElement!; + final span = root.attributeSpans['title']!; + return parsed.copyWith( + nodes: [ + WritersideGenericElementNode( + schemaKnown: true, + name: root.name, + qualifiedName: root.qualifiedName, + attributes: root.attributes, + qualifiedAttributes: root.qualifiedAttributes, + attributeSpans: { + for (final entry in root.attributeSpans.entries) + if (entry.key != 'title') entry.key: entry.value, + if (!missing) + 'title': SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: span.startOffset, + endOffset: span.endOffset + 1, + ), + }, + children: root.children, + span: root.span, + rawSource: root.rawSource, + ), + ], + ); + } +} diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index e76dcc26..80fe8af0 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -230,6 +230,42 @@ void main() { }, ); + testWidgets( + 'live removal dialog uses documented confirmation, redirect label and usage count', + (tester) async { + await start(tester); + final tree = File(p.join(root.path, 'guide.tree')); + final original = await tester.runAsync(tree.readAsString); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + buttons: kSecondaryMouseButton, + ); + await tester.pumpAndSettle(); + await tester.runAsync( + () => tester.tap(find.text('Remove TOC Element...')), + ); + await _settle( + tester, + until: () => find.text('Remove TOC Element').evaluate().isNotEmpty, + ); + expect( + find.text( + "Remove TOC element 'Other topic'? The source file associated with the TOC won't be deleted.", + ), + findsOneWidget, + ); + expect(find.text('Set redirect to:'), findsOneWidget); + expect( + find.textContaining(RegExp(r'^\d+ usages found\.$')), + findsOneWidget, + ); + expect(find.text('Set redirect to'), findsNothing); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(await tester.runAsync(tree.readAsString), original); + }, + ); + for (final choice in [ 'Empty MD Topic', 'Empty XML Topic', diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index f0e2c2a8..fecd3dad 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -420,6 +420,25 @@ class _HarnessState extends ConsumerState<_Harness> { await _capture('02-context-and-creation-menu'); await _tapLabel('Edit Title...'); await _tapLabel('Advanced Settings'); + _check( + _elements( + (widget) => + widget is Text && + widget.data == + 'Used for the current instance only. By default, inherited from topic title.', + ).isNotEmpty && + _elements( + (widget) => + widget is Text && + (widget.textSpan?.toPlainText() ?? '').contains( + 'Used in TOC only.', + ), + ).isNotEmpty && + _elements( + (widget) => widget is Text && widget.data == 'here', + ).isNotEmpty, + 'Advanced title settings show inheritance explanations and documentation link', + ); await _capture('03-edit-title'); await _tapLabel('Cancel'); await _menu('Welcome to BusyMark'); From 29ac59e926baba70762faaf4b0ce1b15503a41a9 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 17:16:13 -0700 Subject: [PATCH 05/35] Add native Linux Writerside dialog host implementation and integrate with TOC functionality. --- .../native_writerside_dialog_service.dart | 156 +++++++ .../presentation/workspace_screen.dart | 121 ++++- linux/runner/CMakeLists.txt | 1 + linux/runner/my_application.cc | 6 + linux/runner/writerside_dialog_host.cc | 418 ++++++++++++++++++ linux/runner/writerside_dialog_host.h | 12 + ...native_writerside_dialog_service_test.dart | 145 ++++++ .../native_writerside_dialog_source_test.dart | 23 + tools/linux_native_menu_probe.py | 40 ++ tools/writerside_toc_visual_smoke.dart | 37 +- 10 files changed, 914 insertions(+), 45 deletions(-) create mode 100644 lib/src/platform/native_writerside_dialog_service.dart create mode 100644 linux/runner/writerside_dialog_host.cc create mode 100644 linux/runner/writerside_dialog_host.h create mode 100644 test/src/native_writerside_dialog_service_test.dart create mode 100644 test/src/native_writerside_dialog_source_test.dart diff --git a/lib/src/platform/native_writerside_dialog_service.dart b/lib/src/platform/native_writerside_dialog_service.dart new file mode 100644 index 00000000..3d7c5d07 --- /dev/null +++ b/lib/src/platform/native_writerside_dialog_service.dart @@ -0,0 +1,156 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +@visibleForTesting +const nativeWritersideDialogChannelName = 'busymark/native_writerside_dialogs'; + +/// Result of asking the Linux host to present a Writerside dialog. +/// +/// [available] distinguishes a user cancellation (`value == null`) from a +/// platform without the native dialog host, where the Flutter fallback should +/// be shown instead. +@immutable +final class NativeWritersideDialogResult { + const NativeWritersideDialogResult.available(this.value) : available = true; + + const NativeWritersideDialogResult.unavailable() + : available = false, + value = null; + + final bool available; + final T? value; +} + +@immutable +final class NativeWritersideTitleValues { + const NativeWritersideTitleValues({ + required this.title, + required this.instanceTitle, + required this.tocTitle, + }); + + final String title; + final String instanceTitle; + final String tocTitle; +} + +/// Presents the Writerside dialogs that should be owned by the Linux toolkit. +class NativeWritersideDialogService { + const NativeWritersideDialogService({ + MethodChannel channel = const MethodChannel( + nativeWritersideDialogChannelName, + ), + }) : _channel = channel; + + final MethodChannel _channel; + + Future> showDuplicateTopic({ + required String title, + required String fileNameLabel, + required String initialValue, + required String cancelLabel, + required String okLabel, + required String requiredError, + required String invalidCharactersError, + required String duplicateError, + required Iterable existingTopicIds, + TextDirection? textDirection, + }) async { + try { + final value = await _channel.invokeMethod('showDuplicateTopic', { + 'title': title, + 'fileNameLabel': fileNameLabel, + 'initialValue': initialValue, + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + 'requiredError': requiredError, + 'invalidCharactersError': invalidCharactersError, + 'duplicateError': duplicateError, + 'existingTopicIds': existingTopicIds.toList(growable: false), + if (textDirection != null) 'textDirection': textDirection.name, + }); + if (value != null && value is! String) { + throw PlatformException(code: 'invalid-result'); + } + return NativeWritersideDialogResult.available(value as String?); + } on MissingPluginException { + return const NativeWritersideDialogResult.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return const NativeWritersideDialogResult.unavailable(); + } + rethrow; + } + } + + Future> + showEditTitle({ + required String dialogTitle, + required String topicTitleLabel, + required String advancedLabel, + required String instanceTitleLabel, + required String tocTitleLabel, + required String instanceExplanation, + required String tocExplanation, + required String documentationLabel, + required String documentationUrl, + required String initialTitle, + required String initialInstanceTitle, + required String initialTocTitle, + required String cancelLabel, + required String okLabel, + TextDirection? textDirection, + }) async { + try { + final value = await _channel.invokeMethod('showEditTitle', { + 'title': dialogTitle, + 'topicTitleLabel': topicTitleLabel, + 'advancedLabel': advancedLabel, + 'instanceTitleLabel': instanceTitleLabel, + 'tocTitleLabel': tocTitleLabel, + 'instanceExplanation': instanceExplanation, + 'tocExplanation': tocExplanation, + 'documentationLabel': documentationLabel, + 'documentationUrl': documentationUrl, + 'initialTitle': initialTitle, + 'initialInstanceTitle': initialInstanceTitle, + 'initialTocTitle': initialTocTitle, + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + if (textDirection != null) 'textDirection': textDirection.name, + }); + if (value == null) { + return const NativeWritersideDialogResult< + NativeWritersideTitleValues + >.available(null); + } + if (value case { + 'title': final String title, + 'instanceTitle': final String instanceTitle, + 'tocTitle': final String tocTitle, + }) { + return NativeWritersideDialogResult< + NativeWritersideTitleValues + >.available( + NativeWritersideTitleValues( + title: title, + instanceTitle: instanceTitle, + tocTitle: tocTitle, + ), + ); + } + throw PlatformException(code: 'invalid-result'); + } on MissingPluginException { + return const NativeWritersideDialogResult< + NativeWritersideTitleValues + >.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable') { + return const NativeWritersideDialogResult< + NativeWritersideTitleValues + >.unavailable(); + } + rethrow; + } + } +} diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 207d21a5..bf2e60e0 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -77,6 +77,7 @@ import '../../local_history/local_history_comparison_view.dart'; import '../../local_history/local_history_controller.dart'; import '../../local_history/local_history_panel.dart'; import '../../platform/linux_header_bar_service.dart'; +import '../../platform/native_writerside_dialog_service.dart'; import '../../search/search_replace_service.dart'; import '../../search/workspace_search_scope.dart'; import '../../visualization/visualization_card.dart'; @@ -6388,35 +6389,107 @@ class _TocTabState extends ConsumerState<_TocTab> { identity: WritersideTocNodeIdentity.fromNode(rawNode), ); if (session == null || !mounted || !context.mounted) return; - final edit = await showBusyMarkModalDialog( - context, - builder: (_) => WritersideTitleDialog(session: session), - ); + 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( + context, + builder: (_) => WritersideTitleDialog(session: session), + ); + } if (edit != null && mounted) { await controller.editWritersideTitles(session, edit); } case _TocTreeAction.duplicate: if (topic == null || rawNode == null) return; - final name = await showBusyMarkModalDialog( - context, - builder: (_) => WritersideTocTextDialog( - title: context.l10n.tocDuplicateDialog, - label: context.l10n.tocDuplicateFilename, - initialValue: p.basenameWithoutExtension(topic.fileName), - validate: (name) { - if (name.trim().isEmpty) return context.l10n.fileNameRequired; - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(name)) { - return context.l10n.useIdentifierCharacters; - } - if (widget.workspace.writersideModule!.topics.any( - (candidate) => candidate.id == name, - )) { - return context.l10n.topicIdAlreadyExists; - } - return null; - }, - ), - ); + String? validateDuplicateName(String name) { + if (name.trim().isEmpty) return context.l10n.fileNameRequired; + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(name)) { + return context.l10n.useIdentifierCharacters; + } + if (widget.workspace.writersideModule!.topics.any( + (candidate) => candidate.id == name, + )) { + return context.l10n.topicIdAlreadyExists; + } + return null; + } + + final nativeName = await const NativeWritersideDialogService() + .showDuplicateTopic( + title: context.l10n.tocDuplicateDialog, + fileNameLabel: context.l10n.tocDuplicateFilename, + initialValue: p.basenameWithoutExtension(topic.fileName), + cancelLabel: context.l10n.cancel, + okLabel: context.l10n.tocOk, + requiredError: context.l10n.fileNameRequired, + invalidCharactersError: context.l10n.useIdentifierCharacters, + duplicateError: context.l10n.topicIdAlreadyExists, + existingTopicIds: widget.workspace.writersideModule!.topics.map( + (candidate) => candidate.id, + ), + textDirection: Directionality.of(context), + ); + if (!mounted || !context.mounted) return; + final String? name; + if (nativeName.available) { + final value = nativeName.value; + name = value != null && validateDuplicateName(value) == null + ? value + : null; + } else { + name = await showBusyMarkModalDialog( + context, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocDuplicateDialog, + label: context.l10n.tocDuplicateFilename, + initialValue: p.basenameWithoutExtension(topic.fileName), + validate: validateDuplicateName, + ), + ); + } if (name == null || !mounted || !context.mounted) return; if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ instanceTreePath, diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index ea077532..20b36776 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -13,6 +13,7 @@ add_executable(${BINARY_NAME} "secure_credential_host.cc" "video_player_host.cc" "web_render_host.cc" + "writerside_dialog_host.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c20919cf..89c7794b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -14,6 +14,7 @@ #include "secure_credential_host.h" #include "video_player_host.h" #include "web_render_host.h" +#include "writerside_dialog_host.h" constexpr char kApplicationDisplayName[] = "BusyMark"; constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; @@ -84,6 +85,7 @@ struct _MyApplication { char** dart_entrypoint_arguments; FlMethodChannel* header_bar_channel; FlMethodChannel* native_menu_channel; + FlMethodChannel* writerside_dialog_channel; FlMethodChannel* asset_input_channel; FlMethodChannel* secure_credential_channel; FlMethodChannel* rich_clipboard_channel; @@ -3169,6 +3171,8 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); register_header_bar_channel(self, view); register_native_menu_channel(self, view); + self->writerside_dialog_channel = + busymark_writerside_dialog_channel_new(view, window); register_asset_input_channel(self, view); self->rich_clipboard_channel = busymark_rich_clipboard_channel_new(view); self->secure_credential_channel = @@ -3235,6 +3239,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->header_bar_css_provider); g_clear_object(&self->header_bar_channel); g_clear_object(&self->native_menu_channel); + g_clear_object(&self->writerside_dialog_channel); g_clear_object(&self->asset_input_channel); g_clear_object(&self->rich_clipboard_channel); g_clear_object(&self->secure_credential_channel); @@ -3279,6 +3284,7 @@ static void my_application_init(MyApplication* self) { self->dart_entrypoint_arguments = nullptr; self->header_bar_channel = nullptr; self->native_menu_channel = nullptr; + self->writerside_dialog_channel = nullptr; self->asset_input_channel = nullptr; self->secure_credential_channel = nullptr; self->visualization_host = nullptr; diff --git a/linux/runner/writerside_dialog_host.cc b/linux/runner/writerside_dialog_host.cc new file mode 100644 index 00000000..4466823a --- /dev/null +++ b/linux/runner/writerside_dialog_host.cc @@ -0,0 +1,418 @@ +#include "writerside_dialog_host.h" + +#include + +namespace { + +constexpr char kChannelName[] = "busymark/native_writerside_dialogs"; + +struct DialogHandlerData { + GtkWidget* view; + GtkWindow* parent; +}; + +const gchar* lookup_string(FlValue* args, const gchar* key) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* value = fl_value_lookup_string(args, key); + return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(value) + : nullptr; +} + +void respond_invalid_arguments(FlMethodCall* call, const gchar* message) { + fl_method_call_respond_error(call, "invalid-arguments", message, nullptr, + nullptr); +} + +GtkTextDirection requested_direction(FlValue* args, GtkWidget* fallback) { + const gchar* value = lookup_string(args, "textDirection"); + if (g_strcmp0(value, "rtl") == 0) { + return GTK_TEXT_DIR_RTL; + } + if (g_strcmp0(value, "ltr") == 0) { + return GTK_TEXT_DIR_LTR; + } + return gtk_widget_get_direction(fallback); +} + +void configure_dialog(GtkWidget* dialog, + GtkTextDirection direction, + gint width) { + gtk_widget_set_direction(dialog, direction); + gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); + gtk_window_set_default_size(GTK_WINDOW(dialog), width, -1); + gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + gtk_widget_set_margin_start(content, 18); + gtk_widget_set_margin_end(content, 18); + gtk_widget_set_margin_top(content, 14); + gtk_widget_set_margin_bottom(content, 8); + gtk_box_set_spacing(GTK_BOX(content), 8); +} + +GtkWidget* left_aligned_label(const gchar* text) { + GtkWidget* label = gtk_label_new(text); + gtk_label_set_xalign(GTK_LABEL(label), 0.0); + return label; +} + +GtkWidget* wrapping_explanation(const gchar* text) { + GtkWidget* label = left_aligned_label(text); + gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(label), 62); + GtkStyleContext* style = gtk_widget_get_style_context(label); + gtk_style_context_add_class(style, "dim-label"); + return label; +} + +gboolean has_only_identifier_characters(const gchar* value) { + if (value == nullptr || value[0] == '\0') { + return FALSE; + } + for (const guchar* current = reinterpret_cast(value); + *current != '\0'; ++current) { + if (!g_ascii_isalnum(*current) && *current != '_' && *current != '-') { + return FALSE; + } + } + return TRUE; +} + +struct DuplicateValidation { + GtkWidget* entry; + GtkWidget* error_label; + GtkWidget* ok_button; + const gchar* required_error; + const gchar* invalid_characters_error; + const gchar* duplicate_error; + GHashTable* existing_ids; +}; + +void update_duplicate_validation(GtkEditable*, gpointer user_data) { + auto* validation = static_cast(user_data); + const gchar* value = gtk_entry_get_text(GTK_ENTRY(validation->entry)); + g_autofree gchar* trimmed = g_strdup(value); + g_strstrip(trimmed); + const gchar* error = nullptr; + if (trimmed[0] == '\0') { + error = validation->required_error; + } else if (!has_only_identifier_characters(value)) { + error = validation->invalid_characters_error; + } else if (g_hash_table_contains(validation->existing_ids, value)) { + error = validation->duplicate_error; + } + gtk_label_set_text(GTK_LABEL(validation->error_label), + error != nullptr ? error : ""); + gtk_widget_set_visible(validation->error_label, error != nullptr); + gtk_widget_set_sensitive(validation->ok_button, error == nullptr); +} + +void show_duplicate_topic(DialogHandlerData* data, + FlMethodCall* call, + FlValue* args) { + const gchar* title = lookup_string(args, "title"); + const gchar* field_label = lookup_string(args, "fileNameLabel"); + const gchar* initial_value = lookup_string(args, "initialValue"); + const gchar* cancel_label = lookup_string(args, "cancelLabel"); + const gchar* ok_label = lookup_string(args, "okLabel"); + const gchar* required_error = lookup_string(args, "requiredError"); + const gchar* invalid_error = + lookup_string(args, "invalidCharactersError"); + const gchar* duplicate_error = lookup_string(args, "duplicateError"); + FlValue* existing_ids = args == nullptr + ? nullptr + : fl_value_lookup_string(args, + "existingTopicIds"); + if (title == nullptr || field_label == nullptr || initial_value == nullptr || + cancel_label == nullptr || ok_label == nullptr || + required_error == nullptr || invalid_error == nullptr || + duplicate_error == nullptr || existing_ids == nullptr || + fl_value_get_type(existing_ids) != FL_VALUE_TYPE_LIST) { + respond_invalid_arguments(call, + "Duplicate Topic dialog arguments are invalid."); + return; + } + + g_autoptr(GHashTable) ids = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, nullptr); + for (size_t index = 0; index < fl_value_get_length(existing_ids); ++index) { + FlValue* item = fl_value_get_list_value(existing_ids, index); + if (item == nullptr || fl_value_get_type(item) != FL_VALUE_TYPE_STRING) { + respond_invalid_arguments(call, + "existingTopicIds must contain strings."); + return; + } + g_hash_table_add(ids, g_strdup(fl_value_get_string(item))); + } + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + title, data->parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT), + cancel_label, GTK_RESPONSE_CANCEL, ok_label, GTK_RESPONSE_OK, nullptr); + configure_dialog(dialog, requested_direction(args, data->view), 470); + GtkWidget* ok_button = + gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* label = left_aligned_label(field_label); + GtkWidget* entry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(entry), initial_value); + gtk_entry_set_activates_default(GTK_ENTRY(entry), TRUE); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), entry); + GtkWidget* error_label = left_aligned_label(""); + gtk_style_context_add_class(gtk_widget_get_style_context(error_label), + "error"); + gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(content), entry, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(content), error_label, FALSE, FALSE, 0); + + DuplicateValidation validation = {entry, + error_label, + ok_button, + required_error, + invalid_error, + duplicate_error, + ids}; + g_signal_connect(entry, "changed", G_CALLBACK(update_duplicate_validation), + &validation); + update_duplicate_validation(GTK_EDITABLE(entry), &validation); + gtk_widget_show_all(dialog); + gtk_widget_set_visible(error_label, + gtk_label_get_text(GTK_LABEL(error_label))[0] != '\0'); + gtk_widget_grab_focus(entry); + gtk_editable_select_region(GTK_EDITABLE(entry), 0, -1); + + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + g_autofree gchar* selected = response == GTK_RESPONSE_OK + ? g_strdup(gtk_entry_get_text(GTK_ENTRY(entry))) + : nullptr; + gtk_widget_destroy(dialog); + if (data->view != nullptr && gtk_widget_get_realized(data->view)) { + gtk_widget_grab_focus(data->view); + } + g_autoptr(FlValue) result = selected == nullptr + ? fl_value_new_null() + : fl_value_new_string(selected); + fl_method_call_respond_success(call, result, nullptr); +} + +struct TitleInheritance { + GtkWidget* topic_entry; + GtkWidget* instance_entry; + GtkWidget* toc_entry; +}; + +void update_title_inheritance(GtkEditable*, gpointer user_data) { + auto* inheritance = static_cast(user_data); + const gchar* topic = + gtk_entry_get_text(GTK_ENTRY(inheritance->topic_entry)); + const gchar* instance = + gtk_entry_get_text(GTK_ENTRY(inheritance->instance_entry)); + 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); +} + +void update_title_validity(GtkEditable* editable, gpointer user_data) { + GtkWidget* ok_button = GTK_WIDGET(user_data); + g_autofree gchar* title = + g_strdup(gtk_entry_get_text(GTK_ENTRY(editable))); + g_strstrip(title); + gtk_widget_set_sensitive(ok_button, title[0] != '\0'); +} + +void add_title_field(GtkGrid* grid, + gint* row, + const gchar* label_text, + GtkWidget* entry, + GtkWidget* explanation) { + GtkWidget* label = left_aligned_label(label_text); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), entry); + gtk_grid_attach(grid, label, 0, *row, 1, 1); + ++*row; + gtk_grid_attach(grid, entry, 0, *row, 1, 1); + ++*row; + if (explanation != nullptr) { + gtk_grid_attach(grid, explanation, 0, *row, 1, 1); + ++*row; + } +} + +void show_edit_title(DialogHandlerData* data, + FlMethodCall* call, + FlValue* args) { + const gchar* keys[] = { + "title", "topicTitleLabel", "advancedLabel", + "instanceTitleLabel", "tocTitleLabel", "instanceExplanation", + "tocExplanation", "documentationLabel", "documentationUrl", + "initialTitle", "initialInstanceTitle", "initialTocTitle", + "cancelLabel", "okLabel", + }; + for (const gchar* key : keys) { + if (lookup_string(args, key) == nullptr) { + respond_invalid_arguments(call, + "Edit Title dialog arguments are invalid."); + return; + } + } + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + lookup_string(args, "title"), data->parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT), + lookup_string(args, "cancelLabel"), GTK_RESPONSE_CANCEL, + lookup_string(args, "okLabel"), GTK_RESPONSE_OK, nullptr); + configure_dialog(dialog, requested_direction(args, data->view), 560); + GtkWidget* ok_button = + gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + + GtkWidget* topic_entry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(topic_entry), + lookup_string(args, "initialTitle")); + gtk_entry_set_activates_default(GTK_ENTRY(topic_entry), TRUE); + GtkWidget* topic_label = left_aligned_label( + lookup_string(args, "topicTitleLabel")); + gtk_label_set_mnemonic_widget(GTK_LABEL(topic_label), topic_entry); + gtk_box_pack_start(GTK_BOX(content), topic_label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(content), topic_entry, FALSE, FALSE, 0); + + GtkWidget* advanced = gtk_expander_new( + lookup_string(args, "advancedLabel")); + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid), 8); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_widget_set_margin_top(grid, 8); + gtk_widget_set_margin_start(grid, 18); + GtkWidget* instance_entry = gtk_entry_new(); + GtkWidget* toc_entry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(instance_entry), + lookup_string(args, "initialInstanceTitle")); + gtk_entry_set_text(GTK_ENTRY(toc_entry), + lookup_string(args, "initialTocTitle")); + 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"))); + GtkWidget* toc_explanation_box = + gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_box_pack_start( + GTK_BOX(toc_explanation_box), + wrapping_explanation(lookup_string(args, "tocExplanation")), TRUE, TRUE, + 0); + GtkWidget* documentation = gtk_link_button_new_with_label( + lookup_string(args, "documentationUrl"), + lookup_string(args, "documentationLabel")); + gtk_widget_set_valign(documentation, GTK_ALIGN_CENTER); + gtk_box_pack_start(GTK_BOX(toc_explanation_box), documentation, FALSE, FALSE, + 0); + add_title_field(GTK_GRID(grid), &row, lookup_string(args, "tocTitleLabel"), + toc_entry, toc_explanation_box); + 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}; + g_signal_connect(topic_entry, "changed", + G_CALLBACK(update_title_inheritance), &inheritance); + g_signal_connect(instance_entry, "changed", + G_CALLBACK(update_title_inheritance), &inheritance); + g_signal_connect(topic_entry, "changed", G_CALLBACK(update_title_validity), + ok_button); + update_title_inheritance(GTK_EDITABLE(topic_entry), &inheritance); + update_title_validity(GTK_EDITABLE(topic_entry), ok_button); + gtk_widget_show_all(dialog); + gtk_widget_grab_focus(topic_entry); + gtk_editable_select_region(GTK_EDITABLE(topic_entry), 0, -1); + + const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + g_autofree gchar* topic = response == GTK_RESPONSE_OK + ? g_strdup(gtk_entry_get_text( + GTK_ENTRY(topic_entry))) + : nullptr; + g_autofree gchar* instance = response == GTK_RESPONSE_OK + ? g_strdup(gtk_entry_get_text( + GTK_ENTRY(instance_entry))) + : nullptr; + g_autofree gchar* toc = response == GTK_RESPONSE_OK + ? g_strdup(gtk_entry_get_text(GTK_ENTRY(toc_entry))) + : nullptr; + gtk_widget_destroy(dialog); + if (data->view != nullptr && gtk_widget_get_realized(data->view)) { + gtk_widget_grab_focus(data->view); + } + + if (response != GTK_RESPONSE_OK) { + g_autoptr(FlValue) result = fl_value_new_null(); + fl_method_call_respond_success(call, result, nullptr); + return; + } + g_autoptr(FlValue) result = fl_value_new_map(); + fl_value_set_string_take(result, "title", fl_value_new_string(topic)); + fl_value_set_string_take(result, "instanceTitle", + fl_value_new_string(instance)); + fl_value_set_string_take(result, "tocTitle", fl_value_new_string(toc)); + fl_method_call_respond_success(call, result, nullptr); +} + +void method_call_cb(FlMethodChannel*, + FlMethodCall* call, + gpointer user_data) { + auto* data = static_cast(user_data); + if (data->view == nullptr || data->parent == nullptr || + !gtk_widget_get_realized(GTK_WIDGET(data->parent))) { + fl_method_call_respond_error(call, "unavailable", + "The native dialog host is unavailable.", + nullptr, nullptr); + return; + } + const gchar* method = fl_method_call_get_name(call); + if (std::strcmp(method, "showDuplicateTopic") == 0) { + show_duplicate_topic(data, call, fl_method_call_get_args(call)); + } else if (std::strcmp(method, "showEditTitle") == 0) { + show_edit_title(data, call, fl_method_call_get_args(call)); + } else { + fl_method_call_respond_not_implemented(call, nullptr); + } +} + +void handler_data_free(gpointer user_data) { + auto* data = static_cast(user_data); + if (data->view != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->view), reinterpret_cast(&data->view)); + } + if (data->parent != nullptr) { + g_object_remove_weak_pointer( + G_OBJECT(data->parent), reinterpret_cast(&data->parent)); + } + g_free(data); +} + +} // namespace + +FlMethodChannel* busymark_writerside_dialog_channel_new(FlView* view, + GtkWindow* parent) { + g_return_val_if_fail(FL_IS_VIEW(view), nullptr); + g_return_val_if_fail(GTK_IS_WINDOW(parent), nullptr); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), kChannelName, + FL_METHOD_CODEC(codec)); + auto* data = g_new0(DialogHandlerData, 1); + data->view = GTK_WIDGET(view); + data->parent = parent; + g_object_add_weak_pointer(G_OBJECT(data->view), + reinterpret_cast(&data->view)); + g_object_add_weak_pointer(G_OBJECT(data->parent), + reinterpret_cast(&data->parent)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, data, + handler_data_free); + return channel; +} diff --git a/linux/runner/writerside_dialog_host.h b/linux/runner/writerside_dialog_host.h new file mode 100644 index 00000000..6c9548ef --- /dev/null +++ b/linux/runner/writerside_dialog_host.h @@ -0,0 +1,12 @@ +#ifndef BUSYMARK_WRITERSIDE_DIALOG_HOST_H_ +#define BUSYMARK_WRITERSIDE_DIALOG_HOST_H_ + +#include +#include + +// Creates the method channel that owns BusyMark's native Linux Writerside +// dialogs. The caller owns the returned reference. +FlMethodChannel* busymark_writerside_dialog_channel_new(FlView* view, + GtkWindow* parent); + +#endif // BUSYMARK_WRITERSIDE_DIALOG_HOST_H_ diff --git a/test/src/native_writerside_dialog_service_test.dart b/test/src/native_writerside_dialog_service_test.dart new file mode 100644 index 00000000..a08cdc30 --- /dev/null +++ b/test/src/native_writerside_dialog_service_test.dart @@ -0,0 +1,145 @@ +import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Duplicate Topic is serialized to the native dialog host', () async { + const channel = MethodChannel('busymark/test/native-writerside-duplicate'); + MethodCall? call; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (value) async { + call = value; + return 'install-copy'; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final result = await const NativeWritersideDialogService(channel: channel) + .showDuplicateTopic( + title: 'Duplicate Topic', + fileNameLabel: 'Topic Filename:', + initialValue: 'install', + cancelLabel: 'Cancel', + okLabel: 'OK', + requiredError: 'required', + invalidCharactersError: 'invalid', + duplicateError: 'duplicate', + existingTopicIds: const {'install', 'start'}, + textDirection: TextDirection.ltr, + ); + + expect(result.available, isTrue); + expect(result.value, 'install-copy'); + expect(call?.method, 'showDuplicateTopic'); + expect(call?.arguments, { + 'title': 'Duplicate Topic', + 'fileNameLabel': 'Topic Filename:', + 'initialValue': 'install', + 'cancelLabel': 'Cancel', + 'okLabel': 'OK', + 'requiredError': 'required', + 'invalidCharactersError': 'invalid', + 'duplicateError': 'duplicate', + 'existingTopicIds': ['install', 'start'], + 'textDirection': 'ltr', + }); + }); + + test('Edit Title returns all native field values', () async { + const channel = MethodChannel('busymark/test/native-writerside-title'); + MethodCall? call; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (value) async { + call = value; + return { + 'title': 'Published title', + 'instanceTitle': 'Instance title', + 'tocTitle': 'TOC title', + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final result = await const NativeWritersideDialogService(channel: channel) + .showEditTitle( + dialogTitle: 'Edit Title', + topicTitleLabel: 'Topic title:', + advancedLabel: 'Advanced Settings', + instanceTitleLabel: "Title for 'docs':", + tocTitleLabel: 'TOC-only title:', + instanceExplanation: 'Instance explanation', + tocExplanation: 'TOC explanation', + documentationLabel: 'here', + documentationUrl: 'https://example.test/titles', + initialTitle: 'Original', + initialInstanceTitle: '', + initialTocTitle: '', + cancelLabel: 'Cancel', + okLabel: 'OK', + textDirection: TextDirection.rtl, + ); + + expect(result.available, isTrue); + expect(result.value?.title, 'Published title'); + expect(result.value?.instanceTitle, 'Instance title'); + expect(result.value?.tocTitle, 'TOC title'); + expect(call?.method, 'showEditTitle'); + expect((call?.arguments as Map)['textDirection'], 'rtl'); + expect( + (call?.arguments as Map)['initialTitle'], + 'Original', + ); + }); + + test( + 'native cancellation remains distinct from an unavailable host', + () async { + const cancelChannel = MethodChannel('busymark/test/native-dialog-cancel'); + const missingChannel = MethodChannel( + 'busymark/test/native-dialog-missing', + ); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(cancelChannel, (_) async => null); + messenger.setMockMethodCallHandler( + missingChannel, + (_) async => throw MissingPluginException(), + ); + addTearDown(() { + messenger.setMockMethodCallHandler(cancelChannel, null); + messenger.setMockMethodCallHandler(missingChannel, null); + }); + + Future> invoke( + MethodChannel channel, + ) { + return NativeWritersideDialogService( + channel: channel, + ).showDuplicateTopic( + title: 'Duplicate Topic', + fileNameLabel: 'Topic Filename:', + initialValue: 'topic', + cancelLabel: 'Cancel', + okLabel: 'OK', + requiredError: 'required', + invalidCharactersError: 'invalid', + duplicateError: 'duplicate', + existingTopicIds: const [], + ); + } + + final cancelled = await invoke(cancelChannel); + final unavailable = await invoke(missingChannel); + expect(cancelled.available, isTrue); + expect(cancelled.value, isNull); + expect(unavailable.available, isFalse); + expect(unavailable.value, isNull); + }, + ); +} diff --git a/test/src/native_writerside_dialog_source_test.dart b/test/src/native_writerside_dialog_source_test.dart new file mode 100644 index 00000000..d55844e2 --- /dev/null +++ b/test/src/native_writerside_dialog_source_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Linux Writerside title and duplicate dialogs are GTK-owned', () { + final host = File( + 'linux/runner/writerside_dialog_host.cc', + ).readAsStringSync(); + final workspace = File( + 'lib/src/workspace/presentation/workspace_screen.dart', + ).readAsStringSync(); + + expect(host, contains('gtk_dialog_new_with_buttons')); + expect(host, contains('show_duplicate_topic')); + expect(host, contains('show_edit_title')); + expect(host, contains('gtk_expander_new')); + expect(host, contains('gtk_link_button_new_with_label')); + expect(workspace, contains('NativeWritersideDialogService()')); + expect(workspace, contains('.showEditTitle(')); + expect(workspace, contains('.showDuplicateTopic(')); + }); +} diff --git a/tools/linux_native_menu_probe.py b/tools/linux_native_menu_probe.py index e22e2d62..971f794d 100644 --- a/tools/linux_native_menu_probe.py +++ b/tools/linux_native_menu_probe.py @@ -34,6 +34,15 @@ def walk(node, depth=0): yield from walk(node.get_child_at_index(index), depth + 1) +def visible_dialog_nodes(app): + dialogs = [node for node in walk(app) + if node.get_role() == Atspi.Role.DIALOG + and node.get_state_set().contains(Atspi.StateType.SHOWING)] + if len(dialogs) != 1: + raise RuntimeError(f"Expected one visible native dialog, got {len(dialogs)}") + return list(walk(dialogs[0])) + + def key(name): x11 = ctypes.CDLL("libX11.so.6") xtst = ctypes.CDLL("libXtst.so.6") @@ -91,6 +100,37 @@ def main(): raise RuntimeError(f"Could not activate native menu item: {args[0]}") time.sleep(0.3) print("submenu" if submenu else "command") + elif command == "activate": + matches = [node for node in visible_dialog_nodes(app) + if node.get_name() == args[0] + and node.get_role() in (Atspi.Role.PUSH_BUTTON, + Atspi.Role.TOGGLE_BUTTON, + Atspi.Role.LINK) + and node.get_state_set().contains(Atspi.StateType.SHOWING)] + if len(matches) != 1: + raise RuntimeError(f"Expected one visible native control {args[0]!r}, got {len(matches)}") + control = matches[0] + if not control.get_state_set().contains(Atspi.StateType.ENABLED): + raise RuntimeError(f"Native control disabled: {args[0]}") + if not control.get_action_iface().do_action(0): + raise RuntimeError(f"Could not activate native control: {args[0]}") + time.sleep(0.3) + elif command == "has": + matches = [node for node in visible_dialog_nodes(app) + if node.get_name() == args[0] + and node.get_state_set().contains(Atspi.StateType.SHOWING)] + print("true" if matches else "false") + elif command == "set-entry": + index_arg, value = args[0].split("\n", 1) + entries = [node for node in visible_dialog_nodes(app) + if node.get_role() in (Atspi.Role.ENTRY, Atspi.Role.TEXT) + and node.get_state_set().contains(Atspi.StateType.SHOWING)] + index = int(index_arg) + if index < 0 or index >= len(entries): + raise RuntimeError(f"Visible native entry {index} not found; got {len(entries)}") + if not entries[index].set_text_contents(value): + raise RuntimeError(f"Could not set native entry {index}") + time.sleep(0.3) else: raise RuntimeError(f"Unknown probe command {command}") diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index fecd3dad..b661a943 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -419,28 +419,23 @@ class _HarnessState extends ConsumerState<_Harness> { await _menu('Welcome to BusyMark'); await _capture('02-context-and-creation-menu'); await _tapLabel('Edit Title...'); - await _tapLabel('Advanced Settings'); + await _native('activate', 'Advanced Settings'); _check( - _elements( - (widget) => - widget is Text && - widget.data == - 'Used for the current instance only. By default, inherited from topic title.', - ).isNotEmpty && - _elements( - (widget) => - widget is Text && - (widget.textSpan?.toPlainText() ?? '').contains( - 'Used in TOC only.', - ), - ).isNotEmpty && - _elements( - (widget) => widget is Text && widget.data == 'here', - ).isNotEmpty, - 'Advanced title settings show inheritance explanations and documentation link', + await _native( + 'has', + 'Used for the current instance only. By default, inherited from topic title.', + ) == + 'true' && + await _native( + 'has', + 'Used in TOC only. By default, inherited from the topic title or instance-specific title if set. Titles are explained', + ) == + 'true' && + await _native('has', 'here') == 'true', + 'Native advanced title settings show inheritance explanations and documentation link', ); await _capture('03-edit-title'); - await _tapLabel('Cancel'); + await _native('activate', 'Cancel'); await _menu('Welcome to BusyMark'); await _tapLabel('Preview Topic'); await _until( @@ -857,8 +852,8 @@ class _HarnessState extends ConsumerState<_Harness> { ); await _menu('XML guide title'); await _tapLabel('Duplicate'); - await _fill('Topic Filename:', 'native-duplicate'); - await _tapLabel('OK'); + await _native('set-entry', '0\nnative-duplicate'); + await _native('activate', 'OK'); final duplicate = File( p.join(widget.root.path, 'topics/native-duplicate.topic'), ); From f06d0633b03e9a5cd58a3772a1d27bfdf59e6254 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 17:52:12 -0700 Subject: [PATCH 06/35] Enhance Writerside TOC handling: add module-based topic management, improve topic file renaming with comprehensive reference tracking, and fix underscore rendering in native menus. --- .../presentation/workspace_screen.dart | 86 ++- lib/src/workspace/workspace_controller.dart | 24 +- lib/src/workspace/workspace_service.dart | 107 +++- .../writerside_topic_file_editor.dart | 494 +++++++++++++++--- linux/runner/my_application.cc | 25 +- test/src/app_smoke_test.dart | 201 +++++++ .../writerside_title_regressions_test.dart | 57 ++ test/src/writerside_toc_workspace_test.dart | 4 + .../writerside_topic_file_editor_test.dart | 153 ++++++ tools/writerside_toc_visual_smoke.dart | 12 + 10 files changed, 1065 insertions(+), 98 deletions(-) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index bf2e60e0..46bf61c9 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -5358,7 +5358,9 @@ class _TocTabState extends ConsumerState<_TocTab> { void _rememberSyncFocus() { final focused = FocusManager.instance.primaryFocus?.context; - if (focused == null || + if (!mounted || + focused == null || + !focused.mounted || focused.findAncestorWidgetOfExactType<_TocHeader>() != null) { return; } @@ -6155,22 +6157,31 @@ class _TocTabState extends ConsumerState<_TocTab> { } Future _copyTocEntries(List<_TocTreeEntry> entries) async { - final module = widget.workspace.writersideModule; final pieces = []; final state = ref.read(workspaceControllerProvider); - final activePath = state.workspace?.activeFilePath; for (final entry in entries) { final reference = entry.node.topicReference; - final topic = reference == null || entry.node.origin != null + final owner = entry.node.origin == null + ? widget.workspace.writersideModule + : widget.workspace.writersideProject?.modulesByOrigin[entry + .node + .origin]; + final topic = reference == null ? null - : module?.topicByReference(reference); + : owner?.topicByReference(reference); if (topic == null) { pieces.add(_tocNodeLabel(context, entry.node)); - } else if (activePath != null && p.equals(activePath, topic.filePath)) { - pieces.add(state.activeText); } else { + final openBuffer = state.documentBuffers + .where( + (buffer) => + buffer.filePath != null && + p.equals(buffer.filePath!, topic.filePath), + ) + .firstOrNull; pieces.add( - await ref.read(workspaceServiceProvider).loadText(topic.filePath), + openBuffer?.text ?? + await ref.read(workspaceServiceProvider).loadText(topic.filePath), ); } } @@ -6236,6 +6247,12 @@ class _TocTabState extends ConsumerState<_TocTab> { final rawNode = canEditStructure ? _rawTocNodeForEntry(widget.workspace, instanceTreePath, entry) : null; + final topicOwner = entry.node.origin == null + ? widget.workspace.writersideModule + : widget.workspace.writersideProject?.modulesByOrigin[entry + .node + .origin]; + final canEditTopicEntry = rawNode?.topicFileName != null; final canPaste = _cutEntries.isNotEmpty && _cutEntries.every( @@ -6263,6 +6280,7 @@ class _TocTabState extends ConsumerState<_TocTab> { context, position, hasTopicFile: topicPath != null, + canEditTopicEntry: canEditTopicEntry, showHistory: historyFile != null, showPaste: canPaste, enableGitActions: gitRelativePath != null, @@ -6271,11 +6289,12 @@ class _TocTabState extends ConsumerState<_TocTab> { canRefineSelection: canRefineSelection, canEditSelection: canEditSelection, canSetHome: + canEditTopicEntry && _tocInstanceForTreePath( - widget.workspace.writersideModule!, - instanceTreePath, - )?.isLibrary == - false, + widget.workspace.writersideModule!, + instanceTreePath, + )?.isLibrary == + false, topicReference: entry.node.topicReference, topicTitle: topic?.title, tocId: entry.node.id, @@ -6373,7 +6392,12 @@ class _TocTabState extends ConsumerState<_TocTab> { choice: _TocCreationChoice.template, ); case _TocTreeAction.editTitle: - if (rawNode == null || topic == null) return; + if (rawNode == null || + topic == null || + topicOwner == null || + !canEditTopicEntry) { + return; + } if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ topic.filePath, instanceTreePath, @@ -6387,6 +6411,8 @@ class _TocTabState extends ConsumerState<_TocTab> { treePath: instanceTreePath, tocPath: entry.editPath!, identity: WritersideTocNodeIdentity.fromNode(rawNode), + topicModuleRoot: topicOwner.rootPath, + topicPath: topic.filePath, ); if (session == null || !mounted || !context.mounted) return; final originalTitle = session.topic.title ?? ''; @@ -6670,7 +6696,7 @@ class _TocTabState extends ConsumerState<_TocTab> { await Clipboard.setData(ClipboardData(text: value)); } case _TocTreeAction.setHome: - if (rawNode == null || topic == null) return; + if (rawNode == null || topic == null || !canEditTopicEntry) return; if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ instanceTreePath, ]) || @@ -6686,7 +6712,7 @@ class _TocTabState extends ConsumerState<_TocTab> { ); case _TocTreeAction.rename: final path = topicPath; - if (path == null) { + if (path == null || topicOwner == null) { return; } final newName = await _showFileNameDialog( @@ -6698,16 +6724,29 @@ class _TocTabState extends ConsumerState<_TocTab> { if (newName == null || !context.mounted || !mounted) { return; } - final canRename = await saveOrConfirmSafeToChangeActiveFile( + final affectedPaths = await ref + .read(workspaceServiceProvider) + .writersideTopicRenameAffectedPaths( + widget.workspace, + topicPath: path, + topicModuleRoot: topicOwner.rootPath, + ); + if (!mounted || !context.mounted || !entryIsCurrent()) return; + final canRename = await confirmSafeToChangeWorkspaceFiles( context, ref, + affectedPaths, ); if (!canRename || !mounted || !context.mounted || !entryIsCurrent()) { return; } final renamed = await ref .read(workspaceControllerProvider.notifier) - .renameWritersideTopicFile(path, newName); + .renameWritersideTopicFile( + path, + newName, + topicModuleRoot: topicOwner.rootPath, + ); if (!mounted) { return; } @@ -6757,7 +6796,9 @@ class _TocTabState extends ConsumerState<_TocTab> { } return; } - final canMove = await saveOrConfirmSafeToChangeActiveFile(context, ref); + final canMove = await confirmSafeToChangeWorkspaceFiles(context, ref, [ + instanceTreePath, + ]); if (!canMove || !mounted || !context.mounted || @@ -7492,6 +7533,7 @@ Future<_TocTreeAction?> _showTocTreeMenu( BuildContext context, Offset position, { required bool hasTopicFile, + required bool canEditTopicEntry, required bool showHistory, required bool showPaste, required bool enableGitActions, @@ -7615,7 +7657,7 @@ Future<_TocTreeAction?> _showTocTreeMenu( BusyMarkPopupMenuItem( value: _TocTreeAction.editTitle, label: context.l10n.tocEditTitleAction, - enabled: hasTopicFile && canEditStructure, + enabled: canEditTopicEntry && hasTopicFile && canEditStructure, ), BusyMarkPopupMenuItem( value: _TocTreeAction.removeFromToc, @@ -7626,7 +7668,11 @@ Future<_TocTreeAction?> _showTocTreeMenu( BusyMarkPopupMenuItem( value: _TocTreeAction.setHome, label: context.l10n.tocSetHomePage, - enabled: canSetHome && hasTopicFile && canEditStructure, + enabled: + canEditTopicEntry && + canSetHome && + hasTopicFile && + canEditStructure, ), BusyMarkPopupMenuItem( value: _TocTreeAction.saveTemplate, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 03e0b4eb..3b924eda 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1644,6 +1644,8 @@ class WorkspaceController extends Notifier { required String treePath, required List tocPath, required WritersideTocNodeIdentity identity, + required String topicModuleRoot, + required String topicPath, }) async { final workspace = state.workspace; if (workspace == null) return null; @@ -1653,6 +1655,8 @@ class WorkspaceController extends Notifier { treePath: treePath, tocPath: tocPath, identity: identity, + topicModuleRoot: topicModuleRoot, + topicPath: topicPath, ); } on Object catch (error) { state = state.copyWith( @@ -1881,13 +1885,31 @@ class WorkspaceController extends Notifier { }); } - Future renameWritersideTopicFile(String topicPath, String newFileName) { + Future renameWritersideTopicFile( + String topicPath, + String newFileName, { + String? topicModuleRoot, + }) { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { + final ownerRoot = topicModuleRoot ?? workspace.writersideModule?.rootPath; + if (ownerRoot == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } + final affectedPaths = await _service.writersideTopicRenameAffectedPaths( + workspace, + topicPath: topicPath, + topicModuleRoot: ownerRoot, + ); + void validate(Iterable paths) => + _requireCleanAffectedFiles(workspace, paths); + validate(affectedPaths); final target = await _service.renameWritersideTopicFile( workspace, topicPath, newFileName, + topicModuleRoot: ownerRoot, + validateBeforePublish: validate, ); final transitions = _beginLocalHistoryPathTransitions(topicPath, target); var committed = false; diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 6caf1e51..368f0474 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -36,6 +36,7 @@ import '../writerside/writerside_document_parser.dart'; class WritersideTitleEditSession { const WritersideTitleEditSession({ required this.topic, + required this.topicModuleRoot, required this.instanceId, required this.treePath, required this.tocPath, @@ -44,6 +45,7 @@ class WritersideTitleEditSession { required this.treeLoad, }); final WritersideTopic topic; + final String topicModuleRoot; final String instanceId; final String treePath; final List tocPath; @@ -348,11 +350,23 @@ class WorkspaceService { required String treePath, required List tocPath, required WritersideTocNodeIdentity identity, + required String topicModuleRoot, + required String topicPath, }) async { final module = await _currentWritersideModule(workspace); final instance = _writersideInstanceForTree(module, treePath); - final reference = identity.topicFileName; - final topic = reference == null ? null : module.topicByReference(reference); + final projectModule = workspace.writersideProject?.modules + .where((candidate) => p.equals(candidate.rootPath, topicModuleRoot)) + .singleOrNull; + if (identity.topicFileName == null || projectModule == null) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final owningModule = p.equals(module.rootPath, topicModuleRoot) + ? module + : await _loadWritersideModule(topicModuleRoot); + final topic = owningModule.topics + .where((candidate) => p.equals(candidate.filePath, topicPath)) + .singleOrNull; if (topic == null) { throw const BusyMarkException('writerside.toc.path-invalid'); } @@ -371,6 +385,7 @@ class WorkspaceService { ); return WritersideTitleEditSession( topic: parsed, + topicModuleRoot: owningModule.rootPath, instanceId: instance.id, treePath: treePath, tocPath: List.unmodifiable(tocPath), @@ -458,6 +473,15 @@ class WorkspaceService { ) onCommitted, }) async { + final currentOwner = await _loadWritersideModule(session.topicModuleRoot); + final currentTopic = currentOwner.topics + .where( + (candidate) => p.equals(candidate.filePath, session.topic.filePath), + ) + .singleOrNull; + if (currentTopic == null) { + throw const BusyMarkException('writerside.toc.tree-changed'); + } final result = const WritersideTitleEditor().prepare( topic: session.topic, instanceId: session.instanceId, @@ -744,18 +768,91 @@ class WorkspaceService { Future renameWritersideTopicFile( Workspace workspace, String topicPath, - String newFileName, - ) async { - final module = await _currentWritersideModule(workspace); + String newFileName, { + String? topicModuleRoot, + void Function(Iterable)? validateBeforePublish, + }) async { + final ownerRoot = topicModuleRoot ?? workspace.writersideModule?.rootPath; + if (ownerRoot == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: ownerRoot, + ); + final module = project.modules + .where((candidate) => p.equals(candidate.rootPath, ownerRoot)) + .singleOrNull; + if (module == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } final topic = _writersideTopicForPath(module, topicPath); final result = await writersideTopicFileEditor.rename( module: module, topic: topic, newFileName: newFileName, + projectModules: project.modules, + validateBeforePublish: validateBeforePublish, ); return result.newTopicPath; } + Future> writersideTopicRenameAffectedPaths( + Workspace workspace, { + required String topicPath, + required String topicModuleRoot, + }) async { + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: topicModuleRoot, + ); + final module = project.modules + .where((candidate) => p.equals(candidate.rootPath, topicModuleRoot)) + .singleOrNull; + if (module == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } + final topic = _writersideTopicForPath(module, topicPath); + final paths = {topic.filePath}; + for (final usage in project.index.references) { + final topicPart = usage.value.split('#').first; + if (topicPart.isNotEmpty && + project.index + .definitions( + topicPart, + moduleId: usage.moduleId, + origin: usage.origin, + kind: WritersideSymbolKind.topic, + filePath: usage.filePath, + referenceOffset: usage.span.startOffset, + ) + .any((symbol) => p.equals(symbol.filePath, topic.filePath))) { + paths.add(usage.filePath); + } + } + for (final candidate in project.modules) { + final moduleId = project.index.modulesById.entries + .where((entry) => p.equals(entry.value.rootPath, candidate.rootPath)) + .map((entry) => entry.key) + .single; + for (final instance in candidate.instances) { + final startPage = instance.startPage; + if (startPage != null && + project.index + .definitions( + startPage, + moduleId: moduleId, + kind: WritersideSymbolKind.topic, + filePath: instance.sourceTreePath, + ) + .any((symbol) => p.equals(symbol.filePath, topic.filePath))) { + paths.add(instance.sourceTreePath); + } + } + } + return paths.toList()..sort(); + } + Future deleteWritersideTopicFile( Workspace workspace, String topicPath, diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 98c709b5..6c33befc 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -6,8 +6,10 @@ import 'package:xml/xml.dart'; import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; +import '../core/source_span.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; +import 'writerside_project.dart'; class WritersideTopicFileRenameResult { const WritersideTopicFileRenameResult({ @@ -39,7 +41,8 @@ class WritersideTopicFileDeleteResult { final int removedTocEntries; } -/// Mutates a resolved Writerside topic file and all configured instance trees. +/// Mutates a resolved Writerside topic file and every semantically resolved +/// project reference supplied to the rename transaction. /// /// Topic files and instance trees must be regular files below the module's /// canonical root. Symlinked path components and paths outside that root are @@ -60,9 +63,18 @@ class WritersideTopicFileEditor { required WritersideModule module, required WritersideTopic topic, required String newFileName, + List? projectModules, + void Function(Iterable)? validateBeforePublish, }) async { final snapshot = await _currentModuleSnapshot(module, topic); final context = await _mutationContext(snapshot); + final referenceContexts = <_ReferenceModuleContext>[ + _ReferenceModuleContext.fromMutation(context), + ]; + for (final candidate in projectModules ?? const []) { + if (p.equals(candidate.rootPath, context.module.rootPath)) continue; + referenceContexts.add(await _referenceModuleContext(candidate)); + } final safeFileName = _safeRenamedFileName( newFileName, oldPath: context.topicPath, @@ -89,16 +101,30 @@ class WritersideTopicFileEditor { newTopicFileName: newTopicFileName, newFileName: safeFileName, ); - final treeEdits = await _renameTreeEdits( - context, + final referenceEdits = _renameProjectReferenceEdits( + referenceContexts, + target: context, newTopicFileName: newTopicFileName, newFileName: safeFileName, ); + final targetReferenceEdit = referenceEdits + .where((edit) => p.equals(edit.path, context.topicPath)) + .singleOrNull; final topicEdit = _renamedTopicSource( path: context.topicPath, - source: context.topicSource, + source: targetReferenceEdit?.updatedSource ?? context.topicSource, newFileName: safeFileName, ); + final publishedEdits = referenceEdits + .where((edit) => !p.equals(edit.path, context.topicPath)) + .toList(); + + final affectedPaths = { + context.topicPath, + targetPath, + for (final edit in publishedEdits) edit.path, + }; + validateBeforePublish?.call(affectedPaths); await _writeNewFile( context.anchor, @@ -108,16 +134,19 @@ class WritersideTopicFileEditor { ); final appliedTreeEdits = <_TreeEdit>[]; try { + validateBeforePublish?.call(affectedPaths); await _applyTreeEdits( context.anchor, - treeEdits, + publishedEdits, applied: appliedTreeEdits, ); - await _ensureTreesAtExpectedSources(context, treeEdits); - await _ensureConfigurationUnchanged(context); - await _ensureTopicSourcesUnchanged( - context, - additionalSources: {targetPath: topicEdit.source}, + validateBeforePublish?.call(affectedPaths); + await _ensureReferenceContextsUnchanged( + referenceContexts, + publishedEdits, + targetPath: targetPath, + targetSource: topicEdit.source, + targetModuleRoot: context.module.rootPath, ); await _deleteUnchangedTopicSource(context); } on Object { @@ -148,7 +177,8 @@ class WritersideTopicFileEditor { oldTopicFileName: context.topic.fileName, newTopicFileName: newTopicFileName, updatedTreePaths: List.unmodifiable([ - for (final edit in treeEdits) edit.path, + for (final edit in publishedEdits) + if (_isTreePath(edit.path)) edit.path, ]), updatedXmlTopicId: topicEdit.updatedXmlTopicId, ); @@ -389,6 +419,35 @@ class WritersideTopicFileEditor { ); } + Future<_ReferenceModuleContext> _referenceModuleContext( + WritersideModule suppliedModule, + ) async { + final anchor = await _moduleAnchor(suppliedModule.rootPath); + final configurationBefore = await _configurationSources( + suppliedModule.rootPath, + ); + final initiallyLoaded = await moduleService.load(suppliedModule.rootPath); + final topicSourcesBefore = await _topicSources(anchor, initiallyLoaded); + final module = await moduleService.load(suppliedModule.rootPath); + final topicSources = await _topicSources(anchor, module); + final configurationSources = await _configurationSources(module.rootPath); + if (!_sameStringMap(configurationBefore, configurationSources) || + !_sameStringMap(topicSourcesBefore, topicSources) || + module.instances.length != module.config.instanceSources.length) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': module.config.filePath}, + ); + } + return _ReferenceModuleContext( + anchor: anchor, + module: module, + configurationSources: configurationSources, + topicSources: topicSources, + trees: await _loadTrees(anchor, module), + ); + } + Future _moduleAnchor(String rootPath) async { try { final anchor = await captureCanonicalDirectoryAnchor( @@ -485,59 +544,286 @@ class WritersideTopicFileEditor { } } - Future> _renameTreeEdits( - _MutationContext context, { + List<_TreeEdit> _renameProjectReferenceEdits( + List<_ReferenceModuleContext> contexts, { + required _MutationContext target, required String newTopicFileName, required String newFileName, - }) async { - final edits = <_TreeEdit>[]; - for (final tree in context.trees) { - var changed = false; - final root = tree.document.rootElement; - final startPage = root.getAttribute('start-page'); - if (startPage != null && - _referenceTargetsTopic(context, startPage, tree.path)) { - root.setAttribute( - 'start-page', - _renamedReference( - startPage, - oldTopicFileName: context.topic.fileName, - newTopicFileName: newTopicFileName, - newFileName: newFileName, - ), - ); - changed = true; + }) { + final modules = [for (final context in contexts) context.module]; + final index = WritersideProjectIndex.build(modules); + final usages = {}; + for (final usage in index.references) { + final topicPart = usage.value.split('#').first; + if (topicPart.isEmpty || + !index + .definitions( + topicPart, + moduleId: usage.moduleId, + origin: usage.origin, + kind: WritersideSymbolKind.topic, + filePath: usage.filePath, + referenceOffset: usage.span.startOffset, + ) + .any((symbol) => p.equals(symbol.filePath, target.topicPath))) { + continue; } - for (final element in tree.document.findAllElements('toc-element')) { - final reference = element.getAttribute('topic'); - if (reference == null || - !_referenceTargetsTopic(context, reference, tree.path)) { - continue; + final key = + '${usage.filePath}:${usage.span.startOffset}:' + '${usage.span.endOffset}:${usage.sourceValue ?? usage.value}'; + usages[key] = usage; + } + // The Markdown AST may assign several inline links one broad source span. + // The general project index intentionally coalesces such semantic entries; + // rename still needs every concrete destination occurrence. + for (final context in contexts) { + final moduleId = index.modulesById.entries + .where( + (entry) => p.equals(entry.value.rootPath, context.module.rootPath), + ) + .map((entry) => entry.key) + .single; + for (final topic in context.module.topics) { + final nextSearchOffsetBySpan = {}; + for (final link in topic.links) { + final topicPart = link.destination.split('#').first; + if (topicPart.isEmpty || + !index + .definitions( + topicPart, + moduleId: moduleId, + kind: WritersideSymbolKind.topic, + filePath: topic.filePath, + referenceOffset: link.span.startOffset, + ) + .any( + (symbol) => p.equals(symbol.filePath, target.topicPath), + )) { + continue; + } + final source = topic.document.source; + final spanKey = '${link.span.startOffset}:${link.span.endOffset}'; + final searchOffset = + nextSearchOffsetBySpan[spanKey] ?? link.span.startOffset; + final start = source.indexOf(link.destination, searchOffset); + if (start < 0 || + start + link.destination.length > link.span.endOffset) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': topic.filePath}, + ); + } + nextSearchOffsetBySpan[spanKey] = start + link.destination.length; + final span = SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: start, + endOffset: start + link.destination.length, + ); + final usage = WritersideReference( + value: link.destination, + kind: WritersideSymbolKind.topic, + moduleId: moduleId, + filePath: topic.filePath, + span: span, + sourceValue: link.destination, + ); + final key = + '${usage.filePath}:${span.startOffset}:' + '${span.endOffset}:${usage.sourceValue}'; + usages[key] = usage; } - element.setAttribute( - 'topic', - _renamedReference( - reference, - oldTopicFileName: context.topic.fileName, - newTopicFileName: newTopicFileName, - newFileName: newFileName, - ), - ); - changed = true; } - if (changed) { - edits.add( - _TreeEdit( - path: tree.path, - originalSource: tree.source, - updatedSource: _xmlSource(tree.document), - ), - ); + } + + final edits = <_TreeEdit>[]; + for (final context in contexts) { + for (final sourceEntry in context.topicSources.entries) { + final references = + usages.values + .where((usage) => p.equals(usage.filePath, sourceEntry.key)) + .toList() + ..sort( + (left, right) => + right.span.startOffset.compareTo(left.span.startOffset), + ); + if (references.isEmpty) continue; + var updated = sourceEntry.value; + var lastStart = updated.length + 1; + for (final reference in references) { + if (reference.span.startOffset < 0 || + reference.span.endOffset > updated.length || + reference.span.startOffset >= lastStart) { + throw BusyMarkException( + 'writerside.topic-file.ambiguous-reference', + args: { + 'reference': reference.value, + 'treePath': reference.filePath, + }, + ); + } + final expected = reference.sourceValue ?? reference.value; + final actual = updated.substring( + reference.span.startOffset, + reference.span.endOffset, + ); + if (actual != expected) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': reference.filePath}, + ); + } + updated = updated.replaceRange( + reference.span.startOffset, + reference.span.endOffset, + _renamedDocumentReference( + reference.value, + oldTopic: target.topic, + newTopicFileName: newTopicFileName, + newFileName: newFileName, + ), + ); + lastStart = reference.span.startOffset; + } + if (updated != sourceEntry.value) { + edits.add( + _TreeEdit( + anchor: context.anchor, + path: sourceEntry.key, + originalSource: sourceEntry.value, + updatedSource: updated, + ), + ); + } + } + + final moduleId = index.modulesById.entries + .where( + (entry) => p.equals(entry.value.rootPath, context.module.rootPath), + ) + .map((entry) => entry.key) + .single; + for (final tree in context.trees) { + var changed = false; + final root = tree.document.rootElement; + final startPage = root.getAttribute('start-page'); + if (startPage != null && + _projectReferenceTargetsTopic( + index, + target, + startPage, + moduleId: moduleId, + filePath: tree.path, + )) { + root.setAttribute( + 'start-page', + _renamedDocumentReference( + startPage, + oldTopic: target.topic, + newTopicFileName: newTopicFileName, + newFileName: newFileName, + ), + ); + changed = true; + } + for (final element in tree.document.findAllElements('toc-element')) { + for (final attributeName in const [ + 'topic', + 'ref', + 'target-for-accept-web-filenames', + ]) { + final reference = element.getAttribute(attributeName); + if (reference == null || + !_projectReferenceTargetsTopic( + index, + target, + reference, + moduleId: moduleId, + origin: element.getAttribute('origin'), + filePath: tree.path, + )) { + continue; + } + element.setAttribute( + attributeName, + _renamedDocumentReference( + reference, + oldTopic: target.topic, + newTopicFileName: newTopicFileName, + newFileName: newFileName, + ), + ); + changed = true; + } + } + if (changed) { + edits.add( + _TreeEdit( + anchor: context.anchor, + path: tree.path, + originalSource: tree.source, + updatedSource: _xmlSource(tree.document), + ), + ); + } } } return edits; } + bool _projectReferenceTargetsTopic( + WritersideProjectIndex index, + _MutationContext target, + String reference, { + required String moduleId, + required String filePath, + String? origin, + }) { + return index + .definitions( + reference.split('#').first, + moduleId: moduleId, + origin: origin, + kind: WritersideSymbolKind.topic, + filePath: filePath, + ) + .any((symbol) => p.equals(symbol.filePath, target.topicPath)); + } + + String _renamedDocumentReference( + String reference, { + required WritersideTopic oldTopic, + required String newTopicFileName, + required String newFileName, + }) { + final hash = reference.indexOf('#'); + final pathPart = hash < 0 ? reference : reference.substring(0, hash); + final suffix = hash < 0 ? '' : reference.substring(hash); + if (pathPart.isEmpty) return reference; + final normalized = _normalizedReference(pathPart); + final directory = p.dirname(normalized); + final oldId = oldTopic.id; + final usesId = + p.extension(normalized).isEmpty && p.basename(normalized) == oldId; + if (usesId && + oldId != + p.basenameWithoutExtension( + _normalizedReference(oldTopic.fileName), + )) { + return reference; + } + final replacementName = usesId + ? p.basenameWithoutExtension(newFileName) + : newFileName; + if (normalized == _normalizedReference(oldTopic.fileName)) { + return '$newTopicFileName$suffix'; + } + final replacement = directory == '.' + ? replacementName + : p.join(directory, replacementName).replaceAll(r'\', '/'); + return '$replacement$suffix'; + } + Future<_DeleteTreeMutation> _deleteTreeEdits(_MutationContext context) async { for (final tree in context.trees) { final startPage = tree.document.rootElement.getAttribute('start-page'); @@ -688,7 +974,7 @@ class WritersideTopicFileEditor { }) async { for (final edit in edits) { await _replaceFileAtomically( - anchor, + edit.anchor ?? anchor, edit.path, edit.updatedSource, expectedCurrentSource: edit.originalSource, @@ -705,7 +991,7 @@ class WritersideTopicFileEditor { for (final edit in edits.reversed) { try { await _replaceFileAtomically( - anchor, + edit.anchor ?? anchor, edit.path, edit.originalSource, expectedCurrentSource: edit.updatedSource, @@ -893,6 +1179,59 @@ class WritersideTopicFileEditor { } } + Future _ensureReferenceContextsUnchanged( + List<_ReferenceModuleContext> contexts, + List<_TreeEdit> edits, { + required String targetPath, + required String targetSource, + required String targetModuleRoot, + }) async { + final updatedSources = { + for (final edit in edits) normalizePath(edit.path): edit.updatedSource, + }; + for (final context in contexts) { + final configuration = await _configurationSources( + context.module.rootPath, + ); + if (!_sameStringMap(configuration, context.configurationSources)) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': context.module.config.filePath}, + ); + } + for (final tree in context.trees) { + final expected = updatedSources[tree.path] ?? tree.source; + final resolved = await _resolvePath( + context.anchor, + tree.path, + allowRoot: false, + ); + if (resolved.type != FileSystemEntityType.file || + await File(resolved.path).readAsString() != expected) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': resolved.path}, + ); + } + } + final expectedTopics = { + ...context.topicSources, + for (final entry in updatedSources.entries) + if (context.topicSources.containsKey(entry.key)) + entry.key: entry.value, + if (p.equals(context.module.rootPath, targetModuleRoot)) + normalizePath(targetPath): targetSource, + }; + final currentTopics = await _topicSources(context.anchor, context.module); + if (!_sameStringMap(currentTopics, expectedTopics)) { + throw BusyMarkException( + 'writerside.topic-file.topic-inventory-changed', + args: {'path': context.module.rootPath}, + ); + } + } + } + Future _renameTargetIsSafeToCleanUp( _MutationContext context, { required String targetPath, @@ -997,19 +1336,6 @@ class WritersideTopicFileEditor { : p.join(directory, newFileName).replaceAll(r'\', '/'); } - String _renamedReference( - String reference, { - required String oldTopicFileName, - required String newTopicFileName, - required String newFileName, - }) { - final normalized = _normalizedReference(reference); - if (normalized == _normalizedReference(oldTopicFileName)) { - return newTopicFileName; - } - return newFileName; - } - String _normalizedReference(String value) { return p.normalize(value.trim()).replaceAll(r'\', '/'); } @@ -1057,6 +1383,32 @@ class _CurrentModuleSnapshot { final Map topicSources; } +class _ReferenceModuleContext { + const _ReferenceModuleContext({ + required this.anchor, + required this.module, + required this.configurationSources, + required this.topicSources, + required this.trees, + }); + + factory _ReferenceModuleContext.fromMutation(_MutationContext context) { + return _ReferenceModuleContext( + anchor: context.anchor, + module: context.module, + configurationSources: context.configurationSources, + topicSources: context.topicSources, + trees: context.trees, + ); + } + + final CanonicalPathAnchor anchor; + final WritersideModule module; + final Map configurationSources; + final Map topicSources; + final List<_LoadedTree> trees; +} + class _LoadedTree { const _LoadedTree({ required this.path, @@ -1071,11 +1423,13 @@ class _LoadedTree { class _TreeEdit { const _TreeEdit({ + this.anchor, required this.path, required this.originalSource, required this.updatedSource, }); + final CanonicalPathAnchor? anchor; final String path; final String originalSource; final String updatedSource; @@ -1112,3 +1466,5 @@ bool _sameStringMap(Map first, Map second) { } return true; } + +bool _isTreePath(String path) => p.extension(path).toLowerCase() == '.tree'; diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 89c7794b..1b80baf5 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2610,6 +2610,17 @@ static gboolean validate_native_menu_entries(FlValue* entries, return TRUE; } +static gchar* escape_native_menu_label(const gchar* label) { + GString* literal = g_string_new(nullptr); + for (const gchar* cursor = label; *cursor != '\0'; cursor++) { + if (*cursor == '_') { + g_string_append_c(literal, '_'); + } + g_string_append_c(literal, *cursor); + } + return g_string_free(literal, FALSE); +} + static void build_native_menu_model(FlValue* entries, NativeMenuSession* session, GMenu* model, @@ -2640,6 +2651,10 @@ static void build_native_menu_model(FlValue* entries, const size_t entry_index = (*next_index)++; const gchar* label = fl_lookup_string_arg(entry, "label"); + // GMenu's GtkMenu adapter treats underscores as mnemonic markers. Flutter + // labels are literal presentation text, so double every underscore only at + // this native boundary; action indexes and clipboard payloads stay intact. + g_autofree gchar* literal_label = escape_native_menu_label(label); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; @@ -2656,7 +2671,8 @@ static void build_native_menu_model(FlValue* entries, g_autoptr(GMenu) submenu = g_menu_new(); build_native_menu_model(children, session, submenu, next_index, ancestors_enabled && enabled); - g_autoptr(GMenuItem) item = g_menu_item_new_submenu(label, G_MENU_MODEL(submenu)); + g_autoptr(GMenuItem) item = + g_menu_item_new_submenu(literal_label, G_MENU_MODEL(submenu)); g_menu_append_item(section, item); section_length++; index++; @@ -2704,11 +2720,13 @@ static void build_native_menu_model(FlValue* entries, for (size_t run_index = run_start; run_index < run_end; run_index++) { FlValue* run_entry = fl_value_get_list_value(entries, run_index); const gchar* run_label = fl_lookup_string_arg(run_entry, "label"); + g_autofree gchar* run_literal_label = + escape_native_menu_label(run_label); const gchar* run_icon = fl_lookup_string_arg(run_entry, "icon"); const gchar* run_shortcut = fl_lookup_string_arg(run_entry, "shortcut"); g_autofree gchar* target = g_strdup_printf("%zu", entry_index + run_index - run_start); - g_autoptr(GMenuItem) item = g_menu_item_new(run_label, nullptr); + g_autoptr(GMenuItem) item = g_menu_item_new(run_literal_label, nullptr); g_menu_item_set_action_and_target_value( item, detailed_group_action, g_variant_new_string(target)); if (run_icon != nullptr && run_icon[0] != '\0') { @@ -2740,7 +2758,8 @@ static void build_native_menu_model(FlValue* entries, g_autofree gchar* detailed_action = g_strdup_printf("%s.%s", kNativeMenuActionNamespace, action_name); - g_autoptr(GMenuItem) item = g_menu_item_new(label, detailed_action); + g_autoptr(GMenuItem) item = + g_menu_item_new(literal_label, detailed_action); if (icon_name != nullptr && icon_name[0] != '\0') { g_autoptr(GIcon) icon = create_native_menu_icon(icon_name, entry); g_menu_item_set_icon(item, icon); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 3276ee32..f5bd0332 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -52,6 +52,7 @@ import 'package:busymark/src/writerside/writerside_toc_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; @@ -4836,6 +4837,203 @@ void main() { } }); + testWidgets('TOC Copy uses an inactive origin topic open buffer', ( + tester, + ) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown( + () => binding.platformDispatcher.defaultRouteNameTestValue = '/', + ); + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final root = Directory.systemTemp.createTempSync( + 'busymark-toc-copy-buffer-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final mainTopics = Directory(p.join(root.path, 'topics'))..createSync(); + final sharedRoot = Directory(p.join(root.path, 'shared'))..createSync(); + final sharedTopics = Directory(p.join(sharedRoot.path, 'topics')) + ..createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + +'''); + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + + + + +'''); + final active = File(p.join(mainTopics.path, 'active.md')) + ..writeAsStringSync('# Active\n'); + File(p.join(sharedRoot.path, 'writerside.cfg')).writeAsStringSync(''' + +'''); + File(p.join(sharedRoot.path, 'library.tree')).writeAsStringSync(''' + +'''); + final shared = File(p.join(sharedTopics.path, 'shared.md')) + ..writeAsStringSync('# Shared saved\n'); + final workspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + final controller = _MutableWorkspaceController( + WorkspaceState( + workspace: workspace.copyWith( + activeFilePath: active.path, + openFilePaths: [shared.path, active.path], + ), + documentBuffers: [ + DocumentBuffer( + id: 'shared', + filePath: shared.path, + text: '# Shared unsaved\n', + lastSavedText: '# Shared saved\n', + dirty: true, + ), + DocumentBuffer( + id: 'active', + filePath: active.path, + text: '# Active\n', + lastSavedText: '# Active\n', + dirty: false, + ), + ], + activeBufferId: 'active', + ), + ); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + final en = AppLocalizationsEn(); + await tester.tap(find.byTooltip(en.sidebarViewMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(en.toc)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Shared saved'), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + await tester.tap(find.text(en.copy)); + await tester.pumpAndSettle(); + expect(clipboardText, '# Shared unsaved\n'); + }); + + testWidgets('TOC Paste reviews an inactive dirty tree before moving', ( + tester, + ) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown( + () => binding.platformDispatcher.defaultRouteNameTestValue = '/', + ); + final root = Directory.systemTemp.createTempSync( + 'busymark-toc-paste-tree-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final topics = Directory(p.join(root.path, 'topics'))..createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + +'''); + final tree = File(p.join(root.path, 'guide.tree')) + ..writeAsStringSync(''' + + + + +'''); + File(p.join(topics.path, 'a.md')).writeAsStringSync('# Topic A\n'); + final active = File(p.join(topics.path, 'b.md')) + ..writeAsStringSync('# Topic B\n'); + final workspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + final controller = _MutableWorkspaceController( + WorkspaceState( + workspace: workspace.copyWith( + activeFilePath: active.path, + openFilePaths: [tree.path, active.path], + ), + documentBuffers: [ + DocumentBuffer( + id: 'tree', + filePath: tree.path, + text: '${tree.readAsStringSync()}\n', + lastSavedText: tree.readAsStringSync(), + dirty: true, + ), + DocumentBuffer( + id: 'active', + filePath: active.path, + text: '# Topic B\n', + lastSavedText: '# Topic B\n', + dirty: false, + ), + ], + activeBufferId: 'active', + ), + ); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + final en = AppLocalizationsEn(); + await tester.tap(find.byTooltip(en.sidebarViewMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(en.toc)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Topic A'), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(en.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text(en.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Topic B').first, buttons: kSecondaryButton); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(en.pasteAfterTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(en.pasteAfterTopic)); + await tester.pumpAndSettle(); + expect(find.text(en.unsavedChanges), findsOneWidget); + expect(find.textContaining('guide.tree'), findsWidgets); + expect(controller.movedTopicPlacement, isNull); + await tester.tap(find.text(en.cancel)); + await tester.pumpAndSettle(); + }); + testWidgets('source undo cannot restore saved text from previous tab', ( tester, ) async { @@ -10455,6 +10653,9 @@ class _MutableWorkspaceController extends WorkspaceController { @override WorkspaceState build() => initialState; + @override + Future flushPersistence() async {} + @override Future validateActive() async => ValidationOutcome( status: ValidationStatus.published, diff --git a/test/src/writerside_title_regressions_test.dart b/test/src/writerside_title_regressions_test.dart index 38120496..0dbafc30 100644 --- a/test/src/writerside_title_regressions_test.dart +++ b/test/src/writerside_title_regressions_test.dart @@ -62,11 +62,14 @@ void main() { Future session(Directory root) async { final workspace = await service.openPath(root.path); final instance = workspace.writersideModule!.instances.single; + final topic = workspace.writersideModule!.topics.single; return service.prepareWritersideTitleEdit( workspace, treePath: instance.sourceTreePath, tocPath: [0], identity: WritersideTocNodeIdentity.fromNode(instance.tocRoots.first), + topicModuleRoot: workspace.writersideModule!.rootPath, + topicPath: topic.filePath, ); } @@ -82,6 +85,59 @@ void main() { ); } + test( + 'title edit preserves the resolved topic owner across modules', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-title-owner-', + ); + addTearDown(() => root.delete(recursive: true)); + final mainTopics = await Directory(p.join(root.path, 'topics')).create(); + final sharedRoot = await Directory(p.join(root.path, 'shared')).create(); + final sharedTopics = await Directory( + p.join(sharedRoot.path, 'topics'), + ).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(root.path, 'guide.tree')).writeAsString(''' + +'''); + final mainTopic = File(p.join(mainTopics.path, 'shared.md')); + await mainTopic.writeAsString('# Main title\n'); + await File(p.join(sharedRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(sharedRoot.path, 'library.tree')).writeAsString(''' + +'''); + final sharedTopic = File(p.join(sharedTopics.path, 'shared.md')); + await sharedTopic.writeAsString('# Shared title\n'); + + final workspace = await service.openPath(root.path); + final instance = workspace.writersideModule!.instances.single; + final node = instance.tocRoots.single; + final owner = workspace.writersideProject!.modulesByOrigin['shared']!; + final resolved = owner.topicByReference(node.topicReference!)!; + final editSession = await service.prepareWritersideTitleEdit( + workspace, + treePath: instance.sourceTreePath, + tocPath: [0], + identity: WritersideTocNodeIdentity.fromNode(node), + topicModuleRoot: owner.rootPath, + topicPath: resolved.filePath, + ); + + expect(editSession.topic.title, 'Shared title'); + await save( + editSession, + const WritersideTitleEdit(title: 'Shared updated'), + ); + expect(await mainTopic.readAsString(), '# Main title\n'); + expect(await sharedTopic.readAsString(), '# Shared updated\n'); + }, + ); + for (final newline in ['\n', '\r\n']) { for (final quote in ['"', "'"]) { test( @@ -235,6 +291,7 @@ void main() { ); final broken = WritersideTitleEditSession( topic: topic, + topicModuleRoot: original.topicModuleRoot, instanceId: original.instanceId, treePath: original.treePath, tocPath: original.tocPath, diff --git a/test/src/writerside_toc_workspace_test.dart b/test/src/writerside_toc_workspace_test.dart index 80d6adca..cd1bf787 100644 --- a/test/src/writerside_toc_workspace_test.dart +++ b/test/src/writerside_toc_workspace_test.dart @@ -302,6 +302,10 @@ void main() { treePath: guide.sourceTreePath, tocPath: [0], identity: WritersideTocNodeIdentity.fromNode(guide.tocRoots.first), + topicModuleRoot: workspace.writersideModule!.rootPath, + topicPath: workspace.writersideModule! + .topicByReference(guide.tocRoots.first.topicReference!)! + .filePath, ); var committed = false; await expectLater( diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index a7696f94..6255c4b5 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -68,6 +68,159 @@ void main() { }, ); + test( + 'rename refactors links, includes, ref entries, and leaves unrelated text', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + 'other.tree': ''' + + + + +''', + }, + topics: { + 'guide.md': '# Guide\n\n\n', + 'links.md': ''' +# Links + +[Guide](guide.md), [again](guide.md), and [part](guide.md#part). + +Literal guide.md must stay literal. +''', + 'other.topic': ''' + + + Guide + +''', + }, + ); + final topic = _topic(fixture.module, 'guide.md'); + + await editor.rename( + module: fixture.module, + topic: topic, + newFileName: 'setup.md', + ); + + final links = File( + p.join(fixture.root.path, 'topics', 'links.md'), + ).readAsStringSync(); + expect(links, contains('[Guide](setup.md)')); + expect(links, contains('[again](setup.md)')); + expect(links, contains('[part](setup.md#part)')); + expect(links, contains('Literal guide.md must stay literal.')); + final other = File( + p.join(fixture.root.path, 'topics', 'other.topic'), + ).readAsStringSync(); + expect(other, contains('from="setup.md"')); + expect(other, contains('href="setup.md#part"')); + final otherTree = _tree(fixture.root, 'other.tree'); + expect( + otherTree.findAllElements('toc-element').first.getAttribute('ref'), + 'setup.md', + ); + }, + ); + + test( + 'rename follows origin across modules without touching a local namesake', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-file-project-', + ); + addTearDown(() => root.delete(recursive: true)); + Future writeModule({ + required String directory, + required String moduleName, + required String tree, + required Map topics, + }) async { + final moduleRoot = await Directory( + p.join(root.path, directory), + ).create(); + final topicRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); + for (final entry in topics.entries) { + await File( + p.join(topicRoot.path, entry.key), + ).writeAsString(entry.value); + } + return const WritersideModuleService().load(moduleRoot.path); + } + + final main = await writeModule( + directory: 'main', + moduleName: 'main', + tree: ''' + + + + +''', + topics: { + 'guide.md': '# Local namesake\n', + 'links.topic': ''' +Shared +''', + }, + ); + final shared = await writeModule( + directory: 'shared', + moduleName: 'shared', + tree: ''' + +''', + topics: {'guide.md': '# Shared guide\n'}, + ); + + await editor.rename( + module: shared, + topic: _topic(shared, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ); + + expect( + File(p.join(main.rootPath, 'topics', 'guide.md')).readAsStringSync(), + '# Local namesake\n', + ); + expect( + File(p.join(main.rootPath, 'topics', 'links.topic')).readAsStringSync(), + contains('href="setup.md" origin="shared"'), + ); + final mainTree = XmlDocument.parse( + File(p.join(main.rootPath, 'guide.tree')).readAsStringSync(), + ); + expect( + mainTree + .findAllElements('toc-element') + .map((element) => element.getAttribute('topic')), + ['guide.md', 'setup.md'], + ); + expect( + File(p.join(shared.rootPath, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + expect( + File(p.join(shared.rootPath, 'topics', 'setup.md')).existsSync(), + isTrue, + ); + }, + ); + test('rename updates a matching XML topic root id', () async { final fixture = await _fixture( trees: { diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index b661a943..831e00c3 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -938,6 +938,8 @@ class _HarnessState extends ConsumerState<_Harness> { ), NativeMenuEntry.separator(), NativeMenuEntry.command(label: 'Extra'), + NativeMenuEntry.command(label: "Topic File Name 'first_topic.md'"), + NativeMenuEntry.command(label: 'Repeated __ and ___ underscores'), ]; for (final direction in TextDirection.values) { final previousFocus = FocusManager.instance.primaryFocus; @@ -957,6 +959,16 @@ class _HarnessState extends ConsumerState<_Harness> { ), 'GTK disables submenu headings in ${direction.name}', ); + _check( + tree.any( + (dynamic row) => + row['name'] == "Topic File Name 'first_topic.md'", + ) && + tree.any( + (dynamic row) => row['name'] == 'Repeated __ and ___ underscores', + ), + 'GTK renders single and repeated underscores literally in ${direction.name}', + ); await _native('key', direction == TextDirection.ltr ? 'Right' : 'Left'); await _pause(); await _native('key', 'Down'); From 39526fe7d45fc40d41b61ed8d943de8a8922d222 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 18:30:09 -0700 Subject: [PATCH 07/35] Fix Rename Topic File defects --- .../writerside_topic_file_editor.dart | 274 +++++++++++++++++- .../writerside_topic_file_editor_test.dart | 105 ++++++- 2 files changed, 365 insertions(+), 14 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 6c33befc..cd26da97 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -7,9 +7,11 @@ import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; import '../core/source_span.dart'; +import '../markdown/markdown_model.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; +import 'writerside_schema.dart'; class WritersideTopicFileRenameResult { const WritersideTopicFileRenameResult({ @@ -552,8 +554,19 @@ class WritersideTopicFileEditor { }) { final modules = [for (final context in contexts) context.module]; final index = WritersideProjectIndex.build(modules); + final topicsByPath = { + for (final context in contexts) + for (final topic in context.module.topics) + normalizePath(topic.filePath): topic, + }; final usages = {}; for (final usage in index.references) { + if (usage.kind != WritersideSymbolKind.topic) continue; + final containingTopic = topicsByPath[normalizePath(usage.filePath)]; + if (containingTopic == null || + !_isWritableTopicReferenceSpan(containingTopic, usage)) { + continue; + } final topicPart = usage.value.split('#').first; if (topicPart.isEmpty || !index @@ -584,14 +597,20 @@ class WritersideTopicFileEditor { .map((entry) => entry.key) .single; for (final topic in context.module.topics) { + if (topic.format != WritersideTopicFormat.markdown) continue; final nextSearchOffsetBySpan = {}; for (final link in topic.links) { + // Markdown's link projection also contains embedded XML nodes, + // but it drops their origin. Recover the typed XML attribute here + // instead of treating those nodes as origin-less Markdown links. + final xmlLink = _markdownXmlTopicReference(topic, link); final topicPart = link.destination.split('#').first; if (topicPart.isEmpty || !index .definitions( topicPart, moduleId: moduleId, + origin: xmlLink?.origin, kind: WritersideSymbolKind.topic, filePath: topic.filePath, referenceOffset: link.span.startOffset, @@ -601,25 +620,35 @@ class WritersideTopicFileEditor { )) { continue; } - final source = topic.document.source; + if (xmlLink != null) { + final usage = WritersideReference( + value: link.destination, + kind: WritersideSymbolKind.topic, + moduleId: moduleId, + filePath: topic.filePath, + span: xmlLink.span, + origin: xmlLink.origin, + sourceValue: link.destination, + ); + final key = + '${usage.filePath}:${usage.span.startOffset}:' + '${usage.span.endOffset}:${usage.sourceValue}'; + usages[key] = usage; + continue; + } final spanKey = '${link.span.startOffset}:${link.span.endOffset}'; final searchOffset = nextSearchOffsetBySpan[spanKey] ?? link.span.startOffset; - final start = source.indexOf(link.destination, searchOffset); - if (start < 0 || - start + link.destination.length > link.span.endOffset) { + final span = _markdownLinkDestinationSpans(topic, link) + .where((candidate) => candidate.startOffset >= searchOffset) + .firstOrNull; + if (span == null) { throw BusyMarkException( 'writerside.topic-file.tree-changed', args: {'path': topic.filePath}, ); } - nextSearchOffsetBySpan[spanKey] = start + link.destination.length; - final span = SourceSpan.fromOffsets( - filePath: topic.filePath, - source: source, - startOffset: start, - endOffset: start + link.destination.length, - ); + nextSearchOffsetBySpan[spanKey] = span.endOffset; final usage = WritersideReference( value: link.destination, kind: WritersideSymbolKind.topic, @@ -771,6 +800,229 @@ class WritersideTopicFileEditor { return edits; } + bool _isWritableTopicReferenceSpan( + WritersideTopic topic, + WritersideReference reference, + ) { + final source = topic.document.source; + final span = reference.span; + final expected = reference.sourceValue ?? reference.value; + if (span.startOffset < 0 || + span.endOffset > source.length || + source.substring(span.startOffset, span.endOffset) != expected) { + return false; + } + if (topic.format != WritersideTopicFormat.markdown) return true; + + if (topic.links.any((link) { + if (link.destination != expected) return false; + final xmlLink = _markdownXmlTopicReference(topic, link); + return xmlLink != null && _sameSpan(xmlLink.span, span); + })) { + // XML links in Markdown are collected separately so their origin is + // recovered from source instead of discarded by the Markdown model. + return false; + } + + for (final element in topic.document.elements) { + final attributeName = switch (element.semanticKind) { + WritersideSemanticKind.include => 'from', + WritersideSemanticKind.link || WritersideSemanticKind.card => 'href', + _ => null, + }; + if (attributeName == null || + element.attributes[attributeName] != expected) { + continue; + } + final attributeSpan = element.attributeSpans[attributeName]; + if (attributeSpan != null && _sameSpan(attributeSpan, span)) return true; + } + + for (final link in topic.links) { + if (link.destination != expected || + _isSemanticXmlTopicLink(topic, link)) { + continue; + } + if (_markdownLinkDestinationSpans( + topic, + link, + ).any((candidate) => _sameSpan(candidate, span))) { + return true; + } + } + return false; + } + + ({SourceSpan span, String? origin})? _markdownXmlTopicReference( + WritersideTopic topic, + MarkdownLink link, + ) { + final source = topic.document.source; + final lowerBound = link.span.startOffset.clamp(0, source.length); + final upperBound = link.span.endOffset.clamp(lowerBound, source.length); + final raw = source.substring(lowerBound, upperBound); + final tagPattern = RegExp( + r''']*>''', + caseSensitive: false, + dotAll: true, + ); + final hrefPattern = RegExp( + r'''\bhref\s*=\s*(["'])(.*?)\1''', + caseSensitive: false, + dotAll: true, + ); + final originPattern = RegExp( + r'''\borigin\s*=\s*(["'])(.*?)\1''', + caseSensitive: false, + dotAll: true, + ); + for (final tagMatch in tagPattern.allMatches(raw)) { + final tag = tagMatch.group(0)!; + final hrefMatch = hrefPattern.firstMatch(tag); + if (hrefMatch == null || hrefMatch.group(2) != link.destination) continue; + final start = + lowerBound + + tagMatch.start + + hrefMatch.end - + 1 - + link.destination.length; + final origin = originPattern.firstMatch(tag)?.group(2)?.trim(); + return ( + span: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: start, + endOffset: start + link.destination.length, + ), + origin: origin?.isEmpty == true ? null : origin, + ); + } + return null; + } + + bool _isSemanticXmlTopicLink(WritersideTopic topic, MarkdownLink link) { + for (final element in topic.document.elements) { + if (element.semanticKind != WritersideSemanticKind.link && + element.semanticKind != WritersideSemanticKind.card) { + continue; + } + if (element.attributes['href'] != link.destination) continue; + final hrefSpan = element.attributeSpans['href']; + if (hrefSpan != null && _sameSpan(hrefSpan, link.span)) return true; + if (hrefSpan == null && _sameSpan(element.span, link.span)) return true; + } + return false; + } + + List _markdownLinkDestinationSpans( + WritersideTopic topic, + MarkdownLink link, + ) { + final source = topic.document.source; + final lowerBound = link.span.startOffset.clamp(0, source.length); + final upperBound = link.span.endOffset.clamp(lowerBound, source.length); + final spans = []; + var cursor = lowerBound; + while (cursor < upperBound) { + final labelStart = source.indexOf('[', cursor); + if (labelStart < 0 || labelStart >= upperBound) break; + cursor = labelStart + 1; + if ((labelStart > lowerBound && source[labelStart - 1] == '!') || + _isEscapedMarkdownCharacter(source, labelStart)) { + continue; + } + + var labelDepth = 1; + var labelEnd = -1; + for (var index = labelStart + 1; index < upperBound; index++) { + if (_isEscapedMarkdownCharacter(source, index)) continue; + if (source[index] == '[') { + labelDepth++; + } else if (source[index] == ']') { + labelDepth--; + if (labelDepth == 0) { + labelEnd = index; + break; + } + } + } + if (labelEnd < 0 || + labelEnd + 1 >= upperBound || + source[labelEnd + 1] != '(') { + continue; + } + + var destinationStart = labelEnd + 2; + while (destinationStart < upperBound && + _isMarkdownWhitespace(source.codeUnitAt(destinationStart))) { + destinationStart++; + } + if (destinationStart >= upperBound) continue; + + var destinationEnd = destinationStart; + if (source[destinationStart] == '<') { + destinationStart++; + destinationEnd = destinationStart; + while (destinationEnd < upperBound && + (source[destinationEnd] != '>' || + _isEscapedMarkdownCharacter(source, destinationEnd))) { + destinationEnd++; + } + if (destinationEnd >= upperBound) continue; + } else { + var nestedParentheses = 0; + while (destinationEnd < upperBound) { + final character = source[destinationEnd]; + if (_isEscapedMarkdownCharacter(source, destinationEnd)) { + destinationEnd++; + continue; + } + if (character == '(') { + nestedParentheses++; + } else if (character == ')') { + if (nestedParentheses == 0) break; + nestedParentheses--; + } else if (_isMarkdownWhitespace(source.codeUnitAt(destinationEnd))) { + break; + } + destinationEnd++; + } + } + if (destinationEnd > destinationStart && + source.substring(destinationStart, destinationEnd) == + link.destination) { + spans.add( + SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: destinationStart, + endOffset: destinationEnd, + ), + ); + } + cursor = labelEnd + 2; + } + return spans; + } + + bool _sameSpan(SourceSpan left, SourceSpan right) => + left.startOffset == right.startOffset && + left.endOffset == right.endOffset; + + bool _isEscapedMarkdownCharacter(String source, int offset) { + var slashCount = 0; + for (var index = offset - 1; index >= 0 && source[index] == '\\'; index--) { + slashCount++; + } + return slashCount.isOdd; + } + + bool _isMarkdownWhitespace(int codeUnit) => + codeUnit == 0x20 || + codeUnit == 0x09 || + codeUnit == 0x0a || + codeUnit == 0x0d; + bool _projectReferenceTargetsTopic( WritersideProjectIndex index, _MutationContext target, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 6255c4b5..b08dd9d7 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -90,7 +90,7 @@ void main() { 'links.md': ''' # Links -[Guide](guide.md), [again](guide.md), and [part](guide.md#part). +guide.md appears before [guide.md](guide.md), [again](guide.md), and [part](guide.md#part). Literal guide.md must stay literal. ''', @@ -98,6 +98,7 @@ Literal guide.md must stay literal. Guide +

    Same-named instance

    ''', }, @@ -113,15 +114,22 @@ Literal guide.md must stay literal. final links = File( p.join(fixture.root.path, 'topics', 'links.md'), ).readAsStringSync(); - expect(links, contains('[Guide](setup.md)')); - expect(links, contains('[again](setup.md)')); + expect( + links, + contains( + 'guide.md appears before [guide.md](setup.md), ' + '[again](setup.md), and [part](setup.md#part).', + ), + ); expect(links, contains('[part](setup.md#part)')); expect(links, contains('Literal guide.md must stay literal.')); final other = File( p.join(fixture.root.path, 'topics', 'other.topic'), ).readAsStringSync(); expect(other, contains('from="setup.md"')); + expect(other, contains('element-id="part"')); expect(other, contains('href="setup.md#part"')); + expect(other, contains('instance="guide"')); final otherTree = _tree(fixture.root, 'other.tree'); expect( otherTree.findAllElements('toc-element').first.getAttribute('ref'), @@ -221,6 +229,97 @@ Literal guide.md must stay literal. }, ); + test( + 'renaming a local namesake preserves origin-qualified Markdown XML links', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-file-project-reverse-', + ); + addTearDown(() => root.delete(recursive: true)); + Future writeModule({ + required String directory, + required String moduleName, + required String tree, + required Map topics, + }) async { + final moduleRoot = await Directory( + p.join(root.path, directory), + ).create(); + final topicRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); + for (final entry in topics.entries) { + await File( + p.join(topicRoot.path, entry.key), + ).writeAsString(entry.value); + } + return const WritersideModuleService().load(moduleRoot.path); + } + + final main = await writeModule( + directory: 'main', + moduleName: 'main', + tree: ''' + + + + +''', + topics: { + 'guide.md': '# Local guide\n', + 'links.md': ''' +# Links + +[Local](guide.md) + + + +Shared +''', + }, + ); + final shared = await writeModule( + directory: 'shared', + moduleName: 'shared', + tree: ''' + +''', + topics: {'guide.md': '# Shared guide\n'}, + ); + + await editor.rename( + module: main, + topic: _topic(main, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ); + + final links = File( + p.join(main.rootPath, 'topics', 'links.md'), + ).readAsStringSync(); + expect(links, contains('[Local](setup.md)')); + expect(links, contains('from="setup.md" element-id="part"')); + expect(links, contains('href="guide.md" origin="shared"')); + final mainTree = XmlDocument.parse( + File(p.join(main.rootPath, 'guide.tree')).readAsStringSync(), + ); + expect( + mainTree + .findAllElements('toc-element') + .map((element) => element.getAttribute('topic')), + ['setup.md', 'guide.md'], + ); + expect( + File(p.join(shared.rootPath, 'topics', 'guide.md')).readAsStringSync(), + '# Shared guide\n', + ); + }, + ); + test('rename updates a matching XML topic root id', () async { final fixture = await _fixture( trees: { From c47fc5de7295d452834e80c700d8006fa4fef450 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 18:53:03 -0700 Subject: [PATCH 08/35] Refactor topic reference handling in Writerside: improve markdown link management, unify reference span indexing logic, and optimize XML link parsing. --- .../writerside_topic_file_editor.dart | 585 ++++++++++++------ .../writerside_topic_file_editor_test.dart | 99 ++- 2 files changed, 473 insertions(+), 211 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index cd26da97..5a3851e2 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -7,7 +7,6 @@ import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; import '../core/source_span.dart'; -import '../markdown/markdown_model.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; @@ -563,10 +562,12 @@ class WritersideTopicFileEditor { for (final usage in index.references) { if (usage.kind != WritersideSymbolKind.topic) continue; final containingTopic = topicsByPath[normalizePath(usage.filePath)]; - if (containingTopic == null || - !_isWritableTopicReferenceSpan(containingTopic, usage)) { - continue; - } + if (containingTopic == null) continue; + final writableUsage = _indexedWritableTopicReference( + containingTopic, + usage, + ); + if (writableUsage == null) continue; final topicPart = usage.value.split('#').first; if (topicPart.isEmpty || !index @@ -582,9 +583,10 @@ class WritersideTopicFileEditor { continue; } final key = - '${usage.filePath}:${usage.span.startOffset}:' - '${usage.span.endOffset}:${usage.sourceValue ?? usage.value}'; - usages[key] = usage; + '${writableUsage.filePath}:${writableUsage.span.startOffset}:' + '${writableUsage.span.endOffset}:' + '${writableUsage.sourceValue ?? writableUsage.value}'; + usages[key] = writableUsage; } // The Markdown AST may assign several inline links one broad source span. // The general project index intentionally coalesces such semantic entries; @@ -598,68 +600,35 @@ class WritersideTopicFileEditor { .single; for (final topic in context.module.topics) { if (topic.format != WritersideTopicFormat.markdown) continue; - final nextSearchOffsetBySpan = {}; - for (final link in topic.links) { - // Markdown's link projection also contains embedded XML nodes, - // but it drops their origin. Recover the typed XML attribute here - // instead of treating those nodes as origin-less Markdown links. - final xmlLink = _markdownXmlTopicReference(topic, link); - final topicPart = link.destination.split('#').first; + for (final reference in _authoredMarkdownTopicReferences(topic)) { + final topicPart = reference.destination.split('#').first; if (topicPart.isEmpty || !index .definitions( topicPart, moduleId: moduleId, - origin: xmlLink?.origin, + origin: reference.origin, kind: WritersideSymbolKind.topic, filePath: topic.filePath, - referenceOffset: link.span.startOffset, + referenceOffset: reference.occurrenceOffset, ) .any( (symbol) => p.equals(symbol.filePath, target.topicPath), )) { continue; } - if (xmlLink != null) { - final usage = WritersideReference( - value: link.destination, - kind: WritersideSymbolKind.topic, - moduleId: moduleId, - filePath: topic.filePath, - span: xmlLink.span, - origin: xmlLink.origin, - sourceValue: link.destination, - ); - final key = - '${usage.filePath}:${usage.span.startOffset}:' - '${usage.span.endOffset}:${usage.sourceValue}'; - usages[key] = usage; - continue; - } - final spanKey = '${link.span.startOffset}:${link.span.endOffset}'; - final searchOffset = - nextSearchOffsetBySpan[spanKey] ?? link.span.startOffset; - final span = _markdownLinkDestinationSpans(topic, link) - .where((candidate) => candidate.startOffset >= searchOffset) - .firstOrNull; - if (span == null) { - throw BusyMarkException( - 'writerside.topic-file.tree-changed', - args: {'path': topic.filePath}, - ); - } - nextSearchOffsetBySpan[spanKey] = span.endOffset; final usage = WritersideReference( - value: link.destination, + value: reference.destination, kind: WritersideSymbolKind.topic, moduleId: moduleId, filePath: topic.filePath, - span: span, - sourceValue: link.destination, + span: reference.destinationSpan, + origin: reference.origin, + sourceValue: reference.rawDestination, ); final key = - '${usage.filePath}:${span.startOffset}:' - '${span.endOffset}:${usage.sourceValue}'; + '${usage.filePath}:${usage.span.startOffset}:' + '${usage.span.endOffset}:${usage.sourceValue}'; usages[key] = usage; } } @@ -800,209 +769,389 @@ class WritersideTopicFileEditor { return edits; } - bool _isWritableTopicReferenceSpan( + WritersideReference? _indexedWritableTopicReference( WritersideTopic topic, WritersideReference reference, ) { final source = topic.document.source; final span = reference.span; - final expected = reference.sourceValue ?? reference.value; if (span.startOffset < 0 || span.endOffset > source.length || - source.substring(span.startOffset, span.endOffset) != expected) { - return false; + span.startOffset > span.endOffset) { + return null; + } + if (topic.format == WritersideTopicFormat.markdown) { + final isTypedAttribute = topic.document.elements.any((element) { + final attributeName = switch (element.semanticKind) { + WritersideSemanticKind.include => 'from', + WritersideSemanticKind.link || WritersideSemanticKind.card => 'href', + _ => null, + }; + if (attributeName == null || + element.attributes[attributeName] != reference.value) { + return false; + } + final attributeSpan = element.attributeSpans[attributeName]; + return attributeSpan != null && _sameSpan(attributeSpan, span); + }); + if (!isTypedAttribute) return null; } - if (topic.format != WritersideTopicFormat.markdown) return true; + return WritersideReference( + value: reference.value, + kind: reference.kind, + moduleId: reference.moduleId, + filePath: reference.filePath, + span: span, + origin: reference.origin, + sourceValue: source.substring(span.startOffset, span.endOffset), + scopeReference: reference.scopeReference, + nullable: reference.nullable, + ); + } - if (topic.links.any((link) { - if (link.destination != expected) return false; - final xmlLink = _markdownXmlTopicReference(topic, link); - return xmlLink != null && _sameSpan(xmlLink.span, span); - })) { - // XML links in Markdown are collected separately so their origin is - // recovered from source instead of discarded by the Markdown model. - return false; + List<_AuthoredMarkdownTopicReference> _authoredMarkdownTopicReferences( + WritersideTopic topic, + ) { + final protected = _markdownLiteralMask(topic); + final definitions = _markdownReferenceDefinitions(topic, protected); + final authored = + <_AuthoredMarkdownTopicReference>[ + ..._markdownLinkOccurrences(topic, protected, definitions), + ..._markdownXmlLinkOccurrences(topic, protected), + ]..sort( + (left, right) => + left.occurrenceOffset.compareTo(right.occurrenceOffset), + ); + final bound = <_AuthoredMarkdownTopicReference>[]; + final consumed = {}; + for (final link in topic.links) { + int? selected; + for (var index = 0; index < authored.length; index++) { + if (consumed.contains(index)) continue; + final candidate = authored[index]; + if (candidate.destination != link.destination) continue; + final belongsToParsedLink = + (candidate.occurrenceOffset >= link.span.startOffset && + candidate.occurrenceOffset < link.span.endOffset) || + (candidate.destinationSpan.startOffset >= link.span.startOffset && + candidate.destinationSpan.endOffset <= link.span.endOffset); + if (!belongsToParsedLink) continue; + selected = index; + break; + } + if (selected != null) { + consumed.add(selected); + bound.add(authored[selected]); + } } + return bound; + } - for (final element in topic.document.elements) { - final attributeName = switch (element.semanticKind) { - WritersideSemanticKind.include => 'from', - WritersideSemanticKind.link || WritersideSemanticKind.card => 'href', - _ => null, - }; - if (attributeName == null || - element.attributes[attributeName] != expected) { - continue; + List _markdownLiteralMask(WritersideTopic topic) { + final source = topic.document.source; + final protected = List.filled(source.length, false); + void protect(int start, int end) { + final safeStart = start.clamp(0, source.length); + final safeEnd = end.clamp(safeStart, source.length); + for (var index = safeStart; index < safeEnd; index++) { + protected[index] = true; } - final attributeSpan = element.attributeSpans[attributeName]; - if (attributeSpan != null && _sameSpan(attributeSpan, span)) return true; } - for (final link in topic.links) { - if (link.destination != expected || - _isSemanticXmlTopicLink(topic, link)) { + for (final codeBlock in topic.markdown?.codeBlocks ?? const []) { + protect(codeBlock.span.startOffset, codeBlock.span.endOffset); + } + for (var cursor = 0; cursor < source.length; cursor++) { + if (protected[cursor] || + source[cursor] != '`' || + _isEscapedMarkdownCharacter(source, cursor)) { continue; } - if (_markdownLinkDestinationSpans( - topic, - link, - ).any((candidate) => _sameSpan(candidate, span))) { - return true; + var delimiterLength = 1; + while (cursor + delimiterLength < source.length && + source[cursor + delimiterLength] == '`') { + delimiterLength++; + } + var closing = cursor + delimiterLength; + while (closing < source.length) { + if (!protected[closing] && source[closing] == '`') { + var runLength = 1; + while (closing + runLength < source.length && + source[closing + runLength] == '`') { + runLength++; + } + if (runLength == delimiterLength) break; + closing += runLength; + } else { + closing++; + } + } + if (closing < source.length) { + protect(cursor, closing + delimiterLength); + cursor = closing + delimiterLength - 1; } } - return false; + return protected; } - ({SourceSpan span, String? origin})? _markdownXmlTopicReference( + Map _markdownReferenceDefinitions( WritersideTopic topic, - MarkdownLink link, + List protected, ) { final source = topic.document.source; - final lowerBound = link.span.startOffset.clamp(0, source.length); - final upperBound = link.span.endOffset.clamp(lowerBound, source.length); - final raw = source.substring(lowerBound, upperBound); - final tagPattern = RegExp( - r''']*>''', - caseSensitive: false, - dotAll: true, - ); - final hrefPattern = RegExp( - r'''\bhref\s*=\s*(["'])(.*?)\1''', - caseSensitive: false, - dotAll: true, - ); - final originPattern = RegExp( - r'''\borigin\s*=\s*(["'])(.*?)\1''', - caseSensitive: false, - dotAll: true, + final definitions = {}; + final pattern = RegExp( + r'''^([ \t]{0,3}\[([^\]\r\n]+)\]:[ \t]*)(?:<([^>\r\n]+)>|([^\s\r\n]+))''', + multiLine: true, ); - for (final tagMatch in tagPattern.allMatches(raw)) { - final tag = tagMatch.group(0)!; - final hrefMatch = hrefPattern.firstMatch(tag); - if (hrefMatch == null || hrefMatch.group(2) != link.destination) continue; - final start = - lowerBound + - tagMatch.start + - hrefMatch.end - - 1 - - link.destination.length; - final origin = originPattern.firstMatch(tag)?.group(2)?.trim(); - return ( - span: SourceSpan.fromOffsets( + for (final match in pattern.allMatches(source)) { + if (_rangeIsProtected(protected, match.start, match.end)) continue; + final rawDestination = match.group(3) ?? match.group(4)!; + final destinationStart = + match.start + + match.group(1)!.length + + (match.group(3) == null ? 0 : 1); + final definition = _MarkdownReferenceDefinition( + destination: _decodeMarkdownDestination(rawDestination), + rawDestination: rawDestination, + destinationSpan: SourceSpan.fromOffsets( filePath: topic.filePath, source: source, - startOffset: start, - endOffset: start + link.destination.length, + startOffset: destinationStart, + endOffset: destinationStart + rawDestination.length, ), - origin: origin?.isEmpty == true ? null : origin, ); - } - return null; - } - - bool _isSemanticXmlTopicLink(WritersideTopic topic, MarkdownLink link) { - for (final element in topic.document.elements) { - if (element.semanticKind != WritersideSemanticKind.link && - element.semanticKind != WritersideSemanticKind.card) { - continue; + definitions.putIfAbsent( + _normalizedMarkdownLabel(match.group(2)!), + () => definition, + ); + for (var index = match.start; index < match.end; index++) { + protected[index] = true; } - if (element.attributes['href'] != link.destination) continue; - final hrefSpan = element.attributeSpans['href']; - if (hrefSpan != null && _sameSpan(hrefSpan, link.span)) return true; - if (hrefSpan == null && _sameSpan(element.span, link.span)) return true; } - return false; + return definitions; } - List _markdownLinkDestinationSpans( + List<_AuthoredMarkdownTopicReference> _markdownLinkOccurrences( WritersideTopic topic, - MarkdownLink link, + List protected, + Map definitions, ) { final source = topic.document.source; - final lowerBound = link.span.startOffset.clamp(0, source.length); - final upperBound = link.span.endOffset.clamp(lowerBound, source.length); - final spans = []; - var cursor = lowerBound; - while (cursor < upperBound) { + final occurrences = <_AuthoredMarkdownTopicReference>[]; + var cursor = 0; + while (cursor < source.length) { final labelStart = source.indexOf('[', cursor); - if (labelStart < 0 || labelStart >= upperBound) break; + if (labelStart < 0) break; cursor = labelStart + 1; - if ((labelStart > lowerBound && source[labelStart - 1] == '!') || - _isEscapedMarkdownCharacter(source, labelStart)) { + if (protected[labelStart] || + _isEscapedMarkdownCharacter(source, labelStart) || + (labelStart > 0 && + source[labelStart - 1] == '!' && + !_isEscapedMarkdownCharacter(source, labelStart - 1))) { continue; } - - var labelDepth = 1; - var labelEnd = -1; - for (var index = labelStart + 1; index < upperBound; index++) { - if (_isEscapedMarkdownCharacter(source, index)) continue; - if (source[index] == '[') { - labelDepth++; - } else if (source[index] == ']') { - labelDepth--; - if (labelDepth == 0) { - labelEnd = index; - break; - } + final labelEnd = _closingMarkdownBracket(source, labelStart, protected); + if (labelEnd == null) continue; + final label = source.substring(labelStart + 1, labelEnd); + final afterLabel = labelEnd + 1; + if (afterLabel < source.length && source[afterLabel] == '(') { + final destination = _inlineMarkdownDestination( + topic, + afterLabel, + protected, + ); + if (destination != null) { + occurrences.add( + _AuthoredMarkdownTopicReference( + occurrenceOffset: labelStart, + destination: destination.destination, + rawDestination: destination.rawDestination, + destinationSpan: destination.destinationSpan, + ), + ); + cursor = destination.destinationSpan.endOffset; } + continue; + } + + String? referenceLabel; + var referenceEnd = afterLabel; + if (afterLabel < source.length && source[afterLabel] == '[') { + final closing = _closingMarkdownBracket(source, afterLabel, protected); + if (closing == null) continue; + final explicit = source.substring(afterLabel + 1, closing); + referenceLabel = explicit.isEmpty ? label : explicit; + referenceEnd = closing + 1; + } else { + referenceLabel = label; } - if (labelEnd < 0 || - labelEnd + 1 >= upperBound || - source[labelEnd + 1] != '(') { + final definition = definitions[_normalizedMarkdownLabel(referenceLabel)]; + if (definition == null) continue; + occurrences.add( + _AuthoredMarkdownTopicReference( + occurrenceOffset: labelStart, + destination: definition.destination, + rawDestination: definition.rawDestination, + destinationSpan: definition.destinationSpan, + ), + ); + cursor = referenceEnd; + } + return occurrences; + } + + List<_AuthoredMarkdownTopicReference> _markdownXmlLinkOccurrences( + WritersideTopic topic, + List protected, + ) { + final source = topic.document.source; + final occurrences = <_AuthoredMarkdownTopicReference>[]; + final tagPattern = RegExp( + r''']*>''', + caseSensitive: false, + dotAll: true, + ); + final hrefPattern = RegExp( + r'''\bhref\s*=\s*(["'])(.*?)\1''', + caseSensitive: false, + dotAll: true, + ); + for (final tagMatch in tagPattern.allMatches(source)) { + if (_rangeIsProtected(protected, tagMatch.start, tagMatch.end)) continue; + final tag = tagMatch.group(0)!; + final hrefMatch = hrefPattern.firstMatch(tag); + if (hrefMatch == null) continue; + final rawDestination = hrefMatch.group(2)!; + final hrefStart = + tagMatch.start + hrefMatch.end - 1 - rawDestination.length; + try { + final selfClosing = tag.endsWith('/>') + ? tag + : '${tag.substring(0, tag.length - 1)}/>'; + final element = XmlDocument.parse(selfClosing).rootElement; + final destination = element.getAttribute('href'); + if (destination == null || destination.isEmpty) continue; + final origin = element.getAttribute('origin')?.trim(); + occurrences.add( + _AuthoredMarkdownTopicReference( + occurrenceOffset: tagMatch.start, + destination: destination, + rawDestination: rawDestination, + destinationSpan: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: hrefStart, + endOffset: hrefStart + rawDestination.length, + ), + origin: origin?.isEmpty == true ? null : origin, + ), + ); + } on XmlParserException { continue; } + } + return occurrences; + } - var destinationStart = labelEnd + 2; - while (destinationStart < upperBound && - _isMarkdownWhitespace(source.codeUnitAt(destinationStart))) { - destinationStart++; + _MarkdownReferenceDefinition? _inlineMarkdownDestination( + WritersideTopic topic, + int openingParenthesis, + List protected, + ) { + final source = topic.document.source; + var start = openingParenthesis + 1; + while (start < source.length && + _isMarkdownWhitespace(source.codeUnitAt(start))) { + start++; + } + if (start >= source.length || protected[start]) return null; + var end = start; + if (source[start] == '<') { + start++; + end = start; + while (end < source.length && + !protected[end] && + (source[end] != '>' || _isEscapedMarkdownCharacter(source, end))) { + end++; } - if (destinationStart >= upperBound) continue; - - var destinationEnd = destinationStart; - if (source[destinationStart] == '<') { - destinationStart++; - destinationEnd = destinationStart; - while (destinationEnd < upperBound && - (source[destinationEnd] != '>' || - _isEscapedMarkdownCharacter(source, destinationEnd))) { - destinationEnd++; + if (end >= source.length || protected[end]) return null; + } else { + var nestedParentheses = 0; + while (end < source.length && !protected[end]) { + final character = source[end]; + if (_isEscapedMarkdownCharacter(source, end)) { + end++; + continue; } - if (destinationEnd >= upperBound) continue; - } else { - var nestedParentheses = 0; - while (destinationEnd < upperBound) { - final character = source[destinationEnd]; - if (_isEscapedMarkdownCharacter(source, destinationEnd)) { - destinationEnd++; - continue; - } - if (character == '(') { - nestedParentheses++; - } else if (character == ')') { - if (nestedParentheses == 0) break; - nestedParentheses--; - } else if (_isMarkdownWhitespace(source.codeUnitAt(destinationEnd))) { - break; - } - destinationEnd++; + if (character == '(') { + nestedParentheses++; + } else if (character == ')') { + if (nestedParentheses == 0) break; + nestedParentheses--; + } else if (_isMarkdownWhitespace(source.codeUnitAt(end))) { + break; } + end++; } - if (destinationEnd > destinationStart && - source.substring(destinationStart, destinationEnd) == - link.destination) { - spans.add( - SourceSpan.fromOffsets( - filePath: topic.filePath, - source: source, - startOffset: destinationStart, - endOffset: destinationEnd, - ), - ); + } + if (end <= start) return null; + final rawDestination = source.substring(start, end); + return _MarkdownReferenceDefinition( + destination: _decodeMarkdownDestination(rawDestination), + rawDestination: rawDestination, + destinationSpan: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: start, + endOffset: end, + ), + ); + } + + int? _closingMarkdownBracket( + String source, + int opening, + List protected, + ) { + var depth = 1; + for (var index = opening + 1; index < source.length; index++) { + if (protected[index]) return null; + if (_isEscapedMarkdownCharacter(source, index)) continue; + if (source[index] == '[') { + depth++; + } else if (source[index] == ']') { + depth--; + if (depth == 0) return index; } - cursor = labelEnd + 2; } - return spans; + return null; + } + + bool _rangeIsProtected(List protected, int start, int end) { + for (var index = start; index < end; index++) { + if (protected[index]) return true; + } + return false; + } + + String _normalizedMarkdownLabel(String value) => + value.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase(); + + String _decodeMarkdownDestination(String value) { + var decoded = value.replaceAllMapped( + RegExp(r'''\\([!"#\$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])'''), + (match) => match.group(1)!, + ); + try { + decoded = XmlDocument.parse( + '$decoded', + ).rootElement.innerText; + } on XmlParserException { + // The Markdown parser remains authoritative for unusual destinations; + // binding below discards source candidates that do not match it. + } + return decoded; } bool _sameSpan(SourceSpan left, SourceSpan right) => @@ -1597,6 +1746,34 @@ class WritersideTopicFileEditor { } } +class _AuthoredMarkdownTopicReference { + const _AuthoredMarkdownTopicReference({ + required this.occurrenceOffset, + required this.destination, + required this.rawDestination, + required this.destinationSpan, + this.origin, + }); + + final int occurrenceOffset; + final String destination; + final String rawDestination; + final SourceSpan destinationSpan; + final String? origin; +} + +class _MarkdownReferenceDefinition { + const _MarkdownReferenceDefinition({ + required this.destination, + required this.rawDestination, + required this.destinationSpan, + }); + + final String destination; + final String rawDestination; + final SourceSpan destinationSpan; +} + class _MutationContext { const _MutationContext({ required this.anchor, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index b08dd9d7..ec294be0 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -90,7 +90,7 @@ void main() { 'links.md': ''' # Links -guide.md appears before [guide.md](guide.md), [again](guide.md), and [part](guide.md#part). +Example: `[sample](guide.md)`; guide.md appears before [guide.md](guide.md), [again](guide.md), and [part](guide.md#part). Literal guide.md must stay literal. ''', @@ -117,7 +117,8 @@ Literal guide.md must stay literal. expect( links, contains( - 'guide.md appears before [guide.md](setup.md), ' + 'Example: `[sample](guide.md)`; guide.md appears before ' + '[guide.md](setup.md), ' '[again](setup.md), and [part](setup.md#part).', ), ); @@ -274,11 +275,9 @@ Literal guide.md must stay literal. 'links.md': ''' # Links -[Local](guide.md) +Shared: Shared; local: [Local](guide.md). - -Shared ''', }, ); @@ -301,9 +300,14 @@ Literal guide.md must stay literal. final links = File( p.join(main.rootPath, 'topics', 'links.md'), ).readAsStringSync(); - expect(links, contains('[Local](setup.md)')); + expect( + links, + contains( + 'href="guide.md" origin="shared">Shared; ' + 'local: [Local](setup.md)', + ), + ); expect(links, contains('from="setup.md" element-id="part"')); - expect(links, contains('href="guide.md" origin="shared"')); final mainTree = XmlDocument.parse( File(p.join(main.rootPath, 'guide.tree')).readAsStringSync(), ); @@ -320,6 +324,87 @@ Literal guide.md must stay literal. }, ); + test('rename updates shared Markdown reference definitions once', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'references.md': ''' +# References + +Full [Guide][g] and repeated [again][g]. +Collapsed [Guide][] and shortcut [Guide]. + +[g]: guide.md +[guide]: guide.md +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final references = File( + p.join(fixture.root.path, 'topics', 'references.md'), + ).readAsStringSync(); + expect(references, contains('Full [Guide][g] and repeated [again][g].')); + expect(references, contains('Collapsed [Guide][] and shortcut [Guide].')); + expect( + RegExp(r'^\[g\]: setup\.md$', multiLine: true).allMatches(references), + hasLength(1), + ); + expect( + RegExp(r'^\[guide\]: setup\.md$', multiLine: true).allMatches(references), + hasLength(1), + ); + expect(references, isNot(contains(']: guide.md'))); + }); + + test('rename rewrites an XML-encoded topic destination', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'links.topic': ''' + + Guide + +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final linksPath = p.join(fixture.root.path, 'topics', 'links.topic'); + final linksSource = File(linksPath).readAsStringSync(); + expect(linksSource, contains('href="setup.md"')); + expect( + XmlDocument.parse( + linksSource, + ).findAllElements('a').single.getAttribute('href'), + 'setup.md', + ); + }); + test('rename updates a matching XML topic root id', () async { final fixture = await _fixture( trees: { From bb015ca55ac22f6a00af1f0deab5563a150996cd Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 19:48:00 -0700 Subject: [PATCH 09/35] Enhance topic file editor: improve XML validation, add Markdown link protections, and refine reference span handling with XML attribute support. --- .../writerside_topic_file_editor.dart | 242 +++++++++++++++--- .../writerside_topic_file_editor_test.dart | 70 +++++ 2 files changed, 281 insertions(+), 31 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 5a3851e2..b009a3d2 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -7,6 +7,7 @@ import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; import '../core/source_span.dart'; +import '../markdown/markdown_model.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; @@ -120,6 +121,13 @@ class WritersideTopicFileEditor { .where((edit) => !p.equals(edit.path, context.topicPath)) .toList(); + _validateGeneratedXmlRenameSources( + referenceContexts, + target: context, + targetSource: topicEdit.source, + publishedEdits: publishedEdits, + ); + final affectedPaths = { context.topicPath, targetPath, @@ -559,15 +567,14 @@ class WritersideTopicFileEditor { normalizePath(topic.filePath): topic, }; final usages = {}; + final xmlAttributeSpans = {}; for (final usage in index.references) { if (usage.kind != WritersideSymbolKind.topic) continue; final containingTopic = topicsByPath[normalizePath(usage.filePath)]; if (containingTopic == null) continue; - final writableUsage = _indexedWritableTopicReference( - containingTopic, - usage, - ); - if (writableUsage == null) continue; + final writable = _indexedWritableTopicReference(containingTopic, usage); + if (writable == null) continue; + final writableUsage = writable.reference; final topicPart = usage.value.split('#').first; if (topicPart.isEmpty || !index @@ -587,6 +594,9 @@ class WritersideTopicFileEditor { '${writableUsage.span.endOffset}:' '${writableUsage.sourceValue ?? writableUsage.value}'; usages[key] = writableUsage; + if (writable.xmlAttribute) { + xmlAttributeSpans.add(_referenceSpanKey(writableUsage)); + } } // The Markdown AST may assign several inline links one broad source span. // The general project index intentionally coalesces such semantic entries; @@ -600,7 +610,31 @@ class WritersideTopicFileEditor { .single; for (final topic in context.module.topics) { if (topic.format != WritersideTopicFormat.markdown) continue; - for (final reference in _authoredMarkdownTopicReferences(topic)) { + final projection = _authoredMarkdownTopicReferences(topic); + for (final unbound in projection.unboundLinks) { + final topicPart = unbound.destination.split('#').first; + if (topicPart.isNotEmpty && + index + .definitions( + topicPart, + moduleId: moduleId, + kind: WritersideSymbolKind.topic, + filePath: topic.filePath, + referenceOffset: unbound.span.startOffset, + ) + .any( + (symbol) => p.equals(symbol.filePath, target.topicPath), + )) { + throw BusyMarkException( + 'writerside.topic-file.ambiguous-reference', + args: { + 'reference': unbound.destination, + 'treePath': topic.filePath, + }, + ); + } + } + for (final reference in projection.references) { final topicPart = reference.destination.split('#').first; if (topicPart.isEmpty || !index @@ -630,6 +664,9 @@ class WritersideTopicFileEditor { '${usage.filePath}:${usage.span.startOffset}:' '${usage.span.endOffset}:${usage.sourceValue}'; usages[key] = usage; + if (reference.xmlAttribute) { + xmlAttributeSpans.add(_referenceSpanKey(usage)); + } } } } @@ -671,15 +708,19 @@ class WritersideTopicFileEditor { args: {'path': reference.filePath}, ); } + var replacement = _renamedDocumentReference( + reference.value, + oldTopic: target.topic, + newTopicFileName: newTopicFileName, + newFileName: newFileName, + ); + if (xmlAttributeSpans.contains(_referenceSpanKey(reference))) { + replacement = _escapeXmlAttributeValue(replacement); + } updated = updated.replaceRange( reference.span.startOffset, reference.span.endOffset, - _renamedDocumentReference( - reference.value, - oldTopic: target.topic, - newTopicFileName: newTopicFileName, - newFileName: newFileName, - ), + replacement, ); lastStart = reference.span.startOffset; } @@ -769,7 +810,8 @@ class WritersideTopicFileEditor { return edits; } - WritersideReference? _indexedWritableTopicReference( + ({WritersideReference reference, bool xmlAttribute})? + _indexedWritableTopicReference( WritersideTopic topic, WritersideReference reference, ) { @@ -796,20 +838,23 @@ class WritersideTopicFileEditor { }); if (!isTypedAttribute) return null; } - return WritersideReference( - value: reference.value, - kind: reference.kind, - moduleId: reference.moduleId, - filePath: reference.filePath, - span: span, - origin: reference.origin, - sourceValue: source.substring(span.startOffset, span.endOffset), - scopeReference: reference.scopeReference, - nullable: reference.nullable, + return ( + reference: WritersideReference( + value: reference.value, + kind: reference.kind, + moduleId: reference.moduleId, + filePath: reference.filePath, + span: span, + origin: reference.origin, + sourceValue: source.substring(span.startOffset, span.endOffset), + scopeReference: reference.scopeReference, + nullable: reference.nullable, + ), + xmlAttribute: true, ); } - List<_AuthoredMarkdownTopicReference> _authoredMarkdownTopicReferences( + _AuthoredMarkdownProjection _authoredMarkdownTopicReferences( WritersideTopic topic, ) { final protected = _markdownLiteralMask(topic); @@ -823,6 +868,7 @@ class WritersideTopicFileEditor { left.occurrenceOffset.compareTo(right.occurrenceOffset), ); final bound = <_AuthoredMarkdownTopicReference>[]; + final unbound = []; final consumed = {}; for (final link in topic.links) { int? selected; @@ -842,9 +888,14 @@ class WritersideTopicFileEditor { if (selected != null) { consumed.add(selected); bound.add(authored[selected]); + } else { + unbound.add(link); } } - return bound; + return _AuthoredMarkdownProjection( + references: List.unmodifiable(bound), + unboundLinks: List.unmodifiable(unbound), + ); } List _markdownLiteralMask(WritersideTopic topic) { @@ -861,6 +912,9 @@ class WritersideTopicFileEditor { for (final codeBlock in topic.markdown?.codeBlocks ?? const []) { protect(codeBlock.span.startOffset, codeBlock.span.endOffset); } + for (final comment in RegExp(r'|$)').allMatches(source)) { + protect(comment.start, comment.end); + } for (var cursor = 0; cursor < source.length; cursor++) { if (protected[cursor] || source[cursor] != '`' || @@ -901,7 +955,7 @@ class WritersideTopicFileEditor { final source = topic.document.source; final definitions = {}; final pattern = RegExp( - r'''^([ \t]{0,3}\[([^\]\r\n]+)\]:[ \t]*)(?:<([^>\r\n]+)>|([^\s\r\n]+))''', + r'''^([ \t]{0,3}\[([^\]\r\n]+)\]:[ \t]*(?:\r?\n[ \t]+)?)(?:<([^>\r\n]+)>|([^\s\r\n]+))''', multiLine: true, ); for (final match in pattern.allMatches(source)) { @@ -925,7 +979,9 @@ class WritersideTopicFileEditor { _normalizedMarkdownLabel(match.group(2)!), () => definition, ); - for (var index = match.start; index < match.end; index++) { + final lineEnd = source.indexOf('\n', match.end); + final protectedEnd = lineEnd < 0 ? source.length : lineEnd; + for (var index = match.start; index < protectedEnd; index++) { protected[index] = true; } } @@ -970,7 +1026,7 @@ class WritersideTopicFileEditor { destinationSpan: destination.destinationSpan, ), ); - cursor = destination.destinationSpan.endOffset; + cursor = destination.linkEndOffset; } continue; } @@ -1045,6 +1101,7 @@ class WritersideTopicFileEditor { endOffset: hrefStart + rawDestination.length, ), origin: origin?.isEmpty == true ? null : origin, + xmlAttribute: true, ), ); } on XmlParserException { @@ -1054,7 +1111,7 @@ class WritersideTopicFileEditor { return occurrences; } - _MarkdownReferenceDefinition? _inlineMarkdownDestination( + _InlineMarkdownDestination? _inlineMarkdownDestination( WritersideTopic topic, int openingParenthesis, List protected, @@ -1096,8 +1153,14 @@ class WritersideTopicFileEditor { } } if (end <= start) return null; + final linkEnd = _inlineMarkdownLinkEnd( + source, + openingParenthesis, + protected, + ); + if (linkEnd == null) return null; final rawDestination = source.substring(start, end); - return _MarkdownReferenceDefinition( + return _InlineMarkdownDestination( destination: _decodeMarkdownDestination(rawDestination), rawDestination: rawDestination, destinationSpan: SourceSpan.fromOffsets( @@ -1106,9 +1169,38 @@ class WritersideTopicFileEditor { startOffset: start, endOffset: end, ), + linkEndOffset: linkEnd, ); } + int? _inlineMarkdownLinkEnd( + String source, + int openingParenthesis, + List protected, + ) { + var nestedParentheses = 0; + String? quote; + for (var index = openingParenthesis + 1; index < source.length; index++) { + if (protected[index] || _isEscapedMarkdownCharacter(source, index)) { + continue; + } + final character = source[index]; + if (quote != null) { + if (character == quote) quote = null; + continue; + } + if (character == '"' || character == "'") { + quote = character; + } else if (character == '(') { + nestedParentheses++; + } else if (character == ')') { + if (nestedParentheses == 0) return index + 1; + nestedParentheses--; + } + } + return null; + } + int? _closingMarkdownBracket( String source, int opening, @@ -1116,7 +1208,7 @@ class WritersideTopicFileEditor { ) { var depth = 1; for (var index = opening + 1; index < source.length; index++) { - if (protected[index]) return null; + if (protected[index]) continue; if (_isEscapedMarkdownCharacter(source, index)) continue; if (source[index] == '[') { depth++; @@ -1154,6 +1246,71 @@ class WritersideTopicFileEditor { return decoded; } + String _referenceSpanKey(WritersideReference reference) => + '${normalizePath(reference.filePath)}:' + '${reference.span.startOffset}:${reference.span.endOffset}'; + + String _escapeXmlAttributeValue(String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + void _validateGeneratedXmlRenameSources( + List<_ReferenceModuleContext> contexts, { + required _MutationContext target, + required String targetSource, + required List<_TreeEdit> publishedEdits, + }) { + if (target.topic.format == WritersideTopicFormat.xml) { + _validateGeneratedXmlTopic(target.topicPath, targetSource); + } + final xmlTopicPaths = { + for (final context in contexts) + for (final topic in context.module.topics) + if (topic.format == WritersideTopicFormat.xml) + normalizePath(topic.filePath), + }; + for (final edit in publishedEdits) { + if (_isTreePath(edit.path)) { + _validateGeneratedTree(edit.path, edit.updatedSource); + } else if (xmlTopicPaths.contains(normalizePath(edit.path))) { + _validateGeneratedXmlTopic(edit.path, edit.updatedSource); + } + } + } + + void _validateGeneratedXmlTopic(String path, String source) { + try { + final document = XmlDocument.parse(source); + if (document.rootElement.name.local != 'topic') { + throw const FormatException('Expected a topic root element.'); + } + } on Object catch (error) { + throw BusyMarkException( + 'writerside.topic-file.topic-invalid', + args: {'path': path, 'error': '$error'}, + ); + } + } + + void _validateGeneratedTree(String path, String source) { + try { + final document = XmlDocument.parse(source); + if (document.rootElement.name.local != 'instance-profile') { + throw const FormatException( + 'Expected an instance-profile root element.', + ); + } + } on Object catch (error) { + throw BusyMarkException( + 'writerside.topic-file.tree-invalid', + args: {'path': path, 'error': '$error'}, + ); + } + } + bool _sameSpan(SourceSpan left, SourceSpan right) => left.startOffset == right.startOffset && left.endOffset == right.endOffset; @@ -1753,6 +1910,7 @@ class _AuthoredMarkdownTopicReference { required this.rawDestination, required this.destinationSpan, this.origin, + this.xmlAttribute = false, }); final int occurrenceOffset; @@ -1760,6 +1918,17 @@ class _AuthoredMarkdownTopicReference { final String rawDestination; final SourceSpan destinationSpan; final String? origin; + final bool xmlAttribute; +} + +class _AuthoredMarkdownProjection { + const _AuthoredMarkdownProjection({ + required this.references, + required this.unboundLinks, + }); + + final List<_AuthoredMarkdownTopicReference> references; + final List unboundLinks; } class _MarkdownReferenceDefinition { @@ -1774,6 +1943,17 @@ class _MarkdownReferenceDefinition { final SourceSpan destinationSpan; } +class _InlineMarkdownDestination extends _MarkdownReferenceDefinition { + const _InlineMarkdownDestination({ + required super.destination, + required super.rawDestination, + required super.destinationSpan, + required this.linkEndOffset, + }); + + final int linkEndOffset; +} + class _MutationContext { const _MutationContext({ required this.anchor, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index ec294be0..c7b245c2 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -87,11 +87,16 @@ void main() { }, topics: { 'guide.md': '# Guide\n\n\n', + 'other.md': '# Other\n', 'links.md': ''' # Links Example: `[sample](guide.md)`; guide.md appears before [guide.md](guide.md), [again](guide.md), and [part](guide.md#part). +[Other](other.md "[example](guide.md)") and [Guide](guide.md). + +Comment: and [Guide again](guide.md). + Literal guide.md must stay literal. ''', 'other.topic': ''' @@ -123,6 +128,19 @@ Literal guide.md must stay literal. ), ); expect(links, contains('[part](setup.md#part)')); + expect( + links, + contains( + '[Other](other.md "[example](guide.md)") and ' + '[Guide](setup.md).', + ), + ); + expect( + links, + contains( + 'Comment: and [Guide again](setup.md).', + ), + ); expect(links, contains('Literal guide.md must stay literal.')); final other = File( p.join(fixture.root.path, 'topics', 'other.topic'), @@ -340,9 +358,13 @@ Shared: Shared; local: [Local](guide.md). Full [Guide][g] and repeated [again][g]. Collapsed [Guide][] and shortcut [Guide]. +Inline code label: [`Guide`](guide.md). +Following-line destination: [Guide][next]. [g]: guide.md [guide]: guide.md +[next]: + guide.md ''', }, ); @@ -358,6 +380,8 @@ Collapsed [Guide][] and shortcut [Guide]. ).readAsStringSync(); expect(references, contains('Full [Guide][g] and repeated [again][g].')); expect(references, contains('Collapsed [Guide][] and shortcut [Guide].')); + expect(references, contains('Inline code label: [`Guide`](setup.md).')); + expect(references, contains('Following-line destination: [Guide][next].')); expect( RegExp(r'^\[g\]: setup\.md$', multiLine: true).allMatches(references), hasLength(1), @@ -366,6 +390,7 @@ Collapsed [Guide][] and shortcut [Guide]. RegExp(r'^\[guide\]: setup\.md$', multiLine: true).allMatches(references), hasLength(1), ); + expect(references, contains('[next]:\n setup.md')); expect(references, isNot(contains(']: guide.md'))); }); @@ -405,6 +430,51 @@ Collapsed [Guide][] and shortcut [Guide]. ); }); + test('rename escapes decoded XML attribute replacements', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'api&tools/guide.md': '# Guide\n', + 'links.topic': ''' + + Guide + +''', + }, + ); + + final result = await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'api&tools/guide.md'), + newFileName: 'setup.md', + ); + + expect( + p.normalize(result.newTopicPath), + p.normalize(p.join(fixture.root.path, 'topics', 'api&tools', 'setup.md')), + ); + final linksSource = File( + p.join(fixture.root.path, 'topics', 'links.topic'), + ).readAsStringSync(); + expect(linksSource, contains('href="api&tools/setup.md"')); + expect( + XmlDocument.parse( + linksSource, + ).findAllElements('a').single.getAttribute('href'), + 'api&tools/setup.md', + ); + expect( + _tree(fixture.root, 'guide.tree').rootElement.getAttribute('start-page'), + 'api&tools/setup.md', + ); + }); + test('rename updates a matching XML topic root id', () async { final fixture = await _fixture( trees: { From 4e68d7e29c1c6f1af3b1430af78ade0b97539b33 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 20:29:07 -0700 Subject: [PATCH 10/35] Enhance topic file editor: add angle bracket support for Markdown destinations, improve reference replacement logic, and refine Markdown AST validation. --- .../writerside_topic_file_editor.dart | 115 +++++++++++++++++- .../writerside_topic_file_editor_test.dart | 45 +++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index b009a3d2..c5b27843 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -7,6 +7,8 @@ import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; import '../core/source_span.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/markdown_ast_adapter.dart'; import '../markdown/markdown_model.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; @@ -568,6 +570,8 @@ class WritersideTopicFileEditor { }; final usages = {}; final xmlAttributeSpans = {}; + final markdownDestinationSpans = {}; + final markdownAngleDestinationSpans = {}; for (final usage in index.references) { if (usage.kind != WritersideSymbolKind.topic) continue; final containingTopic = topicsByPath[normalizePath(usage.filePath)]; @@ -666,6 +670,11 @@ class WritersideTopicFileEditor { usages[key] = usage; if (reference.xmlAttribute) { xmlAttributeSpans.add(_referenceSpanKey(usage)); + } else { + markdownDestinationSpans.add(_referenceSpanKey(usage)); + if (reference.angleDestination) { + markdownAngleDestinationSpans.add(_referenceSpanKey(usage)); + } } } } @@ -716,6 +725,17 @@ class WritersideTopicFileEditor { ); if (xmlAttributeSpans.contains(_referenceSpanKey(reference))) { replacement = _escapeXmlAttributeValue(replacement); + } else if (markdownDestinationSpans.contains( + _referenceSpanKey(reference), + )) { + replacement = _renamedMarkdownReferenceSource( + decodedOriginal: reference.value, + rawOriginal: expected, + decodedReplacement: replacement, + angleDestination: markdownAngleDestinationSpans.contains( + _referenceSpanKey(reference), + ), + ); } updated = updated.replaceRange( reference.span.startOffset, @@ -974,6 +994,7 @@ class WritersideTopicFileEditor { startOffset: destinationStart, endOffset: destinationStart + rawDestination.length, ), + angleDestination: match.group(3) != null, ); definitions.putIfAbsent( _normalizedMarkdownLabel(match.group(2)!), @@ -1014,6 +1035,7 @@ class WritersideTopicFileEditor { if (afterLabel < source.length && source[afterLabel] == '(') { final destination = _inlineMarkdownDestination( topic, + labelStart, afterLabel, protected, ); @@ -1024,6 +1046,7 @@ class WritersideTopicFileEditor { destination: destination.destination, rawDestination: destination.rawDestination, destinationSpan: destination.destinationSpan, + angleDestination: destination.angleDestination, ), ); cursor = destination.linkEndOffset; @@ -1050,6 +1073,7 @@ class WritersideTopicFileEditor { destination: definition.destination, rawDestination: definition.rawDestination, destinationSpan: definition.destinationSpan, + angleDestination: definition.angleDestination, ), ); cursor = referenceEnd; @@ -1113,6 +1137,7 @@ class WritersideTopicFileEditor { _InlineMarkdownDestination? _inlineMarkdownDestination( WritersideTopic topic, + int linkStart, int openingParenthesis, List protected, ) { @@ -1124,7 +1149,8 @@ class WritersideTopicFileEditor { } if (start >= source.length || protected[start]) return null; var end = start; - if (source[start] == '<') { + final angleDestination = source[start] == '<'; + if (angleDestination) { start++; end = start; while (end < source.length && @@ -1160,8 +1186,18 @@ class WritersideTopicFileEditor { ); if (linkEnd == null) return null; final rawDestination = source.substring(start, end); + final decodedDestination = _decodeMarkdownDestination(rawDestination); + final parsed = const MarkdownAstAdapter().parseInlineFragment( + source: source.substring(linkStart, linkEnd), + mode: MarkdownMode.writersideMarkdown, + ); + if (parsed.length != 1 || + parsed.single.kind != BusyInlineKind.link || + parsed.single.destination != decodedDestination) { + return null; + } return _InlineMarkdownDestination( - destination: _decodeMarkdownDestination(rawDestination), + destination: decodedDestination, rawDestination: rawDestination, destinationSpan: SourceSpan.fromOffsets( filePath: topic.filePath, @@ -1169,6 +1205,7 @@ class WritersideTopicFileEditor { startOffset: start, endOffset: end, ), + angleDestination: angleDestination, linkEndOffset: linkEnd, ); } @@ -1257,6 +1294,75 @@ class WritersideTopicFileEditor { .replaceAll('"', '"') .replaceAll("'", '''); + String _renamedMarkdownReferenceSource({ + required String decodedOriginal, + required String rawOriginal, + required String decodedReplacement, + required bool angleDestination, + }) { + if (decodedReplacement == decodedOriginal) return rawOriginal; + final decodedHash = decodedOriginal.indexOf('#'); + final decodedPath = decodedHash < 0 + ? decodedOriginal + : decodedOriginal.substring(0, decodedHash); + final decodedSuffix = decodedHash < 0 + ? '' + : decodedOriginal.substring(decodedHash); + final replacementHash = decodedReplacement.indexOf('#'); + final replacementPath = replacementHash < 0 + ? decodedReplacement + : decodedReplacement.substring(0, replacementHash); + final replacementSuffix = replacementHash < 0 + ? '' + : decodedReplacement.substring(replacementHash); + final rawHash = rawOriginal.indexOf('#'); + final rawPath = rawHash < 0 + ? rawOriginal + : rawOriginal.substring(0, rawHash); + final rawSuffix = rawHash < 0 ? '' : rawOriginal.substring(rawHash); + + final decodedSlash = decodedPath.lastIndexOf('/'); + final replacementSlash = replacementPath.lastIndexOf('/'); + final rawSlash = rawPath.lastIndexOf('/'); + final decodedPrefix = decodedSlash < 0 + ? '' + : decodedPath.substring(0, decodedSlash + 1); + final replacementPrefix = replacementSlash < 0 + ? '' + : replacementPath.substring(0, replacementSlash + 1); + final rawPrefix = rawSlash < 0 ? '' : rawPath.substring(0, rawSlash + 1); + final canPreserveAuthoredPrefix = + decodedPrefix == replacementPrefix && + _decodeMarkdownDestination(rawPrefix) == decodedPrefix; + final canPreserveAuthoredSuffix = + decodedSuffix == replacementSuffix && + _decodeMarkdownDestination(rawSuffix) == decodedSuffix; + final replacementName = replacementSlash < 0 + ? replacementPath + : replacementPath.substring(replacementSlash + 1); + return '${canPreserveAuthoredPrefix ? rawPrefix : _escapeMarkdownDestination(replacementPrefix, angleDestination: angleDestination)}' + '${_escapeMarkdownDestination(replacementName, angleDestination: angleDestination)}' + '${canPreserveAuthoredSuffix ? rawSuffix : _escapeMarkdownDestination(replacementSuffix, angleDestination: angleDestination)}'; + } + + String _escapeMarkdownDestination( + String value, { + required bool angleDestination, + }) { + final result = StringBuffer(); + for (final rune in value.runes) { + final character = String.fromCharCode(rune); + final escape = + character == '\\' || + (angleDestination + ? character == '>' + : character == '(' || character == ')'); + if (escape) result.write('\\'); + result.write(character); + } + return '$result'; + } + void _validateGeneratedXmlRenameSources( List<_ReferenceModuleContext> contexts, { required _MutationContext target, @@ -1911,6 +2017,7 @@ class _AuthoredMarkdownTopicReference { required this.destinationSpan, this.origin, this.xmlAttribute = false, + this.angleDestination = false, }); final int occurrenceOffset; @@ -1919,6 +2026,7 @@ class _AuthoredMarkdownTopicReference { final SourceSpan destinationSpan; final String? origin; final bool xmlAttribute; + final bool angleDestination; } class _AuthoredMarkdownProjection { @@ -1936,11 +2044,13 @@ class _MarkdownReferenceDefinition { required this.destination, required this.rawDestination, required this.destinationSpan, + this.angleDestination = false, }); final String destination; final String rawDestination; final SourceSpan destinationSpan; + final bool angleDestination; } class _InlineMarkdownDestination extends _MarkdownReferenceDefinition { @@ -1948,6 +2058,7 @@ class _InlineMarkdownDestination extends _MarkdownReferenceDefinition { required super.destination, required super.rawDestination, required super.destinationSpan, + required super.angleDestination, required this.linkEndOffset, }); diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index c7b245c2..724ff38c 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -95,6 +95,8 @@ Example: `[sample](guide.md)`; guide.md appears before [guide.md](guide.md), [ag [Other](other.md "[example](guide.md)") and [Guide](guide.md). +[not a link](guide.md unexpected) and [Guide](guide.md). + Comment: and [Guide again](guide.md). Literal guide.md must stay literal. @@ -135,6 +137,10 @@ Literal guide.md must stay literal. '[Guide](setup.md).', ), ); + expect( + links, + contains('[not a link](guide.md unexpected) and [Guide](setup.md).'), + ); expect( links, contains( @@ -394,6 +400,45 @@ Following-line destination: [Guide][next]. expect(references, isNot(contains(']: guide.md'))); }); + test('rename preserves escapes required by a Markdown destination', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'api(tools/guide.md': '# Guide\n', + 'links.md': ''' +# Links + +[Guide](api\\(tools/guide.md) +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'api(tools/guide.md'), + newFileName: 'setup.md', + ); + + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + expect( + File(linksPath).readAsStringSync(), + contains(r'[Guide](api\(tools/setup.md)'), + ); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect( + _topic(reloaded, 'links.md').links.single.destination, + 'api(tools/setup.md', + ); + }); + test('rename rewrites an XML-encoded topic destination', () async { final fixture = await _fixture( trees: { From 08c5b6d962bd00e24d36ee8343fa9d1c81c1a3a8 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Mon, 14 Sep 2026 20:47:11 -0700 Subject: [PATCH 11/35] Enhance topic file editor: add inline Markdown destination handling, improve link validation logic, and refine AST parsing for destination replacements. --- .../writerside_topic_file_editor.dart | 58 +++++++++++++++++++ .../writerside_topic_file_editor_test.dart | 47 +++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index c5b27843..2b3dbf8c 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -902,6 +902,14 @@ class WritersideTopicFileEditor { (candidate.destinationSpan.startOffset >= link.span.startOffset && candidate.destinationSpan.endOffset <= link.span.endOffset); if (!belongsToParsedLink) continue; + if (candidate.inlineMarkdownDestination && + !_inlineMarkdownDestinationBelongsToParsedLink( + topic, + link, + candidate, + )) { + continue; + } selected = index; break; } @@ -918,6 +926,53 @@ class WritersideTopicFileEditor { ); } + bool _inlineMarkdownDestinationBelongsToParsedLink( + WritersideTopic topic, + MarkdownLink link, + _AuthoredMarkdownTopicReference candidate, + ) { + final source = topic.document.source; + final contextStart = link.span.startOffset; + final contextEnd = link.span.endOffset; + final destinationStart = candidate.destinationSpan.startOffset; + final destinationEnd = candidate.destinationSpan.endOffset; + if (contextStart < 0 || + contextEnd > source.length || + contextStart >= contextEnd || + destinationStart < contextStart || + destinationEnd > contextEnd || + destinationStart >= destinationEnd) { + return false; + } + + var probe = 'busymark-rename-probe-$destinationStart.invalid'; + while (source.contains(probe)) { + probe = 'x$probe'; + } + final context = source.substring(contextStart, contextEnd); + final probed = context.replaceRange( + destinationStart - contextStart, + destinationEnd - contextStart, + probe, + ); + final parsed = const MarkdownAstAdapter().parseInlineFragment( + source: probed, + mode: MarkdownMode.writersideMarkdown, + ); + + bool containsProbe(Iterable inlines) { + for (final inline in inlines) { + if (inline.kind == BusyInlineKind.link && inline.destination == probe) { + return true; + } + if (containsProbe(inline.children)) return true; + } + return false; + } + + return containsProbe(parsed); + } + List _markdownLiteralMask(WritersideTopic topic) { final source = topic.document.source; final protected = List.filled(source.length, false); @@ -1047,6 +1102,7 @@ class WritersideTopicFileEditor { rawDestination: destination.rawDestination, destinationSpan: destination.destinationSpan, angleDestination: destination.angleDestination, + inlineMarkdownDestination: true, ), ); cursor = destination.linkEndOffset; @@ -2018,6 +2074,7 @@ class _AuthoredMarkdownTopicReference { this.origin, this.xmlAttribute = false, this.angleDestination = false, + this.inlineMarkdownDestination = false, }); final int occurrenceOffset; @@ -2027,6 +2084,7 @@ class _AuthoredMarkdownTopicReference { final String? origin; final bool xmlAttribute; final bool angleDestination; + final bool inlineMarkdownDestination; } class _AuthoredMarkdownProjection { diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 724ff38c..5d07cdd7 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -163,6 +163,53 @@ Literal guide.md must stay literal. }, ); + test( + 'rename ignores link-shaped text in image titles and HTML attributes', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'links.md': ''' +# Links + +![Screenshot](image.png "[example](guide.md)") and [Guide](guide.md). + +Text label and [Guide](guide.md). +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + expect(File(linksPath).readAsStringSync(), ''' +# Links + +![Screenshot](image.png "[example](guide.md)") and [Guide](setup.md). + +Text label and [Guide](setup.md). +'''); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect( + _topic(reloaded, 'links.md').links.map((link) => link.destination), + ['setup.md', 'setup.md'], + ); + }, + ); + test( 'rename follows origin across modules without touching a local namesake', () async { From d27a3d2a0c0d01e9df3c324da65533e18c145a4e Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Mon, 14 Sep 2026 21:48:08 -0700 Subject: [PATCH 12/35] Add support for inline Markdown destination handling in topic file editor --- .../writerside_topic_file_editor.dart | 108 +++++++++++++++++- .../writerside_topic_file_editor_test.dart | 75 ++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 2b3dbf8c..e31abd8a 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -10,6 +10,7 @@ import '../core/source_span.dart'; import '../markdown/busymark_document.dart'; import '../markdown/markdown_ast_adapter.dart'; import '../markdown/markdown_model.dart'; +import '../markdown/markdown_parser.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; @@ -890,6 +891,7 @@ class WritersideTopicFileEditor { final bound = <_AuthoredMarkdownTopicReference>[]; final unbound = []; final consumed = {}; + final referenceOccurrenceValidity = {}; for (final link in topic.links) { int? selected; for (var index = 0; index < authored.length; index++) { @@ -910,6 +912,14 @@ class WritersideTopicFileEditor { )) { continue; } + if (candidate.referenceLabelSpan != null && + !(referenceOccurrenceValidity[index] ??= + _referenceMarkdownOccurrenceBelongsToParsedLink( + topic, + candidate, + ))) { + continue; + } selected = index; break; } @@ -973,6 +983,71 @@ class WritersideTopicFileEditor { return containsProbe(parsed); } + bool _referenceMarkdownOccurrenceBelongsToParsedLink( + WritersideTopic topic, + _AuthoredMarkdownTopicReference candidate, + ) { + final source = topic.document.source; + final referenceLabelSpan = candidate.referenceLabelSpan; + final definitionLabelSpan = candidate.definitionLabelSpan; + if (referenceLabelSpan == null || definitionLabelSpan == null) { + return false; + } + final spans = [ + referenceLabelSpan, + definitionLabelSpan, + candidate.destinationSpan, + ]; + if (spans.any( + (span) => + span.startOffset < 0 || + span.endOffset > source.length || + span.startOffset >= span.endOffset, + )) { + return false; + } + + var probeLabel = 'busymark-rename-reference-${candidate.occurrenceOffset}'; + while (source.toLowerCase().contains(probeLabel.toLowerCase())) { + probeLabel = 'x$probeLabel'; + } + var probeDestination = + 'busymark-rename-destination-${candidate.occurrenceOffset}.invalid'; + while (source.contains(probeDestination) || + probeDestination == candidate.destination || + topic.links.any((link) => link.destination == probeDestination)) { + probeDestination = 'x$probeDestination'; + } + + final replacements = + <({SourceSpan span, String value})>[ + (span: referenceLabelSpan, value: probeLabel), + (span: definitionLabelSpan, value: probeLabel), + (span: candidate.destinationSpan, value: probeDestination), + ]..sort( + (left, right) => + right.span.startOffset.compareTo(left.span.startOffset), + ); + var probed = source; + for (final replacement in replacements) { + probed = probed.replaceRange( + replacement.span.startOffset, + replacement.span.endOffset, + replacement.value, + ); + } + final parsed = const MarkdownParser().parse( + filePath: topic.filePath, + source: probed, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ); + return parsed.links + .where((link) => link.destination == probeDestination) + .length == + 1; + } + List _markdownLiteralMask(WritersideTopic topic) { final source = topic.document.source; final protected = List.filled(source.length, false); @@ -1036,13 +1111,23 @@ class WritersideTopicFileEditor { for (final match in pattern.allMatches(source)) { if (_rangeIsProtected(protected, match.start, match.end)) continue; final rawDestination = match.group(3) ?? match.group(4)!; + final definitionPrefix = match.group(1)!; + final rawLabel = match.group(2)!; + final definitionLabelStart = + match.start + definitionPrefix.indexOf('[') + 1; final destinationStart = match.start + - match.group(1)!.length + + definitionPrefix.length + (match.group(3) == null ? 0 : 1); final definition = _MarkdownReferenceDefinition( destination: _decodeMarkdownDestination(rawDestination), rawDestination: rawDestination, + labelSpan: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: definitionLabelStart, + endOffset: definitionLabelStart + rawLabel.length, + ), destinationSpan: SourceSpan.fromOffsets( filePath: topic.filePath, source: source, @@ -1111,15 +1196,28 @@ class WritersideTopicFileEditor { } String? referenceLabel; + SourceSpan? referenceLabelSpan; var referenceEnd = afterLabel; if (afterLabel < source.length && source[afterLabel] == '[') { final closing = _closingMarkdownBracket(source, afterLabel, protected); if (closing == null) continue; final explicit = source.substring(afterLabel + 1, closing); referenceLabel = explicit.isEmpty ? label : explicit; + referenceLabelSpan = SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: explicit.isEmpty ? labelStart + 1 : afterLabel + 1, + endOffset: explicit.isEmpty ? labelEnd : closing, + ); referenceEnd = closing + 1; } else { referenceLabel = label; + referenceLabelSpan = SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: labelStart + 1, + endOffset: labelEnd, + ); } final definition = definitions[_normalizedMarkdownLabel(referenceLabel)]; if (definition == null) continue; @@ -1130,6 +1228,8 @@ class WritersideTopicFileEditor { rawDestination: definition.rawDestination, destinationSpan: definition.destinationSpan, angleDestination: definition.angleDestination, + referenceLabelSpan: referenceLabelSpan, + definitionLabelSpan: definition.labelSpan, ), ); cursor = referenceEnd; @@ -2075,6 +2175,8 @@ class _AuthoredMarkdownTopicReference { this.xmlAttribute = false, this.angleDestination = false, this.inlineMarkdownDestination = false, + this.referenceLabelSpan, + this.definitionLabelSpan, }); final int occurrenceOffset; @@ -2085,6 +2187,8 @@ class _AuthoredMarkdownTopicReference { final bool xmlAttribute; final bool angleDestination; final bool inlineMarkdownDestination; + final SourceSpan? referenceLabelSpan; + final SourceSpan? definitionLabelSpan; } class _AuthoredMarkdownProjection { @@ -2102,11 +2206,13 @@ class _MarkdownReferenceDefinition { required this.destination, required this.rawDestination, required this.destinationSpan, + this.labelSpan, this.angleDestination = false, }); final String destination; final String rawDestination; + final SourceSpan? labelSpan; final SourceSpan destinationSpan; final bool angleDestination; } diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 5d07cdd7..94cbdb59 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -210,6 +210,81 @@ Text label and [Guide](setup.md). }, ); + test( + 'rename binds reference links outside image titles and HTML attributes', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'image-links.md': ''' +# Image links + +![Screenshot](image.png "[example][fake]") and [Guide][real]. + +[fake]: guide.md +[real]: guide.md +''', + 'html-links.md': ''' +# HTML links + +Text label and [Guide][real]. + +[fake]: guide.md +[real]: guide.md +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final imageLinks = File( + p.join(fixture.root.path, 'topics', 'image-links.md'), + ).readAsStringSync(); + expect(imageLinks, ''' +# Image links + +![Screenshot](image.png "[example][fake]") and [Guide][real]. + +[fake]: guide.md +[real]: setup.md +'''); + final htmlLinks = File( + p.join(fixture.root.path, 'topics', 'html-links.md'), + ).readAsStringSync(); + expect(htmlLinks, ''' +# HTML links + +Text label and [Guide][real]. + +[fake]: guide.md +[real]: setup.md +'''); + + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect( + _topic(reloaded, 'image-links.md').links.single.destination, + 'setup.md', + ); + expect( + _topic(reloaded, 'html-links.md').links.single.destination, + 'setup.md', + ); + }, + ); + test( 'rename follows origin across modules without touching a local namesake', () async { From 4c144d389ed73911b4116358eaf632376f7f7052 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Mon, 14 Sep 2026 22:27:04 -0700 Subject: [PATCH 13/35] Refine destination handling: unify handling of inline Markdown and XML attributes, update validation logic, and replace regex for attribute parsing. --- .../writerside_topic_file_editor.dart | 22 ++++---- .../writerside_topic_file_editor_test.dart | 51 +++++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index e31abd8a..0c7aa0f5 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -904,12 +904,8 @@ class WritersideTopicFileEditor { (candidate.destinationSpan.startOffset >= link.span.startOffset && candidate.destinationSpan.endOffset <= link.span.endOffset); if (!belongsToParsedLink) continue; - if (candidate.inlineMarkdownDestination && - !_inlineMarkdownDestinationBelongsToParsedLink( - topic, - link, - candidate, - )) { + if ((candidate.inlineMarkdownDestination || candidate.xmlAttribute) && + !_markdownDestinationBelongsToParsedLink(topic, link, candidate)) { continue; } if (candidate.referenceLabelSpan != null && @@ -936,7 +932,7 @@ class WritersideTopicFileEditor { ); } - bool _inlineMarkdownDestinationBelongsToParsedLink( + bool _markdownDestinationBelongsToParsedLink( WritersideTopic topic, MarkdownLink link, _AuthoredMarkdownTopicReference candidate, @@ -1248,17 +1244,19 @@ class WritersideTopicFileEditor { caseSensitive: false, dotAll: true, ); - final hrefPattern = RegExp( - r'''\bhref\s*=\s*(["'])(.*?)\1''', - caseSensitive: false, + final attributePattern = RegExp( + r'''([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])(.*?)\2''', dotAll: true, ); for (final tagMatch in tagPattern.allMatches(source)) { if (_rangeIsProtected(protected, tagMatch.start, tagMatch.end)) continue; final tag = tagMatch.group(0)!; - final hrefMatch = hrefPattern.firstMatch(tag); + final hrefMatch = attributePattern + .allMatches(tag) + .where((match) => match.group(1) == 'href') + .firstOrNull; if (hrefMatch == null) continue; - final rawDestination = hrefMatch.group(2)!; + final rawDestination = hrefMatch.group(3)!; final hrefStart = tagMatch.start + hrefMatch.end - 1 - rawDestination.length; try { diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 94cbdb59..265767e9 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -285,6 +285,57 @@ Text label and [Guide][real]. }, ); + test( + 'rename binds exact HTML hrefs outside Markdown link and image titles', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'links.md': ''' +# Links + +![Screenshot](image.png "Example") and [Guide](guide.md). + +[Other](other.md "Example") and [Guide](guide.md). + +Text Guide. +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + expect(File(linksPath).readAsStringSync(), ''' +# Links + +![Screenshot](image.png "Example") and [Guide](setup.md). + +[Other](other.md "Example") and [Guide](setup.md). + +Text Guide. +'''); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect( + _topic(reloaded, 'links.md').links.map((link) => link.destination), + ['setup.md', 'other.md', 'setup.md', 'setup.md'], + ); + }, + ); + test( 'rename follows origin across modules without touching a local namesake', () async { From 9e1739326621aa2af5069e9f813d95257c61c67a Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Mon, 14 Sep 2026 23:58:54 -0700 Subject: [PATCH 14/35] Improve link resolution: refine unbound link handling, add origin tracking, enhance XML tag parsing, and update validation logic. --- .../writerside_topic_file_editor.dart | 111 ++++++++++++--- .../writerside_topic_file_editor_test.dart | 127 +++++++++++++++++- 2 files changed, 216 insertions(+), 22 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 0c7aa0f5..58406df7 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -618,18 +618,28 @@ class WritersideTopicFileEditor { final projection = _authoredMarkdownTopicReferences(topic); for (final unbound in projection.unboundLinks) { final topicPart = unbound.destination.split('#').first; - if (topicPart.isNotEmpty && + final resolvesInRecordedScope = + topicPart.isNotEmpty && index .definitions( topicPart, moduleId: moduleId, + origin: unbound.origin, kind: WritersideSymbolKind.topic, filePath: topic.filePath, referenceOffset: unbound.span.startOffset, ) + .any((symbol) => p.equals(symbol.filePath, target.topicPath)); + final resolvesConservativelyToTarget = + topicPart.isNotEmpty && + unbound.origin == null && + target.module + .topicsMatchingReference(topicPart) .any( - (symbol) => p.equals(symbol.filePath, target.topicPath), - )) { + (candidate) => + p.equals(candidate.filePath, target.topicPath), + ); + if (resolvesInRecordedScope || resolvesConservativelyToTarget) { throw BusyMarkException( 'writerside.topic-file.ambiguous-reference', args: { @@ -889,10 +899,12 @@ class WritersideTopicFileEditor { left.occurrenceOffset.compareTo(right.occurrenceOffset), ); final bound = <_AuthoredMarkdownTopicReference>[]; - final unbound = []; + final unbound = <_UnboundMarkdownTopicReference>[]; final consumed = {}; final referenceOccurrenceValidity = {}; - for (final link in topic.links) { + final linkOrigins = _markdownLinkOriginsInParseOrder(topic); + for (var linkIndex = 0; linkIndex < topic.links.length; linkIndex++) { + final link = topic.links[linkIndex]; int? selected; for (var index = 0; index < authored.length; index++) { if (consumed.contains(index)) continue; @@ -923,7 +935,14 @@ class WritersideTopicFileEditor { consumed.add(selected); bound.add(authored[selected]); } else { - unbound.add(link); + unbound.add( + _UnboundMarkdownTopicReference( + link: link, + origin: linkIndex < linkOrigins.length + ? linkOrigins[linkIndex] + : null, + ), + ); } } return _AuthoredMarkdownProjection( @@ -932,6 +951,35 @@ class WritersideTopicFileEditor { ); } + List _markdownLinkOriginsInParseOrder(WritersideTopic topic) { + final origins = []; + + void visitInline(BusyInline inline) { + if (inline.kind == BusyInlineKind.link && + inline.destination?.trim().isNotEmpty == true) { + final origin = inline.attributes['origin']?.trim(); + origins.add(origin?.isEmpty == true ? null : origin); + } + for (final child in inline.children) { + visitInline(child); + } + } + + void visitBlock(BusyBlock block) { + for (final inline in block.inlines) { + visitInline(inline); + } + for (final child in block.children) { + visitBlock(child); + } + } + + for (final block in topic.markdown?.busyDocument.blocks ?? const []) { + visitBlock(block); + } + return origins; + } + bool _markdownDestinationBelongsToParsedLink( WritersideTopic topic, MarkdownLink link, @@ -1239,18 +1287,13 @@ class WritersideTopicFileEditor { ) { final source = topic.document.source; final occurrences = <_AuthoredMarkdownTopicReference>[]; - final tagPattern = RegExp( - r''']*>''', - caseSensitive: false, - dotAll: true, - ); final attributePattern = RegExp( r'''([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])(.*?)\2''', dotAll: true, ); - for (final tagMatch in tagPattern.allMatches(source)) { - if (_rangeIsProtected(protected, tagMatch.start, tagMatch.end)) continue; - final tag = tagMatch.group(0)!; + for (final tagRange in _markdownXmlAnchorOpeningTagRanges(source)) { + if (_rangeIsProtected(protected, tagRange.start, tagRange.end)) continue; + final tag = source.substring(tagRange.start, tagRange.end); final hrefMatch = attributePattern .allMatches(tag) .where((match) => match.group(1) == 'href') @@ -1258,7 +1301,7 @@ class WritersideTopicFileEditor { if (hrefMatch == null) continue; final rawDestination = hrefMatch.group(3)!; final hrefStart = - tagMatch.start + hrefMatch.end - 1 - rawDestination.length; + tagRange.start + hrefMatch.end - 1 - rawDestination.length; try { final selfClosing = tag.endsWith('/>') ? tag @@ -1269,7 +1312,7 @@ class WritersideTopicFileEditor { final origin = element.getAttribute('origin')?.trim(); occurrences.add( _AuthoredMarkdownTopicReference( - occurrenceOffset: tagMatch.start, + occurrenceOffset: tagRange.start, destination: destination, rawDestination: rawDestination, destinationSpan: SourceSpan.fromOffsets( @@ -1289,6 +1332,30 @@ class WritersideTopicFileEditor { return occurrences; } + Iterable<({int start, int end})> _markdownXmlAnchorOpeningTagRanges( + String source, + ) sync* { + final openingPattern = RegExp(r'''])''', caseSensitive: false); + for (final opening in openingPattern.allMatches(source)) { + String? quote; + for (var index = opening.end; index < source.length; index++) { + final character = source[index]; + if (quote != null) { + if (character == quote) quote = null; + continue; + } + if (character == '"' || character == "'") { + quote = character; + } else if (character == '>') { + yield (start: opening.start, end: index + 1); + break; + } else if (character == '<') { + break; + } + } + } + } + _InlineMarkdownDestination? _inlineMarkdownDestination( WritersideTopic topic, int linkStart, @@ -2196,7 +2263,17 @@ class _AuthoredMarkdownProjection { }); final List<_AuthoredMarkdownTopicReference> references; - final List unboundLinks; + final List<_UnboundMarkdownTopicReference> unboundLinks; +} + +class _UnboundMarkdownTopicReference { + const _UnboundMarkdownTopicReference({required this.link, this.origin}); + + final MarkdownLink link; + final String? origin; + + String get destination => link.destination; + SourceSpan get span => link.span; } class _MarkdownReferenceDefinition { diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 265767e9..562b3159 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -306,6 +306,10 @@ Text label and [Guide][real]. [Other](other.md "Example") and [Guide](guide.md). Text Guide. + +Text Guide. + +Text Guide. ''', }, ); @@ -325,13 +329,24 @@ Text Guide. [Other](other.md "Example") and [Guide](setup.md). Text Guide. + +Text Guide. + +Text Guide. '''); final reloaded = await const WritersideModuleService().load( fixture.root.path, ); expect( _topic(reloaded, 'links.md').links.map((link) => link.destination), - ['setup.md', 'other.md', 'setup.md', 'setup.md'], + [ + 'setup.md', + 'other.md', + 'setup.md', + 'setup.md', + 'setup.md', + 'setup.md', + ], ); }, ); @@ -378,8 +393,10 @@ Text Guide. ''', topics: { 'guide.md': '# Local namesake\n', - 'links.topic': ''' -Shared + 'links.md': ''' +# Links + +Text Shared. ''', }, ); @@ -404,8 +421,15 @@ Text Guide. '# Local namesake\n', ); expect( - File(p.join(main.rootPath, 'topics', 'links.topic')).readAsStringSync(), - contains('href="setup.md" origin="shared"'), + File(p.join(main.rootPath, 'topics', 'links.md')).readAsStringSync(), + contains('title="A > B" href="setup.md" origin="shared"'), + ); + final reloadedMain = await const WritersideModuleService().load( + main.rootPath, + ); + expect( + _topic(reloadedMain, 'links.md').links.single.destination, + 'setup.md', ); final mainTree = XmlDocument.parse( File(p.join(main.rootPath, 'guide.tree')).readAsStringSync(), @@ -521,6 +545,99 @@ Shared: Shared; local: [Local](guide.md). }, ); + test( + 'unbound origin-qualified Markdown links block cross-module rename', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-file-project-unbound-origin-', + ); + addTearDown(() => root.delete(recursive: true)); + Future writeModule({ + required String directory, + required String moduleName, + required String tree, + required Map topics, + }) async { + final moduleRoot = await Directory( + p.join(root.path, directory), + ).create(); + final topicRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); + for (final entry in topics.entries) { + await File( + p.join(topicRoot.path, entry.key), + ).writeAsString(entry.value); + } + return const WritersideModuleService().load(moduleRoot.path); + } + + final main = await writeModule( + directory: 'main', + moduleName: 'main', + tree: ''' + + + + +''', + topics: { + 'links.md': ''' +# Links + +Text Shared guide. +''', + }, + ); + final shared = await writeModule( + directory: 'shared', + moduleName: 'shared', + tree: ''' + + + +''', + topics: {'guide.md': '# Shared guide\n'}, + ); + final originalLinkSource = File( + p.join(main.rootPath, 'topics', 'links.md'), + ).readAsStringSync(); + + await expectLater( + editor.rename( + module: shared, + topic: _topic(shared, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.ambiguous-reference', + ), + ), + ); + + expect( + File(p.join(main.rootPath, 'topics', 'links.md')).readAsStringSync(), + originalLinkSource, + ); + expect( + File(p.join(shared.rootPath, 'topics', 'guide.md')).existsSync(), + isTrue, + ); + expect( + File(p.join(shared.rootPath, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }, + ); + test('rename updates shared Markdown reference definitions once', () async { final fixture = await _fixture( trees: { From 9f355536826d0d8cee510e4f4af9ceccca17edf8 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 00:28:46 -0700 Subject: [PATCH 15/35] Refactor link resolution: replace XML-based anchor tag handling with HTML tokenizer, improve `href` parsing and attribute span handling, and remove unused XML tag range logic. --- .../writerside_topic_file_editor.dart | 125 ++++++------- .../writerside_topic_file_editor_test.dart | 165 ++++++++++-------- 2 files changed, 148 insertions(+), 142 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 58406df7..4604f943 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -1,5 +1,12 @@ import 'dart:io'; +// HtmlParser exposes its tokenizer but does not expose per-attribute source +// spans. Use the package tokenizer so raw HTML references are interpreted with +// the same HTML rules as BusyMark's Markdown parser. +// ignore: implementation_imports +import 'package:html/src/token.dart' show StartTagToken; +// ignore: implementation_imports +import 'package:html/src/tokenizer.dart' show HtmlTokenizer; import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; @@ -797,11 +804,7 @@ class WritersideTopicFileEditor { changed = true; } for (final element in tree.document.findAllElements('toc-element')) { - for (final attributeName in const [ - 'topic', - 'ref', - 'target-for-accept-web-filenames', - ]) { + for (final attributeName in const ['topic', 'ref']) { final reference = element.getAttribute(attributeName); if (reference == null || !_projectReferenceTargetsTopic( @@ -893,7 +896,7 @@ class WritersideTopicFileEditor { final authored = <_AuthoredMarkdownTopicReference>[ ..._markdownLinkOccurrences(topic, protected, definitions), - ..._markdownXmlLinkOccurrences(topic, protected), + ..._markdownHtmlLinkOccurrences(topic, protected), ]..sort( (left, right) => left.occurrenceOffset.compareTo(right.occurrenceOffset), @@ -1281,81 +1284,67 @@ class WritersideTopicFileEditor { return occurrences; } - List<_AuthoredMarkdownTopicReference> _markdownXmlLinkOccurrences( + List<_AuthoredMarkdownTopicReference> _markdownHtmlLinkOccurrences( WritersideTopic topic, List protected, ) { final source = topic.document.source; final occurrences = <_AuthoredMarkdownTopicReference>[]; - final attributePattern = RegExp( - r'''([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])(.*?)\2''', - dotAll: true, + final tokenizer = HtmlTokenizer( + source, + generateSpans: true, + attributeSpans: true, ); - for (final tagRange in _markdownXmlAnchorOpeningTagRanges(source)) { - if (_rangeIsProtected(protected, tagRange.start, tagRange.end)) continue; - final tag = source.substring(tagRange.start, tagRange.end); - final hrefMatch = attributePattern - .allMatches(tag) - .where((match) => match.group(1) == 'href') + while (tokenizer.moveNext()) { + final token = tokenizer.current; + if (token is! StartTagToken || token.name != 'a') continue; + final tagSpan = token.span; + if (tagSpan == null || + _rangeIsProtected( + protected, + tagSpan.start.offset, + tagSpan.end.offset, + )) { + continue; + } + final attributes = token.attributeSpans; + if (attributes == null) continue; + final href = attributes + .where((attribute) => attribute.name == 'href') .firstOrNull; - if (hrefMatch == null) continue; - final rawDestination = hrefMatch.group(3)!; - final hrefStart = - tagRange.start + hrefMatch.end - 1 - rawDestination.length; - try { - final selfClosing = tag.endsWith('/>') - ? tag - : '${tag.substring(0, tag.length - 1)}/>'; - final element = XmlDocument.parse(selfClosing).rootElement; - final destination = element.getAttribute('href'); - if (destination == null || destination.isEmpty) continue; - final origin = element.getAttribute('origin')?.trim(); - occurrences.add( - _AuthoredMarkdownTopicReference( - occurrenceOffset: tagRange.start, - destination: destination, - rawDestination: rawDestination, - destinationSpan: SourceSpan.fromOffsets( - filePath: topic.filePath, - source: source, - startOffset: hrefStart, - endOffset: hrefStart + rawDestination.length, - ), - origin: origin?.isEmpty == true ? null : origin, - xmlAttribute: true, - ), - ); - } on XmlParserException { + final hrefStart = href?.startValue; + final hrefEnd = href?.endValue; + final destination = href?.value; + if (href == null || + hrefStart == null || + hrefEnd == null || + hrefStart < 0 || + hrefEnd > source.length || + hrefStart >= hrefEnd || + destination == null || + destination.isEmpty) { continue; } + final origin = token.data['origin']?.trim(); + occurrences.add( + _AuthoredMarkdownTopicReference( + occurrenceOffset: tagSpan.start.offset, + destination: destination, + rawDestination: source.substring(hrefStart, hrefEnd), + destinationSpan: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: source, + startOffset: hrefStart, + endOffset: hrefEnd, + ), + origin: origin?.isEmpty == true ? null : origin, + xmlAttribute: true, + ), + ); } return occurrences; } - Iterable<({int start, int end})> _markdownXmlAnchorOpeningTagRanges( - String source, - ) sync* { - final openingPattern = RegExp(r'''])''', caseSensitive: false); - for (final opening in openingPattern.allMatches(source)) { - String? quote; - for (var index = opening.end; index < source.length; index++) { - final character = source[index]; - if (quote != null) { - if (character == quote) quote = null; - continue; - } - if (character == '"' || character == "'") { - quote = character; - } else if (character == '>') { - yield (start: opening.start, end: index + 1); - break; - } else if (character == '<') { - break; - } - } - } - } - _InlineMarkdownDestination? _inlineMarkdownDestination( WritersideTopic topic, int linkStart, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 562b3159..2eb4a87c 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -81,7 +81,9 @@ void main() { 'other.tree': ''' - + ''', }, @@ -160,6 +162,15 @@ Literal guide.md must stay literal. otherTree.findAllElements('toc-element').first.getAttribute('ref'), 'setup.md', ); + final externalRedirect = otherTree.findAllElements('toc-element').last; + expect( + externalRedirect.getAttribute('target-for-accept-web-filenames'), + 'guide.md', + ); + expect( + externalRedirect.getAttribute('target-for-accept-web-file-names'), + 'https://example.com/guide.md', + ); }, ); @@ -310,6 +321,12 @@ Text Guide. Text Guide. Text Guide. + +Text Guide. + +Text Guide. + +Text Guide. ''', }, ); @@ -333,6 +350,12 @@ Text Guide. Text Guide. Text Guide. + +Text Guide. + +Text Guide. + +Text Guide. '''); final reloaded = await const WritersideModuleService().load( fixture.root.path, @@ -346,6 +369,9 @@ Text Guide. 'setup.md', 'setup.md', 'setup.md', + 'setup.md', + 'setup.md', + 'setup.md', ], ); }, @@ -545,98 +571,89 @@ Shared: Shared; local: [Local](guide.md). }, ); - test( - 'unbound origin-qualified Markdown links block cross-module rename', - () async { - final root = await Directory.systemTemp.createTemp( - 'busymark-topic-file-project-unbound-origin-', - ); - addTearDown(() => root.delete(recursive: true)); - Future writeModule({ - required String directory, - required String moduleName, - required String tree, - required Map topics, - }) async { - final moduleRoot = await Directory( - p.join(root.path, directory), - ).create(); - final topicRoot = await Directory( - p.join(moduleRoot.path, 'topics'), - ).create(); - await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + test('rename supports unquoted origin-qualified raw HTML links', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-file-project-unbound-origin-', + ); + addTearDown(() => root.delete(recursive: true)); + Future writeModule({ + required String directory, + required String moduleName, + required String tree, + required Map topics, + }) async { + final moduleRoot = await Directory(p.join(root.path, directory)).create(); + final topicRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' '''); - await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); - for (final entry in topics.entries) { - await File( - p.join(topicRoot.path, entry.key), - ).writeAsString(entry.value); - } - return const WritersideModuleService().load(moduleRoot.path); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); + for (final entry in topics.entries) { + await File( + p.join(topicRoot.path, entry.key), + ).writeAsString(entry.value); } + return const WritersideModuleService().load(moduleRoot.path); + } - final main = await writeModule( - directory: 'main', - moduleName: 'main', - tree: ''' + final main = await writeModule( + directory: 'main', + moduleName: 'main', + tree: ''' ''', - topics: { - 'links.md': ''' + topics: { + 'links.md': ''' # Links Text Shared guide. ''', - }, - ); - final shared = await writeModule( - directory: 'shared', - moduleName: 'shared', - tree: ''' + }, + ); + final shared = await writeModule( + directory: 'shared', + moduleName: 'shared', + tree: ''' ''', - topics: {'guide.md': '# Shared guide\n'}, - ); - final originalLinkSource = File( - p.join(main.rootPath, 'topics', 'links.md'), - ).readAsStringSync(); + topics: {'guide.md': '# Shared guide\n'}, + ); + await editor.rename( + module: shared, + topic: _topic(shared, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ); - await expectLater( - editor.rename( - module: shared, - topic: _topic(shared, 'guide.md'), - newFileName: 'setup.md', - projectModules: [main, shared], - ), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'writerside.topic-file.ambiguous-reference', - ), - ), - ); + final linkPath = p.join(main.rootPath, 'topics', 'links.md'); + expect(File(linkPath).readAsStringSync(), ''' +# Links - expect( - File(p.join(main.rootPath, 'topics', 'links.md')).readAsStringSync(), - originalLinkSource, - ); - expect( - File(p.join(shared.rootPath, 'topics', 'guide.md')).existsSync(), - isTrue, - ); - expect( - File(p.join(shared.rootPath, 'topics', 'setup.md')).existsSync(), - isFalse, - ); - }, - ); +Text Shared guide. +'''); + expect( + File(p.join(shared.rootPath, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + expect( + File(p.join(shared.rootPath, 'topics', 'setup.md')).existsSync(), + isTrue, + ); + final reloadedMain = await const WritersideModuleService().load( + main.rootPath, + ); + expect( + _topic(reloadedMain, 'links.md').links.single.destination, + 'setup.md', + ); + }); test('rename updates shared Markdown reference definitions once', () async { final fixture = await _fixture( From 3c1a1ffd0d6df51606cbdbff9325c1517517e8a1 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 13:04:33 -0700 Subject: [PATCH 16/35] Extend link resolution: add handling for HTML attribute quotes, refine `href` escaping logic, and improve destination replacements. --- .../writerside_topic_file_editor.dart | 64 +++++++++++++++---- .../writerside_topic_file_editor_test.dart | 45 +++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 4604f943..4d78f7ac 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -578,6 +578,7 @@ class WritersideTopicFileEditor { }; final usages = {}; final xmlAttributeSpans = {}; + final htmlAttributeQuotes = {}; final markdownDestinationSpans = {}; final markdownAngleDestinationSpans = {}; for (final usage in index.references) { @@ -686,12 +687,15 @@ class WritersideTopicFileEditor { '${usage.filePath}:${usage.span.startOffset}:' '${usage.span.endOffset}:${usage.sourceValue}'; usages[key] = usage; - if (reference.xmlAttribute) { - xmlAttributeSpans.add(_referenceSpanKey(usage)); + final spanKey = _referenceSpanKey(usage); + if (reference.htmlAttributeQuote case final quote?) { + htmlAttributeQuotes[spanKey] = quote; + } else if (reference.xmlAttribute) { + xmlAttributeSpans.add(spanKey); } else { - markdownDestinationSpans.add(_referenceSpanKey(usage)); + markdownDestinationSpans.add(spanKey); if (reference.angleDestination) { - markdownAngleDestinationSpans.add(_referenceSpanKey(usage)); + markdownAngleDestinationSpans.add(spanKey); } } } @@ -741,18 +745,17 @@ class WritersideTopicFileEditor { newTopicFileName: newTopicFileName, newFileName: newFileName, ); - if (xmlAttributeSpans.contains(_referenceSpanKey(reference))) { + final spanKey = _referenceSpanKey(reference); + if (htmlAttributeQuotes[spanKey] case final quote?) { + replacement = _escapeHtmlAttributeValue(replacement, quote); + } else if (xmlAttributeSpans.contains(spanKey)) { replacement = _escapeXmlAttributeValue(replacement); - } else if (markdownDestinationSpans.contains( - _referenceSpanKey(reference), - )) { + } else if (markdownDestinationSpans.contains(spanKey)) { replacement = _renamedMarkdownReferenceSource( decodedOriginal: reference.value, rawOriginal: expected, decodedReplacement: replacement, - angleDestination: markdownAngleDestinationSpans.contains( - _referenceSpanKey(reference), - ), + angleDestination: markdownAngleDestinationSpans.contains(spanKey), ); } updated = updated.replaceRange( @@ -1326,6 +1329,15 @@ class WritersideTopicFileEditor { continue; } final origin = token.data['origin']?.trim(); + final htmlAttributeQuote = switch (hrefStart > tagSpan.start.offset + ? source[hrefStart - 1] + : null) { + '"' when hrefEnd < tagSpan.end.offset && source[hrefEnd] == '"' => + _HtmlAttributeQuote.doubleQuoted, + "'" when hrefEnd < tagSpan.end.offset && source[hrefEnd] == "'" => + _HtmlAttributeQuote.singleQuoted, + _ => _HtmlAttributeQuote.unquoted, + }; occurrences.add( _AuthoredMarkdownTopicReference( occurrenceOffset: tagSpan.start.offset, @@ -1339,6 +1351,7 @@ class WritersideTopicFileEditor { ), origin: origin?.isEmpty == true ? null : origin, xmlAttribute: true, + htmlAttributeQuote: htmlAttributeQuote, ), ); } @@ -1504,6 +1517,31 @@ class WritersideTopicFileEditor { .replaceAll('"', '"') .replaceAll("'", '''); + String _escapeHtmlAttributeValue(String value, _HtmlAttributeQuote quote) { + if (quote != _HtmlAttributeQuote.unquoted) { + return _escapeXmlAttributeValue(value); + } + final escaped = StringBuffer(); + for (final rune in value.runes) { + escaped.write(switch (rune) { + 0x09 => ' ', + 0x0A => ' ', + 0x0C => ' ', + 0x0D => ' ', + 0x20 => ' ', + 0x22 => '"', + 0x26 => '&', + 0x27 => ''', + 0x3C => '<', + 0x3D => '=', + 0x3E => '>', + 0x60 => '`', + _ => String.fromCharCode(rune), + }); + } + return escaped.toString(); + } + String _renamedMarkdownReferenceSource({ required String decodedOriginal, required String rawOriginal, @@ -2227,6 +2265,7 @@ class _AuthoredMarkdownTopicReference { required this.destinationSpan, this.origin, this.xmlAttribute = false, + this.htmlAttributeQuote, this.angleDestination = false, this.inlineMarkdownDestination = false, this.referenceLabelSpan, @@ -2239,12 +2278,15 @@ class _AuthoredMarkdownTopicReference { final SourceSpan destinationSpan; final String? origin; final bool xmlAttribute; + final _HtmlAttributeQuote? htmlAttributeQuote; final bool angleDestination; final bool inlineMarkdownDestination; final SourceSpan? referenceLabelSpan; final SourceSpan? definitionLabelSpan; } +enum _HtmlAttributeQuote { doubleQuoted, singleQuoted, unquoted } + class _AuthoredMarkdownProjection { const _AuthoredMarkdownProjection({ required this.references, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 2eb4a87c..cc187e5a 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -655,6 +655,51 @@ Text Shared guide. ); }); + test('rename keeps decoded whitespace safe in unquoted HTML hrefs', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'api tools/guide.md': '# Guide\n', + 'links.md': ''' +# Links + +Decimal: Guide. + +Hexadecimal: Guide. +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'api tools/guide.md'), + newFileName: 'setup.md', + ); + + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + expect(File(linksPath).readAsStringSync(), ''' +# Links + +Decimal: Guide. + +Hexadecimal: Guide. +'''); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect(_topic(reloaded, 'links.md').links.map((link) => link.destination), [ + 'api tools/setup.md', + 'api tools/setup.md', + ]); + expect(reloaded.topicByReference('api tools/setup.md')?.title, 'Guide'); + }); + test('rename updates shared Markdown reference definitions once', () async { final fixture = await _fixture( trees: { From 145890b0c0fd5bc298a84d133dffc63c39bce81d Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 13:45:29 -0700 Subject: [PATCH 17/35] Add validation for invalid empty group attributes in TOC elements and corresponding diagnostics logic --- lib/src/core/diagnostic_localizations.dart | 6 ++ lib/src/writerside/writerside_parsers.dart | 21 ++++++ test/src/writerside_test.dart | 76 ++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/lib/src/core/diagnostic_localizations.dart b/lib/src/core/diagnostic_localizations.dart index 887e614b..482b9b58 100644 --- a/lib/src/core/diagnostic_localizations.dart +++ b/lib/src/core/diagnostic_localizations.dart @@ -116,6 +116,12 @@ String localizeDiagnostic(BuildContext context, Diagnostic diagnostic) { l10n.diagnosticWritersideTreeInvalidCrossInstanceReference, 'writerside.tree.conflicting-toc-targets' => l10n.diagnosticWritersideTreeConflictingTargets, + 'writerside.tree.invalid-empty-group-attribute' => + l10n.diagnosticWritersideSchema( + value('element'), + value('attribute'), + value('reason'), + ), 'writerside.tree.duplicate-element-id' => l10n.diagnosticWritersideTreeDuplicateElementId(value('id')), 'writerside.instance-groups.invalid-root' => diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index eb421483..85cda942 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -801,6 +801,27 @@ class WritersideTreeParser { ), ); } + if (primaryTargets == 0) { + for (final attributeName in const [ + 'accepts-web-file-names', + 'accepts-web-file-names-ref', + ]) { + if (element.getAttribute(attributeName) == null) continue; + diagnostics.add( + Diagnostic( + code: 'writerside.tree.invalid-empty-group-attribute', + severity: DiagnosticSeverity.error, + filePath: filePath, + args: { + 'element': 'toc-element', + 'attribute': attributeName, + 'reason': 'The attribute is not allowed on an empty TOC element.', + }, + sourceSpan: span, + ), + ); + } + } final entries = _treeEntries( filePath, source, diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 9950b62f..eb52b935 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -801,6 +801,82 @@ void main() { expect(instance.diagnostics.where(isError), isEmpty); }); + test('rejects redirect-source attributes on empty TOC groups', () { + final instance = treeParser.parse('/project/guide.tree', ''' + + + + + + + + +'''); + + final diagnostics = instance.diagnostics + .where( + (diagnostic) => + diagnostic.code == + 'writerside.tree.invalid-empty-group-attribute', + ) + .toList(); + expect(diagnostics, hasLength(2)); + expect(diagnostics.every(isError), isTrue); + expect(diagnostics.map((diagnostic) => diagnostic.args['attribute']), [ + 'accepts-web-file-names', + 'accepts-web-file-names-ref', + ]); + }); + + test( + 'module service retains empty-group redirect-source diagnostics', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-empty-group-redirect-source-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + Directory(p.join(root.path, 'topics')).createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + + + + +'''); + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + + + + + + + + +'''); + File( + p.join(root.path, 'topics', 'first.md'), + ).writeAsStringSync('# First\n'); + File( + p.join(root.path, 'topics', 'second.md'), + ).writeAsStringSync('# Second\n'); + + final module = await moduleService.load(root.path); + final diagnostics = module.diagnostics + .where( + (diagnostic) => + diagnostic.code == + 'writerside.tree.invalid-empty-group-attribute', + ) + .toList(); + + expect(diagnostics, hasLength(2)); + expect(diagnostics.every(isError), isTrue); + expect(diagnostics.map((diagnostic) => diagnostic.args['attribute']), [ + 'accepts-web-file-names', + 'accepts-web-file-names-ref', + ]); + }, + ); + test( 'resolves registered TOC libraries, filters, groups, and cross-instance refs', () async { From fd3d2d018c7007c9cae3f579f8ee50abda0b6f2a Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 15:50:38 -0700 Subject: [PATCH 18/35] Refactor Writerside topic handling --- lib/l10n/app_ar.arb | 11 +- lib/l10n/app_de.arb | 11 +- lib/l10n/app_en.arb | 18 + lib/l10n/app_es.arb | 11 +- lib/l10n/app_et.arb | 11 +- lib/l10n/app_fa.arb | 11 +- lib/l10n/app_fr.arb | 11 +- lib/l10n/app_hi.arb | 11 +- lib/l10n/app_id.arb | 11 +- lib/l10n/app_it.arb | 11 +- lib/l10n/app_ja.arb | 11 +- lib/l10n/app_ko.arb | 11 +- lib/l10n/app_nb.arb | 11 +- lib/l10n/app_nl.arb | 11 +- lib/l10n/app_pl.arb | 11 +- lib/l10n/app_pt.arb | 11 +- lib/l10n/app_pt_BR.arb | 11 +- lib/l10n/app_ru.arb | 11 +- lib/l10n/app_tr.arb | 11 +- lib/l10n/app_uk.arb | 11 +- lib/l10n/app_vi.arb | 11 +- lib/l10n/app_zh.arb | 11 +- lib/l10n/app_zh_CN.arb | 11 +- lib/l10n/generated/app_localizations.dart | 63 +++ lib/l10n/generated/app_localizations_ar.dart | 47 ++ lib/l10n/generated/app_localizations_de.dart | 47 ++ lib/l10n/generated/app_localizations_en.dart | 47 ++ lib/l10n/generated/app_localizations_es.dart | 47 ++ lib/l10n/generated/app_localizations_et.dart | 47 ++ lib/l10n/generated/app_localizations_fa.dart | 47 ++ lib/l10n/generated/app_localizations_fr.dart | 47 ++ lib/l10n/generated/app_localizations_hi.dart | 47 ++ lib/l10n/generated/app_localizations_id.dart | 47 ++ lib/l10n/generated/app_localizations_it.dart | 47 ++ lib/l10n/generated/app_localizations_ja.dart | 47 ++ lib/l10n/generated/app_localizations_ko.dart | 47 ++ lib/l10n/generated/app_localizations_nb.dart | 47 ++ lib/l10n/generated/app_localizations_nl.dart | 47 ++ lib/l10n/generated/app_localizations_pl.dart | 47 ++ lib/l10n/generated/app_localizations_pt.dart | 94 ++++ lib/l10n/generated/app_localizations_ru.dart | 47 ++ lib/l10n/generated/app_localizations_tr.dart | 47 ++ lib/l10n/generated/app_localizations_uk.dart | 47 ++ lib/l10n/generated/app_localizations_vi.dart | 47 ++ lib/l10n/generated/app_localizations_zh.dart | 92 ++++ lib/src/core/diagnostic_localizations.dart | 13 + lib/src/export/html_publication_plan.dart | 28 +- .../local_history_comparison_view.dart | 12 + .../presentation/workspace_screen.dart | 427 +++++++++++++++--- .../presentation/writerside_toc_dialogs.dart | 99 ++++ lib/src/workspace/workspace_controller.dart | 76 +++- lib/src/workspace/workspace_message.dart | 20 + lib/src/workspace/workspace_service.dart | 36 +- lib/src/writerside/writerside_model.dart | 3 + lib/src/writerside/writerside_parsers.dart | 56 ++- lib/src/writerside/writerside_project.dart | 10 + .../writerside/writerside_topic_creator.dart | 28 +- .../writerside_topic_file_editor.dart | 403 +++++++++++++++-- .../writerside_topic_file_name.dart | 68 +++ .../writerside_topic_removal_service.dart | 83 ++-- .../writerside/writerside_web_file_name.dart | 235 ++++++++++ linux/runner/writerside_dialog_host.cc | 23 +- test/src/app_smoke_test.dart | 349 ++++++++++++++ test/src/html_publication_test.dart | 43 ++ test/src/workspace_controller_test.dart | 26 +- test/src/writerside_project_test.dart | 21 + test/src/writerside_test.dart | 70 +++ test/src/writerside_topic_creator_test.dart | 36 ++ .../writerside_topic_file_editor_test.dart | 314 +++++++++++++ test/src/writerside_web_file_name_test.dart | 384 ++++++++++++++++ tools/writerside_toc_visual_smoke.dart | 92 +++- 71 files changed, 4108 insertions(+), 249 deletions(-) create mode 100644 lib/src/writerside/writerside_topic_file_name.dart create mode 100644 lib/src/writerside/writerside_web_file_name.dart create mode 100644 test/src/writerside_web_file_name_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0b3186d9..53f49075 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "مسار مطلق واحد في كل سطر", "settingsSecondsValue": "{value} ثوان", "settingsDaysValue": "{value} أيام", - "settingsMebibytesValue": "{value} ميجابايت" + "settingsMebibytesValue": "{value} ميجابايت", + "preview": "معاينة", + "topicRenamePreviewTitle": "معاينة إعادة التسمية", + "topicRenamePathChange": "المسار: {oldPath} → {newPath}", + "topicRenameIdChange": "معرّف الموضوع: {oldId} → {newId}", + "topicRenameUrlChange": "المثيل {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "الملفات المتأثرة", + "renameTopicFileInstead": "تُشتق معرّفات الموضوعات من أسماء ملفاتها. استخدم «إعادة تسمية ملف الموضوع» في جدول المحتويات أو الشريط الجانبي للملفات.", + "diagnosticWritersideWebFileNameInvalid": "ينشر المثيل \"{instanceId}\" اسم ملف ويب غير صالح \"{webFileName}\" للموضوع \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "ينشر المثيل \"{instanceId}\" الموضوعين \"{firstTopic}\" و\"{secondTopic}\" باسم \"{webFileName}\"." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a811e010..22e2cd0d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Ein absoluter Pfad pro Zeile", "settingsSecondsValue": "{value} Sekunden", "settingsDaysValue": "{value} Tage", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Vorschau", + "topicRenamePreviewTitle": "Umbenennungsvorschau", + "topicRenamePathChange": "Pfad: {oldPath} → {newPath}", + "topicRenameIdChange": "Themen-ID: {oldId} → {newId}", + "topicRenameUrlChange": "Instanz {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Betroffene Dateien", + "renameTopicFileInstead": "Themen-IDs werden aus ihren Dateinamen abgeleitet. Verwenden Sie „Themendatei umbenennen“ im Inhaltsverzeichnis oder in der Seitenleiste „Dateien“.", + "diagnosticWritersideWebFileNameInvalid": "Instanz \"{instanceId}\" veröffentlicht den ungültigen Webdateinamen \"{webFileName}\" für das Thema \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Instanz \"{instanceId}\" veröffentlicht sowohl \"{firstTopic}\" als auch \"{secondTopic}\" unter \"{webFileName}\"." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9c7b3386..aacb1bdb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -997,6 +997,18 @@ "@outsideSelectedInstance": {"description": "Marker for a usage outside the selected Writerside instance."}, "doRefactor": "Do Refactor", "@doRefactor": {"description": "Action to return from usage review and continue the topic refactoring."}, + "preview": "Preview", + "@preview": {"description": "Action that opens a read-only refactoring preview."}, + "topicRenamePreviewTitle": "Rename Preview", + "@topicRenamePreviewTitle": {"description": "Title of the Writerside topic-file refactoring review."}, + "topicRenamePathChange": "{oldPath} → {newPath}", + "@topicRenamePathChange": {"description": "Old and new topic paths in a rename preview.", "placeholders": {"oldPath": {"type": "String"}, "newPath": {"type": "String"}}}, + "topicRenameIdChange": "Topic ID: {oldId} → {newId}", + "@topicRenameIdChange": {"description": "XML topic ID change in a rename preview.", "placeholders": {"oldId": {"type": "String"}, "newId": {"type": "String"}}}, + "topicRenameUrlChange": "{instance}: {oldName} → {newName}", + "@topicRenameUrlChange": {"description": "Published URL filename change in a rename preview.", "placeholders": {"instance": {"type": "String"}, "oldName": {"type": "String"}, "newName": {"type": "String"}}}, + "topicRenameAffectedFiles": "Affected files", + "@topicRenameAffectedFiles": {"description": "Heading for files changed by a topic refactoring."}, "orphanTopicTitle": "Topic file is no longer used", "@orphanTopicTitle": {"description": "Dialog title after a topic is removed from its last table of contents."}, "keepTopicFile": "Keep Topic File", @@ -1603,6 +1615,10 @@ "description": "Writerside module diagnostic for a duplicate topic element ID.", "placeholders": {"id": {"type": "String"}} }, + "diagnosticWritersideWebFileNameInvalid": "Instance \"{instanceId}\" publishes invalid web filename \"{webFileName}\" for topic \"{topic}\".", + "@diagnosticWritersideWebFileNameInvalid": {"description": "Invalid effective Writerside web filename diagnostic.", "placeholders": {"instanceId": {"type": "String"}, "webFileName": {"type": "String"}, "topic": {"type": "String"}}}, + "diagnosticWritersideWebFileNameCollision": "Instance \"{instanceId}\" publishes both \"{firstTopic}\" and \"{secondTopic}\" as \"{webFileName}\".", + "@diagnosticWritersideWebFileNameCollision": {"description": "Per-instance Writerside web filename collision diagnostic.", "placeholders": {"instanceId": {"type": "String"}, "webFileName": {"type": "String"}, "firstTopic": {"type": "String"}, "secondTopic": {"type": "String"}}}, "diagnosticWritersideTreeMissingTopic": "TOC references missing topic \"{topic}\".", "@diagnosticWritersideTreeMissingTopic": { "description": "Writerside module diagnostic for a TOC reference to a missing topic.", @@ -2506,6 +2522,8 @@ "goToDeclaration": "Go to Declaration", "findUsages": "Find Usages", "cannotRenameSymbol": "The symbol cannot be renamed safely. Check the name and refresh the reference before trying again.", + "renameTopicFileInstead": "Topic IDs come from their filenames. Use Rename Topic File in the Table of Contents or Files sidebar.", + "@renameTopicFileInstead": {"description": "Message shown when source semantic rename targets a Writerside topic ID."}, "keyboardLayout": "Keyboard layout", "diagnosticWritersideUnsupported": "Unsupported or malformed content is displayed as source.", "diagnosticWritersideSource": "Cannot resolve source “{reference}”: {reason}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8fe51789..d329eb2e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Una ruta absoluta por línea", "settingsSecondsValue": "{value} segundos", "settingsDaysValue": "{value} días", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Vista previa", + "topicRenamePreviewTitle": "Vista previa del cambio de nombre", + "topicRenamePathChange": "Ruta: {oldPath} → {newPath}", + "topicRenameIdChange": "ID del tema: {oldId} → {newId}", + "topicRenameUrlChange": "Instancia {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Archivos afectados", + "renameTopicFileInstead": "Los ID de los temas proceden de sus nombres de archivo. Use «Cambiar nombre del archivo de tema» en la tabla de contenido o en la barra lateral Archivos.", + "diagnosticWritersideWebFileNameInvalid": "La instancia \"{instanceId}\" publica el nombre de archivo web no válido \"{webFileName}\" para el tema \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "La instancia \"{instanceId}\" publica \"{firstTopic}\" y \"{secondTopic}\" como \"{webFileName}\"." } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index de39ea19..90e281f6 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -3768,5 +3768,14 @@ "settingsHistoryExcludedPathsHint": "Üks absoluutne tee per rida", "settingsSecondsValue": "{value} sekundid", "settingsDaysValue": "{value} päevad", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Eelvaade", + "topicRenamePreviewTitle": "Ümbernimetamise eelvaade", + "topicRenamePathChange": "Tee: {oldPath} → {newPath}", + "topicRenameIdChange": "Teema ID: {oldId} → {newId}", + "topicRenameUrlChange": "Eksemplar {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Mõjutatud failid", + "renameTopicFileInstead": "Teema ID-d tuletatakse failinimedest. Kasuta sisukorras või failide külgribal käsku „Nimeta teemafail ümber“.", + "diagnosticWritersideWebFileNameInvalid": "Eksemplar \"{instanceId}\" avaldab teema \"{topic}\" jaoks sobimatu veebifailinime \"{webFileName}\".", + "diagnosticWritersideWebFileNameCollision": "Eksemplar \"{instanceId}\" avaldab nii \"{firstTopic}\" kui ka \"{secondTopic}\" nimega \"{webFileName}\"." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 2109e0d1..28ac0dd2 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -3350,5 +3350,14 @@ "settingsHistoryExcludedPathsHint": "یک مسیر مطلق در هر خط", "settingsSecondsValue": "{value} ثانیه", "settingsDaysValue": "{value} روز", - "settingsMebibytesValue": "{value} مگابایت" + "settingsMebibytesValue": "{value} مگابایت", + "preview": "پیش‌نمایش", + "topicRenamePreviewTitle": "پیش‌نمایش تغییر نام", + "topicRenamePathChange": "مسیر: {oldPath} → {newPath}", + "topicRenameIdChange": "شناسهٔ موضوع: {oldId} → {newId}", + "topicRenameUrlChange": "نمونهٔ {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "فایل‌های تحت تأثیر", + "renameTopicFileInstead": "شناسه‌های موضوع از نام فایل‌هایشان گرفته می‌شوند. از «تغییر نام فایل موضوع» در فهرست مطالب یا نوار کناری فایل‌ها استفاده کنید.", + "diagnosticWritersideWebFileNameInvalid": "نمونهٔ \"{instanceId}\" نام فایل وب نامعتبر \"{webFileName}\" را برای موضوع \"{topic}\" منتشر می‌کند.", + "diagnosticWritersideWebFileNameCollision": "نمونهٔ \"{instanceId}\" هر دو موضوع \"{firstTopic}\" و \"{secondTopic}\" را با نام \"{webFileName}\" منتشر می‌کند." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d993e86b..a5da1246 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Un chemin absolu par ligne", "settingsSecondsValue": "{value} secondes", "settingsDaysValue": "{value} jours", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Aperçu", + "topicRenamePreviewTitle": "Aperçu du renommage", + "topicRenamePathChange": "Chemin : {oldPath} → {newPath}", + "topicRenameIdChange": "ID du sujet : {oldId} → {newId}", + "topicRenameUrlChange": "Instance {instance} : {oldName} → {newName}", + "topicRenameAffectedFiles": "Fichiers concernés", + "renameTopicFileInstead": "Les ID de sujet proviennent de leur nom de fichier. Utilisez « Renommer le fichier de sujet » dans la table des matières ou la barre latérale Fichiers.", + "diagnosticWritersideWebFileNameInvalid": "L’instance \"{instanceId}\" publie le nom de fichier web non valide \"{webFileName}\" pour le sujet \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "L’instance \"{instanceId}\" publie \"{firstTopic}\" et \"{secondTopic}\" sous le nom \"{webFileName}\"." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 39a3626d..efb7c8de 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "प्रति पंक्ति एक पूर्ण पथ", "settingsSecondsValue": "{value} सेकंड", "settingsDaysValue": "{value} दिन", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "पूर्वावलोकन", + "topicRenamePreviewTitle": "नाम बदलने का पूर्वावलोकन", + "topicRenamePathChange": "पथ: {oldPath} → {newPath}", + "topicRenameIdChange": "विषय ID: {oldId} → {newId}", + "topicRenameUrlChange": "इंस्टेंस {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "प्रभावित फ़ाइलें", + "renameTopicFileInstead": "विषय ID उनके फ़ाइल नामों से बनते हैं। विषय-सूची या फ़ाइलें साइडबार में ‘विषय फ़ाइल का नाम बदलें’ का उपयोग करें।", + "diagnosticWritersideWebFileNameInvalid": "इंस्टेंस \"{instanceId}\" विषय \"{topic}\" के लिए अमान्य वेब फ़ाइल नाम \"{webFileName}\" प्रकाशित करता है।", + "diagnosticWritersideWebFileNameCollision": "इंस्टेंस \"{instanceId}\" \"{firstTopic}\" और \"{secondTopic}\" दोनों को \"{webFileName}\" के रूप में प्रकाशित करता है।" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0737fbf9..4d8c0234 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "Satu jalur absolut per baris", "settingsSecondsValue": "{value} detik", "settingsDaysValue": "{value} hari", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Pratinjau", + "topicRenamePreviewTitle": "Pratinjau penggantian nama", + "topicRenamePathChange": "Jalur: {oldPath} → {newPath}", + "topicRenameIdChange": "ID topik: {oldId} → {newId}", + "topicRenameUrlChange": "Instans {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "File yang terpengaruh", + "renameTopicFileInstead": "ID topik berasal dari nama filenya. Gunakan Ganti Nama File Topik di daftar isi atau bilah samping File.", + "diagnosticWritersideWebFileNameInvalid": "Instans \"{instanceId}\" menerbitkan nama file web tidak valid \"{webFileName}\" untuk topik \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Instans \"{instanceId}\" menerbitkan \"{firstTopic}\" dan \"{secondTopic}\" sebagai \"{webFileName}\"." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 51ac5993..0613e570 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Un percorso assoluto per riga", "settingsSecondsValue": "{value} secondi", "settingsDaysValue": "{value} giorni", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Anteprima", + "topicRenamePreviewTitle": "Anteprima della ridenominazione", + "topicRenamePathChange": "Percorso: {oldPath} → {newPath}", + "topicRenameIdChange": "ID argomento: {oldId} → {newId}", + "topicRenameUrlChange": "Istanza {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "File interessati", + "renameTopicFileInstead": "Gli ID degli argomenti derivano dai nomi dei file. Usa Rinomina file argomento nel sommario o nella barra laterale File.", + "diagnosticWritersideWebFileNameInvalid": "L’istanza \"{instanceId}\" pubblica il nome file web non valido \"{webFileName}\" per l’argomento \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "L’istanza \"{instanceId}\" pubblica sia \"{firstTopic}\" sia \"{secondTopic}\" come \"{webFileName}\"." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9644b8a0..1a9852c4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "1行に1つの絶対パス", "settingsSecondsValue": "{value} 秒", "settingsDaysValue": "{value} 日", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "プレビュー", + "topicRenamePreviewTitle": "名前変更のプレビュー", + "topicRenamePathChange": "パス: {oldPath} → {newPath}", + "topicRenameIdChange": "トピック ID: {oldId} → {newId}", + "topicRenameUrlChange": "インスタンス {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "影響を受けるファイル", + "renameTopicFileInstead": "トピック ID はファイル名から生成されます。目次またはファイルサイドバーの「トピックファイルの名前を変更」を使用してください。", + "diagnosticWritersideWebFileNameInvalid": "インスタンス \"{instanceId}\" はトピック \"{topic}\" に無効な Web ファイル名 \"{webFileName}\" を使用しています。", + "diagnosticWritersideWebFileNameCollision": "インスタンス \"{instanceId}\" は \"{firstTopic}\" と \"{secondTopic}\" の両方を \"{webFileName}\" として公開します。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 8e75a4e3..6b8d5520 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "한 줄에 하나의 절대 경로", "settingsSecondsValue": "{value} 초", "settingsDaysValue": "{value} 일", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "미리 보기", + "topicRenamePreviewTitle": "이름 바꾸기 미리 보기", + "topicRenamePathChange": "경로: {oldPath} → {newPath}", + "topicRenameIdChange": "토픽 ID: {oldId} → {newId}", + "topicRenameUrlChange": "인스턴스 {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "영향을 받는 파일", + "renameTopicFileInstead": "토픽 ID는 파일 이름에서 생성됩니다. 목차 또는 파일 사이드바에서 토픽 파일 이름 바꾸기를 사용하세요.", + "diagnosticWritersideWebFileNameInvalid": "인스턴스 \"{instanceId}\"에서 토픽 \"{topic}\"의 웹 파일 이름 \"{webFileName}\"이 올바르지 않습니다.", + "diagnosticWritersideWebFileNameCollision": "인스턴스 \"{instanceId}\"에서 \"{firstTopic}\"과 \"{secondTopic}\"이 모두 \"{webFileName}\"으로 게시됩니다." } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 3d7550f4..be54ee8a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Én absolutt sti per linje", "settingsSecondsValue": "{value} sekunder", "settingsDaysValue": "{value} dager", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Forhåndsvisning", + "topicRenamePreviewTitle": "Forhåndsvisning av navneendring", + "topicRenamePathChange": "Sti: {oldPath} → {newPath}", + "topicRenameIdChange": "Emne-ID: {oldId} → {newId}", + "topicRenameUrlChange": "Instans {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Berørte filer", + "renameTopicFileInstead": "Emne-ID-er kommer fra filnavnene. Bruk Gi nytt navn til emnefil i innholdsfortegnelsen eller sidepanelet Filer.", + "diagnosticWritersideWebFileNameInvalid": "Instansen \"{instanceId}\" publiserer det ugyldige nettfilnavnet \"{webFileName}\" for emnet \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Instansen \"{instanceId}\" publiserer både \"{firstTopic}\" og \"{secondTopic}\" som \"{webFileName}\"." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a252cd11..20ffd09b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "Eén absolute pad per regel", "settingsSecondsValue": "{value} seconden", "settingsDaysValue": "{value} dagen", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Voorbeeld", + "topicRenamePreviewTitle": "Voorbeeld van naamswijziging", + "topicRenamePathChange": "Pad: {oldPath} → {newPath}", + "topicRenameIdChange": "Onderwerp-ID: {oldId} → {newId}", + "topicRenameUrlChange": "Instantie {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Betrokken bestanden", + "renameTopicFileInstead": "Onderwerp-ID's zijn afgeleid van de bestandsnamen. Gebruik Onderwerpbestand hernoemen in de inhoudsopgave of de zijbalk Bestanden.", + "diagnosticWritersideWebFileNameInvalid": "Instantie \"{instanceId}\" publiceert de ongeldige webbestandsnaam \"{webFileName}\" voor onderwerp \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Instantie \"{instanceId}\" publiceert zowel \"{firstTopic}\" als \"{secondTopic}\" als \"{webFileName}\"." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index c801993b..96e6e575 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3334,5 +3334,14 @@ "settingsHistoryExcludedPathsHint": "Jedna absolutna ścieżka na linię", "settingsSecondsValue": "{value} sekundy", "settingsDaysValue": "{value} dni", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Podgląd", + "topicRenamePreviewTitle": "Podgląd zmiany nazwy", + "topicRenamePathChange": "Ścieżka: {oldPath} → {newPath}", + "topicRenameIdChange": "Identyfikator tematu: {oldId} → {newId}", + "topicRenameUrlChange": "Instancja {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Pliki objęte zmianą", + "renameTopicFileInstead": "Identyfikatory tematów pochodzą z nazw ich plików. Użyj polecenia Zmień nazwę pliku tematu w spisie treści lub na pasku bocznym Pliki.", + "diagnosticWritersideWebFileNameInvalid": "Instancja \"{instanceId}\" publikuje nieprawidłową nazwę pliku internetowego \"{webFileName}\" dla tematu \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Instancja \"{instanceId}\" publikuje zarówno \"{firstTopic}\", jak i \"{secondTopic}\" jako \"{webFileName}\"." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 026f8fb6..cd65c8ec 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Um caminho absoluto por linha", "settingsSecondsValue": "{value} segundos", "settingsDaysValue": "{value} dias", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Pré-visualizar", + "topicRenamePreviewTitle": "Pré-visualização da mudança de nome", + "topicRenamePathChange": "Caminho: {oldPath} → {newPath}", + "topicRenameIdChange": "ID do tópico: {oldId} → {newId}", + "topicRenameUrlChange": "Instância {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Ficheiros afetados", + "renameTopicFileInstead": "Os IDs dos tópicos provêm dos nomes dos ficheiros. Utilize Mudar o nome do ficheiro de tópico no índice ou na barra lateral Ficheiros.", + "diagnosticWritersideWebFileNameInvalid": "A instância \"{instanceId}\" publica o nome de ficheiro Web inválido \"{webFileName}\" para o tópico \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "A instância \"{instanceId}\" publica \"{firstTopic}\" e \"{secondTopic}\" como \"{webFileName}\"." } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 5449e11d..dde041c6 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -3308,5 +3308,14 @@ "settingsHistoryExcludedPathsHint": "Um caminho absoluto por linha", "settingsSecondsValue": "{value} segundos", "settingsDaysValue": "{value} dias", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Prévia", + "topicRenamePreviewTitle": "Prévia da renomeação", + "topicRenamePathChange": "Caminho: {oldPath} → {newPath}", + "topicRenameIdChange": "ID do tópico: {oldId} → {newId}", + "topicRenameUrlChange": "Instância {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Arquivos afetados", + "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.", + "diagnosticWritersideWebFileNameInvalid": "A instância \"{instanceId}\" publica o nome de arquivo da Web inválido \"{webFileName}\" para o tópico \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "A instância \"{instanceId}\" publica \"{firstTopic}\" e \"{secondTopic}\" como \"{webFileName}\"." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a295437b..9ac526db 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3334,5 +3334,14 @@ "settingsHistoryExcludedPathsHint": "Один абсолютный путь в строке", "settingsSecondsValue": "{value} секунды", "settingsDaysValue": "{value} дни", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Предпросмотр", + "topicRenamePreviewTitle": "Предпросмотр переименования", + "topicRenamePathChange": "Путь: {oldPath} → {newPath}", + "topicRenameIdChange": "ID темы: {oldId} → {newId}", + "topicRenameUrlChange": "Экземпляр {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Затронутые файлы", + "renameTopicFileInstead": "ID тем определяются именами их файлов. Используйте «Переименовать файл темы» в оглавлении или на боковой панели «Файлы».", + "diagnosticWritersideWebFileNameInvalid": "Экземпляр \"{instanceId}\" публикует недопустимое имя веб-файла \"{webFileName}\" для темы \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Экземпляр \"{instanceId}\" публикует темы \"{firstTopic}\" и \"{secondTopic}\" под одним именем \"{webFileName}\"." } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 91a65d6a..1d8fb5da 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "Bir mutlak yol her satırda", "settingsSecondsValue": "{value} saniye", "settingsDaysValue": "{value} gün", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Önizleme", + "topicRenamePreviewTitle": "Yeniden adlandırma önizlemesi", + "topicRenamePathChange": "Yol: {oldPath} → {newPath}", + "topicRenameIdChange": "Konu kimliği: {oldId} → {newId}", + "topicRenameUrlChange": "Örnek {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Etkilenen dosyalar", + "renameTopicFileInstead": "Konu kimlikleri dosya adlarından gelir. İçindekiler tablosunda veya Dosyalar kenar çubuğunda Konu Dosyasını Yeniden Adlandır'ı kullanın.", + "diagnosticWritersideWebFileNameInvalid": "\"{instanceId}\" örneği, \"{topic}\" konusu için geçersiz \"{webFileName}\" web dosyası adını yayımlıyor.", + "diagnosticWritersideWebFileNameCollision": "\"{instanceId}\" örneği hem \"{firstTopic}\" hem de \"{secondTopic}\" konusunu \"{webFileName}\" olarak yayımlıyor." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c40a4d0d..8e640c39 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -3334,5 +3334,14 @@ "settingsHistoryExcludedPathsHint": "Один абсолютний шлях на рядок", "settingsSecondsValue": "{value} секунди", "settingsDaysValue": "{value} дні", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Попередній перегляд", + "topicRenamePreviewTitle": "Попередній перегляд перейменування", + "topicRenamePathChange": "Шлях: {oldPath} → {newPath}", + "topicRenameIdChange": "ID теми: {oldId} → {newId}", + "topicRenameUrlChange": "Екземпляр {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Змінені файли", + "renameTopicFileInstead": "ID тем визначаються іменами їхніх файлів. Скористайтеся командою «Перейменувати файл теми» у змісті або на бічній панелі «Файли».", + "diagnosticWritersideWebFileNameInvalid": "Екземпляр \"{instanceId}\" публікує неприпустиме ім’я вебфайлу \"{webFileName}\" для теми \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Екземпляр \"{instanceId}\" публікує теми \"{firstTopic}\" і \"{secondTopic}\" під одним іменем \"{webFileName}\"." } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0d3bbbb2..46b01539 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "Một đường dẫn tuyệt đối trên mỗi dòng", "settingsSecondsValue": "{value} giây", "settingsDaysValue": "{value} ngày", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "Xem trước", + "topicRenamePreviewTitle": "Xem trước việc đổi tên", + "topicRenamePathChange": "Đường dẫn: {oldPath} → {newPath}", + "topicRenameIdChange": "ID chủ đề: {oldId} → {newId}", + "topicRenameUrlChange": "Phiên bản {instance}: {oldName} → {newName}", + "topicRenameAffectedFiles": "Các tệp bị ảnh hưởng", + "renameTopicFileInstead": "ID chủ đề được tạo từ tên tệp. Hãy dùng Đổi tên tệp chủ đề trong mục lục hoặc thanh bên Tệp.", + "diagnosticWritersideWebFileNameInvalid": "Phiên bản \"{instanceId}\" xuất bản tên tệp web không hợp lệ \"{webFileName}\" cho chủ đề \"{topic}\".", + "diagnosticWritersideWebFileNameCollision": "Phiên bản \"{instanceId}\" xuất bản cả \"{firstTopic}\" và \"{secondTopic}\" dưới tên \"{webFileName}\"." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 013d3f43..2218c1d1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "每行一个绝对路径", "settingsSecondsValue": "{value} 秒", "settingsDaysValue": "{value} 天", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "预览", + "topicRenamePreviewTitle": "重命名预览", + "topicRenamePathChange": "路径:{oldPath} → {newPath}", + "topicRenameIdChange": "主题 ID:{oldId} → {newId}", + "topicRenameUrlChange": "实例 {instance}:{oldName} → {newName}", + "topicRenameAffectedFiles": "受影响的文件", + "renameTopicFileInstead": "主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。", + "diagnosticWritersideWebFileNameInvalid": "实例 \"{instanceId}\" 为主题 \"{topic}\" 发布了无效的 Web 文件名 \"{webFileName}\"。", + "diagnosticWritersideWebFileNameCollision": "实例 \"{instanceId}\" 将 \"{firstTopic}\" 和 \"{secondTopic}\" 都发布为 \"{webFileName}\"。" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 116e0b8a..26522841 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -4867,5 +4867,14 @@ "settingsHistoryExcludedPathsHint": "每行一个绝对路径", "settingsSecondsValue": "{value} 秒", "settingsDaysValue": "{value} 天", - "settingsMebibytesValue": "{value} MiB" + "settingsMebibytesValue": "{value} MiB", + "preview": "预览", + "topicRenamePreviewTitle": "重命名预览", + "topicRenamePathChange": "路径:{oldPath} → {newPath}", + "topicRenameIdChange": "主题 ID:{oldId} → {newId}", + "topicRenameUrlChange": "实例 {instance}:{oldName} → {newName}", + "topicRenameAffectedFiles": "受影响的文件", + "renameTopicFileInstead": "主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。", + "diagnosticWritersideWebFileNameInvalid": "实例 \"{instanceId}\" 为主题 \"{topic}\" 发布了无效的 Web 文件名 \"{webFileName}\"。", + "diagnosticWritersideWebFileNameCollision": "实例 \"{instanceId}\" 将 \"{firstTopic}\" 和 \"{secondTopic}\" 都发布为 \"{webFileName}\"。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b9f46c94..2a050866 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2947,6 +2947,42 @@ abstract class AppLocalizations { /// **'Do Refactor'** String get doRefactor; + /// Action that opens a read-only refactoring preview. + /// + /// In en, this message translates to: + /// **'Preview'** + String get preview; + + /// Title of the Writerside topic-file refactoring review. + /// + /// In en, this message translates to: + /// **'Rename Preview'** + String get topicRenamePreviewTitle; + + /// Old and new topic paths in a rename preview. + /// + /// In en, this message translates to: + /// **'{oldPath} → {newPath}'** + String topicRenamePathChange(String oldPath, String newPath); + + /// XML topic ID change in a rename preview. + /// + /// In en, this message translates to: + /// **'Topic ID: {oldId} → {newId}'** + String topicRenameIdChange(String oldId, String newId); + + /// Published URL filename change in a rename preview. + /// + /// In en, this message translates to: + /// **'{instance}: {oldName} → {newName}'** + String topicRenameUrlChange(String instance, String oldName, String newName); + + /// Heading for files changed by a topic refactoring. + /// + /// In en, this message translates to: + /// **'Affected files'** + String get topicRenameAffectedFiles; + /// Dialog title after a topic is removed from its last table of contents. /// /// In en, this message translates to: @@ -4161,6 +4197,27 @@ abstract class AppLocalizations { /// **'Element id \"{id}\" appears more than once.'** String diagnosticWritersideTopicDuplicateId(String id); + /// Invalid effective Writerside web filename diagnostic. + /// + /// In en, this message translates to: + /// **'Instance \"{instanceId}\" publishes invalid web filename \"{webFileName}\" for topic \"{topic}\".'** + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ); + + /// Per-instance Writerside web filename collision diagnostic. + /// + /// In en, this message translates to: + /// **'Instance \"{instanceId}\" publishes both \"{firstTopic}\" and \"{secondTopic}\" as \"{webFileName}\".'** + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ); + /// Writerside module diagnostic for a TOC reference to a missing topic. /// /// In en, this message translates to: @@ -6697,6 +6754,12 @@ abstract class AppLocalizations { /// **'The symbol cannot be renamed safely. Check the name and refresh the reference before trying again.'** String get cannotRenameSymbol; + /// Message shown when source semantic rename targets a Writerside topic ID. + /// + /// In en, this message translates to: + /// **'Topic IDs come from their filenames. Use Rename Topic File in the Table of Contents or Files sidebar.'** + String get renameTopicFileInstead; + /// No description provided for @keyboardLayout. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index d0092f7e..64030c9c 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1593,6 +1593,30 @@ class AppLocalizationsAr extends AppLocalizations { @override String get doRefactor => 'تنفيذ إعادة الهيكلة'; + @override + String get preview => 'معاينة'; + + @override + String get topicRenamePreviewTitle => 'معاينة إعادة التسمية'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'المسار: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'معرّف الموضوع: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'المثيل $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'الملفات المتأثرة'; + @override String get orphanTopicTitle => 'لم يعد ملف الموضوع مستخدمًا'; @@ -2435,6 +2459,25 @@ class AppLocalizationsAr extends AppLocalizations { return 'يظهر معرّف العنصر \"⁨$id⁩\" أكثر من مرة.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'ينشر المثيل \"$instanceId\" اسم ملف ويب غير صالح \"$webFileName\" للموضوع \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'ينشر المثيل \"$instanceId\" الموضوعين \"$firstTopic\" و\"$secondTopic\" باسم \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'يشير جدول المحتويات إلى موضوع مفقود \"⁨$topic⁩\".'; @@ -4000,6 +4043,10 @@ class AppLocalizationsAr extends AppLocalizations { String get cannotRenameSymbol => 'لا يمكن إعادة تسمية الرمز بأمان. تحقق من الاسم وحدّث المرجع قبل المحاولة مجددًا.'; + @override + String get renameTopicFileInstead => + 'تُشتق معرّفات الموضوعات من أسماء ملفاتها. استخدم «إعادة تسمية ملف الموضوع» في جدول المحتويات أو الشريط الجانبي للملفات.'; + @override String get keyboardLayout => 'تخطيط لوحة المفاتيح'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 2cdfa95d..2bc31373 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1606,6 +1606,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doRefactor => 'Refactoring ausführen'; + @override + String get preview => 'Vorschau'; + + @override + String get topicRenamePreviewTitle => 'Umbenennungsvorschau'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Pfad: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Themen-ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instanz $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Betroffene Dateien'; + @override String get orphanTopicTitle => 'Themendatei wird nicht mehr verwendet'; @@ -2443,6 +2467,25 @@ class AppLocalizationsDe extends AppLocalizations { return 'Die Element-ID „$id“ kommt mehr als einmal vor.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instanz \"$instanceId\" veröffentlicht den ungültigen Webdateinamen \"$webFileName\" für das Thema \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instanz \"$instanceId\" veröffentlicht sowohl \"$firstTopic\" als auch \"$secondTopic\" unter \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Das Inhaltsverzeichnis verweist auf das fehlende Thema „$topic“.'; @@ -3998,6 +4041,10 @@ class AppLocalizationsDe extends AppLocalizations { String get cannotRenameSymbol => 'Das Symbol kann nicht sicher umbenannt werden. Prüfen Sie den Namen und aktualisieren Sie die Referenz, bevor Sie es erneut versuchen.'; + @override + String get renameTopicFileInstead => + 'Themen-IDs werden aus ihren Dateinamen abgeleitet. Verwenden Sie „Themendatei umbenennen“ im Inhaltsverzeichnis oder in der Seitenleiste „Dateien“.'; + @override String get keyboardLayout => 'Tastaturbelegung'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 8b72fa3e..ee096ca4 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1580,6 +1580,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doRefactor => 'Do Refactor'; + @override + String get preview => 'Preview'; + + @override + String get topicRenamePreviewTitle => 'Rename Preview'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return '$oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Topic ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return '$instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Affected files'; + @override String get orphanTopicTitle => 'Topic file is no longer used'; @@ -2418,6 +2442,25 @@ class AppLocalizationsEn extends AppLocalizations { return 'Element id \"$id\" appears more than once.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instance \"$instanceId\" publishes invalid web filename \"$webFileName\" for topic \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instance \"$instanceId\" publishes both \"$firstTopic\" and \"$secondTopic\" as \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'TOC references missing topic \"$topic\".'; @@ -3990,6 +4033,10 @@ class AppLocalizationsEn extends AppLocalizations { String get cannotRenameSymbol => 'The symbol cannot be renamed safely. Check the name and refresh the reference before trying again.'; + @override + String get renameTopicFileInstead => + 'Topic IDs come from their filenames. Use Rename Topic File in the Table of Contents or Files sidebar.'; + @override String get keyboardLayout => 'Keyboard layout'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index a20a24af..82d20615 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1602,6 +1602,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get doRefactor => 'Refactorizar'; + @override + String get preview => 'Vista previa'; + + @override + String get topicRenamePreviewTitle => 'Vista previa del cambio de nombre'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Ruta: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID del tema: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instancia $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Archivos afectados'; + @override String get orphanTopicTitle => 'El archivo del tema ya no se usa'; @@ -2441,6 +2465,25 @@ class AppLocalizationsEs extends AppLocalizations { return 'El ID del elemento \"$id\" aparece más de una vez.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'La instancia \"$instanceId\" publica el nombre de archivo web no válido \"$webFileName\" para el tema \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'La instancia \"$instanceId\" publica \"$firstTopic\" y \"$secondTopic\" como \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'El índice hace referencia a un tema inexistente \"$topic\".'; @@ -4000,6 +4043,10 @@ class AppLocalizationsEs extends AppLocalizations { String get cannotRenameSymbol => 'No se puede cambiar el nombre del símbolo de forma segura. Compruebe el nombre y actualice la referencia antes de volver a intentarlo.'; + @override + String get renameTopicFileInstead => + 'Los ID de los temas proceden de sus nombres de archivo. Use «Cambiar nombre del archivo de tema» en la tabla de contenido o en la barra lateral Archivos.'; + @override String get keyboardLayout => 'Distribución del teclado'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 398b11e2..27b47878 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1584,6 +1584,30 @@ class AppLocalizationsEt extends AppLocalizations { @override String get doRefactor => 'Refaktoreeri'; + @override + String get preview => 'Eelvaade'; + + @override + String get topicRenamePreviewTitle => 'Ümbernimetamise eelvaade'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Tee: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Teema ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Eksemplar $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Mõjutatud failid'; + @override String get orphanTopicTitle => 'Teemafaili ei kasutata enam'; @@ -2416,6 +2440,25 @@ class AppLocalizationsEt extends AppLocalizations { return 'Elemendi ID „$id” esineb mitu korda.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Eksemplar \"$instanceId\" avaldab teema \"$topic\" jaoks sobimatu veebifailinime \"$webFileName\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Eksemplar \"$instanceId\" avaldab nii \"$firstTopic\" kui ka \"$secondTopic\" nimega \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Sisukord viitab puuduvale teemale „$topic”.'; @@ -3961,6 +4004,10 @@ class AppLocalizationsEt extends AppLocalizations { String get cannotRenameSymbol => 'Sümbolit ei saa turvaliselt ümber nimetada. Kontrolli nime ja värskenda viidet enne uuesti proovimist.'; + @override + String get renameTopicFileInstead => + 'Teema ID-d tuletatakse failinimedest. Kasuta sisukorras või failide külgribal käsku „Nimeta teemafail ümber“.'; + @override String get keyboardLayout => 'Klaviatuuripaigutus'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 191f69d1..b855ef04 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1620,6 +1620,30 @@ class AppLocalizationsFa extends AppLocalizations { @override String get doRefactor => 'انجام بازآرایی'; + @override + String get preview => 'پیش‌نمایش'; + + @override + String get topicRenamePreviewTitle => 'پیش‌نمایش تغییر نام'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'مسیر: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'شناسهٔ موضوع: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'نمونهٔ $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'فایل‌های تحت تأثیر'; + @override String get orphanTopicTitle => 'فایل موضوع دیگر استفاده نمی‌شود'; @@ -2457,6 +2481,25 @@ class AppLocalizationsFa extends AppLocalizations { return 'شناسه عنصر «⁨$id⁩» بیش از یک بار ظاهر شده است.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'نمونهٔ \"$instanceId\" نام فایل وب نامعتبر \"$webFileName\" را برای موضوع \"$topic\" منتشر می‌کند.'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'نمونهٔ \"$instanceId\" هر دو موضوع \"$firstTopic\" و \"$secondTopic\" را با نام \"$webFileName\" منتشر می‌کند.'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'فهرست مطالب به موضوع ناموجود «⁨$topic⁩» ارجاع می‌دهد.'; @@ -4007,6 +4050,10 @@ class AppLocalizationsFa extends AppLocalizations { String get cannotRenameSymbol => 'نمی‌توان نام نماد را با اطمینان تغییر داد. نام را بررسی و ارجاع را تازه‌سازی کنید، سپس دوباره تلاش کنید.'; + @override + String get renameTopicFileInstead => + 'شناسه‌های موضوع از نام فایل‌هایشان گرفته می‌شوند. از «تغییر نام فایل موضوع» در فهرست مطالب یا نوار کناری فایل‌ها استفاده کنید.'; + @override String get keyboardLayout => 'چیدمان صفحه‌کلید'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 97bf619b..4db31b68 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1607,6 +1607,30 @@ class AppLocalizationsFr extends AppLocalizations { @override String get doRefactor => 'Refactoriser'; + @override + String get preview => 'Aperçu'; + + @override + String get topicRenamePreviewTitle => 'Aperçu du renommage'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Chemin : $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID du sujet : $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instance $instance : $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Fichiers concernés'; + @override String get orphanTopicTitle => 'Le fichier du sujet n’est plus utilisé'; @@ -2447,6 +2471,25 @@ class AppLocalizationsFr extends AppLocalizations { return 'L’ID d’élément « $id » apparaît plusieurs fois.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'L’instance \"$instanceId\" publie le nom de fichier web non valide \"$webFileName\" pour le sujet \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'L’instance \"$instanceId\" publie \"$firstTopic\" et \"$secondTopic\" sous le nom \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'La table des matières fait référence à un sujet manquant : « $topic ».'; @@ -4001,6 +4044,10 @@ class AppLocalizationsFr extends AppLocalizations { String get cannotRenameSymbol => 'Impossible de renommer ce symbole en toute sécurité. Vérifiez son nom et actualisez la référence avant de réessayer.'; + @override + String get renameTopicFileInstead => + 'Les ID de sujet proviennent de leur nom de fichier. Utilisez « Renommer le fichier de sujet » dans la table des matières ou la barre latérale Fichiers.'; + @override String get keyboardLayout => 'Disposition du clavier'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 936d3d68..f9dbbf0b 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1580,6 +1580,30 @@ class AppLocalizationsHi extends AppLocalizations { @override String get doRefactor => 'रीफ़ैक्टर करें'; + @override + String get preview => 'पूर्वावलोकन'; + + @override + String get topicRenamePreviewTitle => 'नाम बदलने का पूर्वावलोकन'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'पथ: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'विषय ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'इंस्टेंस $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'प्रभावित फ़ाइलें'; + @override String get orphanTopicTitle => 'विषय फ़ाइल अब उपयोग में नहीं है'; @@ -2413,6 +2437,25 @@ class AppLocalizationsHi extends AppLocalizations { return 'एलिमेंट id \"$id\" एक से अधिक बार दिखाई देता है।'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'इंस्टेंस \"$instanceId\" विषय \"$topic\" के लिए अमान्य वेब फ़ाइल नाम \"$webFileName\" प्रकाशित करता है।'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'इंस्टेंस \"$instanceId\" \"$firstTopic\" और \"$secondTopic\" दोनों को \"$webFileName\" के रूप में प्रकाशित करता है।'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'TOC अनुपस्थित विषय \"$topic\" का संदर्भ देता है।'; @@ -3956,6 +3999,10 @@ class AppLocalizationsHi extends AppLocalizations { String get cannotRenameSymbol => 'प्रतीक का नाम सुरक्षित रूप से नहीं बदला जा सकता। नाम जाँचें और दोबारा प्रयास करने से पहले संदर्भ रीफ़्रेश करें।'; + @override + String get renameTopicFileInstead => + 'विषय ID उनके फ़ाइल नामों से बनते हैं। विषय-सूची या फ़ाइलें साइडबार में ‘विषय फ़ाइल का नाम बदलें’ का उपयोग करें।'; + @override String get keyboardLayout => 'कीबोर्ड लेआउट'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 3c9adf82..60615874 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -1586,6 +1586,30 @@ class AppLocalizationsId extends AppLocalizations { @override String get doRefactor => 'Lakukan refaktor'; + @override + String get preview => 'Pratinjau'; + + @override + String get topicRenamePreviewTitle => 'Pratinjau penggantian nama'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Jalur: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID topik: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instans $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'File yang terpengaruh'; + @override String get orphanTopicTitle => 'File topik tidak lagi digunakan'; @@ -2419,6 +2443,25 @@ class AppLocalizationsId extends AppLocalizations { return 'ID elemen \"$id\" muncul lebih dari sekali.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instans \"$instanceId\" menerbitkan nama file web tidak valid \"$webFileName\" untuk topik \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instans \"$instanceId\" menerbitkan \"$firstTopic\" dan \"$secondTopic\" sebagai \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'TOC mereferensikan topik \"$topic\" yang hilang.'; @@ -3969,6 +4012,10 @@ class AppLocalizationsId extends AppLocalizations { String get cannotRenameSymbol => 'Nama simbol tidak dapat diubah dengan aman. Periksa nama dan segarkan referensi sebelum mencoba lagi.'; + @override + String get renameTopicFileInstead => + 'ID topik berasal dari nama filenya. Gunakan Ganti Nama File Topik di daftar isi atau bilah samping File.'; + @override String get keyboardLayout => 'Tata letak papan ketik'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 798b7e64..2fd10198 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1600,6 +1600,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get doRefactor => 'Esegui refactoring'; + @override + String get preview => 'Anteprima'; + + @override + String get topicRenamePreviewTitle => 'Anteprima della ridenominazione'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Percorso: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID argomento: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Istanza $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'File interessati'; + @override String get orphanTopicTitle => 'Il file dell’argomento non è più utilizzato'; @@ -2439,6 +2463,25 @@ class AppLocalizationsIt extends AppLocalizations { return 'L\'ID dell\'elemento \"$id\" compare più di una volta.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'L’istanza \"$instanceId\" pubblica il nome file web non valido \"$webFileName\" per l’argomento \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'L’istanza \"$instanceId\" pubblica sia \"$firstTopic\" sia \"$secondTopic\" come \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Il TOC fa riferimento a un argomento mancante \"$topic\".'; @@ -3993,6 +4036,10 @@ class AppLocalizationsIt extends AppLocalizations { String get cannotRenameSymbol => 'Impossibile rinominare il simbolo in sicurezza. Verifica il nome e aggiorna il riferimento prima di riprovare.'; + @override + String get renameTopicFileInstead => + 'Gli ID degli argomenti derivano dai nomi dei file. Usa Rinomina file argomento nel sommario o nella barra laterale File.'; + @override String get keyboardLayout => 'Disposizione della tastiera'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 96cd6aad..16c4f73e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1536,6 +1536,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get doRefactor => 'リファクタリングを実行'; + @override + String get preview => 'プレビュー'; + + @override + String get topicRenamePreviewTitle => '名前変更のプレビュー'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'パス: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'トピック ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'インスタンス $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => '影響を受けるファイル'; + @override String get orphanTopicTitle => 'トピックファイルは使用されていません'; @@ -2343,6 +2367,25 @@ class AppLocalizationsJa extends AppLocalizations { 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」を参照しています。'; @@ -3855,6 +3898,10 @@ class AppLocalizationsJa extends AppLocalizations { String get cannotRenameSymbol => 'シンボル名を安全に変更できません。名前を確認し、参照を更新してから再試行してください。'; + @override + String get renameTopicFileInstead => + 'トピック ID はファイル名から生成されます。目次またはファイルサイドバーの「トピックファイルの名前を変更」を使用してください。'; + @override String get keyboardLayout => 'キーボード配列'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 36d6f832..bfef58c3 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1533,6 +1533,30 @@ class AppLocalizationsKo extends AppLocalizations { @override String get doRefactor => '리팩토링 수행'; + @override + String get preview => '미리 보기'; + + @override + String get topicRenamePreviewTitle => '이름 바꾸기 미리 보기'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return '경로: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return '토픽 ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return '인스턴스 $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => '영향을 받는 파일'; + @override String get orphanTopicTitle => '토픽 파일이 더 이상 사용되지 않습니다.'; @@ -2338,6 +2362,25 @@ class AppLocalizationsKo extends AppLocalizations { return '요소 ID \"$id\"이(가) 두 번 이상 나타납니다.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return '인스턴스 \"$instanceId\"에서 토픽 \"$topic\"의 웹 파일 이름 \"$webFileName\"이 올바르지 않습니다.'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return '인스턴스 \"$instanceId\"에서 \"$firstTopic\"과 \"$secondTopic\"이 모두 \"$webFileName\"으로 게시됩니다.'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'TOC에서 존재하지 않는 주제를 참조합니다: \"$topic\".'; @@ -3848,6 +3891,10 @@ class AppLocalizationsKo extends AppLocalizations { String get cannotRenameSymbol => '기호 이름을 안전하게 바꿀 수 없습니다. 이름을 확인하고 참조를 새로 고친 후 다시 시도하세요.'; + @override + String get renameTopicFileInstead => + '토픽 ID는 파일 이름에서 생성됩니다. 목차 또는 파일 사이드바에서 토픽 파일 이름 바꾸기를 사용하세요.'; + @override String get keyboardLayout => '키보드 배열'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 8e4b0023..dbcffa0a 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1588,6 +1588,30 @@ class AppLocalizationsNb extends AppLocalizations { @override String get doRefactor => 'Utfør refaktorering'; + @override + String get preview => 'Forhåndsvisning'; + + @override + String get topicRenamePreviewTitle => 'Forhåndsvisning av navneendring'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Sti: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Emne-ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instans $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Berørte filer'; + @override String get orphanTopicTitle => 'Emnefilen brukes ikke lenger'; @@ -2419,6 +2443,25 @@ class AppLocalizationsNb extends AppLocalizations { return 'Element-ID-en «$id» vises mer enn én gang.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instansen \"$instanceId\" publiserer det ugyldige nettfilnavnet \"$webFileName\" for emnet \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instansen \"$instanceId\" publiserer både \"$firstTopic\" og \"$secondTopic\" som \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Innholdsfortegnelsen refererer til et manglende emne: «$topic».'; @@ -3964,6 +4007,10 @@ class AppLocalizationsNb extends AppLocalizations { String get cannotRenameSymbol => 'Symbolet kan ikke gis nytt navn trygt. Kontroller navnet og oppdater referansen før du prøver igjen.'; + @override + String get renameTopicFileInstead => + 'Emne-ID-er kommer fra filnavnene. Bruk Gi nytt navn til emnefil i innholdsfortegnelsen eller sidepanelet Filer.'; + @override String get keyboardLayout => 'Tastaturoppsett'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 50d9ff0a..7d189420 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -1591,6 +1591,30 @@ class AppLocalizationsNl extends AppLocalizations { @override String get doRefactor => 'Refactoring uitvoeren'; + @override + String get preview => 'Voorbeeld'; + + @override + String get topicRenamePreviewTitle => 'Voorbeeld van naamswijziging'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Pad: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Onderwerp-ID: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instantie $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Betrokken bestanden'; + @override String get orphanTopicTitle => 'Onderwerpbestand wordt niet meer gebruikt'; @@ -2429,6 +2453,25 @@ class AppLocalizationsNl extends AppLocalizations { return 'Element-ID \"$id\" komt meerdere keren voor.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instantie \"$instanceId\" publiceert de ongeldige webbestandsnaam \"$webFileName\" voor onderwerp \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instantie \"$instanceId\" publiceert zowel \"$firstTopic\" als \"$secondTopic\" als \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'De inhoudsopgave verwijst naar het ontbrekende onderwerp \"$topic\".'; @@ -4008,6 +4051,10 @@ class AppLocalizationsNl extends AppLocalizations { String get cannotRenameSymbol => 'Het symbool kan niet veilig worden hernoemd. Controleer de naam en vernieuw de verwijzing voordat u het opnieuw probeert.'; + @override + String get renameTopicFileInstead => + 'Onderwerp-ID\'s zijn afgeleid van de bestandsnamen. Gebruik Onderwerpbestand hernoemen in de inhoudsopgave of de zijbalk Bestanden.'; + @override String get keyboardLayout => 'Toetsenbordindeling'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 9d4f7100..7c9f06e2 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1611,6 +1611,30 @@ class AppLocalizationsPl extends AppLocalizations { @override String get doRefactor => 'Wykonaj refaktoryzację'; + @override + String get preview => 'Podgląd'; + + @override + String get topicRenamePreviewTitle => 'Podgląd zmiany nazwy'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Ścieżka: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Identyfikator tematu: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instancja $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Pliki objęte zmianą'; + @override String get orphanTopicTitle => 'Plik tematu nie jest już używany'; @@ -2453,6 +2477,25 @@ class AppLocalizationsPl extends AppLocalizations { return 'Identyfikator elementu „$id” pojawia się więcej niż raz.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Instancja \"$instanceId\" publikuje nieprawidłową nazwę pliku internetowego \"$webFileName\" dla tematu \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Instancja \"$instanceId\" publikuje zarówno \"$firstTopic\", jak i \"$secondTopic\" jako \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Spis treści odwołuje się do brakującego tematu „$topic”.'; @@ -4014,6 +4057,10 @@ class AppLocalizationsPl extends AppLocalizations { String get cannotRenameSymbol => 'Nie można bezpiecznie zmienić nazwy symbolu. Sprawdź nazwę i odśwież odwołanie przed ponowną próbą.'; + @override + String get renameTopicFileInstead => + 'Identyfikatory tematów pochodzą z nazw ich plików. Użyj polecenia Zmień nazwę pliku tematu w spisie treści lub na pasku bocznym Pliki.'; + @override String get keyboardLayout => 'Układ klawiatury'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 7ce5d496..9b07c0aa 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1596,6 +1596,30 @@ class AppLocalizationsPt extends AppLocalizations { @override String get doRefactor => 'Refatorar'; + @override + String get preview => 'Pré-visualizar'; + + @override + String get topicRenamePreviewTitle => 'Pré-visualização da mudança de nome'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Caminho: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID do tópico: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instância $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Ficheiros afetados'; + @override String get orphanTopicTitle => 'O ficheiro do tópico não é mais usado'; @@ -2432,6 +2456,25 @@ class AppLocalizationsPt extends AppLocalizations { 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 ficheiro 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\".'; @@ -4020,6 +4063,10 @@ class AppLocalizationsPt extends AppLocalizations { String get cannotRenameSymbol => 'Não é possível mudar o nome do símbolo com segurança. Verifique o nome e atualize a referência antes de tentar novamente.'; + @override + String get renameTopicFileInstead => + 'Os IDs dos tópicos provêm dos nomes dos ficheiros. Utilize Mudar o nome do ficheiro de tópico no índice ou na barra lateral Ficheiros.'; + @override String get keyboardLayout => 'Disposição do teclado'; @@ -6105,6 +6152,30 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get doRefactor => 'Refatorar'; + @override + String get preview => 'Prévia'; + + @override + String get topicRenamePreviewTitle => 'Prévia da renomeação'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Caminho: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID do tópico: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Instância $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Arquivos afetados'; + @override String get orphanTopicTitle => 'O arquivo do tópico não é mais usado'; @@ -6941,6 +7012,25 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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\".'; @@ -8526,6 +8616,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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 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 keyboardLayout => 'Layout do teclado'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 6a639b91..0d6f21e1 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1607,6 +1607,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get doRefactor => 'Выполнить рефакторинг'; + @override + String get preview => 'Предпросмотр'; + + @override + String get topicRenamePreviewTitle => 'Предпросмотр переименования'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Путь: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID темы: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Экземпляр $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Затронутые файлы'; + @override String get orphanTopicTitle => 'Файл темы больше не используется'; @@ -2448,6 +2472,25 @@ class AppLocalizationsRu extends AppLocalizations { return 'ID элемента «$id» встречается несколько раз.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Экземпляр \"$instanceId\" публикует недопустимое имя веб-файла \"$webFileName\" для темы \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Экземпляр \"$instanceId\" публикует темы \"$firstTopic\" и \"$secondTopic\" под одним именем \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Оглавление ссылается на отсутствующую тему «$topic».'; @@ -4016,6 +4059,10 @@ class AppLocalizationsRu extends AppLocalizations { String get cannotRenameSymbol => 'Невозможно безопасно переименовать символ. Проверьте имя и обновите ссылку перед повторной попыткой.'; + @override + String get renameTopicFileInstead => + 'ID тем определяются именами их файлов. Используйте «Переименовать файл темы» в оглавлении или на боковой панели «Файлы».'; + @override String get keyboardLayout => 'Раскладка клавиатуры'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index b94bb7db..2e5a2744 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1585,6 +1585,30 @@ class AppLocalizationsTr extends AppLocalizations { @override String get doRefactor => 'Yeniden düzenle'; + @override + String get preview => 'Önizleme'; + + @override + String get topicRenamePreviewTitle => 'Yeniden adlandırma önizlemesi'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Yol: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'Konu kimliği: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Örnek $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Etkilenen dosyalar'; + @override String get orphanTopicTitle => 'Konu dosyası artık kullanılmıyor'; @@ -2417,6 +2441,25 @@ class AppLocalizationsTr extends AppLocalizations { return 'Öğe kimliği \"$id\" birden fazla görünüyor.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return '\"$instanceId\" örneği, \"$topic\" konusu için geçersiz \"$webFileName\" web dosyası adını yayımlıyor.'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return '\"$instanceId\" örneği hem \"$firstTopic\" hem de \"$secondTopic\" konusunu \"$webFileName\" olarak yayımlıyor.'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'TOC, eksik \"$topic\" konusuna atıfta bulunuyor.'; @@ -3963,6 +4006,10 @@ class AppLocalizationsTr extends AppLocalizations { String get cannotRenameSymbol => 'Simge güvenle yeniden adlandırılamıyor. Yeniden denemeden önce adı kontrol edin ve başvuruyu yenileyin.'; + @override + String get renameTopicFileInstead => + 'Konu kimlikleri dosya adlarından gelir. İçindekiler tablosunda veya Dosyalar kenar çubuğunda Konu Dosyasını Yeniden Adlandır\'ı kullanın.'; + @override String get keyboardLayout => 'Klavye düzeni'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 461faf33..6bbe5a82 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1614,6 +1614,30 @@ class AppLocalizationsUk extends AppLocalizations { @override String get doRefactor => 'Виконати рефакторинг'; + @override + String get preview => 'Попередній перегляд'; + + @override + String get topicRenamePreviewTitle => 'Попередній перегляд перейменування'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Шлях: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID теми: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Екземпляр $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Змінені файли'; + @override String get orphanTopicTitle => 'Файл теми більше не використовується'; @@ -2457,6 +2481,25 @@ class AppLocalizationsUk extends AppLocalizations { return 'Ідентифікатор елемента «$id» з’являється більше одного разу.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Екземпляр \"$instanceId\" публікує неприпустиме ім’я вебфайлу \"$webFileName\" для теми \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Екземпляр \"$instanceId\" публікує теми \"$firstTopic\" і \"$secondTopic\" під одним іменем \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'У змісті є посилання на відсутню тему «$topic».'; @@ -4024,6 +4067,10 @@ class AppLocalizationsUk extends AppLocalizations { String get cannotRenameSymbol => 'Неможливо безпечно перейменувати символ. Перевірте назву й оновіть посилання перед повторною спробою.'; + @override + String get renameTopicFileInstead => + 'ID тем визначаються іменами їхніх файлів. Скористайтеся командою «Перейменувати файл теми» у змісті або на бічній панелі «Файли».'; + @override String get keyboardLayout => 'Розкладка клавіатури'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 751469ab..d648548c 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1580,6 +1580,30 @@ class AppLocalizationsVi extends AppLocalizations { @override String get doRefactor => 'Tái cấu trúc'; + @override + String get preview => 'Xem trước'; + + @override + String get topicRenamePreviewTitle => 'Xem trước việc đổi tên'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return 'Đường dẫn: $oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return 'ID chủ đề: $oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return 'Phiên bản $instance: $oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => 'Các tệp bị ảnh hưởng'; + @override String get orphanTopicTitle => 'Tệp chủ đề không còn được sử dụng'; @@ -2410,6 +2434,25 @@ class AppLocalizationsVi extends AppLocalizations { return 'ID phần tử \"$id\" xuất hiện nhiều hơn một lần.'; } + @override + String diagnosticWritersideWebFileNameInvalid( + String instanceId, + String webFileName, + String topic, + ) { + return 'Phiên bản \"$instanceId\" xuất bản tên tệp web không hợp lệ \"$webFileName\" cho chủ đề \"$topic\".'; + } + + @override + String diagnosticWritersideWebFileNameCollision( + String instanceId, + String webFileName, + String firstTopic, + String secondTopic, + ) { + return 'Phiên bản \"$instanceId\" xuất bản cả \"$firstTopic\" và \"$secondTopic\" dưới tên \"$webFileName\".'; + } + @override String diagnosticWritersideTreeMissingTopic(String topic) { return 'Mục lục tham chiếu đến chủ đề còn thiếu \"$topic\".'; @@ -3958,6 +4001,10 @@ class AppLocalizationsVi extends AppLocalizations { String get cannotRenameSymbol => 'Không thể đổi tên ký hiệu một cách an toàn. Kiểm tra tên và làm mới tham chiếu trước khi thử lại.'; + @override + String get renameTopicFileInstead => + 'ID chủ đề được tạo từ tên tệp. Hãy dùng Đổi tên tệp chủ đề trong mục lục hoặc thanh bên Tệp.'; + @override String get keyboardLayout => 'Bố cục bàn phím'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index aa44b7d8..f0d25647 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1517,6 +1517,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get doRefactor => '执行重构'; + @override + String get preview => '预览'; + + @override + String get topicRenamePreviewTitle => '重命名预览'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return '路径:$oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return '主题 ID:$oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return '实例 $instance:$oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => '受影响的文件'; + @override String get orphanTopicTitle => '主题文件已不再使用'; @@ -2301,6 +2325,25 @@ class AppLocalizationsZh extends AppLocalizations { 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”。'; @@ -3788,6 +3831,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cannotRenameSymbol => '无法安全地重命名此符号。请检查名称并刷新引用,然后重试。'; + @override + String get renameTopicFileInstead => '主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。'; + @override String get keyboardLayout => '键盘布局'; @@ -5765,6 +5811,30 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get doRefactor => '执行重构'; + @override + String get preview => '预览'; + + @override + String get topicRenamePreviewTitle => '重命名预览'; + + @override + String topicRenamePathChange(String oldPath, String newPath) { + return '路径:$oldPath → $newPath'; + } + + @override + String topicRenameIdChange(String oldId, String newId) { + return '主题 ID:$oldId → $newId'; + } + + @override + String topicRenameUrlChange(String instance, String oldName, String newName) { + return '实例 $instance:$oldName → $newName'; + } + + @override + String get topicRenameAffectedFiles => '受影响的文件'; + @override String get orphanTopicTitle => '主题文件已不再使用'; @@ -6549,6 +6619,25 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { 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”。'; @@ -8036,6 +8125,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get cannotRenameSymbol => '无法安全地重命名此符号。请检查名称并刷新引用,然后重试。'; + @override + String get renameTopicFileInstead => '主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。'; + @override String get keyboardLayout => '键盘布局'; diff --git a/lib/src/core/diagnostic_localizations.dart b/lib/src/core/diagnostic_localizations.dart index 482b9b58..985c97df 100644 --- a/lib/src/core/diagnostic_localizations.dart +++ b/lib/src/core/diagnostic_localizations.dart @@ -239,6 +239,19 @@ String localizeDiagnostic(BuildContext context, Diagnostic diagnostic) { ), 'writerside.topic.duplicate-id' => l10n.diagnosticWritersideTopicDuplicateId(value('id')), + 'writerside.web-file-name.invalid' => + l10n.diagnosticWritersideWebFileNameInvalid( + value('instanceId'), + value('webFileName'), + value('topic'), + ), + 'writerside.web-file-name.collision' => + l10n.diagnosticWritersideWebFileNameCollision( + value('instanceId'), + value('webFileName'), + value('firstTopic'), + value('secondTopic'), + ), 'writerside.tree.missing-topic' => l10n.diagnosticWritersideTreeMissingTopic(value('topic')), 'writerside.tree.invalid-href' => l10n.diagnosticWritersideTreeInvalidHref( diff --git a/lib/src/export/html_publication_plan.dart b/lib/src/export/html_publication_plan.dart index 8904cedf..cde14bb6 100644 --- a/lib/src/export/html_publication_plan.dart +++ b/lib/src/export/html_publication_plan.dart @@ -6,6 +6,7 @@ import '../markdown/busymark_document.dart'; import '../writerside/writerside_model.dart'; import '../writerside/writerside_document_resolver.dart'; import '../writerside/writerside_document_renderer.dart'; +import '../writerside/writerside_web_file_name.dart'; import 'html_export_models.dart'; class HtmlPage { @@ -109,17 +110,15 @@ class HtmlPublicationPlan { ), ); final title = resolved.title ?? topic.title ?? topic.id; - final customName = - resolved.document.elements - .where((e) => e.name == 'web-file-name') - .firstOrNull - ?.plainText - .trim() ?? - topic.webFileName; - final filename = - customName ?? - '${p.basenameWithoutExtension(topic.fileName).toLowerCase().replaceAll(RegExp(r'[^\p{L}\p{N}-]', unicode: true), '-')}.html'; - if (!validFilename(filename)) { + final filename = const WritersideWebFileNameResolver() + .resolve( + module: origin, + topic: topic, + instance: instance, + modulesByOrigin: modulesByOrigin, + ) + .value; + if (!WritersideWebFileNameResolver.isValid(filename)) { throw HtmlExportException( 'Invalid web filename "$filename" in ${topic.filePath}.', ); @@ -240,12 +239,7 @@ class HtmlPublicationPlan { } static bool validFilename(String name) => - name.isNotEmpty && - utf8.encode(name).length <= 240 && - name.toLowerCase().endsWith('.html') && - !name.startsWith('.') && - !RegExp(r'[/\\\x00-\x1f\x7f?#:%]').hasMatch(name) && - p.basename(name) == name; + WritersideWebFileNameResolver.isValid(name); HtmlPage? pageForSource(String source) => pages.where((page) => p.equals(page.sourcePath, source)).firstOrNull; diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index ce5f0260..4ed5a4d4 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -22,6 +22,18 @@ typedef LocalHistoryComparisonComputer = SourceComparisonInput currentInput, ); +/// Read-only rendering of the shared source-comparison model. Refactoring +/// previews reuse this instead of maintaining a second diff presentation. +class SourceComparisonView extends StatelessWidget { + const SourceComparisonView({super.key, required this.comparison}); + + final SourceComparison comparison; + + @override + Widget build(BuildContext context) => + _UnifiedComparisonPane(comparison: comparison); +} + final localHistoryComparisonComputerProvider = Provider( (ref) => diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 46bf61c9..662f9a0d 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -39,6 +39,7 @@ import '../../core/diagnostic_localizations.dart'; import '../../core/path_utils.dart' show isTextDocumentationPath, slugForHeading; import '../../core/source_span.dart'; +import '../../comparison/source_comparison.dart'; import '../../core/uri_utils.dart'; import '../../editor/document_callout.dart'; import '../../editor/document_code_block.dart'; @@ -88,6 +89,8 @@ import '../../writerside/writerside_toc_editor.dart'; import '../../writerside/writerside_toc_presentation.dart'; import '../../writerside/writerside_toc_navigation.dart'; import '../../writerside/writerside_topic_creator.dart'; +import '../../writerside/writerside_topic_file_editor.dart'; +import '../../writerside/writerside_topic_file_name.dart'; import '../../writerside/writerside_topic_removal_service.dart'; import '../../writerside/writerside_video.dart'; import '../workspace_controller.dart'; @@ -2197,6 +2200,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { late String _workspaceId; String? _activeFilePath; _WritersideTopicUsageReview? _topicUsageReview; + WritersideTopicRenamePlan? _topicRenameReview; WorkspaceReplacementCancellation? _replacementCancellation; @override @@ -2228,6 +2232,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { _workspaceId = widget.workspace.id; _activeFilePath = widget.workspace.activeFilePath; _topicUsageReview = null; + _topicRenameReview = null; _tab = _initialSidebarTabIndex(widget.workspace); return; } @@ -2299,6 +2304,12 @@ class _SidebarState extends ConsumerState<_Sidebar> { onOpenResult: widget.onOpenSearchResult, onReviewReplacement: _reviewWorkspaceReplacement, ) + : _topicRenameReview != null + ? _WritersideTopicRenameSidebar( + plan: _topicRenameReview!, + onBack: () => setState(() => _topicRenameReview = null), + onDoRefactor: () => _applyReviewedTopicRename(context), + ) : _topicUsageReview != null ? _WritersideTopicUsagesSidebar( review: _topicUsageReview!, @@ -2311,6 +2322,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { _SidebarTab.files => _FilesTab( workspace: widget.workspace, onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), onRequestTopicRemoval: (target) => _runWritersideTopicRemoval(context, target), ), @@ -2319,6 +2332,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { canExport: widget.canExport, onExport: widget.onExport, onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), onRequestTopicRemoval: (target) => _runWritersideTopicRemoval(context, target), ), @@ -2424,6 +2439,85 @@ class _SidebarState extends ConsumerState<_Sidebar> { ); } + Future _startWritersideTopicRename( + BuildContext context, + String topicPath, + ) async { + final project = widget.workspace.writersideProject; + final owner = project?.modules + .where( + (module) => + module.topics.any((topic) => p.equals(topic.filePath, topicPath)), + ) + .firstOrNull; + if (owner == null) return false; + final decision = + await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => WritersideTopicRenameDialog( + currentFileName: p.basename(topicPath), + ), + ); + if (decision == null || !mounted || !context.mounted) return false; + final controller = ref.read(workspaceControllerProvider.notifier); + var plan = await controller.prepareWritersideTopicRename( + topicPath, + decision.fileName, + topicModuleRoot: owner.rootPath, + ); + if (plan == null || !mounted || !context.mounted) { + if (mounted) _showLatestWorkspaceMessage(this.context); + return false; + } + if (!await confirmSafeToChangeWorkspaceFiles( + context, + ref, + plan.affectedPaths, + ) || + !mounted || + !context.mounted) { + return false; + } + // Saving dirty participants invalidates the first snapshot. Build the one + // the user will actually review or commit after all buffers are clean. + plan = await controller.prepareWritersideTopicRename( + topicPath, + decision.fileName, + topicModuleRoot: owner.rootPath, + ); + if (plan == null || !mounted || !context.mounted) { + if (mounted) _showLatestWorkspaceMessage(this.context); + return false; + } + if (decision.action == WritersideTopicRenameDialogAction.preview) { + setState(() { + _topicUsageReview = null; + _topicRenameReview = plan; + }); + return true; + } + final applied = await controller.applyWritersideTopicRename(plan); + if (!mounted || !context.mounted) return applied; + if (!applied) _showLatestWorkspaceMessage(this.context); + return applied; + } + + Future _applyReviewedTopicRename(BuildContext context) async { + final plan = _topicRenameReview; + if (plan == null) return; + final applied = await ref + .read(workspaceControllerProvider.notifier) + .applyWritersideTopicRename(plan); + if (!mounted || !context.mounted) return; + if (applied) { + setState(() => _topicRenameReview = null); + _clearGitDetailSelection(ref); + } else { + _showLatestWorkspaceMessage(this.context); + } + } + Future _runWritersideTopicRemoval( BuildContext context, _WritersideTopicRemovalTarget target, { @@ -3948,6 +4042,180 @@ class _WritersideTopicUsagesSidebar extends StatelessWidget { } } +class _WritersideTopicRenameSidebar extends StatefulWidget { + const _WritersideTopicRenameSidebar({ + required this.plan, + required this.onBack, + required this.onDoRefactor, + }); + + final WritersideTopicRenamePlan plan; + final VoidCallback onBack; + final VoidCallback onDoRefactor; + + @override + State<_WritersideTopicRenameSidebar> createState() => + _WritersideTopicRenameSidebarState(); +} + +class _WritersideTopicRenameSidebarState + extends State<_WritersideTopicRenameSidebar> { + var _selected = 0; + + List<({String path, String before, String after})> get _files => [ + ( + path: widget.plan.newTopicPath, + before: widget.plan.originalTargetSource, + after: widget.plan.resultingTargetSource, + ), + for (final change in widget.plan.changedFiles) + ( + path: change.path, + before: change.originalSource, + after: change.resultingSource, + ), + ]; + + @override + void didUpdateWidget(covariant _WritersideTopicRenameSidebar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.plan, widget.plan)) _selected = 0; + } + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final files = _files; + final selected = files[_selected.clamp(0, files.length - 1)]; + final comparison = compareSource( + SourceComparisonInput( + id: '${selected.path}:before', + version: 0, + label: context.l10n.localHistorySelectedRevision, + source: selected.before, + ), + SourceComparisonInput( + id: '${selected.path}:after', + version: 1, + label: context.l10n.localHistoryCurrentDisk, + source: selected.after, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colors.sidebar, + border: Border(bottom: BorderSide(color: colors.subtleBorder)), + ), + child: SizedBox( + height: BusyMarkSizes.paneHeaderHeight, + child: Row( + children: [ + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + transparent: true, + onPressed: widget.onBack, + ), + const SizedBox(width: BusyMarkSpacing.xs), + const Icon(BusyMarkGlyphs.edit, size: BusyMarkSizes.iconSm), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + context.l10n.topicRenamePreviewTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.topicRenamePathChange( + widget.plan.oldTopicPath, + widget.plan.newTopicPath, + ), + key: const ValueKey('topic-rename-path-change'), + ), + if (widget.plan.updatesXmlTopicId) + Text( + context.l10n.topicRenameIdChange( + widget.plan.oldTopicId, + widget.plan.newTopicId, + ), + key: const ValueKey('topic-rename-id-change'), + ), + for (final change in widget.plan.webFileNameChanges) + if (change.changed) + Text( + context.l10n.topicRenameUrlChange( + change.instanceId, + change.oldWebFileName, + change.newWebFileName, + ), + ), + const SizedBox(height: BusyMarkSpacing.sm), + Text( + context.l10n.topicRenameAffectedFiles, + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: ListView.builder( + key: const ValueKey('topic-rename-affected-files'), + itemCount: files.length, + itemBuilder: (context, index) => ListTile( + dense: true, + selected: index == _selected, + leading: Icon( + index == 0 ? BusyMarkGlyphs.edit : BusyMarkGlyphs.document, + size: BusyMarkSizes.iconSm, + ), + title: Text( + index == 0 + ? '${p.basename(widget.plan.oldTopicPath)} → ${p.basename(widget.plan.newTopicPath)}' + : p.basename(files[index].path), + ), + subtitle: Text( + files[index].path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => setState(() => _selected = index), + ), + ), + ), + Divider(height: 1, color: colors.subtleBorder), + Expanded(child: SourceComparisonView(comparison: comparison)), + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: BusyMarkPushButton.standardIcon( + key: const ValueKey('topic-rename-do-refactor'), + onPressed: widget.onDoRefactor, + icon: const Icon(BusyMarkGlyphs.edit), + label: Text(context.l10n.doRefactor), + ), + ), + ], + ); + } +} + String _writersideTopicUsageKindLabel( BuildContext context, WritersideTopicUsageKind kind, @@ -4001,11 +4269,13 @@ class _FilesTab extends ConsumerStatefulWidget { const _FilesTab({ required this.workspace, required this.onShowFileHistory, + required this.onRenameTopic, required this.onRequestTopicRemoval, }); final Workspace workspace; final Future Function(DocumentFile file) onShowFileHistory; + final Future Function(String topicPath) onRenameTopic; final Future Function( _WritersideTopicRemovalTarget target, ) @@ -4084,6 +4354,8 @@ class _FilesTabState extends ConsumerState<_FilesTab> { const _ShowSelectedSidebarTreeMenuIntent(), const SingleActivator(LogicalKeyboardKey.f10, shift: true): const _ShowSelectedSidebarTreeMenuIntent(), + const SingleActivator(LogicalKeyboardKey.f6, shift: true): + const _RenameSelectedTopicFileIntent(), }, child: Actions( actions: { @@ -4101,6 +4373,20 @@ class _FilesTabState extends ConsumerState<_FilesTab> { return null; }, ), + _RenameSelectedTopicFileIntent: + CallbackAction<_RenameSelectedTopicFileIntent>( + onInvoke: (_) { + final path = _selectedPath; + final file = path == null + ? null + : _documentFileForPath(widget.workspace, path); + if (path != null && + _isWritersideTopicFile(widget.workspace, file)) { + unawaited(widget.onRenameTopic(path)); + } + return null; + }, + ), }, child: Focus( focusNode: _treeFocusNode, @@ -4234,24 +4520,24 @@ class _FilesTabState extends ConsumerState<_FilesTab> { _clearGitDetailSelection(ref); } case _FileTreeAction.rename: - final newName = await _showFileNameDialog( - context, - title: context.l10n.rename, - actionLabel: context.l10n.rename, - initialValue: name, - ); - if (newName == null || - !context.mounted || - !await saveOrConfirmSafeToChangeActiveFile(context, ref)) { - return; - } final renamed = isTopic - ? await ref - .read(workspaceControllerProvider.notifier) - .renameWritersideTopicFile(path, newName) - : await ref - .read(workspaceControllerProvider.notifier) - .renameWorkspaceEntity(path, newName); + ? await widget.onRenameTopic(path) + : await (() async { + final newName = await _showFileNameDialog( + context, + title: context.l10n.rename, + actionLabel: context.l10n.rename, + initialValue: name, + ); + if (newName == null || + !context.mounted || + !await saveOrConfirmSafeToChangeActiveFile(context, ref)) { + return false; + } + return ref + .read(workspaceControllerProvider.notifier) + .renameWorkspaceEntity(path, newName); + }()); if (renamed) { setState(() => _cutEntry = null); _clearGitDetailSelection(ref); @@ -4447,6 +4733,10 @@ class _ShowSelectedSidebarTreeMenuIntent extends Intent { const _ShowSelectedSidebarTreeMenuIntent(); } +class _RenameSelectedTopicFileIntent extends Intent { + const _RenameSelectedTopicFileIntent(); +} + bool _isWritersideTopicFile(Workspace workspace, DocumentFile? file) { if (file == null) { return false; @@ -4498,6 +4788,7 @@ Future<_FileTreeAction?> _showFileTreeMenu( ? context.l10n.renameTopicFile : context.l10n.rename, icon: BusyMarkGlyphs.edit, + shortcut: safeDeleteTopic ? 'Shift+F6' : null, ), BusyMarkPopupMenuItem( value: _FileTreeAction.cut, @@ -5317,6 +5608,7 @@ class _TocTab extends ConsumerStatefulWidget { required this.canExport, required this.onExport, required this.onShowFileHistory, + required this.onRenameTopic, required this.onRequestTopicRemoval, }); @@ -5324,6 +5616,7 @@ class _TocTab extends ConsumerStatefulWidget { final bool canExport; final VoidCallback onExport; final Future Function(DocumentFile file) onShowFileHistory; + final Future Function(String topicPath) onRenameTopic; final Future Function( _WritersideTopicRemovalTarget target, ) @@ -5563,6 +5856,8 @@ class _TocTabState extends ConsumerState<_TocTab> { const _ShowSelectedSidebarTreeMenuIntent(), const SingleActivator(LogicalKeyboardKey.f10, shift: true): const _ShowSelectedSidebarTreeMenuIntent(), + const SingleActivator(LogicalKeyboardKey.f6, shift: true): + const _RenameSelectedTopicFileIntent(), }, child: Actions( actions: { @@ -5602,6 +5897,27 @@ class _TocTabState extends ConsumerState<_TocTab> { return null; }, ), + _RenameSelectedTopicFileIntent: + CallbackAction<_RenameSelectedTopicFileIntent>( + onInvoke: (_) { + final selectedEntries = entries + .where( + (entry) => _selectedNodePathKeys.isEmpty + ? entry.pathKey == selectedEntry?.pathKey + : _selectedNodePathKeys.contains(entry.pathKey), + ) + .toList(); + if (selectedEntries.length == 1) { + final topic = _presenter! + .present(selectedEntries.single.node) + .topic; + if (topic != null) { + unawaited(widget.onRenameTopic(topic.filePath)); + } + } + return null; + }, + ), }, child: Focus( focusNode: _treeFocusNode, @@ -6472,7 +6788,7 @@ class _TocTabState extends ConsumerState<_TocTab> { if (topic == null || rawNode == null) return; String? validateDuplicateName(String name) { if (name.trim().isEmpty) return context.l10n.fileNameRequired; - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(name)) { + if (!isValidWritersideTopicId(name.trim())) { return context.l10n.useIdentifierCharacters; } if (widget.workspace.writersideModule!.topics.any( @@ -6715,38 +7031,7 @@ class _TocTabState extends ConsumerState<_TocTab> { if (path == null || topicOwner == null) { return; } - final newName = await _showFileNameDialog( - context, - title: context.l10n.rename, - actionLabel: context.l10n.rename, - initialValue: p.basename(path), - ); - if (newName == null || !context.mounted || !mounted) { - return; - } - final affectedPaths = await ref - .read(workspaceServiceProvider) - .writersideTopicRenameAffectedPaths( - widget.workspace, - topicPath: path, - topicModuleRoot: topicOwner.rootPath, - ); - if (!mounted || !context.mounted || !entryIsCurrent()) return; - final canRename = await confirmSafeToChangeWorkspaceFiles( - context, - ref, - affectedPaths, - ); - if (!canRename || !mounted || !context.mounted || !entryIsCurrent()) { - return; - } - final renamed = await ref - .read(workspaceControllerProvider.notifier) - .renameWritersideTopicFile( - path, - newName, - topicModuleRoot: topicOwner.rootPath, - ); + final renamed = await widget.onRenameTopic(path); if (!mounted) { return; } @@ -7756,6 +8041,7 @@ Future<_TocTreeAction?> _showTocTreeMenu( value: _TocTreeAction.rename, label: context.l10n.renameTopicFile, icon: BusyMarkGlyphs.edit, + shortcut: 'Shift+F6', enabled: hasTopicFile, ), BusyMarkPopupMenuItem( @@ -8587,23 +8873,29 @@ class _CreateWritersideTopicDialogState if (value.isEmpty) { return context.l10n.fileNameRequired; } - if (value == '.' || - value == '..' || - p.isAbsolute(value) || - value.contains('/') || - value.contains(r'\') || - value.contains('..')) { - return context.l10n.useSingleSafeFileName; - } final expectedExtension = _extensionFor(_format); final extension = p.extension(value).toLowerCase(); if (extension.isNotEmpty && extension != expectedExtension) { return context.l10n.useExpectedExtension(expectedExtension); } - final id = p.basenameWithoutExtension(value); - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id)) { + final effective = extension.isEmpty ? '$value$expectedExtension' : value; + try { + validateWritersideTopicFileName( + effective, + requiredExtension: expectedExtension, + ); + } on Object { + if (value == '.' || + value == '..' || + p.isAbsolute(value) || + value.contains('/') || + value.contains(r'\') || + value.contains('\u0000')) { + return context.l10n.useSingleSafeFileName; + } return context.l10n.useIdentifierCharacters; } + final id = p.basenameWithoutExtension(effective); final existingIds = widget.workspace.writersideModule?.topics .map((topic) => topic.id) .toSet(); @@ -12313,6 +12605,21 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); if (selected != null) await navigate(selected.filePath, selected.span); } else { + if (symbol.kind == WritersideSymbolKind.topic) { + await showBusyMarkModalDialog( + context, + builder: (context) => AlertDialog( + content: Text(context.l10n.renameTopicFileInstead), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + ), + ); + return; + } var pendingName = symbol.name; final newName = await showBusyMarkModalEditorDialog( context, diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index cdb86341..e7f7eff2 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -1,14 +1,113 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; import '../../writerside/writerside_model.dart'; +import '../../writerside/writerside_topic_file_name.dart'; import '../../writerside/writerside_title_editor.dart'; import '../workspace_service.dart'; +enum WritersideTopicRenameDialogAction { preview, refactor } + +class WritersideTopicRenameDialogResult { + const WritersideTopicRenameDialogResult({ + required this.fileName, + required this.action, + }); + + final String fileName; + final WritersideTopicRenameDialogAction action; +} + +class WritersideTopicRenameDialog extends StatefulWidget { + const WritersideTopicRenameDialog({super.key, required this.currentFileName}); + + final String currentFileName; + + @override + State createState() => + _WritersideTopicRenameDialogState(); +} + +class _WritersideTopicRenameDialogState + extends State { + late final TextEditingController _controller = TextEditingController( + text: widget.currentFileName, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String? get _error { + try { + validateWritersideTopicFileName( + _controller.text, + requiredExtension: p.extension(widget.currentFileName).toLowerCase(), + ); + return null; + } on Object { + return context.l10n.errorTopicFileNameInvalid; + } + } + + void _submit(WritersideTopicRenameDialogAction action) { + if (_error != null) return; + Navigator.pop( + context, + WritersideTopicRenameDialogResult( + fileName: _controller.text.trim(), + action: action, + ), + ); + } + + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.rename, + maxWidth: BusyMarkSizes.dialog, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.preview, + onPressed: _error == null + ? () => _submit(WritersideTopicRenameDialogAction.preview) + : null, + ), + BusyMarkDialogButton( + label: context.l10n.tocRefactorMenu, + suggested: true, + onPressed: _error == null + ? () => _submit(WritersideTopicRenameDialogAction.refactor) + : null, + ), + ], + children: [ + Text(widget.currentFileName), + const SizedBox(height: BusyMarkSpacing.sm), + TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: context.l10n.fileName, + errorText: _error, + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(WritersideTopicRenameDialogAction.refactor), + ), + ], + ); +} + class WritersideTitleDialog extends StatefulWidget { const WritersideTitleDialog({super.key, required this.session}); final WritersideTitleEditSession session; diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 3b924eda..ec97e6e7 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -23,6 +23,7 @@ import '../writerside/writerside_project.dart'; import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_topic_removal_service.dart'; import '../writerside/writerside_topic_creator.dart'; +import '../writerside/writerside_topic_file_editor.dart'; import '../writerside/writerside_toc_editor.dart'; import '../writerside/writerside_title_editor.dart'; import 'document_buffer.dart'; @@ -1889,35 +1890,72 @@ class WorkspaceController extends Notifier { String topicPath, String newFileName, { String? topicModuleRoot, - }) { - final activeFilePath = state.workspace?.activeFilePath; - return _runWorkspaceFileOperation((workspace) async { - final ownerRoot = topicModuleRoot ?? workspace.writersideModule?.rootPath; - if (ownerRoot == null) { - throw const BusyMarkException('writerside.topic.module-not-open'); - } - final affectedPaths = await _service.writersideTopicRenameAffectedPaths( + }) async { + final workspace = state.workspace; + if (workspace == null) return false; + final ownerRoot = topicModuleRoot ?? workspace.writersideModule?.rootPath; + if (ownerRoot == null) return false; + final plan = await prepareWritersideTopicRename( + topicPath, + newFileName, + topicModuleRoot: ownerRoot, + ); + return plan == null ? false : await applyWritersideTopicRename(plan); + } + + Future prepareWritersideTopicRename( + String topicPath, + String newFileName, { + required String topicModuleRoot, + }) async { + final workspace = state.workspace; + if (workspace == null) return null; + try { + final plan = await _service.prepareWritersideTopicRename( workspace, topicPath: topicPath, - topicModuleRoot: ownerRoot, + newFileName: newFileName, + topicModuleRoot: topicModuleRoot, + ); + return plan; + } on Object catch (error, stackTrace) { + busyMarkDebugLogError( + '[BusyMark] Prepare topic rename failed', + error, + stackTrace, + context: {'root': busyMarkLogPath(workspace.rootPath)}, ); + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: error, + ), + ); + return null; + } + } + + Future applyWritersideTopicRename(WritersideTopicRenamePlan plan) { + final activeFilePath = state.workspace?.activeFilePath; + return _runWorkspaceFileOperation((workspace) async { void validate(Iterable paths) => _requireCleanAffectedFiles(workspace, paths); - validate(affectedPaths); - final target = await _service.renameWritersideTopicFile( - workspace, - topicPath, - newFileName, - topicModuleRoot: ownerRoot, + validate(plan.affectedPaths); + final result = await _service.applyWritersideTopicRename( + plan, validateBeforePublish: validate, ); - final transitions = _beginLocalHistoryPathTransitions(topicPath, target); + final target = result.newTopicPath; + final transitions = _beginLocalHistoryPathTransitions( + plan.oldTopicPath, + target, + ); var committed = false; try { - await _localHistory.remapPath(topicPath, target); - _remapOpenWorkspacePaths(workspace, topicPath, target); + await _localHistory.remapPath(plan.oldTopicPath, target); + _remapOpenWorkspacePaths(workspace, plan.oldTopicPath, target); committed = true; - return _remapMovedPath(activeFilePath, topicPath, target); + return _remapMovedPath(activeFilePath, plan.oldTopicPath, target); } finally { await _finishLocalHistoryPathTransitions( transitions, diff --git a/lib/src/workspace/workspace_message.dart b/lib/src/workspace/workspace_message.dart index 1f345e67..799fce3a 100644 --- a/lib/src/workspace/workspace_message.dart +++ b/lib/src/workspace/workspace_message.dart @@ -170,6 +170,26 @@ String _localizeWorkspaceError(BuildContext context, Object? error) { l10n.errorTopicFileExtensionMismatch(value('extension')), 'writerside.topic-file.file-name-invalid' => l10n.errorTopicFileNameInvalid, + 'writerside.topic-file.missing-root-id' => + l10n.diagnosticWritersideTopicMissingRootId, + 'writerside.topic-file.root-id-mismatch' => + l10n.diagnosticWritersideTopicRootIdMismatch( + value('id'), + value('expectedId'), + ), + 'writerside.topic-file.web-file-name-invalid' => + l10n.diagnosticWritersideWebFileNameInvalid( + value('instanceId'), + value('webFileName'), + value('topic'), + ), + 'writerside.topic-file.web-file-name-collision' => + l10n.diagnosticWritersideWebFileNameCollision( + value('instanceId'), + value('webFileName'), + value('firstTopic'), + value('secondTopic'), + ), 'writerside.topic-file.path-unsafe' => l10n.errorFileOperationOutsideRoot, 'writerside.topic-file.source-unsafe' => l10n.errorFileOperationOutsideRoot, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 368f0474..fbdf1699 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -772,7 +772,29 @@ class WorkspaceService { String? topicModuleRoot, void Function(Iterable)? validateBeforePublish, }) async { - final ownerRoot = topicModuleRoot ?? workspace.writersideModule?.rootPath; + final plan = await prepareWritersideTopicRename( + workspace, + topicPath: topicPath, + newFileName: newFileName, + topicModuleRoot: + topicModuleRoot ?? workspace.writersideModule?.rootPath ?? '', + ); + final result = await applyWritersideTopicRename( + plan, + validateBeforePublish: validateBeforePublish, + ); + return result.newTopicPath; + } + + Future prepareWritersideTopicRename( + Workspace workspace, { + required String topicPath, + required String newFileName, + required String topicModuleRoot, + }) async { + final ownerRoot = topicModuleRoot.isEmpty + ? workspace.writersideModule?.rootPath + : topicModuleRoot; if (ownerRoot == null) { throw const BusyMarkException('writerside.topic.module-not-open'); } @@ -787,14 +809,22 @@ class WorkspaceService { throw const BusyMarkException('writerside.topic.module-not-open'); } final topic = _writersideTopicForPath(module, topicPath); - final result = await writersideTopicFileEditor.rename( + return writersideTopicFileEditor.prepareRename( module: module, topic: topic, newFileName: newFileName, projectModules: project.modules, + ); + } + + Future applyWritersideTopicRename( + WritersideTopicRenamePlan plan, { + void Function(Iterable)? validateBeforePublish, + }) { + return writersideTopicFileEditor.applyRename( + plan, validateBeforePublish: validateBeforePublish, ); - return result.newTopicPath; } Future> writersideTopicRenameAffectedPaths( diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index 8c92d76b..1d2121f3 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -486,6 +486,9 @@ class WritersideTopic { final List includes; final WritersideDocument document; final List diagnostics; + + /// A direct, authored topic-level value when one is unconditionally + /// available. This is not an instance-effective publication filename. final String? webFileName; final ParsedMarkdownDocument? markdown; final List titleOverrides; diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index 85cda942..6f614ca5 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -1190,7 +1190,9 @@ class WritersideTopicParser { includes: includes, document: document, diagnostics: sortDiagnostics(topicDiagnostics), - webFileName: _webFileName(source), + // Authored convenience metadata only. Instance-effective publication + // names are resolved by WritersideWebFileNameResolver. + webFileName: _topicWebFileName(document), markdown: parsed, titleOverrides: titleOverrides, semanticElementNames: document.elements @@ -1304,6 +1306,28 @@ class WritersideTopicParser { ), ); } + final directTopicChildren = root.children.whereType(); + for (final element in directTopicChildren) { + switch (element.name.local) { + case 'title': + final instance = element.getAttribute('instance'); + if (instance != null && instance.isNotEmpty) { + titleOverrides.add( + WritersideTopicTitleOverride( + instance: instance, + title: element.innerText.trim(), + ), + ); + } + case 'web-file-name': + if (!element.attributes.any( + (attribute) => + {'instance', 'filter'}.contains(attribute.name.local), + )) { + webFileName ??= _trimmedOrNull(element.innerText); + } + } + } final seenIds = {}; for (final element in document.descendants.whereType()) { final semanticElement = nextSemanticElement(element); @@ -1329,18 +1353,6 @@ class WritersideTopicParser { seenIds[elementId] = span; } switch (element.name.local) { - case 'title': - final instance = element.getAttribute('instance'); - if (instance != null && instance.isNotEmpty) { - titleOverrides.add( - WritersideTopicTitleOverride( - instance: instance, - title: element.innerText.trim(), - ), - ); - } - case 'web-file-name': - webFileName = element.innerText.trim(); case 'a': final href = element.getAttribute('href') ?? @@ -1492,14 +1504,16 @@ List _topicTitleOverrides( ]; } -String? _webFileName(String source) { - final match = RegExp( - r'\s*(.*?)\s*', - dotAll: true, - ).firstMatch(source); - final value = match?.group(1)?.trim(); - return value == null || value.isEmpty ? null : value; -} +String? _topicWebFileName(WritersideDocument document) => document.nodes + .whereType() + .where( + (element) => + element.name == 'web-file-name' && + !element.attributes.keys.any({'instance', 'filter'}.contains), + ) + .map((element) => _trimmedOrNull(element.plainText)) + .whereType() + .firstOrNull; String? _trimmedOrNull(String? value) { final trimmed = value?.trim(); diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index edeea1da..850d1913 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -11,6 +11,7 @@ import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_schema.dart'; import 'writerside_source_loader.dart'; +import 'writerside_web_file_name.dart'; enum WritersideSymbolKind { module, @@ -765,6 +766,12 @@ class WritersideProjectIndex { WritersideSymbol symbol, String newName, ) { + // A topic declaration is the filename-derived identity. It must only be + // changed by the atomic topic-file refactoring, which also renames the + // file and every semantic reference. + if (symbol.kind == WritersideSymbolKind.topic) { + return const []; + } final normalized = newName.trim(); if (normalized.isEmpty || !RegExp(r'^[A-Za-z_][A-Za-z0-9_.-]*$').hasMatch(normalized) || @@ -958,6 +965,7 @@ class WritersideProject { diagnostics: sortDiagnostics([ for (final candidate in nextModules) ...candidate.diagnostics, ...nextIndex.diagnostics, + ...writersideWebFileNameDiagnostics(nextModules), ]), ); } @@ -1033,6 +1041,7 @@ class WritersideProjectService { diagnostics: sortDiagnostics([ for (final module in modules) ...module.diagnostics, ...index.diagnostics, + ...writersideWebFileNameDiagnostics(modules), ]), ); } @@ -1062,6 +1071,7 @@ class WritersideProjectService { diagnostics: sortDiagnostics([ for (final candidate in replaced.modules) ...candidate.diagnostics, ...index.diagnostics, + ...writersideWebFileNameDiagnostics(replaced.modules), ]), ); } diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 946293a7..02a334d0 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -7,6 +7,7 @@ import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; import '../core/path_utils.dart'; import 'writerside_model.dart'; +import 'writerside_topic_file_name.dart'; enum WritersideTopicCreatePlacement { root, sibling, child } @@ -684,32 +685,19 @@ class WritersideTopicCreator { String _topicFileName(String value, WritersideTopicFormat format) { final trimmed = value.trim(); - if (trimmed.isEmpty || - trimmed == '.' || - trimmed == '..' || - p.isAbsolute(trimmed) || - trimmed.contains('/') || - trimmed.contains(r'\') || - trimmed.contains('..')) { - throw const BusyMarkException('writerside.topic.file-name-unsafe'); - } final expectedExtension = switch (format) { WritersideTopicFormat.markdown => '.md', WritersideTopicFormat.xml => '.topic', }; final extension = p.extension(trimmed).toLowerCase(); final fileName = extension.isEmpty ? '$trimmed$expectedExtension' : trimmed; - if (p.extension(fileName).toLowerCase() != expectedExtension) { - throw BusyMarkException( - 'writerside.topic.file-extension-mismatch', - args: {'extension': expectedExtension}, - ); - } - final id = p.basenameWithoutExtension(fileName); - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id)) { - throw const BusyMarkException('writerside.topic.file-name-invalid'); - } - return fileName; + return validateWritersideTopicFileName( + fileName, + requiredExtension: expectedExtension, + unsafeCode: 'writerside.topic.file-name-unsafe', + invalidCode: 'writerside.topic.file-name-invalid', + extensionCode: 'writerside.topic.file-extension-mismatch', + ); } String _topicSource(WritersideTopicFormat format, String id, String title) { diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 4d78f7ac..0dd0408f 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -22,6 +22,82 @@ import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; import 'writerside_schema.dart'; +import 'writerside_topic_file_name.dart'; +import 'writerside_web_file_name.dart'; + +class WritersideTopicRenameFileChange { + const WritersideTopicRenameFileChange({ + required this.path, + required this.originalSource, + required this.resultingSource, + }); + + final String path; + final String originalSource; + final String resultingSource; +} + +class WritersideTopicRenameUrlChange { + const WritersideTopicRenameUrlChange({ + required this.moduleRoot, + required this.instanceId, + required this.oldWebFileName, + required this.newWebFileName, + }); + + final String moduleRoot; + final String instanceId; + final String oldWebFileName; + final String newWebFileName; + + bool get changed => oldWebFileName != newWebFileName; +} + +/// An immutable, fully calculated topic-file refactoring. Applying this object +/// never recalculates reference edits; every participating source is checked +/// against the captured snapshots before the first write. +class WritersideTopicRenamePlan { + WritersideTopicRenamePlan._({ + required this.owningModule, + required this.oldTopicPath, + required this.newTopicPath, + required this.oldTopicReference, + required this.newTopicReference, + required this.oldTopicId, + required this.newTopicId, + required this.format, + required this.originalTargetSource, + required this.resultingTargetSource, + required List changedFiles, + required this.createsNewTopicFile, + required this.deletesOldTopicFile, + required this.updatesXmlTopicId, + required Set affectedPaths, + required List webFileNameChanges, + required _PreparedTopicRename prepared, + }) : changedFiles = List.unmodifiable(changedFiles), + affectedPaths = Set.unmodifiable(affectedPaths), + webFileNameChanges = List.unmodifiable(webFileNameChanges), + _prepared = prepared; + + final WritersideModule owningModule; + final String oldTopicPath; + final String newTopicPath; + final String oldTopicReference; + final String newTopicReference; + final String oldTopicId; + final String newTopicId; + final WritersideTopicFormat format; + final String originalTargetSource; + final String resultingTargetSource; + final List changedFiles; + final bool createsNewTopicFile; + final bool deletesOldTopicFile; + final bool updatesXmlTopicId; + final Set affectedPaths; + final List webFileNameChanges; + final _PreparedTopicRename _prepared; +} class WritersideTopicFileRenameResult { const WritersideTopicFileRenameResult({ @@ -77,6 +153,21 @@ class WritersideTopicFileEditor { required String newFileName, List? projectModules, void Function(Iterable)? validateBeforePublish, + }) async { + final plan = await prepareRename( + module: module, + topic: topic, + newFileName: newFileName, + projectModules: projectModules, + ); + return applyRename(plan, validateBeforePublish: validateBeforePublish); + } + + Future prepareRename({ + required WritersideModule module, + required WritersideTopic topic, + required String newFileName, + List? projectModules, }) async { final snapshot = await _currentModuleSnapshot(module, topic); final context = await _mutationContext(snapshot); @@ -97,13 +188,29 @@ class WritersideTopicFileEditor { safeFileName, ); if (p.equals(targetPath, context.topicPath)) { - return WritersideTopicFileRenameResult( + return WritersideTopicRenamePlan._( + owningModule: context.module, oldTopicPath: context.topicPath, newTopicPath: context.topicPath, - oldTopicFileName: context.topic.fileName, - newTopicFileName: context.topic.fileName, - updatedTreePaths: const [], - updatedXmlTopicId: false, + oldTopicReference: context.topic.fileName, + newTopicReference: context.topic.fileName, + oldTopicId: context.topic.id, + newTopicId: context.topic.id, + format: context.topic.format, + originalTargetSource: context.topicSource, + resultingTargetSource: context.topicSource, + changedFiles: const [], + createsNewTopicFile: false, + deletesOldTopicFile: false, + updatesXmlTopicId: false, + affectedPaths: {context.topicPath}, + webFileNameChanges: const [], + prepared: _PreparedTopicRename( + target: context, + referenceContexts: referenceContexts, + targetSource: context.topicSource, + publishedEdits: const [], + ), ); } @@ -138,33 +245,93 @@ class WritersideTopicFileEditor { publishedEdits: publishedEdits, ); + final webFileNameChanges = _validateProspectiveWebFileNames( + referenceContexts, + target: context, + newTopicFileName: newTopicFileName, + ); + final affectedPaths = { context.topicPath, targetPath, for (final edit in publishedEdits) edit.path, }; - validateBeforePublish?.call(affectedPaths); + return WritersideTopicRenamePlan._( + owningModule: context.module, + oldTopicPath: context.topicPath, + newTopicPath: targetPath, + oldTopicReference: context.topic.fileName, + newTopicReference: newTopicFileName, + oldTopicId: p.basenameWithoutExtension(context.topic.fileName), + newTopicId: p.basenameWithoutExtension(newTopicFileName), + format: context.topic.format, + originalTargetSource: context.topicSource, + resultingTargetSource: topicEdit.source, + changedFiles: [ + for (final edit in publishedEdits) + WritersideTopicRenameFileChange( + path: edit.path, + originalSource: edit.originalSource, + resultingSource: edit.updatedSource, + ), + ], + createsNewTopicFile: true, + deletesOldTopicFile: true, + updatesXmlTopicId: topicEdit.updatedXmlTopicId, + affectedPaths: affectedPaths, + webFileNameChanges: webFileNameChanges, + prepared: _PreparedTopicRename( + target: context, + referenceContexts: referenceContexts, + targetSource: topicEdit.source, + publishedEdits: publishedEdits, + ), + ); + } + + Future applyRename( + WritersideTopicRenamePlan plan, { + void Function(Iterable)? validateBeforePublish, + }) async { + final prepared = plan._prepared; + final context = prepared.target; + final referenceContexts = prepared.referenceContexts; + final publishedEdits = prepared.publishedEdits; + if (!plan.createsNewTopicFile) { + return WritersideTopicFileRenameResult( + oldTopicPath: plan.oldTopicPath, + newTopicPath: plan.newTopicPath, + oldTopicFileName: plan.oldTopicReference, + newTopicFileName: plan.newTopicReference, + updatedTreePaths: const [], + updatedXmlTopicId: false, + ); + } + + validateBeforePublish?.call(plan.affectedPaths); + await _ensurePreparedPlanUnchanged(plan); + validateBeforePublish?.call(plan.affectedPaths); await _writeNewFile( context.anchor, - targetPath, - topicEdit.source, + plan.newTopicPath, + prepared.targetSource, sourceStat: context.topicStat, ); final appliedTreeEdits = <_TreeEdit>[]; try { - validateBeforePublish?.call(affectedPaths); + validateBeforePublish?.call(plan.affectedPaths); await _applyTreeEdits( context.anchor, publishedEdits, applied: appliedTreeEdits, ); - validateBeforePublish?.call(affectedPaths); + validateBeforePublish?.call(plan.affectedPaths); await _ensureReferenceContextsUnchanged( referenceContexts, publishedEdits, - targetPath: targetPath, - targetSource: topicEdit.source, + targetPath: plan.newTopicPath, + targetSource: prepared.targetSource, targetModuleRoot: context.module.rootPath, ); await _deleteUnchangedTopicSource(context); @@ -177,29 +344,29 @@ class WritersideTopicFileEditor { restored && await _renameTargetIsSafeToCleanUp( context, - targetPath: targetPath, - targetSource: topicEdit.source, + targetPath: plan.newTopicPath, + targetSource: prepared.targetSource, ); if (safeToCleanUp) { await _deleteCreatedFileBestEffort( context.anchor, - targetPath, - topicEdit.source, + plan.newTopicPath, + prepared.targetSource, ); } rethrow; } return WritersideTopicFileRenameResult( - oldTopicPath: context.topicPath, - newTopicPath: targetPath, - oldTopicFileName: context.topic.fileName, - newTopicFileName: newTopicFileName, + oldTopicPath: plan.oldTopicPath, + newTopicPath: plan.newTopicPath, + oldTopicFileName: plan.oldTopicReference, + newTopicFileName: plan.newTopicReference, updatedTreePaths: List.unmodifiable([ for (final edit in publishedEdits) if (_isTreePath(edit.path)) edit.path, ]), - updatedXmlTopicId: topicEdit.updatedXmlTopicId, + updatedXmlTopicId: plan.updatesXmlTopicId, ); } @@ -563,6 +730,97 @@ class WritersideTopicFileEditor { } } + List _validateProspectiveWebFileNames( + List<_ReferenceModuleContext> contexts, { + required _MutationContext target, + required String newTopicFileName, + }) { + final modules = [for (final context in contexts) context.module]; + final origins = { + for (final module in modules) + (module.config.moduleName?.trim().isNotEmpty == true + ? module.config.moduleName!.trim() + : p.basename(module.rootPath)): + module, + }; + const resolver = WritersideWebFileNameResolver(); + final result = []; + for (final host in modules) { + for (final instance in host.instances.where( + (value) => !value.isLibrary, + )) { + final published = writersidePublishedTopicsForInstance( + hostModule: host, + instance: instance, + modulesByOrigin: origins, + ); + final publishesTarget = published.any( + (item) => + p.equals(item.sourceModule.rootPath, target.module.rootPath) && + p.equals(item.topic.filePath, target.topicPath), + ); + if (!publishesTarget) continue; + final oldEffective = resolver.resolve( + module: target.module, + topic: target.topic, + instance: instance, + modulesByOrigin: origins, + ); + final newEffective = resolver.resolve( + module: target.module, + topic: target.topic, + instance: instance, + modulesByOrigin: origins, + topicFileName: newTopicFileName, + ); + if (!newEffective.isValid) { + throw BusyMarkException( + 'writerside.topic-file.web-file-name-invalid', + args: { + 'instanceId': instance.id, + 'webFileName': newEffective.value, + 'topic': newTopicFileName, + }, + ); + } + for (final other in published) { + if (p.equals(other.sourceModule.rootPath, target.module.rootPath) && + p.equals(other.topic.filePath, target.topicPath)) { + continue; + } + final otherEffective = resolver.resolve( + module: other.sourceModule, + topic: other.topic, + instance: instance, + modulesByOrigin: origins, + ); + if (otherEffective.isValid && + otherEffective.value.toLowerCase() == + newEffective.value.toLowerCase()) { + throw BusyMarkException( + 'writerside.topic-file.web-file-name-collision', + args: { + 'instanceId': instance.id, + 'webFileName': newEffective.value, + 'firstTopic': newTopicFileName, + 'secondTopic': other.topic.fileName, + }, + ); + } + } + result.add( + WritersideTopicRenameUrlChange( + moduleRoot: host.rootPath, + instanceId: instance.id, + oldWebFileName: oldEffective.value, + newWebFileName: newEffective.value, + ), + ); + } + } + return List.unmodifiable(result); + } + List<_TreeEdit> _renameProjectReferenceEdits( List<_ReferenceModuleContext> contexts, { required _MutationContext target, @@ -1869,8 +2127,18 @@ class WritersideTopicFileEditor { ); } final oldId = p.basenameWithoutExtension(path); - if (root.getAttribute('id') != oldId) { - return _RenamedTopicSource(source: source, updatedXmlTopicId: false); + final currentId = root.getAttribute('id'); + if (currentId == null || currentId.isEmpty) { + throw BusyMarkException( + 'writerside.topic-file.missing-root-id', + args: {'path': path}, + ); + } + if (currentId != oldId) { + throw BusyMarkException( + 'writerside.topic-file.root-id-mismatch', + args: {'path': path, 'id': currentId, 'expectedId': oldId}, + ); } root.setAttribute('id', p.basenameWithoutExtension(newFileName)); return _RenamedTopicSource( @@ -2144,6 +2412,55 @@ class WritersideTopicFileEditor { } } + Future _ensurePreparedPlanUnchanged( + WritersideTopicRenamePlan plan, + ) async { + final prepared = plan._prepared; + for (final context in prepared.referenceContexts) { + final configuration = await _configurationSources( + context.module.rootPath, + ); + if (!_sameStringMap(configuration, context.configurationSources)) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': context.module.config.filePath}, + ); + } + for (final tree in context.trees) { + final resolved = await _resolvePath( + context.anchor, + tree.path, + allowRoot: false, + ); + if (resolved.type != FileSystemEntityType.file || + await File(resolved.path).readAsString() != tree.source) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': tree.path}, + ); + } + } + final currentTopics = await _topicSources(context.anchor, context.module); + if (!_sameStringMap(currentTopics, context.topicSources)) { + throw BusyMarkException( + 'writerside.topic-file.topic-inventory-changed', + args: {'path': context.module.rootPath}, + ); + } + } + final destination = await _resolvePath( + prepared.target.anchor, + plan.newTopicPath, + allowRoot: false, + ); + if (destination.type != FileSystemEntityType.notFound) { + throw BusyMarkException( + 'writerside.topic-file.target-exists', + args: {'path': destination.path}, + ); + } + } + Future _renameTargetIsSafeToCleanUp( _MutationContext context, { required String targetPath, @@ -2216,29 +2533,11 @@ class WritersideTopicFileEditor { } String _safeRenamedFileName(String value, {required String oldPath}) { - final fileName = value.trim(); - if (fileName.isEmpty || - fileName == '.' || - fileName == '..' || - p.isAbsolute(fileName) || - fileName.contains('/') || - fileName.contains(r'\') || - fileName.contains('..')) { - throw const BusyMarkException('writerside.topic-file.file-name-unsafe'); - } final oldExtension = p.extension(oldPath).toLowerCase(); - if (!{'.md', '.markdown', '.topic'}.contains(oldExtension) || - p.extension(fileName).toLowerCase() != oldExtension) { - throw BusyMarkException( - 'writerside.topic-file.file-extension-mismatch', - args: {'extension': oldExtension}, - ); - } - final id = p.basenameWithoutExtension(fileName); - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id)) { - throw const BusyMarkException('writerside.topic-file.file-name-invalid'); - } - return fileName; + return validateWritersideTopicFileName( + value, + requiredExtension: oldExtension, + ); } String _renamedTopicFileName(String oldFileName, String newFileName) { @@ -2445,6 +2744,20 @@ class _RenamedTopicSource { final bool updatedXmlTopicId; } +class _PreparedTopicRename { + const _PreparedTopicRename({ + required this.target, + required this.referenceContexts, + required this.targetSource, + required this.publishedEdits, + }); + + final _MutationContext target; + final List<_ReferenceModuleContext> referenceContexts; + final String targetSource; + final List<_TreeEdit> publishedEdits; +} + bool _sameStringMap(Map first, Map second) { if (first.length != second.length) { return false; diff --git a/lib/src/writerside/writerside_topic_file_name.dart b/lib/src/writerside/writerside_topic_file_name.dart new file mode 100644 index 00000000..3b2a8da7 --- /dev/null +++ b/lib/src/writerside/writerside_topic_file_name.dart @@ -0,0 +1,68 @@ +import 'package:path/path.dart' as p; + +import '../core/busymark_exception.dart'; + +const writersideTopicExtensions = {'.md', '.markdown', '.topic'}; + +/// Applies the shared Writerside topic filename policy without permitting a +/// filename to become a path or a rename to change topic format. +String validateWritersideTopicFileName( + String value, { + String? requiredExtension, + String unsafeCode = 'writerside.topic-file.file-name-unsafe', + String invalidCode = 'writerside.topic-file.file-name-invalid', + String extensionCode = 'writerside.topic-file.file-extension-mismatch', +}) { + final fileName = value.trim(); + if (fileName.isEmpty || + fileName == '.' || + fileName == '..' || + p.isAbsolute(fileName) || + fileName.contains('/') || + fileName.contains(r'\') || + fileName.contains('\u0000')) { + throw BusyMarkException(unsafeCode); + } + final extension = p.extension(fileName).toLowerCase(); + if (!writersideTopicExtensions.contains(extension) || + (requiredExtension != null && extension != requiredExtension)) { + throw BusyMarkException( + extensionCode, + args: {'extension': requiredExtension ?? extension}, + ); + } + final id = p.basenameWithoutExtension(fileName); + if (!isValidWritersideTopicId(id)) { + throw BusyMarkException(invalidCode); + } + return fileName; +} + +bool isValidWritersideTopicId(String value) => + RegExp(r'^[\p{L}\p{N}_-]+$', unicode: true).hasMatch(value) && + !_windowsReservedTopicIds.contains(value.toUpperCase()); + +const _windowsReservedTopicIds = { + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +}; diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index 5bdec2d2..486c09fd 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -13,6 +13,7 @@ import '../core/source_span.dart'; import '../core/uri_utils.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; +import 'writerside_web_file_name.dart'; enum WritersideTopicRemovalMode { removeFromInstance, safeDeleteFile } @@ -313,11 +314,7 @@ class WritersideTopicRemovalService { topicPath: snapshot.topic.filePath, topicFileName: snapshot.topic.fileName, topicTitle: snapshot.topic.title, - oldWebFileName: _oldWebFileName( - snapshot.topic, - disablePreprocessing: - snapshot.module.config.settings.disableWebNamePreprocessing == true, - ), + oldWebFileName: _effectiveWebFileName(snapshot, preferredTree), selectedTreePath: selectedTree, selectedNodePath: selectedNodePath, childCount: childCount, @@ -498,6 +495,7 @@ class WritersideTopicRemovalService { snapshot, element, analysis.oldWebFileName, + treePath: entry.key, )), ); if (conflictingRedirect) { @@ -515,6 +513,7 @@ class WritersideTopicRemovalService { snapshot, entry.value, analysis.oldWebFileName, + treePath: entry.key, )) { continue; } @@ -1239,8 +1238,9 @@ class WritersideTopicRemovalService { bool _elementPublishesWebFileName( _RemovalSnapshot snapshot, XmlElement element, - String webFileName, - ) { + String webFileName, { + required String treePath, + }) { final reference = element.getAttribute('topic'); if (reference == null) { return false; @@ -1249,15 +1249,50 @@ class WritersideTopicRemovalService { if (matches.length != 1) { return false; } - return _oldWebFileName( - matches.single, - disablePreprocessing: - snapshot.module.config.settings.disableWebNamePreprocessing == - true, - ) == + return _effectiveWebFileName(snapshot, treePath, topic: matches.single) == webFileName; } + String _effectiveWebFileName( + _RemovalSnapshot snapshot, + String? treePath, { + WritersideTopic? topic, + }) { + final instance = snapshot.module.instances + .where( + (candidate) => + treePath != null && p.equals(candidate.sourceTreePath, treePath), + ) + .firstOrNull; + final effectiveInstance = + instance ?? + snapshot.module.instances + .where((value) => !value.isLibrary) + .firstOrNull ?? + snapshot.module.instances.firstOrNull; + final target = topic ?? snapshot.topic; + if (effectiveInstance == null) { + return WritersideWebFileNameResolver.defaultName( + target.fileName, + disablePreprocessing: + snapshot.module.config.settings.disableWebNamePreprocessing == true, + ); + } + return const WritersideWebFileNameResolver() + .resolve( + module: snapshot.module, + topic: target, + instance: effectiveInstance, + modulesByOrigin: { + (snapshot.module.config.moduleName?.trim().isNotEmpty == true + ? snapshot.module.config.moduleName!.trim() + : p.basename(snapshot.module.rootPath)): + snapshot.module, + }, + ) + .value; + } + bool _canRewriteLink( _RemovalSnapshot snapshot, WritersideTopic topic, @@ -1811,28 +1846,6 @@ bool _samePath(List first, List second) { String _referenceWithoutAnchor(String destination) => destination.split('#').first.split('?').first; -String _oldWebFileName( - WritersideTopic topic, { - required bool disablePreprocessing, -}) { - final explicit = topic.webFileName?.trim(); - if (explicit != null && explicit.isNotEmpty) { - return explicit; - } - final rawBase = p.basenameWithoutExtension(topic.fileName).trim(); - final base = disablePreprocessing - ? _replaceUnsafeWebFileNameCharacters(rawBase) - : rawBase - .toLowerCase() - .replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), '-') - .replaceAll(RegExp(r'^-+|-+$'), ''); - return '$base.html'; -} - -String _replaceUnsafeWebFileNameCharacters(String value) => value - .replaceAll(RegExp(r'[^\p{L}\p{N}._~-]+', unicode: true), '-') - .replaceAll(RegExp(r'^-+|-+$'), ''); - String _xmlSource(XmlDocument document) => '${document.toXmlString(pretty: true, indent: ' ')}\n'; diff --git a/lib/src/writerside/writerside_web_file_name.dart b/lib/src/writerside/writerside_web_file_name.dart new file mode 100644 index 00000000..3f90a00b --- /dev/null +++ b/lib/src/writerside/writerside_web_file_name.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; + +import 'package:path/path.dart' as p; + +import '../core/diagnostic.dart'; +import '../core/source_span.dart'; +import 'writerside_document.dart'; +import 'writerside_document_resolver.dart'; +import 'writerside_model.dart'; + +class WritersideEffectiveWebFileName { + const WritersideEffectiveWebFileName({ + required this.value, + required this.custom, + this.sourceSpan, + }); + + final String value; + final bool custom; + final SourceSpan? sourceSpan; + + bool get isValid => WritersideWebFileNameResolver.isValid(value); +} + +class WritersidePublishedTopic { + const WritersidePublishedTopic({ + required this.hostModule, + required this.sourceModule, + required this.instance, + required this.topic, + }); + + final WritersideModule hostModule; + final WritersideModule sourceModule; + final WritersideInstance instance; + final WritersideTopic topic; + + String get identity => '${sourceModule.rootPath}\u0000${topic.filePath}'; +} + +class WritersideWebFileNameResolver { + const WritersideWebFileNameResolver({ + this.documentResolver = const WritersideDocumentResolver(), + }); + + final WritersideDocumentResolver documentResolver; + + WritersideEffectiveWebFileName resolve({ + required WritersideModule module, + required WritersideTopic topic, + required WritersideInstance instance, + required Map modulesByOrigin, + String? topicFileName, + }) { + final resolved = documentResolver.resolve( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: modulesByOrigin, + ), + ); + final scope = resolved.document.format == WritersideDocumentFormat.xmlTopic + ? resolved.document.rootElement?.children ?? + const [] + : resolved.document.nodes; + final authored = scope + .whereType() + .where((element) => element.name == 'web-file-name') + .firstOrNull; + final custom = authored?.plainText.trim(); + if (custom != null && custom.isNotEmpty) { + return WritersideEffectiveWebFileName( + value: custom, + custom: true, + sourceSpan: authored!.span, + ); + } + return WritersideEffectiveWebFileName( + value: defaultName( + topicFileName ?? topic.fileName, + disablePreprocessing: + module.config.settings.disableWebNamePreprocessing == true, + ), + custom: false, + ); + } + + static String defaultName( + String topicFileName, { + required bool disablePreprocessing, + }) { + final rawBase = p.basenameWithoutExtension(topicFileName).trim(); + final base = disablePreprocessing + ? rawBase + .replaceAll(RegExp(r'[^\p{L}\p{N}._~-]+', unicode: true), '-') + .replaceAll(RegExp(r'^-+|-+$'), '') + : rawBase + .toLowerCase() + .replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + return '$base.html'; + } + + static bool isValid(String name) => + name.isNotEmpty && + utf8.encode(name).length <= 240 && + name.toLowerCase().endsWith('.html') && + !name.startsWith('.') && + !RegExp(r'[/\\\x00-\x1f\x7f?#:%]').hasMatch(name) && + p.basename(name) == name; +} + +List writersidePublishedTopicsForInstance({ + required WritersideModule hostModule, + required WritersideInstance instance, + required Map modulesByOrigin, +}) { + final result = []; + final seen = {}; + + void add(String? reference, WritersideModule sourceModule) { + if (reference == null) return; + final topic = sourceModule.topicByReference(reference); + if (topic == null) return; + final published = WritersidePublishedTopic( + hostModule: hostModule, + sourceModule: sourceModule, + instance: instance, + topic: topic, + ); + if (seen.add(published.identity)) result.add(published); + } + + void visit(TocNode node, WritersideModule inheritedModule) { + final sourceModule = node.origin == null + ? inheritedModule + : modulesByOrigin[node.origin]; + if (sourceModule == null) return; + if (node.referenceInstanceId == null || + node.referenceInstanceId == instance.id) { + add(node.topicReference, sourceModule); + } + for (final child in node.children) { + visit(child, sourceModule); + } + } + + add(instance.startPage, hostModule); + for (final root in instance.navigationTocRoots) { + visit(root, hostModule); + } + return List.unmodifiable(result); +} + +List writersideWebFileNameDiagnostics( + List modules, +) { + final diagnostics = []; + final origins = { + for (final module in modules) + (module.config.moduleName?.trim().isNotEmpty == true + ? module.config.moduleName!.trim() + : p.basename(module.rootPath)): + module, + }; + const resolver = WritersideWebFileNameResolver(); + for (final host in modules) { + for (final instance in host.instances.where((value) => !value.isLibrary)) { + final byName = + < + String, + (WritersidePublishedTopic, WritersideEffectiveWebFileName) + >{}; + for (final published in writersidePublishedTopicsForInstance( + hostModule: host, + instance: instance, + modulesByOrigin: origins, + )) { + final effective = resolver.resolve( + module: published.sourceModule, + topic: published.topic, + instance: instance, + modulesByOrigin: origins, + ); + if (!effective.isValid) { + diagnostics.add( + Diagnostic( + code: 'writerside.web-file-name.invalid', + severity: DiagnosticSeverity.error, + filePath: published.topic.filePath, + args: { + 'instanceId': instance.id, + 'webFileName': effective.value, + 'topic': published.topic.fileName, + }, + sourceSpan: effective.sourceSpan, + ), + ); + continue; + } + final key = effective.value.toLowerCase(); + final previous = byName[key]; + if (previous == null) { + byName[key] = (published, effective); + continue; + } + diagnostics.add( + Diagnostic( + code: 'writerside.web-file-name.collision', + severity: DiagnosticSeverity.error, + filePath: published.topic.filePath, + args: { + 'instanceId': instance.id, + 'webFileName': effective.value, + 'firstTopic': previous.$1.topic.fileName, + 'secondTopic': published.topic.fileName, + }, + sourceSpan: effective.sourceSpan, + relatedSpans: [ + previous.$2.sourceSpan ?? + SourceSpan.entireFile(previous.$1.topic.filePath, ''), + ], + ), + ); + } + } + } + return sortDiagnostics(diagnostics); +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/linux/runner/writerside_dialog_host.cc b/linux/runner/writerside_dialog_host.cc index 4466823a..6c949723 100644 --- a/linux/runner/writerside_dialog_host.cc +++ b/linux/runner/writerside_dialog_host.cc @@ -68,12 +68,27 @@ GtkWidget* wrapping_explanation(const gchar* text) { } gboolean has_only_identifier_characters(const gchar* value) { - if (value == nullptr || value[0] == '\0') { + if (value == nullptr || value[0] == '\0' || + !g_utf8_validate(value, -1, nullptr)) { return FALSE; } - for (const guchar* current = reinterpret_cast(value); - *current != '\0'; ++current) { - if (!g_ascii_isalnum(*current) && *current != '_' && *current != '-') { + for (const gchar* current = value; *current != '\0'; + current = g_utf8_next_char(current)) { + const gunichar character = g_utf8_get_char(current); + if (!g_unichar_isalnum(character) && character != '_' && + character != '-') { + return FALSE; + } + } + g_autofree gchar* upper = g_ascii_strup(value, -1); + const gchar* reserved[] = { + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", + "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", + "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", + "LPT9", + }; + for (const gchar* candidate : reserved) { + if (std::strcmp(upper, candidate) == 0) { return FALSE; } } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index f5bd0332..6ed4c208 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -19,6 +19,7 @@ import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/app/window_control_service.dart'; +import 'package:busymark/src/core/busymark_exception.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/local_image_resolver.dart'; import 'package:busymark/src/core/source_span.dart'; @@ -48,13 +49,16 @@ import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_toc_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_file_monitor.dart'; +import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter/gestures.dart'; @@ -2725,6 +2729,234 @@ void main() { }, ); + testWidgets( + 'Writerside topic rename uses Preview, Do Refactor, and shared entry points', + (tester) async { + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(yaruWindowChannel, (call) async { + if (call.method == 'state') return {}; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(yaruWindowEventsChannel, (_) async => null); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(yaruWindowChannel, null) + ..setMockMethodCallHandler(yaruWindowEventsChannel, null); + }); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + await binding.setSurfaceSize(const Size(1440, 900)); + addTearDown(() async { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + await binding.setSurfaceSize(null); + }); + final root = Directory.systemTemp.createTempSync( + 'busymark-topic-rename-ui-', + ); + addTearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + final topics = Directory(p.join(root.path, 'topics'))..createSync(); + final configPath = p.join(root.path, 'writerside.cfg'); + final treePath = p.join(root.path, 'guide.tree'); + final topicPath = p.join(topics.path, 'guide.topic'); + final linksPath = p.join(topics.path, 'links.md'); + File(configPath).writeAsStringSync(''' + +'''); + File(treePath).writeAsStringSync(''' + + + + +'''); + const topicSource = + '' + '' + '\n'; + File(topicPath).writeAsStringSync(topicSource); + File(linksPath).writeAsStringSync('# Links\n\n[Guide](guide.topic)\n'); + final workspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + final setupPlan = (await tester.runAsync( + () => const WorkspaceService().prepareWritersideTopicRename( + workspace, + topicPath: topicPath, + newFileName: 'setup.topic', + topicModuleRoot: root.path, + ), + ))!; + final controller = + _MutableWorkspaceController( + WorkspaceState(workspace: workspace, activeText: topicSource), + ) + ..topicRenamePlans['setup.topic'] = setupPlan + ..simulateTopicRename = true; + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var index = 0; index < 10; index++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + Future openPopup(Finder anchor) async { + await tester.tap(anchor, buttons: kSecondaryButton); + await tester.pumpAndSettle(); + } + + Future pressShiftF6() async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f6); + await tester.sendKeyUpEvent(LogicalKeyboardKey.f6); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + } + + Future enterRename(String fileName) async { + final field = find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(TextField), + ); + expect(field, findsOneWidget); + await tester.enterText(field, fileName); + await tester.pump(); + } + + await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.toc)); + await tester.pumpAndSettle(); + final firstRow = find.byKey( + const ValueKey('workspace-sidebar-toc-row-0'), + ); + await tester.tap(firstRow); + await tester.pump(); + + await pressShiftF6(); + expect(find.text(l10n.rename), findsOneWidget); + expect(find.text(l10n.preview), findsOneWidget); + expect(find.text(l10n.tocRefactorMenu), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + expect(File(topicPath).readAsStringSync(), topicSource); + + await openPopup(firstRow); + final renameMenuItem = find.text(l10n.renameTopicFile); + expect(renameMenuItem, findsOneWidget); + await tester.tap(renameMenuItem); + await tester.pumpAndSettle(); + await enterRename('setup.topic'); + await tester.tap(find.text(l10n.preview)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.text(l10n.topicRenamePreviewTitle), + findsOneWidget, + reason: + 'prepared=${controller.topicRenamePrepareCount}, ' + 'error=${controller.state.message?.error}', + ); + expect(find.text(l10n.doRefactor), findsOneWidget); + expect(find.text('guide.topic → setup.topic'), findsOneWidget); + expect(find.text('guide.tree'), findsOneWidget); + expect(find.text('links.md'), findsOneWidget); + expect(File(topicPath).readAsStringSync(), topicSource); + expect(File(p.join(topics.path, 'setup.topic')).existsSync(), isFalse); + + controller.rejectTopicRename = true; + await tester.tap(find.text(l10n.doRefactor)); + await tester.pump(); + expect(find.text(l10n.topicRenamePreviewTitle), findsOneWidget); + expect(File(topicPath).readAsStringSync(), topicSource); + expect(File(p.join(topics.path, 'setup.topic')).existsSync(), isFalse); + + controller.rejectTopicRename = false; + await tester.tap(find.text(l10n.doRefactor)); + await tester.pumpAndSettle(); + expect(controller.appliedTopicRenamePlan, same(setupPlan)); + expect(File(topicPath).readAsStringSync(), topicSource); + expect(File(treePath).readAsStringSync(), contains('guide.topic')); + expect(File(linksPath).readAsStringSync(), contains('(guide.topic)')); + + await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.files)); + await tester.pumpAndSettle(); + controller.appliedTopicRenamePlan = null; + await openPopup(find.text('guide.topic').first); + await tester.tap(find.text(l10n.renameTopicFile)); + await tester.pumpAndSettle(); + await enterRename('setup.topic'); + await tester.tap(find.text(l10n.tocRefactorMenu)); + await tester.pumpAndSettle(); + expect(controller.appliedTopicRenamePlan, same(setupPlan)); + + await openPopup(find.text('guide.tree')); + await tester.tap(find.text(l10n.rename)); + await tester.pumpAndSettle(); + expect(find.text(l10n.preview), findsNothing); + expect(find.text(l10n.rename), findsWidgets); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + + await openPopup(find.text('guide.topic').first); + await tester.tap(find.text(l10n.renameTopicFile)); + await tester.pumpAndSettle(); + expect(find.text(l10n.preview), findsOneWidget); + expect(find.text(l10n.tocRefactorMenu), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + expect(sourceField, findsOneWidget); + await tester.tap(sourceField); + final sourceController = tester + .widget(sourceField) + .controller!; + sourceController.selection = TextSelection.collapsed( + offset: topicSource.indexOf('guide') + 1, + ); + await pressShiftF6(); + expect(find.text(l10n.renameTopicFileInstead), findsOneWidget); + expect(controller.state.activeText, topicSource); + expect(File(topicPath).readAsStringSync(), topicSource); + await tester.tap(find.text(l10n.close)); + await tester.pumpAndSettle(); + + await tester.tap(sourceField); + sourceController.selection = TextSelection.collapsed( + offset: topicSource.indexOf('install') + 1, + ); + await pressShiftF6(); + expect(find.text('${l10n.rename}: install'), findsOneWidget); + await tester.enterText(find.byType(TextFormField), 'renamed-install'); + await tester.tap(find.text(l10n.rename)); + await tester.pumpAndSettle(); + expect(controller.state.activeText, contains('id="renamed-install"')); + expect(controller.state.activeText, contains('id="guide"')); + }, + ); + testWidgets('tab keyboard shortcuts move and close editor tabs', ( tester, ) async { @@ -10633,6 +10865,12 @@ class _MutableWorkspaceController extends WorkspaceController { String? selectedWritersideInstanceId; String? openedFilePath; DocumentViewModePreference? requestedEditorMode; + var topicRenamePrepareCount = 0; + final topicRenamePlans = {}; + Future? topicRenameApplication; + var simulateTopicRename = false; + var rejectTopicRename = false; + WritersideTopicRenamePlan? appliedTopicRenamePlan; @override void updateActiveEditorMode(DocumentViewModePreference mode) { @@ -10656,6 +10894,42 @@ class _MutableWorkspaceController extends WorkspaceController { @override Future flushPersistence() async {} + @override + Future writersideEditorIndex() async => + state.workspace?.writersideProject?.index; + + @override + Future applyWritersideRename(List edits) async { + if (edits.isEmpty) return false; + final activePath = state.workspace?.activeFilePath; + if (activePath == null || + edits.any((edit) => edit.filePath != activePath)) { + return false; + } + var source = state.activeText; + final ordered = edits.toList() + ..sort( + (first, second) => + second.span.startOffset.compareTo(first.span.startOffset), + ); + for (final edit in ordered) { + final expected = edit.expectedText; + if (expected == null || + edit.span.endOffset > source.length || + source.substring(edit.span.startOffset, edit.span.endOffset) != + expected) { + return false; + } + source = source.replaceRange( + edit.span.startOffset, + edit.span.endOffset, + edit.replacement, + ); + } + state = state.copyWith(activeText: source, isDirty: true); + return true; + } + @override Future validateActive() async => ValidationOutcome( status: ValidationStatus.published, @@ -10687,6 +10961,81 @@ class _MutableWorkspaceController extends WorkspaceController { return true; } + @override + Future prepareWritersideTopicRename( + String topicPath, + String newFileName, { + required String topicModuleRoot, + }) async { + topicRenamePrepareCount++; + final prepared = topicRenamePlans[newFileName]; + if (prepared != null) return prepared; + try { + return await const WorkspaceService().prepareWritersideTopicRename( + state.workspace!, + topicPath: topicPath, + newFileName: newFileName, + topicModuleRoot: topicModuleRoot, + ); + } on Object catch (error) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: error, + ), + ); + return null; + } + } + + @override + Future applyWritersideTopicRename(WritersideTopicRenamePlan plan) { + appliedTopicRenamePlan = plan; + if (rejectTopicRename) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: BusyMarkException( + 'writerside.topic-file.topic-inventory-changed', + args: {'path': plan.oldTopicPath}, + ), + ), + ); + return Future.value(false); + } + if (simulateTopicRename) return Future.value(true); + final operation = _applyWritersideTopicRename(plan); + topicRenameApplication = operation; + return operation; + } + + Future _applyWritersideTopicRename( + WritersideTopicRenamePlan plan, + ) async { + try { + await const WorkspaceService().applyWritersideTopicRename(plan); + final refreshed = await const WorkspaceService().openPath( + state.workspace!.rootPath, + ); + final activePath = refreshed.activeFilePath; + state = WorkspaceState( + workspace: refreshed, + activeText: activePath == null + ? '' + : File(activePath).readAsStringSync(), + ); + return true; + } on Object catch (error) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: error, + ), + ); + return false; + } + } + @override Future moveWritersideTocEntry({ required String treePath, diff --git a/test/src/html_publication_test.dart b/test/src/html_publication_test.dart index 8c6ee2f1..0d4c333c 100644 --- a/test/src/html_publication_test.dart +++ b/test/src/html_publication_test.dart @@ -363,6 +363,49 @@ void main() { ); }, ); + + test( + 'Writerside publication uses canonical effective web filenames', + () async { + await module('module', 'docs', ''' + + + + + +'''); + await put( + 'module/topics/Document_everything.topic', + '', + ); + await put( + 'module/topics/My___Awesome.topic', + '', + ); + await put('module/topics/custom.topic', ''' + + Keep_CASE.v2.html + +'''); + + await site(); + + expect( + File( + p.join(root.path, 'site', 'document-everything.html'), + ).existsSync(), + isTrue, + ); + expect( + File(p.join(root.path, 'site', 'my-awesome.html')).existsSync(), + isTrue, + ); + expect( + File(p.join(root.path, 'site', 'Keep_CASE.v2.html')).existsSync(), + isTrue, + ); + }, + ); test( 'source and media are captured before progress or later editor changes', () async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index ef8c9f0d..090e00d8 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -21,6 +21,7 @@ import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -712,10 +713,18 @@ void main() { expect(first.childElements.single.getAttribute('topic'), 'secondary.md'); expect(controller.state.workspace?.activeFilePath, startPath); + final renamePlan = await controller.prepareWritersideTopicRename( + secondaryPath, + 'renamed.md', + topicModuleRoot: rootPath, + ); + expect(renamePlan, isNotNull); + expect(File(secondaryPath).existsSync(), isTrue); expect( - await controller.renameWritersideTopicFile(secondaryPath, 'renamed.md'), - isTrue, + File(p.join(rootPath, 'topics', 'renamed.md')).existsSync(), + isFalse, ); + expect(await controller.applyWritersideTopicRename(renamePlan!), isTrue); final renamedPath = p.join(rootPath, 'topics', 'renamed.md'); tree = XmlDocument.parse(File(treePath).readAsStringSync()); first = tree.rootElement.childElements @@ -3249,6 +3258,19 @@ class _WorkspaceControllerDriver { String newFileName, ) => _notifier.renameWritersideTopicFile(topicPath, newFileName); + Future prepareWritersideTopicRename( + String topicPath, + String newFileName, { + required String topicModuleRoot, + }) => _notifier.prepareWritersideTopicRename( + topicPath, + newFileName, + topicModuleRoot: topicModuleRoot, + ); + + Future applyWritersideTopicRename(WritersideTopicRenamePlan plan) => + _notifier.applyWritersideTopicRename(plan); + Future deleteWritersideTopicFile(String topicPath) => _notifier.deleteWritersideTopicFile(topicPath); diff --git a/test/src/writerside_project_test.dart b/test/src/writerside_project_test.dart index 05b5fdd0..4045c835 100644 --- a/test/src/writerside_project_test.dart +++ b/test/src/writerside_project_test.dart @@ -91,6 +91,27 @@ void main() { }, ); + test( + 'topic symbols cannot be renamed independently of their files', + () async { + final fixture = await _ProjectFixture.create(); + addTearDown(fixture.dispose); + final project = await const WritersideProjectService().load(fixture.path); + final topics = project.index.symbols.where( + (symbol) => + symbol.kind == WritersideSymbolKind.topic && + symbol.filePath.endsWith( + 'main${p.separator}topics${p.separator}main.topic', + ), + ); + + expect(topics, isNotEmpty); + for (final topic in topics) { + expect(project.index.safeRenameEdits(topic, 'renamed-main'), isEmpty); + } + }, + ); + test( 'workspace module and instance selection changes active context', () async { diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index eb52b935..76317b9b 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -6,6 +6,7 @@ import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/writerside/writerside_document.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_parsers.dart'; @@ -162,6 +163,75 @@ void main() { expect(xmlTopic.webFileName, 'install-page.html'); }); + test('Markdown web filename metadata ignores literal examples', () { + const parser = WritersideTopicParser(); + final real = parser.parseMarkdown( + filePath: '/tmp/topics/real.md', + topicsRoot: '/tmp/topics', + source: ''' +# Real + +linux-guide.html +windows-guide.html +''', + ); + final fenced = parser.parseMarkdown( + filePath: '/tmp/topics/fenced.md', + topicsRoot: '/tmp/topics', + source: r''' +# Fenced + +```xml +example.html +``` +''', + ); + final literal = parser.parseMarkdown( + filePath: '/tmp/topics/literal.md', + topicsRoot: '/tmp/topics', + source: ''' +# Literal + + + +`inline.html` +''', + ); + + expect( + real.document.nodes + .whereType() + .where((element) => element.name == 'web-file-name') + .map((element) => element.attributes['instance']), + ['linux', 'windows'], + ); + expect(real.webFileName, isNull); + expect(fenced.webFileName, isNull); + expect(literal.webFileName, isNull); + }); + + test('XML topic metadata is scoped to direct topic children', () { + final topic = const WritersideTopicParser().parseXml( + filePath: '/tmp/topics/guide.topic', + topicsRoot: '/tmp/topics', + source: ''' + + Desktop Guide + guide-exact.html + + Install on Linux + nested-metadata.html + + +''', + ); + + expect(topic.titleOverrides, hasLength(1)); + expect(topic.titleOverrides.single.instance, 'desktop'); + expect(topic.titleOverrides.single.title, 'Desktop Guide'); + expect(topic.webFileName, 'guide-exact.html'); + }); + test('parses .tree metadata and TOC hierarchy', () { final path = 'test/fixtures/writerside/basic_project/user-guide.tree'; final instance = treeParser.parse(path, File(path).readAsStringSync()); diff --git a/test/src/writerside_topic_creator_test.dart b/test/src/writerside_topic_creator_test.dart index 64cec20c..8636ac99 100644 --- a/test/src/writerside_topic_creator_test.dart +++ b/test/src/writerside_topic_creator_test.dart @@ -73,6 +73,42 @@ void main() { }, ); + test('creates Unicode Markdown and XML topic filenames', () async { + final root = await tempModule(); + final target = WritersideTopicCreateTarget( + rootPath: root.path, + treePath: p.join(root.path, 'ug.tree'), + topicsRootDir: 'topics', + existingTopicIds: const {'intro'}, + ); + + final markdown = await creator.create( + target, + const WritersideTopicCreateRequest( + title: 'Руководство', + fileName: 'Руководство', + ), + ); + final xml = await creator.create( + target, + const WritersideTopicCreateRequest( + title: '開始', + fileName: '開始.topic', + format: WritersideTopicFormat.xml, + ), + ); + + expect(p.basename(markdown.topicPath), 'Руководство.md'); + expect(File(markdown.topicPath).existsSync(), isTrue); + expect(p.basename(xml.topicPath), '開始.topic'); + expect( + XmlDocument.parse( + File(xml.topicPath).readAsStringSync(), + ).rootElement.getAttribute('id'), + '開始', + ); + }); + test('requires a reference for every non-root placement', () async { final root = await tempModule(); diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index cc187e5a..887a0108 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -910,6 +910,313 @@ Following-line destination: [Guide][next]. ]); }); + test('missing or mismatched XML root IDs block rename atomically', () async { + for (final source in [ + '

    Missing.

    \n', + '

    Mismatch.

    \n', + ]) { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: {'guide.topic': source}, + ); + final treePath = p.join(fixture.root.path, 'guide.tree'); + final originalTree = File(treePath).readAsStringSync(); + + await expectLater( + editor.prepareRename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.topic'), + newFileName: 'renamed.topic', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + source.contains('wrong') + ? 'writerside.topic-file.root-id-mismatch' + : 'writerside.topic-file.missing-root-id', + ), + ), + ); + + expect(File(treePath).readAsStringSync(), originalTree); + expect( + File( + p.join(fixture.root.path, 'topics', 'guide.topic'), + ).readAsStringSync(), + source, + ); + expect( + File(p.join(fixture.root.path, 'topics', 'renamed.topic')).existsSync(), + isFalse, + ); + } + }); + + test( + 'prepared rename is read-only and applies its exact captured changes', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + topics: { + 'guide.topic': '\n', + 'links.md': '# Links\n\n[Guide](guide.topic)\n', + }, + ); + final topic = _topic(fixture.module, 'guide.topic'); + final treePath = p.join(fixture.root.path, 'guide.tree'); + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + final originalTree = File(treePath).readAsStringSync(); + final originalTopic = File(topic.filePath).readAsStringSync(); + final originalLinks = File(linksPath).readAsStringSync(); + + final plan = await editor.prepareRename( + module: fixture.module, + topic: topic, + newFileName: 'setup.topic', + ); + + expect(File(topic.filePath).readAsStringSync(), originalTopic); + expect(File(treePath).readAsStringSync(), originalTree); + expect(File(linksPath).readAsStringSync(), originalLinks); + expect(File(plan.newTopicPath).existsSync(), isFalse); + expect(plan.oldTopicReference, 'guide.topic'); + expect(plan.newTopicReference, 'setup.topic'); + expect(plan.oldTopicId, 'guide'); + expect(plan.newTopicId, 'setup'); + expect(plan.updatesXmlTopicId, isTrue); + expect( + plan.changedFiles.map((change) => change.path), + containsAll([treePath, linksPath]), + ); + + await editor.applyRename(plan); + + expect(File(topic.filePath).existsSync(), isFalse); + expect( + File(plan.newTopicPath).readAsStringSync(), + contains('id="setup"'), + ); + expect( + File(treePath).readAsStringSync(), + contains('topic="setup.topic"'), + ); + expect(File(linksPath).readAsStringSync(), contains('(setup.topic)')); + }, + ); + + test('stale prepared rename fails before any partial write', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'links.md': '# Links\n\n[Guide](guide.md)\n', + }, + ); + final topic = _topic(fixture.module, 'guide.md'); + final treePath = p.join(fixture.root.path, 'guide.tree'); + final linksPath = p.join(fixture.root.path, 'topics', 'links.md'); + final originalTree = File(treePath).readAsStringSync(); + final plan = await editor.prepareRename( + module: fixture.module, + topic: topic, + newFileName: 'setup.md', + ); + const externallyChanged = '# Externally changed\n\n[Guide](guide.md)\n'; + File(linksPath).writeAsStringSync(externallyChanged); + + await expectLater( + editor.applyRename(plan), + throwsA(isA()), + ); + + expect(File(topic.filePath).readAsStringSync(), '# Guide\n'); + expect(File(plan.newTopicPath).existsSync(), isFalse); + expect(File(treePath).readAsStringSync(), originalTree); + expect(File(linksPath).readAsStringSync(), externallyChanged); + }); + + test('rename preflights collisions only in publishing instances', () async { + final colliding = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'custom.topic': ''' +setup.html +''', + }, + ); + + await expectLater( + editor.prepareRename( + module: colliding.module, + topic: _topic(colliding.module, 'guide.md'), + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.web-file-name-collision', + ), + ), + ); + expect( + File(p.join(colliding.root.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + + final disjoint = await _fixture( + trees: { + 'guide.tree': _instanceTree('guide', ['guide.md']), + 'other.tree': _instanceTree('other', ['custom.topic']), + }, + topics: { + 'guide.md': '# Guide\n', + 'custom.topic': ''' +setup.html +''', + }, + ); + final result = await editor.rename( + module: disjoint.module, + topic: _topic(disjoint.module, 'guide.md'), + newFileName: 'setup.md', + ); + expect(File(result.newTopicPath).existsSync(), isTrue); + }); + + test( + 'origin topic collisions block a project-wide prepared rename', + () async { + final project = await Directory.systemTemp.createTemp( + 'busymark-topic-rename-origin-', + ); + addTearDown(() async { + if (await project.exists()) await project.delete(recursive: true); + }); + final mainRoot = Directory(p.join(project.path, 'main'))..createSync(); + final sharedRoot = Directory(p.join(project.path, 'shared')) + ..createSync(); + Directory(p.join(mainRoot.path, 'topics')).createSync(); + Directory(p.join(sharedRoot.path, 'topics')).createSync(); + File(p.join(mainRoot.path, 'writerside.cfg')).writeAsStringSync(''' + +'''); + File(p.join(mainRoot.path, 'guide.tree')).writeAsStringSync(''' + + + + +'''); + File( + p.join(mainRoot.path, 'topics', 'guide.md'), + ).writeAsStringSync('# Guide\n'); + File(p.join(sharedRoot.path, 'writerside.cfg')).writeAsStringSync(''' + +'''); + File(p.join(sharedRoot.path, 'lib.tree')).writeAsStringSync(''' + + + +'''); + File(p.join(sharedRoot.path, 'topics', 'shared.topic')).writeAsStringSync( + ''' +setup.html +''', + ); + final main = await const WritersideModuleService().load(mainRoot.path); + final shared = await const WritersideModuleService().load( + sharedRoot.path, + ); + + await expectLater( + editor.prepareRename( + module: main, + topic: _topic(main, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.web-file-name-collision', + ), + ), + ); + expect( + File(p.join(mainRoot.path, 'topics', 'guide.md')).existsSync(), + isTrue, + ); + expect( + File(p.join(mainRoot.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }, + ); + + test('Unicode Markdown and XML topic filenames rename safely', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': _instanceTree('guide', ['Начало.md', '開始.topic']), + }, + topics: { + 'Начало.md': '# Начало\n', + '開始.topic': '\n', + }, + ); + + final markdown = await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'Начало.md'), + newFileName: 'Руководство.md', + ); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + final xml = await editor.rename( + module: reloaded, + topic: _topic(reloaded, '開始.topic'), + newFileName: '指南.topic', + ); + + expect(File(markdown.newTopicPath).existsSync(), isTrue); + expect( + XmlDocument.parse( + File(xml.newTopicPath).readAsStringSync(), + ).rootElement.getAttribute('id'), + '指南', + ); + }); + test('rename supports Writerside .markdown topics', () async { final fixture = await _fixture( trees: { @@ -1578,3 +1885,10 @@ List _directTopicReferences(XmlElement element) { if (child.name.local == 'toc-element') child.getAttribute('topic')!, ]; } + +String _instanceTree(String id, List topics) => + ''' + +${[for (final topic in topics) ' '].join('\n')} + +'''; diff --git a/test/src/writerside_web_file_name_test.dart b/test/src/writerside_web_file_name_test.dart new file mode 100644 index 00000000..fe5a797d --- /dev/null +++ b/test/src/writerside_web_file_name_test.dart @@ -0,0 +1,384 @@ +import 'dart:io'; + +import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/writerside/writerside_web_file_name.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('canonical Writerside web filenames', () { + test('normalizes runs, edges, case, and Unicode once', () { + expect( + WritersideWebFileNameResolver.defaultName( + 'Document_everything.topic', + disablePreprocessing: false, + ), + 'document-everything.html', + ); + expect( + WritersideWebFileNameResolver.defaultName( + 'My___Awesome.topic', + disablePreprocessing: false, + ), + 'my-awesome.html', + ); + expect( + WritersideWebFileNameResolver.defaultName( + '_-Guide--_.topic', + disablePreprocessing: false, + ), + 'guide.html', + ); + expect( + WritersideWebFileNameResolver.defaultName( + 'Руководство_開始.topic', + disablePreprocessing: false, + ), + 'руководство-開始.html', + ); + }); + + test('disabled preprocessing preserves established safe characters', () { + expect( + WritersideWebFileNameResolver.defaultName( + 'Document_everything.topic', + disablePreprocessing: true, + ), + 'Document_everything.html', + ); + expect( + WritersideWebFileNameResolver.defaultName( + 'Guide name.topic', + disablePreprocessing: true, + ), + 'Guide-name.html', + ); + }); + + test( + 'resolves exact custom values per instance for Markdown and XML', + () async { + final root = await _module( + trees: { + 'linux.tree': _tree('linux', [ + 'conditional.md', + 'conditional.topic', + ]), + 'windows.tree': _tree('windows', [ + 'conditional.md', + 'conditional.topic', + ]), + }, + topics: { + 'conditional.md': ''' +# Markdown + +Keep_CASE.v2.html +windows-markdown.html +''', + 'conditional.topic': ''' + + linux-xml.html + Windows_Exact.HTML + +''', + }, + ); + final module = await const WritersideModuleService().load(root.path); + final instances = { + for (final value in module.instances) value.id: value, + }; + final modules = {'docs': module}; + const resolver = WritersideWebFileNameResolver(); + + expect( + resolver + .resolve( + module: module, + topic: module.topicByReference('conditional.md')!, + instance: instances['linux']!, + modulesByOrigin: modules, + ) + .value, + 'Keep_CASE.v2.html', + ); + expect( + resolver + .resolve( + module: module, + topic: module.topicByReference('conditional.md')!, + instance: instances['windows']!, + modulesByOrigin: modules, + ) + .value, + 'windows-markdown.html', + ); + expect( + resolver + .resolve( + module: module, + topic: module.topicByReference('conditional.topic')!, + instance: instances['linux']!, + modulesByOrigin: modules, + ) + .value, + 'linux-xml.html', + ); + expect( + resolver + .resolve( + module: module, + topic: module.topicByReference('conditional.topic')!, + instance: instances['windows']!, + modulesByOrigin: modules, + ) + .value, + 'Windows_Exact.HTML', + ); + }, + ); + }); + + group('project instance URL diagnostics', () { + test('diagnoses default/default collision', () async { + final root = await _module( + trees: { + 'guide.tree': _tree('guide', ['Guide_A.topic', 'Guide-A.topic']), + }, + topics: { + 'Guide_A.topic': '', + 'Guide-A.topic': '', + }, + ); + + expect( + await _diagnosticCodes(root.path), + contains('writerside.web-file-name.collision'), + ); + }); + + test('diagnoses default/custom collision', () async { + final root = await _module( + trees: { + 'guide.tree': _tree('guide', ['setup.topic', 'other.topic']), + }, + topics: { + 'setup.topic': '', + 'other.topic': ''' + + setup.html + +''', + }, + ); + + expect( + await _diagnosticCodes(root.path), + contains('writerside.web-file-name.collision'), + ); + }); + + test('diagnoses custom/custom collision', () async { + final root = await _module( + trees: { + 'guide.tree': _tree('guide', ['one.md', 'two.md']), + }, + topics: { + 'one.md': '# One\n\nshared.html\n', + 'two.md': '# Two\n\nshared.html\n', + }, + ); + + expect( + await _diagnosticCodes(root.path), + contains('writerside.web-file-name.collision'), + ); + }); + + test('allows the same output name in disjoint instances', () async { + final root = await _module( + trees: { + 'one.tree': _tree('one', ['one.topic']), + 'two.tree': _tree('two', ['two.topic']), + }, + topics: { + 'one.topic': ''' +shared.html +''', + 'two.topic': ''' +shared.html +''', + }, + ); + + expect( + await _diagnosticCodes(root.path), + isNot(contains('writerside.web-file-name.collision')), + ); + }); + + test('deduplicates repeated publication of one physical topic', () async { + final root = await _module( + trees: { + 'guide.tree': _tree('guide', ['one.topic', 'one.topic']), + }, + topics: {'one.topic': ''}, + ); + + expect( + await _diagnosticCodes(root.path), + isNot(contains('writerside.web-file-name.collision')), + ); + }); + + test('diagnoses a collision with an origin module topic', () async { + final project = await Directory.systemTemp.createTemp( + 'busymark-web-name-project-', + ); + addTearDown(() async { + if (await project.exists()) await project.delete(recursive: true); + }); + await _module( + parent: project, + directoryName: 'main', + moduleName: 'main', + trees: { + 'guide.tree': ''' + + + + +''', + }, + topics: {'setup.topic': ''}, + ); + await _module( + parent: project, + directoryName: 'shared', + moduleName: 'shared', + library: true, + trees: { + 'library.tree': _tree('library', ['shared.topic'], library: true), + }, + topics: { + 'shared.topic': ''' + + setup.html + +''', + }, + ); + + expect( + await _diagnosticCodes(project.path), + contains('writerside.web-file-name.collision'), + ); + }); + + test('resolves conditioned names independently in each instance', () async { + final root = await _module( + trees: { + 'one.tree': _tree('one', ['conditional.topic', 'first.topic']), + 'two.tree': _tree('two', ['conditional.topic', 'second.topic']), + }, + topics: { + 'conditional.topic': ''' + + one-special.html + two-special.html + +''', + 'first.topic': '', + 'second.topic': '', + }, + ); + + expect( + await _diagnosticCodes(root.path), + isNot(contains('writerside.web-file-name.collision')), + ); + }); + + test('diagnoses invalid custom filenames before export', () async { + final root = await _module( + trees: { + 'guide.tree': _tree('guide', ['bad.topic']), + }, + topics: { + 'bad.topic': ''' +../bad.html +''', + }, + ); + + expect( + await _diagnosticCodes(root.path), + contains('writerside.web-file-name.invalid'), + ); + }); + }); +} + +Future _module({ + Directory? parent, + String directoryName = 'module', + String moduleName = 'docs', + bool library = false, + required Map trees, + required Map topics, +}) async { + final ownedRoot = parent == null + ? await Directory.systemTemp.createTemp('busymark-web-name-module-') + : null; + final root = + parent == null + ? ownedRoot! + : Directory(p.join(parent.path, directoryName)) + ..createSync(); + if (ownedRoot != null) { + addTearDown(() async { + if (await ownedRoot.exists()) await ownedRoot.delete(recursive: true); + }); + } + final config = StringBuffer(''' + + + +'''); + for (final tree in trees.keys) { + config.writeln(' '); + } + config.write('\n'); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync('$config'); + final topicsDirectory = Directory(p.join(root.path, 'topics'))..createSync(); + for (final entry in trees.entries) { + final source = library && !entry.value.contains('is-library=') + ? entry.value.replaceFirst( + ' topics, {bool library = false}) => + ''' + +${[for (final topic in topics) ' '].join('\n')} + +'''; + +Future> _diagnosticCodes(String root) async => [ + for (final diagnostic in (await const WritersideProjectService().load( + root, + )).diagnostics) + diagnostic.code, +]; diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index 831e00c3..dc6e2c59 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -572,6 +572,85 @@ class _HarnessState extends ConsumerState<_Harness> { 'Native new XML', 'native-child.topic', ); + final originalChild = File( + p.join(widget.root.path, 'topics/native-child.topic'), + ); + final renamedChild = File( + p.join(widget.root.path, 'topics/native-child-renamed.topic'), + ); + await _tapLabel('Native new XML'); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.f6, LogicalKeyboardKey.f6); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.up, + ); + await _until( + () => _elements( + (widget) => + widget.runtimeType.toString() == 'WritersideTopicRenameDialog', + ).isNotEmpty, + 'Shift+F6 opens the topic rename dialog', + ); + _check( + _elements( + (widget) => widget is Text && widget.data == 'Preview', + ).isNotEmpty && + _elements( + (widget) => widget is Text && widget.data == 'Refactor', + ).isNotEmpty, + 'Topic rename offers Preview and Refactor', + ); + await _fill('File name', 'native-child-renamed.topic'); + await _capture('20-topic-rename-dialog'); + await _tapLabel('Preview'); + await _until( + () => _elements( + (widget) => widget is Text && widget.data == 'Rename Preview', + ).isNotEmpty, + 'Topic rename preview opens', + ); + _check( + originalChild.existsSync() && !renamedChild.existsSync(), + 'Topic rename Preview writes no files', + ); + _check( + _elements( + (widget) => + widget is Text && + widget.data == + 'native-child.topic → native-child-renamed.topic', + ).isNotEmpty && + _elements( + (widget) => widget is Text && widget.data == 'guide.tree', + ).isNotEmpty, + 'Topic rename preview lists the file and reference change', + ); + await _capture('21-topic-rename-preview'); + await _tapLabel('Do Refactor'); + await _until( + () => renamedChild.existsSync() && !originalChild.existsSync(), + 'Do Refactor commits the reviewed topic rename', + ); + final renamedChildSource = await renamedChild.readAsString(); + final renamedTreeSource = await File( + p.join(widget.root.path, 'guide.tree'), + ).readAsString(); + _check( + XmlDocument.parse(renamedChildSource).rootElement.getAttribute('id') == + 'native-child-renamed', + 'Do Refactor changes the XML topic ID with the filename', + ); + _check( + renamedTreeSource.contains('native-child-renamed.topic') && + !renamedTreeSource.contains('native-child.topic'), + 'Do Refactor changes the TOC reference with the filename', + ); await _menu('Native new Markdown'); await _tapLabel('New Topic'); await _tapLabel('Link Topic Files to TOC...'); @@ -672,7 +751,10 @@ class _HarnessState extends ConsumerState<_Harness> { _check( group .findAllElements('toc-element') - .any((node) => node.getAttribute('topic') == 'native-child.topic'), + .any( + (node) => + node.getAttribute('topic') == 'native-child-renamed.topic', + ), 'Pointer drag retains complete subtree', ); _check( @@ -696,6 +778,14 @@ class _HarnessState extends ConsumerState<_Harness> { ); await _menu('Navigation-only title'); await _tapLabel('Group'); + await _until( + () => _elements( + (widget) => + widget is TextField && + widget.decoration?.labelText == 'Group Name', + ).isNotEmpty, + 'Group dialog is ready', + ); await _fill('Group Name', 'Native group'); await _key(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); await _until( From c63b8c4fd6f688b3accdafeb139c617fbf4c4001 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 19:22:30 -0700 Subject: [PATCH 19/35] Refactor Writerside topic renaming logic: add project-wide safety validations, improve buffer handling, and centralize XML topic source validation. --- lib/l10n/app_ar.arb | 17 + lib/l10n/app_de.arb | 17 + lib/l10n/app_en.arb | 17 + lib/l10n/app_es.arb | 17 + lib/l10n/app_et.arb | 17 + lib/l10n/app_fa.arb | 17 + lib/l10n/app_fr.arb | 17 + lib/l10n/app_hi.arb | 17 + lib/l10n/app_id.arb | 17 + lib/l10n/app_it.arb | 17 + lib/l10n/app_ja.arb | 17 + lib/l10n/app_ko.arb | 17 + lib/l10n/app_nb.arb | 17 + lib/l10n/app_nl.arb | 17 + lib/l10n/app_pl.arb | 17 + lib/l10n/app_pt.arb | 17 + lib/l10n/app_pt_BR.arb | 17 + lib/l10n/app_ru.arb | 17 + lib/l10n/app_tr.arb | 17 + lib/l10n/app_uk.arb | 17 + lib/l10n/app_vi.arb | 17 + lib/l10n/app_zh.arb | 17 + lib/l10n/app_zh_CN.arb | 17 + lib/l10n/generated/app_localizations.dart | 22 ++ lib/l10n/generated/app_localizations_ar.dart | 17 + lib/l10n/generated/app_localizations_de.dart | 17 + lib/l10n/generated/app_localizations_en.dart | 17 + lib/l10n/generated/app_localizations_es.dart | 17 + lib/l10n/generated/app_localizations_et.dart | 17 + lib/l10n/generated/app_localizations_fa.dart | 17 + lib/l10n/generated/app_localizations_fr.dart | 17 + lib/l10n/generated/app_localizations_hi.dart | 17 + lib/l10n/generated/app_localizations_id.dart | 17 + lib/l10n/generated/app_localizations_it.dart | 17 + lib/l10n/generated/app_localizations_ja.dart | 17 + lib/l10n/generated/app_localizations_ko.dart | 17 + lib/l10n/generated/app_localizations_nb.dart | 17 + lib/l10n/generated/app_localizations_nl.dart | 17 + lib/l10n/generated/app_localizations_pl.dart | 17 + lib/l10n/generated/app_localizations_pt.dart | 34 ++ lib/l10n/generated/app_localizations_ru.dart | 17 + lib/l10n/generated/app_localizations_tr.dart | 17 + lib/l10n/generated/app_localizations_uk.dart | 17 + lib/l10n/generated/app_localizations_vi.dart | 17 + lib/l10n/generated/app_localizations_zh.dart | 34 ++ lib/src/markdown/markdown_parser.dart | 7 +- .../presentation/workspace_screen.dart | 36 +-- .../writerside_template_dialogs.dart | 3 +- lib/src/workspace/workspace_controller.dart | 60 +++- lib/src/workspace/workspace_message.dart | 10 + lib/src/workspace/workspace_model.dart | 24 ++ lib/src/workspace/workspace_safety.dart | 97 +++++- lib/src/workspace/workspace_service.dart | 28 +- lib/src/writerside/writerside_model.dart | 10 +- .../writerside/writerside_title_editor.dart | 10 - .../writerside/writerside_topic_creator.dart | 48 +-- .../writerside_topic_file_editor.dart | 87 ++++- test/src/app_smoke_test.dart | 302 ++++++++++++++++++ test/src/markdown_parser_test.dart | 25 ++ test/src/workspace_controller_test.dart | 139 +++++++- test/src/workspace_service_test.dart | 116 +++++++ .../src/writerside_template_dialogs_test.dart | 81 +++++ .../src/writerside_template_service_test.dart | 8 +- test/src/writerside_toc_actions_test.dart | 33 +- test/src/writerside_topic_creator_test.dart | 108 +++++++ .../writerside_topic_file_editor_test.dart | 244 +++++++++++++- tools/writerside_toc_visual_smoke.dart | 55 ++++ 67 files changed, 2247 insertions(+), 88 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 53f49075..836d1878 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "لا يمكن لإعادة التسمية تحديث كل مراجع المشروع بأمان لأن {count} من ملفات المواضيع في الوحدة {module} لم تُحلَّل: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "تحتوي ملفات مشروع Writerside على تغييرات غير محفوظة. عالجها وأنشئ معاينة جديدة لإعادة التسمية.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "تغيّرت مجموعة وحدات مشروع Writerside. أنشئ معاينة جديدة لإعادة التسمية قبل إعادة الهيكلة.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "يجب أن يكون جذر المواضيع دليلاً نسبيًا آمنًا.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 22e2cd0d..73c7333a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Die Umbenennung kann nicht alle Projektverweise sicher aktualisieren, da {count} Themendatei(en) im Modul {module} nicht analysiert wurden: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside-Projektdateien enthalten ungespeicherte Änderungen. Beheben Sie diese und erstellen Sie eine neue Umbenennungsvorschau.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Die Modulgruppe des Writerside-Projekts wurde geändert. Erstellen Sie vor dem Refactoring eine neue Umbenennungsvorschau.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Das Themen-Stammverzeichnis muss ein zulässiges relatives Verzeichnis sein.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aacb1bdb..2499321f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1399,6 +1399,23 @@ "description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}} }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Rename cannot safely update every project reference because {count} topic file(s) in module {module} were not parsed: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside project files have unsaved changes. Resolve them and prepare a new rename preview.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "The Writerside project module set changed. Prepare a new rename preview before refactoring.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Topics root must be a safe relative directory.", "@errorTopicsRootUnsafe": {"description": "Detail for an unsafe topics root directory."}, "errorTopicFileNameUnsafe": "Topic file name must be a single safe path segment.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d329eb2e..a7a1ea84 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "No se pueden actualizar de forma segura todas las referencias del proyecto porque no se analizaron {count} archivo(s) de tema del módulo {module}: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Los archivos del proyecto de Writerside tienen cambios sin guardar. Resuélvalos y prepare una nueva vista previa del cambio de nombre.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "El conjunto de módulos del proyecto de Writerside cambió. Prepare una nueva vista previa del cambio de nombre antes de refactorizar.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "La raíz de temas debe ser un directorio relativo seguro.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 90e281f6..7909f3fb 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -2201,6 +2201,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Ümbernimetamine ei saa kõiki projektiviiteid ohutult uuendada, sest moodulis {module} jäi sõelumata {count} teemafaili: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside’i projektifailides on salvestamata muudatusi. Lahenda need ja koosta uus ümbernimetamise eelvaade.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside’i projekti moodulite komplekt muutus. Koosta enne refaktoreerimist uus ümbernimetamise eelvaade.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Teemade juurkaust peab olema ohutu suhteline kataloog.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 28ac0dd2..89da8ec7 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1848,6 +1848,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "تغییر نام نمی‌تواند همهٔ ارجاع‌های پروژه را با اطمینان به‌روزرسانی کند، زیرا {count} فایل موضوع در ماژول {module} تجزیه نشده است: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "فایل‌های پروژهٔ Writerside تغییرات ذخیره‌نشده دارند. آن‌ها را برطرف کنید و پیش‌نمایش تازه‌ای برای تغییر نام آماده کنید.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "مجموعهٔ ماژول‌های پروژهٔ Writerside تغییر کرده است. پیش از بازآرایی، پیش‌نمایش تازه‌ای برای تغییر نام آماده کنید.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "ریشهٔ موضوع‌ها باید یک پوشهٔ نسبی مجاز باشد.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a5da1246..59f2a785 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Le renommage ne peut pas mettre à jour toutes les références du projet en toute sécurité, car {count} fichier(s) de rubrique du module {module} n’ont pas été analysés : {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Des fichiers du projet Writerside contiennent des modifications non enregistrées. Résolvez-les et préparez un nouvel aperçu du renommage.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "L’ensemble des modules du projet Writerside a changé. Préparez un nouvel aperçu du renommage avant la refactorisation.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "La racine des sujets doit être un répertoire relatif valide.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index efb7c8de..c83b7de7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "नाम बदलने से प्रोजेक्ट के सभी संदर्भ सुरक्षित रूप से अपडेट नहीं किए जा सकते क्योंकि मॉड्यूल {module} में {count} विषय फ़ाइल पार्स नहीं हुईं: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside प्रोजेक्ट फ़ाइलों में सहेजे न गए बदलाव हैं। उन्हें हल करें और नाम बदलने का नया पूर्वावलोकन तैयार करें।", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside प्रोजेक्ट का मॉड्यूल सेट बदल गया है। रीफ़ैक्टर करने से पहले नाम बदलने का नया पूर्वावलोकन तैयार करें।", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "विषय रूट सुरक्षित सापेक्ष डायरेक्टरी होना चाहिए।", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4d8c0234..dce44dc3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Penggantian nama tidak dapat memperbarui setiap referensi proyek dengan aman karena {count} file topik di modul {module} tidak diurai: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "File proyek Writerside memiliki perubahan yang belum disimpan. Selesaikan perubahan tersebut dan siapkan pratinjau penggantian nama baru.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Kumpulan modul proyek Writerside berubah. Siapkan pratinjau penggantian nama baru sebelum melakukan refaktor.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Akar topik harus berupa direktori relatif yang aman.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 0613e570..29bfae25 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "La ridenominazione non può aggiornare in modo sicuro tutti i riferimenti del progetto perché {count} file di argomento nel modulo {module} non sono stati analizzati: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "I file del progetto Writerside contengono modifiche non salvate. Risolvile e prepara una nuova anteprima della ridenominazione.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "L’insieme dei moduli del progetto Writerside è cambiato. Prepara una nuova anteprima della ridenominazione prima del refactoring.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "La radice degli argomenti deve essere una directory relativa sicura.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1a9852c4..d628a259 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "モジュール {module} の {count} 個のトピックファイルが解析されていないため、名前変更ですべてのプロジェクト参照を安全に更新できません: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside プロジェクトファイルに未保存の変更があります。変更を解決して、新しい名前変更プレビューを準備してください。", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside プロジェクトのモジュール構成が変更されました。リファクタリングの前に新しい名前変更プレビューを準備してください。", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "トピックのルートは安全な相対ディレクトリである必要があります。", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6b8d5520..fc9b3057 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "모듈 {module}의 토픽 파일 {count}개가 구문 분석되지 않아 이름 바꾸기로 모든 프로젝트 참조를 안전하게 업데이트할 수 없습니다: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside 프로젝트 파일에 저장되지 않은 변경 사항이 있습니다. 이를 해결하고 새 이름 바꾸기 미리보기를 준비하세요.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside 프로젝트의 모듈 집합이 변경되었습니다. 리팩터링하기 전에 새 이름 바꾸기 미리보기를 준비하세요.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "토픽 루트는 안전한 상대 디렉터리여야 합니다.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index be54ee8a..f98d1c94 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Navneendringen kan ikke oppdatere alle prosjektreferanser trygt fordi {count} emnefil(er) i modulen {module} ikke ble analysert: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside-prosjektfiler har ulagrede endringer. Løs dem og klargjør en ny forhåndsvisning av navneendringen.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Modulsettet i Writerside-prosjektet er endret. Klargjør en ny forhåndsvisning av navneendringen før refaktorering.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Emneroten må være en sikker relativ mappe.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 20ffd09b..1009eade 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Niet alle projectverwijzingen kunnen veilig worden bijgewerkt omdat {count} topicbestand(en) in module {module} niet zijn geparseerd: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside-projectbestanden bevatten niet-opgeslagen wijzigingen. Los deze op en maak een nieuw voorbeeld van de naamswijziging.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "De moduleset van het Writerside-project is gewijzigd. Maak vóór het refactoren een nieuw voorbeeld van de naamswijziging.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "De hoofdmap van Onderwerpen moet een veilige relatieve map zijn.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 96e6e575..a179c886 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Zmiana nazwy nie może bezpiecznie zaktualizować wszystkich odwołań w projekcie, ponieważ nie przeanalizowano {count} plików tematów w module {module}: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Pliki projektu Writerside zawierają niezapisane zmiany. Rozwiąż je i przygotuj nowy podgląd zmiany nazwy.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Zestaw modułów projektu Writerside uległ zmianie. Przed refaktoryzacją przygotuj nowy podgląd zmiany nazwy.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Katalog główny tematów musi być bezpiecznym katalogiem względnym.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cd65c8ec..73e38dd5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "A mudança de nome não pode atualizar com segurança todas as referências do projeto porque {count} ficheiro(s) de tópico no módulo {module} não foram analisados: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Os ficheiros do projeto Writerside têm alterações não guardadas. Resolva-as e prepare uma nova pré-visualização da mudança de nome.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "O conjunto de módulos do projeto Writerside foi alterado. Prepare uma nova pré-visualização da mudança de nome antes da refatoração.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "A raiz dos tópicos deve ser um diretório relativo seguro.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index dde041c6..c24aa9fd 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "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}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Os arquivos do projeto Writerside têm alterações não salvas. Resolva-as e prepare uma nova visualização da renomeação.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "O conjunto de módulos do projeto Writerside mudou. Prepare uma nova visualização da renomeação antes da refatoração.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "A raiz dos tópicos deve ser um diretório relativo seguro.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9ac526db..1e0d405e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Переименование не может безопасно обновить все ссылки проекта, поскольку в модуле {module} не разобрано файлов тем: {count}. Файлы: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "В файлах проекта Writerside есть несохранённые изменения. Разрешите их и подготовьте новый предварительный просмотр переименования.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Набор модулей проекта Writerside изменился. Перед рефакторингом подготовьте новый предварительный просмотр переименования.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Корневой каталог тем должен быть допустимым относительным каталогом.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 1d8fb5da..a32c6cbc 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Yeniden adlandırma, {module} modülündeki {count} konu dosyası ayrıştırılmadığından tüm proje başvurularını güvenle güncelleyemiyor: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside proje dosyalarında kaydedilmemiş değişiklikler var. Bunları çözün ve yeni bir yeniden adlandırma önizlemesi hazırlayın.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside projesinin modül kümesi değişti. Yeniden düzenlemeden önce yeni bir yeniden adlandırma önizlemesi hazırlayın.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Konu kökü güvenli bir göreli dizin olmalıdır.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 8e640c39..e86ff4b9 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1840,6 +1840,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Перейменування не може безпечно оновити всі посилання проєкту, оскільки в модулі {module} не розібрано файлів тем: {count}. Файли: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "У файлах проєкту Writerside є незбережені зміни. Розв’яжіть їх і підготуйте новий попередній перегляд перейменування.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Набір модулів проєкту Writerside змінився. Перед рефакторингом підготуйте новий попередній перегляд перейменування.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Кореневий каталог тем має бути допустимим відносним каталогом.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 46b01539..d40ddb3e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "Không thể đổi tên và cập nhật an toàn mọi tham chiếu trong dự án vì {count} tệp chủ đề trong mô-đun {module} chưa được phân tích: {paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Các tệp dự án Writerside có thay đổi chưa lưu. Hãy xử lý chúng và chuẩn bị bản xem trước đổi tên mới.", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Tập hợp mô-đun của dự án Writerside đã thay đổi. Hãy chuẩn bị bản xem trước đổi tên mới trước khi tái cấu trúc.", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "Gốc chủ đề phải là một thư mục tương đối an toàn.", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2218c1d1..2d8b3d5a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "无法安全地重命名并更新所有项目引用,因为模块 {module} 中有 {count} 个主题文件尚未解析:{paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside 项目文件有未保存的更改。请先处理这些更改,再准备新的重命名预览。", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside 项目的模块集合已更改。请在重构前准备新的重命名预览。", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "主题根目录必须是安全的相对目录。", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 26522841..60e4a263 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -2496,6 +2496,23 @@ } } }, + "errorWritersideTopicRenameIncompleteProjectIndex": "无法安全地重命名并更新所有项目引用,因为模块 {module} 中有 {count} 个主题文件尚未解析:{paths}", + "@errorWritersideTopicRenameIncompleteProjectIndex": { + "description": "Error shown when topic rename cannot prove complete project-wide semantic coverage.", + "placeholders": { + "module": {"type": "String"}, + "count": {"type": "String"}, + "paths": {"type": "String"} + } + }, + "errorWritersideTopicRenameProjectBuffersDirty": "Writerside 项目文件有未保存的更改。请先处理这些更改,再准备新的重命名预览。", + "@errorWritersideTopicRenameProjectBuffersDirty": { + "description": "Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer." + }, + "errorWritersideTopicRenameProjectInventoryChanged": "Writerside 项目的模块集合已更改。请在重构前准备新的重命名预览。", + "@errorWritersideTopicRenameProjectInventoryChanged": { + "description": "Error shown when module discovery differs from a prepared topic rename plan." + }, "errorTopicsRootUnsafe": "主题根目录必须是安全的相对目录。", "@errorTopicsRootUnsafe": { "description": "Detail for an unsafe topics root directory." diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 2a050866..67b265cf 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3841,6 +3841,28 @@ abstract class AppLocalizations { /// **'Topic removal could not be fully rolled back. Review these paths before continuing: {paths}'** String errorWritersideRollbackFailed(String paths); + /// Error shown when topic rename cannot prove complete project-wide semantic coverage. + /// + /// In en, this message translates to: + /// **'Rename cannot safely update every project reference because {count} topic file(s) in module {module} were not parsed: {paths}'** + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ); + + /// Error shown when a topic rename or reviewed rename plan is blocked by a dirty Writerside project buffer. + /// + /// In en, this message translates to: + /// **'Writerside project files have unsaved changes. Resolve them and prepare a new rename preview.'** + String get errorWritersideTopicRenameProjectBuffersDirty; + + /// Error shown when module discovery differs from a prepared topic rename plan. + /// + /// In en, this message translates to: + /// **'The Writerside project module set changed. Prepare a new rename preview before refactoring.'** + String get errorWritersideTopicRenameProjectInventoryChanged; + /// Detail for an unsafe topics root directory. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 64030c9c..a116dac0 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2186,6 +2186,23 @@ class AppLocalizationsAr extends AppLocalizations { return 'تعذّر التراجع بالكامل عن إزالة الموضوع. راجع هذه المسارات قبل المتابعة: ⁨$paths⁩'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'لا يمكن لإعادة التسمية تحديث كل مراجع المشروع بأمان لأن $count من ملفات المواضيع في الوحدة $module لم تُحلَّل: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'تحتوي ملفات مشروع Writerside على تغييرات غير محفوظة. عالجها وأنشئ معاينة جديدة لإعادة التسمية.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'تغيّرت مجموعة وحدات مشروع Writerside. أنشئ معاينة جديدة لإعادة التسمية قبل إعادة الهيكلة.'; + @override String get errorTopicsRootUnsafe => 'يجب أن يكون جذر المواضيع دليلاً نسبيًا آمنًا.'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 2bc31373..c213a782 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2193,6 +2193,23 @@ class AppLocalizationsDe extends AppLocalizations { return 'Das Entfernen des Themas konnte nicht vollständig rückgängig gemacht werden. Prüfen Sie vor dem Fortfahren diese Pfade: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Die Umbenennung kann nicht alle Projektverweise sicher aktualisieren, da $count Themendatei(en) im Modul $module nicht analysiert wurden: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside-Projektdateien enthalten ungespeicherte Änderungen. Beheben Sie diese und erstellen Sie eine neue Umbenennungsvorschau.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Die Modulgruppe des Writerside-Projekts wurde geändert. Erstellen Sie vor dem Refactoring eine neue Umbenennungsvorschau.'; + @override String get errorTopicsRootUnsafe => 'Das Themen-Stammverzeichnis muss ein zulässiges relatives Verzeichnis sein.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ee096ca4..309087ba 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2169,6 +2169,23 @@ class AppLocalizationsEn extends AppLocalizations { return 'Topic removal could not be fully rolled back. Review these paths before continuing: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Rename cannot safely update every project reference because $count topic file(s) in module $module were not parsed: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside project files have unsaved changes. Resolve them and prepare a new rename preview.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'The Writerside project module set changed. Prepare a new rename preview before refactoring.'; + @override String get errorTopicsRootUnsafe => 'Topics root must be a safe relative directory.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 82d20615..4360e377 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2191,6 +2191,23 @@ class AppLocalizationsEs extends AppLocalizations { return 'No se pudo revertir por completo la eliminación del tema. Revisa estas rutas antes de continuar: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'No se pueden actualizar de forma segura todas las referencias del proyecto porque no se analizaron $count archivo(s) de tema del módulo $module: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Los archivos del proyecto de Writerside tienen cambios sin guardar. Resuélvalos y prepare una nueva vista previa del cambio de nombre.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'El conjunto de módulos del proyecto de Writerside cambió. Prepare una nueva vista previa del cambio de nombre antes de refactorizar.'; + @override String get errorTopicsRootUnsafe => 'La raíz de temas debe ser un directorio relativo seguro.'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 27b47878..ae19095d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -2166,6 +2166,23 @@ class AppLocalizationsEt extends AppLocalizations { return 'Teema eemaldamist ei saanud täielikult tagasi pöörata. Enne jätkamist vaata üle järgmised asukohad: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Ümbernimetamine ei saa kõiki projektiviiteid ohutult uuendada, sest moodulis $module jäi sõelumata $count teemafaili: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside’i projektifailides on salvestamata muudatusi. Lahenda need ja koosta uus ümbernimetamise eelvaade.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Writerside’i projekti moodulite komplekt muutus. Koosta enne refaktoreerimist uus ümbernimetamise eelvaade.'; + @override String get errorTopicsRootUnsafe => 'Teemade juurkaust peab olema ohutu suhteline kataloog.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index b855ef04..16a90ba8 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2207,6 +2207,23 @@ class AppLocalizationsFa extends AppLocalizations { return 'حذف موضوع به‌طور کامل بازگردانده نشد. پیش از ادامه این مسیرها را بررسی کنید: ⁨$paths⁩'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'تغییر نام نمی‌تواند همهٔ ارجاع‌های پروژه را با اطمینان به‌روزرسانی کند، زیرا $count فایل موضوع در ماژول $module تجزیه نشده است: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'فایل‌های پروژهٔ Writerside تغییرات ذخیره‌نشده دارند. آن‌ها را برطرف کنید و پیش‌نمایش تازه‌ای برای تغییر نام آماده کنید.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'مجموعهٔ ماژول‌های پروژهٔ Writerside تغییر کرده است. پیش از بازآرایی، پیش‌نمایش تازه‌ای برای تغییر نام آماده کنید.'; + @override String get errorTopicsRootUnsafe => 'ریشهٔ موضوع‌ها باید یک پوشهٔ نسبی مجاز باشد.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 4db31b68..f62643b7 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2197,6 +2197,23 @@ class AppLocalizationsFr extends AppLocalizations { return 'La suppression du sujet n’a pas pu être entièrement annulée. Vérifiez ces chemins avant de continuer : $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Le renommage ne peut pas mettre à jour toutes les références du projet en toute sécurité, car $count fichier(s) de rubrique du module $module n’ont pas été analysés : $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Des fichiers du projet Writerside contiennent des modifications non enregistrées. Résolvez-les et préparez un nouvel aperçu du renommage.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'L’ensemble des modules du projet Writerside a changé. Préparez un nouvel aperçu du renommage avant la refactorisation.'; + @override String get errorTopicsRootUnsafe => 'La racine des sujets doit être un répertoire relatif valide.'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index f9dbbf0b..e13ab169 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2163,6 +2163,23 @@ class AppLocalizationsHi extends AppLocalizations { 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 => 'विषय रूट सुरक्षित सापेक्ष डायरेक्टरी होना चाहिए।'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 60615874..c7a20d7a 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -2169,6 +2169,23 @@ class AppLocalizationsId extends AppLocalizations { return 'Penghapusan topik tidak dapat dibatalkan sepenuhnya. Tinjau jalur ini sebelum melanjutkan: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Penggantian nama tidak dapat memperbarui setiap referensi proyek dengan aman karena $count file topik di modul $module tidak diurai: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'File proyek Writerside memiliki perubahan yang belum disimpan. Selesaikan perubahan tersebut dan siapkan pratinjau penggantian nama baru.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Kumpulan modul proyek Writerside berubah. Siapkan pratinjau penggantian nama baru sebelum melakukan refaktor.'; + @override String get errorTopicsRootUnsafe => 'Akar topik harus berupa direktori relatif yang aman.'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 2fd10198..408b8ed1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2189,6 +2189,23 @@ class AppLocalizationsIt extends AppLocalizations { return 'Non è stato possibile annullare completamente la rimozione dell’argomento. Controlla questi percorsi prima di continuare: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'La ridenominazione non può aggiornare in modo sicuro tutti i riferimenti del progetto perché $count file di argomento nel modulo $module non sono stati analizzati: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'I file del progetto Writerside contengono modifiche non salvate. Risolvile e prepara una nuova anteprima della ridenominazione.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'L’insieme dei moduli del progetto Writerside è cambiato. Prepara una nuova anteprima della ridenominazione prima del refactoring.'; + @override String get errorTopicsRootUnsafe => 'La radice degli argomenti deve essere una directory relativa sicura.'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 16c4f73e..3f37dff2 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -2100,6 +2100,23 @@ class AppLocalizationsJa extends AppLocalizations { 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 => 'トピックのルートは安全な相対ディレクトリである必要があります。'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index bfef58c3..fc425f93 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -2096,6 +2096,23 @@ class AppLocalizationsKo extends AppLocalizations { 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 => '토픽 루트는 안전한 상대 디렉터리여야 합니다.'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index dbcffa0a..9ee8085d 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -2171,6 +2171,23 @@ class AppLocalizationsNb extends AppLocalizations { return 'Fjerningen av emnet kunne ikke angres fullstendig. Se gjennom disse banene før du fortsetter: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Navneendringen kan ikke oppdatere alle prosjektreferanser trygt fordi $count emnefil(er) i modulen $module ikke ble analysert: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside-prosjektfiler har ulagrede endringer. Løs dem og klargjør en ny forhåndsvisning av navneendringen.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Modulsettet i Writerside-prosjektet er endret. Klargjør en ny forhåndsvisning av navneendringen før refaktorering.'; + @override String get errorTopicsRootUnsafe => 'Emneroten må være en sikker relativ mappe.'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 7d189420..14a483a5 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -2182,6 +2182,23 @@ class AppLocalizationsNl extends AppLocalizations { return 'Het verwijderen van het onderwerp kan niet volledig worden teruggedraaid. Bekijk deze paden voordat u verdergaat: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Niet alle projectverwijzingen kunnen veilig worden bijgewerkt omdat $count topicbestand(en) in module $module niet zijn geparseerd: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside-projectbestanden bevatten niet-opgeslagen wijzigingen. Los deze op en maak een nieuw voorbeeld van de naamswijziging.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'De moduleset van het Writerside-project is gewijzigd. Maak vóór het refactoren een nieuw voorbeeld van de naamswijziging.'; + @override String get errorTopicsRootUnsafe => 'De hoofdmap van Onderwerpen moet een veilige relatieve map zijn.'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 7c9f06e2..8a6f77e8 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -2203,6 +2203,23 @@ class AppLocalizationsPl extends AppLocalizations { return 'Nie udało się całkowicie wycofać usunięcia tematu. Przed kontynuowaniem przejrzyj te ścieżki: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Zmiana nazwy nie może bezpiecznie zaktualizować wszystkich odwołań w projekcie, ponieważ nie przeanalizowano $count plików tematów w module $module: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Pliki projektu Writerside zawierają niezapisane zmiany. Rozwiąż je i przygotuj nowy podgląd zmiany nazwy.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Zestaw modułów projektu Writerside uległ zmianie. Przed refaktoryzacją przygotuj nowy podgląd zmiany nazwy.'; + @override String get errorTopicsRootUnsafe => 'Katalog główny tematów musi być bezpiecznym katalogiem względnym.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 9b07c0aa..3bf1d211 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2182,6 +2182,23 @@ class AppLocalizationsPt extends AppLocalizations { return 'Não foi possível reverter completamente a remoção do tópico. Reveja estes caminhos antes de continuar: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'A mudança de nome não pode atualizar com segurança todas as referências do projeto porque $count ficheiro(s) de tópico no módulo $module não foram analisados: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Os ficheiros do projeto Writerside têm alterações não guardadas. Resolva-as e prepare uma nova pré-visualização da mudança de nome.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'O conjunto de módulos do projeto Writerside foi alterado. Prepare uma nova pré-visualização da mudança de nome antes da refatoração.'; + @override String get errorTopicsRootUnsafe => 'A raiz dos tópicos deve ser um diretório relativo seguro.'; @@ -6738,6 +6755,23 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 0d6f21e1..932b28ab 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2198,6 +2198,23 @@ class AppLocalizationsRu extends AppLocalizations { 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 => 'Корневой каталог тем должен быть допустимым относительным каталогом.'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 2e5a2744..56d1a69b 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -2167,6 +2167,23 @@ class AppLocalizationsTr extends AppLocalizations { return 'Konu kaldırma işlemi tamamen geri alınamadı. Devam etmeden önce şu yolları inceleyin: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Yeniden adlandırma, $module modülündeki $count konu dosyası ayrıştırılmadığından tüm proje başvurularını güvenle güncelleyemiyor: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Writerside proje dosyalarında kaydedilmemiş değişiklikler var. Bunları çözün ve yeni bir yeniden adlandırma önizlemesi hazırlayın.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Writerside projesinin modül kümesi değişti. Yeniden düzenlemeden önce yeni bir yeniden adlandırma önizlemesi hazırlayın.'; + @override String get errorTopicsRootUnsafe => 'Konu kökü güvenli bir göreli dizin olmalıdır.'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 6bbe5a82..ffdfba5f 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -2207,6 +2207,23 @@ class AppLocalizationsUk extends AppLocalizations { 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 => 'Кореневий каталог тем має бути допустимим відносним каталогом.'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index d648548c..160cfe61 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2163,6 +2163,23 @@ class AppLocalizationsVi extends AppLocalizations { return 'Không thể khôi phục hoàn toàn việc xóa chủ đề. Hãy xem lại các đường dẫn này trước khi tiếp tục: $paths'; } + @override + String errorWritersideTopicRenameIncompleteProjectIndex( + String module, + String count, + String paths, + ) { + return 'Không thể đổi tên và cập nhật an toàn mọi tham chiếu trong dự án vì $count tệp chủ đề trong mô-đun $module chưa được phân tích: $paths'; + } + + @override + String get errorWritersideTopicRenameProjectBuffersDirty => + 'Các tệp dự án Writerside có thay đổi chưa lưu. Hãy xử lý chúng và chuẩn bị bản xem trước đổi tên mới.'; + + @override + String get errorWritersideTopicRenameProjectInventoryChanged => + 'Tập hợp mô-đun của dự án Writerside đã thay đổi. Hãy chuẩn bị bản xem trước đổi tên mới trước khi tái cấu trúc.'; + @override String get errorTopicsRootUnsafe => 'Gốc chủ đề phải là một thư mục tương đối an toàn.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index f0d25647..919cf613 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -2065,6 +2065,23 @@ class AppLocalizationsZh extends AppLocalizations { 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 => '主题根目录必须是安全的相对目录。'; @@ -6359,6 +6376,23 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { 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 => '主题根目录必须是安全的相对目录。'; diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index ada4d3ff..dec5cc16 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -118,7 +118,12 @@ class MarkdownParser { final xmlBlocks = []; final variables = []; const astAdapter = MarkdownAstAdapter(); - var title = _frontMatterTitle(filePath, source, diagnostics); + // Writerside defines a Markdown topic title exclusively as its H1. Keep + // front matter in the document model, but never let its `title` metadata + // shadow (or substitute for) the authored Writerside heading. + var title = mode == MarkdownMode.writersideMarkdown + ? null + : _frontMatterTitle(filePath, source, diagnostics); void inspectScannedHeadingAttributes({ required String? attrText, required int startOffset, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 662f9a0d..40b17d37 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -2459,34 +2459,20 @@ class _SidebarState extends ConsumerState<_Sidebar> { currentFileName: p.basename(topicPath), ), ); - if (decision == null || !mounted || !context.mounted) return false; - final controller = ref.read(workspaceControllerProvider.notifier); - var plan = await controller.prepareWritersideTopicRename( - topicPath, - decision.fileName, - topicModuleRoot: owner.rootPath, - ); - if (plan == null || !mounted || !context.mounted) { - if (mounted) _showLatestWorkspaceMessage(this.context); - return false; - } - if (!await confirmSafeToChangeWorkspaceFiles( - context, - ref, - plan.affectedPaths, - ) || - !mounted || - !context.mounted) { + if (decision == null || !mounted) return false; + // Use the sidebar State's context rather than the row/menu context that + // launched the workflow; transient menu entries may already be unmounted. + if (!await confirmSafeToRefactorWritersideProject(this.context, ref) || + !mounted) { return false; } - // Saving dirty participants invalidates the first snapshot. Build the one - // the user will actually review or commit after all buffers are clean. - plan = await controller.prepareWritersideTopicRename( + final controller = ref.read(workspaceControllerProvider.notifier); + final plan = await controller.prepareWritersideTopicRename( topicPath, decision.fileName, topicModuleRoot: owner.rootPath, ); - if (plan == null || !mounted || !context.mounted) { + if (plan == null || !mounted) { if (mounted) _showLatestWorkspaceMessage(this.context); return false; } @@ -2498,7 +2484,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { return true; } final applied = await controller.applyWritersideTopicRename(plan); - if (!mounted || !context.mounted) return applied; + if (!mounted) return applied; if (!applied) _showLatestWorkspaceMessage(this.context); return applied; } @@ -2506,6 +2492,10 @@ class _SidebarState extends ConsumerState<_Sidebar> { Future _applyReviewedTopicRename(BuildContext context) async { final plan = _topicRenameReview; if (plan == null) return; + final becameDirty = hasDirtyWritersideProjectBuffers(ref); + if (becameDirty && mounted) { + setState(() => _topicRenameReview = null); + } final applied = await ref .read(workspaceControllerProvider.notifier) .applyWritersideTopicRename(plan); diff --git a/lib/src/workspace/presentation/writerside_template_dialogs.dart b/lib/src/workspace/presentation/writerside_template_dialogs.dart index bb16b6b7..968c625d 100644 --- a/lib/src/workspace/presentation/writerside_template_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_template_dialogs.dart @@ -8,6 +8,7 @@ import '../../app/busymark_design.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/localization.dart'; import '../../writerside/writerside_template_service.dart'; +import '../../writerside/writerside_topic_file_name.dart'; typedef WritersideTemplatePreviewBuilder = Widget Function( @@ -101,7 +102,7 @@ class _TemplateDialogState extends ConsumerState { String? get _filenameError { final value = _filename.text.trim(); if (value.isEmpty) return context.l10n.fileNameRequired; - if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(value)) { + if (!isValidWritersideTopicId(value)) { return context.l10n.useIdentifierCharacters; } if (widget.existingIds.contains(value)) { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index ec97e6e7..9995b404 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1641,6 +1641,19 @@ class WorkspaceController extends Notifier { } } + void _requireCleanWritersideProject(Workspace workspace) { + if (state.workspace?.id != workspace.id || + state.dirtyBuffers.any( + (buffer) => + buffer.filePath != null && + isWritersideProjectPath(workspace, buffer.filePath!), + )) { + throw const BusyMarkException( + 'writerside.topic-file.project-buffers-dirty', + ); + } + } + Future prepareWritersideTitleEdit({ required String treePath, required List tocPath, @@ -1938,8 +1951,8 @@ class WorkspaceController extends Notifier { Future applyWritersideTopicRename(WritersideTopicRenamePlan plan) { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { - void validate(Iterable paths) => - _requireCleanAffectedFiles(workspace, paths); + void validate(Iterable _) => + _requireCleanWritersideProject(workspace); validate(plan.affectedPaths); final result = await _service.applyWritersideTopicRename( plan, @@ -3331,6 +3344,7 @@ class WorkspaceController extends Notifier { } Future saveAll({ + Iterable? bufferIds, Map mixedLineEndingNormalizations = const {}, }) async { @@ -3339,9 +3353,14 @@ class WorkspaceController extends Notifier { final failed = []; final conflicts = []; final normalizationRequired = []; + final includedBufferIds = bufferIds?.toSet(); final targets = [ for (final buffer in state.documentBuffers) - if (buffer.isDirty && buffer.filePath != null) buffer, + if (buffer.isDirty && + buffer.filePath != null && + (includedBufferIds == null || + includedBufferIds.contains(buffer.id))) + buffer, ]; final writes = <({String id, Future<_BufferWriteResult> result})>[]; for (final target in targets) { @@ -3708,6 +3727,41 @@ class WorkspaceController extends Notifier { ); } + /// Discards the selected buffers without changing the active document. + /// + /// Project-wide refactoring safety uses this to resolve several inactive + /// Writerside buffers without unmounting the UI that owns the confirmation + /// workflow between files. + Future discardDocumentBuffers(Iterable bufferIds) async { + final workspace = state.workspace; + if (workspace == null) return false; + for (final bufferId in bufferIds.toSet()) { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null || !buffer.isDirty) continue; + final target = ActiveDocumentSaveTarget._( + workspaceId: workspace.id, + bufferId: buffer.id, + path: buffer.filePath, + documentRevision: _activeDocumentRevision, + editRevision: buffer.revision, + snapshot: buffer.diskSnapshot, + text: buffer.text, + workspaceKind: workspace.kind, + format: buffer.format, + ); + _cancelAutoSave(buffer.id); + _cancelPendingDerivedRefresh(); + final discarded = await _enqueueBufferWrite( + buffer.id, + () => _discardBufferChangesNow(target), + ); + if (!discarded) return false; + } + return true; + } + Future restoreLocalHistoryRevision({ required LocalHistoryDocument document, required LocalHistoryRevision revision, diff --git a/lib/src/workspace/workspace_message.dart b/lib/src/workspace/workspace_message.dart index 799fce3a..5cee44d6 100644 --- a/lib/src/workspace/workspace_message.dart +++ b/lib/src/workspace/workspace_message.dart @@ -203,6 +203,16 @@ String _localizeWorkspaceError(BuildContext context, Object? error) { l10n.workspaceErrorSaveBlockedFileChangedOnDisk, 'writerside.topic-file.topic-inventory-changed' => l10n.workspaceErrorSaveBlockedFileChangedOnDisk, + 'writerside.topic-file.incomplete-project-index' => + l10n.errorWritersideTopicRenameIncompleteProjectIndex( + value('module'), + value('count'), + value('paths'), + ), + 'writerside.topic-file.project-buffers-dirty' => + l10n.errorWritersideTopicRenameProjectBuffersDirty, + 'writerside.topic-file.project-inventory-changed' => + l10n.errorWritersideTopicRenameProjectInventoryChanged, 'writerside.topic-removal.safe-delete-required' => l10n.errorWritersideSafeDeleteRequired, 'writerside.topic-removal.scan-failed' => diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index b56c9b3e..b8bfe265 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -1,4 +1,7 @@ +import 'package:path/path.dart' as p; + import '../core/diagnostic.dart'; +import '../core/path_utils.dart'; import '../markdown/document_outline.dart'; import '../markdown/markdown_model.dart'; import '../markdown/preview_model.dart'; @@ -187,6 +190,27 @@ class Workspace { } } +/// Whether [path] is part of the currently discovered Writerside project. +/// Module roots cover newly opened project files, while the explicit source +/// inventory also handles any configured source whose canonical path is known. +bool isWritersideProjectPath(Workspace workspace, String path) { + final project = workspace.writersideProject; + if (project == null || path.isEmpty) return false; + final candidate = normalizePath(path); + for (final module in project.modules) { + final root = normalizePath(module.rootPath); + if (p.equals(candidate, root) || p.isWithin(root, candidate)) return true; + final knownPaths = { + module.config.filePath, + for (final instance in module.instances) instance.sourceTreePath, + for (final topic in module.topics) topic.filePath, + ...module.sourceFiles.keys, + }; + if (knownPaths.any((known) => p.equals(candidate, known))) return true; + } + return false; +} + List _normalizedOpenFilePaths( List openFilePaths, String? activeFilePath, diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index e1fb1f20..b03969aa 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -11,6 +11,7 @@ import '../app/localization.dart'; import '../platform/linux_header_bar_service.dart'; import 'workspace_controller.dart'; import 'workspace_message.dart'; +import 'workspace_model.dart'; import 'text_format_metadata.dart'; enum _UnsavedChangesAction { cancel, discard, save } @@ -54,6 +55,39 @@ Future confirmSafeToChangeWorkspaceFiles( ); } +bool hasDirtyWritersideProjectBuffers(WidgetRef ref) { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + if (workspace == null) return false; + return state.dirtyBuffers.any( + (buffer) => + buffer.filePath != null && + isWritersideProjectPath(workspace, buffer.filePath!), + ); +} + +/// Resolves every dirty buffer in every discovered module before a +/// project-wide semantic refactoring is planned. +Future confirmSafeToRefactorWritersideProject( + BuildContext context, + WidgetRef ref, +) { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + if (workspace == null) return Future.value(false); + return _confirmUnsavedChanges( + context, + ref, + withoutActivatingBuffers: true, + dirtyBufferIds: [ + for (final buffer in state.dirtyBuffers) + if (buffer.filePath != null && + isWritersideProjectPath(workspace, buffer.filePath!)) + buffer.id, + ], + ); +} + Future confirmSafeToCloseActiveDocument( BuildContext context, WidgetRef ref, @@ -69,6 +103,7 @@ Future _confirmUnsavedChanges( BuildContext context, WidgetRef ref, { required List dirtyBufferIds, + bool withoutActivatingBuffers = false, }) async { final initialState = ref.read(workspaceControllerProvider); final dirtyBuffers = [ @@ -159,17 +194,69 @@ Future _confirmUnsavedChanges( } if (action == _UnsavedChangesAction.discard) { + if (withoutActivatingBuffers) { + return ref + .read(workspaceControllerProvider.notifier) + .discardDocumentBuffers(dirtyBufferIds); + } return _discardDirtyDocuments(ref, dirtyBufferIds); } if (action == _UnsavedChangesAction.save) { if (!context.mounted) { return false; } - return _saveDirtyDocuments(context, ref, dirtyBufferIds); + return withoutActivatingBuffers + ? _saveDirtyDocumentsWithoutActivation(context, ref, dirtyBufferIds) + : _saveDirtyDocuments(context, ref, dirtyBufferIds); } return false; } +Future _saveDirtyDocumentsWithoutActivation( + BuildContext context, + WidgetRef ref, + List bufferIds, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final firstResult = await controller.saveAll(bufferIds: bufferIds); + if (firstResult.failedBufferIds.isNotEmpty || + firstResult.conflictBufferIds.isNotEmpty) { + return false; + } + if (firstResult.normalizationRequiredBufferIds.isNotEmpty) { + final normalizations = {}; + for (final bufferId in firstResult.normalizationRequiredBufferIds) { + if (!context.mounted) return false; + final buffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null || !buffer.isDirty) continue; + final normalization = await _chooseMixedLineEndingNormalizationForFormat( + context, + ref, + buffer.format, + ); + if (normalization == null) return false; + normalizations[bufferId] = normalization; + } + final normalizedResult = await controller.saveAll( + bufferIds: firstResult.normalizationRequiredBufferIds, + mixedLineEndingNormalizations: normalizations, + ); + if (!normalizedResult.succeeded) return false; + } + return bufferIds.every((id) { + final buffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == id) + .firstOrNull; + return buffer == null || !buffer.isDirty; + }); +} + Future _saveDirtyDocuments( BuildContext context, WidgetRef ref, @@ -413,8 +500,14 @@ Future _chooseMixedLineEndingNormalization( BuildContext context, WidgetRef ref, ActiveDocumentSaveTarget target, +) => _chooseMixedLineEndingNormalizationForFormat(context, ref, target.format); + +Future _chooseMixedLineEndingNormalizationForFormat( + BuildContext context, + WidgetRef ref, + TextFormatMetadata format, ) async { - if (!target.format.hasMixedLineEndings) { + if (!format.hasMixedLineEndings) { return null; } final headerBar = ref.read(linuxHeaderBarServiceProvider); diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index fbdf1699..5ee3edbe 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -96,6 +96,14 @@ class WorkspaceBatchPartialApplicationConflict implements Exception { final Object cause; } +bool _sameStringList(List first, List second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index += 1) { + if (first[index] != second[index]) return false; + } + return true; +} + class WorkspaceService { const WorkspaceService({ this.markdownParser = const MarkdownParser(), @@ -814,13 +822,31 @@ class WorkspaceService { topic: topic, newFileName: newFileName, projectModules: project.modules, + projectRoot: project.rootPath, + projectModuleRoots: project.modules + .map((module) => module.rootPath) + .toList(), ); } Future applyWritersideTopicRename( WritersideTopicRenamePlan plan, { void Function(Iterable)? validateBeforePublish, - }) { + }) async { + final projectRoot = plan.projectRoot; + if (projectRoot != null) { + final currentRoots = await _writersideProjectService.discoverModuleRoots( + projectRoot, + ); + final normalizedCurrent = currentRoots.map(normalizePath).toSet().toList() + ..sort(); + if (!_sameStringList(normalizedCurrent, plan.projectModuleRoots)) { + throw BusyMarkException( + 'writerside.topic-file.project-inventory-changed', + args: {'path': projectRoot}, + ); + } + } return writersideTopicFileEditor.applyRename( plan, validateBeforePublish: validateBeforePublish, diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index 1d2121f3..f8d248cd 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -526,7 +526,7 @@ class WritersideCategory { } class WritersideModule { - const WritersideModule({ + WritersideModule({ required this.rootPath, required this.config, required this.instances, @@ -541,9 +541,10 @@ class WritersideModule { this.sourceFiles = const {}, this.referenceData = const WritersideReferenceData(), this.semanticDiagnostics = const [], - this.unparsedTopicReferences = const {}, + Set unparsedTopicReferences = const {}, this.variablesAvailable = true, - }) : structuralDiagnostics = diagnostics; + }) : structuralDiagnostics = diagnostics, + unparsedTopicReferences = Set.unmodifiable(unparsedTopicReferences); final String rootPath; final WritersideConfig config; @@ -556,6 +557,9 @@ class WritersideModule { /// are replaced separately whenever the project dependency snapshot changes. final List structuralDiagnostics; final List semanticDiagnostics; + + /// Discovered topic sources excluded from the semantic model because they + /// could not be parsed within the configured scan limits. final Set unparsedTopicReferences; final bool variablesAvailable; List get diagnostics => diff --git a/lib/src/writerside/writerside_title_editor.dart b/lib/src/writerside/writerside_title_editor.dart index c84251df..b95d9f3a 100644 --- a/lib/src/writerside/writerside_title_editor.dart +++ b/lib/src/writerside/writerside_title_editor.dart @@ -84,16 +84,6 @@ class WritersideTitleEditor { )); } } else { - // Front-matter titles are a BusyMark Markdown extension, not the - // Writerside H1 representation edited by this action. Do not change an - // H1 while leaving a higher-precedence authored title unchanged. - if (document.nodes.whereType().any( - (node) => - node.block.kind == BusyBlockKind.frontMatter && - node.block.attributes.containsKey('title'), - )) { - throw const BusyMarkException('writerside.toc.path-invalid'); - } final heading = document.nodes .whereType() .where( diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 02a334d0..57181e94 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -257,25 +257,10 @@ class WritersideTopicCreator { if (title.isEmpty) { throw const BusyMarkException('writerside.topic.title-required'); } - if (initialSource != null && request.format == WritersideTopicFormat.xml) { - // A customized template is untrusted authoring input. Validate it before - // any directory/file publication, just like the filename and TOC identity. - XmlDocument templateDocument; - try { - templateDocument = XmlDocument.parse(initialSource); - if (templateDocument.rootElement.name.local != 'topic') { - throw const FormatException('Expected a topic root'); - } - } on Object { - throw const BusyMarkException('writerside.toc.path-invalid'); - } - final sourceId = templateDocument.rootElement.getAttribute('id'); - if (sourceId != null && target.existingTopicIds.contains(sourceId)) { - throw BusyMarkException( - 'writerside.topic.id-exists', - args: {'topicId': sourceId}, - ); - } + final topicSource = + initialSource ?? _topicSource(request.format, topicId, title); + if (request.format == WritersideTopicFormat.xml) { + _validateXmlTopicSource(topicSource, expectedId: topicId); } final topicResolution = await _topicTargetPath( @@ -309,8 +294,6 @@ class WritersideTopicCreator { if (createdTopicsRoot.type != FileSystemEntityType.directory) { throw const BusyMarkException('writerside.topic.topics-root-unsafe'); } - final topicSource = - initialSource ?? _topicSource(request.format, topicId, title); var topicCreated = false; try { await validateBeforePublish?.call(); @@ -337,6 +320,29 @@ class WritersideTopicCreator { ); } + void _validateXmlTopicSource(String source, {required String expectedId}) { + final XmlElement root; + try { + final document = XmlDocument.parse(source); + root = document.rootElement; + if (root.name.local != 'topic') { + throw const FormatException('Expected a topic root'); + } + } on Object { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + final id = root.getAttribute('id'); + if (id == null || id.isEmpty) { + throw const BusyMarkException('writerside.topic-file.missing-root-id'); + } + if (id != expectedId) { + throw BusyMarkException( + 'writerside.topic-file.root-id-mismatch', + args: {'id': id, 'expectedId': expectedId}, + ); + } + } + Future _treePath( CanonicalPathAnchor anchor, String path, diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 0dd0408f..f81b1ea4 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -74,10 +74,16 @@ class WritersideTopicRenamePlan { required this.updatesXmlTopicId, required Set affectedPaths, required List webFileNameChanges, + String? projectRoot, + List projectModuleRoots = const [], required _PreparedTopicRename prepared, }) : changedFiles = List.unmodifiable(changedFiles), affectedPaths = Set.unmodifiable(affectedPaths), webFileNameChanges = List.unmodifiable(webFileNameChanges), + projectRoot = projectRoot == null ? null : normalizePath(projectRoot), + projectModuleRoots = List.unmodifiable( + projectModuleRoots.map(normalizePath).toSet().toList()..sort(), + ), _prepared = prepared; final WritersideModule owningModule; @@ -96,6 +102,8 @@ class WritersideTopicRenamePlan { final bool updatesXmlTopicId; final Set affectedPaths; final List webFileNameChanges; + final String? projectRoot; + final List projectModuleRoots; final _PreparedTopicRename _prepared; } @@ -168,7 +176,10 @@ class WritersideTopicFileEditor { required WritersideTopic topic, required String newFileName, List? projectModules, + String? projectRoot, + List projectModuleRoots = const [], }) async { + _validateSuppliedSemanticCoverage([module, ...?projectModules]); final snapshot = await _currentModuleSnapshot(module, topic); final context = await _mutationContext(snapshot); final referenceContexts = <_ReferenceModuleContext>[ @@ -178,6 +189,7 @@ class WritersideTopicFileEditor { if (p.equals(candidate.rootPath, context.module.rootPath)) continue; referenceContexts.add(await _referenceModuleContext(candidate)); } + _validateCompleteSemanticCoverage(referenceContexts); final safeFileName = _safeRenamedFileName( newFileName, oldPath: context.topicPath, @@ -205,6 +217,8 @@ class WritersideTopicFileEditor { updatesXmlTopicId: false, affectedPaths: {context.topicPath}, webFileNameChanges: const [], + projectRoot: projectRoot, + projectModuleRoots: projectModuleRoots, prepared: _PreparedTopicRename( target: context, referenceContexts: referenceContexts, @@ -280,6 +294,8 @@ class WritersideTopicFileEditor { updatesXmlTopicId: topicEdit.updatedXmlTopicId, affectedPaths: affectedPaths, webFileNameChanges: webFileNameChanges, + projectRoot: projectRoot, + projectModuleRoots: projectModuleRoots, prepared: _PreparedTopicRename( target: context, referenceContexts: referenceContexts, @@ -289,6 +305,36 @@ class WritersideTopicFileEditor { ); } + void _validateCompleteSemanticCoverage( + List<_ReferenceModuleContext> contexts, + ) { + for (final context in contexts) { + _validateModuleSemanticCoverage(context.module); + } + } + + void _validateSuppliedSemanticCoverage(List modules) { + final seenRoots = {}; + for (final module in modules) { + if (seenRoots.add(normalizePath(module.rootPath))) { + _validateModuleSemanticCoverage(module); + } + } + } + + void _validateModuleSemanticCoverage(WritersideModule module) { + final skipped = module.unparsedTopicReferences.toList()..sort(); + if (skipped.isEmpty) return; + throw BusyMarkException( + 'writerside.topic-file.incomplete-project-index', + args: { + 'module': module.config.moduleName ?? module.rootPath, + 'paths': skipped.join(', '), + 'count': '${skipped.length}', + }, + ); + } + Future applyRename( WritersideTopicRenamePlan plan, { void Function(Iterable)? validateBeforePublish, @@ -343,9 +389,10 @@ class WritersideTopicFileEditor { final safeToCleanUp = restored && await _renameTargetIsSafeToCleanUp( - context, + referenceContexts, targetPath: plan.newTopicPath, targetSource: prepared.targetSource, + targetModuleRoot: context.module.rootPath, ); if (safeToCleanUp) { await _deleteCreatedFileBestEffort( @@ -2462,17 +2509,41 @@ class WritersideTopicFileEditor { } Future _renameTargetIsSafeToCleanUp( - _MutationContext context, { + List<_ReferenceModuleContext> contexts, { required String targetPath, required String targetSource, + required String targetModuleRoot, }) async { try { - await _ensureConfigurationUnchanged(context); - await _ensureTreesAtExpectedSources(context, const []); - await _ensureTopicSourcesUnchanged( - context, - additionalSources: {targetPath: targetSource}, - ); + for (final context in contexts) { + final configuration = await _configurationSources( + context.module.rootPath, + ); + if (!_sameStringMap(configuration, context.configurationSources)) { + return false; + } + for (final tree in context.trees) { + final resolved = await _resolvePath( + context.anchor, + tree.path, + allowRoot: false, + ); + if (resolved.type != FileSystemEntityType.file || + await File(resolved.path).readAsString() != tree.source) { + return false; + } + } + final expectedTopics = { + ...context.topicSources, + if (p.equals(context.module.rootPath, targetModuleRoot)) + normalizePath(targetPath): targetSource, + }; + final currentTopics = await _topicSources( + context.anchor, + context.module, + ); + if (!_sameStringMap(currentTopics, expectedTopics)) return false; + } return true; } on Object { // A concurrent edit may now reference the new path. Retaining a harmless diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 6ed4c208..e4a9fe66 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -61,6 +61,7 @@ import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -2888,6 +2889,27 @@ void main() { expect(File(p.join(topics.path, 'setup.topic')).existsSync(), isFalse); controller.rejectTopicRename = false; + controller.appliedTopicRenamePlan = null; + controller.markWritersideProjectBufferDirty( + path: linksPath, + text: '# Links\n\nUnsaved after preview.\n', + lastSavedText: File(linksPath).readAsStringSync(), + ); + await tester.tap(find.text(l10n.doRefactor)); + await tester.pumpAndSettle(); + expect(find.text(l10n.topicRenamePreviewTitle), findsNothing); + expect(controller.appliedTopicRenamePlan, isNull); + expect(File(topicPath).readAsStringSync(), topicSource); + expect(File(p.join(topics.path, 'setup.topic')).existsSync(), isFalse); + + controller.clearDocumentBuffersForTest(); + await openPopup(firstRow); + await tester.tap(find.text(l10n.renameTopicFile)); + await tester.pumpAndSettle(); + await enterRename('setup.topic'); + await tester.tap(find.text(l10n.preview)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.text(l10n.doRefactor)); await tester.pumpAndSettle(); expect(controller.appliedTopicRenamePlan, same(setupPlan)); @@ -2957,6 +2979,209 @@ void main() { }, ); + testWidgets( + 'topic rename saves every dirty Writerside project source before planning', + (tester) async { + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(yaruWindowChannel, (call) async { + if (call.method == 'state') return {}; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(yaruWindowEventsChannel, (_) async => null); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(yaruWindowChannel, null) + ..setMockMethodCallHandler(yaruWindowEventsChannel, null); + }); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + await binding.setSurfaceSize(const Size(1440, 900)); + addTearDown(() async { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + await binding.setSurfaceSize(null); + }); + + final root = Directory.systemTemp.createTempSync( + 'busymark-topic-rename-dirty-project-', + ); + addTearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + final topics = Directory(p.join(root.path, 'topics'))..createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync( + '\n', + ); + const savedTree = ''' + + + + + +'''; + const dirtyTree = ''' + + + + + + +'''; + final tree = File(p.join(root.path, 'guide.tree')) + ..writeAsStringSync(savedTree); + final guide = File(p.join(topics.path, 'guide.md')) + ..writeAsStringSync('# Guide\n'); + final markdown = File(p.join(topics.path, 'notes.md')) + ..writeAsStringSync('# Notes\n'); + final xml = File(p.join(topics.path, 'notes.topic')) + ..writeAsStringSync('\n'); + const workspaceService = WorkspaceService(); + final workspace = (await tester.runAsync( + () => workspaceService.openPath(root.path), + ))!; + + Future buffer( + String id, + File file, + String saved, + String current, + ) async { + final snapshot = (await tester.runAsync( + () => workspaceService.fileSnapshot(file.path), + ))!; + return DocumentBuffer.file( + id: id, + filePath: file.path, + text: saved, + snapshot: snapshot, + format: TextFormatMetadata.utf8Lf, + ).edited(current); + } + + final cleanGuide = await buffer('guide', guide, '# Guide\n', '# Guide\n'); + final dirtyMarkdown = await buffer( + 'markdown', + markdown, + '# Notes\n', + '# Notes\n\n[Guide](guide.md)\n', + ); + final dirtyXml = await buffer( + 'xml', + xml, + '\n', + 'Guide\n', + ); + final dirtyTreeBuffer = await buffer('tree', tree, savedTree, dirtyTree); + tree.writeAsStringSync(dirtyTree); + markdown.writeAsStringSync('# Notes\n\n[Guide](guide.md)\n'); + xml.writeAsStringSync( + 'Guide\n', + ); + final authoritativePlan = (await tester.runAsync( + () => workspaceService.prepareWritersideTopicRename( + workspace, + topicPath: guide.path, + newFileName: 'setup.md', + topicModuleRoot: root.path, + ), + ))!; + tree.writeAsStringSync(savedTree); + markdown.writeAsStringSync('# Notes\n'); + xml.writeAsStringSync('\n'); + final controller = + _MutableWorkspaceController( + WorkspaceState( + workspace: workspace.copyWith( + activeFilePath: guide.path, + openFilePaths: [ + tree.path, + markdown.path, + xml.path, + guide.path, + ], + ), + documentBuffers: [ + dirtyTreeBuffer, + dirtyMarkdown, + dirtyXml, + cleanGuide, + ], + activeBufferId: cleanGuide.id, + ), + ) + ..topicRenamePlans['setup.md'] = authoritativePlan + ..simulateTopicRename = true; + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var index = 0; index < 10; index++) { + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.toc)); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + ); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f6); + await tester.sendKeyUpEvent(LogicalKeyboardKey.f6); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + final renameField = find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(TextField), + ); + await tester.enterText(renameField, 'setup.md'); + await tester.tap(find.text(l10n.tocRefactorMenu)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.unsavedChanges), findsOneWidget); + for (final name in ['guide.tree', 'notes.md', 'notes.topic']) { + expect(find.text(name), findsWidgets); + } + expect(controller.topicRenamePrepareCount, 0); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + + expect(controller.topicRenamePrepareCount, 1); + expect(tree.readAsStringSync(), dirtyTree); + expect(markdown.readAsStringSync(), contains('(guide.md)')); + expect(xml.readAsStringSync(), contains('href="guide.md"')); + final prepared = controller.appliedTopicRenamePlan; + expect(prepared, isNotNull); + final plannedByPath = { + for (final change in prepared!.changedFiles) + p.basename(change.path): change.resultingSource, + }; + for (final name in ['guide.tree', 'notes.md', 'notes.topic']) { + expect( + plannedByPath[name], + contains('setup.md'), + reason: '$name must be part of the clean-disk authoritative plan', + ); + } + expect(File(guide.path).existsSync(), isTrue); + expect(File(p.join(topics.path, 'setup.md')).existsSync(), isFalse); + }, + ); + testWidgets('tab keyboard shortcuts move and close editor tabs', ( tester, ) async { @@ -10872,6 +11097,29 @@ class _MutableWorkspaceController extends WorkspaceController { var rejectTopicRename = false; WritersideTopicRenamePlan? appliedTopicRenamePlan; + void markWritersideProjectBufferDirty({ + required String path, + required String text, + required String lastSavedText, + }) { + state = state.copyWith( + documentBuffers: [ + DocumentBuffer( + id: 'topic-rename-dirty', + filePath: path, + text: text, + lastSavedText: lastSavedText, + dirty: true, + ), + ], + activeBufferId: null, + ); + } + + void clearDocumentBuffersForTest() { + state = state.copyWith(documentBuffers: const [], activeBufferId: null); + } + @override void updateActiveEditorMode(DocumentViewModePreference mode) { requestedEditorMode = mode; @@ -10891,6 +11139,43 @@ class _MutableWorkspaceController extends WorkspaceController { @override WorkspaceState build() => initialState; + @override + Future saveAll({ + Iterable? bufferIds, + Map mixedLineEndingNormalizations = + const {}, + }) async { + final included = bufferIds?.toSet(); + final saved = []; + final failed = []; + final replacements = {}; + for (final buffer in state.documentBuffers) { + if (!buffer.isDirty || + buffer.filePath == null || + (included != null && !included.contains(buffer.id))) { + continue; + } + try { + File(buffer.filePath!).writeAsStringSync(buffer.text, flush: true); + saved.add(buffer.id); + replacements[buffer.id] = buffer.copyWith( + lastSavedText: buffer.text, + dirty: false, + revision: buffer.revision + 1, + ); + } on Object { + failed.add(buffer.id); + } + } + state = state.copyWith( + documentBuffers: [ + for (final buffer in state.documentBuffers) + replacements[buffer.id] ?? buffer, + ], + ); + return SaveAllResult(savedBufferIds: saved, failedBufferIds: failed); + } + @override Future flushPersistence() async {} @@ -10990,6 +11275,23 @@ class _MutableWorkspaceController extends WorkspaceController { @override Future applyWritersideTopicRename(WritersideTopicRenamePlan plan) { + final workspace = state.workspace; + if (workspace != null && + state.dirtyBuffers.any( + (buffer) => + buffer.filePath != null && + isWritersideProjectPath(workspace, buffer.filePath!), + )) { + state = state.copyWith( + message: const WorkspaceMessage( + WorkspaceMessageCode.fileOperationFailed, + error: BusyMarkException( + 'writerside.topic-file.project-buffers-dirty', + ), + ), + ); + return Future.value(false); + } appliedTopicRenamePlan = plan; if (rejectTopicRename) { state = state.copyWith( diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index 800ed5a7..95009506 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -54,6 +54,31 @@ void main() { expect(parsed.title, 'Front Matter Title'); }); + test('Writerside Markdown title comes from H1, not front matter', () { + final parsed = parser.parse( + filePath: 'guide.md', + source: '---\ntitle: Front Matter\n---\n\n# Writerside H1\n', + mode: MarkdownMode.writersideMarkdown, + ); + + expect(parsed.title, 'Writerside H1'); + expect(parsed.busyDocument.frontMatter['title'], 'Front Matter'); + }); + + test('Writerside front matter title does not satisfy missing H1', () { + final parsed = parser.parse( + filePath: 'guide.md', + source: '---\ntitle: Front Matter\n---\n', + mode: MarkdownMode.writersideMarkdown, + ); + + expect(parsed.title, isNull); + expect( + parsed.diagnostics.map((diagnostic) => diagnostic.code), + contains('writerside.topic.missing-title'), + ); + }); + test('parseAsync handles documents above the background threshold', () async { final source = List.generate( 1800, diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 090e00d8..c7eea075 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/core/busymark_exception.dart'; import 'package:busymark/src/core/source_span.dart'; import 'package:busymark/src/local_history/local_history_controller.dart'; import 'package:busymark/src/local_history/local_history_models.dart'; @@ -758,6 +759,56 @@ void main() { }, ); + test( + 'reviewed topic rename is rejected after a project buffer becomes dirty', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-controller-stale-topic-preview-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + final tree = File(p.join(root.path, 'guide.tree')); + const treeSource = + ''; + await tree.writeAsString(treeSource); + final guide = File(p.join(root.path, 'topics', 'guide.md')); + final notes = File(p.join(root.path, 'topics', 'notes.md')); + await guide.writeAsString('# Guide\n'); + await notes.writeAsString('# Notes\n'); + final harness = await _createControllerHarness( + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final plan = await controller.prepareWritersideTopicRename( + guide.path, + 'setup.md', + topicModuleRoot: root.path, + ); + expect(plan, isNotNull); + await controller.openActiveFile(notes.path); + controller.updateActiveText('# Notes\n\nNew unsaved project content.\n'); + + expect(await controller.applyWritersideTopicRename(plan!), isFalse); + + expect(await guide.readAsString(), '# Guide\n'); + expect(await notes.readAsString(), '# Notes\n'); + expect(await tree.readAsString(), treeSource); + expect( + await File(p.join(root.path, 'topics', 'setup.md')).exists(), + isFalse, + ); + expect( + (harness.controller.state.message!.error as BusyMarkException).code, + 'writerside.topic-file.project-buffers-dirty', + ); + }, + ); + test('save as writes a new Markdown file and records it as recent', () async { final directory = await Directory.systemTemp.createTemp( 'busymark-save-as-', @@ -1282,6 +1333,88 @@ void main() { settingsController.dispose(); }); + test( + 'Save All can be scoped without saving unrelated dirty buffers', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-save-selected-', + ); + addTearDown(() => directory.delete(recursive: true)); + final first = File(p.join(directory.path, 'a.md')) + ..writeAsStringSync('# A\n'); + final second = File(p.join(directory.path, 'b.md')) + ..writeAsStringSync('# B\n'); + final unrelated = File(p.join(directory.path, 'outside.md')) + ..writeAsStringSync('# Outside\n'); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await controller.openPath(directory.path); + controller.updateActiveText('# Edited A\n'); + expect(await controller.openActiveFile(second.path), isTrue); + controller.updateActiveText('# Edited B\n'); + expect(await controller.openActiveFile(unrelated.path), isTrue); + controller.updateActiveText('# Edited outside\n'); + final selectedIds = controller.state.documentBuffers + .where( + (buffer) => + buffer.filePath == first.path || buffer.filePath == second.path, + ) + .map((buffer) => buffer.id) + .toList(); + + final result = await controller.saveAll(bufferIds: selectedIds); + + expect(result.savedBufferIds, unorderedEquals(selectedIds)); + expect(first.readAsStringSync(), '# Edited A\n'); + expect(second.readAsStringSync(), '# Edited B\n'); + expect(unrelated.readAsStringSync(), '# Outside\n'); + expect(controller.state.dirtyBuffers.map((buffer) => buffer.filePath), [ + unrelated.path, + ]); + + controller.dispose(); + settingsController.dispose(); + }, + ); + + test( + 'selected dirty buffers can be discarded without changing tabs', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-discard-selected-', + ); + addTearDown(() => directory.delete(recursive: true)); + final first = File(p.join(directory.path, 'a.md')) + ..writeAsStringSync('# A\n'); + final second = File(p.join(directory.path, 'b.md')) + ..writeAsStringSync('# B\n'); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await controller.openPath(directory.path); + controller.updateActiveText('# Edited A\n'); + expect(await controller.openActiveFile(second.path), isTrue); + controller.updateActiveText('# Edited B\n'); + final dirtyIds = controller.state.dirtyBuffers + .map((buffer) => buffer.id) + .toList(); + + expect(await controller.discardDocumentBuffers(dirtyIds), isTrue); + + expect(controller.state.workspace?.activeFilePath, second.path); + expect(controller.state.dirtyBuffers, isEmpty); + expect(first.readAsStringSync(), '# A\n'); + expect(second.readAsStringSync(), '# B\n'); + expect(controller.state.activeText, '# B\n'); + + controller.dispose(); + settingsController.dispose(); + }, + ); + test('closing active file tabs selects a neighboring tab', () async { final harness = await _createControllerHarness(); final settingsController = harness.settingsController; @@ -3349,7 +3482,11 @@ class _WorkspaceControllerDriver { Future saveActiveAs(String path, {bool overwriteExisting = false}) => _notifier.saveActiveAs(path, overwriteExisting: overwriteExisting); - Future saveAll() => _notifier.saveAll(); + Future saveAll({Iterable? bufferIds}) => + _notifier.saveAll(bufferIds: bufferIds); + + Future discardDocumentBuffers(Iterable bufferIds) => + _notifier.discardDocumentBuffers(bufferIds); Future autoSaveActiveIfNeeded() => _notifier.autoSaveActiveIfNeeded(); diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index 5e7a3203..6aad6ac1 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -414,6 +414,80 @@ void main() { }, ); + test( + 'prepared topic rename is stale when a new module is discovered', + () async { + final fixture = await _createTopicRenameWorkspace(); + final plan = await service.prepareWritersideTopicRename( + fixture.workspace, + topicPath: fixture.guidePath, + newFileName: 'setup.md', + topicModuleRoot: fixture.mainRoot.path, + ); + final shared = await Directory( + p.join(fixture.projectRoot.path, 'shared'), + ).create(); + await Directory(p.join(shared.path, 'topics')).create(); + await File(p.join(shared.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(shared.path, 'shared.tree')).writeAsString(''' + + + +'''); + await File( + p.join(shared.path, 'topics', 'shared.md'), + ).writeAsString('# Shared\n'); + + await expectLater( + service.applyWritersideTopicRename(plan), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.project-inventory-changed', + ), + ), + ); + + expect(File(fixture.guidePath).existsSync(), isTrue); + expect( + File(p.join(fixture.mainRoot.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + expect( + await File(p.join(fixture.mainRoot.path, 'guide.tree')).readAsString(), + contains('guide.md'), + ); + }, + ); + + test( + 'ordinary directory creation does not stale a prepared rename', + () async { + final fixture = await _createTopicRenameWorkspace(); + final plan = await service.prepareWritersideTopicRename( + fixture.workspace, + topicPath: fixture.guidePath, + newFileName: 'setup.md', + topicModuleRoot: fixture.mainRoot.path, + ); + await Directory( + p.join(fixture.projectRoot.path, 'ordinary-directory'), + ).create(); + + final result = await service.applyWritersideTopicRename(plan); + + expect( + result.newTopicPath, + p.join(fixture.mainRoot.path, 'topics', 'setup.md'), + ); + expect(File(fixture.guidePath).existsSync(), isFalse); + expect(File(result.newTopicPath).existsSync(), isTrue); + }, + ); + test('normalizes portal-style document paths as filesystem paths', () { const path = '/run/user/1000/doc/abcdef/smoke.md'; @@ -1108,6 +1182,48 @@ void main() { }); } +Future< + ({ + Directory projectRoot, + Directory mainRoot, + String guidePath, + Workspace workspace, + }) +> +_createTopicRenameWorkspace() async { + final projectRoot = await Directory.systemTemp.createTemp( + 'busymark-workspace-rename-inventory-', + ); + addTearDown(() async { + if (await projectRoot.exists()) { + await projectRoot.delete(recursive: true); + } + }); + final mainRoot = await Directory(p.join(projectRoot.path, 'main')).create(); + await Directory(p.join(mainRoot.path, 'topics')).create(); + await File(p.join(mainRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(mainRoot.path, 'guide.tree')).writeAsString(''' + + + +'''); + final guidePath = p.join(mainRoot.path, 'topics', 'guide.md'); + await File(guidePath).writeAsString('# Guide\n'); + final canonicalProject = Directory(await projectRoot.resolveSymbolicLinks()); + final canonicalMain = Directory(await mainRoot.resolveSymbolicLinks()); + final workspace = await const WorkspaceService().openPath( + canonicalProject.path, + ); + return ( + projectRoot: canonicalProject, + mainRoot: canonicalMain, + guidePath: p.join(canonicalMain.path, 'topics', 'guide.md'), + workspace: workspace, + ); +} + Future<_WorkspaceSymlinkFixture> _createWorkspaceSymlinkFixture() async { final base = await Directory.systemTemp.createTemp( 'busymark-workspace-symlink-escape-', diff --git a/test/src/writerside_template_dialogs_test.dart b/test/src/writerside_template_dialogs_test.dart index 0e857858..33cabc6e 100644 --- a/test/src/writerside_template_dialogs_test.dart +++ b/test/src/writerside_template_dialogs_test.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/core/busymark_exception.dart'; import 'package:busymark/src/workspace/presentation/writerside_template_dialogs.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_template_service.dart'; +import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_parsers.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -177,6 +180,84 @@ void main() { expect(find.text('File and Code Templates'), findsNothing); }); + testWidgets('custom XML template cannot publish a mismatching root ID', ( + tester, + ) async { + const invalidTemplate = WritersideTemplate( + id: 'custom-invalid-xml-id', + name: 'Broken XML ID', + category: 'custom', + extension: 'topic', + source: '', + ); + await tester.runAsync( + () async => service.save(await service.read(), [invalidTemplate]), + ); + final project = Directory(p.join(root.path, 'project'))..createSync(); + final tree = File(p.join(project.path, 'guide.tree')); + const originalTree = '\n'; + tree.writeAsStringSync(originalTree); + var createAttempts = 0; + + await show( + tester, + WritersideTemplateDialog( + existingIds: const {}, + previewBuilder: (_, template, title, filename) => Text( + WritersideTemplateService.generate( + template, + title: title, + id: filename, + ), + ), + onCreate: (template, title, filename) async { + createAttempts++; + try { + await const WritersideTopicCreator().create( + WritersideTopicCreateTarget( + rootPath: project.path, + treePath: tree.path, + topicsRootDir: 'topics', + existingTopicIds: const {}, + ), + WritersideTopicCreateRequest( + title: title, + fileName: '$filename.topic', + format: WritersideTopicFormat.xml, + ), + initialSource: WritersideTemplateService.generate( + template, + title: title, + id: filename, + ), + ); + return null; + } on BusyMarkException catch (error) { + return error.code; + } + }, + ), + ); + await tester.tap(find.text('Broken XML ID')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('template-filename')), + 'setup', + ); + await tester.tap(find.text('Create')); + await settle(tester); + + expect(createAttempts, 1); + expect(find.text('writerside.topic-file.root-id-mismatch'), findsOneWidget); + expect(find.text('Create Topic from Template'), findsOneWidget); + expect( + File(p.join(project.path, 'topics', 'setup.topic')).existsSync(), + isFalse, + ); + expect(Directory(p.join(project.path, 'topics')).existsSync(), isFalse); + expect(tree.readAsStringSync(), originalTree); + }); + testWidgets( 'save-as-template appears under Custom; edit, duplicate and delete preserve cancel', (tester) async { diff --git a/test/src/writerside_template_service_test.dart b/test/src/writerside_template_service_test.dart index 657b0af3..7c2daacb 100644 --- a/test/src/writerside_template_service_test.dart +++ b/test/src/writerside_template_service_test.dart @@ -399,7 +399,13 @@ void main() { topicsRootDir: 'topics', existingTopicIds: {'occupied'}, ); - for (final source in ['', '']) { + for (final source in [ + '', + '', + '', + '', + ]) { await expectLater( const WritersideTopicCreator().create( target, diff --git a/test/src/writerside_toc_actions_test.dart b/test/src/writerside_toc_actions_test.dart index 38dde97d..b6f79866 100644 --- a/test/src/writerside_toc_actions_test.dart +++ b/test/src/writerside_toc_actions_test.dart @@ -141,23 +141,32 @@ void main() { }, ); - test('front-matter title cannot be silently shadowed by an H1 edit', () { + test('Markdown title edit changes H1 and preserves front matter title', () { final topic = parser.parseMarkdown( filePath: '/topics/topic.md', - source: '---\ntitle: Front matter\n---\n# Heading\n', + source: '---\ntitle: Metadata\n---\n\n# Old\n', topicsRoot: '/topics', ); + final result = titleEditor.prepare( + topic: topic, + instanceId: 'guide', + treePath: '/guide.tree', + treeSource: tree, + tocPath: [1], + tocIdentity: identity, + edit: const WritersideTitleEdit(title: 'New'), + ); + + expect(result.topicSource, '---\ntitle: Metadata\n---\n\n# New\n'); expect( - () => titleEditor.prepare( - topic: topic, - instanceId: 'guide', - treePath: '/guide.tree', - treeSource: tree, - tocPath: [1], - tocIdentity: identity, - edit: const WritersideTitleEdit(title: 'New'), - ), - throwsA(isA()), + parser + .parseMarkdown( + filePath: '/topics/topic.md', + source: result.topicSource, + topicsRoot: '/topics', + ) + .title, + 'New', ); }); diff --git a/test/src/writerside_topic_creator_test.dart b/test/src/writerside_topic_creator_test.dart index 8636ac99..ef0c47b3 100644 --- a/test/src/writerside_topic_creator_test.dart +++ b/test/src/writerside_topic_creator_test.dart @@ -109,6 +109,114 @@ void main() { ); }); + test( + 'rejects custom XML source without a root ID before publication', + () async { + final root = await tempModule(); + final tree = File(p.join(root.path, 'ug.tree')); + final originalTree = tree.readAsStringSync(); + + await expectLater( + creator.create( + WritersideTopicCreateTarget( + rootPath: root.path, + treePath: tree.path, + topicsRootDir: 'custom-topics', + existingTopicIds: const {'intro'}, + ), + const WritersideTopicCreateRequest( + title: 'Setup', + fileName: 'setup.topic', + format: WritersideTopicFormat.xml, + ), + initialSource: '', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.missing-root-id', + ), + ), + ); + + expect( + Directory(p.join(root.path, 'custom-topics')).existsSync(), + isFalse, + ); + expect(tree.readAsStringSync(), originalTree); + }, + ); + + test( + 'rejects custom XML source whose root ID differs from filename', + () async { + final root = await tempModule(); + final tree = File(p.join(root.path, 'ug.tree')); + final originalTree = tree.readAsStringSync(); + + await expectLater( + creator.create( + WritersideTopicCreateTarget( + rootPath: root.path, + treePath: tree.path, + topicsRootDir: 'custom-topics', + existingTopicIds: const {'intro'}, + ), + const WritersideTopicCreateRequest( + title: 'Setup', + fileName: 'setup.topic', + format: WritersideTopicFormat.xml, + ), + initialSource: '', + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'writerside.topic-file.root-id-mismatch', + ) + .having( + (error) => error.args['expectedId'], + 'expected ID', + 'setup', + ), + ), + ); + + expect( + Directory(p.join(root.path, 'custom-topics')).existsSync(), + isFalse, + ); + expect(tree.readAsStringSync(), originalTree); + }, + ); + + test('accepts custom XML source whose root ID matches filename', () async { + final root = await tempModule(); + final result = await creator.create( + WritersideTopicCreateTarget( + rootPath: root.path, + treePath: p.join(root.path, 'ug.tree'), + topicsRootDir: 'topics', + existingTopicIds: const {'intro'}, + ), + const WritersideTopicCreateRequest( + title: 'Setup', + fileName: 'setup.topic', + format: WritersideTopicFormat.xml, + ), + initialSource: '', + ); + + expect(File(result.topicPath).readAsStringSync(), contains('id="setup"')); + expect( + File(p.join(root.path, 'ug.tree')).readAsStringSync(), + contains('setup.topic'), + ); + }); + test('requires a reference for every non-root placement', () async { final root = await tempModule(); diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 887a0108..3bcaa0f6 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; @@ -11,6 +12,118 @@ import 'package:xml/xml.dart'; void main() { const editor = WritersideTopicFileEditor(); + test('rename planning rejects a topic skipped by file-size limits', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'huge.md': '# Huge\n\n[Guide](guide.md)\n${'content ' * 80}', + }, + scanOptions: const WorkspaceScanOptions(maxParsedFileBytes: 256), + ); + final guide = _topic(fixture.module, 'guide.md'); + final original = File(guide.filePath).readAsStringSync(); + + await expectLater( + editor.prepareRename( + module: fixture.module, + topic: guide, + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.incomplete-project-index', + ), + ), + ); + + expect(File(guide.filePath).readAsStringSync(), original); + expect( + File(p.join(fixture.root.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }); + + test('rename planning rejects a topic skipped by document limits', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'huge.md': '# Huge\n\n[Guide](guide.md)\n', + }, + scanOptions: const WorkspaceScanOptions(maxParsedDocuments: 1), + ); + + await expectLater( + editor.prepareRename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.incomplete-project-index', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }); + + test( + 'rename planning rejects a discovered unreadable topic source', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: {'guide.md': '# Guide\n', 'invalid.md': '# Placeholder\n'}, + beforeLoad: (root) { + File( + p.join(root.path, 'topics', 'invalid.md'), + ).writeAsBytesSync([0xff, 0xfe]); + }, + ); + + await expectLater( + editor.prepareRename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.incomplete-project-index', + ), + ), + ); + }, + ); + test( 'rename updates topic and start-page references in every tree', () async { @@ -1610,6 +1723,129 @@ Following-line destination: [Guide][next]. }, ); + test( + 'cross-module rollback retains a target after another module changes', + () async { + final projectRoot = await Directory.systemTemp.createTemp( + 'busymark-topic-file-cross-module-rollback-', + ); + addTearDown(() async { + if (await projectRoot.exists()) { + await projectRoot.delete(recursive: true); + } + }); + + Future createModule({ + required String directory, + required String moduleName, + required String topicName, + required String topicSource, + }) async { + final root = await Directory( + p.join(projectRoot.path, directory), + ).create(); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(root.path, 'guide.tree')).writeAsString(''' + + + +'''); + await File( + p.join(root.path, 'topics', topicName), + ).writeAsString(topicSource); + return const WritersideModuleService().load(root.path); + } + + final main = await createModule( + directory: 'main', + moduleName: 'main', + topicName: 'guide.md', + topicSource: '# Guide\n', + ); + final shared = await createModule( + directory: 'shared', + moduleName: 'shared', + topicName: 'watch.md', + topicSource: '# Watch\n', + ); + final mainTree = File(p.join(main.rootPath, 'guide.tree')); + final originalMainTree = await mainTree.readAsString(); + final concurrent = File(p.join(shared.rootPath, 'topics', 'watch.md')); + const concurrentSource = + '# Watch\n\nSetup\n'; + var changed = false; + final racingEditor = WritersideTopicFileEditor( + beforeTreePublish: (_) async { + if (!changed) { + changed = true; + await concurrent.writeAsString(concurrentSource, flush: true); + } + }, + ); + + await expectLater( + racingEditor.rename( + module: main, + topic: _topic(main, 'guide.md'), + newFileName: 'setup.md', + projectModules: [main, shared], + ), + throwsA(isA()), + ); + + expect(await mainTree.readAsString(), originalMainTree); + expect( + File(p.join(main.rootPath, 'topics', 'guide.md')).existsSync(), + isTrue, + ); + expect( + File(p.join(main.rootPath, 'topics', 'setup.md')).readAsStringSync(), + '# Guide\n', + ); + expect(await concurrent.readAsString(), concurrentSource); + }, + ); + + test('clean rollback removes the harmless newly created target', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: {'guide.md': '# Guide\n'}, + ); + final tree = File(p.join(fixture.root.path, 'guide.tree')); + final originalTree = await tree.readAsString(); + final racingEditor = WritersideTopicFileEditor( + beforeTreePublish: (_) async => throw StateError('stop publication'), + ); + + await expectLater( + racingEditor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ), + throwsStateError, + ); + + expect(await tree.readAsString(), originalTree); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isTrue, + ); + expect( + File(p.join(fixture.root.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }); + test('rename preserves topic and instance tree file modes', () async { final fixture = await _fixture( trees: { @@ -1829,6 +2065,8 @@ Following-line destination: [Guide][next]. Future<({Directory root, WritersideModule module})> _fixture({ required Map trees, required Map topics, + WorkspaceScanOptions scanOptions = const WorkspaceScanOptions(), + void Function(Directory root)? beforeLoad, }) async { final root = await Directory.systemTemp.createTemp( 'busymark-topic-file-editor-', @@ -1856,9 +2094,13 @@ Future<({Directory root, WritersideModule module})> _fixture({ file.parent.createSync(recursive: true); file.writeAsStringSync(entry.value); } + beforeLoad?.call(root); return ( root: Directory(await root.resolveSymbolicLinks()), - module: await const WritersideModuleService().load(root.path), + module: await const WritersideModuleService().load( + root.path, + options: scanOptions, + ), ); } diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index dc6e2c59..ac8a5826 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -651,6 +651,61 @@ class _HarnessState extends ConsumerState<_Harness> { !renamedTreeSource.contains('native-child.topic'), 'Do Refactor changes the TOC reference with the filename', ); + await _tapLabel('Native new XML'); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.down, + ); + await _key(PhysicalKeyboardKey.f6, LogicalKeyboardKey.f6); + _keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.up, + ); + await _until( + () => _elements( + (widget) => + widget.runtimeType.toString() == 'WritersideTopicRenameDialog', + ).isNotEmpty, + 'Second Shift+F6 opens the topic rename dialog', + ); + await _fill('File name', 'native-child-stale.topic'); + await _tapLabel('Preview'); + await _until( + () => _elements( + (widget) => widget is Text && widget.data == 'Rename Preview', + ).isNotEmpty, + 'Second topic rename preview opens', + ); + controller.updateActiveText( + '${ref.read(workspaceControllerProvider).activeText}\n' + '\n', + ); + await _pause(); + final staleTarget = File( + p.join(widget.root.path, 'topics/native-child-stale.topic'), + ); + await _tapLabel('Do Refactor'); + await _until( + () => + _elements( + (widget) => widget is Text && widget.data == 'Rename Preview', + ).isEmpty && + !ref.read(workspaceControllerProvider).isLoading, + 'Dirty project buffer invalidates the rename preview', + ); + _check( + renamedChild.existsSync() && + !staleTarget.existsSync() && + ref.read(workspaceControllerProvider).activeBuffer?.isDirty == true, + 'Dirty-after-Preview refuses Do Refactor without changing files', + ); + await _capture('22-topic-rename-stale'); + _check( + await controller.discardActiveChanges(), + 'Stale-preview smoke edit can be discarded safely', + ); await _menu('Native new Markdown'); await _tapLabel('New Topic'); await _tapLabel('Link Topic Files to TOC...'); From cbfd4d9f56ef4889ac773db0b2faf124f3d8ef48 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 20:43:39 -0700 Subject: [PATCH 20/35] Enforce discovery completion checks for module and topic handling, refactor related diagnostics and reserved topic ID logic. --- lib/src/core/path_utils.dart | 26 +- .../presentation/workspace_screen.dart | 15 +- lib/src/workspace/workspace_message.dart | 5 + lib/src/workspace/workspace_service.dart | 30 +- lib/src/writerside/writerside_model.dart | 17 ++ .../writerside/writerside_module_service.dart | 9 + lib/src/writerside/writerside_project.dart | 47 +++- .../writerside/writerside_topic_creator.dart | 5 + .../writerside_topic_file_editor.dart | 6 + test/src/path_utils_test.dart | 29 ++ test/src/workspace_service_test.dart | 266 ++++++++++++++++++ .../writerside_topic_file_editor_test.dart | 114 ++++++++ 12 files changed, 547 insertions(+), 22 deletions(-) diff --git a/lib/src/core/path_utils.dart b/lib/src/core/path_utils.dart index b20a00f5..170bbf5e 100644 --- a/lib/src/core/path_utils.dart +++ b/lib/src/core/path_utils.dart @@ -85,10 +85,18 @@ class WorkspaceScanResult { const WorkspaceScanResult({ required this.entities, required this.diagnostics, + required this.traversalComplete, }); final List entities; final List diagnostics; + + /// Whether every reachable directory entry was inspected. + /// + /// Diagnostics describe why a scan was incomplete, but callers that must + /// prove a filesystem inventory is exhaustive should use this bit rather + /// than infer completeness from the set of discovered files. + final bool traversalComplete; } String normalizePath(String path) { @@ -147,7 +155,11 @@ Future scanWorkspaceEntities( }) async { final directory = Directory(rootPath); if (!await directory.exists()) { - return const WorkspaceScanResult(entities: [], diagnostics: []); + return const WorkspaceScanResult( + entities: [], + diagnostics: [], + traversalComplete: true, + ); } final listDirectory = directoryLister ?? _listWorkspaceDirectory; final entities = []; @@ -156,6 +168,7 @@ Future scanWorkspaceEntities( var pendingIndex = 0; var inspectedEntries = 0; var reachedTreeEntryLimit = options.maxTreeEntries <= 0; + var traversalComplete = !reachedTreeEntryLimit; while (pendingIndex < pending.length && inspectedEntries < options.maxTreeEntries) { @@ -174,6 +187,7 @@ Future scanWorkspaceEntities( } } } on Object catch (error) { + traversalComplete = false; diagnostics.add( _scanWarning( current.path, @@ -193,6 +207,7 @@ Future scanWorkspaceEntities( followLinks: options.followLinks, ); } on Object catch (error) { + traversalComplete = false; diagnostics.add( _scanWarning( entity.path, @@ -202,6 +217,13 @@ Future scanWorkspaceEntities( ); continue; } + if (type == FileSystemEntityType.notFound) { + traversalComplete = false; + diagnostics.add( + _scanWarning(entity.path, 'workspace.scan.inspect-failed'), + ); + continue; + } if (type == FileSystemEntityType.directory) { final name = p.basename(entity.path); if (versionControlMetadataDirectoryNames.contains(name) || @@ -228,12 +250,14 @@ Future scanWorkspaceEntities( } } if (reachedTreeEntryLimit) { + traversalComplete = false; diagnostics.add(_scanWarning(rootPath, 'workspace.scan.skipped')); } entities.sort((a, b) => a.path.compareTo(b.path)); return WorkspaceScanResult( entities: entities, diagnostics: sortDiagnostics(diagnostics), + traversalComplete: traversalComplete, ); } diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 40b17d37..4f5e6959 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -6781,8 +6781,8 @@ class _TocTabState extends ConsumerState<_TocTab> { if (!isValidWritersideTopicId(name.trim())) { return context.l10n.useIdentifierCharacters; } - if (widget.workspace.writersideModule!.topics.any( - (candidate) => candidate.id == name, + if (widget.workspace.writersideModule!.reservedTopicIds.contains( + name, )) { return context.l10n.topicIdAlreadyExists; } @@ -6799,9 +6799,8 @@ class _TocTabState extends ConsumerState<_TocTab> { requiredError: context.l10n.fileNameRequired, invalidCharactersError: context.l10n.useIdentifierCharacters, duplicateError: context.l10n.topicIdAlreadyExists, - existingTopicIds: widget.workspace.writersideModule!.topics.map( - (candidate) => candidate.id, - ), + existingTopicIds: + widget.workspace.writersideModule!.reservedTopicIds, textDirection: Directionality.of(context), ); if (!mounted || !context.mounted) return; @@ -7580,7 +7579,7 @@ class _TocTabState extends ConsumerState<_TocTab> { await showBusyMarkModalDialog( context, builder: (_) => WritersideTemplateDialog( - existingIds: module.topics.map((topic) => topic.id).toSet(), + existingIds: module.reservedTopicIds, previewBuilder: (context, template, title, filename) { final source = WritersideTemplateService.generate( template, @@ -8886,9 +8885,7 @@ class _CreateWritersideTopicDialogState return context.l10n.useIdentifierCharacters; } final id = p.basenameWithoutExtension(effective); - final existingIds = widget.workspace.writersideModule?.topics - .map((topic) => topic.id) - .toSet(); + final existingIds = widget.workspace.writersideModule?.reservedTopicIds; if (existingIds?.contains(id) ?? false) { return context.l10n.topicIdAlreadyExists; } diff --git a/lib/src/workspace/workspace_message.dart b/lib/src/workspace/workspace_message.dart index 5cee44d6..0ca247e8 100644 --- a/lib/src/workspace/workspace_message.dart +++ b/lib/src/workspace/workspace_message.dart @@ -140,6 +140,8 @@ String _localizeWorkspaceError(BuildContext context, Object? error) { 'writerside.topic.file-extension-mismatch' => l10n.errorTopicFileExtensionMismatch(value('extension')), 'writerside.topic.file-name-invalid' => l10n.errorTopicFileNameInvalid, + 'writerside.topic.discovery-incomplete' => + l10n.diagnosticWorkspaceScanSkipped, 'writerside.topic.tree-changed' => l10n.workspaceErrorSaveBlockedFileChangedOnDisk, 'writerside.toc.destination-required' => @@ -209,6 +211,9 @@ String _localizeWorkspaceError(BuildContext context, Object? error) { value('count'), value('paths'), ), + 'writerside.topic-file.topic-discovery-incomplete' || + 'writerside.topic-file.project-discovery-incomplete' => + l10n.diagnosticWorkspaceScanSkipped, 'writerside.topic-file.project-buffers-dirty' => l10n.errorWritersideTopicRenameProjectBuffersDirty, 'writerside.topic-file.project-inventory-changed' => diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 5ee3edbe..45a1035e 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -181,13 +181,13 @@ class WorkspaceService { File(p.join(canonicalPath, 'project.ihp')).existsSync()) { return _openWriterside(canonicalPath, preferredModuleRoot: canonicalPath); } - final moduleRoots = await _writersideProjectService.discoverModuleRoots( + final moduleDiscovery = await _writersideProjectService.discoverModuleRoots( canonicalPath, ); - if (moduleRoots.isNotEmpty) { + if (moduleDiscovery.roots.isNotEmpty) { return _openWriterside( canonicalPath, - preferredModuleRoot: moduleRoots.first, + preferredModuleRoot: moduleDiscovery.roots.first, ); } return _openMarkdownFolder(canonicalPath); @@ -262,7 +262,8 @@ class WorkspaceService { rootPath: module.rootPath, treePath: instance.sourceTreePath, topicsRootDir: topicsRootDir, - existingTopicIds: {for (final topic in module.topics) topic.id}, + existingTopicIds: module.reservedTopicIds, + topicDiscoveryComplete: module.topicDiscoveryComplete, ), request, initialSource: initialSource, @@ -455,7 +456,8 @@ class WorkspaceService { rootPath: module.rootPath, treePath: instance.sourceTreePath, topicsRootDir: p.relative(p.dirname(topicPath), from: module.rootPath), - existingTopicIds: module.topics.map((topic) => topic.id).toSet(), + existingTopicIds: module.reservedTopicIds, + topicDiscoveryComplete: module.topicDiscoveryComplete, ), WritersideTopicCreateRequest( title: topic.title ?? topic.id, @@ -810,6 +812,12 @@ class WorkspaceService { workspace.rootPath, preferredModuleRoot: ownerRoot, ); + if (!project.moduleDiscoveryComplete) { + throw BusyMarkException( + 'writerside.topic-file.project-discovery-incomplete', + args: {'path': project.rootPath}, + ); + } final module = project.modules .where((candidate) => p.equals(candidate.rootPath, ownerRoot)) .singleOrNull; @@ -835,11 +843,17 @@ class WorkspaceService { }) async { final projectRoot = plan.projectRoot; if (projectRoot != null) { - final currentRoots = await _writersideProjectService.discoverModuleRoots( + final discovery = await _writersideProjectService.discoverModuleRoots( projectRoot, ); - final normalizedCurrent = currentRoots.map(normalizePath).toSet().toList() - ..sort(); + if (!discovery.complete) { + throw BusyMarkException( + 'writerside.topic-file.project-discovery-incomplete', + args: {'path': projectRoot}, + ); + } + final normalizedCurrent = + discovery.roots.map(normalizePath).toSet().toList()..sort(); if (!_sameStringList(normalizedCurrent, plan.projectModuleRoots)) { throw BusyMarkException( 'writerside.topic-file.project-inventory-changed', diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index f8d248cd..d3576b19 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -542,6 +542,7 @@ class WritersideModule { this.referenceData = const WritersideReferenceData(), this.semanticDiagnostics = const [], Set unparsedTopicReferences = const {}, + this.topicDiscoveryComplete = true, this.variablesAvailable = true, }) : structuralDiagnostics = diagnostics, unparsedTopicReferences = Set.unmodifiable(unparsedTopicReferences); @@ -561,6 +562,13 @@ class WritersideModule { /// Discovered topic sources excluded from the semantic model because they /// could not be parsed within the configured scan limits. final Set unparsedTopicReferences; + + /// Whether every configured topic root was traversed exhaustively. + /// + /// This is separate from [unparsedTopicReferences], whose entries were + /// discovered but could not be parsed. An incomplete traversal cannot name + /// the topic sources it never reached. + final bool topicDiscoveryComplete; final bool variablesAvailable; List get diagnostics => sortDiagnostics([...structuralDiagnostics, ...semanticDiagnostics]); @@ -592,6 +600,7 @@ class WritersideModule { diagnostics: diagnostics ?? structuralDiagnostics, semanticDiagnostics: semanticDiagnostics ?? this.semanticDiagnostics, unparsedTopicReferences: unparsedTopicReferences, + topicDiscoveryComplete: topicDiscoveryComplete, variablesAvailable: variablesAvailable, validatedImageDirs: validatedImageDirs, buildProfiles: buildProfiles, @@ -619,6 +628,14 @@ class WritersideModule { for (final topic in topics) topic.id: topic, }; + /// Every topic ID whose basename is already reserved in this help module, + /// including discovered sources that were not parsed successfully. + Set get reservedTopicIds => Set.unmodifiable({ + for (final topic in topics) topic.id, + for (final reference in unparsedTopicReferences) + p.basenameWithoutExtension(reference), + }); + WritersideTopic? topicByReference( String reference, { WritersideTopic? fromTopic, diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index 0e631377..dc738b96 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -29,6 +29,7 @@ class WritersideModuleService { this.variablesParser = const WritersideVariablesParser(), this.categoriesParser = const WritersideCategoriesParser(), this.scanOptions = const WorkspaceScanOptions(), + this.topicDirectoryLister, }); final WritersideConfigParser configParser; @@ -40,6 +41,7 @@ class WritersideModuleService { final WritersideVariablesParser variablesParser; final WritersideCategoriesParser categoriesParser; final WorkspaceScanOptions scanOptions; + final WorkspaceDirectoryLister? topicDirectoryLister; Future load( String rootPath, { @@ -357,13 +359,18 @@ class WritersideModuleService { final topics = []; final unparsedTopics = _UnparsedTopicIndex(); + var topicDiscoveryComplete = true; var parsedDocuments = 0; for (final topicsRoot in usableTopicRoots) { final scan = await scanWorkspaceEntities( topicsRoot, options: effectiveScanOptions, + directoryLister: topicDirectoryLister, ); diagnostics.addAll(scan.diagnostics); + if (!scan.traversalComplete) { + topicDiscoveryComplete = false; + } for (final entity in scan.entities.whereType()) { final extension = p.extension(entity.path).toLowerCase(); if (extension != '.md' && @@ -479,6 +486,7 @@ class WritersideModuleService { categories: categories, diagnostics: const [], unparsedTopicReferences: Set.unmodifiable(unparsedTopics._fileNames), + topicDiscoveryComplete: topicDiscoveryComplete, variablesAvailable: variablesAvailable, validatedImageDirs: validatedImageDirs, buildProfiles: buildProfiles, @@ -564,6 +572,7 @@ class WritersideModuleService { categories: const [], diagnostics: sortDiagnostics(diagnostics), validatedImageDirs: const ['images'], + topicDiscoveryComplete: false, sourceOverrides: sourceOverrides, ); } diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index 850d1913..c3b95a3f 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -861,6 +861,8 @@ class WritersideProject { required this.activeInstanceId, required this.index, required this.diagnostics, + this.moduleDiscoveryComplete = true, + this.moduleDiscoveryDiagnostics = const [], }); final String rootPath; @@ -869,6 +871,8 @@ class WritersideProject { final String? activeInstanceId; final WritersideProjectIndex index; final List diagnostics; + final bool moduleDiscoveryComplete; + final List moduleDiscoveryDiagnostics; WritersideModule? get activeModule { for (final module in modules) { @@ -909,6 +913,8 @@ class WritersideProject { activeInstanceId: instanceId, index: index, diagnostics: diagnostics, + moduleDiscoveryComplete: moduleDiscoveryComplete, + moduleDiscoveryDiagnostics: moduleDiscoveryDiagnostics, ); } @@ -966,21 +972,43 @@ class WritersideProject { for (final candidate in nextModules) ...candidate.diagnostics, ...nextIndex.diagnostics, ...writersideWebFileNameDiagnostics(nextModules), + ...moduleDiscoveryDiagnostics, ]), + moduleDiscoveryComplete: moduleDiscoveryComplete, + moduleDiscoveryDiagnostics: moduleDiscoveryDiagnostics, ); } } +class WritersideModuleDiscoveryResult { + WritersideModuleDiscoveryResult({ + required Iterable roots, + required Iterable diagnostics, + required this.complete, + }) : roots = List.unmodifiable( + roots.map(normalizePath).toSet().toList()..sort(), + ), + diagnostics = List.unmodifiable(sortDiagnostics(diagnostics)); + + final List roots; + final List diagnostics; + final bool complete; +} + class WritersideProjectService { const WritersideProjectService({ this.moduleService = const WritersideModuleService(), this.scanOptions = const WorkspaceScanOptions(), + this.moduleDirectoryLister, }); final WritersideModuleService moduleService; final WorkspaceScanOptions scanOptions; + final WorkspaceDirectoryLister? moduleDirectoryLister; - Future> discoverModuleRoots(String projectRoot) async { + Future discoverModuleRoots( + String projectRoot, + ) async { final root = normalizePath(projectRoot); final direct = [ p.join(root, 'writerside.cfg'), @@ -999,22 +1027,27 @@ class WritersideProjectService { includeHiddenDirectories: false, includeExcludedDirectories: false, ), + directoryLister: moduleDirectoryLister, ); for (final file in scan.entities.whereType()) { if ({'writerside.cfg', 'project.ihp'}.contains(p.basename(file.path))) { roots.add(p.dirname(file.path)); } } - return roots.toList()..sort(); + return WritersideModuleDiscoveryResult( + roots: roots, + diagnostics: scan.diagnostics, + complete: scan.traversalComplete, + ); } Future load( String projectRoot, { String? preferredModuleRoot, }) async { - final roots = await discoverModuleRoots(projectRoot); + final discovery = await discoverModuleRoots(projectRoot); var modules = []; - for (final root in roots) { + for (final root in discovery.roots) { modules.add(await moduleService.load(root, options: scanOptions)); } modules = _resolveProjectModules(modules); @@ -1042,7 +1075,10 @@ class WritersideProjectService { for (final module in modules) ...module.diagnostics, ...index.diagnostics, ...writersideWebFileNameDiagnostics(modules), + ...discovery.diagnostics, ]), + moduleDiscoveryComplete: discovery.complete, + moduleDiscoveryDiagnostics: discovery.diagnostics, ); } @@ -1072,7 +1108,10 @@ class WritersideProjectService { for (final candidate in replaced.modules) ...candidate.diagnostics, ...index.diagnostics, ...writersideWebFileNameDiagnostics(replaced.modules), + ...replaced.moduleDiscoveryDiagnostics, ]), + moduleDiscoveryComplete: replaced.moduleDiscoveryComplete, + moduleDiscoveryDiagnostics: replaced.moduleDiscoveryDiagnostics, ); } diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 57181e94..3de55994 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -172,12 +172,14 @@ class WritersideTopicCreateTarget { required this.treePath, required this.topicsRootDir, required this.existingTopicIds, + this.topicDiscoveryComplete = true, }); final String rootPath; final String treePath; final String topicsRootDir; final Set existingTopicIds; + final bool topicDiscoveryComplete; } class WritersideTopicCreateResult { @@ -211,6 +213,9 @@ class WritersideTopicCreator { String? initialSource, Future Function()? validateBeforePublish, }) async { + if (!target.topicDiscoveryComplete) { + throw const BusyMarkException('writerside.topic.discovery-incomplete'); + } final rootPath = normalizePath(target.rootPath); final CanonicalPathAnchor rootAnchor; try { diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index f81b1ea4..996155e8 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -323,6 +323,12 @@ class WritersideTopicFileEditor { } void _validateModuleSemanticCoverage(WritersideModule module) { + if (!module.topicDiscoveryComplete) { + throw BusyMarkException( + 'writerside.topic-file.topic-discovery-incomplete', + args: {'module': module.config.moduleName ?? module.rootPath}, + ); + } final skipped = module.unparsedTopicReferences.toList()..sort(); if (skipped.isEmpty) return; throw BusyMarkException( diff --git a/test/src/path_utils_test.dart b/test/src/path_utils_test.dart index 3431ec4a..3977d0fc 100644 --- a/test/src/path_utils_test.dart +++ b/test/src/path_utils_test.dart @@ -35,6 +35,7 @@ void main() { expect(yieldedEntries, 3); expect(result.entities, isEmpty); + expect(result.traversalComplete, isFalse); expect( result.diagnostics.map((diagnostic) => diagnostic.code), contains('workspace.scan.skipped'), @@ -60,12 +61,40 @@ void main() { ); expect(result.entities, isEmpty); + expect(result.traversalComplete, isFalse); expect( result.diagnostics.map((diagnostic) => diagnostic.code), contains('workspace.scan.skipped'), ); }); + test('directory listing failure marks traversal incomplete', () async { + final root = await Directory.systemTemp.createTemp('busymark-scan-fail-'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + + Stream failListing( + Directory directory, { + required bool followLinks, + }) async* { + throw FileSystemException('listing failed', directory.path); + } + + final result = await scanWorkspaceEntities( + root.path, + directoryLister: failListing, + ); + + expect(result.traversalComplete, isFalse); + expect( + result.diagnostics.map((diagnostic) => diagnostic.code), + contains('workspace.scan.inspect-failed'), + ); + }); + test( 'display scan includes hidden and unsupported entries but not VCS data', () async { diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index 6aad6ac1..a25eb272 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -8,6 +8,7 @@ import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; import 'package:busymark/src/writerside/writerside_parsers.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -367,6 +368,136 @@ void main() { }, ); + test( + 'unparsed topic basename reserves its module-wide topic ID for creation', + () async { + final fixture = await _createWorkspaceWithUnparsedGuide(); + final module = fixture.workspace.writersideModule!; + + expect(module.unparsedTopicReferences, contains('guide.topic')); + expect(module.reservedTopicIds, containsAll(['guide', 'intro'])); + await expectLater( + fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Duplicate guide', + fileName: 'guide.md', + ), + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ) + .having((error) => error.args['topicId'], 'topicId', 'guide'), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + + final updated = await fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Different topic', + fileName: 'different.md', + ), + ); + expect( + File(p.join(updated.rootPath, 'topics', 'different.md')).existsSync(), + isTrue, + ); + }, + ); + + test('duplicate topic cannot reuse an unparsed topic basename', () async { + final fixture = await _createWorkspaceWithUnparsedGuide(); + final module = fixture.workspace.writersideModule!; + final intro = module.topicByReference('intro.md')!; + final node = module.instances.single.tocRoots.single; + + await expectLater( + fixture.service.duplicateWritersideTopic( + fixture.workspace, + treePath: fixture.treePath, + tocPath: const [0], + identity: WritersideTocNodeIdentity.fromNode(node), + topicPath: intro.filePath, + expectedSource: intro.document.source, + newName: 'guide', + validateBeforePublish: () async {}, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + }); + + test('template source cannot reuse an unparsed topic basename', () async { + final fixture = await _createWorkspaceWithUnparsedGuide(); + + await expectLater( + fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Template guide', + fileName: 'guide.md', + ), + initialSource: '# Template guide\n', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + }); + + test('topic creation refuses incomplete topic discovery', () async { + final fixture = await _createWorkspaceWithUnparsedGuide( + scanOptions: const WorkspaceScanOptions(maxTreeEntries: 1), + ); + + expect(fixture.workspace.writersideModule!.topicDiscoveryComplete, isFalse); + await expectLater( + fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Cannot prove uniqueness', + fileName: 'unknown.md', + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.discovery-incomplete', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'unknown.md')).existsSync(), + isFalse, + ); + }); + test( 'rejects topic creation when the configured instance tree is outside the module', () async { @@ -463,6 +594,91 @@ void main() { }, ); + test( + 'topic rename planning rejects incomplete project module discovery', + () async { + final fixture = await _createTopicRenameWorkspace(); + final limitedService = WorkspaceService( + scanOptions: const WorkspaceScanOptions(maxTreeEntries: 1), + ); + + await expectLater( + limitedService.prepareWritersideTopicRename( + fixture.workspace, + topicPath: fixture.guidePath, + newFileName: 'setup.md', + topicModuleRoot: fixture.mainRoot.path, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.project-discovery-incomplete', + ), + ), + ); + expect(File(fixture.guidePath).existsSync(), isTrue); + expect( + File(p.join(fixture.mainRoot.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }, + ); + + test( + 'prepared topic rename rejects incomplete module discovery on apply', + () async { + final fixture = await _createTopicRenameWorkspace(); + final plan = await service.prepareWritersideTopicRename( + fixture.workspace, + topicPath: fixture.guidePath, + newFileName: 'setup.md', + topicModuleRoot: fixture.mainRoot.path, + ); + final limitedService = WorkspaceService( + scanOptions: const WorkspaceScanOptions(maxTreeEntries: 1), + ); + + await expectLater( + limitedService.applyWritersideTopicRename(plan), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.project-discovery-incomplete', + ), + ), + ); + expect(File(fixture.guidePath).existsSync(), isTrue); + expect( + File(p.join(fixture.mainRoot.path, 'topics', 'setup.md')).existsSync(), + isFalse, + ); + }, + ); + + test('project module discovery reports directory listing failure', () async { + final fixture = await _createTopicRenameWorkspace(); + + Stream failListing( + Directory directory, { + required bool followLinks, + }) async* { + throw FileSystemException('listing failed', directory.path); + } + + final discovery = await WritersideProjectService( + moduleDirectoryLister: failListing, + ).discoverModuleRoots(fixture.projectRoot.path); + + expect(discovery.complete, isFalse); + expect(discovery.roots, isEmpty); + expect( + discovery.diagnostics.map((diagnostic) => diagnostic.code), + contains('workspace.scan.inspect-failed'), + ); + }); + test( 'ordinary directory creation does not stale a prepared rename', () async { @@ -1182,6 +1398,56 @@ void main() { }); } +Future< + ({ + Directory root, + WorkspaceService service, + Workspace workspace, + String treePath, + }) +> +_createWorkspaceWithUnparsedGuide({ + WorkspaceScanOptions scanOptions = const WorkspaceScanOptions( + maxParsedFileBytes: 256, + ), +}) async { + final root = await Directory.systemTemp.createTemp( + 'busymark-workspace-unparsed-topic-id-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString(''' + + + + +'''); + final treePath = p.join(root.path, 'guide.tree'); + await File(treePath).writeAsString(''' + + + +'''); + await File( + p.join(root.path, 'topics', 'intro.md'), + ).writeAsString('# Intro\n'); + await File(p.join(root.path, 'topics', 'guide.topic')).writeAsString( + '${'content ' * 80}\n', + ); + final service = WorkspaceService(scanOptions: scanOptions); + final workspace = await service.openPath(root.path); + return ( + root: Directory(await root.resolveSymbolicLinks()), + service: service, + workspace: workspace, + treePath: normalizePath(treePath), + ); +} + Future< ({ Directory projectRoot, diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 3bcaa0f6..f681ad89 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -88,6 +88,120 @@ void main() { ); }); + test( + 'rename planning rejects topic discovery truncated by tree limits', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'deep/reference.md': '# Reference\n\n[Guide](../guide.md)\n', + }, + ); + final topicsRoot = p.join(fixture.root.path, 'topics'); + final guidePath = p.join(topicsRoot, 'guide.md'); + final deepPath = p.join(topicsRoot, 'deep'); + + Stream deterministicListing( + Directory directory, { + required bool followLinks, + }) async* { + if (p.equals(directory.path, topicsRoot)) { + yield File(guidePath); + yield Directory(deepPath); + return; + } + yield File(p.join(deepPath, 'reference.md')); + } + + final limitedModule = + await WritersideModuleService( + topicDirectoryLister: deterministicListing, + ).load( + fixture.root.path, + options: const WorkspaceScanOptions(maxTreeEntries: 2), + ); + + expect(limitedModule.topicDiscoveryComplete, isFalse); + expect(limitedModule.unparsedTopicReferences, isEmpty); + await expectLater( + editor.prepareRename( + module: limitedModule, + topic: _topic(limitedModule, 'guide.md'), + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.topic-discovery-incomplete', + ), + ), + ); + expect(File(guidePath).existsSync(), isTrue); + expect(File(p.join(topicsRoot, 'setup.md')).existsSync(), isFalse); + }, + ); + + test( + 'rename planning rejects a topic-root directory listing failure', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'deep/reference.md': '# Reference\n\n[Guide](../guide.md)\n', + }, + ); + final topicsRoot = p.join(fixture.root.path, 'topics'); + final deepPath = p.join(topicsRoot, 'deep'); + + Stream failingListing( + Directory directory, { + required bool followLinks, + }) async* { + if (p.equals(directory.path, deepPath)) { + throw FileSystemException('listing failed', directory.path); + } + yield File(p.join(topicsRoot, 'guide.md')); + yield Directory(deepPath); + } + + final incompleteModule = await WritersideModuleService( + topicDirectoryLister: failingListing, + ).load(fixture.root.path); + + expect(incompleteModule.topicDiscoveryComplete, isFalse); + expect(incompleteModule.unparsedTopicReferences, isEmpty); + await expectLater( + editor.prepareRename( + module: incompleteModule, + topic: _topic(incompleteModule, 'guide.md'), + newFileName: 'setup.md', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.topic-discovery-incomplete', + ), + ), + ); + }, + ); + test( 'rename planning rejects a discovered unreadable topic source', () async { From 7599b561f2a44ecae6870703abb14947e7194907 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 21:22:13 -0700 Subject: [PATCH 21/35] Fix Writerside semantic directory discovery --- .../writerside/writerside_module_service.dart | 14 +- lib/src/writerside/writerside_project.dart | 7 +- test/src/workspace_service_test.dart | 80 +++++- test/src/writerside_project_test.dart | 92 ++++++ .../writerside_topic_file_editor_test.dart | 271 ++++++++++++++++++ 5 files changed, 459 insertions(+), 5 deletions(-) diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index dc738b96..137fef87 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -364,7 +364,19 @@ class WritersideModuleService { for (final topicsRoot in usableTopicRoots) { final scan = await scanWorkspaceEntities( topicsRoot, - options: effectiveScanOptions, + // Directory names ignored by generic workspace browsing remain + // semantically valid inside configured Writerside topic roots. + // Explicit VCS metadata remains excluded by the scanner itself. + options: WorkspaceScanOptions( + maxParsedFileBytes: effectiveScanOptions.maxParsedFileBytes, + maxParsedDocuments: effectiveScanOptions.maxParsedDocuments, + maxTreeEntries: effectiveScanOptions.maxTreeEntries, + followLinks: false, + includeUnsupportedFiles: false, + includeDirectories: false, + includeHiddenDirectories: true, + includeExcludedDirectories: true, + ), directoryLister: topicDirectoryLister, ); diagnostics.addAll(scan.diagnostics); diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index c3b95a3f..f3227647 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -1017,6 +1017,9 @@ class WritersideProjectService { final roots = {for (final file in direct) p.dirname(file.path)}; final scan = await scanWorkspaceEntities( root, + // Directory names ignored by generic workspace browsing remain + // semantically valid as Writerside module containers. Explicit VCS + // metadata remains excluded by the scanner itself. options: WorkspaceScanOptions( maxParsedFileBytes: scanOptions.maxParsedFileBytes, maxParsedDocuments: scanOptions.maxParsedDocuments, @@ -1024,8 +1027,8 @@ class WritersideProjectService { followLinks: false, includeUnsupportedFiles: true, includeDirectories: false, - includeHiddenDirectories: false, - includeExcludedDirectories: false, + includeHiddenDirectories: true, + includeExcludedDirectories: true, ), directoryLister: moduleDirectoryLister, ); diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index a25eb272..e0615e37 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -413,6 +413,77 @@ void main() { }, ); + test('topic under build reserves its module-wide ID for creation', () async { + final fixture = await _createWorkspaceWithUnparsedGuide( + reservedTopicPath: 'build/guide.topic', + ); + + expect(fixture.workspace.writersideModule!.topicDiscoveryComplete, isTrue); + expect( + fixture.workspace.writersideModule!.reservedTopicIds, + contains('guide'), + ); + await expectLater( + fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Duplicate guide', + fileName: 'guide.md', + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + }); + + test( + 'topic under a hidden directory reserves its module-wide ID for creation', + () async { + final fixture = await _createWorkspaceWithUnparsedGuide( + reservedTopicPath: '.internal/guide.md', + reservedTopicSource: '# Guide\n\n${'content ' * 80}', + ); + + expect( + fixture.workspace.writersideModule!.topicDiscoveryComplete, + isTrue, + ); + expect( + fixture.workspace.writersideModule!.reservedTopicIds, + contains('guide'), + ); + await expectLater( + fixture.service.createWritersideTopic( + fixture.workspace, + const WritersideTopicCreateRequest( + title: 'Duplicate guide', + fileName: 'guide.md', + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect( + File(p.join(fixture.root.path, 'topics', 'guide.md')).existsSync(), + isFalse, + ); + }, + ); + test('duplicate topic cannot reuse an unparsed topic basename', () async { final fixture = await _createWorkspaceWithUnparsedGuide(); final module = fixture.workspace.writersideModule!; @@ -1410,6 +1481,8 @@ _createWorkspaceWithUnparsedGuide({ WorkspaceScanOptions scanOptions = const WorkspaceScanOptions( maxParsedFileBytes: 256, ), + String reservedTopicPath = 'guide.topic', + String? reservedTopicSource, }) async { final root = await Directory.systemTemp.createTemp( 'busymark-workspace-unparsed-topic-id-', @@ -1435,8 +1508,11 @@ _createWorkspaceWithUnparsedGuide({ await File( p.join(root.path, 'topics', 'intro.md'), ).writeAsString('# Intro\n'); - await File(p.join(root.path, 'topics', 'guide.topic')).writeAsString( - '${'content ' * 80}\n', + final reservedTopic = File(p.join(root.path, 'topics', reservedTopicPath)); + await reservedTopic.parent.create(recursive: true); + await reservedTopic.writeAsString( + reservedTopicSource ?? + '${'content ' * 80}\n', ); final service = WorkspaceService(scanOptions: scanOptions); final workspace = await service.openPath(root.path); diff --git a/test/src/writerside_project_test.dart b/test/src/writerside_project_test.dart index 4045c835..6efcab71 100644 --- a/test/src/writerside_project_test.dart +++ b/test/src/writerside_project_test.dart @@ -77,6 +77,71 @@ void main() { }, ); + test('discovers and loads a module under build', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-writerside-build-discovery-', + ); + addTearDown(() => root.delete(recursive: true)); + final main = await _writeDiscoveryModule(root, 'main', 'main'); + final build = await _writeDiscoveryModule(root, 'build', 'build-docs'); + const service = WritersideProjectService(); + + final discovery = await service.discoverModuleRoots(root.path); + + expect(discovery.complete, isTrue); + expect(discovery.roots, containsAll([main.path, build.path])); + expect(discovery.roots, hasLength(2)); + final project = await service.load(root.path); + expect(project.moduleDiscoveryComplete, isTrue); + expect(project.modules, hasLength(2)); + expect(project.modulesByOrigin.keys, containsAll(['main', 'build-docs'])); + }); + + test('discovers and loads a module under a hidden directory', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-writerside-hidden-discovery-', + ); + addTearDown(() => root.delete(recursive: true)); + final main = await _writeDiscoveryModule(root, 'main', 'main'); + final hidden = await _writeDiscoveryModule(root, '.docs', 'hidden-docs'); + const service = WritersideProjectService(); + + final discovery = await service.discoverModuleRoots(root.path); + + expect(discovery.complete, isTrue); + expect(discovery.roots, containsAll([main.path, hidden.path])); + expect(discovery.roots, hasLength(2)); + final project = await service.load(root.path); + expect(project.moduleDiscoveryComplete, isTrue); + expect(project.modules, hasLength(2)); + expect(project.modulesByOrigin.keys, containsAll(['main', 'hidden-docs'])); + }); + + test('module discovery hard-excludes VCS metadata directories', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-writerside-vcs-discovery-', + ); + addTearDown(() => root.delete(recursive: true)); + final main = await _writeDiscoveryModule(root, 'main', 'main'); + for (final metadata in ['.git', '.hg', '.svn']) { + await _writeDiscoveryModule( + root, + p.join(metadata, 'fake'), + '$metadata-fake', + ); + } + const service = WritersideProjectService(); + + final discovery = await service.discoverModuleRoots(root.path); + + expect(discovery.complete, isTrue); + expect(discovery.roots, [main.path]); + final project = await service.load(root.path); + expect(project.moduleDiscoveryComplete, isTrue); + expect(project.modules, hasLength(1)); + expect(project.modules.single.config.moduleName, 'main'); + }); + test( 'workspace promotes a parent directory to a multi-module project', () async { @@ -540,6 +605,33 @@ Iterable _walkBlocks(Iterable blocks) sync* { } } +Future _writeDiscoveryModule( + Directory projectRoot, + String relativePath, + String moduleName, +) async { + final moduleRoot = await Directory( + p.join(projectRoot.path, relativePath), + ).create(recursive: true); + final topicsRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + + + + + +'''); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(''' + + + +'''); + await File(p.join(topicsRoot.path, 'guide.md')).writeAsString('# Guide\n'); + return moduleRoot; +} + class _ProjectFixture { const _ProjectFixture(this.directory); diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index f681ad89..3b37e9d9 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -2,8 +2,12 @@ import 'dart:io'; import 'package:busymark/src/core/busymark_exception.dart'; import 'package:busymark/src/core/path_utils.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/writerside/writerside_document_renderer.dart'; +import 'package:busymark/src/writerside/writerside_document_resolver.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -202,6 +206,138 @@ void main() { }, ); + test( + 'topic discovery traverses excluded and hidden directories but not VCS metadata', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'build/reference.md': '# Build reference\n', + '.internal/architecture.topic': + '\n', + '.git/fake.md': '# Git fake\n', + '.hg/fake.md': '# Mercurial fake\n', + '.svn/fake.md': '# Subversion fake\n', + }, + ); + + expect(fixture.module.topicDiscoveryComplete, isTrue); + expect( + fixture.module.topics.map((topic) => topic.fileName), + containsAll([ + 'guide.md', + 'build/reference.md', + '.internal/architecture.topic', + ]), + ); + expect( + fixture.module.topics.map((topic) => topic.fileName), + isNot( + anyOf( + contains('.git/fake.md'), + contains('.hg/fake.md'), + contains('.svn/fake.md'), + ), + ), + ); + }, + ); + + test( + 'rename updates and resolves a Markdown reference under build', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + 'build/reference.md': '# Reference\n\n[Guide](../guide.md)\n', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final referencePath = p.join( + fixture.root.path, + 'topics', + 'build', + 'reference.md', + ); + expect( + File(referencePath).readAsStringSync(), + '# Reference\n\n[Guide](../setup.md)\n', + ); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect(_resolvedLinkDestinations(reloaded, 'build/reference.md'), [ + p.join(fixture.root.path, 'topics', 'setup.md'), + ]); + }, + ); + + test( + 'rename updates and resolves an XML reference under a hidden directory', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + +''', + }, + topics: { + 'guide.md': '# Guide\n', + '.internal/reference.topic': ''' + + Guide + +''', + }, + ); + + await editor.rename( + module: fixture.module, + topic: _topic(fixture.module, 'guide.md'), + newFileName: 'setup.md', + ); + + final referencePath = p.join( + fixture.root.path, + 'topics', + '.internal', + 'reference.topic', + ); + expect( + File(referencePath).readAsStringSync(), + contains('Guide'), + ); + final reloaded = await const WritersideModuleService().load( + fixture.root.path, + ); + expect(_resolvedLinkDestinations(reloaded, '.internal/reference.topic'), [ + p.join(fixture.root.path, 'topics', 'setup.md'), + ]); + }, + ); + test( 'rename planning rejects a discovered unreadable topic source', () async { @@ -704,6 +840,102 @@ Text Shared. }, ); + test( + 'project-wide rename includes an origin reference in a build module', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-file-build-module-', + ); + addTearDown(() => root.delete(recursive: true)); + + Future writeModule({ + required String directory, + required String moduleName, + required String topicName, + required String tree, + required String source, + }) async { + final moduleRoot = await Directory( + p.join(root.path, directory), + ).create(); + final topicsRoot = await Directory( + p.join(moduleRoot.path, 'topics'), + ).create(); + await File(p.join(moduleRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(moduleRoot.path, 'guide.tree')).writeAsString(tree); + await File(p.join(topicsRoot.path, topicName)).writeAsString(source); + } + + await writeModule( + directory: 'main', + moduleName: 'main', + topicName: 'guide.md', + tree: ''' + + + +''', + source: '# Guide\n', + ); + await writeModule( + directory: 'build', + moduleName: 'build-docs', + topicName: 'reference.topic', + tree: ''' + + + + +''', + source: ''' + + Guide + +''', + ); + final project = await const WritersideProjectService().load(root.path); + final main = project.modulesByOrigin['main']!; + final build = project.modulesByOrigin['build-docs']!; + final referencePath = p.join(build.rootPath, 'topics', 'reference.topic'); + + final plan = await editor.prepareRename( + module: main, + topic: _topic(main, 'guide.md'), + newFileName: 'setup.md', + projectModules: project.modules, + ); + + expect( + plan.changedFiles.map((change) => change.path), + contains(referencePath), + ); + await editor.applyRename(plan); + expect( + File(referencePath).readAsStringSync(), + contains('href="setup.md" origin="main"'), + ); + + final reloaded = await const WritersideProjectService().load(root.path); + final reloadedBuild = reloaded.modulesByOrigin['build-docs']!; + expect( + _resolvedLinkDestinations( + reloadedBuild, + 'reference.topic', + modulesByOrigin: reloaded.modulesByOrigin, + ), + [ + p.join( + reloaded.modulesByOrigin['main']!.rootPath, + 'topics', + 'setup.md', + ), + ], + ); + }, + ); + test( 'renaming a local namesake preserves origin-qualified Markdown XML links', () async { @@ -2222,6 +2454,45 @@ WritersideTopic _topic(WritersideModule module, String fileName) { return module.topics.singleWhere((topic) => topic.fileName == fileName); } +List _resolvedLinkDestinations( + WritersideModule module, + String fileName, { + Map modulesByOrigin = const {}, +}) { + final topic = _topic(module, fileName); + final resolved = const WritersideDocumentResolver().resolve( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: module.instances.isEmpty ? null : module.instances.first, + modulesByOrigin: modulesByOrigin, + ), + ); + final rendered = const WritersideDocumentRenderer().toBusyDocument( + resolved.document, + ); + return _busyBlocks(rendered.blocks) + .expand((block) => _busyInlines(block.inlines)) + .where((inline) => inline.kind == BusyInlineKind.link) + .map((inline) => inline.destination) + .toList(); +} + +Iterable _busyBlocks(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _busyBlocks(block.children); + } +} + +Iterable _busyInlines(Iterable inlines) sync* { + for (final inline in inlines) { + yield inline; + yield* _busyInlines(inline.children); + } +} + XmlDocument _tree(Directory root, String fileName) { return XmlDocument.parse( File(p.join(root.path, fileName)).readAsStringSync(), From 68ecd901a28c5b36c515cd20c054a24ab112584a Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Wed, 16 Sep 2026 00:13:41 -0700 Subject: [PATCH 22/35] Complete project-wide Writerside topic removal --- .../writerside-toc-implementation.md | 58 +- lib/l10n/app_en.arb | 2 +- lib/l10n/generated/app_localizations.dart | 2 +- lib/l10n/generated/app_localizations_en.dart | 2 +- .../presentation/workspace_screen.dart | 183 +- lib/src/workspace/workspace_controller.dart | 36 +- lib/src/workspace/workspace_service.dart | 76 +- lib/src/writerside/writerside_project.dart | 61 + .../writerside_topic_removal_service.dart | 1726 +++++++++++------ test/src/app_smoke_test.dart | 113 +- test/src/workspace_controller_test.dart | 58 + test/src/workspace_service_test.dart | 53 + test/src/writerside_project_test.dart | 36 + test/src/writerside_toc_regressions_test.dart | 11 +- ...writerside_topic_removal_service_test.dart | 786 +++++++- tools/writerside_toc_visual_smoke.dart | 31 +- 16 files changed, 2538 insertions(+), 696 deletions(-) diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md index d815f836..4b9c712e 100644 --- a/docs/development/writerside-toc-implementation.md +++ b/docs/development/writerside-toc-implementation.md @@ -105,10 +105,10 @@ screenshot comparison. | S2, P | Context: **Copy Special** → **Topic File Name '{name}'**, **Topic File Path**, **Topic Title '{title}'**, **TOC Element ID '{id}'** | `WS._showTocTreeMenu`, `_showTopicContextMenu` | App: all four Copy Special clipboard payloads in RTL, including base versus navigation title; M nested keyboard/focus/RTL | Native menu captured. Payloads are raw topic reference, canonical path, base title, explicit ID; unavailable values are disabled. No claim that contextual TOC title is copied. | | S2, S14 | Context: **Preview Topic** | `WS._showTopicContextMenu` preview case, existing view-mode/preview controller | Existing preview/view-mode suites; native selected-topic assertion | Native selected-topic preview passed and was captured. Uses BusyMark renderer, not the Writerside website renderer. | | S2, S4–S7, P | Context: **Edit Title...**; dialog **Edit Title** with **Topic title:**, **Advanced Settings**, **Title for '{id}':**, **TOC-only title:**, **Cancel**, **OK** | `Dialogs.WritersideTitleDialog`, `WritersideTitleEditor.prepare`, `WorkspaceService.editWritersideTitles` | A: XML/Markdown escaping, independent overrides, inherited values, clearing, repeated occurrence; W: rollback on second-file guard | Native dialog/advanced fields captured and inspected. Front-matter title editing is rejected rather than silently changing a shadowed H1; edit that BusyMark extension in source. | -| S11–S12, P | Context: **Remove TOC Element...**; dialog **Remove TOC Element** | `WS._runWritersideTopicRemoval`, `WritersideTopicRemovalService` | R: references, redirect, malformed source, races, file retained/deleted; W: dirty guard rollback | Native ordinary removal and Find transition captured. Redirect precedes automatic-update checkbox; automatic update defaults on when analysis permits. Safety diagnostics are BusyMark-specific. | +| S11–S12, P | Context: **Remove TOC Element**; dialog **Remove TOC Element** | `WS._runWritersideTopicRemoval`, `WritersideTopicRemovalService` | R: project-wide references, `topic`/`ref`/`origin`, redirects, malformed or incomplete semantic discovery, races, file retained/deleted; W: project-wide dirty guard and rollback | The approved sidebar Review Usages flow is instance-aware; Do Refactor reanalyzes and completes directly when all blocking usages are resolved. | | S1, P | Multi-selection: **Remove TOC Elements...**; compact **Remove {count} TOC Elements** | `WS._removeTocEntries`, guarded batch structural removal | E: source identities and direct-child promotion; App: multi-selection route | Existing direct-child promotion is preserved, as required by handoff. Installed descendant-inclusive count/removal is not cloned; selected-entry count is shown. | | S11, user decision | Existing sidebar: **Find**, **Do Refactor** | `_WritersideTopicUsageReviewPanel`, usage navigation/reanalysis orchestration | R and controller stale-analysis tests; native review transition | Native surface captured and inspected. Sidebar placement is explicitly user-approved; this is not a general JetBrains tool-window clone. | -| S11 | Files context: **Refactor** → **Safe Delete**; dialog **Delete**, **Safe Delete**, **OK** | Files nested menu, same topic-removal analyzer/apply service | R: generic delete bypass prohibited, usage checks, orphan choices; W: rollback | Native Files → Refactor → Safe Delete passed; Delete dialog captured and inspected. Unchecking Safe Delete does not bypass BusyMark reference safety checks. | +| S11 | Files context: **Refactor** → **Safe Delete**; dialog **Delete**, mandatory checked **Safe Delete**, **OK** | Files nested menu, same topic-removal analyzer/apply service | R: project-wide generic-delete bypass prohibited (including unparsed/non-active-module topics), usage checks, orphan choices; W: rollback | Safe Delete is deliberately non-toggleable for Writerside topic files. | | S2, S15, P | Context: **Set as Home Page** | `WritersideTocEditor.setHomePage`, controller/service | W: root attributes and other instance unchanged; C: first-topic initialization retained | Native reassignment passed and marker inspected. No home action for groups, URLs or library instances; included structural mutation remains restricted. | | S1, P | Multi-selection after Duplicate: **Group**; **New Group**, **Group Name**, Enter | `WritersideTocEditor.groupElements`, `Dialogs.WritersideTocTextDialog` | A: source order, full nodes, cross-parent rejection; E guards | Native Group/Enter passed, wrapping the two selected source subtrees. Same-parent selections only, preserving complete XML subtrees. | | S1, P | Context after source navigation: **Sort Child Topics Alphabetically** | `WorkspaceService.sortWritersideTocChildren`, `WritersideTocEditor.reorderChildren` | W: concurrent resolved-title dependency change rejected; native expected order | Native shallow contextual-title order passed. Deliberately rejects mixed XML child entries instead of reproducing the installed action's loss of includes/non-TOC tags. | @@ -129,10 +129,32 @@ New mutations validate raw source identities and expected content, use guarded publication, and inspect inactive affected buffers as well as the active document. Title changes are prepared before a multi-file transaction; dirty-buffer and disk guards are rechecked at publication, with rollback on partial failure. Topic -deletion uses the existing analyzer and rollback machinery. Clean tabs refresh; -unrelated dirty tabs remain untouched. File-monitor events are deferred during -foreground file operations so an own-write event cannot invalidate the subsequent -open-new-document step. +removal uses the complete `WritersideProject`: ownership is chosen by exact parsed +topic path or the most-specific configured topic root, so non-active and nested +modules cannot bypass Safe Delete. The reviewed snapshot includes the sorted module +inventory, module inputs, every parsed topic, variables and instance groups, all +semantic `.tree` files, and redirect rules. Hidden, `build`, `target`, `dist`, and +`out` directories participate in semantic discovery; `.git`, `.hg`, and `.svn` +remain hard exclusions. Incomplete module/topic/tree discovery, unparsed topics, +or a changed snapshot fails closed before publication. + +Removal combines indexed links, includes, cards and other semantic references with +an exhaustive tree/start-page pass. `topic`, `ref`, `in`, and `origin` resolve +through the owning host and target modules. Current-instance relevance comes from +the resolved navigation tree and resolved topic documents, including filters and +instance conditions; Safe Delete remains project-wide. Unsupported but resolved +references appear in the approved Review Usages sidebar as manual blockers. Orphan +status is asserted only when the complete post-refactor project has no remaining +usage. + +Redirect plans retain each host instance's effective web filename plus direct and +rule-based accepted aliases, and validate the resolved instance namespace before +writing. Do Refactor resolves dirty project buffers, reanalyzes from disk, preserves +the reviewed options, and applies immediately when blockers are gone. Cross-module +writes use guarded atomic replacement and rollback, with the topic file deleted +last. Clean tabs refresh; unrelated dirty tabs remain untouched. File-monitor +events are deferred during foreground file operations so an own-write event cannot +invalidate the subsequent open-new-document step. Structural serialization remains the existing XML pretty-printer: semantic preservation, not byte-for-byte formatting preservation. Included nodes remain @@ -196,30 +218,30 @@ The existing main application menu remains flat. ## Verification log -Final verification, including templates and native menus: +Final verification, including topic removal, templates, and native menus: -- `flutter gen-l10n`: exit 0, all 23 ARBs regenerated. -- Formatting checks: exit 0, 22 changed and 13 new non-generated Dart files. +- `flutter gen-l10n`: exit 0; generated localization sources are current. +- Changed Dart sources and tests were formatted with `dart format`. - `flutter analyze --no-pub`: exit 0, no issues. -- Full `flutter test --no-pub --file-reporter json:/tmp/busymark-final-tests.json`: - exit 0, **1,905 passed, 58 skipped**. -- Documented focused Writerside/source/controller/export suite: exit 0, - **400 passed, 1 skipped**, report `/tmp/busymark-gtk-focused-verified.json`. +- Full `flutter test --no-pub`: exit 0, **2,038 passed, 58 skipped**. +- Required focused removal/project/workspace/controller/TOC/UI suites: exit 0, + **302 passed**. - `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**. This uses the pinned builder 2026.08.0328, distinct from the selected UI plugin 2026.07.8925. It checks the semantic fixture, not website or menu parity. -- Linux interaction harness: exit 0, **35 checks passed**, **20 captures**. +- Linux interaction harness: result artifact reports no failure and **48 checks + passed**. Includes real GTK two-level keyboard traversal in LTR/RTL, disabled headings, Escape, focus return, session-scoped dismissal, existing flat radio selectors, - and the core TOC/template workflows in the action matrix. + the reviewed removal flow, mandatory Safe Delete, and the core TOC/template + workflows in the action matrix. - Normal `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; the bundle is restored to the normal application, not the acceptance harness. - `git diff --check`: exit 0. The 58 full-suite skips are optional Typst/PDF/Poppler and D2 integrations that -require explicit tool-path environment configuration. The focused skip is a -Typst integration. No unavailable optional integration is counted as a passing -TOC or native-menu check. +require explicit tool-path environment configuration. No unavailable optional +integration is counted as a passing TOC or native-menu check. The first template full run exposed three test-contract problems: reviewed technical strings in localization audits, the Chinese locale inheritance diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2499321f..98d43230 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -91,7 +91,7 @@ "@tocEditTitleAction": {"description":"Writerside Table of Contents command or dialog text."}, "tocEditTitleDialog": "Edit Title", "@tocEditTitleDialog": {"description":"Writerside Table of Contents command or dialog text."}, - "tocRemoveElementAction": "Remove TOC Element...", + "tocRemoveElementAction": "Remove TOC Element", "@tocRemoveElementAction": {"description":"Writerside Table of Contents command or dialog text."}, "tocSetHomePage": "Set as Home Page", "@tocSetHomePage": {"description":"Writerside Table of Contents command or dialog text."}, diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 67b265cf..172fcf63 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -406,7 +406,7 @@ abstract class AppLocalizations { /// Writerside Table of Contents command or dialog text. /// /// In en, this message translates to: - /// **'Remove TOC Element...'** + /// **'Remove TOC Element'** String get tocRemoveElementAction; /// Writerside Table of Contents command or dialog text. diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 309087ba..c1f0338a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -157,7 +157,7 @@ class AppLocalizationsEn extends AppLocalizations { String get tocEditTitleDialog => 'Edit Title'; @override - String get tocRemoveElementAction => 'Remove TOC Element...'; + String get tocRemoveElementAction => 'Remove TOC Element'; @override String get tocSetHomePage => 'Set as Home Page'; diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 4f5e6959..53f82b7b 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -440,6 +440,7 @@ class _SourceNavigationTarget { const _SourceNavigationTarget({ required this.filePath, required this.line, + this.sourceOffset, this.tocPath, this.tocIdentity, this.xmlChildren = false, @@ -447,6 +448,7 @@ class _SourceNavigationTarget { final String filePath; final int line; + final int? sourceOffset; final List? tocPath; final WritersideTocNodeIdentity? tocIdentity; final bool xmlChildren; @@ -2515,10 +2517,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { String? redirectTopicPath, bool applyIfUnused = false, }) async { - if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ - target.topicPath, - if (target.treePath != null) target.treePath!, - ]) || + if (!await confirmSafeToRefactorWritersideProject(context, ref) || !mounted || !context.mounted) { return null; @@ -2575,18 +2574,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { }); return null; } - final affectedPaths = { - target.topicPath, - if (target.treePath != null) target.treePath!, - if (decision.redirectTarget != null) decision.redirectTarget!.treePath, - for (final usage in analysis.usages) - if (usage.relevant) usage.filePath, - }; - final dirtyInputs = ref - .read(workspaceControllerProvider) - .dirtyBuffers - .any((buffer) => affectedPaths.contains(buffer.filePath)); - if (!await confirmSafeToChangeWorkspaceFiles(context, ref, affectedPaths) || + final dirtyInputs = hasDirtyWritersideProjectBuffers(ref); + if (!await confirmSafeToRefactorWritersideProject(context, ref) || !mounted || !context.mounted) { return null; @@ -2646,16 +2635,85 @@ class _SidebarState extends ConsumerState<_Sidebar> { Future _resumeWritersideTopicRemoval(BuildContext context) async { final review = _topicUsageReview; - if (review == null) { + if (review == null) return; + if (!await confirmSafeToRefactorWritersideProject(context, ref) || + !mounted || + !context.mounted) { return; } - setState(() => _topicUsageReview = null); - await _runWritersideTopicRemoval( - context, - review.target, - updateUsagesAutomatically: review.updateUsagesAutomatically, - redirectTopicPath: review.redirectTopicPath, + final controller = ref.read(workspaceControllerProvider.notifier); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: review.target.topicPath, + mode: review.target.mode, + treePath: review.target.treePath, + nodePath: review.target.nodePath, + ); + if (!mounted || !context.mounted) return; + if (analysis == null) { + _showLatestWorkspaceMessage(context); + return; + } + final redirect = review.redirectTopicPath == null + ? null + : analysis.redirectTargets + .where( + (candidate) => + p.equals(candidate.topicPath, review.redirectTopicPath!), + ) + .firstOrNull; + if (review.redirectTopicPath != null && redirect == null) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.errorWritersideRedirectInvalid, + ); + return; + } + if (analysis.blockingUsages.any( + (usage) => + !review.updateUsagesAutomatically || !usage.canUpdateAutomatically, + )) { + setState(() { + _topicUsageReview = _WritersideTopicUsageReview( + target: review.target, + analysis: analysis, + updateUsagesAutomatically: review.updateUsagesAutomatically, + redirectTopicPath: review.redirectTopicPath, + ); + }); + return; + } + final result = await controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: review.updateUsagesAutomatically, + redirectTarget: redirect, + ), ); + if (!mounted || !context.mounted) return; + if (result == null) { + _showLatestWorkspaceMessage(context); + return; + } + setState(() => _topicUsageReview = null); + _clearGitDetailSelection(ref); + if (review.target.mode == WritersideTopicRemovalMode.removeFromInstance && + result.orphaned && + !result.deletedFile) { + final deleteOrphan = await _confirmDeleteOrphanTopicFile( + context, + analysis.topicFileName, + ); + if (deleteOrphan && mounted && context.mounted) { + await _runWritersideTopicRemoval( + context, + _WritersideTopicRemovalTarget( + mode: WritersideTopicRemovalMode.safeDeleteFile, + topicPath: review.target.topicPath, + ), + applyIfUnused: true, + ); + } + } } Future _openWritersideTopicUsage( @@ -2671,7 +2729,11 @@ class _SidebarState extends ConsumerState<_Sidebar> { ref .read(_sourceNavigationTargetProvider.notifier) .set( - _SourceNavigationTarget(filePath: usage.filePath, line: usage.line), + _SourceNavigationTarget( + filePath: usage.filePath, + line: usage.line, + sourceOffset: usage.span?.startOffset, + ), ); _clearGitDetailSelection(ref); } @@ -3731,7 +3793,6 @@ class _WritersideTopicRemovalDialog extends StatefulWidget { class _WritersideTopicRemovalDialogState extends State<_WritersideTopicRemovalDialog> { late bool _updateUsagesAutomatically; - bool _safeDelete = true; WritersideTopicRedirectTarget? _redirectTarget; WritersideTopicRemovalAnalysis get _analysis => widget.analysis; @@ -3740,9 +3801,7 @@ class _WritersideTopicRemovalDialogState if (_analysis.blockingUsages.isEmpty) { return true; } - return _safeDelete && - _updateUsagesAutomatically && - _analysis.canUpdateUsagesAutomatically; + return _updateUsagesAutomatically && _analysis.canUpdateUsagesAutomatically; } @override @@ -3862,38 +3921,31 @@ class _WritersideTopicRemovalDialogState contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, title: Text(context.l10n.tocSafeDelete), - value: _safeDelete, - onChanged: (value) => setState(() { - _safeDelete = value ?? true; - if (!_safeDelete) { - _updateUsagesAutomatically = false; - _redirectTarget = null; - } - }), + value: true, + onChanged: null, ), - if (removeFromInstance || _safeDelete) - Row( - children: [ - Checkbox( - value: _updateUsagesAutomatically, - onChanged: _analysis.canUpdateUsagesAutomatically - ? (value) => setState( - () => _updateUsagesAutomatically = value ?? false, - ) - : null, - ), - Flexible(child: Text(context.l10n.updateUsagesAutomatically)), - const SizedBox(width: BusyMarkSpacing.md), - Flexible( - child: Text( - context.l10n.topicUsagesCount(relevantUsages.length), - style: TextStyle( - color: BusyMarkSurfaceColors.of(context).mutedForeground, - ), + Row( + children: [ + Checkbox( + value: _updateUsagesAutomatically, + onChanged: _analysis.canUpdateUsagesAutomatically + ? (value) => setState( + () => _updateUsagesAutomatically = value ?? false, + ) + : null, + ), + Flexible(child: Text(context.l10n.updateUsagesAutomatically)), + const SizedBox(width: BusyMarkSpacing.md), + Flexible( + child: Text( + context.l10n.topicUsagesCount(relevantUsages.length), + style: TextStyle( + color: BusyMarkSurfaceColors.of(context).mutedForeground, ), ), - ], - ), + ), + ], + ), if (!_canApply) ...[ const SizedBox(height: BusyMarkSpacing.md), BusyMarkStatusBox( @@ -4005,7 +4057,7 @@ class _WritersideTopicUsagesSidebar extends StatelessWidget { _SidebarNavigationResultRow( title: usage.reference, subtitle: - '${busyMarkLtrIsolateFor(context, '${p.basename(usage.filePath)}:${usage.line}:${usage.column}')}' + '${busyMarkLtrIsolateFor(context, '${p.relative(usage.filePath, from: analysis.projectRoot)}:${usage.line}:${usage.column}')}' '${usage.relevant ? '' : ' · ${context.l10n.outsideSelectedInstance}'}', icon: _writersideTopicUsageKindIcon(kind), onOpen: () => onOpenUsage(usage), @@ -4215,6 +4267,8 @@ String _writersideTopicUsageKindLabel( WritersideTopicUsageKind.startPage => context.l10n.topicUsageStartPages, WritersideTopicUsageKind.topicLink => context.l10n.topicUsageTopicLinks, WritersideTopicUsageKind.include => context.l10n.topicUsageIncludes, + WritersideTopicUsageKind.otherTopicReference => + context.l10n.topicUsageTopicLinks, }; } @@ -4224,6 +4278,7 @@ IconData _writersideTopicUsageKindIcon(WritersideTopicUsageKind kind) { WritersideTopicUsageKind.startPage => BusyMarkGlyphs.startTopic, WritersideTopicUsageKind.topicLink => BusyMarkGlyphs.link, WritersideTopicUsageKind.include => BusyMarkGlyphs.insertObject, + WritersideTopicUsageKind.otherTopicReference => BusyMarkGlyphs.link, }; } @@ -4735,7 +4790,8 @@ bool _isWritersideTopicFile(Workspace workspace, DocumentFile? file) { file.kind == DocumentKind.writersideXmlTopic) { return true; } - return workspace.writersideModule?.topics.any( + return workspace.writersideProject?.isTopicPath(file.absolutePath) ?? + workspace.writersideModule?.topics.any( (topic) => p.equals(topic.filePath, file.absolutePath), ) ?? false; @@ -6340,9 +6396,14 @@ class _TocTabState extends ConsumerState<_TocTab> { if (rawNode == null) { return; } + final topicOwner = entry.node.origin == null + ? widget.workspace.writersideModule + : widget.workspace.writersideProject?.modulesByOrigin[entry + .node + .origin]; final topic = reference == null ? null - : widget.workspace.writersideModule?.topicByReference(reference); + : topicOwner?.topicByReference(reference); if (topic != null) { final result = await widget.onRequestTopicRemoval( _WritersideTopicRemovalTarget( @@ -11915,6 +11976,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); if (span == null) return; editor.scrollToOffset(span.startOffset); + } else if (sourceTarget.sourceOffset case final offset?) { + editor.scrollToOffset(offset); } else { editor.scrollToLine(sourceTarget.line); } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 9995b404..030f9e02 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -2024,17 +2024,33 @@ class WorkspaceController extends Notifier { Future applyWritersideTopicRemoval( WritersideTopicRemovalRequest request, ) async { - WritersideTopicRemovalResult? result; - final success = await _runWorkspaceFileOperation((workspace) async { - result = await _service.applyWritersideTopicRemoval( - workspace, - request, - validateBeforeCommit: (paths) => - _requireCleanAffectedFiles(workspace, paths), - ); + final deleting = + request.analysis.mode == WritersideTopicRemovalMode.safeDeleteFile; + final deletedPath = p.normalize(request.analysis.topicPath); + if (deleting && !await _protectWorkspaceEntityBeforeDelete(deletedPath)) { return null; - }); - return success ? result : null; + } + WritersideTopicRemovalResult? result; + if (deleting) _intentionallyRemovedPaths.add(deletedPath); + try { + final success = await _runWorkspaceFileOperation((workspace) async { + void validate(Iterable _) => + _requireCleanWritersideProject(workspace); + validate(const []); + result = await _service.applyWritersideTopicRemoval( + workspace, + request, + validateBeforeCommit: validate, + ); + if (result?.deletedFile == true) { + await _localHistory.markDeleted(deletedPath, recursive: false); + } + return null; + }); + return success ? result : null; + } finally { + if (deleting) _intentionallyRemovedPaths.remove(deletedPath); + } } Future closeOpenFileTab(String path) => _closeOpenFileTabNow(path); diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 45a1035e..b68c43b4 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -927,10 +927,18 @@ class WorkspaceService { Workspace workspace, String topicPath, ) async { - final module = await _currentWritersideModule(workspace); + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: workspace.writersideModule?.rootPath, + ); + final module = project.topicOwnerForPath(topicPath); + if (module == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } final topic = _writersideTopicForPath(module, topicPath); final analysis = await writersideTopicRemovalService.analyze( - module: module, + project: project, + projectRoot: workspace.rootPath, topicPath: topic.filePath, mode: WritersideTopicRemovalMode.safeDeleteFile, ); @@ -949,10 +957,18 @@ class WorkspaceService { String? treePath, List? nodePath, }) async { - final module = await _currentWritersideModule(workspace); + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: workspace.writersideModule?.rootPath, + ); + final module = project.topicOwnerForPath(topicPath); + if (module == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } final topic = _writersideTopicForPath(module, topicPath); return writersideTopicRemovalService.analyze( - module: module, + project: project, + projectRoot: workspace.rootPath, topicPath: topic.filePath, mode: mode, selectedTreePath: treePath, @@ -965,8 +981,13 @@ class WorkspaceService { WritersideTopicRemovalRequest request, { void Function(Iterable)? validateBeforeCommit, }) async { - final module = await _currentWritersideModule(workspace); - if (!p.equals(module.rootPath, request.analysis.moduleRoot)) { + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: request.analysis.targetModuleRoot, + ); + final module = project.topicOwnerForPath(request.analysis.topicPath); + if (module == null || + !p.equals(module.rootPath, request.analysis.targetModuleRoot)) { throw const BusyMarkException('writerside.topic.module-not-open'); } return writersideTopicRemovalService.apply( @@ -1581,35 +1602,22 @@ class WorkspaceService { String sourcePath, FileSystemEntityType type, ) async { - final module = await _currentWritersideModule(workspace); - for (final configuredRoot in module.config.topicRoots) { - final topicRoot = p.normalize( - p.join(module.rootPath, configuredRoot.dir), - ); - if (type == FileSystemEntityType.directory && - (p.equals(sourcePath, topicRoot) || - p.isWithin(topicRoot, sourcePath) || - p.isWithin(sourcePath, topicRoot))) { - return true; - } - final extension = p.extension(sourcePath).toLowerCase(); - if (type == FileSystemEntityType.file && - p.isWithin(topicRoot, sourcePath) && - (extension == '.md' || - extension == '.markdown' || - extension == '.topic')) { - return true; - } - } - for (final topic in module.topics) { - final topicPath = p.normalize(topic.filePath); - if (p.equals(topicPath, sourcePath) || - (type == FileSystemEntityType.directory && - p.isWithin(sourcePath, topicPath))) { - return true; - } + final project = await _writersideProjectService.load( + workspace.rootPath, + preferredModuleRoot: workspace.writersideModule?.rootPath, + ); + if (!project.moduleDiscoveryComplete && + (type == FileSystemEntityType.directory || + isWritersideTopicSourcePath(sourcePath))) { + // An incomplete module inventory cannot prove that a Markdown/XML topic + // (or a recursively deleted directory) belongs only to ordinary files. + // Keep the operation on the Safe Delete side of the guard. + return true; } - return false; + return project.deletionTouchesTopics( + sourcePath, + isDirectory: type == FileSystemEntityType.directory, + ); } Future _workspacePathAnchor(Workspace workspace) async { diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index f3227647..cebf24d5 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -902,6 +902,60 @@ class WritersideProject { for (final module in modules) _moduleId(module): module, }; + /// Returns the module that semantically owns a Writerside topic source. + /// + /// Parsed topic identity wins. Discovered-but-unparsed topic candidates are + /// recognized by their extension and configured topic root. Nested modules + /// are resolved to the most-specific containing module root. + WritersideModule? topicOwnerForPath(String filePath) { + final path = normalizePath(filePath); + final exact = modules + .where( + (module) => module.topics.any( + (topic) => p.equals(normalizePath(topic.filePath), path), + ), + ) + .toList(); + if (exact.isNotEmpty) { + exact.sort((a, b) => b.rootPath.length.compareTo(a.rootPath.length)); + return exact.first; + } + if (!isWritersideTopicSourcePath(path)) return null; + final candidates = modules.where((module) { + return module.config.topicRoots.any((configured) { + final root = normalizePath(p.join(module.rootPath, configured.dir)); + return p.isWithin(root, path); + }); + }).toList(); + candidates.sort((a, b) => b.rootPath.length.compareTo(a.rootPath.length)); + return candidates.firstOrNull; + } + + bool isTopicPath(String filePath) => topicOwnerForPath(filePath) != null; + + /// Whether deleting [path] would directly or recursively remove a topic + /// source or a configured topic root anywhere in the project. + bool deletionTouchesTopics(String path, {required bool isDirectory}) { + final candidate = normalizePath(path); + if (!isDirectory) return isTopicPath(candidate); + for (final module in modules) { + for (final configured in module.config.topicRoots) { + final root = normalizePath(p.join(module.rootPath, configured.dir)); + if (p.equals(candidate, root) || + p.isWithin(candidate, root) || + p.isWithin(root, candidate)) { + return true; + } + } + if (module.topics.any( + (topic) => p.isWithin(candidate, normalizePath(topic.filePath)), + )) { + return true; + } + } + return false; + } + WritersideProject withSelection({ required String moduleId, String? instanceId, @@ -980,6 +1034,13 @@ class WritersideProject { } } +bool isWritersideTopicSourcePath(String path) { + final extension = p.extension(path).toLowerCase(); + return extension == '.md' || + extension == '.markdown' || + extension == '.topic'; +} + class WritersideModuleDiscoveryResult { WritersideModuleDiscoveryResult({ required Iterable roots, diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index 486c09fd..da9494ad 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -13,14 +13,26 @@ import '../core/source_span.dart'; import '../core/uri_utils.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; +import 'writerside_project.dart'; +import 'writerside_document.dart'; +import 'writerside_document_resolver.dart'; +import 'writerside_document_parser.dart'; +import 'writerside_schema.dart'; +import 'writerside_toc_presentation.dart'; import 'writerside_web_file_name.dart'; enum WritersideTopicRemovalMode { removeFromInstance, safeDeleteFile } -enum WritersideTopicUsageKind { tocElement, startPage, topicLink, include } +enum WritersideTopicUsageKind { + tocElement, + startPage, + topicLink, + include, + otherTopicReference, +} class WritersideTopicUsage { - const WritersideTopicUsage({ + WritersideTopicUsage({ required this.kind, required this.filePath, required this.line, @@ -28,7 +40,10 @@ class WritersideTopicUsage { required this.reference, required this.relevant, required this.canUpdateAutomatically, - }); + this.span, + this.moduleRoot, + List? nodePath, + }) : nodePath = nodePath == null ? null : List.unmodifiable(nodePath); final WritersideTopicUsageKind kind; final String filePath; @@ -37,6 +52,25 @@ class WritersideTopicUsage { final String reference; final bool relevant; final bool canUpdateAutomatically; + final SourceSpan? span; + final String? moduleRoot; + final List? nodePath; +} + +class WritersideTopicRedirectSource { + WritersideTopicRedirectSource({ + required this.hostModuleRoot, + required this.treePath, + required this.instanceId, + required Iterable acceptedWebFileNames, + }) : acceptedWebFileNames = List.unmodifiable( + acceptedWebFileNames.toSet().toList()..sort(), + ); + + final String hostModuleRoot; + final String treePath; + final String? instanceId; + final List acceptedWebFileNames; } class WritersideTopicRedirectTarget { @@ -58,11 +92,15 @@ class WritersideTopicRedirectTarget { class WritersideTopicRemovalAnalysis { WritersideTopicRemovalAnalysis({ required this.mode, - required this.moduleRoot, + String? projectRoot, + String? targetModuleRoot, + String? hostModuleRoot, + String? moduleRoot, required this.topicPath, required this.topicFileName, required this.topicTitle, - required this.oldWebFileName, + String? oldWebFileName, + List redirectSources = const [], required this.selectedTreePath, required List? selectedNodePath, required this.childCount, @@ -70,18 +108,46 @@ class WritersideTopicRemovalAnalysis { required List usages, required List redirectTargets, required this.fingerprint, - }) : selectedNodePath = selectedNodePath == null + List projectModuleRoots = const [], + }) : projectRoot = + projectRoot ?? + moduleRoot ?? + targetModuleRoot ?? + (throw ArgumentError('A Writerside project root is required.')), + targetModuleRoot = + targetModuleRoot ?? + moduleRoot ?? + (throw ArgumentError('A Writerside topic owner is required.')), + hostModuleRoot = + hostModuleRoot ?? + targetModuleRoot ?? + moduleRoot ?? + (throw ArgumentError('A Writerside host module is required.')), + oldWebFileName = + oldWebFileName ?? + redirectSources.firstOrNull?.acceptedWebFileNames.firstOrNull ?? + '', + redirectSources = List.unmodifiable(redirectSources), + projectModuleRoots = List.unmodifiable( + projectModuleRoots.map(normalizePath).toSet().toList()..sort(), + ), + selectedNodePath = selectedNodePath == null ? null : List.unmodifiable(selectedNodePath), usages = List.unmodifiable(usages), redirectTargets = List.unmodifiable(redirectTargets); final WritersideTopicRemovalMode mode; - final String moduleRoot; + final String projectRoot; + final String targetModuleRoot; + final String hostModuleRoot; + String get moduleRoot => targetModuleRoot; final String topicPath; final String topicFileName; final String? topicTitle; final String oldWebFileName; + final List redirectSources; + final List projectModuleRoots; final String? selectedTreePath; final List? selectedNodePath; final int childCount; @@ -101,13 +167,15 @@ class WritersideTopicRemovalAnalysis { (usage.kind == WritersideTopicUsageKind.tocElement && !usage.canUpdateAutomatically) || usage.kind == WritersideTopicUsageKind.topicLink || - usage.kind == WritersideTopicUsageKind.include, + usage.kind == WritersideTopicUsageKind.include || + usage.kind == WritersideTopicUsageKind.otherTopicReference, WritersideTopicRemovalMode.safeDeleteFile => usage.kind == WritersideTopicUsageKind.startPage || (usage.kind == WritersideTopicUsageKind.tocElement && !usage.canUpdateAutomatically) || usage.kind == WritersideTopicUsageKind.topicLink || - usage.kind == WritersideTopicUsageKind.include, + usage.kind == WritersideTopicUsageKind.include || + usage.kind == WritersideTopicUsageKind.otherTopicReference, }, ) .toList(growable: false); @@ -146,24 +214,35 @@ class WritersideTopicRemovalResult { /// Implements Writerside's two-stage Remove from Instance / Safe Delete flow. /// -/// Analysis snapshots every topic source and every `.tree` file below the -/// module root. Apply rebuilds that snapshot and refuses to mutate anything if -/// any input changed, so a dialog can never authorize a stale refactoring. +/// Analysis snapshots every semantic input and `.tree` file in the complete +/// Writerside project. Apply rebuilds that snapshot and refuses to mutate +/// anything if an input changed, so a reviewed dialog can never authorize a +/// stale refactoring. class WritersideTopicRemovalService { const WritersideTopicRemovalService({ this.moduleService = const WritersideModuleService(), + this.projectService, + this.treeDirectoryLister, }); final WritersideModuleService moduleService; + final WritersideProjectService? projectService; + final WorkspaceDirectoryLister? treeDirectoryLister; Future analyze({ - required WritersideModule module, + WritersideProject? project, + WritersideModule? module, + String? projectRoot, required String topicPath, required WritersideTopicRemovalMode mode, String? selectedTreePath, List? selectedNodePath, }) async { - final snapshot = await _snapshot(module.rootPath, topicPath); + final root = project?.rootPath ?? projectRoot ?? module?.rootPath; + if (root == null) { + throw const BusyMarkException('writerside.topic.module-not-open'); + } + final snapshot = await _snapshot(root, topicPath, loadedProject: project); final selectedTree = selectedTreePath == null ? null : _canonicalInputPath(snapshot.anchor, selectedTreePath); @@ -185,8 +264,12 @@ class WritersideTopicRemovalService { snapshot.trees[selectedTree]!.document.rootElement, selectedNodePath, ); - final selectedReference = selectedElement.getAttribute('topic'); - if (selectedReference == null || !_targets(snapshot, selectedReference)) { + if (!_elementTargets( + snapshot, + selectedElement, + snapshot.topic.filePath, + tree: snapshot.trees[selectedTree]!, + )) { throw BusyMarkException( 'writerside.toc.path-invalid', args: {'path': selectedNodePath.join('/'), 'role': 'source'}, @@ -194,127 +277,178 @@ class WritersideTopicRemovalService { } } - final selectedTopicPaths = selectedTree == null - ? const {} - : _topicsInTree(snapshot, snapshot.trees[selectedTree]!); + final selectedInstance = selectedTree == null + ? null + : snapshot.trees[selectedTree]!.owner.instances + .where( + (instance) => p.equals(instance.sourceTreePath, selectedTree), + ) + .firstOrNull; + if (mode == WritersideTopicRemovalMode.removeFromInstance && + selectedInstance == null) { + throw BusyMarkException( + 'writerside.topic.tree-file-missing', + args: {'path': selectedTree}, + ); + } + final activeTreeNodes = {}; + final participatingTopics = {}; + final activeDocumentRanges = >{}; + if (selectedInstance != null) { + final presenter = WritersideTocPresenter( + module: snapshot.trees[selectedTree]!.owner, + instance: selectedInstance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ); + for (final node in selectedInstance.navigationTocRoots.expand( + (root) => root.flatten(), + )) { + final presentation = presenter.present(node); + final topic = presentation.topic; + if (topic != null) { + participatingTopics.add(normalizePath(topic.filePath)); + final owner = snapshot.project.topicOwnerForPath(topic.filePath); + if (owner != null) { + final resolved = const WritersideDocumentResolver().resolve( + topic.document, + WritersideResolveContext( + module: owner, + topic: topic, + instance: selectedInstance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ), + ); + for (final documentNode in resolved.document.walk()) { + final sourcePath = normalizePath( + documentNode.provenance?.topicPath ?? + documentNode.span.filePath, + ); + // Authored references in this topic are checked against the + // resolved node tree below. These ranges are only for content + // pulled from another topic by an active include; retaining a + // root span here would make every conditioned child look active. + if (p.equals(sourcePath, normalizePath(topic.filePath))) { + continue; + } + activeDocumentRanges + .putIfAbsent(sourcePath, () => []) + .add(documentNode.span); + } + } + } + if (topic != null && + p.equals(topic.filePath, snapshot.topic.filePath)) { + activeTreeNodes[_treeNodeKey( + node.span.filePath, + node.span.startOffset, + )] = + node; + } + } + } final usages = []; var childCount = 0; for (final tree in snapshot.trees.values) { final root = tree.document.rootElement; final startPage = root.getAttribute('start-page'); - if (startPage != null && _couldTarget(snapshot, startPage)) { + if (startPage != null && + _couldTarget(snapshot, startPage, sourceModule: tree.owner)) { + final span = _attributeSpan(tree, root, 'start-page'); usages.add( _treeUsage( tree, + element: root, + attribute: 'start-page', kind: WritersideTopicUsageKind.startPage, reference: startPage, relevant: mode == WritersideTopicRemovalMode.safeDeleteFile || - (selectedTree != null && p.equals(tree.path, selectedTree)), + (selectedTree != null && + p.equals(tree.path, selectedTree) && + selectedInstance?.startPage == startPage), canUpdateAutomatically: false, + span: span, ), ); } for (final entry in _tocEntries(root)) { - final reference = entry.element.getAttribute('topic'); - if (reference == null || !_couldTarget(snapshot, reference)) { + final reference = _tocReference(entry.element); + if (reference == null || + !_elementCouldTarget(snapshot, entry.element, tree: tree)) { continue; } - final selectedOccurrence = - selectedTree != null && - p.equals(tree.path, selectedTree) && - (selectedNodePath == null || - _samePath(entry.path, selectedNodePath)); + final span = _attributeSpan( + tree, + entry.element, + entry.element.getAttribute('topic') != null ? 'topic' : 'ref', + ); + final activeNode = + activeTreeNodes[_treeNodeKey( + tree.path, + _elementSpan(tree, entry.element).startOffset, + )]; + final relevant = + mode == WritersideTopicRemovalMode.safeDeleteFile || + activeNode != null; + final automatic = + _elementTargets( + snapshot, + entry.element, + snapshot.topic.filePath, + tree: tree, + ) && + (mode == WritersideTopicRemovalMode.safeDeleteFile || + activeNode?.included != true); usages.add( _treeUsage( tree, + element: entry.element, + attribute: entry.element.getAttribute('topic') != null + ? 'topic' + : 'ref', kind: WritersideTopicUsageKind.tocElement, reference: reference, - relevant: - mode == WritersideTopicRemovalMode.safeDeleteFile || - selectedOccurrence, - canUpdateAutomatically: _targets(snapshot, reference), + relevant: relevant, + canUpdateAutomatically: automatic, + span: span, + nodePath: entry.path, ), ); final directChildren = entry.element.childElements .where(_isTocElement) .length; - if (mode == WritersideTopicRemovalMode.safeDeleteFile && - _targets(snapshot, reference)) { + if (relevant && automatic) { childCount += directChildren; - } else if (selectedOccurrence) { - childCount = directChildren; } } } - for (final sourceTopic in snapshot.module.topics) { - if (p.equals(sourceTopic.filePath, snapshot.topic.filePath)) { - continue; - } - final relevantSource = - mode == WritersideTopicRemovalMode.safeDeleteFile || - selectedTopicPaths.contains(normalizePath(sourceTopic.filePath)); - final source = snapshot.sources[normalizePath(sourceTopic.filePath)]!; - for (final link in sourceTopic.links) { - if (hasUriScheme(link.destination) || - !_couldTarget( - snapshot, - _referenceWithoutAnchor(link.destination), - fromTopic: sourceTopic, - )) { - continue; - } - usages.add( - WritersideTopicUsage( - kind: WritersideTopicUsageKind.topicLink, - filePath: sourceTopic.filePath, - line: link.span.startLine, - column: link.span.startColumn, - reference: link.destination, - relevant: relevantSource, - canUpdateAutomatically: _canRewriteLink( - snapshot, - sourceTopic, - source, - link.destination, - ), - ), - ); - } - for (final include in sourceTopic.includes) { - final reference = include.from; - if (reference == null || - !_couldTarget(snapshot, reference, fromTopic: sourceTopic)) { - continue; - } - usages.add( - WritersideTopicUsage( - kind: WritersideTopicUsageKind.include, - filePath: sourceTopic.filePath, - line: include.span.startLine, - column: include.span.startColumn, - reference: reference, - relevant: relevantSource, - canUpdateAutomatically: _canRewriteInclude( - snapshot, - sourceTopic, - source, - reference, - ), - ), - ); - } - } + usages.addAll( + _contentUsages( + snapshot, + mode: mode, + selectedInstance: selectedInstance, + participatingTopics: participatingTopics, + activeDocumentRanges: activeDocumentRanges, + ), + ); final preferredTree = selectedTree ?? _preferredTree(snapshot); + final redirectSources = _redirectSources(snapshot, usages); return WritersideTopicRemovalAnalysis( mode: mode, - moduleRoot: snapshot.anchor.rootPath, + projectRoot: snapshot.anchor.rootPath, + targetModuleRoot: snapshot.module.rootPath, + hostModuleRoot: selectedTree == null + ? snapshot.module.rootPath + : snapshot.trees[selectedTree]!.owner.rootPath, topicPath: snapshot.topic.filePath, topicFileName: snapshot.topic.fileName, topicTitle: snapshot.topic.title, - oldWebFileName: _effectiveWebFileName(snapshot, preferredTree), + oldWebFileName: + redirectSources.firstOrNull?.acceptedWebFileNames.firstOrNull ?? + _effectiveWebFileName(snapshot, preferredTree), + redirectSources: redirectSources, selectedTreePath: selectedTree, selectedNodePath: selectedNodePath, childCount: childCount, @@ -331,6 +465,7 @@ class WritersideTopicRemovalService { preferredTree: snapshot.trees[preferredTree]!, ), fingerprint: snapshot.fingerprint, + projectModuleRoots: snapshot.moduleRoots, ); } @@ -339,8 +474,15 @@ class WritersideTopicRemovalService { void Function(Iterable)? validateBeforeCommit, }) async { final analysis = request.analysis; - final snapshot = await _snapshot(analysis.moduleRoot, analysis.topicPath); - if (snapshot.fingerprint != analysis.fingerprint) { + final analyzedPaths = { + analysis.topicPath, + if (analysis.selectedTreePath case final String treePath) treePath, + for (final usage in analysis.usages) usage.filePath, + }; + validateBeforeCommit?.call(analyzedPaths); + final snapshot = await _snapshot(analysis.projectRoot, analysis.topicPath); + if (!_sameStringList(snapshot.moduleRoots, analysis.projectModuleRoots) || + snapshot.fingerprint != analysis.fingerprint) { throw BusyMarkException( 'writerside.topic-file.tree-changed', args: {'path': analysis.topicPath}, @@ -391,6 +533,7 @@ class WritersideTopicRemovalService { snapshot, selectedRemovalElement, snapshot.topic.filePath, + tree: snapshot.trees[normalizePath(treePath)]!, )) { throw BusyMarkException( 'writerside.toc.path-invalid', @@ -399,18 +542,16 @@ class WritersideTopicRemovalService { } } final removedElements = Set.identity(); - if (selectedRemovalElement != null) { - removedElements.add(selectedRemovalElement); - } else { - for (final document in treeDocuments.values) { - removedElements.addAll( - document - .findAllElements('toc-element') - .where( - (element) => - _elementTargets(snapshot, element, snapshot.topic.filePath), - ), - ); + for (final usage in analysis.usages.where( + (usage) => + usage.kind == WritersideTopicUsageKind.tocElement && + usage.relevant && + usage.canUpdateAutomatically, + )) { + final nodePath = usage.nodePath; + final document = treeDocuments[normalizePath(usage.filePath)]; + if (nodePath != null && document != null) { + removedElements.add(_elementAtPath(document.rootElement, nodePath)); } } @@ -439,7 +580,12 @@ class WritersideTopicRemovalService { redirectDocument.rootElement, redirect.nodePath, ); - if (!_elementTargets(snapshot, element, redirect.topicPath)) { + if (!_elementTargets( + snapshot, + element, + redirect.topicPath, + tree: snapshot.trees[redirectPath]!, + )) { throw const BusyMarkException( 'writerside.topic-removal.redirect-invalid', ); @@ -452,6 +598,7 @@ class WritersideTopicRemovalService { snapshot, entry.value, snapshot.topic.filePath, + tree: snapshot.trees[entry.key]!, )) entry.key, ]; @@ -469,6 +616,7 @@ class WritersideTopicRemovalService { snapshot, document, redirect.topicPath, + tree: snapshot.trees[treePath]!, ); if (matches.length != 1) { throw const BusyMarkException( @@ -481,23 +629,34 @@ class WritersideTopicRemovalService { for (final entry in redirectElements.entries) { final document = treeDocuments[entry.key]!; - final conflictingRedirect = document - .findAllElements('toc-element') - .any( - (element) => - !identical(element, entry.value) && - !removedElements.contains(element) && - (_acceptedWebFileNames( - snapshot, - element, - ).contains(analysis.oldWebFileName) || - _elementPublishesWebFileName( - snapshot, - element, - analysis.oldWebFileName, - treePath: entry.key, - )), - ); + final transferredNames = _redirectNamesForTree(analysis, entry.key); + final conflictingRedirect = transferredNames.any( + (name) => + document + .findAllElements('toc-element') + .any( + (element) => + !identical(element, entry.value) && + !removedElements.contains(element) && + (_acceptedWebFileNames( + snapshot, + element, + treePath: entry.key, + ).contains(name) || + _elementPublishesWebFileName( + snapshot, + element, + name, + treePath: entry.key, + )), + ) || + _resolvedInstanceHasRedirectConflict( + snapshot, + treePath: entry.key, + webFileName: name, + redirectTopicPath: redirect.topicPath, + ), + ); if (conflictingRedirect) { throw const BusyMarkException( 'writerside.topic-removal.redirect-invalid', @@ -505,26 +664,34 @@ class WritersideTopicRemovalService { } } for (final entry in redirectElements.entries) { - if (_acceptedWebFileNames( - snapshot, - entry.value, - ).contains(analysis.oldWebFileName) || - _elementPublishesWebFileName( - snapshot, - entry.value, - analysis.oldWebFileName, - treePath: entry.key, - )) { - continue; - } + final existingNames = _acceptedWebFileNames( + snapshot, + entry.value, + treePath: entry.key, + ); final oldNames = (entry.value.getAttribute('accepts-web-file-names') ?? '') .split(',') .map((value) => value.trim()) .where((value) => value.isNotEmpty) .toList(); - if (!oldNames.contains(analysis.oldWebFileName)) { - oldNames.add(analysis.oldWebFileName); + var changed = false; + for (final name in _redirectNamesForTree(analysis, entry.key)) { + if (existingNames.contains(name) || + _elementPublishesWebFileName( + snapshot, + entry.value, + name, + treePath: entry.key, + )) { + continue; + } + if (!oldNames.contains(name)) { + oldNames.add(name); + changed = true; + } + } + if (changed) { entry.value.setAttribute( 'accepts-web-file-names', oldNames.join(','), @@ -535,24 +702,27 @@ class WritersideTopicRemovalService { } } - if (selectedRemovalElement != null) { - promotedChildren += _removeAndPromote(selectedRemovalElement); - changedTreePaths.add(normalizePath(analysis.selectedTreePath!)); - } else { - for (final entry in treeDocuments.entries) { - if (!_documentContainsTarget( - snapshot, - entry.value, - snapshot.topic.filePath, - )) { - continue; - } - promotedChildren += _removeAllTargetEntries( - snapshot, - entry.value.rootElement, + final removalsByTree = >>{}; + for (final usage in analysis.usages.where( + (usage) => + usage.kind == WritersideTopicUsageKind.tocElement && + usage.relevant && + usage.canUpdateAutomatically && + usage.nodePath != null, + )) { + removalsByTree + .putIfAbsent(normalizePath(usage.filePath), () => []) + .add(usage.nodePath!); + } + for (final entry in removalsByTree.entries) { + final document = treeDocuments[entry.key]!; + final paths = entry.value..sort((a, b) => _comparePathsForRemoval(b, a)); + for (final nodePath in paths) { + promotedChildren += _removeAndPromote( + _elementAtPath(document.rootElement, nodePath), ); - changedTreePaths.add(entry.key); } + changedTreePaths.add(entry.key); } for (final path in changedTreePaths) { @@ -570,35 +740,38 @@ class WritersideTopicRemovalService { .map((usage) => normalizePath(usage.filePath)) .toSet(); for (final path in sourcePaths) { - final sourceTopic = snapshot.module.topics.firstWhere( - (topic) => p.equals(topic.filePath, path), - ); + final sourceTopic = snapshot.project.modules + .expand((module) => module.topics) + .firstWhere((topic) => p.equals(topic.filePath, path)); final original = snapshot.sources[path]!; - final updated = _rewriteTopicUsages(snapshot, sourceTopic, original); + final updated = _rewriteReviewedTopicUsages( + snapshot, + sourceTopic, + original, + analysis.usages.where( + (usage) => + usage.relevant && + usage.canUpdateAutomatically && + p.equals(usage.filePath, path), + ), + ); if (updated == original) { throw BusyMarkException( 'writerside.topic-removal.usages-remain', args: {'path': path}, ); } - if (_topicSourceStillReferencesTarget(snapshot, sourceTopic, updated)) { - throw BusyMarkException( - 'writerside.topic-removal.usages-remain', - args: {'path': path}, - ); - } updatedSources[path] = updated; updatedUsageFiles.add(path); } } - final sourceUsageRemains = _topicSourcesCouldContainTarget( - snapshot, - updatedSources, + final sourceUsageRemains = _plannedUsageRemains( + analysis, + updateUsagesAutomatically: request.updateUsagesAutomatically, ); if (analysis.mode == WritersideTopicRemovalMode.safeDeleteFile && - (_treeDocumentsCouldContainTarget(snapshot, treeDocuments.values) || - sourceUsageRemains)) { + sourceUsageRemains) { throw BusyMarkException( 'writerside.topic-removal.usages-remain', args: {'path': analysis.topicPath}, @@ -671,8 +844,7 @@ class WritersideTopicRemovalService { final orphaned = analysis.mode == WritersideTopicRemovalMode.safeDeleteFile ? true - : !_treeDocumentsCouldContainTarget(snapshot, treeDocuments.values) && - !sourceUsageRemains; + : !sourceUsageRemains; return WritersideTopicRemovalResult( deletedFile: analysis.mode == WritersideTopicRemovalMode.safeDeleteFile, orphaned: orphaned, @@ -683,36 +855,47 @@ class WritersideTopicRemovalService { } Future<_RemovalSnapshot> _snapshot( - String moduleRoot, - String requestedTopicPath, - ) async { - final anchor = await captureCanonicalDirectoryAnchor(moduleRoot); - final module = await moduleService.load(anchor.rootPath); - final incompleteModule = module.diagnostics.where( - (diagnostic) => - diagnostic.code.startsWith('workspace.scan.') || - diagnostic.code == 'writerside.topic.read-failed' || - diagnostic.code == 'writerside.topic.invalid-xml' || - diagnostic.code == 'writerside.variables.invalid-xml' || - diagnostic.code == 'writerside.config.invalid-xml' || - diagnostic.code == 'writerside.config.invalid-root', - ); - if (incompleteModule.isNotEmpty) { - final diagnostic = incompleteModule.first; + String projectRoot, + String requestedTopicPath, { + WritersideProject? loadedProject, + }) async { + final anchor = await captureCanonicalDirectoryAnchor(projectRoot); + final loader = + projectService ?? + WritersideProjectService( + moduleService: moduleService, + scanOptions: moduleService.scanOptions, + ); + final project = loadedProject ?? await loader.load(anchor.rootPath); + if (!project.moduleDiscoveryComplete) { throw BusyMarkException( - 'writerside.topic-removal.scan-failed', - args: {'path': diagnostic.filePath, 'error': diagnostic.code}, + 'writerside.topic-file.project-discovery-incomplete', + args: {'path': anchor.rootPath}, ); } - final safeTopicPath = _canonicalInputPath(anchor, requestedTopicPath); - WritersideTopic? topic; - for (final candidate in module.topics) { - if (p.equals(normalizePath(candidate.filePath), safeTopicPath)) { - topic = candidate; - break; + for (final candidate in project.modules) { + if (!candidate.topicDiscoveryComplete || + candidate.unparsedTopicReferences.isNotEmpty || + !candidate.variablesAvailable || + candidate.topics.any((topic) => !topic.document.isWellFormed)) { + throw BusyMarkException( + 'writerside.topic-removal.scan-failed', + args: { + 'path': candidate.rootPath, + 'error': 'writerside.topic-file.incomplete-project-index', + }, + ); } } - if (topic == null) { + final safeTopicPath = _canonicalInputPath(anchor, requestedTopicPath); + final module = project.topicOwnerForPath(safeTopicPath); + final topic = module?.topics + .where( + (candidate) => + p.equals(normalizePath(candidate.filePath), safeTopicPath), + ) + .firstOrNull; + if (module == null || topic == null) { throw BusyMarkException( 'writerside.topic-file.not-found', args: {'path': safeTopicPath}, @@ -720,38 +903,40 @@ class WritersideTopicRemovalService { } final sources = {}; - final configPath = _canonicalInputPath(anchor, module.config.filePath); - sources[configPath] = await _readRegularFile(anchor, configPath); - final variablesFile = module.config.varsFile; - if (variablesFile != null) { - final variablesPath = _canonicalInputPath( + final redirectRules = >>{}; + for (final candidate in project.modules) { + final inputPaths = { + candidate.config.filePath, + for (final sourceTopic in candidate.topics) sourceTopic.filePath, + if (candidate.config.varsFile case final path?) + p.join(candidate.rootPath, path), + if (candidate.config.instanceGroupsFile case final path?) + p.join(candidate.rootPath, path), + }; + for (final input in inputPaths) { + final path = _canonicalInputPath(anchor, input); + sources[path] = await _readRegularFile(anchor, path); + } + final redirectRulesPath = _canonicalInputPath( anchor, - p.join(anchor.rootPath, variablesFile), + p.join(candidate.rootPath, 'redirection-rules.xml'), ); - sources[variablesPath] = await _readRegularFile(anchor, variablesPath); - } - for (final sourceTopic in module.topics) { - final path = _canonicalInputPath(anchor, sourceTopic.filePath); - sources[path] = await _readRegularFile(anchor, path); - } - - final redirectRules = >{}; - final redirectRulesPath = _canonicalInputPath( - anchor, - p.join(anchor.rootPath, 'redirection-rules.xml'), - ); - if (await FileSystemEntity.type(redirectRulesPath, followLinks: false) != - FileSystemEntityType.notFound) { + if (await FileSystemEntity.type(redirectRulesPath, followLinks: false) == + FileSystemEntityType.notFound) { + continue; + } try { final source = await _readRegularFile(anchor, redirectRulesPath); final document = XmlDocument.parse(source); sources[redirectRulesPath] = source; + final moduleRules = redirectRules.putIfAbsent( + candidate.rootPath, + () => >{}, + ); for (final rule in document.findAllElements('rule')) { final id = rule.getAttribute('id')?.trim(); - if (id == null || id.isEmpty) { - continue; - } - final accepted = redirectRules.putIfAbsent(id, () => {}); + if (id == null || id.isEmpty) continue; + final accepted = moduleRules.putIfAbsent(id, () => {}); for (final element in rule.findElements('accepts')) { accepted.addAll( element.innerText @@ -775,31 +960,64 @@ class WritersideTopicRemovalService { try { final scan = await scanWorkspaceEntities( anchor.rootPath, - options: moduleService.scanOptions, + options: WorkspaceScanOptions( + maxParsedFileBytes: moduleService.scanOptions.maxParsedFileBytes, + maxParsedDocuments: moduleService.scanOptions.maxParsedDocuments, + maxTreeEntries: moduleService.scanOptions.maxTreeEntries, + followLinks: false, + includeUnsupportedFiles: true, + includeDirectories: false, + includeHiddenDirectories: true, + includeExcludedDirectories: true, + ), + directoryLister: treeDirectoryLister, ); - if (scan.diagnostics.isNotEmpty) { + if (!scan.traversalComplete || scan.diagnostics.isNotEmpty) { final diagnostic = scan.diagnostics.first; throw BusyMarkException( 'writerside.topic-removal.scan-failed', args: {'path': diagnostic.filePath, 'error': diagnostic.code}, ); } + var parsedTrees = 0; for (final entity in scan.entities) { if (entity is! File || p.extension(entity.path).toLowerCase() != '.tree') { continue; } + if (parsedTrees >= moduleService.scanOptions.maxParsedDocuments) { + throw BusyMarkException( + 'writerside.topic-removal.scan-failed', + args: { + 'path': entity.path, + 'error': 'workspace.scan.document-limit', + }, + ); + } + parsedTrees++; final path = _canonicalInputPath(anchor, entity.path); - final source = await _readRegularFile(anchor, path); + final source = await _readRegularFile( + anchor, + path, + maxBytes: moduleService.scanOptions.maxParsedFileBytes, + ); final document = XmlDocument.parse(source); if (document.rootElement.name.local != 'instance-profile') { throw FormatException('.tree root must be .', path); } sources[path] = source; + final owner = _mostSpecificModuleForPath(project.modules, path); + if (owner == null) { + throw BusyMarkException( + 'writerside.topic-removal.scan-failed', + args: {'path': path, 'error': 'writerside.topic.module-not-open'}, + ); + } trees[path] = _TreeSnapshot( path: path, source: source, document: document, + owner: owner, ); } } on BusyMarkException { @@ -810,28 +1028,30 @@ class WritersideTopicRemovalService { args: {'path': anchor.rootPath, 'error': '$error'}, ); } - for (final configured in module.config.instanceSources) { - final configuredPath = _canonicalInputPath( - anchor, - p.join(anchor.rootPath, configured), - ); - if (!trees.containsKey(configuredPath)) { - throw BusyMarkException( - 'writerside.topic-file.tree-missing', - args: {'path': configuredPath}, + for (final candidate in project.modules) { + for (final configured in candidate.config.instanceSources) { + final configuredPath = _canonicalInputPath( + anchor, + p.join(candidate.rootPath, configured), ); + if (!trees.containsKey(configuredPath)) { + throw BusyMarkException( + 'writerside.topic-file.tree-missing', + args: {'path': configuredPath}, + ); + } } } return _RemovalSnapshot( anchor: anchor, + project: project, module: module, topic: topic, sources: Map.unmodifiable(sources), trees: Map.unmodifiable(trees), - redirectRules: Map.unmodifiable({ - for (final entry in redirectRules.entries) - entry.key: Set.unmodifiable(entry.value), - }), + redirectRules: Map.unmodifiable(redirectRules), + moduleRoots: project.modules.map((module) => module.rootPath).toList() + ..sort(), fingerprint: _fingerprint(sources), ); } @@ -862,8 +1082,9 @@ class WritersideTopicRemovalService { Future _readRegularFile( CanonicalPathAnchor anchor, - String path, - ) async { + String path, { + int? maxBytes, + }) async { try { final resolution = await resolveAnchoredPath( anchor, @@ -873,6 +1094,9 @@ class WritersideTopicRemovalService { if (resolution.type != FileSystemEntityType.file) { throw const FileSystemException('Not a regular file'); } + if (maxBytes != null && await File(resolution.path).length() > maxBytes) { + throw const FileSystemException('File exceeds semantic parse limit'); + } return await File(resolution.path).readAsString(); } on BusyMarkException { rethrow; @@ -888,15 +1112,34 @@ class WritersideTopicRemovalService { _RemovalSnapshot snapshot, String reference, { WritersideTopic? fromTopic, + WritersideModule? sourceModule, + String? origin, }) { - final expansion = _expandReference(snapshot, reference, fromTopic); + final source = + sourceModule ?? + (fromTopic == null + ? snapshot.module + : snapshot.project.topicOwnerForPath(fromTopic.filePath)) ?? + snapshot.module; + final targetModule = origin == null + ? source + : snapshot.project.modulesByOrigin[origin]; + if (targetModule == null) return false; + final expansion = _expandReference( + snapshot, + reference, + fromTopic, + sourceModule: source, + ); if (expansion.unresolved || expansion.values.isEmpty) { return false; } for (final value in expansion.values) { - final matches = snapshot.module.topicsMatchingReference( + final matches = targetModule.topicsMatchingReference( value, - fromTopic: fromTopic, + fromTopic: p.equals(targetModule.rootPath, source.rootPath) + ? fromTopic + : null, ); if (matches.length != 1 || !p.equals(matches.single.filePath, snapshot.topic.filePath)) { @@ -910,10 +1153,33 @@ class WritersideTopicRemovalService { _RemovalSnapshot snapshot, String reference, { WritersideTopic? fromTopic, + WritersideModule? sourceModule, + String? origin, }) { - final expansion = _expandReference(snapshot, reference, fromTopic); + final source = + sourceModule ?? + (fromTopic == null + ? snapshot.module + : snapshot.project.topicOwnerForPath(fromTopic.filePath)) ?? + snapshot.module; + final targetModule = origin == null + ? source + : snapshot.project.modulesByOrigin[origin]; + if (targetModule == null) { + return _referencePatternCouldMatchTopic( + reference, + snapshot.topic, + fromTopic: fromTopic, + ); + } + final expansion = _expandReference( + snapshot, + reference, + fromTopic, + sourceModule: source, + ); for (final value in expansion.values) { - if (snapshot.module + if (targetModule .topicsMatchingReference(value, fromTopic: fromTopic) .any( (candidate) => @@ -935,8 +1201,9 @@ class WritersideTopicRemovalService { _ReferenceExpansion _expandReference( _RemovalSnapshot snapshot, String reference, - WritersideTopic? fromTopic, - ) { + WritersideTopic? fromTopic, { + WritersideModule? sourceModule, + }) { final normalizedReference = _decodeWritersideVariableEscapes(reference); final pending = [normalizedReference]; final seen = {normalizedReference}; @@ -951,7 +1218,12 @@ class WritersideTopicRemovalService { continue; } final token = match.group(0)!; - final values = _variableValues(snapshot, match.group(1)!, fromTopic); + final values = _variableValues( + snapshot, + match.group(1)!, + fromTopic, + sourceModule: sourceModule, + ); if (values.isEmpty) { unresolved.add(candidate); continue; @@ -984,9 +1256,11 @@ class WritersideTopicRemovalService { Set _variableValues( _RemovalSnapshot snapshot, String name, - WritersideTopic? fromTopic, - ) { - final configured = snapshot.module.variables + WritersideTopic? fromTopic, { + WritersideModule? sourceModule, + }) { + final module = sourceModule ?? snapshot.module; + final configured = module.variables .where((variable) => variable.name == name) .map((variable) => variable.value) .where((value) => value.isNotEmpty) @@ -996,15 +1270,10 @@ class WritersideTopicRemovalService { } return switch (name) { 'thisTopic' when fromTopic != null => {fromTopic.id}, - 'currentId' => { - for (final instance in snapshot.module.instances) instance.id, - }, - 'instance' => { - for (final instance in snapshot.module.instances) instance.name, - }, + 'currentId' => {for (final instance in module.instances) instance.id}, + 'instance' => {for (final instance in module.instances) instance.name}, 'instance-lowercase' => { - for (final instance in snapshot.module.instances) - instance.name.toLowerCase(), + for (final instance in module.instances) instance.name.toLowerCase(), }, _ => const {}, }; @@ -1043,18 +1312,26 @@ class WritersideTopicRemovalService { bool _elementTargets( _RemovalSnapshot snapshot, XmlElement element, - String topicPath, - ) { - final reference = element.getAttribute('topic'); + String topicPath, { + required _TreeSnapshot tree, + }) { + final reference = _tocReference(element); if (reference == null) { return false; } - final expansion = _expandReference(snapshot, reference, null); - if (expansion.unresolved || expansion.values.isEmpty) { - return false; - } + final targetModule = element.getAttribute('origin') == null + ? tree.owner + : snapshot.project.modulesByOrigin[element.getAttribute('origin')]; + if (targetModule == null) return false; + final expansion = _expandReference( + snapshot, + reference, + null, + sourceModule: tree.owner, + ); + if (expansion.unresolved || expansion.values.isEmpty) return false; for (final value in expansion.values) { - final matches = snapshot.module.topicsMatchingReference(value); + final matches = targetModule.topicsMatchingReference(value); if (matches.length != 1 || !p.equals(matches.single.filePath, topicPath)) { return false; @@ -1063,47 +1340,32 @@ class WritersideTopicRemovalService { return true; } - Set _topicsInTree(_RemovalSnapshot snapshot, _TreeSnapshot tree) { - final result = {}; - final startPage = tree.document.rootElement.getAttribute('start-page'); - if (startPage != null) { - final expansion = _expandReference(snapshot, startPage, null); - for (final value in expansion.values) { - for (final match in snapshot.module.topicsMatchingReference(value)) { - result.add(normalizePath(match.filePath)); - } - } - } - for (final element in tree.document.findAllElements('toc-element')) { - final reference = element.getAttribute('topic'); - if (reference == null) { - continue; - } - final expansion = _expandReference(snapshot, reference, null); - for (final value in expansion.values) { - final matches = snapshot.module.topicsMatchingReference(value); - for (final match in matches) { - result.add(normalizePath(match.filePath)); - } - } - } - return result; + bool _elementCouldTarget( + _RemovalSnapshot snapshot, + XmlElement element, { + required _TreeSnapshot tree, + }) { + final reference = _tocReference(element); + return reference != null && + _couldTarget( + snapshot, + reference, + sourceModule: tree.owner, + origin: element.getAttribute('origin'), + ); } WritersideTopicUsage _treeUsage( _TreeSnapshot tree, { + required XmlElement element, + required String attribute, required WritersideTopicUsageKind kind, required String reference, required bool relevant, required bool canUpdateAutomatically, + required SourceSpan span, + List? nodePath, }) { - final offset = tree.source.indexOf(reference); - final span = SourceSpan.fromOffsets( - filePath: tree.path, - source: tree.source, - startOffset: offset < 0 ? 0 : offset, - endOffset: offset < 0 ? 0 : offset + reference.length, - ); return WritersideTopicUsage( kind: kind, filePath: tree.path, @@ -1112,13 +1374,425 @@ class WritersideTopicRemovalService { reference: reference, relevant: relevant, canUpdateAutomatically: canUpdateAutomatically, + span: span, + moduleRoot: tree.owner.rootPath, + nodePath: nodePath, + ); + } + + SourceSpan _elementSpan(_TreeSnapshot tree, XmlElement element) { + final authored = [ + tree.document.rootElement, + ...tree.document.rootElement.descendants.whereType(), + ]; + final index = authored.indexWhere( + (candidate) => identical(candidate, element), + ); + final semantic = const WritersideDocumentParser() + .parseXml(filePath: tree.path, source: tree.source) + .elements + .toList(); + if (index >= 0 && index < semantic.length) return semantic[index].span; + return SourceSpan.fromOffsets( + filePath: tree.path, + source: tree.source, + startOffset: 0, + endOffset: 0, + ); + } + + SourceSpan _attributeSpan( + _TreeSnapshot tree, + XmlElement element, + String attribute, + ) { + final authored = [ + tree.document.rootElement, + ...tree.document.rootElement.descendants.whereType(), + ]; + final index = authored.indexWhere( + (candidate) => identical(candidate, element), + ); + final semantic = const WritersideDocumentParser() + .parseXml(filePath: tree.path, source: tree.source) + .elements + .toList(); + if (index >= 0 && index < semantic.length) { + return semantic[index].attributeSpans[attribute] ?? semantic[index].span; + } + return _elementSpan(tree, element); + } + + List _contentUsages( + _RemovalSnapshot snapshot, { + required WritersideTopicRemovalMode mode, + required WritersideInstance? selectedInstance, + required Set participatingTopics, + required Map> activeDocumentRanges, + }) { + final targetSymbol = snapshot.project.index.symbols + .where( + (symbol) => + symbol.kind == WritersideSymbolKind.topic && + p.equals(symbol.filePath, snapshot.topic.filePath) && + symbol.name == snapshot.topic.id, + ) + .firstOrNull; + final definitelyIndexed = targetSymbol == null + ? const {} + : snapshot.project.index.findUsages(targetSymbol).toSet(); + final result = []; + final seen = {}; + for (final reference in snapshot.project.index.references.where( + (reference) => reference.kind == WritersideSymbolKind.topic, + )) { + if (p.extension(reference.filePath).toLowerCase() == '.tree' || + p.equals(reference.filePath, snapshot.topic.filePath) || + hasUriScheme(reference.value)) { + continue; + } + final sourceModule = + snapshot.project.index.modulesById[reference.moduleId]; + if (sourceModule == null) continue; + final sourceTopic = sourceModule.topics + .where((topic) => p.equals(topic.filePath, reference.filePath)) + .firstOrNull; + if (sourceTopic == null) continue; + final semanticTargets = snapshot.project.index + .definitions( + reference.value.split('#').first, + moduleId: reference.moduleId, + origin: reference.origin, + kind: WritersideSymbolKind.topic, + filePath: reference.filePath, + referenceOffset: reference.span.startOffset, + ) + .where((symbol) => symbol.kind == WritersideSymbolKind.topic) + .toList(); + final exactTarget = semanticTargets.any( + (symbol) => p.equals(symbol.filePath, snapshot.topic.filePath), + ); + final couldTarget = + exactTarget || + _couldTarget( + snapshot, + _referenceWithoutAnchor(reference.value), + fromTopic: sourceTopic, + sourceModule: sourceModule, + origin: reference.origin, + ); + if (!couldTarget) continue; + final key = + '${reference.filePath}:${reference.span.startOffset}:' + '${reference.span.endOffset}'; + if (!seen.add(key)) continue; + final element = _elementContainingReference(sourceTopic, reference.span); + final kind = switch (element?.semanticKind) { + WritersideSemanticKind.include => WritersideTopicUsageKind.include, + WritersideSemanticKind.card => + WritersideTopicUsageKind.otherTopicReference, + _ => WritersideTopicUsageKind.topicLink, + }; + final source = snapshot.sources[normalizePath(sourceTopic.filePath)]!; + final expandedExact = _targets( + snapshot, + _referenceWithoutAnchor(reference.value), + fromTopic: sourceTopic, + sourceModule: sourceModule, + origin: reference.origin, + ); + final uniqueTarget = + expandedExact || + (exactTarget && + semanticTargets + .map((symbol) => normalizePath(symbol.filePath)) + .toSet() + .length == + 1 && + (definitelyIndexed.contains(reference) || + semanticTargets.isNotEmpty)); + final automatic = + uniqueTarget && + kind != WritersideTopicUsageKind.otherTopicReference && + _sourceReplacementForUsage( + sourceTopic, + source, + kind, + reference.span, + ) != + null; + final relevant = + mode == WritersideTopicRemovalMode.safeDeleteFile || + (selectedInstance != null && + ((participatingTopics.contains( + normalizePath(sourceTopic.filePath), + ) && + _referenceActiveInInstance( + snapshot, + sourceModule, + sourceTopic, + selectedInstance, + reference.span, + )) || + (activeDocumentRanges[normalizePath(sourceTopic.filePath)] ?? + const []) + .any( + (span) => + span.startOffset <= reference.span.startOffset && + span.endOffset >= reference.span.endOffset, + ))); + result.add( + WritersideTopicUsage( + kind: kind, + filePath: reference.filePath, + line: reference.span.startLine, + column: reference.span.startColumn, + reference: reference.sourceValue ?? reference.value, + relevant: relevant, + canUpdateAutomatically: automatic, + span: reference.span, + moduleRoot: sourceModule.rootPath, + ), + ); + } + return result; + } + + WritersideElementNode? _elementContainingReference( + WritersideTopic topic, + SourceSpan span, + ) { + final matches = + topic.document.elements + .where( + (element) => + element.span.startOffset <= span.startOffset && + element.span.endOffset >= span.endOffset, + ) + .toList() + ..sort( + (a, b) => (a.span.endOffset - a.span.startOffset).compareTo( + b.span.endOffset - b.span.startOffset, + ), + ); + return matches.firstOrNull; + } + + bool _referenceActiveInInstance( + _RemovalSnapshot snapshot, + WritersideModule sourceModule, + WritersideTopic sourceTopic, + WritersideInstance selectedInstance, + SourceSpan span, + ) { + final sourceElement = _elementContainingReference(sourceTopic, span); + if (sourceElement?.semanticKind == WritersideSemanticKind.include) { + return sourceTopic.document + .walk() + .whereType() + .where( + (element) => + p.equals(element.span.filePath, span.filePath) && + element.span.startOffset <= span.startOffset && + element.span.endOffset >= span.endOffset, + ) + .every( + (element) => _matchesInstanceCondition( + element.attributes['instance'], + sourceModule, + selectedInstance.id, + ), + ); + } + final authoredContainer = + sourceTopic.document + .walk() + .where( + (node) => + p.equals(node.span.filePath, span.filePath) && + node.span.startOffset <= span.startOffset && + node.span.endOffset >= span.endOffset, + ) + .toList() + ..sort( + (a, b) => (a.span.endOffset - a.span.startOffset).compareTo( + b.span.endOffset - b.span.startOffset, + ), + ); + final authoredSpan = sourceElement?.span ?? authoredContainer.first.span; + final resolved = const WritersideDocumentResolver().resolve( + sourceTopic.document, + WritersideResolveContext( + module: sourceModule, + topic: sourceTopic, + instance: selectedInstance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ), ); + return resolved.document.walk().any( + (node) => + p.equals(node.span.filePath, authoredSpan.filePath) && + node.span.startOffset == authoredSpan.startOffset && + node.span.endOffset == authoredSpan.endOffset, + ); + } + + bool _matchesInstanceCondition( + String? condition, + WritersideModule module, + String instanceId, + ) { + if (condition == null || condition.trim().isEmpty) return true; + final trimmed = condition.trim(); + final negated = trimmed.startsWith('!'); + final body = negated ? trimmed.substring(1) : trimmed; + final matches = body + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .any( + (token) => token.startsWith('@') + ? module.instanceGroups?.groups[token.substring(1)]?.instanceIds + .contains(instanceId) == + true + : token == instanceId, + ); + return negated ? !matches : matches; + } + + _TextReplacement? _sourceReplacementForUsage( + WritersideTopic topic, + String source, + WritersideTopicUsageKind kind, + SourceSpan span, + ) { + if (kind == WritersideTopicUsageKind.include) { + final element = _elementContainingReference(topic, span); + if (element?.semanticKind != WritersideSemanticKind.include) return null; + return _TextReplacement( + element!.span.startOffset, + element.span.endOffset, + '', + ); + } + if (kind != WritersideTopicUsageKind.topicLink) return null; + for (final match in _markdownLinkPattern.allMatches(source)) { + if (match.start <= span.startOffset && match.end >= span.endOffset) { + return _TextReplacement(match.start, match.end, match.group(1)!); + } + } + for (final match in _xmlAnchorPattern.allMatches(source)) { + if (match.start <= span.startOffset && match.end >= span.endOffset) { + return _TextReplacement(match.start, match.end, match.group(2)!); + } + } + return null; + } + + String _rewriteReviewedTopicUsages( + _RemovalSnapshot snapshot, + WritersideTopic topic, + String source, + Iterable usages, + ) { + final replacements = <_TextReplacement>[]; + for (final usage in usages) { + final span = usage.span; + if (span == null) continue; + final replacement = _sourceReplacementForUsage( + topic, + source, + usage.kind, + span, + ); + if (replacement != null) replacements.add(replacement); + } + replacements.sort((a, b) => b.start.compareTo(a.start)); + var updated = source; + var previousStart = source.length + 1; + for (final replacement in replacements) { + if (replacement.end > previousStart) continue; + updated = updated.replaceRange( + replacement.start, + replacement.end, + replacement.text, + ); + previousStart = replacement.start; + } + return updated; + } + + bool _plannedUsageRemains( + WritersideTopicRemovalAnalysis analysis, { + required bool updateUsagesAutomatically, + }) { + for (final usage in analysis.usages) { + if (!usage.relevant) return true; + if (usage.kind == WritersideTopicUsageKind.tocElement && + usage.canUpdateAutomatically) { + continue; + } + if (updateUsagesAutomatically && + usage.canUpdateAutomatically && + (usage.kind == WritersideTopicUsageKind.topicLink || + usage.kind == WritersideTopicUsageKind.include)) { + continue; + } + return true; + } + return false; + } + + List _redirectSources( + _RemovalSnapshot snapshot, + List usages, + ) { + final result = []; + for (final usage in usages.where( + (usage) => + usage.kind == WritersideTopicUsageKind.tocElement && usage.relevant, + )) { + final tree = snapshot.trees[normalizePath(usage.filePath)]; + final nodePath = usage.nodePath; + if (tree == null || nodePath == null) continue; + final element = _elementAtPath(tree.document.rootElement, nodePath); + final instance = tree.owner.instances + .where((candidate) => p.equals(candidate.sourceTreePath, tree.path)) + .firstOrNull; + final names = { + _effectiveWebFileName(snapshot, tree.path), + ..._acceptedWebFileNames(snapshot, element, treePath: tree.path), + }; + result.add( + WritersideTopicRedirectSource( + hostModuleRoot: tree.owner.rootPath, + treePath: tree.path, + instanceId: instance?.id, + acceptedWebFileNames: names, + ), + ); + } + return result; + } + + Set _redirectNamesForTree( + WritersideTopicRemovalAnalysis analysis, + String treePath, + ) { + final exact = { + for (final source in analysis.redirectSources) + if (p.equals(source.treePath, treePath)) ...source.acceptedWebFileNames, + }; + if (exact.isNotEmpty) return exact; + return analysis.oldWebFileName.isEmpty + ? const {} + : {analysis.oldWebFileName}; } String? _preferredTree(_RemovalSnapshot snapshot) { for (final configured in snapshot.module.config.instanceSources) { final candidate = normalizePath( - p.join(snapshot.anchor.rootPath, configured), + p.join(snapshot.module.rootPath, configured), ); if (snapshot.trees.containsKey(candidate)) { return candidate; @@ -1133,7 +1807,7 @@ class WritersideTopicRemovalService { required _TreeSnapshot preferredTree, }) { if (mode == WritersideTopicRemovalMode.removeFromInstance) { - return _redirectTargets(snapshot, preferredTree); + return _resolvedRedirectTargets(snapshot, preferredTree); } final affectedTrees = snapshot.trees.values .where( @@ -1141,6 +1815,7 @@ class WritersideTopicRemovalService { snapshot, tree.document, snapshot.topic.filePath, + tree: tree, ), ) .toList(growable: false); @@ -1159,6 +1834,7 @@ class WritersideTopicRemovalService { snapshot, tree.document, candidate.topicPath, + tree: tree, ).length == 1, ), @@ -1166,6 +1842,50 @@ class WritersideTopicRemovalService { .toList(growable: false); } + List _resolvedRedirectTargets( + _RemovalSnapshot snapshot, + _TreeSnapshot tree, + ) { + final instance = tree.owner.instances + .where((candidate) => p.equals(candidate.sourceTreePath, tree.path)) + .firstOrNull; + if (instance == null) return const []; + final presenter = WritersideTocPresenter( + module: tree.owner, + instance: instance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ); + final result = []; + final seen = {}; + for (final node in instance.navigationTocRoots.expand( + (root) => root.flatten(), + )) { + if (!node.canEditStructure || + !p.equals(node.sourceTreePath!, tree.path) || + node.sourceTocPath == null) { + continue; + } + final topic = presenter.present(node).topic; + if (topic == null || + p.equals(topic.filePath, snapshot.topic.filePath) || + !seen.add(normalizePath(topic.filePath))) { + continue; + } + result.add( + WritersideTopicRedirectTarget( + topicPath: topic.filePath, + topicFileName: topic.fileName, + label: topic.title?.trim().isNotEmpty == true + ? topic.title!.trim() + : topic.fileName, + treePath: tree.path, + nodePath: node.sourceTocPath!, + ), + ); + } + return result; + } + List _redirectTargets( _RemovalSnapshot snapshot, _TreeSnapshot tree, @@ -1173,11 +1893,16 @@ class WritersideTopicRemovalService { final result = []; final seen = {}; for (final entry in _tocEntries(tree.document.rootElement)) { - final reference = entry.element.getAttribute('topic'); + final reference = _tocReference(entry.element); if (reference == null) { continue; } - final matches = snapshot.module.topicsMatchingReference(reference); + final owner = entry.element.getAttribute('origin') == null + ? tree.owner + : snapshot.project.modulesByOrigin[entry.element.getAttribute( + 'origin', + )]; + final matches = owner?.topicsMatchingReference(reference) ?? const []; if (matches.length != 1) { continue; } @@ -1205,24 +1930,31 @@ class WritersideTopicRemovalService { List _elementsTargetingPath( _RemovalSnapshot snapshot, XmlDocument document, - String topicPath, - ) => document + String topicPath, { + required _TreeSnapshot tree, + }) => document .findAllElements('toc-element') - .where((element) => _elementTargets(snapshot, element, topicPath)) + .where( + (element) => _elementTargets(snapshot, element, topicPath, tree: tree), + ) .toList(growable: false); bool _documentContainsTarget( _RemovalSnapshot snapshot, XmlDocument document, - String topicPath, - ) => document + String topicPath, { + required _TreeSnapshot tree, + }) => document .findAllElements('toc-element') - .any((element) => _elementTargets(snapshot, element, topicPath)); + .any( + (element) => _elementTargets(snapshot, element, topicPath, tree: tree), + ); Set _acceptedWebFileNames( _RemovalSnapshot snapshot, - XmlElement element, - ) { + XmlElement element, { + required String treePath, + }) { final result = _directlyAcceptedWebFileNames(element); final references = (element.getAttribute('accepts-web-file-names-ref') ?? '') @@ -1230,22 +1962,83 @@ class WritersideTopicRemovalService { .map((value) => value.trim()) .where((value) => value.isNotEmpty); for (final reference in references) { - result.addAll(snapshot.redirectRules[reference] ?? const {}); + final owner = snapshot.trees[normalizePath(treePath)]?.owner; + result.addAll( + snapshot.redirectRules[owner?.rootPath]?[reference] ?? const {}, + ); } return result; } + bool _resolvedInstanceHasRedirectConflict( + _RemovalSnapshot snapshot, { + required String treePath, + required String webFileName, + required String redirectTopicPath, + }) { + final tree = snapshot.trees[normalizePath(treePath)]; + if (tree == null) return true; + final instance = tree.owner.instances + .where((candidate) => p.equals(candidate.sourceTreePath, tree.path)) + .firstOrNull; + if (instance == null) return false; + final presenter = WritersideTocPresenter( + module: tree.owner, + instance: instance, + modulesByOrigin: snapshot.project.modulesByOrigin, + ); + for (final node in instance.navigationTocRoots.expand( + (root) => root.flatten(), + )) { + final topic = presenter.present(node).topic; + if (topic != null && + (p.equals(topic.filePath, snapshot.topic.filePath) || + p.equals(topic.filePath, redirectTopicPath))) { + continue; + } + final names = { + ...(node.acceptsWebFileNames ?? '') + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty), + }; + final sourceTree = node.sourceTreePath == null + ? null + : snapshot.trees[normalizePath(node.sourceTreePath!)]; + final rules = snapshot + .redirectRules[sourceTree?.owner.rootPath ?? tree.owner.rootPath]; + for (final reference + in (node.acceptsWebFileNamesRef ?? '') + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty)) { + names.addAll(rules?[reference] ?? const {}); + } + if (names.contains(webFileName)) return true; + if (topic != null && + _effectiveWebFileName(snapshot, tree.path, topic: topic) == + webFileName) { + return true; + } + } + return false; + } + bool _elementPublishesWebFileName( _RemovalSnapshot snapshot, XmlElement element, String webFileName, { required String treePath, }) { - final reference = element.getAttribute('topic'); + final reference = _tocReference(element); if (reference == null) { return false; } - final matches = snapshot.module.topicsMatchingReference(reference); + final tree = snapshot.trees[normalizePath(treePath)]!; + final owner = element.getAttribute('origin') == null + ? tree.owner + : snapshot.project.modulesByOrigin[element.getAttribute('origin')]; + final matches = owner?.topicsMatchingReference(reference) ?? const []; if (matches.length != 1) { return false; } @@ -1258,7 +2051,10 @@ class WritersideTopicRemovalService { String? treePath, { WritersideTopic? topic, }) { - final instance = snapshot.module.instances + final tree = treePath == null + ? null + : snapshot.trees[normalizePath(treePath)]; + final instance = tree?.owner.instances .where( (candidate) => treePath != null && p.equals(candidate.sourceTreePath, treePath), @@ -1266,215 +2062,28 @@ class WritersideTopicRemovalService { .firstOrNull; final effectiveInstance = instance ?? - snapshot.module.instances - .where((value) => !value.isLibrary) - .firstOrNull ?? - snapshot.module.instances.firstOrNull; + tree?.owner.instances.where((value) => !value.isLibrary).firstOrNull ?? + tree?.owner.instances.firstOrNull; final target = topic ?? snapshot.topic; + final targetOwner = + snapshot.project.topicOwnerForPath(target.filePath) ?? snapshot.module; if (effectiveInstance == null) { return WritersideWebFileNameResolver.defaultName( target.fileName, disablePreprocessing: - snapshot.module.config.settings.disableWebNamePreprocessing == true, + targetOwner.config.settings.disableWebNamePreprocessing == true, ); } return const WritersideWebFileNameResolver() .resolve( - module: snapshot.module, + module: targetOwner, topic: target, instance: effectiveInstance, - modulesByOrigin: { - (snapshot.module.config.moduleName?.trim().isNotEmpty == true - ? snapshot.module.config.moduleName!.trim() - : p.basename(snapshot.module.rootPath)): - snapshot.module, - }, + modulesByOrigin: snapshot.project.modulesByOrigin, ) .value; } - bool _canRewriteLink( - _RemovalSnapshot snapshot, - WritersideTopic topic, - String source, - String destination, - ) { - if (topic.format == WritersideTopicFormat.xml) { - try { - return XmlDocument.parse(source) - .findAllElements('a') - .any( - (element) => - element.getAttribute('href') == destination && - _targets( - snapshot, - _referenceWithoutAnchor(destination), - fromTopic: topic, - ), - ); - } on Object { - return false; - } - } - return _markdownLinkPattern.allMatches(source).any((match) { - final found = _unquoteDestination(match.group(2)!); - return _sameWritersideReference(found, destination) && - _targets(snapshot, _referenceWithoutAnchor(found), fromTopic: topic); - }); - } - - bool _canRewriteInclude( - _RemovalSnapshot snapshot, - WritersideTopic topic, - String source, - String reference, - ) { - if (!_targets(snapshot, reference, fromTopic: topic)) { - return false; - } - if (topic.format == WritersideTopicFormat.xml) { - try { - return XmlDocument.parse(source) - .findAllElements('include') - .any((element) => element.getAttribute('from') == reference); - } on Object { - return false; - } - } - return _includePattern - .allMatches(source) - .any((match) => _attributeValue(match.group(0)!, 'from') == reference); - } - - String _rewriteTopicUsages( - _RemovalSnapshot snapshot, - WritersideTopic topic, - String source, - ) { - if (topic.format == WritersideTopicFormat.xml) { - final document = XmlDocument.parse(source); - for (final anchor in document.findAllElements('a').toList()) { - final href = anchor.getAttribute('href'); - if (href == null || - !_targets( - snapshot, - _referenceWithoutAnchor(href), - fromTopic: topic, - )) { - continue; - } - final parent = anchor.parent; - if (parent == null) { - continue; - } - final index = parent.children.indexOf(anchor); - final promoted = anchor.children.map((node) => node.copy()).toList(); - parent.children.removeAt(index); - parent.children.insertAll(index, promoted); - } - for (final include in document.findAllElements('include').toList()) { - final from = include.getAttribute('from'); - if (from != null && _targets(snapshot, from, fromTopic: topic)) { - include.parent?.children.remove(include); - } - } - return _xmlSource(document); - } - - var updated = source.replaceAllMapped(_markdownLinkPattern, (match) { - final destination = _unquoteDestination(match.group(2)!); - if (!_targets( - snapshot, - _referenceWithoutAnchor(destination), - fromTopic: topic, - )) { - return match.group(0)!; - } - return match.group(1)!; - }); - updated = updated.replaceAllMapped(_xmlAnchorPattern, (match) { - final href = match.group(1)!; - if (!_targets( - snapshot, - _referenceWithoutAnchor(href), - fromTopic: topic, - )) { - return match.group(0)!; - } - return match.group(2)!; - }); - updated = updated.replaceAllMapped(_includePattern, (match) { - final element = match.group(0)!; - final from = _attributeValue(element, 'from'); - return from != null && _targets(snapshot, from, fromTopic: topic) - ? '' - : element; - }); - return updated; - } - - bool _topicSourceStillReferencesTarget( - _RemovalSnapshot snapshot, - WritersideTopic topic, - String source, - ) { - final parsed = switch (topic.format) { - WritersideTopicFormat.markdown => moduleService.topicParser.parseMarkdown( - filePath: topic.filePath, - source: source, - topicsRoot: topic.topicRoot, - ), - WritersideTopicFormat.xml => moduleService.topicParser.parseXml( - filePath: topic.filePath, - source: source, - topicsRoot: topic.topicRoot, - ), - }; - return parsed.links.any( - (link) => - !hasUriScheme(link.destination) && - _couldTarget( - snapshot, - _referenceWithoutAnchor(link.destination), - fromTopic: topic, - ), - ) || - parsed.includes.any( - (include) => - include.from != null && - _couldTarget(snapshot, include.from!, fromTopic: topic), - ); - } - - bool _topicSourcesCouldContainTarget( - _RemovalSnapshot snapshot, - Map plannedSources, - ) { - for (final topic in snapshot.module.topics) { - if (p.equals(topic.filePath, snapshot.topic.filePath)) { - continue; - } - final path = normalizePath(topic.filePath); - final source = plannedSources[path] ?? snapshot.sources[path]!; - if (_topicSourceStillReferencesTarget(snapshot, topic, source)) { - return true; - } - } - return false; - } - - int _removeAllTargetEntries(_RemovalSnapshot snapshot, XmlElement parent) { - var promoted = 0; - for (final child in parent.childElements.where(_isTocElement).toList()) { - promoted += _removeAllTargetEntries(snapshot, child); - if (!_elementTargets(snapshot, child, snapshot.topic.filePath)) { - continue; - } - promoted += _removeAndPromote(child); - } - return promoted; - } - int _removeAndPromote(XmlElement element) { final parent = element.parent; if (parent == null) { @@ -1490,20 +2099,6 @@ class WritersideTopicRemovalService { return children.length; } - bool _treeDocumentsCouldContainTarget( - _RemovalSnapshot snapshot, - Iterable documents, - ) => documents.any((document) { - final startPage = document.rootElement.getAttribute('start-page'); - if (startPage != null && _couldTarget(snapshot, startPage)) { - return true; - } - return document.findAllElements('toc-element').any((element) { - final reference = element.getAttribute('topic'); - return reference != null && _couldTarget(snapshot, reference); - }); - }); - Future _ensureExpectedState( _RemovalSnapshot snapshot, List<_SourceEdit> edits, @@ -1740,20 +2335,24 @@ class WritersideTopicRemovalService { class _RemovalSnapshot { const _RemovalSnapshot({ required this.anchor, + required this.project, required this.module, required this.topic, required this.sources, required this.trees, required this.redirectRules, + required this.moduleRoots, required this.fingerprint, }); final CanonicalPathAnchor anchor; + final WritersideProject project; final WritersideModule module; final WritersideTopic topic; final Map sources; final Map trees; - final Map> redirectRules; + final Map>> redirectRules; + final List moduleRoots; final String fingerprint; } @@ -1762,11 +2361,21 @@ class _TreeSnapshot { required this.path, required this.source, required this.document, + required this.owner, }); final String path; final String source; final XmlDocument document; + final WritersideModule owner; +} + +class _TextReplacement { + const _TextReplacement(this.start, this.end, this.text); + + final int start; + final int end; + final String text; } class _TocEntry { @@ -1843,6 +2452,45 @@ bool _samePath(List first, List second) { return true; } +String? _tocReference(XmlElement element) { + final topic = element.getAttribute('topic')?.trim(); + if (topic != null && topic.isNotEmpty) return topic; + final reference = element.getAttribute('ref')?.trim(); + return reference == null || reference.isEmpty ? null : reference; +} + +String _treeNodeKey(String filePath, int offset) => + '${normalizePath(filePath)}:$offset'; + +WritersideModule? _mostSpecificModuleForPath( + Iterable modules, + String filePath, +) { + final path = normalizePath(filePath); + final matches = modules.where((module) { + final root = normalizePath(module.rootPath); + return p.equals(root, path) || p.isWithin(root, path); + }).toList()..sort((a, b) => b.rootPath.length.compareTo(a.rootPath.length)); + return matches.firstOrNull; +} + +int _comparePathsForRemoval(List first, List second) { + final length = first.length < second.length ? first.length : second.length; + for (var index = 0; index < length; index += 1) { + final compared = first[index].compareTo(second[index]); + if (compared != 0) return compared; + } + return first.length.compareTo(second.length); +} + +bool _sameStringList(List first, List second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index += 1) { + if (!p.equals(first[index], second[index])) return false; + } + return true; +} + String _referenceWithoutAnchor(String destination) => destination.split('#').first.split('?').first; @@ -1862,30 +2510,9 @@ String _fingerprint(Map sources) { return crypto.sha256.convert(utf8.encode('$data')).toString(); } -String _unquoteDestination(String value) { - final trimmed = value.trim(); - if (trimmed.startsWith('<') && trimmed.endsWith('>')) { - return trimmed.substring(1, trimmed.length - 1); - } - return trimmed; -} - String _decodeWritersideVariableEscapes(String value) => value.replaceAll(RegExp('%25', caseSensitive: false), '%'); -bool _sameWritersideReference(String first, String second) => - _decodeWritersideVariableEscapes(first) == - _decodeWritersideVariableEscapes(second); - -String? _attributeValue(String element, String attribute) { - final match = RegExp( - '$attribute\\s*=\\s*(["\\\'])(.*?)\\1', - caseSensitive: false, - dotAll: true, - ).firstMatch(element); - return match?.group(2); -} - Set _directlyAcceptedWebFileNames(XmlElement element) => (element.getAttribute('accepts-web-file-names') ?? '') .split(',') @@ -1901,11 +2528,6 @@ final RegExp _xmlAnchorPattern = RegExp( caseSensitive: false, dotAll: true, ); -final RegExp _includePattern = RegExp( - r']*(?:/>|>.*?)', - caseSensitive: false, - dotAll: true, -); final RegExp _writersideVariableReference = RegExp( r'(? + + '''); File(p.join(root.path, 'api.tree')).writeAsStringSync(''' @@ -1928,11 +1930,10 @@ void main() { ).writeAsStringSync('# Target\n'); File(p.join(root.path, 'topics', 'api.md')).writeAsStringSync('# API\n'); final sharedModule = Directory(p.join(root.path, 'shared'))..createSync(); - Directory(p.join(sharedModule.path, 'topics')).createSync(); File(p.join(sharedModule.path, 'writerside.cfg')).writeAsStringSync(''' - + '''); @@ -1942,7 +1943,7 @@ void main() { '''); File( - p.join(sharedModule.path, 'topics', 'shared.md'), + p.join(sharedModule.path, 'shared.md'), ).writeAsStringSync('# Shared\n'); final workspace = (await tester.runAsync( () => const WorkspaceService().openPath(root.path), @@ -2008,9 +2009,25 @@ void main() { WritersideTopicRemovalMode.safeDeleteFile, ); expect(find.text(l10n.delete), findsOneWidget); + expect(find.text(l10n.tocSafeDelete), findsOneWidget); + final mandatorySafeDelete = tester.widget( + find.widgetWithText(CheckboxListTile, l10n.tocSafeDelete), + ); + expect(mandatorySafeDelete.value, isTrue); + expect(mandatorySafeDelete.onChanged, isNull); + expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); + expect(find.text(l10n.reviewUsages), findsOneWidget); await tester.tap(find.text(l10n.cancel)); await tester.pump(const Duration(milliseconds: 200)); + await openPopup(find.text('target.md'), buttons: kSecondaryButton); + expect(popupMenuItem(l10n.tocRefactorMenu), findsOneWidget); + await tester.tap(popupMenuItem(l10n.tocRefactorMenu)); + await tester.pumpAndSettle(); + expect(popupMenuItem(l10n.tocSafeDelete), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); await tester.tap(find.text(l10n.toc)); await tester.pumpAndSettle(); @@ -2301,8 +2318,33 @@ void main() { WritersideTopicRemovalMode.removeFromInstance, ); expect(find.text(l10n.removeTocElement), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 200)); + expect(find.text(l10n.setRedirectTo), findsOneWidget); + expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); + expect(find.text(l10n.topicUsagesCount(1)), findsOneWidget); + expect(find.text(l10n.reviewUsages), findsOneWidget); + expect(find.text(l10n.removeAction), findsOneWidget); + await tester.tap(find.text(l10n.reviewUsages)); + await tester.pumpAndSettle(); + expect(find.text(l10n.doRefactor), findsOneWidget); + await tester.tap(find.text(l10n.doRefactor)); + await tester.pumpAndSettle(); + expect(controller.appliedTopicRemovalCount, 1); + expect(find.text(l10n.removeTocElement), findsNothing); + + for (final label in ['Ref entry', 'Origin entry']) { + await tester.tap(find.text(label)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + expect( + controller.analyzedRemovalMode, + WritersideTopicRemovalMode.removeFromInstance, + ); + expect(find.text(l10n.removeTocElement), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + } await tester.pumpAndSettle(); await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); @@ -11086,6 +11128,7 @@ class _MutableWorkspaceController extends WorkspaceController { List? movedTopicSourcePath; List? movedTopicReferencePath; WritersideTopicRemovalMode? analyzedRemovalMode; + var appliedTopicRemovalCount = 0; String? selectedWritersideModuleId; String? selectedWritersideInstanceId; String? openedFilePath; @@ -11412,12 +11455,36 @@ class _MutableWorkspaceController extends WorkspaceController { List? nodePath, }) async { analyzedRemovalMode = mode; - final topic = state.workspace!.writersideModule!.topics.singleWhere( + final workspace = state.workspace!; + final owner = + workspace.writersideProject?.topicOwnerForPath(topicPath) ?? + workspace.writersideModule; + final topic = owner!.topics.singleWhere( (candidate) => p.equals(candidate.filePath, topicPath), ); + final usagePath = + workspace.writersideProject?.modules + .where((module) => !p.equals(module.rootPath, owner.rootPath)) + .expand((module) => module.topics) + .firstOrNull + ?.filePath ?? + topicPath; return WritersideTopicRemovalAnalysis( mode: mode, - moduleRoot: state.workspace!.rootPath, + projectRoot: workspace.rootPath, + targetModuleRoot: owner.rootPath, + hostModuleRoot: treePath == null + ? owner.rootPath + : workspace.writersideProject?.modules + .where( + (module) => module.instances.any( + (instance) => + p.equals(instance.sourceTreePath, treePath), + ), + ) + .firstOrNull + ?.rootPath ?? + owner.rootPath, topicPath: topicPath, topicFileName: topic.fileName, topicTitle: topic.title, @@ -11426,9 +11493,39 @@ class _MutableWorkspaceController extends WorkspaceController { selectedNodePath: nodePath, childCount: 0, isStartPage: false, - usages: const [], + usages: [ + WritersideTopicUsage( + kind: WritersideTopicUsageKind.topicLink, + filePath: usagePath, + line: 1, + column: 1, + reference: topic.fileName, + relevant: true, + canUpdateAutomatically: true, + moduleRoot: p.dirname(p.dirname(usagePath)), + ), + ], redirectTargets: const [], fingerprint: 'widget-test', + projectModuleRoots: [ + for (final module in workspace.writersideProject?.modules ?? [owner]) + module.rootPath, + ], + ); + } + + @override + Future applyWritersideTopicRemoval( + WritersideTopicRemovalRequest request, + ) async { + appliedTopicRemovalCount++; + return WritersideTopicRemovalResult( + deletedFile: + request.analysis.mode == WritersideTopicRemovalMode.safeDeleteFile, + orphaned: false, + promotedChildren: 0, + redirectAdded: false, + updatedUsageFiles: const [], ); } diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index c7eea075..842c982a 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -23,6 +23,7 @@ import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; +import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,6 +31,63 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + test( + 'Safe Delete closes the topic buffer, selects a survivor, and records history', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-topic-removal-lifecycle-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'guide.tree')).writeAsString(''' + + + + +'''); + final survivor = File(p.join(root.path, 'topics', 'survivor.md')); + final doomed = File(p.join(root.path, 'topics', 'doomed.md')); + await survivor.writeAsString('# Survivor\n'); + await doomed.writeAsString('# Doomed\n'); + final history = MemoryLocalHistoryStore(); + final harness = await _createControllerHarness( + localHistoryStore: history, + ); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(survivor.path); + await controller.openActiveFile(doomed.path); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: doomed.path, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + + final result = await controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest(analysis: analysis!), + ); + + expect(result?.deletedFile, isTrue); + expect(doomed.existsSync(), isFalse); + expect( + controller.state.documentBuffers.map((buffer) => buffer.filePath), + isNot(contains(doomed.path)), + ); + expect( + controller.state.workspace?.openFilePaths, + isNot(contains(doomed.path)), + ); + expect(controller.state.workspace?.activeFilePath, survivor.path); + final historySnapshot = await history.load(); + final deletedDocument = historySnapshot.documents.singleWhere( + (document) => document.historicalPaths.contains(doomed.path), + ); + expect(deletedDocument.deleted, isTrue); + }, + ); + test( 'TOC writes reject an inactive dirty tree without discarding either buffer', () async { diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index e0615e37..b1365adf 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -1126,6 +1126,59 @@ void main() { skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, ); + test( + 'generic deletion rejects topics and containing folders in a non-active module', + () async { + final fixture = await _createTopicRenameWorkspace(); + final sharedRoot = await Directory( + p.join(fixture.projectRoot.path, 'shared'), + ).create(); + final topicsRoot = await Directory( + p.join(sharedRoot.path, 'topics'), + ).create(); + await File(p.join(sharedRoot.path, 'writerside.cfg')).writeAsString(''' + +'''); + await File(p.join(sharedRoot.path, 'shared.tree')).writeAsString(''' + +'''); + final reference = File(p.join(topicsRoot.path, 'reference.md')); + await reference.writeAsString('# Reference\n'); + + for (final path in [reference.path, sharedRoot.path]) { + await expectLater( + service.deleteEntity(fixture.workspace, path), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-removal.safe-delete-required', + ), + ), + ); + } + expect(reference.existsSync(), isTrue); + expect(sharedRoot.existsSync(), isTrue); + }, + ); + + test('an unparsed topic cannot bypass generic deletion', () async { + final fixture = await _createWorkspaceWithUnparsedGuide(); + final unparsed = p.join(fixture.root.path, 'topics', 'guide.topic'); + + await expectLater( + fixture.service.deleteEntity(fixture.workspace, unparsed), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-removal.safe-delete-required', + ), + ), + ); + expect(File(unparsed).existsSync(), isTrue); + }); + test('missing nested mutation paths keep domain-specific errors', () async { final root = await Directory.systemTemp.createTemp( 'busymark-workspace-missing-path-', diff --git a/test/src/writerside_project_test.dart b/test/src/writerside_project_test.dart index 6efcab71..99f2a6f4 100644 --- a/test/src/writerside_project_test.dart +++ b/test/src/writerside_project_test.dart @@ -142,6 +142,42 @@ void main() { expect(project.modules.single.config.moduleName, 'main'); }); + test( + 'topic ownership is project-wide and prefers the nested module', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-writerside-topic-owner-', + ); + addTearDown(() => root.delete(recursive: true)); + final parent = await _writeDiscoveryModule(root, 'docs', 'parent'); + final nested = await _writeDiscoveryModule( + root, + p.join('docs', 'topics', 'nested'), + 'nested', + ); + final project = await const WritersideProjectService().load(root.path); + final unparsedCandidate = p.join( + nested.path, + 'topics', + 'future.markdown', + ); + + expect( + project.topicOwnerForPath(unparsedCandidate)?.rootPath, + nested.path, + ); + expect(project.isTopicPath(unparsedCandidate), isTrue); + expect( + project.deletionTouchesTopics(nested.path, isDirectory: true), + isTrue, + ); + expect( + project.topicOwnerForPath(p.join(parent.path, 'README.md')), + isNull, + ); + }, + ); + test( 'workspace promotes a parent directory to a multi-module project', () async { diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index 80fe8af0..a3dbd42f 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -241,12 +241,15 @@ void main() { buttons: kSecondaryMouseButton, ); await tester.pumpAndSettle(); - await tester.runAsync( - () => tester.tap(find.text('Remove TOC Element...')), - ); + await tester.runAsync(() => tester.tap(find.text('Remove TOC Element'))); await _settle( tester, - until: () => find.text('Remove TOC Element').evaluate().isNotEmpty, + until: () => find + .text( + "Remove TOC element 'Other topic'? The source file associated with the TOC won't be deleted.", + ) + .evaluate() + .isNotEmpty, ); expect( find.text( diff --git a/test/src/writerside_topic_removal_service_test.dart b/test/src/writerside_topic_removal_service_test.dart index 278b8371..94b71742 100644 --- a/test/src/writerside_topic_removal_service_test.dart +++ b/test/src/writerside_topic_removal_service_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -317,6 +319,201 @@ Read [the old topic](doomed.md). expect(File(doomed.filePath).existsSync(), isTrue); }); + test('file-size-skipped topics make removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'reference.md': + '# Reference\n\n[Old](doomed.md)\n${List.filled(512, 'x').join()}', + }, + ); + final target = p.join(fixture.root.path, 'topics', 'doomed.md'); + final reference = p.join(fixture.root.path, 'topics', 'reference.md'); + final before = File(reference).readAsStringSync(); + final limited = WritersideTopicRemovalService( + moduleService: const WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedFileBytes: 256), + ), + ); + + await expectLater( + limited.analyze( + projectRoot: fixture.root.path, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA(isA()), + ); + expect(File(target).existsSync(), isTrue); + expect(File(reference).readAsStringSync(), before); + }); + + test('document-count-skipped topics make removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'a-doomed.md': '# Doomed\n', + 'z-reference.md': '# Reference\n\n[Old](a-doomed.md)\n', + }, + ); + final target = p.join(fixture.root.path, 'topics', 'a-doomed.md'); + final reference = p.join(fixture.root.path, 'topics', 'z-reference.md'); + final before = File(reference).readAsStringSync(); + final limited = WritersideTopicRemovalService( + moduleService: const WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedDocuments: 1), + ), + ); + + await expectLater( + limited.analyze( + projectRoot: fixture.root.path, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA(isA()), + ); + expect(File(target).existsSync(), isTrue); + expect(File(reference).readAsStringSync(), before); + }); + + test('unreadable topic contents make removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n', 'unreadable.md': '# Placeholder\n'}, + ); + final target = p.join(fixture.root.path, 'topics', 'doomed.md'); + final unreadable = File( + p.join(fixture.root.path, 'topics', 'unreadable.md'), + )..writeAsBytesSync([0xff, 0xfe, 0xfd]); + final before = unreadable.readAsBytesSync(); + + await expectLater( + service.analyze( + projectRoot: fixture.root.path, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA(isA()), + ); + expect(File(target).existsSync(), isTrue); + expect(unreadable.readAsBytesSync(), before); + }); + + test('topic-root traversal truncation makes removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'deep/reference.md': '# Reference\n\n[Old](../doomed.md)\n', + }, + ); + final target = p.join(fixture.root.path, 'topics', 'doomed.md'); + final limitedModuleService = const WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxTreeEntries: 2), + ); + final incompleteModule = await limitedModuleService.load(fixture.root.path); + final incompleteProject = WritersideProject( + rootPath: fixture.root.path, + modules: [incompleteModule], + activeModuleId: null, + activeInstanceId: null, + index: WritersideProjectIndex.build([incompleteModule]), + diagnostics: incompleteModule.diagnostics, + ); + + await expectLater( + service.analyze( + project: incompleteProject, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA(isA()), + ); + expect(File(target).existsSync(), isTrue); + }); + + test('incomplete project-module discovery makes removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n'}, + ); + final target = p.join(fixture.root.path, 'topics', 'doomed.md'); + final limited = WritersideTopicRemovalService( + projectService: const WritersideProjectService( + scanOptions: WorkspaceScanOptions(maxTreeEntries: 1), + ), + ); + + await expectLater( + limited.analyze( + projectRoot: fixture.root.path, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-file.project-discovery-incomplete', + ), + ), + ); + expect(File(target).existsSync(), isTrue); + }); + + test('tree directory enumeration failure makes removal fail closed', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': + '', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n'}, + ); + final target = p.join(fixture.root.path, 'topics', 'doomed.md'); + Stream failListing( + Directory directory, { + required bool followLinks, + }) async* { + throw FileSystemException('listing failed', directory.path); + } + + final failing = WritersideTopicRemovalService( + treeDirectoryLister: failListing, + ); + await expectLater( + failing.analyze( + projectRoot: fixture.root.path, + topicPath: target, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + throwsA(isA()), + ); + expect(File(target).existsSync(), isTrue); + }); + test( 'single-quoted includes without element-id are found and removed', () async { @@ -580,6 +777,177 @@ Read [the old topic](doomed.md). expect(File(doomed.filePath).existsSync(), isTrue); }); + test('redirect preserves direct and rule-based accepted aliases', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n', 'replacement.md': '# Replacement\n'}, + ); + File(p.join(fixture.root.path, 'redirection-rules.xml')).writeAsStringSync( + ''' + + very-old.html,oldest.html + +''', + ); + final analysis = await service.analyze( + module: fixture.module, + topicPath: _topic(fixture.module, 'doomed.md').filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'guide.tree'), + selectedNodePath: const [0], + ); + final replacement = analysis.redirectTargets.singleWhere( + (target) => target.topicFileName == 'replacement.md', + ); + + await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + redirectTarget: replacement, + ), + ); + final destination = _tree( + fixture.root, + 'guide.tree', + ).findAllElements('toc-element').single; + expect( + destination.getAttribute('accepts-web-file-names')!.split(',').toSet(), + containsAll({ + 'doomed.html', + 'older.html', + 'legacy.html', + 'very-old.html', + 'oldest.html', + }), + ); + }); + + test('a transferred redirect alias collision fails before writes', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + + +''', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'replacement.md': '# Replacement\n', + 'existing.md': '# Existing\n', + }, + ); + final treePath = p.join(fixture.root.path, 'guide.tree'); + final before = File(treePath).readAsStringSync(); + final target = _topic(fixture.module, 'doomed.md'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: treePath, + selectedNodePath: const [0], + ); + final replacement = analysis.redirectTargets.singleWhere( + (candidate) => candidate.topicFileName == 'replacement.md', + ); + + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + redirectTarget: replacement, + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic-removal.redirect-invalid', + ), + ), + ); + expect(File(treePath).readAsStringSync(), before); + expect(File(target.filePath).existsSync(), isTrue); + }); + + test('redirect sources use each host instance web filename', () async { + final fixture = await _fixture( + trees: { + 'user.tree': ''' + + + + +''', + 'admin.tree': ''' + + + + +''', + }, + configuredTrees: const ['user.tree', 'admin.tree'], + topics: { + 'doomed.topic': ''' + + old-user.html + old-admin.html + +''', + 'replacement.md': '# Replacement\n', + }, + ); + final target = _topic(fixture.module, 'doomed.topic'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final namesByTree = { + for (final source in analysis.redirectSources) + p.basename(source.treePath): source.acceptedWebFileNames.toSet(), + }; + expect(namesByTree['user.tree'], contains('old-user.html')); + expect(namesByTree['admin.tree'], contains('old-admin.html')); + final replacement = analysis.redirectTargets.singleWhere( + (candidate) => candidate.topicFileName == 'replacement.md', + ); + + await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + redirectTarget: replacement, + ), + ); + expect(File(target.filePath).existsSync(), isFalse); + for (final entry in { + 'user.tree': 'old-user.html', + 'admin.tree': 'old-admin.html', + }.entries) { + final destination = _tree( + fixture.root, + entry.key, + ).findAllElements('toc-element').single; + expect( + destination.getAttribute('accepts-web-file-names'), + contains(entry.value), + ); + } + }); + test( 'remaining start-page and cross-instance usages prevent orphan status', () async { @@ -617,6 +985,70 @@ Read [the old topic](doomed.md). }, ); + test('remove-from-instance relevance respects document conditions', () async { + final fixture = await _fixture( + trees: { + 'user.tree': ''' + + + + +''', + 'admin.tree': ''' + + + + +''', + }, + configuredTrees: const ['user.tree', 'admin.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'referrer.topic': ''' + + Admin link +
    + +
    +
    +''', + }, + ); + final target = _topic(fixture.module, 'doomed.md').filePath; + + final user = await service.analyze( + module: fixture.module, + topicPath: target, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'user.tree'), + selectedNodePath: const [1], + ); + final admin = await service.analyze( + module: fixture.module, + topicPath: target, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'admin.tree'), + selectedNodePath: const [1], + ); + final userLink = user.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ); + final adminLink = admin.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ); + final userInclude = user.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.include, + ); + final adminInclude = admin.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.include, + ); + + expect(userLink.relevant, isFalse); + expect(adminLink.relevant, isTrue); + expect(userInclude.relevant, isFalse); + expect(adminInclude.relevant, isTrue); + }); + test( 'start-page and stale analyses fail closed without deleting the topic', () async { @@ -721,6 +1153,354 @@ Read [the old topic](doomed.md). ); expect(Directory(p.join(fixture.root.path, 'topics')).existsSync(), isTrue); }); + + test( + 'project analysis finds origin links, includes, cards, and ref TOC nodes', + () async { + final fixture = await _projectFixture( + mainTree: ''' + + + +''', + sharedTree: ''' + + + + +''', + sharedTopic: ''' + + Guide + + Guide + +''', + ); + final project = await const WritersideProjectService().load( + fixture.root.path, + preferredModuleRoot: fixture.mainRoot, + ); + final target = project.modulesByOrigin['main']!.topics.single; + final analysis = await service.analyze( + project: project, + projectRoot: fixture.root.path, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + + expect( + analysis.usages.where( + (usage) => usage.kind == WritersideTopicUsageKind.tocElement, + ), + hasLength(2), + ); + expect( + analysis.usages.where( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ), + hasLength(1), + ); + expect( + analysis.usages.where( + (usage) => usage.kind == WritersideTopicUsageKind.include, + ), + hasLength(1), + ); + final card = analysis.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.otherTopicReference, + ); + expect(card.canUpdateAutomatically, isFalse); + expect(analysis.canUpdateUsagesAutomatically, isFalse); + }, + ); + + test('safe delete updates automatic cross-module origin usages', () async { + final fixture = await _projectFixture( + mainTree: ''' + + + +''', + sharedTree: ''' + + + + + +''', + sharedTopic: ''' + +

    Guide text

    + +
    +''', + ); + final project = await const WritersideProjectService().load( + fixture.root.path, + preferredModuleRoot: fixture.mainRoot, + ); + final target = project.modulesByOrigin['main']!.topics.single; + final analysis = await service.analyze( + project: project, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + expect(analysis.canUpdateUsagesAutomatically, isTrue); + + await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + expect(File(target.filePath).existsSync(), isFalse); + final sharedSource = File( + p.join(fixture.sharedRoot, 'topics', 'references.topic'), + ).readAsStringSync(); + expect(sharedSource, contains('Guide text')); + expect(sharedSource, isNot(contains('href="guide.md"'))); + expect(sharedSource, isNot(contains(' + + + +
    +''', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n', 'child.md': '# Child\n'}, + ); + final doomed = _topic(fixture.module, 'doomed.md'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: doomed.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'guide.tree'), + selectedNodePath: const [0], + ); + expect(analysis.relevantUsages.single.reference, 'doomed.md'); + + final result = await service.apply( + WritersideTopicRemovalRequest(analysis: analysis), + ); + expect(result.deletedFile, isFalse); + expect(result.promotedChildren, 1); + final tree = _tree(fixture.root, 'guide.tree'); + expect( + tree.findAllElements('toc-element').single.getAttribute('topic'), + 'child.md', + ); + }, + ); + + test( + 'semantic tree scan includes hidden/build trees and excludes VCS metadata', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + +''', + 'build/reusable.tree': ''' + +''', + '.internal/shared.tree': ''' + +''', + '.git/ignored.tree': ''' + +''', + '.hg/ignored.tree': ''' + +''', + '.svn/ignored.tree': ''' + +''', + }, + configuredTrees: const ['guide.tree'], + topics: {'doomed.md': '# Doomed\n'}, + ); + final analysis = await service.analyze( + module: fixture.module, + topicPath: _topic(fixture.module, 'doomed.md').filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final treePaths = analysis.usages + .where((usage) => usage.kind == WritersideTopicUsageKind.tocElement) + .map((usage) => p.relative(usage.filePath, from: fixture.root.path)) + .toSet(); + expect( + treePaths, + containsAll([ + 'guide.tree', + 'build/reusable.tree', + '.internal/shared.tree', + ]), + ); + expect(treePaths, isNot(contains('.git/ignored.tree'))); + expect(treePaths, isNot(contains('.hg/ignored.tree'))); + expect(treePaths, isNot(contains('.svn/ignored.tree'))); + }, + ); + + test('a new module after analysis makes the reviewed plan stale', () async { + final fixture = await _projectFixture( + mainTree: ''' + +''', + sharedTree: ''' + +''', + sharedTopic: '', + ); + final project = await const WritersideProjectService().load( + fixture.root.path, + preferredModuleRoot: fixture.mainRoot, + ); + final target = project.modulesByOrigin['main']!.topics.single; + final analysis = await service.analyze( + project: project, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final lateRoot = Directory(p.join(fixture.root.path, 'late'))..createSync(); + Directory(p.join(lateRoot.path, 'topics')).createSync(); + File(p.join(lateRoot.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(lateRoot.path, 'topics', 'late.topic')).writeAsStringSync( + 'Guide', + ); + + await expectLater( + service.apply(WritersideTopicRemovalRequest(analysis: analysis)), + throwsA(isA()), + ); + expect(File(target.filePath).existsSync(), isTrue); + }); + + test( + 'cross-module failure rolls back BusyMark edits without overwriting a concurrent edit', + () async { + final fixture = await _projectFixture( + mainTree: ''' + +''', + sharedTree: ''' + + + + +''', + sharedTopic: ''' + + Guide + +''', + ); + final project = await const WritersideProjectService().load( + fixture.root.path, + preferredModuleRoot: fixture.mainRoot, + ); + final target = project.modulesByOrigin['main']!.topics.single; + final analysis = await service.analyze( + project: project, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final mutablePaths = [ + p.join(fixture.mainRoot, 'guide.tree'), + p.join(fixture.sharedRoot, 'shared.tree'), + p.join(fixture.sharedRoot, 'topics', 'references.topic'), + ]; + final originals = { + for (final path in mutablePaths) path: File(path).readAsStringSync(), + }; + String? concurrentlyEdited; + + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + validateBeforeCommit: (_) { + if (concurrentlyEdited != null) return; + for (final entry in originals.entries) { + if (File(entry.key).readAsStringSync() == entry.value) continue; + concurrentlyEdited = entry.key; + File(entry.key).writeAsStringSync('\n'); + throw const BusyMarkException('test.concurrent-edit'); + } + }, + ), + throwsA(isA()), + ); + + expect(concurrentlyEdited, isNotNull); + expect( + File(concurrentlyEdited!).readAsStringSync(), + '\n', + ); + for (final entry in originals.entries) { + if (entry.key == concurrentlyEdited) continue; + expect(File(entry.key).readAsStringSync(), entry.value); + } + expect(File(target.filePath).existsSync(), isTrue); + }, + ); +} + +Future<({Directory root, String mainRoot, String sharedRoot})> _projectFixture({ + required String mainTree, + required String sharedTree, + required String sharedTopic, +}) async { + final root = await Directory.systemTemp.createTemp( + 'busymark-removal-project-', + ); + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final mainRoot = p.join(root.path, 'main'); + final sharedRoot = p.join(root.path, 'shared'); + Directory(p.join(mainRoot, 'topics')).createSync(recursive: true); + Directory(p.join(sharedRoot, 'topics')).createSync(recursive: true); + File(p.join(mainRoot, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(sharedRoot, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(mainRoot, 'guide.tree')).writeAsStringSync(mainTree.trimLeft()); + File( + p.join(sharedRoot, 'shared.tree'), + ).writeAsStringSync(sharedTree.trimLeft()); + File( + p.join(mainRoot, 'topics', 'guide.md'), + ).writeAsStringSync('# Guide\n\nReusable.\n'); + File( + p.join(sharedRoot, 'topics', 'references.topic'), + ).writeAsStringSync(sharedTopic.trimLeft()); + final canonicalRoot = Directory(await root.resolveSymbolicLinks()); + return ( + root: canonicalRoot, + mainRoot: p.join(canonicalRoot.path, 'main'), + sharedRoot: p.join(canonicalRoot.path, 'shared'), + ); } Future<({Directory root, WritersideModule module})> _fixture({ @@ -750,9 +1530,9 @@ Future<({Directory root, WritersideModule module})> _fixture({ File(p.join(root.path, 'v.list')).writeAsStringSync(variables); } for (final entry in trees.entries) { - File( - p.join(root.path, entry.key), - ).writeAsStringSync(entry.value.trimLeft()); + final file = File(p.join(root.path, entry.key)); + file.parent.createSync(recursive: true); + file.writeAsStringSync(entry.value.trimLeft()); } for (final entry in topics.entries) { final file = File(p.join(topicsDirectory.path, entry.key)); diff --git a/tools/writerside_toc_visual_smoke.dart b/tools/writerside_toc_visual_smoke.dart index ac8a5826..f4fd45b9 100644 --- a/tools/writerside_toc_visual_smoke.dart +++ b/tools/writerside_toc_visual_smoke.dart @@ -540,17 +540,31 @@ class _HarnessState extends ConsumerState<_Harness> { ); await _pause(); await _menu('Usage review'); - await _tapLabel('Remove TOC Element...'); + await _tapLabel('Remove TOC Element'); await _capture('06-removal'); if (_elements( (widget) => widget is Text && widget.data == 'Review Usages', ).isNotEmpty) { await _tapLabel('Review Usages'); await _capture('07-find-review'); - await _tap( + await _tapLabel('Do Refactor'); + await _until( + () => _elements( + (widget) => + widget is Text && widget.data == 'Topic file is no longer used', + ).isNotEmpty, + 'Do Refactor completes without reopening the removal dialog', + ); + _check( _elements( - (widget) => widget is Tooltip && widget.message == 'Back', - ).last, + (widget) => widget is Text && widget.data == 'Remove TOC Element', + ).isEmpty, + 'Do Refactor does not reopen Remove TOC Element', + ); + await _tapLabel('Keep Topic File'); + _check( + await File(p.join(widget.root.path, 'topics/review.md')).exists(), + 'Keeping an orphan retains its topic source', ); } else { await _tapLabel('Cancel'); @@ -1041,6 +1055,15 @@ class _HarnessState extends ConsumerState<_Harness> { await _tapLabel('Refactor'); await _tapLabel('Safe Delete'); await _capture('12-safe-delete'); + _check( + _elements( + (widget) => + widget is Checkbox && + widget.value == true && + widget.onChanged == null, + ).isNotEmpty, + 'Safe Delete remains selected and mandatory', + ); await _tapLabel('OK'); await _until(() => !duplicate.existsSync(), 'Safe Delete completes'); _check( From 35630ad754d0b371e5b9d83f81142531c2238410 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Wed, 16 Sep 2026 08:51:45 -0700 Subject: [PATCH 23/35] Detect authored HTML links during topic removal --- .../writerside_html_reference_scanner.dart | 356 +++++++++++++ .../writerside_topic_file_editor.dart | 150 +----- .../writerside_topic_removal_service.dart | 241 ++++++++- ...riterside_html_reference_scanner_test.dart | 115 ++++ ...writerside_topic_removal_service_test.dart | 489 +++++++++++++++++- 5 files changed, 1204 insertions(+), 147 deletions(-) create mode 100644 lib/src/writerside/writerside_html_reference_scanner.dart create mode 100644 test/src/writerside_html_reference_scanner_test.dart diff --git a/lib/src/writerside/writerside_html_reference_scanner.dart b/lib/src/writerside/writerside_html_reference_scanner.dart new file mode 100644 index 00000000..539d9260 --- /dev/null +++ b/lib/src/writerside/writerside_html_reference_scanner.dart @@ -0,0 +1,356 @@ +// HtmlParser exposes its tokenizer but does not expose per-attribute source +// spans through its public DOM API. Keep authored-reference discovery on the +// same tokenizer used by Rename Topic File. +// ignore: implementation_imports +import 'package:html/src/token.dart' show EndTagToken, StartTagToken; +// ignore: implementation_imports +import 'package:html/src/tokenizer.dart' show HtmlTokenizer; + +import '../core/source_span.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/markdown_ast_adapter.dart'; +import '../markdown/markdown_model.dart'; + +enum WritersideHtmlAttributeQuote { doubleQuoted, singleQuoted, unquoted } + +class WritersideAuthoredHtmlAnchorReference { + const WritersideAuthoredHtmlAnchorReference({ + required this.href, + required this.authoredHref, + required this.hrefSpan, + required this.startTagSpan, + required this.quote, + this.origin, + this.anchorSpan, + this.innerContentSpan, + this.structurallySafe = false, + }); + + /// The tokenizer-decoded destination used for semantic resolution. + final String href; + + /// The exact source spelling of the href value, excluding its quotes. + final String authoredHref; + final String? origin; + final SourceSpan hrefSpan; + final SourceSpan startTagSpan; + final SourceSpan? anchorSpan; + final SourceSpan? innerContentSpan; + final WritersideHtmlAttributeQuote quote; + + /// Whether the scanner proved that one complete authored anchor can be + /// replaced by its exact inner source without losing surrounding markup. + final bool structurallySafe; +} + +class WritersideAuthoredHtmlReferenceScanner { + const WritersideAuthoredHtmlReferenceScanner(); + + List scanMarkdownAnchors({ + required String filePath, + required String source, + required List protectedSource, + }) { + if (protectedSource.length != source.length) { + throw ArgumentError.value( + protectedSource.length, + 'protectedSource', + 'The Markdown literal mask must match the source length.', + ); + } + final completed = []; + final open = <_OpenHtmlAnchor>[]; + final tokenizer = HtmlTokenizer( + source, + generateSpans: true, + attributeSpans: true, + ); + while (tokenizer.moveNext()) { + final token = tokenizer.current; + if (token is StartTagToken && token.name == 'a') { + final tagSpan = token.span; + if (tagSpan == null || + _rangeIsProtected( + protectedSource, + tagSpan.start.offset, + tagSpan.end.offset, + )) { + continue; + } + if (open.isNotEmpty) { + for (final pending in open) { + pending.structurallySafe = false; + } + } + final pending = _openAnchor( + filePath: filePath, + source: source, + token: token, + ); + if (token.selfClosing) { + if (pending.reference != null) completed.add(pending.reference!); + continue; + } + open.add(pending); + continue; + } + if (token is! EndTagToken || token.name != 'a') continue; + final endSpan = token.span; + if (endSpan == null || + _rangeIsProtected( + protectedSource, + endSpan.start.offset, + endSpan.end.offset, + ) || + open.isEmpty) { + continue; + } + final pending = open.removeLast(); + if (open.isNotEmpty) { + pending.structurallySafe = false; + for (final ancestor in open) { + ancestor.structurallySafe = false; + } + } + final reference = pending.reference; + if (reference == null) continue; + final bodyStart = reference.startTagSpan.endOffset; + final bodyEnd = endSpan.start.offset; + final anchorEnd = endSpan.end.offset; + final safe = + pending.structurallySafe && + bodyStart <= bodyEnd && + anchorEnd <= source.length && + !_rangeIsProtected(protectedSource, bodyStart, anchorEnd); + completed.add( + WritersideAuthoredHtmlAnchorReference( + href: reference.href, + authoredHref: reference.authoredHref, + origin: reference.origin, + hrefSpan: reference.hrefSpan, + startTagSpan: reference.startTagSpan, + quote: reference.quote, + anchorSpan: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: reference.startTagSpan.startOffset, + endOffset: anchorEnd, + ), + innerContentSpan: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: bodyStart, + endOffset: bodyEnd, + ), + structurallySafe: safe, + ), + ); + } + for (final pending in open) { + if (pending.reference case final reference?) completed.add(reference); + } + final rendered = _renderedAnchorIndexes( + filePath: filePath, + source: source, + references: completed, + ); + for (var index = completed.length - 1; index >= 0; index--) { + if (!rendered.contains(index)) completed.removeAt(index); + } + completed.sort( + (left, right) => + left.hrefSpan.startOffset.compareTo(right.hrefSpan.startOffset), + ); + return List.unmodifiable(completed); + } + + Set _renderedAnchorIndexes({ + required String filePath, + required String source, + required List references, + }) { + if (references.isEmpty) return const {}; + final probes = {}; + var probed = source; + for (var index = references.length - 1; index >= 0; index--) { + var probe = 'busymark-html-reference-$index.invalid'; + while (source.contains(probe)) { + probe = 'x$probe'; + } + probes[index] = probe; + final span = references[index].hrefSpan; + probed = probed.replaceRange(span.startOffset, span.endOffset, probe); + } + final document = const MarkdownAstAdapter().parse( + filePath: filePath, + source: probed, + mode: MarkdownMode.writersideMarkdown, + ); + final renderedDestinations = {}; + void visitInline(BusyInline inline) { + if (inline.kind == BusyInlineKind.link && inline.destination != null) { + renderedDestinations.add(inline.destination!); + } + for (final child in inline.children) { + visitInline(child); + } + } + + void visitBlock(BusyBlock block) { + for (final inline in block.inlines) { + visitInline(inline); + } + for (final child in block.children) { + visitBlock(child); + } + } + + for (final block in document.blocks) { + visitBlock(block); + } + return { + for (final entry in probes.entries) + if (renderedDestinations.contains(entry.value)) entry.key, + }; + } + + _OpenHtmlAnchor _openAnchor({ + required String filePath, + required String source, + required StartTagToken token, + }) { + final tagSpan = token.span!; + final attributes = token.attributeSpans; + final href = attributes + ?.where((attribute) => attribute.name == 'href') + .firstOrNull; + final hrefStart = href?.startValue; + final hrefEnd = href?.endValue; + final destination = href?.value; + if (href == null || + hrefStart == null || + hrefEnd == null || + hrefStart < tagSpan.start.offset || + hrefEnd > tagSpan.end.offset || + hrefStart >= hrefEnd || + destination == null || + destination.isEmpty) { + return _OpenHtmlAnchor(reference: null); + } + final quote = switch (hrefStart > tagSpan.start.offset + ? source[hrefStart - 1] + : null) { + '"' when hrefEnd < tagSpan.end.offset && source[hrefEnd] == '"' => + WritersideHtmlAttributeQuote.doubleQuoted, + "'" when hrefEnd < tagSpan.end.offset && source[hrefEnd] == "'" => + WritersideHtmlAttributeQuote.singleQuoted, + _ => WritersideHtmlAttributeQuote.unquoted, + }; + final origin = token.data['origin']?.trim(); + return _OpenHtmlAnchor( + reference: WritersideAuthoredHtmlAnchorReference( + href: destination, + authoredHref: source.substring(hrefStart, hrefEnd), + origin: origin?.isEmpty == true ? null : origin, + hrefSpan: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: hrefStart, + endOffset: hrefEnd, + ), + startTagSpan: SourceSpan.fromOffsets( + filePath: filePath, + source: source, + startOffset: tagSpan.start.offset, + endOffset: tagSpan.end.offset, + ), + quote: quote, + ), + ); + } +} + +/// Builds the literal mask shared by topic rename and topic removal. +/// +/// Fenced code ranges come from BusyMark's Markdown parser. Inline code and +/// comments are protected here so HTML tokenization cannot promote examples +/// into authored topic references. +List writersideMarkdownLiteralMask({ + required String source, + Iterable protectedRanges = const [], +}) { + final protected = List.filled(source.length, false); + void protect(int start, int end) { + final safeStart = start.clamp(0, source.length); + final safeEnd = end.clamp(safeStart, source.length); + for (var index = safeStart; index < safeEnd; index++) { + protected[index] = true; + } + } + + for (final range in protectedRanges) { + protect(range.startOffset, range.endOffset); + } + for (final comment in RegExp(r'|$)').allMatches(source)) { + protect(comment.start, comment.end); + } + for (var cursor = 0; cursor < source.length; cursor++) { + if (protected[cursor] || + source[cursor] != '`' || + _isEscapedMarkdownCharacter(source, cursor)) { + continue; + } + var delimiterLength = 1; + while (cursor + delimiterLength < source.length && + source[cursor + delimiterLength] == '`') { + delimiterLength++; + } + var closing = cursor + delimiterLength; + while (closing < source.length) { + if (!protected[closing] && source[closing] == '`') { + var runLength = 1; + while (closing + runLength < source.length && + source[closing + runLength] == '`') { + runLength++; + } + if (runLength == delimiterLength) break; + closing += runLength; + } else { + closing++; + } + } + if (closing < source.length) { + protect(cursor, closing + delimiterLength); + cursor = closing + delimiterLength - 1; + } + } + return protected; +} + +class _OpenHtmlAnchor { + _OpenHtmlAnchor({required this.reference}); + + final WritersideAuthoredHtmlAnchorReference? reference; + bool structurallySafe = true; +} + +bool _rangeIsProtected(List protected, int start, int end) { + final safeStart = start.clamp(0, protected.length); + final safeEnd = end.clamp(safeStart, protected.length); + for (var index = safeStart; index < safeEnd; index++) { + if (protected[index]) return true; + } + return false; +} + +bool _isEscapedMarkdownCharacter(String source, int offset) { + var slashCount = 0; + for (var index = offset - 1; index >= 0 && source[index] == '\\'; index--) { + slashCount++; + } + return slashCount.isOdd; +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/writerside/writerside_topic_file_editor.dart b/lib/src/writerside/writerside_topic_file_editor.dart index 996155e8..7a7e56b0 100644 --- a/lib/src/writerside/writerside_topic_file_editor.dart +++ b/lib/src/writerside/writerside_topic_file_editor.dart @@ -1,12 +1,5 @@ import 'dart:io'; -// HtmlParser exposes its tokenizer but does not expose per-attribute source -// spans. Use the package tokenizer so raw HTML references are interpreted with -// the same HTML rules as BusyMark's Markdown parser. -// ignore: implementation_imports -import 'package:html/src/token.dart' show StartTagToken; -// ignore: implementation_imports -import 'package:html/src/tokenizer.dart' show HtmlTokenizer; import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; @@ -18,6 +11,7 @@ import '../markdown/busymark_document.dart'; import '../markdown/markdown_ast_adapter.dart'; import '../markdown/markdown_model.dart'; import '../markdown/markdown_parser.dart'; +import 'writerside_html_reference_scanner.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_project.dart'; @@ -889,7 +883,7 @@ class WritersideTopicFileEditor { }; final usages = {}; final xmlAttributeSpans = {}; - final htmlAttributeQuotes = {}; + final htmlAttributeQuotes = {}; final markdownDestinationSpans = {}; final markdownAngleDestinationSpans = {}; for (final usage in index.references) { @@ -1205,7 +1199,11 @@ class WritersideTopicFileEditor { _AuthoredMarkdownProjection _authoredMarkdownTopicReferences( WritersideTopic topic, ) { - final protected = _markdownLiteralMask(topic); + final protected = writersideMarkdownLiteralMask( + source: topic.document.source, + protectedRanges: + topic.markdown?.codeBlocks.map((block) => block.span) ?? const [], + ); final definitions = _markdownReferenceDefinitions(topic, protected); final authored = <_AuthoredMarkdownTopicReference>[ @@ -1409,56 +1407,6 @@ class WritersideTopicFileEditor { 1; } - List _markdownLiteralMask(WritersideTopic topic) { - final source = topic.document.source; - final protected = List.filled(source.length, false); - void protect(int start, int end) { - final safeStart = start.clamp(0, source.length); - final safeEnd = end.clamp(safeStart, source.length); - for (var index = safeStart; index < safeEnd; index++) { - protected[index] = true; - } - } - - for (final codeBlock in topic.markdown?.codeBlocks ?? const []) { - protect(codeBlock.span.startOffset, codeBlock.span.endOffset); - } - for (final comment in RegExp(r'|$)').allMatches(source)) { - protect(comment.start, comment.end); - } - for (var cursor = 0; cursor < source.length; cursor++) { - if (protected[cursor] || - source[cursor] != '`' || - _isEscapedMarkdownCharacter(source, cursor)) { - continue; - } - var delimiterLength = 1; - while (cursor + delimiterLength < source.length && - source[cursor + delimiterLength] == '`') { - delimiterLength++; - } - var closing = cursor + delimiterLength; - while (closing < source.length) { - if (!protected[closing] && source[closing] == '`') { - var runLength = 1; - while (closing + runLength < source.length && - source[closing + runLength] == '`') { - runLength++; - } - if (runLength == delimiterLength) break; - closing += runLength; - } else { - closing++; - } - } - if (closing < source.length) { - protect(cursor, closing + delimiterLength); - cursor = closing + delimiterLength - 1; - } - } - return protected; - } - Map _markdownReferenceDefinitions( WritersideTopic topic, List protected, @@ -1603,70 +1551,23 @@ class WritersideTopicFileEditor { List protected, ) { final source = topic.document.source; - final occurrences = <_AuthoredMarkdownTopicReference>[]; - final tokenizer = HtmlTokenizer( - source, - generateSpans: true, - attributeSpans: true, - ); - while (tokenizer.moveNext()) { - final token = tokenizer.current; - if (token is! StartTagToken || token.name != 'a') continue; - final tagSpan = token.span; - if (tagSpan == null || - _rangeIsProtected( - protected, - tagSpan.start.offset, - tagSpan.end.offset, - )) { - continue; - } - final attributes = token.attributeSpans; - if (attributes == null) continue; - final href = attributes - .where((attribute) => attribute.name == 'href') - .firstOrNull; - final hrefStart = href?.startValue; - final hrefEnd = href?.endValue; - final destination = href?.value; - if (href == null || - hrefStart == null || - hrefEnd == null || - hrefStart < 0 || - hrefEnd > source.length || - hrefStart >= hrefEnd || - destination == null || - destination.isEmpty) { - continue; - } - final origin = token.data['origin']?.trim(); - final htmlAttributeQuote = switch (hrefStart > tagSpan.start.offset - ? source[hrefStart - 1] - : null) { - '"' when hrefEnd < tagSpan.end.offset && source[hrefEnd] == '"' => - _HtmlAttributeQuote.doubleQuoted, - "'" when hrefEnd < tagSpan.end.offset && source[hrefEnd] == "'" => - _HtmlAttributeQuote.singleQuoted, - _ => _HtmlAttributeQuote.unquoted, - }; - occurrences.add( - _AuthoredMarkdownTopicReference( - occurrenceOffset: tagSpan.start.offset, - destination: destination, - rawDestination: source.substring(hrefStart, hrefEnd), - destinationSpan: SourceSpan.fromOffsets( + return [ + for (final reference + in const WritersideAuthoredHtmlReferenceScanner().scanMarkdownAnchors( filePath: topic.filePath, source: source, - startOffset: hrefStart, - endOffset: hrefEnd, - ), - origin: origin?.isEmpty == true ? null : origin, + protectedSource: protected, + )) + _AuthoredMarkdownTopicReference( + occurrenceOffset: reference.startTagSpan.startOffset, + destination: reference.href, + rawDestination: reference.authoredHref, + destinationSpan: reference.hrefSpan, + origin: reference.origin, xmlAttribute: true, - htmlAttributeQuote: htmlAttributeQuote, + htmlAttributeQuote: reference.quote, ), - ); - } - return occurrences; + ]; } _InlineMarkdownDestination? _inlineMarkdownDestination( @@ -1828,8 +1729,11 @@ class WritersideTopicFileEditor { .replaceAll('"', '"') .replaceAll("'", '''); - String _escapeHtmlAttributeValue(String value, _HtmlAttributeQuote quote) { - if (quote != _HtmlAttributeQuote.unquoted) { + String _escapeHtmlAttributeValue( + String value, + WritersideHtmlAttributeQuote quote, + ) { + if (quote != WritersideHtmlAttributeQuote.unquoted) { return _escapeXmlAttributeValue(value); } final escaped = StringBuffer(); @@ -2654,15 +2558,13 @@ class _AuthoredMarkdownTopicReference { final SourceSpan destinationSpan; final String? origin; final bool xmlAttribute; - final _HtmlAttributeQuote? htmlAttributeQuote; + final WritersideHtmlAttributeQuote? htmlAttributeQuote; final bool angleDestination; final bool inlineMarkdownDestination; final SourceSpan? referenceLabelSpan; final SourceSpan? definitionLabelSpan; } -enum _HtmlAttributeQuote { doubleQuoted, singleQuoted, unquoted } - class _AuthoredMarkdownProjection { const _AuthoredMarkdownProjection({ required this.references, diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index da9494ad..688c883f 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -17,6 +17,7 @@ import 'writerside_project.dart'; import 'writerside_document.dart'; import 'writerside_document_resolver.dart'; import 'writerside_document_parser.dart'; +import 'writerside_html_reference_scanner.dart'; import 'writerside_schema.dart'; import 'writerside_toc_presentation.dart'; import 'writerside_web_file_name.dart'; @@ -42,6 +43,8 @@ class WritersideTopicUsage { required this.canUpdateAutomatically, this.span, this.moduleRoot, + this.replacementSpan, + this.replacementText, List? nodePath, }) : nodePath = nodePath == null ? null : List.unmodifiable(nodePath); @@ -54,6 +57,8 @@ class WritersideTopicUsage { final bool canUpdateAutomatically; final SourceSpan? span; final String? moduleRoot; + final SourceSpan? replacementSpan; + final String? replacementText; final List? nodePath; } @@ -1443,6 +1448,8 @@ class WritersideTopicRemovalService { : snapshot.project.index.findUsages(targetSymbol).toSet(); final result = []; final seen = {}; + final indexedUsages = + <({WritersideReference reference, int resultIndex})>[]; for (final reference in snapshot.project.index.references.where( (reference) => reference.kind == WritersideSymbolKind.topic, )) { @@ -1521,26 +1528,16 @@ class WritersideTopicRemovalService { reference.span, ) != null; - final relevant = - mode == WritersideTopicRemovalMode.safeDeleteFile || - (selectedInstance != null && - ((participatingTopics.contains( - normalizePath(sourceTopic.filePath), - ) && - _referenceActiveInInstance( - snapshot, - sourceModule, - sourceTopic, - selectedInstance, - reference.span, - )) || - (activeDocumentRanges[normalizePath(sourceTopic.filePath)] ?? - const []) - .any( - (span) => - span.startOffset <= reference.span.startOffset && - span.endOffset >= reference.span.endOffset, - ))); + final relevant = _contentUsageRelevant( + snapshot, + mode: mode, + sourceModule: sourceModule, + sourceTopic: sourceTopic, + selectedInstance: selectedInstance, + participatingTopics: participatingTopics, + activeDocumentRanges: activeDocumentRanges, + span: reference.span, + ); result.add( WritersideTopicUsage( kind: kind, @@ -1554,10 +1551,188 @@ class WritersideTopicRemovalService { moduleRoot: sourceModule.rootPath, ), ); + indexedUsages.add((reference: reference, resultIndex: result.length - 1)); + } + // The project index remains authoritative. Its Markdown projection can, + // however, omit an inline authored HTML anchor embedded in an otherwise + // ordinary Markdown block. Bind each authored anchor to at most one + // covering indexed usage; only missing occurrences are supplemented. The + // same tokenizer-backed scanner is shared with Rename Topic File. + final consumedIndexedUsages = {}; + for (final sourceModule in snapshot.project.modules) { + for (final sourceTopic in sourceModule.topics) { + if (sourceTopic.format != WritersideTopicFormat.markdown || + p.equals(sourceTopic.filePath, snapshot.topic.filePath)) { + continue; + } + final source = snapshot.sources[normalizePath(sourceTopic.filePath)]!; + final protected = writersideMarkdownLiteralMask( + source: source, + protectedRanges: + sourceTopic.markdown?.codeBlocks.map((block) => block.span) ?? + const [], + ); + final anchors = const WritersideAuthoredHtmlReferenceScanner() + .scanMarkdownAnchors( + filePath: sourceTopic.filePath, + source: source, + protectedSource: protected, + ); + for (final anchor in anchors) { + if (hasUriScheme(anchor.href)) continue; + final topicReference = _referenceWithoutAnchor(anchor.href); + if (topicReference.isEmpty || + !_couldTarget( + snapshot, + topicReference, + fromTopic: sourceTopic, + sourceModule: sourceModule, + origin: anchor.origin, + )) { + continue; + } + final key = + '${sourceTopic.filePath}:${anchor.hrefSpan.startOffset}:' + '${anchor.hrefSpan.endOffset}'; + final exact = _targets( + snapshot, + topicReference, + fromTopic: sourceTopic, + sourceModule: sourceModule, + origin: anchor.origin, + ); + final anchorSpan = anchor.anchorSpan; + final innerSpan = anchor.innerContentSpan; + final automatic = + exact && + anchor.structurallySafe && + anchorSpan != null && + innerSpan != null; + final matchingIndexed = + indexedUsages + .where( + (indexed) => + !consumedIndexedUsages.contains(indexed.resultIndex) && + p.equals( + indexed.reference.filePath, + sourceTopic.filePath, + ) && + indexed.reference.span.startOffset <= + anchor.hrefSpan.startOffset && + indexed.reference.span.endOffset >= + anchor.hrefSpan.endOffset && + _referenceWithoutAnchor(indexed.reference.value) == + topicReference && + _normalizedOptionalValue(indexed.reference.origin) == + _normalizedOptionalValue(anchor.origin), + ) + .toList() + ..sort( + (left, right) => + (left.reference.span.endOffset - + left.reference.span.startOffset) + .compareTo( + right.reference.span.endOffset - + right.reference.span.startOffset, + ), + ); + final indexedUsage = matchingIndexed.firstOrNull?.resultIndex; + if (indexedUsage != null) { + consumedIndexedUsages.add(indexedUsage); + final usage = result[indexedUsage]; + if (usage.kind == WritersideTopicUsageKind.topicLink) { + result[indexedUsage] = WritersideTopicUsage( + kind: usage.kind, + filePath: usage.filePath, + line: anchor.hrefSpan.startLine, + column: anchor.hrefSpan.startColumn, + reference: anchor.authoredHref, + relevant: _contentUsageRelevant( + snapshot, + mode: mode, + sourceModule: sourceModule, + sourceTopic: sourceTopic, + selectedInstance: selectedInstance, + participatingTopics: participatingTopics, + activeDocumentRanges: activeDocumentRanges, + span: anchor.hrefSpan, + ), + canUpdateAutomatically: automatic, + span: anchor.hrefSpan, + moduleRoot: usage.moduleRoot, + nodePath: usage.nodePath, + replacementSpan: automatic ? anchorSpan : null, + replacementText: automatic + ? source.substring( + innerSpan.startOffset, + innerSpan.endOffset, + ) + : null, + ); + } + continue; + } + if (!seen.add(key)) continue; + result.add( + WritersideTopicUsage( + kind: WritersideTopicUsageKind.topicLink, + filePath: sourceTopic.filePath, + line: anchor.hrefSpan.startLine, + column: anchor.hrefSpan.startColumn, + reference: anchor.authoredHref, + relevant: _contentUsageRelevant( + snapshot, + mode: mode, + sourceModule: sourceModule, + sourceTopic: sourceTopic, + selectedInstance: selectedInstance, + participatingTopics: participatingTopics, + activeDocumentRanges: activeDocumentRanges, + span: anchor.hrefSpan, + ), + canUpdateAutomatically: automatic, + span: anchor.hrefSpan, + moduleRoot: sourceModule.rootPath, + replacementSpan: automatic ? anchorSpan : null, + replacementText: automatic + ? source.substring(innerSpan.startOffset, innerSpan.endOffset) + : null, + ), + ); + } + } } return result; } + bool _contentUsageRelevant( + _RemovalSnapshot snapshot, { + required WritersideTopicRemovalMode mode, + required WritersideModule sourceModule, + required WritersideTopic sourceTopic, + required WritersideInstance? selectedInstance, + required Set participatingTopics, + required Map> activeDocumentRanges, + required SourceSpan span, + }) => + mode == WritersideTopicRemovalMode.safeDeleteFile || + (selectedInstance != null && + ((participatingTopics.contains(normalizePath(sourceTopic.filePath)) && + _referenceActiveInInstance( + snapshot, + sourceModule, + sourceTopic, + selectedInstance, + span, + )) || + (activeDocumentRanges[normalizePath(sourceTopic.filePath)] ?? + const []) + .any( + (active) => + active.startOffset <= span.startOffset && + active.endOffset >= span.endOffset, + ))); + WritersideElementNode? _elementContainingReference( WritersideTopic topic, SourceSpan span, @@ -1664,8 +1839,23 @@ class WritersideTopicRemovalService { WritersideTopic topic, String source, WritersideTopicUsageKind kind, - SourceSpan span, - ) { + SourceSpan span, { + SourceSpan? replacementSpan, + String? replacementText, + }) { + if (replacementSpan != null && replacementText != null) { + if (replacementSpan.startOffset < 0 || + replacementSpan.endOffset > source.length || + replacementSpan.startOffset > span.startOffset || + replacementSpan.endOffset < span.endOffset) { + return null; + } + return _TextReplacement( + replacementSpan.startOffset, + replacementSpan.endOffset, + replacementText, + ); + } if (kind == WritersideTopicUsageKind.include) { final element = _elementContainingReference(topic, span); if (element?.semanticKind != WritersideSemanticKind.include) return null; @@ -1704,6 +1894,8 @@ class WritersideTopicRemovalService { source, usage.kind, span, + replacementSpan: usage.replacementSpan, + replacementText: usage.replacementText, ); if (replacement != null) replacements.add(replacement); } @@ -2494,6 +2686,11 @@ bool _sameStringList(List first, List second) { String _referenceWithoutAnchor(String destination) => destination.split('#').first.split('?').first; +String? _normalizedOptionalValue(String? value) { + final normalized = value?.trim(); + return normalized == null || normalized.isEmpty ? null : normalized; +} + String _xmlSource(XmlDocument document) => '${document.toXmlString(pretty: true, indent: ' ')}\n'; diff --git a/test/src/writerside_html_reference_scanner_test.dart b/test/src/writerside_html_reference_scanner_test.dart new file mode 100644 index 00000000..d61eba18 --- /dev/null +++ b/test/src/writerside_html_reference_scanner_test.dart @@ -0,0 +1,115 @@ +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_html_reference_scanner.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const scanner = WritersideAuthoredHtmlReferenceScanner(); + + List scan(String source) { + final markdown = const MarkdownParser().parse( + filePath: '/project/topics/source.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ); + return scanner.scanMarkdownAnchors( + filePath: markdown.filePath, + source: source, + protectedSource: writersideMarkdownLiteralMask( + source: source, + protectedRanges: markdown.codeBlocks.map((block) => block.span), + ), + ); + } + + test('finds exact href and body spans for every supported quote form', () { + const source = ''' +One +Two +Three +Ignored +'''; + + final references = scan(source); + + expect(references.map((reference) => reference.href), [ + 'one.md', + 'two.md', + 'three.md', + ]); + expect(references.map((reference) => reference.quote), [ + WritersideHtmlAttributeQuote.doubleQuoted, + WritersideHtmlAttributeQuote.singleQuoted, + WritersideHtmlAttributeQuote.unquoted, + ]); + expect( + references.map( + (reference) => source.substring( + reference.hrefSpan.startOffset, + reference.hrefSpan.endOffset, + ), + ), + ['one.md', 'two.md', 'three.md'], + ); + expect( + references.map( + (reference) => source.substring( + reference.innerContentSpan!.startOffset, + reference.innerContentSpan!.endOffset, + ), + ), + ['One', 'Two', 'Three'], + ); + expect(references.every((reference) => reference.structurallySafe), isTrue); + }); + + test( + 'ignores comments, fenced code, and inline code but keeps containers', + () { + const source = ''' + + +```html +Fenced +``` + +`Inline` + +![Image](image.png "Image title") + +[Other](other.md "Link title") + +- Read List. + +> Read Quote. +'''; + + expect(scan(source).map((reference) => reference.href), [ + 'list.md', + 'quote.md', + ]); + }, + ); + + test('multiline anchors are exact and malformed anchors remain manual', () { + const source = ''' +Read Guide. + +Read Broken. +'''; + + final references = scan(source); + + expect(references, hasLength(2)); + expect(references.first.origin, 'main'); + expect(references.first.href, 'guide.md#part'); + expect(references.first.structurallySafe, isTrue); + expect(references.last.href, 'broken.md'); + expect(references.last.structurallySafe, isFalse); + expect(references.last.anchorSpan, isNull); + }); +} diff --git a/test/src/writerside_topic_removal_service_test.dart b/test/src/writerside_topic_removal_service_test.dart index 94b71742..60b45462 100644 --- a/test/src/writerside_topic_removal_service_test.dart +++ b/test/src/writerside_topic_removal_service_test.dart @@ -618,6 +618,426 @@ Read [the old topic](doomed.md). }, ); + test( + 'Safe Delete finds and unlinks authored HTML anchors without literal false positives', + () async { + const referrerSource = ''' +# Referrer + +Read the old topic. + +Read the single-quoted topic. + +Read the unquoted topic. + +Read the old section. + +Read the queried section. + +One + +Two + +- Read the list topic. + +> Read the quoted topic. + +Not a topic link + +External + +![Image](image.png "Image title") + +[Other](other.md "Link title") + +`Inline example` + + + +```html +Fenced example +``` +'''; + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n\nPart.\n', + 'referrer.md': referrerSource, + }, + ); + final doomed = _topic(fixture.module, 'doomed.md'); + final referrerPath = p.join(fixture.root.path, 'topics', 'referrer.md'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: doomed.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final links = analysis.usages + .where((usage) => usage.kind == WritersideTopicUsageKind.topicLink) + .toList(); + + expect(links, hasLength(9)); + expect(links.every((usage) => usage.relevant), isTrue); + expect( + links.map((usage) => usage.canUpdateAutomatically), + everyElement(isTrue), + ); + expect( + links.map( + (usage) => referrerSource.substring( + usage.span!.startOffset, + usage.span!.endOffset, + ), + ), + [ + 'doomed.md', + 'doomed.md', + 'doomed.md', + 'doomed.md#part', + 'doomed.md?mode=x#part', + 'doomed.md', + 'doomed.md', + 'doomed.md', + 'doomed.md', + ], + ); + expect( + links.map((usage) => usage.span!.startOffset).toSet(), + hasLength(9), + ); + + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + + expect(result.deletedFile, isTrue); + expect(File(doomed.filePath).existsSync(), isFalse); + expect(File(referrerPath).readAsStringSync(), ''' +# Referrer + +Read the old topic. + +Read the single-quoted topic. + +Read the unquoted topic. + +Read the old section. + +Read the queried section. + +One + +Two + +- Read the list topic. + +> Read the quoted topic. + +Not a topic link + +External + +![Image](image.png "Image title") + +[Other](other.md "Link title") + +`Inline example` + + + +```html +Fenced example +``` +'''); + final reloaded = await const WritersideProjectService().load( + fixture.root.path, + ); + expect( + reloaded.index.references.where( + (reference) => + reference.kind == WritersideSymbolKind.topic && + reference.value.startsWith('doomed.md'), + ), + isEmpty, + ); + }, + ); + + test( + 'malformed authored HTML anchors are manual Safe Delete blockers', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'referrer.md': '# Referrer\n\nRead unfinished.\n', + }, + ); + final doomed = _topic(fixture.module, 'doomed.md'); + final before = File(doomed.filePath).readAsStringSync(); + final analysis = await service.analyze( + module: fixture.module, + topicPath: doomed.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final usage = analysis.usages.singleWhere( + (candidate) => candidate.kind == WritersideTopicUsageKind.topicLink, + ); + + expect(usage.reference, 'doomed.md'); + expect(usage.canUpdateAutomatically, isFalse); + expect(analysis.canUpdateUsagesAutomatically, isFalse); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ), + throwsA(isA()), + ); + expect(File(doomed.filePath).readAsStringSync(), before); + }, + ); + + test( + 'authored HTML variable hrefs are expanded and ambiguity blocks', + () async { + Future analyzeWithVariables( + String variables, + ) async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + + +''', + }, + configuredTrees: const ['guide.tree'], + variables: variables, + topics: { + 'doomed.md': '# Doomed\n', + 'other.md': '# Other\n', + 'referrer.md': '# Referrer\n\nTarget\n', + }, + ); + return service.analyze( + module: fixture.module, + topicPath: _topic(fixture.module, 'doomed.md').filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + } + + final unique = await analyzeWithVariables( + '', + ); + expect( + unique.usages + .singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ) + .canUpdateAutomatically, + isTrue, + ); + final ambiguous = await analyzeWithVariables(''' + + + + +'''); + expect( + ambiguous.usages + .singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ) + .canUpdateAutomatically, + isFalse, + ); + expect(ambiguous.canUpdateUsagesAutomatically, isFalse); + }, + ); + + test( + 'authored HTML relevance follows its conditioned Markdown chapter', + () async { + final fixture = await _fixture( + trees: { + 'user.tree': ''' + + + + +''', + 'admin.tree': ''' + + + + +''', + }, + configuredTrees: const ['user.tree', 'admin.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'referrer.md': ''' +# Referrer + +## Admin {instance="admin"} + +Read the admin topic. +''', + }, + ); + final target = _topic(fixture.module, 'doomed.md').filePath; + final user = await service.analyze( + module: fixture.module, + topicPath: target, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'user.tree'), + selectedNodePath: const [1], + ); + final admin = await service.analyze( + module: fixture.module, + topicPath: target, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'admin.tree'), + selectedNodePath: const [1], + ); + + expect( + user.usages + .singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ) + .relevant, + isFalse, + ); + expect( + admin.usages + .singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ) + .relevant, + isTrue, + ); + }, + ); + + test('automatic HTML unlinking permits project-wide orphan status', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' + + + + +''', + }, + configuredTrees: const ['guide.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'referrer.md': '# Referrer\n\nOld\n', + }, + ); + final target = _topic(fixture.module, 'doomed.md'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'guide.tree'), + selectedNodePath: const [1], + ); + + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + + expect(result.orphaned, isTrue); + expect(File(target.filePath).existsSync(), isTrue); + expect( + File( + p.join(fixture.root.path, 'topics', 'referrer.md'), + ).readAsStringSync(), + '# Referrer\n\nOld\n', + ); + }); + + test('a surviving authored HTML link suppresses orphan status', () async { + final fixture = await _fixture( + trees: { + 'user.tree': ''' + + + + +''', + }, + configuredTrees: const ['user.tree'], + topics: { + 'doomed.md': '# Doomed\n', + 'referrer.md': ''' +# Referrer + +## Other instance {instance="admin"} + +Old +''', + }, + ); + final target = _topic(fixture.module, 'doomed.md'); + final analysis = await service.analyze( + module: fixture.module, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.removeFromInstance, + selectedTreePath: p.join(fixture.root.path, 'user.tree'), + selectedNodePath: const [1], + ); + final link = analysis.usages.singleWhere( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ); + expect(link.relevant, isFalse); + + final result = await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + + expect(result.orphaned, isFalse); + expect(File(target.filePath).existsSync(), isTrue); + expect( + File( + p.join(fixture.root.path, 'topics', 'referrer.md'), + ).readAsStringSync(), + contains('Old'), + ); + }); + test( 'ambiguous references block deletion without changing any file', () async { @@ -1215,6 +1635,72 @@ Read [the old topic](doomed.md). }, ); + test('authored Markdown HTML anchors resolve origin project-wide', () async { + final fixture = await _projectFixture( + mainTree: ''' + +''', + sharedTree: ''' + +''', + sharedTopicFileName: 'references.md', + sharedTopic: ''' +# References + +Main: Main guide. + +Other: Other guide. +''', + ); + final otherRoot = p.join(fixture.root.path, 'other'); + Directory(p.join(otherRoot, 'topics')).createSync(recursive: true); + File(p.join(otherRoot, 'writerside.cfg')).writeAsStringSync(''' + +'''); + File(p.join(otherRoot, 'other.tree')).writeAsStringSync( + '\n', + ); + File( + p.join(otherRoot, 'topics', 'guide.md'), + ).writeAsStringSync('# Other guide\n'); + final project = await const WritersideProjectService().load( + fixture.root.path, + preferredModuleRoot: fixture.mainRoot, + ); + final target = project.modulesByOrigin['main']!.topics.single; + final analysis = await service.analyze( + project: project, + projectRoot: fixture.root.path, + topicPath: target.filePath, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final links = analysis.usages.where( + (usage) => usage.kind == WritersideTopicUsageKind.topicLink, + ); + + expect(links, hasLength(1)); + expect(links.single.reference, 'guide.md'); + expect(links.single.canUpdateAutomatically, isTrue); + await service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + expect( + File( + p.join(fixture.sharedRoot, 'topics', 'references.md'), + ).readAsStringSync(), + ''' +# References + +Main: Main guide. + +Other: Other guide. +''', + ); + }); + test('safe delete updates automatic cross-module origin usages', () async { final fixture = await _projectFixture( mainTree: ''' @@ -1468,6 +1954,7 @@ Future<({Directory root, String mainRoot, String sharedRoot})> _projectFixture({ required String mainTree, required String sharedTree, required String sharedTopic, + String sharedTopicFileName = 'references.topic', }) async { final root = await Directory.systemTemp.createTemp( 'busymark-removal-project-', @@ -1493,7 +1980,7 @@ Future<({Directory root, String mainRoot, String sharedRoot})> _projectFixture({ p.join(mainRoot, 'topics', 'guide.md'), ).writeAsStringSync('# Guide\n\nReusable.\n'); File( - p.join(sharedRoot, 'topics', 'references.topic'), + p.join(sharedRoot, 'topics', sharedTopicFileName), ).writeAsStringSync(sharedTopic.trimLeft()); final canonicalRoot = Directory(await root.resolveSymbolicLinks()); return ( From 5013d9ce92086a6981b06a32a70472b86adea233 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Wed, 16 Sep 2026 11:01:35 -0700 Subject: [PATCH 24/35] Add Writerside local Markdown topic import --- .../writerside-toc-implementation.md | 46 +- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_pt_BR.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_CN.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 3 + lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 3 + lib/l10n/generated/app_localizations_fa.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_id.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_ja.dart | 3 + lib/l10n/generated/app_localizations_ko.dart | 3 + lib/l10n/generated/app_localizations_nb.dart | 3 + lib/l10n/generated/app_localizations_nl.dart | 3 + lib/l10n/generated/app_localizations_pl.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 6 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_tr.dart | 3 + lib/l10n/generated/app_localizations_uk.dart | 3 + lib/l10n/generated/app_localizations_vi.dart | 3 + lib/l10n/generated/app_localizations_zh.dart | 6 + .../presentation/workspace_screen.dart | 79 +- .../writerside_markdown_import_dialog.dart | 158 ++++ lib/src/workspace/workspace_controller.dart | 13 + lib/src/workspace/workspace_service.dart | 54 +- .../writerside_instance_service.dart | 269 ++++++- .../writerside/writerside_topic_creator.dart | 331 +++++---- test/src/app_smoke_test.dart | 8 +- test/src/workspace_controller_test.dart | 98 +++ test/src/workspace_service_test.dart | 157 ++++ .../src/writerside_instance_service_test.dart | 678 +++++++++++++++++- ...riterside_markdown_import_dialog_test.dart | 210 ++++++ test/src/writerside_toc_regressions_test.dart | 43 ++ test/src/writerside_topic_creator_test.dart | 141 ++++ tools/writerside_toc_visual_smoke.dart | 29 + 60 files changed, 2221 insertions(+), 192 deletions(-) create mode 100644 lib/src/workspace/presentation/writerside_markdown_import_dialog.dart create mode 100644 test/src/writerside_markdown_import_dialog_test.dart diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md index 4b9c712e..fa36b419 100644 --- a/docs/development/writerside-toc-implementation.md +++ b/docs/development/writerside-toc-implementation.md @@ -34,6 +34,7 @@ The handoff's source identifiers are retained: | S4 | [Topics and titles](https://www.jetbrains.com/help/writerside/topics.html) | | S5–S7 | [Basic title dialog](https://resources.jetbrains.com/help/img/writerside/edit-title-action.png), [instance title](https://resources.jetbrains.com/help/img/writerside/edit_title_instance_specific.png), [TOC title](https://resources.jetbrains.com/help/img/writerside/edit_title_toc_title.png) | | S8 | [Add topics](https://www.jetbrains.com/help/writerside/add-a-topic.html) | +| S8a | [Import from Markdown](https://www.jetbrains.com/help/writerside/import-markdown.html) | | S9–S10 | [Reuse topics](https://www.jetbrains.com/help/writerside/reuse-topics.html), [creation menu](https://resources.jetbrains.com/help/img/writerside/reuse-from-another.png) | | S11–S12 | [Remove topics](https://www.jetbrains.com/help/writerside/delete-a-topic.html), [removal dialog](https://resources.jetbrains.com/help/img/writerside/remove_topic_dialog.png) | | S13 | [Save topic as template](https://www.jetbrains.com/help/writerside/save-as-template.html) | @@ -101,6 +102,7 @@ screenshot comparison. | S8, P | Either creation menu: **Empty MD Topic**, **Empty XML Topic**; dialog **New Topic** | `WS._CreateWritersideTopicDialog`, `WritersideTopicCreator.create` | C: formats, roots, collisions, races, first home page; controller monitor-race and dirty inactive-tree tests | Native both formats passed; new documents opened with other tabs retained. Existing BusyMark empty-body content retained; not a claim of identical installed template bytes. | | S1, P | Creation menu: **Empty Group**; dialog **New Empty Group**, **TOC title:**, **Cancel**, **OK** | `Dialogs.WritersideTocTextDialog`, `WritersideTocEditor.insertElement` | E/A structural paths; guarded insertion service | Native creation passed: a titled element without a topic reference. | | S9–S10, P | Creation menu after separator: **Link Topic Files to TOC...**; picker **Select Topic to Add to the Current Instance** | `Dialogs.WritersideExistingTopicPicker`, `WorkspaceService.insertWritersideTocElement` | M: filter/arrows/submit/cancel; W: unchanged bytes and stale eligibility | Native linking passed, including unchanged source bytes. Candidates use canonical resolved membership, including includes. | +| S8, S8a | **New Topic** and header Add: **Add Local Markdown Files**; dialog **Add Local Markdown Files** | Existing Markdown discovery and `WritersideInstanceService.addMarkdownTopics`; focused selection dialog | Instance service: selected-only/root/sibling ordering, relative layout, media, first home page, ID and concurrency failures; workspace/controller/dialog/menu suites | Context imports are siblings and header imports are roots. **New Child Topic** intentionally has no import action. Imported Markdown bytes are unchanged. | | S2, P | Context after creation separator: **Duplicate**; **Duplicate Topic**, **Topic Filename:** | `WorkspaceService.duplicateWritersideTopic`, guarded creator | W: new XML ID, unchanged body, basic sibling only; C publication guards | Native duplication passed: source copy opened with its new XML root ID. Filename validation retains BusyMark's conservative identifier restrictions; race errors use existing workspace error presentation. | | S2, P | Context: **Copy Special** → **Topic File Name '{name}'**, **Topic File Path**, **Topic Title '{title}'**, **TOC Element ID '{id}'** | `WS._showTocTreeMenu`, `_showTopicContextMenu` | App: all four Copy Special clipboard payloads in RTL, including base versus navigation title; M nested keyboard/focus/RTL | Native menu captured. Payloads are raw topic reference, canonical path, base title, explicit ID; unavailable values are disabled. No claim that contextual TOC title is copied. | | S2, S14 | Context: **Preview Topic** | `WS._showTopicContextMenu` preview case, existing view-mode/preview controller | Existing preview/view-mode suites; native selected-topic assertion | Native selected-topic preview passed and was captured. Uses BusyMark renderer, not the Writerside website renderer. | @@ -163,6 +165,34 @@ mixed-entry sort, safe-delete and front-matter restrictions are explicit above. The two pictured toolbar chevrons' original tooltips have not been established; no guessed tooltip names were added. +Existing-instance Markdown import reuses `discoverMarkdownFiles` for source-root +discovery and the shared import planner for selected topic and referenced-media +copies. Topic targets retain source-root-relative directories beneath the first +configured topics root. Media references use the established Markdown, semantic +XML, Writerside video, and preview-source scan; external/absolute references and +intentionally missing local media retain their prior behavior, and duplicate +media targets are staged once. + +Before planning, and again immediately before publication, the owning module is +reloaded and complete semantic topic discovery is required. Each source basename +passes the shared topic filename validator. IDs are basenames without extensions; +the selected batch must be unique and must not intersect +`WritersideModule.reservedTopicIds`, which includes parsed and discovered-but- +unparsed topic files across all configured topic roots. The import transaction +stages every selected Markdown file, deduplicated media file, and the updated +tree, verifies all source/target snapshots, then publishes once with conservative +rollback. Consequently a stale TOC identity, target change, or concurrent topic +ID leaves no partial topic/media/tree publication. The shared first-topic helper +sets only the first imported reference as `start-page` when appropriate. + +Ordinary empty, template, custom-template, and duplicate creation retains exact +exclusive path creation, then reloads semantic module state after its own topic +exists and before tree publication. A valid final state has exactly one parsed +reservation for the requested ID and that reservation is the candidate path; +another extension, subdirectory, or configured root fails with the existing ID +collision error. Existing owned-file cleanup and final tree snapshot checks remain +independent safeguards. + ## Template implementation and resource provenance `assets/writerside/templates.json` contains the installed resources, with each @@ -218,23 +248,25 @@ The existing main application menu remains flat. ## Verification log -Final verification, including topic removal, templates, and native menus: +Final verification, including local Markdown topic import, topic removal, +templates, and native menus: - `flutter gen-l10n`: exit 0; generated localization sources are current. - Changed Dart sources and tests were formatted with `dart format`. - `flutter analyze --no-pub`: exit 0, no issues. -- Full `flutter test --no-pub`: exit 0, **2,038 passed, 58 skipped**. -- Required focused removal/project/workspace/controller/TOC/UI suites: exit 0, - **302 passed**. +- Full `flutter test --no-pub`: exit 0, **2,074 passed, 58 skipped**. +- Required focused import/topic-creator/template/workspace/controller/TOC/UI + suites, including the focused import dialog: exit 0, **335 passed**. - `bash tools/validate_writerside_conformance.sh`: exit 0, **181 checks passed**. This uses the pinned builder 2026.08.0328, distinct from the selected UI plugin 2026.07.8925. It checks the semantic fixture, not website or menu parity. -- Linux interaction harness: result artifact reports no failure and **48 checks +- Linux interaction harness: result artifact reports no failure and **50 checks passed**. Includes real GTK two-level keyboard traversal in LTR/RTL, disabled headings, Escape, focus return, session-scoped dismissal, existing flat radio selectors, - the reviewed removal flow, mandatory Safe Delete, and the core TOC/template - workflows in the action matrix. + both Add Local Markdown Files menu locations, the reviewed removal flow, + mandatory Safe Delete, and the core TOC/template workflows in the action + matrix. - Normal `flutter build linux --debug --no-pub --target lib/main.dart`: exit 0; the bundle is restored to the normal application, not the acceptance harness. - `git diff --check`: exit 0. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 836d1878..118d4c08 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -53,6 +53,7 @@ "tocNewGroup": "مجموعة جديدة", "tocSortChildren": "ترتيب المواضيع الفرعية أبجديًا", "tocLinkTopicFiles": "ربط ملفات المواضيع بجدول المحتويات...", + "addLocalMarkdownFiles": "إضافة ملفات Markdown محلية", "tocTitleField": "عنوان جدول المحتويات:", "tocTopicTitleField": "عنوان الموضوع:", "tocAdvancedSettings": "إعدادات متقدمة", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 73c7333a..3fe4b24f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Neue Gruppe", "tocSortChildren": "Untergeordnete Themen alphabetisch sortieren", "tocLinkTopicFiles": "Themendateien mit Inhaltsverzeichnis verknüpfen...", + "addLocalMarkdownFiles": "Lokale Markdown-Dateien hinzufügen", "tocTitleField": "Inhaltsverzeichnistitel:", "tocTopicTitleField": "Thementitel:", "tocAdvancedSettings": "Erweiterte Einstellungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 98d43230..d8fab085 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -111,6 +111,8 @@ "@tocSortChildren": {"description":"Writerside Table of Contents command or dialog text."}, "tocLinkTopicFiles": "Link Topic Files to TOC...", "@tocLinkTopicFiles": {"description":"Writerside Table of Contents command or dialog text."}, + "addLocalMarkdownFiles": "Add Local Markdown Files", + "@addLocalMarkdownFiles": {"description":"Action and dialog title for importing selected Markdown files as topics into an existing Writerside instance."}, "tocTitleField": "TOC title:", "@tocTitleField": {"description":"Writerside Table of Contents command or dialog text."}, "tocTopicTitleField": "Topic title:", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a7a1ea84..ee921bb7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nuevo grupo", "tocSortChildren": "Ordenar temas secundarios alfabéticamente", "tocLinkTopicFiles": "Vincular archivos de temas al índice...", + "addLocalMarkdownFiles": "Añadir archivos Markdown locales", "tocTitleField": "Título del índice:", "tocTopicTitleField": "Título del tema:", "tocAdvancedSettings": "Configuración avanzada", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 7909f3fb..104af141 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Uus rühm", "tocSortChildren": "Sordi alamteemad tähestikuliselt", "tocLinkTopicFiles": "Lingi teemafailid sisukorraga...", + "addLocalMarkdownFiles": "Lisa kohalikud Markdown-failid", "tocTitleField": "Sisukorra pealkiri:", "tocTopicTitleField": "Teema pealkiri:", "tocAdvancedSettings": "Täpsemad seaded", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 89da8ec7..0c70efe1 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -53,6 +53,7 @@ "tocNewGroup": "گروه جدید", "tocSortChildren": "مرتب‌سازی الفبایی زیرموضوع‌ها", "tocLinkTopicFiles": "پیوند پرونده‌های موضوع به فهرست مطالب...", + "addLocalMarkdownFiles": "افزودن فایل‌های Markdown محلی", "tocTitleField": "عنوان فهرست مطالب:", "tocTopicTitleField": "عنوان موضوع:", "tocAdvancedSettings": "تنظیمات پیشرفته", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 59f2a785..c0e1b226 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nouveau groupe", "tocSortChildren": "Trier les sujets enfants par ordre alphabétique", "tocLinkTopicFiles": "Lier des fichiers de sujets à la table des matières...", + "addLocalMarkdownFiles": "Ajouter des fichiers Markdown locaux", "tocTitleField": "Titre dans la table des matières:", "tocTopicTitleField": "Titre du sujet:", "tocAdvancedSettings": "Paramètres avancés", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c83b7de7..7c88a6d2 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -53,6 +53,7 @@ "tocNewGroup": "नया समूह", "tocSortChildren": "उपविषयों को वर्णानुक्रम में क्रमबद्ध करें", "tocLinkTopicFiles": "विषय फ़ाइलों को विषय-सूची से लिंक करें...", + "addLocalMarkdownFiles": "स्थानीय Markdown फ़ाइलें जोड़ें", "tocTitleField": "विषय-सूची का शीर्षक:", "tocTopicTitleField": "विषय का शीर्षक:", "tocAdvancedSettings": "उन्नत सेटिंग्स", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index dce44dc3..d9ef940e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Grup baru", "tocSortChildren": "Urutkan subtopik berdasarkan abjad", "tocLinkTopicFiles": "Tautkan berkas topik ke daftar isi...", + "addLocalMarkdownFiles": "Tambahkan Berkas Markdown Lokal", "tocTitleField": "Judul daftar isi:", "tocTopicTitleField": "Judul topik:", "tocAdvancedSettings": "Pengaturan lanjutan", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 29bfae25..d9e6e27f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nuovo gruppo", "tocSortChildren": "Ordina alfabeticamente gli argomenti secondari", "tocLinkTopicFiles": "Collega file di argomenti al sommario...", + "addLocalMarkdownFiles": "Aggiungi file Markdown locali", "tocTitleField": "Titolo nel sommario:", "tocTopicTitleField": "Titolo dell’argomento:", "tocAdvancedSettings": "Impostazioni avanzate", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d628a259..671f985b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -53,6 +53,7 @@ "tocNewGroup": "新しいグループ", "tocSortChildren": "子トピックをアルファベット順に並べ替え", "tocLinkTopicFiles": "トピックファイルを目次にリンク...", + "addLocalMarkdownFiles": "ローカル Markdown ファイルを追加", "tocTitleField": "目次タイトル:", "tocTopicTitleField": "トピックのタイトル:", "tocAdvancedSettings": "詳細設定", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index fc9b3057..4d002325 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -53,6 +53,7 @@ "tocNewGroup": "새 그룹", "tocSortChildren": "하위 토픽을 알파벳순으로 정렬", "tocLinkTopicFiles": "토픽 파일을 목차에 연결...", + "addLocalMarkdownFiles": "로컬 Markdown 파일 추가", "tocTitleField": "목차 제목:", "tocTopicTitleField": "토픽 제목:", "tocAdvancedSettings": "고급 설정", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index f98d1c94..f138c7d8 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Ny gruppe", "tocSortChildren": "Sorter underemner alfabetisk", "tocLinkTopicFiles": "Koble emnefiler til innholdsfortegnelsen...", + "addLocalMarkdownFiles": "Legg til lokale Markdown-filer", "tocTitleField": "Tittel i innholdsfortegnelsen:", "tocTopicTitleField": "Emnetittel:", "tocAdvancedSettings": "Avanserte innstillinger", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 1009eade..fe9dc0bb 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nieuwe groep", "tocSortChildren": "Onderliggende onderwerpen alfabetisch sorteren", "tocLinkTopicFiles": "Onderwerpbestanden aan inhoudsopgave koppelen...", + "addLocalMarkdownFiles": "Lokale Markdown-bestanden toevoegen", "tocTitleField": "Titel in inhoudsopgave:", "tocTopicTitleField": "Onderwerptitel:", "tocAdvancedSettings": "Geavanceerde instellingen", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a179c886..351a1a45 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nowa grupa", "tocSortChildren": "Sortuj tematy podrzędne alfabetycznie", "tocLinkTopicFiles": "Połącz pliki tematów ze spisem treści...", + "addLocalMarkdownFiles": "Dodaj lokalne pliki Markdown", "tocTitleField": "Tytuł w spisie treści:", "tocTopicTitleField": "Tytuł tematu:", "tocAdvancedSettings": "Ustawienia zaawansowane", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 73e38dd5..c618a7e2 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Novo grupo", "tocSortChildren": "Ordenar subtópicos alfabeticamente", "tocLinkTopicFiles": "Ligar ficheiros de tópicos ao índice...", + "addLocalMarkdownFiles": "Adicionar ficheiros Markdown locais", "tocTitleField": "Título no índice:", "tocTopicTitleField": "Título do tópico:", "tocAdvancedSettings": "Definições avançadas", diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index c24aa9fd..cca20ae6 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Novo grupo", "tocSortChildren": "Ordenar subtópicos alfabeticamente", "tocLinkTopicFiles": "Vincular arquivos de tópicos ao índice...", + "addLocalMarkdownFiles": "Adicionar arquivos Markdown locais", "tocTitleField": "Título no índice:", "tocTopicTitleField": "Título do tópico:", "tocAdvancedSettings": "Configurações avançadas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1e0d405e..91b5805b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Новая группа", "tocSortChildren": "Сортировать дочерние темы по алфавиту", "tocLinkTopicFiles": "Связать файлы тем с оглавлением...", + "addLocalMarkdownFiles": "Добавить локальные файлы Markdown", "tocTitleField": "Заголовок в оглавлении:", "tocTopicTitleField": "Заголовок темы:", "tocAdvancedSettings": "Дополнительные настройки", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index a32c6cbc..3b7443cd 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Yeni grup", "tocSortChildren": "Alt konuları alfabetik sırala", "tocLinkTopicFiles": "Konu dosyalarını içindekilere bağla...", + "addLocalMarkdownFiles": "Yerel Markdown Dosyaları Ekle", "tocTitleField": "İçindekiler başlığı:", "tocTopicTitleField": "Konu başlığı:", "tocAdvancedSettings": "Gelişmiş ayarlar", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index e86ff4b9..018bd9b7 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Нова група", "tocSortChildren": "Сортувати дочірні теми за абеткою", "tocLinkTopicFiles": "Пов’язати файли тем зі змістом...", + "addLocalMarkdownFiles": "Додати локальні файли Markdown", "tocTitleField": "Заголовок у змісті:", "tocTopicTitleField": "Заголовок теми:", "tocAdvancedSettings": "Додаткові налаштування", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d40ddb3e..858172ef 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -53,6 +53,7 @@ "tocNewGroup": "Nhóm mới", "tocSortChildren": "Sắp xếp chủ đề con theo bảng chữ cái", "tocLinkTopicFiles": "Liên kết tệp chủ đề với mục lục...", + "addLocalMarkdownFiles": "Thêm tệp Markdown cục bộ", "tocTitleField": "Tiêu đề mục lục:", "tocTopicTitleField": "Tiêu đề chủ đề:", "tocAdvancedSettings": "Cài đặt nâng cao", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2d8b3d5a..5df5e1ce 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -53,6 +53,7 @@ "tocNewGroup": "新建组", "tocSortChildren": "按字母顺序排列子主题", "tocLinkTopicFiles": "将主题文件链接到目录...", + "addLocalMarkdownFiles": "添加本地 Markdown 文件", "tocTitleField": "目录标题:", "tocTopicTitleField": "主题标题:", "tocAdvancedSettings": "高级设置", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 60e4a263..a6976048 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -53,6 +53,7 @@ "tocNewGroup": "新建组", "tocSortChildren": "按字母顺序排列子主题", "tocLinkTopicFiles": "将主题文件链接到目录...", + "addLocalMarkdownFiles": "添加本地 Markdown 文件", "tocTitleField": "目录标题:", "tocTopicTitleField": "主题标题:", "tocAdvancedSettings": "高级设置", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 172fcf63..8a8a4440 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -463,6 +463,12 @@ abstract class AppLocalizations { /// **'Link Topic Files to TOC...'** String get tocLinkTopicFiles; + /// Action and dialog title for importing selected Markdown files as topics into an existing Writerside instance. + /// + /// In en, this message translates to: + /// **'Add Local Markdown Files'** + String get addLocalMarkdownFiles; + /// Writerside Table of Contents command or dialog text. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index a116dac0..e3ebe64f 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -188,6 +188,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get tocLinkTopicFiles => 'ربط ملفات المواضيع بجدول المحتويات...'; + @override + String get addLocalMarkdownFiles => 'إضافة ملفات Markdown محلية'; + @override String get tocTitleField => 'عنوان جدول المحتويات:'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c213a782..65a51020 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -189,6 +189,9 @@ class AppLocalizationsDe extends AppLocalizations { String get tocLinkTopicFiles => 'Themendateien mit Inhaltsverzeichnis verknüpfen...'; + @override + String get addLocalMarkdownFiles => 'Lokale Markdown-Dateien hinzufügen'; + @override String get tocTitleField => 'Inhaltsverzeichnistitel:'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index c1f0338a..aec6aa67 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -188,6 +188,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tocLinkTopicFiles => 'Link Topic Files to TOC...'; + @override + String get addLocalMarkdownFiles => 'Add Local Markdown Files'; + @override String get tocTitleField => 'TOC title:'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 4360e377..c64d111f 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -188,6 +188,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tocLinkTopicFiles => 'Vincular archivos de temas al índice...'; + @override + String get addLocalMarkdownFiles => 'Añadir archivos Markdown locales'; + @override String get tocTitleField => 'Título del índice:'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index ae19095d..f202b38f 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -188,6 +188,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get tocLinkTopicFiles => 'Lingi teemafailid sisukorraga...'; + @override + String get addLocalMarkdownFiles => 'Lisa kohalikud Markdown-failid'; + @override String get tocTitleField => 'Sisukorra pealkiri:'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 16a90ba8..377e1c15 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -188,6 +188,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get tocLinkTopicFiles => 'پیوند پرونده‌های موضوع به فهرست مطالب...'; + @override + String get addLocalMarkdownFiles => 'افزودن فایل‌های Markdown محلی'; + @override String get tocTitleField => 'عنوان فهرست مطالب:'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index f62643b7..ba248bf1 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -193,6 +193,9 @@ class AppLocalizationsFr extends AppLocalizations { String get tocLinkTopicFiles => 'Lier des fichiers de sujets à la table des matières...'; + @override + String get addLocalMarkdownFiles => 'Ajouter des fichiers Markdown locaux'; + @override String get tocTitleField => 'Titre dans la table des matières:'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index e13ab169..0b1fcfe1 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -188,6 +188,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tocLinkTopicFiles => 'विषय फ़ाइलों को विषय-सूची से लिंक करें...'; + @override + String get addLocalMarkdownFiles => 'स्थानीय Markdown फ़ाइलें जोड़ें'; + @override String get tocTitleField => 'विषय-सूची का शीर्षक:'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index c7a20d7a..b4648697 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -188,6 +188,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get tocLinkTopicFiles => 'Tautkan berkas topik ke daftar isi...'; + @override + String get addLocalMarkdownFiles => 'Tambahkan Berkas Markdown Lokal'; + @override String get tocTitleField => 'Judul daftar isi:'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 408b8ed1..18870513 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -189,6 +189,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tocLinkTopicFiles => 'Collega file di argomenti al sommario...'; + @override + String get addLocalMarkdownFiles => 'Aggiungi file Markdown locali'; + @override String get tocTitleField => 'Titolo nel sommario:'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 3f37dff2..fb3761b5 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -183,6 +183,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tocLinkTopicFiles => 'トピックファイルを目次にリンク...'; + @override + String get addLocalMarkdownFiles => 'ローカル Markdown ファイルを追加'; + @override String get tocTitleField => '目次タイトル:'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index fc425f93..a357fcce 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -182,6 +182,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get tocLinkTopicFiles => '토픽 파일을 목차에 연결...'; + @override + String get addLocalMarkdownFiles => '로컬 Markdown 파일 추가'; + @override String get tocTitleField => '목차 제목:'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 9ee8085d..43971418 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -189,6 +189,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get tocLinkTopicFiles => 'Koble emnefiler til innholdsfortegnelsen...'; + @override + String get addLocalMarkdownFiles => 'Legg til lokale Markdown-filer'; + @override String get tocTitleField => 'Tittel i innholdsfortegnelsen:'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 14a483a5..0a19ec98 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -190,6 +190,9 @@ class AppLocalizationsNl extends AppLocalizations { String get tocLinkTopicFiles => 'Onderwerpbestanden aan inhoudsopgave koppelen...'; + @override + String get addLocalMarkdownFiles => 'Lokale Markdown-bestanden toevoegen'; + @override String get tocTitleField => 'Titel in inhoudsopgave:'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 8a6f77e8..27a0714e 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -188,6 +188,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tocLinkTopicFiles => 'Połącz pliki tematów ze spisem treści...'; + @override + String get addLocalMarkdownFiles => 'Dodaj lokalne pliki Markdown'; + @override String get tocTitleField => 'Tytuł w spisie treści:'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 3bf1d211..3e4d5065 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -188,6 +188,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tocLinkTopicFiles => 'Ligar ficheiros de tópicos ao índice...'; + @override + String get addLocalMarkdownFiles => 'Adicionar ficheiros Markdown locais'; + @override String get tocTitleField => 'Título no índice:'; @@ -4760,6 +4763,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get tocLinkTopicFiles => 'Vincular arquivos de tópicos ao índice...'; + @override + String get addLocalMarkdownFiles => 'Adicionar arquivos Markdown locais'; + @override String get tocTitleField => 'Título no índice:'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 932b28ab..b7b9f240 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -188,6 +188,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tocLinkTopicFiles => 'Связать файлы тем с оглавлением...'; + @override + String get addLocalMarkdownFiles => 'Добавить локальные файлы Markdown'; + @override String get tocTitleField => 'Заголовок в оглавлении:'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 56d1a69b..c3d436c2 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -187,6 +187,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tocLinkTopicFiles => 'Konu dosyalarını içindekilere bağla...'; + @override + String get addLocalMarkdownFiles => 'Yerel Markdown Dosyaları Ekle'; + @override String get tocTitleField => 'İçindekiler başlığı:'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index ffdfba5f..772aa42a 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -188,6 +188,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tocLinkTopicFiles => 'Пов’язати файли тем зі змістом...'; + @override + String get addLocalMarkdownFiles => 'Додати локальні файли Markdown'; + @override String get tocTitleField => 'Заголовок у змісті:'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 160cfe61..8dceee36 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -188,6 +188,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get tocLinkTopicFiles => 'Liên kết tệp chủ đề với mục lục...'; + @override + String get addLocalMarkdownFiles => 'Thêm tệp Markdown cục bộ'; + @override String get tocTitleField => 'Tiêu đề mục lục:'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 919cf613..2450c943 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -182,6 +182,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tocLinkTopicFiles => '将主题文件链接到目录...'; + @override + String get addLocalMarkdownFiles => '添加本地 Markdown 文件'; + @override String get tocTitleField => '目录标题:'; @@ -4493,6 +4496,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get tocLinkTopicFiles => '将主题文件链接到目录...'; + @override + String get addLocalMarkdownFiles => '添加本地 Markdown 文件'; + @override String get tocTitleField => '目录标题:'; diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 53f82b7b..3f8a4633 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -11,6 +11,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -104,6 +105,7 @@ import '../workspace_safety.dart'; import '../workspace_tabs.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; +import 'writerside_markdown_import_dialog.dart'; import 'writerside_toc_dialogs.dart'; import 'writerside_template_dialogs.dart'; import '../../writerside/writerside_template_service.dart'; @@ -6041,20 +6043,8 @@ class _TocTabState extends ConsumerState<_TocTab> { onCreateTopic: (choice) => _createTocItem( context, instanceTreePath: instance.sourceTreePath, - placement: - selectedEntry?.canEditStructureIn( - instance.sourceTreePath, - ) == - true - ? WritersideTopicCreatePlacement.sibling - : WritersideTopicCreatePlacement.root, - referenceEntry: - selectedEntry?.canEditStructureIn( - instance.sourceTreePath, - ) == - true - ? selectedEntry - : null, + placement: WritersideTopicCreatePlacement.root, + referenceEntry: null, choice: choice, ), onSynchronize: () => @@ -6902,6 +6892,7 @@ class _TocTabState extends ConsumerState<_TocTab> { ); case _TocTreeAction.emptyGroup: case _TocTreeAction.emptyChildGroup: + case _TocTreeAction.importMarkdownTopics: case _TocTreeAction.linkTopics: case _TocTreeAction.linkChildTopics: await _createTocItem( @@ -6917,6 +6908,8 @@ class _TocTabState extends ConsumerState<_TocTab> { action == _TocTreeAction.emptyGroup || action == _TocTreeAction.emptyChildGroup ? _TocCreationChoice.emptyGroup + : action == _TocTreeAction.importMarkdownTopics + ? _TocCreationChoice.importMarkdown : _TocCreationChoice.link, ); case _TocTreeAction.group: @@ -7633,6 +7626,46 @@ class _TocTabState extends ConsumerState<_TocTab> { final identity = raw == null ? null : WritersideTocNodeIdentity.fromNode(raw); + if (choice == _TocCreationChoice.importMarkdown) { + final sourcePath = await getDirectoryPath( + initialDirectory: widget.workspace.rootPath, + confirmButtonText: context.l10n.open, + canCreateDirectories: false, + ); + if (sourcePath == null || !mounted || !context.mounted) return; + final candidates = await ref + .read(workspaceControllerProvider.notifier) + .discoverWritersideMarkdownImport(sourcePath); + if (candidates == null || !mounted || !context.mounted) { + return; + } + if (!await confirmSafeToRefactorWritersideProject(context, ref) || + !mounted || + !context.mounted) { + return; + } + final imported = await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => WritersideMarkdownImportDialog( + sourceRootPath: sourcePath, + candidates: candidates, + treePath: instanceTreePath, + placement: placement, + referenceTocPath: referenceEntry?.editPath, + referenceTocIdentity: identity, + ), + ); + if (imported == true && mounted) { + setState(() { + _cutEntries = []; + _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; + }); + } + return; + } if (choice == _TocCreationChoice.template) { final module = widget.workspace.writersideModule!; final instance = _tocInstanceForTreePath(module, instanceTreePath)!; @@ -7846,6 +7879,7 @@ enum _TocTreeAction { editTitle, emptyGroup, emptyChildGroup, + importMarkdownTopics, linkTopics, linkChildTopics, group, @@ -7912,6 +7946,10 @@ Future<_TocTreeAction?> _showTocTreeMenu( label: context.l10n.tocTopicFromTemplate, ), const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocTreeAction.importMarkdownTopics, + label: context.l10n.addLocalMarkdownFiles, + ), BusyMarkPopupMenuItem( value: _TocTreeAction.linkTopics, label: context.l10n.tocLinkTopicFiles, @@ -8449,7 +8487,14 @@ enum _TocHeaderAction { export, } -enum _TocCreationChoice { markdown, xml, emptyGroup, template, link } +enum _TocCreationChoice { + markdown, + xml, + emptyGroup, + template, + importMarkdown, + link, +} class _TocHeader extends StatelessWidget { const _TocHeader({ @@ -8572,6 +8617,10 @@ class _TocHeader extends StatelessWidget { label: context.l10n.tocTopicFromTemplate, ), const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocCreationChoice.importMarkdown, + label: context.l10n.addLocalMarkdownFiles, + ), BusyMarkPopupMenuItem( value: _TocCreationChoice.link, label: context.l10n.tocLinkTopicFiles, diff --git a/lib/src/workspace/presentation/writerside_markdown_import_dialog.dart b/lib/src/workspace/presentation/writerside_markdown_import_dialog.dart new file mode 100644 index 00000000..68160365 --- /dev/null +++ b/lib/src/workspace/presentation/writerside_markdown_import_dialog.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/localization.dart'; +import '../../writerside/writerside_instance_service.dart'; +import '../../writerside/writerside_topic_creator.dart'; +import '../workspace_controller.dart'; +import '../workspace_message.dart'; + +class WritersideMarkdownImportDialog extends ConsumerStatefulWidget { + const WritersideMarkdownImportDialog({ + super.key, + required this.sourceRootPath, + required this.candidates, + required this.treePath, + required this.placement, + this.referenceTocPath, + this.referenceTocIdentity, + }); + + final String sourceRootPath; + final List candidates; + final String treePath; + final WritersideTopicCreatePlacement placement; + final List? referenceTocPath; + final WritersideTocNodeIdentity? referenceTocIdentity; + + @override + ConsumerState createState() => + _WritersideMarkdownImportDialogState(); +} + +class _WritersideMarkdownImportDialogState + extends ConsumerState { + late final Set _selectedPaths = { + for (final candidate in widget.candidates) candidate.absolutePath, + }; + bool _saving = false; + String? _error; + + @override + Widget build(BuildContext context) { + return BusyMarkDialogShell( + title: context.l10n.addLocalMarkdownFiles, + maxWidth: BusyMarkSizes.dialogWide, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: _saving ? null : () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.tocOk, + suggested: true, + onPressed: _saving || _selectedPaths.isEmpty ? null : _import, + ), + ], + children: [ + BusyMarkGroupedList( + title: context.l10n.markdownImportFiles, + filled: true, + children: [ + YaruListTile.square( + title: Wrap( + spacing: BusyMarkSpacing.sm, + children: [ + TextButton( + onPressed: _saving + ? null + : () => setState(() { + _selectedPaths + ..clear() + ..addAll( + widget.candidates.map( + (candidate) => candidate.absolutePath, + ), + ); + _error = null; + }), + child: Text(context.l10n.selectAll), + ), + TextButton( + onPressed: _saving + ? null + : () => setState(() { + _selectedPaths.clear(); + _error = null; + }), + child: Text(context.l10n.selectNone), + ), + ], + ), + ), + for (final candidate in widget.candidates) + BusyMarkActionRow( + title: candidate.title, + subtitle: candidate.relativePath, + trailing: BusyMarkCheckbox( + value: _selectedPaths.contains(candidate.absolutePath), + onChanged: _saving + ? null + : (_) => _toggle(candidate.absolutePath), + ), + onTap: _saving ? null : () => _toggle(candidate.absolutePath), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkStatusBox(message: _error!, kind: BusyMarkStatusKind.error), + ], + ], + ); + } + + void _toggle(String path) { + setState(() { + if (!_selectedPaths.remove(path)) _selectedPaths.add(path); + _error = null; + }); + } + + Future _import() async { + setState(() { + _saving = true; + _error = null; + }); + final succeeded = await ref + .read(workspaceControllerProvider.notifier) + .addWritersideMarkdownTopics( + WritersideMarkdownTopicImportRequest( + sourceRootPath: widget.sourceRootPath, + selectedMarkdownPaths: [ + for (final candidate in widget.candidates) + if (_selectedPaths.contains(candidate.absolutePath)) + candidate.absolutePath, + ], + treePath: widget.treePath, + placement: widget.placement, + referenceTocPath: widget.referenceTocPath, + referenceTocIdentity: widget.referenceTocIdentity, + ), + ); + if (!mounted) return; + if (succeeded) { + Navigator.pop(context, true); + return; + } + final message = ref.read(workspaceControllerProvider).message; + setState(() { + _saving = false; + _error = message == null + ? context.l10n.workspaceErrorFileOperationFailed('') + : localizeWorkspaceMessage(context, message); + }); + } +} diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 030f9e02..a3833779 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1379,6 +1379,19 @@ class WorkspaceController extends Notifier { } } + Future addWritersideMarkdownTopics( + WritersideMarkdownTopicImportRequest request, + ) => _runWorkspaceFileOperation((workspace) async { + _requireCleanWritersideProject(workspace); + final updated = await _service.addWritersideMarkdownTopics( + workspace, + request, + validateBeforePublish: () async => + _requireCleanWritersideProject(workspace), + ); + return updated.activeFilePath; + }, openForEditing: true); + Future createWritersideInstance( WritersideInstanceCreateRequest request, ) async { diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index b68c43b4..851ef6fc 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -268,6 +268,8 @@ class WorkspaceService { request, initialSource: initialSource, validateBeforePublish: validateBeforePublish, + validateTopicIdentityBeforePublish: (validation) => + _validateCreatedTopicIdentity(module.rootPath, validation), ); return _openWriterside(module.rootPath, activeFilePath: result.topicPath); } @@ -282,7 +284,29 @@ class WorkspaceService { WritersideInstanceCreateRequest request, ) async { final module = await _currentWritersideModule(workspace); - return writersideInstanceService.create(module: module, request: request); + return writersideInstanceService.create( + module: module, + request: request, + reloadModule: () => _loadWritersideModule(module.rootPath), + ); + } + + Future addWritersideMarkdownTopics( + Workspace workspace, + WritersideMarkdownTopicImportRequest request, { + Future Function()? validateBeforePublish, + }) async { + final module = await _currentWritersideModule(workspace); + final result = await writersideInstanceService.addMarkdownTopics( + module: module, + request: request, + reloadModule: () => _loadWritersideModule(module.rootPath), + validateBeforePublish: validateBeforePublish, + ); + return _openWriterside( + module.rootPath, + activeFilePath: result.firstTopicPath, + ); } Future updateWritersideInstance( @@ -469,6 +493,8 @@ class WorkspaceService { ), initialSource: source, validateBeforePublish: validate, + validateTopicIdentityBeforePublish: (validation) => + _validateCreatedTopicIdentity(module.rootPath, validation), ); return result.topicPath; } @@ -1056,6 +1082,32 @@ class WorkspaceService { return _loadWritersideModule(openedModule.rootPath); } + Future _validateCreatedTopicIdentity( + String moduleRoot, + WritersideTopicIdentityValidation validation, + ) async { + final current = await _loadWritersideModule(moduleRoot); + if (!current.topicDiscoveryComplete) { + throw const BusyMarkException('writerside.topic.discovery-incomplete'); + } + final candidatePath = p.normalize(validation.candidateTopicPath); + final matchingTopics = current.topics + .where((topic) => topic.id == validation.topicId) + .toList(); + final unparsedConflict = current.unparsedTopicReferences.any( + (reference) => + p.basenameWithoutExtension(reference) == validation.topicId, + ); + if (unparsedConflict || + matchingTopics.length != 1 || + !p.equals(matchingTopics.single.filePath, candidatePath)) { + throw BusyMarkException( + 'writerside.topic.id-exists', + args: {'topicId': validation.topicId}, + ); + } + } + Future _loadWritersideModule(String rootPath) { return writersideService.load( rootPath, diff --git a/lib/src/writerside/writerside_instance_service.dart b/lib/src/writerside/writerside_instance_service.dart index f83da82c..5445dbf4 100644 --- a/lib/src/writerside/writerside_instance_service.dart +++ b/lib/src/writerside/writerside_instance_service.dart @@ -14,8 +14,11 @@ import '../markdown/busymark_document.dart'; import '../markdown/markdown_model.dart'; import '../markdown/markdown_parser.dart'; import 'writerside_model.dart'; +import 'writerside_module_service.dart'; import 'writerside_parsers.dart'; import 'writerside_project_creator.dart'; +import 'writerside_topic_creator.dart'; +import 'writerside_topic_file_name.dart'; enum WritersideInstanceStatus { release, eap, deprecated } @@ -90,6 +93,41 @@ class WritersideInstanceUpdateRequest { final WritersideInstanceSettings settings; } +class WritersideMarkdownTopicImportRequest { + WritersideMarkdownTopicImportRequest({ + required this.sourceRootPath, + required List selectedMarkdownPaths, + required this.treePath, + required this.placement, + List? referenceTocPath, + this.referenceTocIdentity, + this.copyReferencedMedia = true, + }) : selectedMarkdownPaths = List.unmodifiable(selectedMarkdownPaths), + referenceTocPath = referenceTocPath == null + ? null + : List.unmodifiable(referenceTocPath); + + final String sourceRootPath; + final List selectedMarkdownPaths; + final String treePath; + final WritersideTopicCreatePlacement placement; + final List? referenceTocPath; + final WritersideTocNodeIdentity? referenceTocIdentity; + final bool copyReferencedMedia; +} + +class WritersideMarkdownTopicImportResult { + WritersideMarkdownTopicImportResult({ + required List importedTopicPaths, + required this.treePath, + required this.firstTopicPath, + }) : importedTopicPaths = List.unmodifiable(importedTopicPaths); + + final List importedTopicPaths; + final String treePath; + final String firstTopicPath; +} + class WritersideInstanceMutationResult { const WritersideInstanceMutationResult({ required this.treePath, @@ -111,11 +149,13 @@ class WritersideInstanceService { const WritersideInstanceService({ this.markdownParser = const MarkdownParser(), this.buildProfilesParser = const WritersideBuildProfilesParser(), + this.moduleService = const WritersideModuleService(), Future Function()? beforePublish, }) : _beforePublish = beforePublish; final MarkdownParser markdownParser; final WritersideBuildProfilesParser buildProfilesParser; + final WritersideModuleService moduleService; final Future Function()? _beforePublish; Future> discoverMarkdownFiles( @@ -170,7 +210,14 @@ class WritersideInstanceService { Future create({ required WritersideModule module, required WritersideInstanceCreateRequest request, + Future Function()? reloadModule, }) async { + final loadModule = + reloadModule ?? () => moduleService.load(module.rootPath); + if (request.importsMarkdown) { + module = await loadModule(); + _requireCompleteTopicDiscovery(module); + } final settings = _validatedSettings(request.settings); if (request.isLibrary && request.importsMarkdown) { throw const BusyMarkException( @@ -204,7 +251,13 @@ class WritersideInstanceService { writes[configPath] = _DesiredFile.text(_xml(configDocument)); final imported = request.importsMarkdown - ? await _prepareImport(rootAnchor, module, request) + ? await _prepareMarkdownImport( + rootAnchor: rootAnchor, + module: module, + sourceRootPath: request.importRootPath, + selectedMarkdownPaths: request.importedMarkdownPaths, + copyReferencedMedia: request.copyReferencedMedia, + ) : const _PreparedImport.empty(); for (final entry in imported.files.entries) { writes[entry.key] = entry.value; @@ -231,7 +284,16 @@ class WritersideInstanceService { await _MutationTransaction( anchor: rootAnchor, desired: writes, - beforePublish: _beforePublish, + sourceSnapshots: imported.sourceSnapshots, + beforePublish: () async { + await _beforePublish?.call(); + if (request.importsMarkdown) { + await _validateImportedTopicIds( + await loadModule(), + imported.topicIds, + ); + } + }, ).commit(); return WritersideInstanceMutationResult( treePath: treePath, @@ -354,6 +416,71 @@ class WritersideInstanceService { ); } + Future addMarkdownTopics({ + required WritersideModule module, + required WritersideMarkdownTopicImportRequest request, + Future Function()? reloadModule, + Future Function()? validateBeforePublish, + }) async { + final loadModule = + reloadModule ?? () => moduleService.load(module.rootPath); + module = await loadModule(); + _requireCompleteTopicDiscovery(module); + final instance = module.instances + .where( + (candidate) => p.equals(candidate.sourceTreePath, request.treePath), + ) + .singleOrNull; + if (instance == null) { + throw const BusyMarkException('writerside.instance.not-found'); + } + if (request.placement == WritersideTopicCreatePlacement.child) { + throw const BusyMarkException('writerside.topic.reference-missing'); + } + + final rootAnchor = await _moduleAnchor(module.rootPath); + final prepared = await _prepareMarkdownImport( + rootAnchor: rootAnchor, + module: module, + sourceRootPath: request.sourceRootPath, + selectedMarkdownPaths: request.selectedMarkdownPaths, + copyReferencedMedia: request.copyReferencedMedia, + ); + final treePath = await _existingFilePath( + rootAnchor, + instance.sourceTreePath, + errorCode: 'writerside.instance.tree-missing', + ); + final treeSource = await File(treePath).readAsString(); + final updatedTree = insertWritersideTopicReferences( + treePath: treePath, + source: treeSource, + topicReferences: prepared.topicReferences, + placement: request.placement, + referenceTocPath: request.referenceTocPath, + referenceTocIdentity: request.referenceTocIdentity, + ); + final desired = { + ...prepared.files, + treePath: _DesiredFile.text(updatedTree), + }; + await _MutationTransaction( + anchor: rootAnchor, + desired: desired, + sourceSnapshots: prepared.sourceSnapshots, + beforePublish: () async { + await _beforePublish?.call(); + await _validateImportedTopicIds(await loadModule(), prepared.topicIds); + await validateBeforePublish?.call(); + }, + ).commit(); + return WritersideMarkdownTopicImportResult( + importedTopicPaths: prepared.topicPaths, + treePath: treePath, + firstTopicPath: prepared.firstTopicPath!, + ); + } + WritersideInstanceSettings _validatedSettings( WritersideInstanceSettings settings, ) { @@ -399,12 +526,15 @@ class WritersideInstanceService { } } - Future<_PreparedImport> _prepareImport( - CanonicalPathAnchor rootAnchor, - WritersideModule module, - WritersideInstanceCreateRequest request, - ) async { - final importRoot = request.importRootPath; + Future<_PreparedImport> _prepareMarkdownImport({ + required CanonicalPathAnchor rootAnchor, + required WritersideModule module, + required String? sourceRootPath, + required List selectedMarkdownPaths, + required bool copyReferencedMedia, + }) async { + _requireCompleteTopicDiscovery(module); + final importRoot = sourceRootPath; if (importRoot == null || importRoot.trim().isEmpty) { throw const BusyMarkException( 'writerside.instance.import-source-missing', @@ -422,8 +552,17 @@ class WritersideInstanceService { )).path; final files = {}; final topicReferences = []; + final topicPaths = []; + final topicIds = {}; + final selectedTopics = <_ResolvedImportTopic>[]; + final sourceSnapshots = {}; String? firstTopicPath; - for (final requestedPath in request.importedMarkdownPaths) { + if (selectedMarkdownPaths.isEmpty) { + throw const BusyMarkException( + 'writerside.instance.import-selection-required', + ); + } + for (final requestedPath in selectedMarkdownPaths) { final source = await _resolve( sourceAnchor, requestedPath, @@ -436,6 +575,22 @@ class WritersideInstanceService { args: {'path': source.path}, ); } + final sourceFileName = p.basename(source.path); + try { + validateWritersideTopicFileName(sourceFileName); + } on BusyMarkException catch (error) { + throw BusyMarkException( + error.code, + args: {...error.args, 'path': source.path}, + ); + } + final topicId = p.basenameWithoutExtension(sourceFileName); + if (!topicIds.add(topicId)) { + throw BusyMarkException( + 'writerside.topic.id-exists', + args: {'topicId': topicId, 'path': source.path}, + ); + } final relative = normalizedRelative(sourceAnchor.rootPath, source.path); final target = (await _resolve( rootAnchor, @@ -444,18 +599,37 @@ class WritersideInstanceService { allowMissingAncestors: true, )).path; await _ensureTargetMissing(target); - final bytes = await File(source.path).readAsBytes(); - files[target] = _DesiredFile( + if (module.reservedTopicIds.contains(topicId)) { + throw BusyMarkException( + 'writerside.topic.id-exists', + args: {'topicId': topicId, 'path': source.path}, + ); + } + selectedTopics.add( + _ResolvedImportTopic( + sourcePath: source.path, + relativePath: relative, + targetPath: target, + ), + ); + } + + for (final selected in selectedTopics) { + final sourceFile = File(selected.sourcePath); + final bytes = await sourceFile.readAsBytes(); + sourceSnapshots[selected.sourcePath] = Uint8List.fromList(bytes); + files[selected.targetPath] = _DesiredFile( bytes: Uint8List.fromList(bytes), - sourceMode: (await File(source.path).stat()).mode, + sourceMode: (await sourceFile.stat()).mode, ); - topicReferences.add(relative); - firstTopicPath ??= target; + topicReferences.add(selected.relativePath); + topicPaths.add(selected.targetPath); + firstTopicPath ??= selected.targetPath; - if (request.copyReferencedMedia) { + if (copyReferencedMedia) { final sourceText = utf8.decode(bytes); final parsed = markdownParser.parse( - filePath: source.path, + filePath: selected.sourcePath, source: sourceText, mode: MarkdownMode.writersideMarkdown, validateLocalReferences: false, @@ -463,7 +637,7 @@ class WritersideInstanceService { for (final mediaPath in _referencedMediaPaths(parsed)) { final media = await _resolveReferencedMedia( sourceAnchor, - source.path, + selected.sourcePath, mediaPath, ); if (media == null) { @@ -483,8 +657,10 @@ class WritersideInstanceService { continue; } await _ensureTargetMissing(mediaTarget); + final mediaBytes = await File(media.path).readAsBytes(); + sourceSnapshots[media.path] = Uint8List.fromList(mediaBytes); files[mediaTarget] = _DesiredFile( - bytes: Uint8List.fromList(await File(media.path).readAsBytes()), + bytes: Uint8List.fromList(mediaBytes), sourceMode: (await File(media.path).stat()).mode, ); } @@ -498,10 +674,34 @@ class WritersideInstanceService { return _PreparedImport( files: files, topicReferences: topicReferences, + topicPaths: topicPaths, + topicIds: topicIds, + sourceSnapshots: sourceSnapshots, firstTopicPath: firstTopicPath, ); } + void _requireCompleteTopicDiscovery(WritersideModule module) { + if (!module.topicDiscoveryComplete) { + throw const BusyMarkException('writerside.topic.discovery-incomplete'); + } + } + + Future _validateImportedTopicIds( + WritersideModule module, + Set topicIds, + ) async { + _requireCompleteTopicDiscovery(module); + for (final topicId in topicIds) { + if (module.reservedTopicIds.contains(topicId)) { + throw BusyMarkException( + 'writerside.topic.id-exists', + args: {'topicId': topicId}, + ); + } + } + } + Iterable _referencedMediaPaths( ParsedMarkdownDocument document, ) sync* { @@ -1166,19 +1366,40 @@ class _PreparedImport { const _PreparedImport({ required this.files, required this.topicReferences, + required this.topicPaths, + required this.topicIds, + required this.sourceSnapshots, required this.firstTopicPath, }); const _PreparedImport.empty() : files = const {}, topicReferences = const [], + topicPaths = const [], + topicIds = const {}, + sourceSnapshots = const {}, firstTopicPath = null; final Map files; final List topicReferences; + final List topicPaths; + final Set topicIds; + final Map sourceSnapshots; final String? firstTopicPath; } +class _ResolvedImportTopic { + const _ResolvedImportTopic({ + required this.sourcePath, + required this.relativePath, + required this.targetPath, + }); + + final String sourcePath; + final String relativePath; + final String targetPath; +} + class _DesiredFile { const _DesiredFile({required this.bytes, this.sourceMode}); @@ -1202,11 +1423,13 @@ class _MutationTransaction { const _MutationTransaction({ required this.anchor, required this.desired, + this.sourceSnapshots = const {}, this.beforePublish, }); final CanonicalPathAnchor anchor; final Map desired; + final Map sourceSnapshots; final Future Function()? beforePublish; Future commit() async { @@ -1251,6 +1474,16 @@ class _MutationTransaction { } await beforePublish?.call(); + for (final entry in sourceSnapshots.entries) { + final type = await FileSystemEntity.type(entry.key, followLinks: false); + if (type != FileSystemEntityType.file || + !_sameBytes(await File(entry.key).readAsBytes(), entry.value)) { + throw BusyMarkException( + 'writerside.instance.files-changed', + args: {'path': entry.key}, + ); + } + } for (final entry in originals.entries) { await _verifyOriginal(entry.key, entry.value); } diff --git a/lib/src/writerside/writerside_topic_creator.dart b/lib/src/writerside/writerside_topic_creator.dart index 3de55994..83fab6e3 100644 --- a/lib/src/writerside/writerside_topic_creator.dart +++ b/lib/src/writerside/writerside_topic_creator.dart @@ -11,6 +11,162 @@ import 'writerside_topic_file_name.dart'; enum WritersideTopicCreatePlacement { root, sibling, child } +/// Applies one guarded TOC placement to an already-loaded tree document. +/// +/// This helper deliberately performs no I/O. Callers retain ownership of the +/// tree snapshot and publish the returned source with their existing atomic +/// mutation boundary. +String insertWritersideTopicReferences({ + required String treePath, + required String source, + required List topicReferences, + required WritersideTopicCreatePlacement placement, + String? referenceTopic, + List? referenceTocPath, + WritersideTocNodeIdentity? referenceTocIdentity, +}) { + final document = XmlDocument.parse(source); + final root = document.rootElement; + if (root.name.local != 'instance-profile') { + throw FormatException('.tree root must be .', treePath); + } + if (topicReferences.isEmpty) { + return source; + } + initializeWritersideFirstTopicHomePage(root, topicReferences.first); + final elements = [ + for (final topicReference in topicReferences) + XmlElement(XmlName.parts('toc-element'), [ + XmlAttribute(XmlName.parts('topic'), topicReference), + ]), + ]; + if (placement == WritersideTopicCreatePlacement.root) { + root.children.addAll(elements); + return _writersideTreeXml(document); + } + + if (referenceTocPath != null) { + final reference = _writersideTocElementAtPath(root, referenceTocPath); + if (reference == null || + !(referenceTocIdentity?.matches(reference) ?? true)) { + throw BusyMarkException( + 'writerside.topic.reference-missing', + args: {'topic': referenceTocPath.join('/')}, + ); + } + switch (placement) { + case WritersideTopicCreatePlacement.child: + reference.children.addAll(elements); + case WritersideTopicCreatePlacement.sibling: + _insertWritersideElementsAfter(reference, elements); + case WritersideTopicCreatePlacement.root: + break; + } + return _writersideTreeXml(document); + } + + final topic = referenceTopic?.trim(); + if (topic == null || topic.isEmpty) { + throw const BusyMarkException( + 'writerside.topic.reference-missing', + args: {'topic': ''}, + ); + } + final inserted = switch (placement) { + WritersideTopicCreatePlacement.child => _appendWritersideTopics( + root, + topic, + elements, + ), + WritersideTopicCreatePlacement.sibling => _insertWritersideTopicsAfter( + root, + topic, + elements, + ), + WritersideTopicCreatePlacement.root => true, + }; + if (!inserted) { + throw BusyMarkException( + 'writerside.topic.reference-missing', + args: {'topic': topic}, + ); + } + return _writersideTreeXml(document); +} + +XmlElement? _writersideTocElementAtPath(XmlElement root, List path) { + if (path.isEmpty || path.any((index) => index < 0)) return null; + var parent = root; + for (final index in path) { + final children = parent.childElements + .where((element) => element.name.local == 'toc-element') + .toList(); + if (index >= children.length) return null; + parent = children[index]; + } + return parent; +} + +void _insertWritersideElementsAfter( + XmlElement reference, + List elements, +) { + final parent = reference.parent; + if (parent is! XmlElement) { + throw const BusyMarkException( + 'writerside.topic.reference-missing', + args: {'topic': ''}, + ); + } + final index = parent.children.indexOf(reference); + if (index < 0) { + throw const BusyMarkException( + 'writerside.topic.reference-missing', + args: {'topic': ''}, + ); + } + parent.children.insertAll(index + 1, elements); +} + +bool _appendWritersideTopics( + XmlElement current, + String referenceTopic, + List elements, +) { + for (final child in current.childElements) { + if (child.name.local == 'toc-element' && + child.getAttribute('topic') == referenceTopic) { + child.children.addAll(elements); + return true; + } + if (_appendWritersideTopics(child, referenceTopic, elements)) return true; + } + return false; +} + +bool _insertWritersideTopicsAfter( + XmlElement current, + String referenceTopic, + List elements, +) { + for (var index = 0; index < current.children.length; index++) { + final child = current.children[index]; + if (child is! XmlElement) continue; + if (child.name.local == 'toc-element' && + child.getAttribute('topic') == referenceTopic) { + current.children.insertAll(index + 1, elements); + return true; + } + if (_insertWritersideTopicsAfter(child, referenceTopic, elements)) { + return true; + } + } + return false; +} + +String _writersideTreeXml(XmlDocument document) => + '${document.toXmlString(pretty: true, indent: ' ')}\n'; + /// Shared by creation and linking, before inserting the first topic into a /// guarded tree snapshot. Empty groups do not count as existing topics. void initializeWritersideFirstTopicHomePage( @@ -194,6 +350,16 @@ class WritersideTopicCreateResult { final String topicFileName; } +class WritersideTopicIdentityValidation { + const WritersideTopicIdentityValidation({ + required this.topicId, + required this.candidateTopicPath, + }); + + final String topicId; + final String candidateTopicPath; +} + class WritersideTopicCreator { const WritersideTopicCreator({ Future Function(String treePath)? beforeTreePublish, @@ -212,6 +378,8 @@ class WritersideTopicCreator { WritersideTopicCreateRequest request, { String? initialSource, Future Function()? validateBeforePublish, + Future Function(WritersideTopicIdentityValidation validation)? + validateTopicIdentityBeforePublish, }) async { if (!target.topicDiscoveryComplete) { throw const BusyMarkException('writerside.topic.discovery-incomplete'); @@ -310,6 +478,15 @@ class WritersideTopicCreator { updatedTree, expectedCurrentSource: treeSource, validateBeforePublish: validateBeforePublish, + validateTopicIdentityBeforePublish: + validateTopicIdentityBeforePublish == null + ? null + : () => validateTopicIdentityBeforePublish( + WritersideTopicIdentityValidation( + topicId: topicId, + candidateTopicPath: topicPath, + ), + ), ); } on Object { if (topicCreated) { @@ -408,149 +585,15 @@ class WritersideTopicCreator { required String topicFileName, required WritersideTopicCreateRequest request, }) { - final document = XmlDocument.parse(source); - final root = document.rootElement; - if (root.name.local != 'instance-profile') { - throw FormatException('.tree root must be .', treePath); - } - final element = XmlElement(XmlName.parts('toc-element'), [ - XmlAttribute(XmlName.parts('topic'), topicFileName), - ]); - initializeWritersideFirstTopicHomePage(root, topicFileName); - if (request.placement == WritersideTopicCreatePlacement.root) { - root.children.add(element); - return _treeXml(document); - } - - final referencePath = request.referenceTocPath; - if (referencePath != null) { - final referenceElement = _tocElementAtPath(root, referencePath); - if (referenceElement == null || - !(request.referenceTocIdentity?.matches(referenceElement) ?? true)) { - throw BusyMarkException( - 'writerside.topic.reference-missing', - args: {'topic': _tocPathLabel(referencePath)}, - ); - } - switch (request.placement) { - case WritersideTopicCreatePlacement.child: - referenceElement.children.add(element); - case WritersideTopicCreatePlacement.sibling: - _insertAfterElement(referenceElement, element); - case WritersideTopicCreatePlacement.root: - break; - } - return _treeXml(document); - } - - final referenceTopic = request.referenceTopic?.trim(); - if (referenceTopic == null || referenceTopic.isEmpty) { - throw const BusyMarkException( - 'writerside.topic.reference-missing', - args: {'topic': ''}, - ); - } - final inserted = switch (request.placement) { - WritersideTopicCreatePlacement.child => _appendToTopic( - root, - referenceTopic, - element, - ), - WritersideTopicCreatePlacement.sibling => _insertAfterTopic( - root, - referenceTopic, - element, - ), - WritersideTopicCreatePlacement.root => true, - }; - if (!inserted) { - throw BusyMarkException( - 'writerside.topic.reference-missing', - args: {'topic': referenceTopic}, - ); - } - return _treeXml(document); - } - - XmlElement? _tocElementAtPath(XmlElement root, List path) { - if (path.isEmpty || path.any((index) => index < 0)) { - return null; - } - var parent = root; - for (var depth = 0; depth < path.length; depth += 1) { - final children = parent.childElements - .where((element) => element.name.local == 'toc-element') - .toList(); - final index = path[depth]; - if (index >= children.length) { - return null; - } - parent = children[index]; - } - return parent; - } - - void _insertAfterElement(XmlElement reference, XmlElement element) { - final parent = reference.parent; - if (parent is! XmlElement) { - throw const BusyMarkException( - 'writerside.topic.reference-missing', - args: {'topic': ''}, - ); - } - final index = parent.children.indexOf(reference); - if (index < 0) { - throw const BusyMarkException( - 'writerside.topic.reference-missing', - args: {'topic': ''}, - ); - } - parent.children.insert(index + 1, element); - } - - String _tocPathLabel(List path) => path.join('/'); - - bool _appendToTopic( - XmlElement current, - String referenceTopic, - XmlElement element, - ) { - for (final child in current.childElements) { - if (child.name.local == 'toc-element' && - child.getAttribute('topic') == referenceTopic) { - child.children.add(element); - return true; - } - if (_appendToTopic(child, referenceTopic, element)) { - return true; - } - } - return false; - } - - bool _insertAfterTopic( - XmlElement current, - String referenceTopic, - XmlElement element, - ) { - for (var index = 0; index < current.children.length; index++) { - final child = current.children[index]; - if (child is XmlElement) { - if (child.name.local == 'toc-element' && - child.getAttribute('topic') == referenceTopic) { - current.children.insert(index + 1, element); - return true; - } - if (_insertAfterTopic(child, referenceTopic, element)) { - return true; - } - } - } - return false; - } - - String _treeXml(XmlDocument document) { - return '${document.toXmlString(pretty: true, indent: ' ')}\n'; + return insertWritersideTopicReferences( + treePath: treePath, + source: source, + topicReferences: [topicFileName], + placement: request.placement, + referenceTopic: request.referenceTopic, + referenceTocPath: request.referenceTocPath, + referenceTocIdentity: request.referenceTocIdentity, + ); } Future _writeNewFile( @@ -592,6 +635,7 @@ class WritersideTopicCreator { String source, { required String expectedCurrentSource, Future Function()? validateBeforePublish, + Future Function()? validateTopicIdentityBeforePublish, }) async { final tree = await _treePath(anchor, path); await _ensureTreeUnchanged(tree, expectedCurrentSource); @@ -601,6 +645,7 @@ class WritersideTopicCreator { await temporary.writeAsString(source, flush: true); await _copyFileMode(targetStat, temporary); await _beforeTreePublish?.call(tree.path); + await validateTopicIdentityBeforePublish?.call(); await validateBeforePublish?.call(); final publishTarget = await _treePath(anchor, path); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 5373004f..11d0b946 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -2267,6 +2267,8 @@ void main() { await tester.pumpAndSettle(); await openPopup(find.byTooltip(l10n.newTopic)); + expect(find.text(l10n.addLocalMarkdownFiles), findsOneWidget); + expect(find.text(l10n.tocLinkTopicFiles), findsOneWidget); await tester.tap(find.text(l10n.tocEmptyMdTopic)); await tester.pumpAndSettle(); @@ -2286,10 +2288,10 @@ void main() { expect(controller.createdTopicRequest, isNotNull); expect( controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.sibling, + WritersideTopicCreatePlacement.root, ); - expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); - expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); + expect(controller.createdTopicRequest!.referenceTocPath, isNull); + expect(controller.createdTopicRequest!.referenceTopic, isNull); expect(controller.createdTopicTreePath, p.join(root.path, 'guide.tree')); await openPopup(instanceSelector); diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 842c982a..fb01f824 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -20,6 +20,7 @@ import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; +import 'package:busymark/src/writerside/writerside_instance_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; @@ -188,6 +189,103 @@ void main() { }, ); + test( + 'Markdown topic import opens its first topic in an editable buffer', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-controller-topic-import-', + ); + final source = await Directory.systemTemp.createTemp( + 'busymark-controller-topic-source-', + ); + addTearDown(() => root.delete(recursive: true)); + addTearDown(() => source.delete(recursive: true)); + Directory(p.join(root.path, 'topics')).createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + final tree = File(p.join(root.path, 'g.tree')) + ..writeAsStringSync( + '' + '', + ); + final original = File(p.join(root.path, 'topics', 'a.md')) + ..writeAsStringSync('# Original\n'); + final imported = File(p.join(source.path, 'imported.md')) + ..writeAsStringSync('# Imported\n'); + final harness = await _createControllerHarness(); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(original.path); + + final succeeded = await controller.addWritersideMarkdownTopics( + WritersideMarkdownTopicImportRequest( + sourceRootPath: source.path, + selectedMarkdownPaths: [imported.path], + treePath: tree.path, + placement: WritersideTopicCreatePlacement.root, + ), + ); + + final target = p.join(root.path, 'topics', 'imported.md'); + expect(succeeded, true); + expect(controller.state.workspace?.activeFilePath, target); + expect(controller.state.workspace?.openFilePaths, contains(target)); + expect(controller.state.activeBuffer?.filePath, target); + expect( + controller.state.activeBuffer?.editorState.mode, + isNot(DocumentViewModePreference.preview), + ); + }, + ); + + test( + 'failed Markdown import preserves the active document and workspace', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-controller-topic-import-fail-', + ); + final source = await Directory.systemTemp.createTemp( + 'busymark-controller-topic-source-', + ); + addTearDown(() => root.delete(recursive: true)); + addTearDown(() => source.delete(recursive: true)); + Directory(p.join(root.path, 'topics')).createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + final tree = File(p.join(root.path, 'g.tree')) + ..writeAsStringSync( + '' + '', + ); + final original = File(p.join(root.path, 'topics', 'a.md')) + ..writeAsStringSync('# Original\n'); + final conflict = File(p.join(source.path, 'a.md')) + ..writeAsStringSync('# Conflict\n'); + final harness = await _createControllerHarness(); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(original.path); + final originalTree = tree.readAsStringSync(); + + final succeeded = await controller.addWritersideMarkdownTopics( + WritersideMarkdownTopicImportRequest( + sourceRootPath: source.path, + selectedMarkdownPaths: [conflict.path], + treePath: tree.path, + placement: WritersideTopicCreatePlacement.root, + ), + ); + + expect(succeeded, false); + expect(controller.state.workspace?.activeFilePath, original.path); + expect(controller.state.activeBuffer?.filePath, original.path); + expect(original.readAsStringSync(), '# Original\n'); + expect(tree.readAsStringSync(), originalTree); + }, + ); + test('Validate publishes a valid table containing an escaped pipe', () async { final root = await Directory.systemTemp.createTemp( 'busymark-table-validation-', diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index b1365adf..c6b7aac5 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -7,6 +7,7 @@ import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_instance_service.dart'; import 'package:busymark/src/writerside/writerside_parsers.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; @@ -294,6 +295,162 @@ void main() { }, ); + test( + 'imports Markdown topics into the selected instance and opens the first', + () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-workspace-import-topics-', + ); + final source = await Directory.systemTemp.createTemp( + 'busymark-workspace-import-source-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + addTearDown(() => source.deleteSync(recursive: true)); + final workspace = await service.createWritersideProject( + WritersideProjectCreateRequest( + parentDirectoryPath: parent.path, + projectName: 'Docs', + directoryName: 'docs', + instanceName: 'User Guide', + topicTitle: 'Getting started', + ), + ); + final imported = File(p.join(source.path, 'imported.md')) + ..writeAsStringSync('# Imported\n\nExact source.\n'); + final treePath = + workspace.writersideModule!.instances.single.sourceTreePath; + + final updated = await service.addWritersideMarkdownTopics( + workspace, + WritersideMarkdownTopicImportRequest( + sourceRootPath: source.path, + selectedMarkdownPaths: [imported.path], + treePath: treePath, + placement: WritersideTopicCreatePlacement.root, + ), + ); + + final target = p.join(updated.rootPath, 'topics', 'imported.md'); + expect(updated.activeFilePath, target); + expect(updated.openFilePaths, contains(target)); + expect(updated.markdown?.title, 'Imported'); + expect( + updated.writersideModule!.topicByReference('imported.md'), + isNotNull, + ); + expect(File(target).readAsStringSync(), imported.readAsStringSync()); + expect( + File(treePath).readAsStringSync(), + contains('topic="imported.md"'), + ); + }, + ); + + test('Markdown import reloads stale UI inventory before planning', () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-workspace-import-stale-', + ); + final source = await Directory.systemTemp.createTemp( + 'busymark-workspace-import-source-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + addTearDown(() => source.deleteSync(recursive: true)); + final workspace = await service.createWritersideProject( + WritersideProjectCreateRequest( + parentDirectoryPath: parent.path, + projectName: 'Docs', + directoryName: 'docs', + instanceName: 'User Guide', + topicTitle: 'Getting started', + ), + ); + final imported = File(p.join(source.path, 'guide.md')) + ..writeAsStringSync('# Guide\n'); + File( + p.join(workspace.rootPath, 'topics', 'guide.topic'), + ).writeAsStringSync('\n'); + + await expectLater( + service.addWritersideMarkdownTopics( + workspace, + WritersideMarkdownTopicImportRequest( + sourceRootPath: source.path, + selectedMarkdownPaths: [imported.path], + treePath: workspace.writersideModule!.instances.single.sourceTreePath, + placement: WritersideTopicCreatePlacement.root, + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect( + File(p.join(workspace.rootPath, 'topics', 'guide.md')).existsSync(), + false, + ); + }); + + test( + 'topic creation revalidates module IDs after its candidate is written', + () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-workspace-topic-id-race-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + const baseline = WorkspaceService(); + final workspace = await baseline.createWritersideProject( + WritersideProjectCreateRequest( + parentDirectoryPath: parent.path, + projectName: 'Docs', + directoryName: 'docs', + instanceName: 'User Guide', + topicTitle: 'Getting started', + ), + ); + final concurrent = File( + p.join(workspace.rootPath, 'topics', 'details.topic'), + ); + final racingService = WorkspaceService( + writersideTopicCreator: WritersideTopicCreator( + beforeTreePublish: (_) async => concurrent.writeAsString( + '\n', + ), + ), + ); + final tree = File( + workspace.writersideModule!.instances.single.sourceTreePath, + ); + final originalTree = tree.readAsStringSync(); + + await expectLater( + racingService.createWritersideTopic( + workspace, + const WritersideTopicCreateRequest( + title: 'Details', + fileName: 'details.md', + ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.id-exists', + ), + ), + ); + expect(concurrent.existsSync(), true); + expect( + File(p.join(workspace.rootPath, 'topics', 'details.md')).existsSync(), + false, + ); + expect(tree.readAsStringSync(), originalTree); + }, + ); + test( 'creates a topic in the explicitly selected instance and TOC node', () async { diff --git a/test/src/writerside_instance_service_test.dart b/test/src/writerside_instance_service_test.dart index 02bb55a6..89e8b812 100644 --- a/test/src/writerside_instance_service_test.dart +++ b/test/src/writerside_instance_service_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/writerside/writerside_instance_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; @@ -101,7 +102,7 @@ void main() { Directory( p.join(source.path, 'guide', 'images'), ).createSync(recursive: true); - final first = File(p.join(source.path, 'guide', 'intro.md')) + final first = File(p.join(source.path, 'guide', 'imported-intro.md')) ..writeAsStringSync('''# Imported intro ![Logo](images/logo.png) @@ -124,7 +125,7 @@ void main() { final candidates = await instanceService.discoverMarkdownFiles(source.path); expect(candidates.map((candidate) => candidate.relativePath), [ - 'guide/intro.md', + 'guide/imported-intro.md', 'guide/other.md', ]); expect(candidates.first.title, 'Imported intro'); @@ -143,7 +144,7 @@ void main() { expect( result.firstTopicPath, - p.join(root.path, 'topics', 'guide', 'intro.md'), + p.join(root.path, 'topics', 'guide', 'imported-intro.md'), ); expect( File(result.firstTopicPath!).readAsStringSync(), @@ -174,10 +175,13 @@ void main() { final tree = XmlDocument.parse( File(p.join(root.path, 'imported.tree')).readAsStringSync(), ); - expect(tree.rootElement.getAttribute('start-page'), 'guide/intro.md'); + expect( + tree.rootElement.getAttribute('start-page'), + 'guide/imported-intro.md', + ); expect( tree.rootElement.childElements.single.getAttribute('topic'), - 'guide/intro.md', + 'guide/imported-intro.md', ); }); @@ -393,6 +397,670 @@ Text {instance="!guide,other"} expect(config.readAsStringSync(), contains('concurrent')); }, ); + + group('existing-instance Markdown topic import', () { + test( + 'copies selected topics, media, and inserts root entries in order', + () async { + final root = await _project(); + final source = await Directory.systemTemp.createTemp( + 'busymark-topic-import-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + addTearDown(() => source.deleteSync(recursive: true)); + Directory( + p.join(source.path, 'nested', 'media'), + ).createSync(recursive: true); + final one = File(p.join(source.path, 'one.md')) + ..writeAsStringSync('# One\n\n![shared](nested/media/shared.png)\n'); + File(p.join(source.path, 'skip.md')).writeAsStringSync('# Skip\n'); + final two = File(p.join(source.path, 'nested', 'two.md')) + ..writeAsStringSync('''# Two + +