From b578c3171dfcb443a7f0bc65612eb29296f7ff9d Mon Sep 17 00:00:00 2001 From: chernukhinav Date: Wed, 14 Oct 2026 12:25:15 +0300 Subject: [PATCH 1/6] feat: replace collections with tags and saved searches --- CONTEXT.md | 28 + README.md | 11 +- docs/README.en.md | 12 +- docs/adr/0004-tags-and-saved-searches.md | 5 + docs/architecture.md | 4 +- docs/tag-model-design.md | 70 ++ docs/user-stories.md | 68 +- .../MainPage.TagDialogs.cs | 240 ++++ .../MainPage.TagWorkspace.cs | 268 +++++ .../MainPage.xaml | 38 +- .../MainPage.xaml.cs | 1043 +++-------------- .../MainWindow.xaml.cs | 9 +- .../Abstractions/ICollectionRepository.cs | 12 - .../ICollectionViewPreferenceStore.cs | 10 - .../Abstractions/IResourceRepository.cs | 11 - .../IResourceViewPreferenceStore.cs | 10 + .../Abstractions/IWorkspaceRepository.cs | 16 + .../Collections/SystemCollections.cs | 8 - .../Models/CollectionRecord.cs | 11 - .../Models/ResourceRecord.cs | 13 - .../Models/TagRecord.cs | 24 + .../Preferences/CollectionViewMode.cs | 24 - .../Preferences/ResourceViewMode.cs | 24 + .../Resources/ResourceLaunchService.cs | 1 - .../Resources/ResourceTypeMapper.cs | 10 - .../Resources/SystemTags.cs | 45 + .../Resources/ResourceType.cs | 1 - .../DependencyInjection.cs | 7 +- .../Persistence/SqliteCollectionRepository.cs | 181 --- .../SqliteCollectionViewPreferenceStore.cs | 13 - .../Persistence/SqliteMigrationRunner.cs | 328 ------ .../Persistence/SqliteResourceRepository.cs | 80 -- .../SqliteResourceViewPreferenceStore.cs | 13 + .../Persistence/SqliteWorkspaceInitializer.cs | 130 ++ .../Persistence/SqliteWorkspaceRepository.cs | 244 ++++ .../Visuals/WindowsResourceVisualService.cs | 2 - .../CollectionViewModePreferenceTests.cs | 32 - .../ResourceViewModePreferenceTests.cs | 32 + .../Resources/ResourceLaunchServiceTests.cs | 1 - .../Resources/SystemTagsTests.cs | 27 + .../SqliteCollectionRepositoryTests.cs | 124 -- .../Persistence/SqliteMigrationTests.cs | 207 ---- .../SqliteResourceRepositoryTests.cs | 139 --- .../Persistence/SqliteWorkspaceTests.cs | 160 +++ 44 files changed, 1566 insertions(+), 2170 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0004-tags-and-saved-searches.md create mode 100644 docs/tag-model-design.md create mode 100644 src/PersonalResourceWorkspace.App/MainPage.TagDialogs.cs create mode 100644 src/PersonalResourceWorkspace.App/MainPage.TagWorkspace.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/ICollectionRepository.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/ICollectionViewPreferenceStore.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/IResourceRepository.cs create mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/IResourceViewPreferenceStore.cs create mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/IWorkspaceRepository.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Collections/SystemCollections.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Models/CollectionRecord.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Models/ResourceRecord.cs create mode 100644 src/PersonalResourceWorkspace.Application/Models/TagRecord.cs delete mode 100644 src/PersonalResourceWorkspace.Application/Preferences/CollectionViewMode.cs create mode 100644 src/PersonalResourceWorkspace.Application/Preferences/ResourceViewMode.cs create mode 100644 src/PersonalResourceWorkspace.Application/Resources/SystemTags.cs delete mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionRepository.cs delete mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionViewPreferenceStore.cs delete mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteMigrationRunner.cs delete mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteResourceRepository.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteResourceViewPreferenceStore.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteWorkspaceInitializer.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteWorkspaceRepository.cs delete mode 100644 tests/PersonalResourceWorkspace.Application.Tests/Preferences/CollectionViewModePreferenceTests.cs create mode 100644 tests/PersonalResourceWorkspace.Application.Tests/Preferences/ResourceViewModePreferenceTests.cs create mode 100644 tests/PersonalResourceWorkspace.Application.Tests/Resources/SystemTagsTests.cs delete mode 100644 tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteCollectionRepositoryTests.cs delete mode 100644 tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteMigrationTests.cs delete mode 100644 tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteResourceRepositoryTests.cs create mode 100644 tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteWorkspaceTests.cs diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..cebcdc9 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,28 @@ +# Personal Resource Workspace + +Рабочее пространство ресурсов, которые пользователь описывает тэгами и находит через закреплённую навигацию. Ниже зафиксирован язык модели приложения. + +## Язык + +**Ресурс**: +Сохранённый объект рабочего пространства, например ссылка, файл или заметка. + +**Тэг**: +Именованная характеристика ресурса. Один ресурс может иметь несколько тэгов; в первой версии тэг не имеет визуальных атрибутов. +_Избегать_: коллекция, когда имеется в виду характеристика ресурса. + +**Предопределённый тэг**: +Тэг «Корзина» или «Входящие», который существует в рабочем пространстве и не может быть удалён пользователем. + +**Входящие**: +Предопределённый тэг для ресурса без пользовательских тэгов при создании. При назначении первого пользовательского тэга снимается автоматически, но может быть назначен вручную. + +**Корзина**: +Предопределённый тэг для удалённого ресурса. Остальные тэги ресурса сохраняются до восстановления или окончательного удаления. + +**Сохранённый поиск**: +Именованный набор обязательных тэгов для поиска ресурсов, представленный узлом боковой панели со своим цветом и иконкой. Узлы организованы в дерево только для отображения; их расположение не влияет на результаты поиска и связи ресурсов с тэгами. +_Избегать_: коллекция, закреплённый тэг, когда имеется в виду узел навигации. + +**Обзор «Все ресурсы»**: +Доступный без сохранения поиск без обязательных тэгов, показывающий ресурсы вне Корзины. diff --git a/README.md b/README.md index 7547389..128eb51 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,30 @@ [English](docs/README.en.md) · [Сценарии пользователя](docs/user-stories.md) · [Запуск из исходников](docs/development.md) · [Устройство проекта](docs/architecture.md) -![Personal Resource Workspace — общий вид приложения](assets/personal-resource-workspace.png) - ## Что уже работает - **Всё под рукой:** добавляйте веб-ссылки, файлы, папки, приложения, текстовые и графические фрагменты, команды. Ресурсы сохраняются локально и доступны после перезапуска. - **Узнаваемые карточки:** смотрите ресурсы в сетке с обложками, значками и превью либо переключайтесь на компактный список. -- **Удобная организация:** создавайте коллекции, задавайте им цвет и значок, отмечайте важное как избранное. Раздел «Все ресурсы» показывает содержимое целиком. +- **Тэги и сохранённые поиски:** назначайте ресурсу несколько тэгов, объединяйте их условием «И» и сохраняйте именованные поиски в дереве боковой панели. «Все ресурсы» показывает активные ресурсы без дополнительных условий. - **Быстрый доступ:** ищите по каталогу и сортируйте по названию или дате изменения. Открывайте ресурсы из карточки и редактируйте их через контекстное меню. - **Простое добавление:** перетаскивайте файлы, папки и веб-ссылки в окно. При создании ресурса приложение может подставить содержимое буфера обмена. -- **Корзина:** случайно удалённый ресурс сначала попадает в корзину; её можно очистить отдельно. +- **Входящие и корзина:** новые ресурсы без пользовательских тэгов попадают во «Входящие»; удаление добавляет тэг «Корзина» с сохранением остальных тэгов. Ресурс можно восстановить или удалить окончательно. - **Portable-данные:** новые сборки хранят базу и визуальные файлы в `content` рядом с приложением. В «Настройках» можно выбрать другой каталог; вид и сортировка сохраняются в БД. -Приложение находится в активной разработке. Скриншот показывает один из вариантов наполнения личного пространства; ваши данные и карточки будут другими. +Приложение находится в активной разработке. ## Попробовать Скачайте [предварительную версию v0.1.1 для Windows x64](https://github.com/web2bear/PersonalResourceWorkspace/releases/tag/v0.1.1): распакуйте ZIP целиком и запустите `PersonalResourceWorkspace.App.exe`. Это переносимая версия без установщика и цифровой подписи. При желании проект можно [собрать из исходников](docs/development.md). Версия v0.1.1 выпущена до реализации portable-хранения; описанное выше расположение `content` относится к текущему исходному коду и будущим сборкам. +При первом запуске новой схемы старая база коллекций сохраняется в `Data/workspace.collections-backup-*.db`, после чего создаётся пустое рабочее пространство. Данные старых коллекций автоматически не переносятся. ## Идеи на будущее Это направления развития, **не реализованные функции и не обещание сроков**: -- вложенные коллекции и более гибкое перемещение ресурсов между ними; +- дополнительные визуальные атрибуты тэгов и выбор стартового поиска; - ручной порядок карточек и дополнительные способы навигации; - быстрый вызов пространства глобальной горячей клавишей; - более удобная работа с текстовыми и графическими фрагментами; diff --git a/docs/README.en.md b/docs/README.en.md index 2885c45..b88dba8 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -4,28 +4,28 @@ [Русский](../README.md) · [Build and run](development.md) · [Architecture](architecture.md) -![Personal Resource Workspace app overview](../assets/personal-resource-workspace.png) - ## What works today - **One place for your resources:** save web links, files, folders, applications, text and image snippets, and commands. Your catalog persists locally across restarts. - **Visual browsing:** recognize cards by their covers, icons, and previews, or switch to a compact list. -- **Collections and favorites:** create collections, choose their color and icon, mark resources as favorites, and see everything in one view. +- **Tags and saved searches:** assign several tags to a resource, combine them with AND, and organize named searches in the sidebar tree. The All Resources view shows active resources without extra conditions. - **Find and open quickly:** search the catalog, sort by title or modification date, open resources from their cards, and edit them through a context menu. - **Easy capture:** drop files, folders, or web links into the window. Creating a resource can also prefill it from the clipboard. -- **Trash:** deleted resources first go to Trash, which you can empty separately. +- **Inbox and Trash:** untagged new resources enter Inbox. Deletion adds the Trash tag while preserving other tags; resources can be restored or permanently removed. -The app is under active development. The image shows one example workspace; your content will differ. +The app is under active development. ## Try it Download the [v0.1.1 prerelease for Windows x64](https://github.com/web2bear/PersonalResourceWorkspace/releases/tag/v0.1.1), extract the entire ZIP, and run `PersonalResourceWorkspace.App.exe`. This is a portable build without an installer or digital signature. You can also [build from source](development.md). +The release predates this source implementation. On first launch with the new schema, the previous collections database is backed up as `Data/workspace.collections-backup-*.db`, then a new empty workspace is created. Existing collection entries are not imported. + ## Ideas for the future These are **possibilities, not shipped features or delivery commitments**: -- nested collections and easier movement between them; +- optional visual attributes for tags and a configurable startup search; - manual card ordering and more navigation options; - a global keyboard shortcut to bring the workspace forward; - richer workflows for text and image snippets; diff --git a/docs/adr/0004-tags-and-saved-searches.md b/docs/adr/0004-tags-and-saved-searches.md new file mode 100644 index 0000000..9a1a0d0 --- /dev/null +++ b/docs/adr/0004-tags-and-saved-searches.md @@ -0,0 +1,5 @@ +# Разделить тэги ресурсов и сохранённые поиски панели + +Статус: принято, реализуется в текущей ветке. + +Решено заменить единственную коллекцию ресурса множеством тэгов, а боковую панель строить из самостоятельных именованных сохранённых поисков. Поиск хранит набор обязательных тэгов, собственное имя и оформление. Дерево панели задаёт только визуальную организацию, поэтому перемещение узла не меняет ни тэги ресурсов, ни результаты других поисков. Такой выбор позволяет повторно использовать тэг в разных сочетаниях и не связывает модель ресурсов с расположением навигации. Согласованные правила поведения описаны в [проекте модели](../tag-model-design.md). diff --git a/docs/architecture.md b/docs/architecture.md index ae73e20..98b9cf1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,10 +9,12 @@ | `src/PersonalResourceWorkspace.App` | WinUI UI, startup, composition root | | `src/PersonalResourceWorkspace.Domain` | Domain boundary | | `src/PersonalResourceWorkspace.Application` | Use cases and ports | -| `src/PersonalResourceWorkspace.Infrastructure` | SQLite, files, migrations, search | +| `src/PersonalResourceWorkspace.Infrastructure` | SQLite, files, workspace initialization and backup | | `src/PersonalResourceWorkspace.Windows` | Windows shell, clipboard, activation, visuals | | `tests` | Unit and isolated SQLite integration tests | По умолчанию данные находятся в `content` рядом с исполняемым файлом: БД — `Data\workspace.db`, пользовательские изображения — `Data\Visuals\user`, автоматически полученные превью — `Data\Visuals\cache`. Все пользовательские настройки, кроме пути к каталогу данных, хранятся в таблице `Settings` БД. Исторический каталог `%LOCALAPPDATA%\PersonalResourceWorkspace` копируется при первом запуске с пустым portable-каталогом. / By default, app data lives in `content` beside the executable. The database is `Data\workspace.db`; user images and generated previews are under `Data\Visuals`. User preferences live in SQLite. A legacy profile is copied on first launch into an empty portable data root. +Новая схема хранит ресурсы, тэги, связи ресурса с тэгами, сохранённые поиски и их обязательные тэги в отдельных таблицах с внешними ключами. При обнаружении старой схемы создаётся проверенная резервная копия `workspace.collections-backup-*.db`, затем новая пустая база. Переноса записей коллекций нет. / The new schema separates resources, tags, resource tags, saved searches, and required search tags. A legacy database is backed up before a fresh empty database is created; collection rows are not imported. + Подробнее / Further reading: [project specification](../PROJECT.md), [portable storage decision](adr/0003-portable-data-and-settings.md), [architecture review](../architecture-review.md). diff --git a/docs/tag-model-design.md b/docs/tag-model-design.md new file mode 100644 index 0000000..1a250c4 --- /dev/null +++ b/docs/tag-model-design.md @@ -0,0 +1,70 @@ +# Переход от коллекций к тэгам: решения обсуждения + +Статус: согласованная модель, реализуемая в текущей ветке. Поведение приложения проверяется отдельно от этого документа. + +## Принятые решения + +- Ресурс может иметь несколько тэгов. В первой версии тэг не имеет цвета и иконки; визуальные атрибуты принадлежат только сохранённому поиску. Цвет тэга может быть рассмотрен в будущей версии. +- Боковая панель показывает дерево именованных сохранённых поисков, а не иерархию тэгов. Каждый узел содержит собственное имя и набор условий. Поиск по одному тэгу — частный случай. +- Сохранённый поиск содержит набор обязательных тэгов, объединённых условием «И». Сохранённая текстовая строка и другие фильтры в первую версию не входят. +- Новый пользовательский поиск создаётся только кнопкой «Сохранить поиск» рядом с полем поиска. Действия «закрепить тэг» в карточке нет. +- Кнопка «Сохранить поиск» создаёт новый узел из текущих условий. Временные условия не изменяют выбранный поиск; его условия меняются только через отдельное «Редактировать поиск». +- При сохранении объединяются тэги выбранного поиска и временные тэги; текстовый запрос не включается. Перед подтверждением показывается итоговый набор и пояснение, что сохраняются только тэги. +- При переходе к другому узлу панели временные тэги и текстовый запрос очищаются. +- Временные тэги показываются отдельными удаляемыми чипами рядом с текстовым вводом; обязательные тэги выбранного поиска показываются отдельно как постоянная основа результата. +- В редакторе ресурса ввод имени показывает существующие тэги; Enter создаёт отсутствующий тэг и назначает его ресурсу. Сравнение имён игнорирует регистр и пробелы по краям. +- При создании ресурса из пользовательского поиска редактор предварительно назначает тэги этого поиска и временные тэги из поля поиска. Из обязательного поиска «Входящие» назначается «Входящие»; из «Корзины» создание отключено. +- Если условия поиска содержат «Входящие» вместе с пользовательскими тэгами, новый ресурс получает их все: явные условия поиска имеют приоритет над автоматическим снятием «Входящих». Создание отключено в любом поиске с условием «Корзина». +- Имена разных сохранённых поисков могут совпадать; каждый поиск имеет собственную устойчивую идентичность. +- Переименование тэга сохраняет его связи с ресурсами и условия сохранённых поисков, которые ссылаются на устойчивую идентичность тэга. +- Текстовый запрос сочетается с обязательными тэгами выбранного поиска и временными тэгами по правилу «И». Текст и временные тэги не сохраняются автоматически. +- Вложенность узлов влияет только на отображение панели. Дочерние поиски не расширяют результат родителя. Перемещение узла не меняет результаты поиска и тэги ресурсов. +- Удаление родительского поиска удаляет из панели всё его поддерево после предупреждения с числом дочерних поисков. Это не меняет тэги и связи ресурсов. +- Только «Корзина» и «Входящие» — предопределённые неудаляемые тэги и обязательные закреплённые поиски. Их поиски находятся на верхнем уровне, их можно переставлять, но нельзя вложить или удалить. +- Условия обязательных поисков не редактируются: каждый ищет по одноимённому системному тэгу. Их оформление и порядок на верхнем уровне можно менять. +- «Избранное» не является системным тэгом или поиском. Если пользователь создаст такой тэг, он будет обычным. Явные операции со звездой и состоянием избранного убираются из целевой модели. +- Имена тэгов уникальны без учёта регистра и пробелов по краям. Переименование сохраняет связи с ресурсами и условия сохранённых поисков. +- Одновременно выбирается только один узел панели. Тэги, добавленные через поле поиска, соединяются с условиями выбранного сохранённого поиска оператором «И» и не изменяют сохранённый поиск без явного сохранения. Множественного выбора узлов панели нет. +- Удаление ресурса добавляет «Корзина» и сохраняет остальные тэги. Ресурс с «Корзиной» скрыт из обычных результатов. Восстановление снимает «Корзина». +- Восстановление снимает «Корзину». Если пользовательских тэгов нет, ресурс получает «Входящие»; иначе сохранённые тэги остаются. +- «Входящие» можно назначать и снимать вручную в поле тэгов. «Корзина» там видна, но назначается только через «Удалить» и снимается через «Восстановить». +- Новый ресурс без пользовательских тэгов получает «Входящие». При назначении первого пользовательского тэга «Входящие» снимается автоматически; его можно назначить вручную снова. +- Если у активного ресурса удалён последний пользовательский тэг, «Входящие» назначается автоматически. +- Сохранённый поиск может не иметь обязательных тэгов: он показывает все ресурсы вне Корзины. Такой поиск пользователь может назвать «Все ресурсы». +- Незакреплённый обзор «Все ресурсы» доступен всегда и открывается по умолчанию после запуска. В будущем пользователь сможет назначить стартовый поиск в настройках; эта настройка не входит в первоначальный переход. +- Ручное снятие «Входящих» у ресурса без пользовательских тэгов допускается и не отменяется автоматикой. Автоматическое назначение происходит только при создании, восстановлении или снятии последнего пользовательского тэга. +- При отправке ресурса в Корзину его тэг «Входящие» сохраняется. Сочетание `Корзина И Входящие` допустимо, но обычный поиск «Входящие» исключает ресурсы Корзины. +- Пользовательские поиски можно переставлять выше, ниже и внутрь других поисков перетаскиванием; контекстное меню предоставляет действия перемещения для клавиатуры. Циклы вложенности запрещены. +- Новый поиск создаётся на верхнем уровне по умолчанию; в диалоге можно выбрать родителя. Иконка и цвет имеют значения по умолчанию и редактируются при создании либо позже. +- Поиск с явным условием «Корзина» может сочетаться с другими тэгами и показывает только соответствующие удалённые ресурсы. Если условия «Корзина» нет, удалённые ресурсы скрыты. +- Удаление тэга, используемого сохранёнными поисками, запрещено до удаления его из условий этих поисков; пользователь видит список затронутых поисков. +- Удаление обычного тэга, назначенного ресурсам, после подтверждения снимает его у всех таких ресурсов. Подтверждение показывает число ресурсов; снятие последнего пользовательского тэга у активного ресурса назначает «Входящие». +- Переименование тэга не меняет имя сохранённого поиска, использующего этот тэг. +- Текстовый поиск не сопоставляет имена тэгов: тэги выбираются отдельными чипами. Текст ищется по названию, описанию, адресу и типу ресурса. +- Перетаскивание ресурса на сохранённый поиск назначает все его обязательные тэги, не снимая существующие; перед действием показываются добавляемые тэги. На поиск без условий такое перетаскивание не действует. Перетаскивание на «Корзину» запускает удаление с подтверждением. +- Перетаскивание на составной поиск, содержащий «Корзину», недоступно; для удаления используется обязательный узел «Корзина». +- Повторное перетаскивание ресурса на поиск, все тэги которого уже назначены, не меняет ресурс и даёт короткое уведомление без диалога. + +## Устройство прежней реализации + +- `Resources.Collection` хранит одно изменяемое имя коллекции. +- «Корзина» сейчас является коллекцией: удаление заменяет прежнее значение `Collection`. +- Боковая панель сейчас представляет плоский список коллекций. +- Существующую пользовательскую базу переносить в новую схему не требуется: владелец приложения согласен начать с пустых данных. При внедрении старую базу следует сохранить как резервную копию с понятным именем и создать новую пустую базу. + +## Детали реализации для следующего этапа + +- При проектировании схемы и алгоритма поиска проверить составные условия с системными тэгами, пустой поиск, совпадающие имена поисков и отсутствие циклов в дереве. +- При обновлении UI обеспечить понятное исчезновение карточки из результатов после изменения её тэгов, удаления или восстановления. +- Перед созданием новой пустой базы корректно сохранить резервную копию SQLite, включая актуальное состояние базы при используемом режиме журналирования. + +## Изменённые решения + +- Первоначальное правило «родитель показывает свои и дочерние ресурсы» отменено: дерево имеет только визуальную семантику. +- Первоначальный вариант выбора нескольких узлов панели для поиска «И» отменён: выбирается один узел, дополнительные тэги вводятся в поле поиска. +- Вариант «один тэг — один узел» отменён: узел панели является самостоятельным именованным сохранённым поиском. +- Ранее обсуждавшееся удаление родительского тэга не определяет удаление родительского сохранённого поиска: это разные действия. +- Первоначальная идея перетаскивания ресурса на тэг панели заменена правилами перетаскивания на сохранённый поиск. +- Предыдущее решение о предопределённом тэге «Избранное», звезде и визуальных атрибутах тэгов отменено. +- Первоначальное действие «закрепить тэг из карточки» отменено: поиски создаются только кнопкой у поля поиска. +- Первоначальная потребность в миграции данных коллекций отменена по решению владельца приложения. diff --git a/docs/user-stories.md b/docs/user-stories.md index d240491..7a1e432 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1,54 +1,46 @@ # Реализованные пользовательские сценарии -Короткие заголовки User Stories, сгруппированные по эпикам. Список описывает возможности текущей реализации; планы из [спецификации](../PROJECT.md) сюда не включены. +Список относится к текущему исходному коду. Предварительный релиз v0.1.1 создан до перехода на тэги. ## Epic: Добавление ресурсов -- Пользователь создаёт ресурс вручную через кнопку «Добавить». -- Пользователь создаёт веб-ссылку через кнопку «Добавить», указав её адрес в редакторе. -- Пользователь создаёт ресурс через кнопку «Добавить» с данными, подставленными из буфера обмена. -- Пользователь создаёт веб-ссылку перетаскиванием в окно приложения. -- Пользователь создаёт ресурс из файла или папки перетаскиванием в окно приложения. -- Пользователь создаёт ресурс из перетащенного текста, содержащего ссылку или путь. -- Пользователь выбирает при ручном добавлении тип ресурса: приложение, веб-ссылка, файл, папка, заметка, изображение или команда. +- Пользователь создаёт ресурс вручную или с данными из буфера обмена. +- Пользователь выбирает тип, название, описание, адрес и визуал ресурса. +- Пользователь добавляет файл, папку, ссылку или подходящий текст перетаскиванием в окно. +- Пользователь создаёт ресурс из поиска: тэги поиска и временные тэги назначаются заранее. +- В поиске с условием «Корзина» создание недоступно. ## Epic: Просмотр и поиск -- Пользователь просматривает все ресурсы, кроме помещённых в корзину. -- Пользователь просматривает ресурсы выбранной коллекции. -- Пользователь просматривает избранные ресурсы. -- Пользователь ищет ресурсы по названию, описанию, адресу или типу в текущем разделе. -- Пользователь сортирует ресурсы по названию или дате изменения. -- Пользователь переключает представление ресурсов между сеткой и списком. -- Пользователь видит число ресурсов в разделе и рядом с коллекциями. +- Пользователь открывает обзор «Все ресурсы» без сохранённых условий; удалённые ресурсы скрыты. +- Пользователь выбирает один именованный поиск в боковой панели. +- Обязательные тэги поиска и временные тэги соединяются условием «И». +- Текст ищется по названию, описанию, адресу и типу, без сопоставления имён тэгов. +- При переходе к другому поиску текст и временные тэги очищаются. +- Пользователь сортирует результаты по названию или дате изменения и переключает сетку и список. + +## Epic: Тэги и сохранённые поиски + +- Пользователь назначает ресурсу несколько тэгов; поле ввода предлагает существующие имена и создаёт новый тэг по Enter. +- Пользователь вручную назначает и снимает «Входящие»; «Корзина» меняется только удалением и восстановлением. +- Пользователь создаёт именованный поиск кнопкой «Сохранить поиск» рядом с полем поиска; сохраняются только тэги. +- Пользователь редактирует имя, условия, иконку, цвет и родителя поиска; одинаковые имена разных поисков допустимы. +- Пользователь переставляет поиски, вкладывает их для отображения и удаляет поддерево после подтверждения. +- Перетаскивание ресурса на поиск назначает недостающие обязательные тэги после подтверждения. +- Пользователь переименовывает тэг без потери его связей; удаление тэга, используемого поисками, запрещено. ## Epic: Действия с ресурсами - Пользователь открывает ресурс двойным щелчком, клавишей Enter или через контекстное меню. - Пользователь копирует текст заметки в буфер обмена, открывая её. -- Пользователь редактирует название, описание, тип и адрес ресурса. -- Пользователь переносит ресурс в другую коллекцию через редактор. -- Пользователь добавляет ресурс в избранное или убирает его оттуда через редактор. -- Пользователь выбирает для карточки автоматический визуал, собственное изображение или значок типа. -- Пользователь удаляет ресурс в корзину после подтверждения. - -## Epic: Коллекции - -- Пользователь создаёт коллекцию с названием, цветом и значком либо изображением. -- Пользователь переходит между коллекциями через боковую панель. -- Пользователь переименовывает коллекцию. -- Пользователь меняет значок, цвет или изображение коллекции. -- Пользователь удаляет коллекцию после подтверждения, перенося её ресурсы в другую коллекцию. - -## Epic: Корзина - -- Пользователь просматривает ресурсы в корзине. -- Пользователь удаляет отдельный ресурс из корзины безвозвратно после подтверждения. -- Пользователь очищает корзину после подтверждения. +- Пользователь редактирует ресурс и выбирает автоматический визуал, своё изображение или значок типа. +- Пользователь удаляет ресурс в корзину, сохраняя остальные тэги. +- Пользователь восстанавливает ресурс; при отсутствии пользовательских тэгов ресурс получает «Входящие». +- Пользователь окончательно удаляет ресурс из корзины или очищает её после подтверждения. ## Epic: Сохранение рабочего пространства -- Пользователь продолжает работу с сохранёнными ресурсами и коллекциями после перезапуска приложения. -- Пользователь продолжает работу в выбранном представлении коллекции после перезапуска приложения. -- Пользователь продолжает работу с выбранной сортировкой после перезапуска приложения. -- Пользователь хранит рабочее пространство в каталоге `content` рядом с приложением или выбирает другой каталог через «Настройки». +- Пользователь продолжает работу с ресурсами, тэгами и сохранёнными поисками после перезапуска. +- Представление и сортировка сохраняются между запусками. +- Рабочее пространство хранится в `content` рядом с приложением или в выбранном пользователем каталоге. +- Перед созданием пустой базы новой схемы старая база коллекций сохраняется как резервная копия. diff --git a/src/PersonalResourceWorkspace.App/MainPage.TagDialogs.cs b/src/PersonalResourceWorkspace.App/MainPage.TagDialogs.cs new file mode 100644 index 0000000..136214c --- /dev/null +++ b/src/PersonalResourceWorkspace.App/MainPage.TagDialogs.cs @@ -0,0 +1,240 @@ +using PersonalResourceWorkspace.Application.Models; +using PersonalResourceWorkspace.Application.Resources; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace PersonalResourceWorkspace.App; + +public sealed partial class MainPage +{ + private static readonly string[] NewTagChoice = ["Создать новый тэг"]; + private void UpdateTagFilterChips() + { + if (BaseTagsPanel is null || TemporaryTagsPanel is null) return; + BaseTagsPanel.Children.Clear(); + TemporaryTagsPanel.Children.Clear(); + foreach (var id in ActiveRequiredTags()) + BaseTagsPanel.Children.Add(new TextBlock { Text = TagName(id), VerticalAlignment = VerticalAlignment.Center, + Padding = new Thickness(6, 3, 6, 3), Opacity = 0.75 }); + foreach (var id in _temporaryTags) + { + var button = new Button { Content = $"{TagName(id)} ×", Padding = new Thickness(6, 2, 6, 2) }; + button.Click += (_, _) => { _temporaryTags.Remove(id); UpdateTagFilterChips(); ApplyFilter(); }; + TemporaryTagsPanel.Children.Add(button); + } + UpdateCreateAvailability(); + } + + private void UpdateCreateAvailability() + { + if (AddResourceButton is not null) + AddResourceButton.IsEnabled = !ActiveRequiredTags().Concat(_temporaryTags).Contains(SystemTags.TrashId); + } + + private void TagFilter_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) return; + var text = sender.Text.Trim(); + sender.ItemsSource = text.Length == 0 ? [] : _tags + .Where(tag => tag.Name.Contains(text, StringComparison.CurrentCultureIgnoreCase) && + !_temporaryTags.Contains(tag.Id)) + .Select(tag => tag.Name).ToArray(); + } + + private async void TagFilter_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + var name = (args.ChosenSuggestion as string ?? args.QueryText).Trim(); + if (name.Length == 0) return; + try + { + var tag = await GetOrCreateTagAsync(name); + _temporaryTags.Add(tag.Id); + sender.Text = string.Empty; + UpdateTagFilterChips(); + ApplyFilter(); + } + catch (Exception exception) { StatusText.Text = exception.Message; } + } + + private async Task GetOrCreateTagAsync(string name) + { + var normalized = name.Trim(); + if (normalized.Length == 0) throw new InvalidOperationException("Введите имя тэга."); + var existing = _tags.FirstOrDefault(tag => string.Equals(tag.Name, normalized, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) return existing; + var created = new TagRecord(Guid.NewGuid(), normalized, false); + await _workspaceRepository.SaveTagAsync(created, CancellationToken.None); + _tags.Add(created); + return created; + } + + private async void SaveSearch_Click(object sender, RoutedEventArgs e) + { + var conditionIds = ActiveRequiredTags().Concat(_temporaryTags).Distinct().ToArray(); + var search = new SavedSearchRecord(Guid.NewGuid(), "", null, "\uE721", "#7EA7FF", _searches.Count, false, conditionIds); + await ShowSearchEditorAsync(search, isNew: true); + } + + private Task EditSearchAsync(SavedSearchRecord search) => ShowSearchEditorAsync(search, isNew: false); + + private async Task ShowSearchEditorAsync(SavedSearchRecord search, bool isNew) + { + var nameBox = new TextBox { Header = "Имя поиска", Text = search.Name, IsEnabled = !search.IsSystem }; + var glyphBox = new TextBox { Header = "Иконка (символ)", Text = search.Glyph }; + var colorBox = new TextBox { Header = "Цвет (#RRGGBB)", Text = search.Color }; + var parents = _searches.Where(item => item.Id != search.Id && !IsDescendant(item, search.Id)).ToArray(); + var parentBox = new ComboBox { Header = "Родитель (для отображения)", HorizontalAlignment = HorizontalAlignment.Stretch }; + parentBox.Items.Add("Верхний уровень"); + foreach (var parent in parents) parentBox.Items.Add(parent.Name); + parentBox.SelectedIndex = search.ParentId is { } currentParent + ? Math.Max(0, Array.FindIndex(parents, item => item.Id == currentParent) + 1) : 0; + parentBox.IsEnabled = !search.IsSystem; + var conditions = new StackPanel { Spacing = 4 }; + var selectors = new List<(Guid Id, CheckBox Box)>(); + foreach (var tag in _tags) + { + var check = new CheckBox { Content = tag.Name, IsChecked = search.RequiredTagIds.Contains(tag.Id), + IsEnabled = !search.IsSystem }; + conditions.Children.Add(check); + selectors.Add((tag.Id, check)); + } + var fields = new StackPanel { Spacing = 10, MinWidth = 380 }; + fields.Children.Add(nameBox); + fields.Children.Add(glyphBox); + fields.Children.Add(colorBox); + fields.Children.Add(parentBox); + fields.Children.Add(new TextBlock { Text = isNew + ? "Сохраняются только обязательные тэги. Текстовый запрос не сохраняется." + : "Условия поиска изменяются только здесь.", TextWrapping = TextWrapping.Wrap }); + var summary = new TextBlock { TextWrapping = TextWrapping.Wrap }; + void UpdateSummary() + { + var names = selectors.Where(item => item.Box.IsChecked == true).Select(item => TagName(item.Id)).ToArray(); + summary.Text = $"Итоговые условия: {(names.Length == 0 ? "без тэгов" : string.Join(" И ", names))}"; + } + foreach (var selector in selectors) + { + selector.Box.Checked += (_, _) => UpdateSummary(); + selector.Box.Unchecked += (_, _) => UpdateSummary(); + } + UpdateSummary(); + fields.Children.Add(summary); + fields.Children.Add(conditions); + var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = isNew ? "Сохранить поиск" : "Редактировать поиск", + Content = new ScrollViewer { Content = fields, MaxHeight = 510 }, + PrimaryButtonText = isNew ? "Создать" : "Сохранить", CloseButtonText = "Отмена" }; + dialog.PrimaryButtonClick += (_, args) => + { + if (string.IsNullOrWhiteSpace(nameBox.Text) || !System.Text.RegularExpressions.Regex.IsMatch(colorBox.Text, "^#[0-9a-fA-F]{6}$")) + args.Cancel = true; + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + var updated = search with + { + Name = nameBox.Text.Trim(), Glyph = glyphBox.Text, Color = colorBox.Text, + ParentId = search.IsSystem || parentBox.SelectedIndex <= 0 ? null : parents[parentBox.SelectedIndex - 1].Id, + RequiredTagIds = search.IsSystem ? search.RequiredTagIds : selectors.Where(item => item.Box.IsChecked == true).Select(item => item.Id).ToArray(), + }; + try + { + await _workspaceRepository.SaveSearchAsync(updated, CancellationToken.None); + if (isNew) _searches.Add(updated); + else _searches[_searches.FindIndex(item => item.Id == updated.Id)] = updated; + _activeSearchId = updated.Id; + PageTitle.Text = updated.Name; + if (isNew) + { + _temporaryTags.Clear(); + SearchBox.Text = string.Empty; + TagFilterBox.Text = string.Empty; + } + RebuildSearchNavItems(); + UpdateTagFilterChips(); + ApplyFilter(); + } + catch (Exception exception) { await ShowMessageAsync("Не удалось сохранить поиск", exception.Message); } + } + + private async void ManageTags_Click(object sender, RoutedEventArgs e) => await ManageTagsAsync(); + + private async Task ManageTagsAsync() + { + while (true) + { + var candidates = _tags.Where(tag => !tag.IsSystem).ToArray(); + var box = new ComboBox { Header = "Тэг", HorizontalAlignment = HorizontalAlignment.Stretch, + ItemsSource = NewTagChoice.Concat(candidates.Select(tag => tag.Name)).ToArray(), SelectedIndex = 0 }; + var newName = new TextBox { Header = "Имя нового тэга или новое имя выбранного" }; + var fields = new StackPanel { Spacing = 10, MinWidth = 340 }; + fields.Children.Add(new TextBlock { Text = "«Входящие» и «Корзина» нельзя переименовать или удалить." }); + fields.Children.Add(box); + fields.Children.Add(newName); + var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Управление тэгами", Content = fields, + PrimaryButtonText = "Создать / переименовать", SecondaryButtonText = "Удалить выбранный", CloseButtonText = "Готово" }; + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.None) return; + try + { + if (result == ContentDialogResult.Primary) + { + var name = newName.Text.Trim(); + if (name.Length == 0) continue; + if (box.SelectedIndex <= 0) + await GetOrCreateTagAsync(name); + else + { + var old = candidates[box.SelectedIndex - 1]; + await _workspaceRepository.SaveTagAsync(old with { Name = name }, CancellationToken.None); + _tags[_tags.FindIndex(tag => tag.Id == old.Id)] = old with { Name = name }; + } + } + else if (box.SelectedIndex > 0) + { + var tag = candidates[box.SelectedIndex - 1]; + var searches = _searches.Where(search => search.RequiredTagIds.Contains(tag.Id)).ToArray(); + if (searches.Length > 0) + { + await ShowMessageAsync("Тэг используется в поисках", string.Join(", ", searches.Select(search => search.Name))); + continue; + } + var count = _resources.Count(resource => resource.TagIds.Contains(tag.Id)); + var confirm = new ContentDialog { XamlRoot = XamlRoot, Title = $"Удалить тэг «{tag.Name}»?", + Content = $"Тэг будет снят с {count} ресурсов.", PrimaryButtonText = "Удалить", CloseButtonText = "Отмена" }; + if (await confirm.ShowAsync() != ContentDialogResult.Primary) continue; + await _workspaceRepository.DeleteTagAsync(tag.Id, CancellationToken.None); + _tags.RemoveAll(candidate => candidate.Id == tag.Id); + foreach (var resource in _resources) + if (resource.TagIds.Remove(tag.Id) && !IsInTrash(resource) && + resource.TagIds.All(id => id == SystemTags.InboxId)) resource.TagIds.Add(SystemTags.InboxId); + _temporaryTags.Remove(tag.Id); + } + UpdateTagFilterChips(); + ApplyFilter(); + } + catch (Exception exception) { await ShowMessageAsync("Не удалось изменить тэг", exception.Message); } + } + } + + private async Task EmptyTagTrashAsync() + { + var trashed = _resources.Where(IsInTrash).ToArray(); + var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Очистить корзину?", + Content = $"Безвозвратно удалить ресурсов: {trashed.Length}?", PrimaryButtonText = "Очистить", CloseButtonText = "Отмена" }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + foreach (var resource in trashed) + { + await _workspaceRepository.DeleteResourceAsync(resource.Id, CancellationToken.None); + _resources.Remove(resource); + } + ApplyFilter(); + } + + private async void RestoreResourceMenu_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem { Tag: WorkspaceResource resource } || !IsInTrash(resource)) return; + resource.TagIds = SystemTags.AfterRestore(resource.TagIds).ToHashSet(); + await SaveResourceAsync(resource); + ApplyFilter(); + StatusText.Text = $"Восстановлено: {resource.Title}"; + } +} diff --git a/src/PersonalResourceWorkspace.App/MainPage.TagWorkspace.cs b/src/PersonalResourceWorkspace.App/MainPage.TagWorkspace.cs new file mode 100644 index 0000000..59af121 --- /dev/null +++ b/src/PersonalResourceWorkspace.App/MainPage.TagWorkspace.cs @@ -0,0 +1,268 @@ +using PersonalResourceWorkspace.Application.Models; +using PersonalResourceWorkspace.Application.Resources; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.ApplicationModel.DataTransfer; + +namespace PersonalResourceWorkspace.App; + +public sealed partial class MainPage +{ + private readonly Dictionary _searchNavItems = []; + + private IReadOnlyList ActiveRequiredTags() => + _searches.FirstOrDefault(search => search.Id == _activeSearchId)?.RequiredTagIds ?? []; + + private Guid[] NewResourceTags() + { + var tags = ActiveRequiredTags().Concat(_temporaryTags).ToHashSet(); + if (tags.Count == 0) tags.Add(SystemTags.InboxId); + return tags.ToArray(); + } + + private string TagName(Guid id) => _tags.FirstOrDefault(tag => tag.Id == id)?.Name ?? id.ToString(); + + private async Task LoadWorkspaceNavAsync() + { + try + { + _tags.Clear(); + _tags.AddRange(await _workspaceRepository.GetTagsAsync(CancellationToken.None)); + _searches.Clear(); + _searches.AddRange(await _workspaceRepository.GetSearchesAsync(CancellationToken.None)); + RebuildSearchNavItems(); + UpdateTagFilterChips(); + } + catch (Exception exception) + { + StatusText.Text = $"Не удалось загрузить тэги и поиски: {exception.Message}"; + } + } + + private void RebuildSearchNavItems() + { + for (var index = ShellNavigation.MenuItems.Count - 1; index >= 0; index--) + { + if (ShellNavigation.MenuItems[index] is NavigationViewItem item && item.Tag is SavedSearchRecord) + ShellNavigation.MenuItems.RemoveAt(index); + } + _searchNavItems.Clear(); + foreach (var search in _searches) + { + var item = CreateSearchNavItem(search); + _searchNavItems.Add(search.Id, item); + } + foreach (var search in _searches.OrderBy(item => item.SortOrder)) + { + if (search.ParentId is { } parentId && _searchNavItems.TryGetValue(parentId, out var parent)) + parent.MenuItems.Add(_searchNavItems[search.Id]); + else + ShellNavigation.MenuItems.Add(_searchNavItems[search.Id]); + } + ShellNavigation.SelectedItem = _activeSearchId is { } active && _searchNavItems.TryGetValue(active, out var selected) + ? selected : AllResourcesNavItem; + UpdateSearchCounts(); + } + + private NavigationViewItem CreateSearchNavItem(SavedSearchRecord search) + { + var item = new NavigationViewItem + { + Tag = search, + Content = CreateNavigationContent(search.Name, 0), + Icon = new FontIcon { Glyph = search.Glyph, Foreground = BrushFromHex(search.Color) }, + AllowDrop = true, + CanDrag = !search.IsSystem, + }; + AutomationProperties.SetName(item, search.Name); + item.DragStarting += (_, args) => args.Data.Properties["SearchId"] = search.Id.ToString(); + item.DragOver += (_, args) => + { + if (args.DataView.Properties.ContainsKey("ResourceId")) + args.AcceptedOperation = CanDropResourceOn(search) ? DataPackageOperation.Copy : DataPackageOperation.None; + else if (args.DataView.Properties.ContainsKey("SearchId")) + args.AcceptedOperation = DataPackageOperation.Move; + args.Handled = true; + }; + item.Drop += async (_, args) => + { + args.Handled = true; + if (args.DataView.Properties.TryGetValue("ResourceId", out var resourceValue) && + Guid.TryParse(resourceValue as string, out var resourceId)) + { + var resource = _resources.FirstOrDefault(candidate => candidate.Id == resourceId); + if (resource is not null) await AssignSearchTagsByDropAsync(resource, search); + } + else if (args.DataView.Properties.TryGetValue("SearchId", out var searchValue) && + Guid.TryParse(searchValue as string, out var sourceId)) + { + var position = args.GetPosition(item); + if (position.Y < item.ActualHeight * 0.25) + await MoveSearchRelativeAsync(sourceId, search, before: true); + else if (position.Y > item.ActualHeight * 0.75) + await MoveSearchRelativeAsync(sourceId, search, before: false); + else + await MoveSearchAsync(sourceId, search.Id, null); + } + }; + var flyout = new MenuFlyout(); + AddSearchMenuItem(flyout, "Редактировать поиск", async () => await EditSearchAsync(search)); + AddSearchMenuItem(flyout, "Переместить выше", async () => await ShiftSearchAsync(search, -1)); + AddSearchMenuItem(flyout, "Переместить ниже", async () => await ShiftSearchAsync(search, 1)); + if (!search.IsSystem) + { + AddSearchMenuItem(flyout, "Переместить в…", async () => await ChooseSearchParentAsync(search)); + AddSearchMenuItem(flyout, "Переместить на верхний уровень", async () => await MoveSearchAsync(search.Id, null, null)); + AddSearchMenuItem(flyout, "Удалить поиск", async () => await DeleteSearchAsync(search)); + } + if (search.Id == SystemTags.TrashSearchId) + AddSearchMenuItem(flyout, "Очистить корзину", EmptyTagTrashAsync); + item.ContextFlyout = flyout; + return item; + } + + private static void AddSearchMenuItem(MenuFlyout flyout, string title, Func action) + { + var menu = new MenuFlyoutItem { Text = title }; + menu.Click += async (_, _) => await action(); + flyout.Items.Add(menu); + } + + private static bool CanDropResourceOn(SavedSearchRecord search) => + search.RequiredTagIds.Count > 0 && + (!search.RequiredTagIds.Contains(SystemTags.TrashId) || search.Id == SystemTags.TrashSearchId); + + private async Task AssignSearchTagsByDropAsync(WorkspaceResource resource, SavedSearchRecord search) + { + if (!CanDropResourceOn(search)) return; + if (search.Id == SystemTags.TrashSearchId) + { + await DeleteResourceAsync(resource); + return; + } + var added = search.RequiredTagIds.Where(id => !resource.TagIds.Contains(id)).ToArray(); + if (added.Length == 0) + { + StatusText.Text = "Все тэги поиска уже назначены"; + return; + } + var dialog = new ContentDialog + { + XamlRoot = XamlRoot, + Title = "Назначить тэги ресурсу?", + Content = $"«{resource.Title}»: {string.Join(", ", added.Select(TagName))}", + PrimaryButtonText = "Назначить", + CloseButtonText = "Отмена", + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + resource.TagIds.UnionWith(added); + await SaveResourceAsync(resource); + ApplyFilter(); + } + + private void Resources_DragItemsStarting(object sender, DragItemsStartingEventArgs args) + { + if (args.Items.FirstOrDefault() is WorkspaceResource resource) + args.Data.Properties["ResourceId"] = resource.Id.ToString(); + } + + private async Task ShiftSearchAsync(SavedSearchRecord search, int direction) + { + var siblings = _searches.Where(item => item.ParentId == search.ParentId).OrderBy(item => item.SortOrder).ToList(); + var index = siblings.FindIndex(item => item.Id == search.Id); + var other = index + direction; + if (index < 0 || other < 0 || other >= siblings.Count) return; + (siblings[index], siblings[other]) = (siblings[other], siblings[index]); + for (var position = 0; position < siblings.Count; position++) + { + var updated = siblings[position] with { SortOrder = position }; + await _workspaceRepository.SaveSearchAsync(updated, CancellationToken.None); + var current = _searches.FindIndex(item => item.Id == updated.Id); + _searches[current] = updated; + } + RebuildSearchNavItems(); + } + + private async Task MoveSearchAsync(Guid id, Guid? parentId, int? sortOrder) + { + var index = _searches.FindIndex(item => item.Id == id); + if (index < 0 || _searches[index].IsSystem) return; + var search = _searches[index]; + try + { + var updated = search with { ParentId = parentId, SortOrder = sortOrder ?? _searches.Count }; + await _workspaceRepository.SaveSearchAsync(updated, CancellationToken.None); + _searches[index] = updated; + RebuildSearchNavItems(); + StatusText.Text = $"Перемещён поиск «{search.Name}»"; + } + catch (Exception exception) + { + await ShowMessageAsync("Не удалось переместить поиск", exception.Message); + } + } + + private async Task MoveSearchRelativeAsync(Guid sourceId, SavedSearchRecord target, bool before) + { + var source = _searches.FirstOrDefault(item => item.Id == sourceId); + if (source is null || source.IsSystem || source.Id == target.Id || IsDescendant(target, source.Id)) return; + var siblings = _searches.Where(item => item.ParentId == target.ParentId && item.Id != sourceId) + .OrderBy(item => item.SortOrder).ToList(); + var targetIndex = siblings.FindIndex(item => item.Id == target.Id); + if (targetIndex < 0) return; + siblings.Insert(targetIndex + (before ? 0 : 1), source with { ParentId = target.ParentId }); + try + { + for (var position = 0; position < siblings.Count; position++) + { + var updated = siblings[position] with { SortOrder = position }; + await _workspaceRepository.SaveSearchAsync(updated, CancellationToken.None); + _searches[_searches.FindIndex(item => item.Id == updated.Id)] = updated; + } + RebuildSearchNavItems(); + } + catch (Exception exception) + { + await LoadWorkspaceNavAsync(); + await ShowMessageAsync("Не удалось переместить поиск", exception.Message); + } + } + + private async Task ChooseSearchParentAsync(SavedSearchRecord search) + { + var choices = _searches.Where(item => item.Id != search.Id && !IsDescendant(item, search.Id)).ToList(); + var box = new ComboBox { Header = "Родитель", ItemsSource = choices.Select(item => item.Name).ToArray() }; + var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Переместить поиск", Content = box, + PrimaryButtonText = "Переместить", CloseButtonText = "Отмена" }; + if (await dialog.ShowAsync() == ContentDialogResult.Primary && box.SelectedIndex >= 0) + await MoveSearchAsync(search.Id, choices[box.SelectedIndex].Id, null); + } + + private async Task DeleteSearchAsync(SavedSearchRecord search) + { + var descendants = _searches.Where(candidate => IsDescendant(candidate, search.Id)).ToArray(); + var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Удалить сохранённый поиск?", + Content = $"«{search.Name}» и дочерние поиски ({descendants.Length}) исчезнут из панели. Тэги ресурсов сохранятся.", + PrimaryButtonText = "Удалить", CloseButtonText = "Отмена" }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + await _workspaceRepository.DeleteSearchTreeAsync(search.Id, CancellationToken.None); + await LoadWorkspaceNavAsync(); + _activeSearchId = null; + PageTitle.Text = "Все ресурсы"; + ApplyFilter(); + } + + private bool IsDescendant(SavedSearchRecord candidate, Guid ancestorId) + { + var parent = candidate.ParentId; + var visited = new HashSet(); + while (parent is { } id && visited.Add(id)) + { + if (id == ancestorId) return true; + parent = _searches.FirstOrDefault(item => item.Id == id)?.ParentId; + } + return false; + } +} diff --git a/src/PersonalResourceWorkspace.App/MainPage.xaml b/src/PersonalResourceWorkspace.App/MainPage.xaml index 06245c8..9717c6f 100644 --- a/src/PersonalResourceWorkspace.App/MainPage.xaml +++ b/src/PersonalResourceWorkspace.App/MainPage.xaml @@ -46,17 +46,7 @@ - - - - - - - - - - - + - + @@ -90,17 +80,25 @@ QueryIcon="Find" TextChanged="SearchBox_TextChanged" /> + + + + + +