From 8f43c94905c89e6b7f458fba4751d630c4fcb599 Mon Sep 17 00:00:00 2001 From: chernukhinav Date: Wed, 14 Oct 2026 09:16:59 +0300 Subject: [PATCH 1/2] docs: capture current stories and portable storage requirements --- PROJECT.md | 30 ++++++++--- README.md | 2 +- docs/adr/0001-unpackaged-deployment.md | 2 + .../adr/0002-sqlite-and-filesystem-storage.md | 2 + docs/adr/0003-portable-data-and-settings.md | 25 +++++++++ docs/user-stories.md | 52 +++++++++++++++++++ 6 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0003-portable-data-and-settings.md create mode 100644 docs/user-stories.md diff --git a/PROJECT.md b/PROJECT.md index f6e538e..70af055 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -2,6 +2,8 @@ > Нормативная спецификация для реализации Windows-приложения на C# + .NET + WinUI 3 + Windows App SDK + SQLite. +> Требования к portable-хранению в разделах 7 и 10 описывают целевое состояние; текущая реализация до их выполнения описана в `docs/architecture.md`. + ## 0. Как Codex должен использовать этот документ Этот файл является исходным продуктовым и техническим контрактом проекта. При конфликте с предположениями, примерами, шаблонным кодом или неявными решениями приоритет имеют требования этого файла. @@ -348,7 +350,18 @@ Visual resolution MUST поддерживать приоритет: - Startup impact MUST быть минимальным: database migrations и загрузка первого экрана не должны блокировать UI thread. - Автозапуск с Windows MAY быть добавлен после MVP; он MUST быть opt-in. -## 7. Persistence: SQLite + filesystem assets +## 7. Persistence: portable SQLite + filesystem assets + +### 7.0 Portable data directory and configuration + +- Приложение MUST запускаться как portable из распакованного каталога без установки и хранить пользовательские данные по умолчанию в подкаталоге `content` рядом с исполняемым файлом (`AppContext.BaseDirectory/content`), независимо от текущего каталога процесса. +- В `content` MUST находиться БД и весь app-owned контент: пользовательские assets, thumbnails/cache и другие создаваемые приложением файлы. Внутренние ссылки на assets MUST быть относительными к корню данных, чтобы каталог можно было переносить вместе с приложением или отдельно от него. +- Единственный параметр вне БД, который MAY сохраняться между запусками, — путь к каталогу данных. Он MUST храниться в `portable-config.json` рядом с исполняемым файлом в поле `dataDirectory`. JSON MUST NOT содержать иных пользовательских настроек или копий данных из БД. +- Путь к каталогу данных MAY явно задаваться параметром командной строки `--data-dir ` или переменной окружения `PERSONAL_RESOURCE_WORKSPACE_DATA_DIR`. Приоритет источников MUST быть: параметр командной строки → переменная окружения → JSON-конфигурация → `content` рядом с исполняемым файлом. Неизвестные/ошибочные значения MUST давать понятную ошибку, а не молча открывать другой каталог. +- Относительный путь из любого источника MUST разрешаться относительно каталога исполняемого файла, а не текущего каталога процесса. Перед открытием БД приложение MUST нормализовать путь и проверять доступность каталога и возможность записи; при ошибке MUST сохранить пользовательские данные и предложить исправить путь. +- Форма «Настройки» MUST показывать эффективный каталог данных и источник выбора (параметр, окружение, JSON или default), позволять выбрать другой каталог и явно объяснять, когда параметр командной строки или переменная окружения переопределяют сохранённый выбор. При изменении из формы новый путь MUST записываться в JSON-конфигурацию; внешние overrides форма MUST NOT изменять. +- Смена каталога через форму MUST переносить рабочее пространство целиком: БД с согласованным snapshot/backup, app-owned assets и другие необходимые файлы. MUST проверяться свободное место, доступность записи и целостность результата; существующие данные в целевом каталоге MUST NOT перезаписываться без явного выбора пользователя. Переключение на новый каталог MUST происходить только после успешного копирования и проверки, с безопасным закрытием/повторным открытием БД либо перезапуском приложения. При ошибке MUST сохраняться прежний рабочий каталог и его содержимое; прежние данные MUST NOT удаляться автоматически. +- Переход с существующего `%LOCALAPPDATA%/PersonalResourceWorkspace` на portable layout MUST сохранять текущую БД, визуальные файлы и настройки. При первом запуске после обновления, если выбранный каталог данных пуст, приложение MUST автоматически скопировать и проверить существующее рабочее пространство; при непустом каталоге MUST запросить выбор без неявного слияния/перезаписи. Исходные данные MUST сохраняться до подтверждённого успешного запуска из нового каталога. Режим вида из `Settings/collection-view.txt` MUST мигрировать в БД. ### 7.1 Storage responsibilities @@ -359,7 +372,7 @@ SQLite MUST хранить: - membership/placement и ordering; - action configuration/overrides, если они пользовательские; - asset metadata; -- settings, необходимые для согласованности данных; +- все пользовательские настройки, включая режим вида, сортировку и будущие параметры интерфейса/поведения; исключение — только путь к каталогу данных, необходимый до открытия БД; - schema migration history. Filesystem MUST хранить: @@ -371,6 +384,8 @@ Filesystem MUST хранить: SQLite MUST NOT использоваться как безразмерное хранилище крупных image blobs в MVP. +Каждая новая пользовательская настройка MUST иметь запись/миграцию в БД и доступный элемент формы «Настройки» в той же поставке. Разрозненные файлы настроек вне БД MUST NOT создаваться. Значения по умолчанию MUST быть определены и применяться при отсутствующей записи; изменения MUST сохраняться после перезапуска. + ### 7.2 Data integrity - Схема MUST иметь foreign keys, indexes и constraints для основных инвариантов. @@ -479,8 +494,8 @@ Dependency direction MUST быть направлен к Domain/Application. Dom ## 10. Deployment model -- Начальная реализация SHOULD использовать **packaged** WinUI 3 app как стандартный, Store-friendly путь с package identity. -- Если обязательным становится прямой agent-driven запуск `.exe` без deployment, решение MAY перейти на **unpackaged**, но изменение MUST быть оформлено архитектурным решением и учесть Windows App SDK bootstrap/runtime initialization, storage и activation differences. +- Целевая поставка MUST быть **portable unpackaged** WinUI 3 app: распаковка и запуск `.exe` без installer/MSIX, при сохранении требований Windows App SDK к runtime/bootstrap и минимальной версии Windows. +- Portable-архив MUST включать все необходимые файлы приложения; каталог `content` MUST создаваться при первом запуске, если выбранный каталог данных пуст. - Код MUST NOT неявно смешивать packaged и unpackaged assumptions. - Конкретные версии .NET, Windows App SDK и NuGet packages MUST быть централизованы и закреплены на поддерживаемых стабильных версиях на момент bootstrap проекта. - Минимальная поддерживаемая Windows version MUST быть явно задана в проекте и README; baseline MUST соответствовать требованиям выбранной версии Windows App SDK. @@ -666,7 +681,7 @@ MVP MUST NOT включать: ### Phase 1 — Domain and persistence vertical slice - Реализовать Resource, Collection, Placement, typed payloads и action contracts. -- Реализовать SQLite connection factory, migrations, repositories и filesystem asset store. +- Реализовать разрешение portable-каталога данных, SQLite connection factory, migrations, repositories и filesystem asset store. - Реализовать root collection bootstrap и CRUD use cases. **Exit:** все типы проходят round-trip tests; invariants и migration tests зелёные; пользовательская БД не пересоздаётся молча. @@ -697,7 +712,7 @@ MVP MUST NOT включать: ### Phase 5 — Resident lifecycle and search - Реализовать single-instance activation, global hotkey, show/hide, close-to-resident и explicit Exit. -- Реализовать settings и local secondary search. +- Реализовать форму настроек, выбор/безопасный перенос каталога данных, хранение всех прочих настроек в БД и local secondary search. - Восстанавливать window placement и last collection. **Exit:** повторная activation не создаёт второй workspace; hotkey стабильно управляет окном; поиск и restart проходят acceptance scenarios. @@ -733,6 +748,9 @@ MVP принимается только если все следующие ут 13. SQLite foreign keys включены, migrations протестированы, assets лежат во filesystem по относительным app-owned paths. 14. Domain и integration test suites проходят; solution собирается без ошибок из документированного workflow. 15. Release build реально запускается на заявленной минимальной Windows version/configuration и показывает ожидаемый top-level window. +16. Распакованное приложение создаёт `content` рядом с `.exe`, сохраняет там БД и app-owned файлы и после переноса каталога приложения сохраняет рабочее пространство. +17. Каталог данных выбирается с документированным приоритетом CLI → ENV → JSON → default; невалидный путь не приводит к молчаливому созданию пустого рабочего пространства в другом месте. +18. Форма «Настройки» показывает текущий каталог и все пользовательские настройки. Смена каталога переносит БД и app-owned файлы без потери данных; при ошибке прежний каталог остаётся рабочим. Режим вида и остальные настройки хранятся в БД и восстанавливаются после перезапуска. ## 18. Definition of Done for every feature diff --git a/README.md b/README.md index e67fd21..63da2b1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Все ресурсы. Одно пространство.** Локальное приложение для Windows, в котором ссылки, файлы, папки, программы и заметки собраны в одном визуальном каталоге. -[English](docs/README.en.md) · [Запуск из исходников](docs/development.md) · [Устройство проекта](docs/architecture.md) +[English](docs/README.en.md) · [Сценарии пользователя](docs/user-stories.md) · [Запуск из исходников](docs/development.md) · [Устройство проекта](docs/architecture.md) ![Personal Resource Workspace — общий вид приложения](assets/personal-resource-workspace.png) diff --git a/docs/adr/0001-unpackaged-deployment.md b/docs/adr/0001-unpackaged-deployment.md index c1e2739..50f736e 100644 --- a/docs/adr/0001-unpackaged-deployment.md +++ b/docs/adr/0001-unpackaged-deployment.md @@ -3,6 +3,8 @@ - Status: Accepted - Date: 2026-09-10 +> Расположение пользовательских данных в этом ADR заменено целевым решением [ADR 0003](0003-portable-data-and-settings.md); до его реализации код продолжает использовать `%LOCALAPPDATA%`. + ## Context The contract prefers packaged deployment but explicitly allows unpackaged deployment when direct agent-driven executable launch is required. The official packaged template built successfully, but the audited sandbox cannot register a loose-layout MSIX: Windows deployment reports that the current token has no resolvable user-profile type (`0x80073D19`, inner `0x80070002`). Installing the signed Windows App Runtime also failed under the sandbox deployment API (`0x80070570`). Granting access to the package directories did not change the profile-token failure. diff --git a/docs/adr/0002-sqlite-and-filesystem-storage.md b/docs/adr/0002-sqlite-and-filesystem-storage.md index 517e8ee..599a0a0 100644 --- a/docs/adr/0002-sqlite-and-filesystem-storage.md +++ b/docs/adr/0002-sqlite-and-filesystem-storage.md @@ -3,6 +3,8 @@ - Status: Accepted - Date: 2026-09-10 +> Расположение пользовательских данных в этом ADR заменено целевым решением [ADR 0003](0003-portable-data-and-settings.md); до его реализации код продолжает использовать `%LOCALAPPDATA%`. + ## Context The application is local-first and must preserve structured metadata safely while keeping large binary images outside the database. diff --git a/docs/adr/0003-portable-data-and-settings.md b/docs/adr/0003-portable-data-and-settings.md new file mode 100644 index 0000000..a6affdc --- /dev/null +++ b/docs/adr/0003-portable-data-and-settings.md @@ -0,0 +1,25 @@ +# ADR 0003: Portable data directory and database-backed settings + +- Status: Accepted as target specification; implementation pending +- Date: 2026-10-14 +- Supersedes: the storage-location decisions in ADR 0001 and ADR 0002 + +## Context + +The current unpackaged app stores its database and visuals under `%LOCALAPPDATA%/PersonalResourceWorkspace`. Its grid/list preference is a separate text file, and the Settings navigation item is a placeholder. A portable archive needs a movable, self-contained default data location and one authoritative store for user preferences. + +## Decision + +The default data root is `content` beside the executable, resolved from `AppContext.BaseDirectory`. The database and all app-owned content live under this root. A user may select another data root in Settings. The only persistent bootstrap configuration outside the database is `portable-config.json` beside the executable, with `dataDirectory` as its sole user-configurable value. + +At startup, the effective data root is resolved in this order: `--data-dir `, `PERSONAL_RESOURCE_WORKSPACE_DATA_DIR`, `portable-config.json`, then the default `content`. Relative paths are resolved against the executable directory. An invalid explicit path is reported and never silently replaced with a different workspace. Settings shows the effective root and its source; it can save a new path to JSON, but cannot alter CLI or environment overrides. + +Every other user preference is stored in SQLite and exposed in the Settings form when introduced. The existing grid/list preference is migrated from `Settings/collection-view.txt`; the sort preference becomes durable in SQLite. The database schema and migration process own preference defaults and upgrades. + +Changing the data root copies a consistent database snapshot and all app-owned content to a validated destination before switching. The old root is retained. Existing target data is never silently overwritten or merged. On first startup after upgrading, an empty selected root receives an automatic copy from the legacy `%LOCALAPPDATA%` root; a nonempty destination requires an explicit user choice. The source is retained. The app must not open a partially copied database, and failed migration must leave the old workspace usable. + +## Consequences + +Portable operation requires write access to the selected data root and, when changing the saved location, to the directory containing `portable-config.json`. SQLite connection creation, visual storage, migrations, and startup must use one resolved root. Tests must cover source precedence, relative paths, legacy migration, preference migration, successful relocation, conflicts, and rollback on failure. + +ADR 0001 still governs unpackaged WinUI deployment. ADR 0002 still governs SQLite integrity and filesystem assets, except for its former packaged/local-profile storage location. Current implementation documentation may continue to describe `%LOCALAPPDATA%` until this decision is implemented and verified. diff --git a/docs/user-stories.md b/docs/user-stories.md new file mode 100644 index 0000000..fec4bda --- /dev/null +++ b/docs/user-stories.md @@ -0,0 +1,52 @@ +# Реализованные пользовательские сценарии + +Короткие заголовки User Stories, сгруппированные по эпикам. Список описывает возможности текущей реализации; планы из [спецификации](../PROJECT.md) сюда не включены. + +## Epic: Добавление ресурсов + +- Пользователь создаёт ресурс вручную через кнопку «Добавить». +- Пользователь создаёт веб-ссылку через кнопку «Добавить», указав её адрес в редакторе. +- Пользователь создаёт ресурс через кнопку «Добавить» с данными, подставленными из буфера обмена. +- Пользователь создаёт веб-ссылку перетаскиванием в окно приложения. +- Пользователь создаёт ресурс из файла или папки перетаскиванием в окно приложения. +- Пользователь создаёт ресурс из перетащенного текста, содержащего ссылку или путь. +- Пользователь выбирает при ручном добавлении тип ресурса: приложение, веб-ссылка, файл, папка, заметка, изображение или команда. + +## Epic: Просмотр и поиск + +- Пользователь просматривает все ресурсы, кроме помещённых в корзину. +- Пользователь просматривает ресурсы выбранной коллекции. +- Пользователь просматривает избранные ресурсы. +- Пользователь ищет ресурсы по названию, описанию, адресу или типу в текущем разделе. +- Пользователь сортирует ресурсы по названию или дате изменения. +- Пользователь переключает представление ресурсов между сеткой и списком. +- Пользователь видит число ресурсов в разделе и рядом с коллекциями. + +## Epic: Действия с ресурсами + +- Пользователь открывает ресурс двойным щелчком, клавишей Enter или через контекстное меню. +- Пользователь копирует текст заметки в буфер обмена, открывая её. +- Пользователь редактирует название, описание, тип и адрес ресурса. +- Пользователь переносит ресурс в другую коллекцию через редактор. +- Пользователь добавляет ресурс в избранное или убирает его оттуда через редактор. +- Пользователь выбирает для карточки автоматический визуал, собственное изображение или значок типа. +- Пользователь удаляет ресурс в корзину после подтверждения. + +## Epic: Коллекции + +- Пользователь создаёт коллекцию с названием, цветом и значком либо изображением. +- Пользователь переходит между коллекциями через боковую панель. +- Пользователь переименовывает коллекцию. +- Пользователь меняет значок, цвет или изображение коллекции. +- Пользователь удаляет коллекцию после подтверждения, перенося её ресурсы в другую коллекцию. + +## Epic: Корзина + +- Пользователь просматривает ресурсы в корзине. +- Пользователь удаляет отдельный ресурс из корзины безвозвратно после подтверждения. +- Пользователь очищает корзину после подтверждения. + +## Epic: Сохранение рабочего пространства + +- Пользователь продолжает работу с сохранёнными ресурсами и коллекциями после перезапуска приложения. +- Пользователь продолжает работу в выбранном представлении коллекции после перезапуска приложения. From 1ca23ff959c872eee587c5bc1035f99d6724d60e Mon Sep 17 00:00:00 2001 From: chernukhinav Date: Wed, 14 Oct 2026 09:25:52 +0300 Subject: [PATCH 2/2] feat: store portable workspace data and preferences --- PROJECT.md | 2 +- README.md | 3 + docs/adr/0001-unpackaged-deployment.md | 2 +- .../adr/0002-sqlite-and-filesystem-storage.md | 2 +- docs/adr/0003-portable-data-and-settings.md | 4 +- docs/architecture.md | 4 +- docs/development.md | 2 +- docs/user-stories.md | 2 + src/PersonalResourceWorkspace.App/App.xaml.cs | 11 +- .../MainPage.xaml.cs | 85 ++++++++- .../MainWindow.xaml.cs | 6 +- .../Abstractions/IPortableDataDirectory.cs | 9 + .../Abstractions/IUserPreferenceStore.cs | 7 + .../DependencyInjection.cs | 7 + .../SqliteCollectionViewPreferenceStore.cs | 13 ++ .../Persistence/SqliteMigrationRunner.cs | 20 ++- .../Persistence/SqliteUserPreferenceStore.cs | 25 +++ .../Storage/PortableDataDirectory.cs | 169 ++++++++++++++++++ .../DependencyInjection.cs | 3 - .../FileCollectionViewPreferenceStore.cs | 38 ---- .../Storage/UnpackagedAppDataPathProvider.cs | 12 -- .../Persistence/PortableDataDirectoryTests.cs | 71 ++++++++ .../Persistence/SqliteMigrationTests.cs | 14 ++ 23 files changed, 440 insertions(+), 71 deletions(-) create mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/IPortableDataDirectory.cs create mode 100644 src/PersonalResourceWorkspace.Application/Abstractions/IUserPreferenceStore.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionViewPreferenceStore.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteUserPreferenceStore.cs create mode 100644 src/PersonalResourceWorkspace.Infrastructure/Storage/PortableDataDirectory.cs delete mode 100644 src/PersonalResourceWorkspace.Windows/Storage/FileCollectionViewPreferenceStore.cs delete mode 100644 src/PersonalResourceWorkspace.Windows/Storage/UnpackagedAppDataPathProvider.cs create mode 100644 tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/PortableDataDirectoryTests.cs diff --git a/PROJECT.md b/PROJECT.md index 70af055..ef99eae 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -2,7 +2,7 @@ > Нормативная спецификация для реализации Windows-приложения на C# + .NET + WinUI 3 + Windows App SDK + SQLite. -> Требования к portable-хранению в разделах 7 и 10 описывают целевое состояние; текущая реализация до их выполнения описана в `docs/architecture.md`. +> Текущее устройство хранения описано в `docs/architecture.md`; требования к будущим возможностям остаются в этой спецификации. ## 0. Как Codex должен использовать этот документ diff --git a/README.md b/README.md index 63da2b1..7547389 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - **Быстрый доступ:** ищите по каталогу и сортируйте по названию или дате изменения. Открывайте ресурсы из карточки и редактируйте их через контекстное меню. - **Простое добавление:** перетаскивайте файлы, папки и веб-ссылки в окно. При создании ресурса приложение может подставить содержимое буфера обмена. - **Корзина:** случайно удалённый ресурс сначала попадает в корзину; её можно очистить отдельно. +- **Portable-данные:** новые сборки хранят базу и визуальные файлы в `content` рядом с приложением. В «Настройках» можно выбрать другой каталог; вид и сортировка сохраняются в БД. Приложение находится в активной разработке. Скриншот показывает один из вариантов наполнения личного пространства; ваши данные и карточки будут другими. @@ -21,6 +22,8 @@ Скачайте [предварительную версию 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` относится к текущему исходному коду и будущим сборкам. + ## Идеи на будущее Это направления развития, **не реализованные функции и не обещание сроков**: diff --git a/docs/adr/0001-unpackaged-deployment.md b/docs/adr/0001-unpackaged-deployment.md index 50f736e..152e2d9 100644 --- a/docs/adr/0001-unpackaged-deployment.md +++ b/docs/adr/0001-unpackaged-deployment.md @@ -3,7 +3,7 @@ - Status: Accepted - Date: 2026-09-10 -> Расположение пользовательских данных в этом ADR заменено целевым решением [ADR 0003](0003-portable-data-and-settings.md); до его реализации код продолжает использовать `%LOCALAPPDATA%`. +> Расположение пользовательских данных в этом ADR заменено [ADR 0003](0003-portable-data-and-settings.md). Текущий код использует portable-каталог `content` по умолчанию. ## Context diff --git a/docs/adr/0002-sqlite-and-filesystem-storage.md b/docs/adr/0002-sqlite-and-filesystem-storage.md index 599a0a0..24dbdd3 100644 --- a/docs/adr/0002-sqlite-and-filesystem-storage.md +++ b/docs/adr/0002-sqlite-and-filesystem-storage.md @@ -3,7 +3,7 @@ - Status: Accepted - Date: 2026-09-10 -> Расположение пользовательских данных в этом ADR заменено целевым решением [ADR 0003](0003-portable-data-and-settings.md); до его реализации код продолжает использовать `%LOCALAPPDATA%`. +> Расположение пользовательских данных в этом ADR заменено [ADR 0003](0003-portable-data-and-settings.md). Текущий код использует portable-каталог `content` по умолчанию. ## Context diff --git a/docs/adr/0003-portable-data-and-settings.md b/docs/adr/0003-portable-data-and-settings.md index a6affdc..4d29c7f 100644 --- a/docs/adr/0003-portable-data-and-settings.md +++ b/docs/adr/0003-portable-data-and-settings.md @@ -1,6 +1,6 @@ # ADR 0003: Portable data directory and database-backed settings -- Status: Accepted as target specification; implementation pending +- Status: Accepted and implemented - Date: 2026-10-14 - Supersedes: the storage-location decisions in ADR 0001 and ADR 0002 @@ -22,4 +22,4 @@ Changing the data root copies a consistent database snapshot and all app-owned c Portable operation requires write access to the selected data root and, when changing the saved location, to the directory containing `portable-config.json`. SQLite connection creation, visual storage, migrations, and startup must use one resolved root. Tests must cover source precedence, relative paths, legacy migration, preference migration, successful relocation, conflicts, and rollback on failure. -ADR 0001 still governs unpackaged WinUI deployment. ADR 0002 still governs SQLite integrity and filesystem assets, except for its former packaged/local-profile storage location. Current implementation documentation may continue to describe `%LOCALAPPDATA%` until this decision is implemented and verified. +ADR 0001 still governs unpackaged WinUI deployment. ADR 0002 still governs SQLite integrity and filesystem assets, except for its former packaged/local-profile storage location. The existing v0.1.1 release predates this decision; current source builds use the portable location. diff --git a/docs/architecture.md b/docs/architecture.md index c7031f0..ae73e20 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,6 @@ | `src/PersonalResourceWorkspace.Windows` | Windows shell, clipboard, activation, visuals | | `tests` | Unit and isolated SQLite integration tests | -Локальная база данных хранится в `%LOCALAPPDATA%\PersonalResourceWorkspace\Data\workspace.db`. Пользовательские изображения — в `Data\Visuals\user`, автоматически полученные превью — в `Data\Visuals\cache`. Миграции БД выполняются транзакционно. / The local database is stored at `%LOCALAPPDATA%\PersonalResourceWorkspace\Data\workspace.db`. User images live in `Data\Visuals\user`; generated previews are cached in `Data\Visuals\cache`. Database migrations are transactional. +По умолчанию данные находятся в `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. -Подробнее / Further reading: [project specification](../PROJECT.md), [deployment decision](adr/0001-unpackaged-deployment.md), [storage decision](adr/0002-sqlite-and-filesystem-storage.md), [architecture review](../architecture-review.md). +Подробнее / 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/development.md b/docs/development.md index 72450ba..73d2210 100644 --- a/docs/development.md +++ b/docs/development.md @@ -21,7 +21,7 @@ dotnet test PersonalResourceWorkspace.slnx -c Debug --no-build dotnet run --project src/PersonalResourceWorkspace.App/PersonalResourceWorkspace.App.csproj -c Debug -r win-x64 ``` -Данные приложения находятся в `%LOCALAPPDATA%\PersonalResourceWorkspace`; при повторном запуске каталог сохраняется. / App data lives in `%LOCALAPPDATA%\PersonalResourceWorkspace` and persists across launches. +Данные по умолчанию находятся в `content` рядом с запускаемым `.exe`. Путь можно задать через `--data-dir `, переменную `PERSONAL_RESOURCE_WORKSPACE_DATA_DIR` или поле `dataDirectory` в `portable-config.json` рядом с `.exe`; приоритет именно в этом порядке. Относительные пути считаются от каталога `.exe`. Формой «Настройки» можно скопировать рабочее пространство в другой пустой каталог; для перехода на него перезапустите приложение. Прежние данные сохраняются. / By default, data lives in `content` beside the executable. The CLI argument, environment variable, and JSON config override it in that order. Settings can copy the workspace to another empty directory; restart to use it. ## Выпуск версий / Releases diff --git a/docs/user-stories.md b/docs/user-stories.md index fec4bda..d240491 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -50,3 +50,5 @@ - Пользователь продолжает работу с сохранёнными ресурсами и коллекциями после перезапуска приложения. - Пользователь продолжает работу в выбранном представлении коллекции после перезапуска приложения. +- Пользователь продолжает работу с выбранной сортировкой после перезапуска приложения. +- Пользователь хранит рабочее пространство в каталоге `content` рядом с приложением или выбирает другой каталог через «Настройки». diff --git a/src/PersonalResourceWorkspace.App/App.xaml.cs b/src/PersonalResourceWorkspace.App/App.xaml.cs index d375bd3..b16d615 100644 --- a/src/PersonalResourceWorkspace.App/App.xaml.cs +++ b/src/PersonalResourceWorkspace.App/App.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using PersonalResourceWorkspace.Application.Abstractions; using PersonalResourceWorkspace.Application.Resources; using PersonalResourceWorkspace.Infrastructure; @@ -43,6 +44,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) { try { + await _services.GetRequiredService().InitializeAsync(CancellationToken.None); foreach (var initializer in _services.GetServices()) { await initializer.InitializeAsync(CancellationToken.None); @@ -54,7 +56,14 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) catch (Exception exception) { LogInitializationFailure(_services.GetRequiredService>(), exception); - Exit(); + _window = new Window { Title = "Ошибка каталога данных" }; + _window.Content = new TextBlock + { + Text = $"Не удалось открыть данные приложения:\n{exception.Message}\n\nПроверьте --data-dir, PERSONAL_RESOURCE_WORKSPACE_DATA_DIR или portable-config.json.", + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(24), + }; + _window.Activate(); } } diff --git a/src/PersonalResourceWorkspace.App/MainPage.xaml.cs b/src/PersonalResourceWorkspace.App/MainPage.xaml.cs index 5493754..b07aad4 100644 --- a/src/PersonalResourceWorkspace.App/MainPage.xaml.cs +++ b/src/PersonalResourceWorkspace.App/MainPage.xaml.cs @@ -61,6 +61,8 @@ public sealed partial class MainPage : Page private readonly IClipboardService _clipboardService; private readonly IResourceVisualService _resourceVisualService; private readonly ICollectionViewPreferenceStore _viewPreferenceStore; + private readonly IUserPreferenceStore _userPreferences; + private readonly IPortableDataDirectory _dataDirectory; private readonly Window _window; private CollectionViewMode _viewMode = CollectionViewMode.Grid; private ResourceSortMode _sortMode = ResourceSortMode.Title; @@ -78,7 +80,9 @@ public MainPage( ResourceLaunchService resourceLaunchService, IClipboardService clipboardService, IResourceVisualService resourceVisualService, - ICollectionViewPreferenceStore viewPreferenceStore) + ICollectionViewPreferenceStore viewPreferenceStore, + IUserPreferenceStore userPreferences, + IPortableDataDirectory dataDirectory) { _window = window; _resourceRepository = resourceRepository; @@ -87,6 +91,8 @@ public MainPage( _clipboardService = clipboardService; _resourceVisualService = resourceVisualService; _viewPreferenceStore = viewPreferenceStore; + _userPreferences = userPreferences; + _dataDirectory = dataDirectory; InitializeComponent(); if (ShellNavigation.SettingsItem is NavigationViewItem settingsItem) { @@ -104,6 +110,7 @@ public MainPage( private async void MainPage_Loaded(object sender, RoutedEventArgs e) { await RestoreViewModeAsync(); + await RestoreSortModeAsync(); await LoadCollectionsAsync(); await LoadResourcesAsync(); UpdateResponsiveLayout(ActualWidth); @@ -117,12 +124,11 @@ private static Brush ThemeBrush(string resourceKey, Brush fallback) => ? brush : fallback; - private void ShellNavigation_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) + private async void ShellNavigation_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) { if (args.IsSettingsInvoked) { - PageTitle.Text = "Настройки"; - StatusText.Text = "Раздел настроек будет добавлен позднее"; + await ShowSettingsAsync(); return; } @@ -344,20 +350,87 @@ private async Task SetViewModeAsync(CollectionViewMode mode, bool persist) } } - private void SortByTitle_Click(object sender, RoutedEventArgs e) + private async void SortByTitle_Click(object sender, RoutedEventArgs e) { _sortMode = ResourceSortMode.Title; SortByTitleItem.IsChecked = true; SortByModifiedItem.IsChecked = false; ApplyFilter(); + await SaveSortModeAsync(); } - private void SortByModified_Click(object sender, RoutedEventArgs e) + private async void SortByModified_Click(object sender, RoutedEventArgs e) { _sortMode = ResourceSortMode.Modified; SortByTitleItem.IsChecked = false; SortByModifiedItem.IsChecked = true; ApplyFilter(); + await SaveSortModeAsync(); + } + + private async Task RestoreSortModeAsync() + { + try + { + _sortMode = string.Equals(await _userPreferences.GetAsync("sort-mode", CancellationToken.None), "modified", StringComparison.OrdinalIgnoreCase) + ? ResourceSortMode.Modified : ResourceSortMode.Title; + SortByTitleItem.IsChecked = _sortMode == ResourceSortMode.Title; + SortByModifiedItem.IsChecked = _sortMode == ResourceSortMode.Modified; + ApplyFilter(); + } + catch (Exception exception) { StatusText.Text = $"Не удалось восстановить сортировку: {exception.Message}"; } + } + + private async Task SaveSortModeAsync() + { + try { await _userPreferences.SetAsync("sort-mode", _sortMode == ResourceSortMode.Modified ? "modified" : "title", CancellationToken.None); } + catch (Exception exception) { StatusText.Text = $"Не удалось сохранить сортировку: {exception.Message}"; } + } + + private async Task ShowSettingsAsync() + { + var rootLabel = new TextBlock { Text = $"Каталог данных (источник: {_dataDirectory.Source})" }; + var pathBox = new TextBox { Text = _dataDirectory.GetLocalDataRoot(), IsEnabled = !_dataDirectory.IsOverridden }; + var browse = new Button { Content = "Выбрать каталог", IsEnabled = !_dataDirectory.IsOverridden }; + browse.Click += async (_, _) => + { + var picker = new FolderPicker(); + picker.FileTypeFilter.Add("*"); + WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(_window)); + var selected = await picker.PickSingleFolderAsync(); + if (selected is not null) pathBox.Text = selected.Path; + }; + var viewBox = new ComboBox { Header = "Вид ресурсов" }; + viewBox.Items.Add("Сетка"); viewBox.Items.Add("Список"); + viewBox.SelectedIndex = _viewMode == CollectionViewMode.List ? 1 : 0; + var sortBox = new ComboBox { Header = "Сортировка" }; + sortBox.Items.Add("По названию"); sortBox.Items.Add("По дате изменения"); + sortBox.SelectedIndex = _sortMode == ResourceSortMode.Modified ? 1 : 0; + var stack = new StackPanel { Spacing = 12, MinWidth = 400 }; + stack.Children.Add(rootLabel); stack.Children.Add(pathBox); stack.Children.Add(browse); + if (_dataDirectory.IsOverridden) + stack.Children.Add(new TextBlock { Text = "Путь задан извне. Измените параметр запуска или переменную окружения, чтобы выбрать другой каталог.", TextWrapping = TextWrapping.Wrap }); + stack.Children.Add(viewBox); stack.Children.Add(sortBox); + var dialog = new ContentDialog + { + Title = "Настройки", Content = stack, PrimaryButtonText = "Сохранить", CloseButtonText = "Отмена", + DefaultButton = ContentDialogButton.Primary, XamlRoot = XamlRoot, + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + try + { + var requestedRoot = Path.GetFullPath(Path.IsPathFullyQualified(pathBox.Text) ? pathBox.Text : Path.Combine(AppContext.BaseDirectory, pathBox.Text)); + var changedRoot = !string.Equals(requestedRoot, _dataDirectory.GetLocalDataRoot(), StringComparison.OrdinalIgnoreCase); + await SetViewModeAsync(viewBox.SelectedIndex == 1 ? CollectionViewMode.List : CollectionViewMode.Grid, persist: true); + _sortMode = sortBox.SelectedIndex == 1 ? ResourceSortMode.Modified : ResourceSortMode.Title; + SortByTitleItem.IsChecked = _sortMode == ResourceSortMode.Title; + SortByModifiedItem.IsChecked = _sortMode == ResourceSortMode.Modified; + ApplyFilter(); + await SaveSortModeAsync(); + if (changedRoot) await _dataDirectory.RelocateAsync(pathBox.Text, CancellationToken.None); + StatusText.Text = changedRoot ? "Данные скопированы. Перезапустите приложение для перехода в новый каталог." : "Настройки сохранены."; + } + catch (Exception exception) { StatusText.Text = $"Не удалось сохранить настройки: {exception.Message}"; } } private void MainPage_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateResponsiveLayout(e.NewSize.Width); diff --git a/src/PersonalResourceWorkspace.App/MainWindow.xaml.cs b/src/PersonalResourceWorkspace.App/MainWindow.xaml.cs index 82236fe..c9f1a6b 100644 --- a/src/PersonalResourceWorkspace.App/MainWindow.xaml.cs +++ b/src/PersonalResourceWorkspace.App/MainWindow.xaml.cs @@ -17,7 +17,9 @@ public MainWindow( ResourceLaunchService resourceLaunchService, IClipboardService clipboardService, IResourceVisualService resourceVisualService, - ICollectionViewPreferenceStore viewPreferenceStore) + ICollectionViewPreferenceStore viewPreferenceStore, + IUserPreferenceStore userPreferences, + IPortableDataDirectory dataDirectory) { InitializeComponent(); @@ -26,7 +28,7 @@ public MainWindow( AppWindow.SetIcon(Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.ico")); - _mainPage = new MainPage(this, resourceRepository, collectionRepository, resourceLaunchService, clipboardService, resourceVisualService, viewPreferenceStore); + _mainPage = new MainPage(this, resourceRepository, collectionRepository, resourceLaunchService, clipboardService, resourceVisualService, viewPreferenceStore, userPreferences, dataDirectory); RootFrame.Content = _mainPage; Activated += MainWindow_Activated; Closed += MainWindow_Closed; diff --git a/src/PersonalResourceWorkspace.Application/Abstractions/IPortableDataDirectory.cs b/src/PersonalResourceWorkspace.Application/Abstractions/IPortableDataDirectory.cs new file mode 100644 index 0000000..dcc030a --- /dev/null +++ b/src/PersonalResourceWorkspace.Application/Abstractions/IPortableDataDirectory.cs @@ -0,0 +1,9 @@ +namespace PersonalResourceWorkspace.Application.Abstractions; + +public interface IPortableDataDirectory : IAppDataPathProvider +{ + string Source { get; } + bool IsOverridden { get; } + Task InitializeAsync(CancellationToken cancellationToken); + Task RelocateAsync(string destination, CancellationToken cancellationToken); +} diff --git a/src/PersonalResourceWorkspace.Application/Abstractions/IUserPreferenceStore.cs b/src/PersonalResourceWorkspace.Application/Abstractions/IUserPreferenceStore.cs new file mode 100644 index 0000000..0fb1c99 --- /dev/null +++ b/src/PersonalResourceWorkspace.Application/Abstractions/IUserPreferenceStore.cs @@ -0,0 +1,7 @@ +namespace PersonalResourceWorkspace.Application.Abstractions; + +public interface IUserPreferenceStore +{ + Task GetAsync(string key, CancellationToken cancellationToken); + Task SetAsync(string key, string value, CancellationToken cancellationToken); +} diff --git a/src/PersonalResourceWorkspace.Infrastructure/DependencyInjection.cs b/src/PersonalResourceWorkspace.Infrastructure/DependencyInjection.cs index ca2997e..adfffe2 100644 --- a/src/PersonalResourceWorkspace.Infrastructure/DependencyInjection.cs +++ b/src/PersonalResourceWorkspace.Infrastructure/DependencyInjection.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using PersonalResourceWorkspace.Application.Abstractions; using PersonalResourceWorkspace.Infrastructure.Persistence; +using PersonalResourceWorkspace.Infrastructure.Storage; namespace PersonalResourceWorkspace.Infrastructure; @@ -8,6 +10,9 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.TryAddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(serviceProvider => { var pathProvider = serviceProvider.GetRequiredService(); @@ -15,6 +20,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi return new SqliteConnectionFactory(databasePath); }); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionViewPreferenceStore.cs b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionViewPreferenceStore.cs new file mode 100644 index 0000000..400049b --- /dev/null +++ b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteCollectionViewPreferenceStore.cs @@ -0,0 +1,13 @@ +using PersonalResourceWorkspace.Application.Abstractions; +using PersonalResourceWorkspace.Application.Preferences; + +namespace PersonalResourceWorkspace.Infrastructure.Persistence; + +internal sealed class SqliteCollectionViewPreferenceStore(IUserPreferenceStore preferences) : ICollectionViewPreferenceStore +{ + public async Task LoadAsync(CancellationToken cancellationToken) => + CollectionViewModePreference.Parse(await preferences.GetAsync("collection-view", cancellationToken).ConfigureAwait(false)); + + public Task SaveAsync(CollectionViewMode mode, CancellationToken cancellationToken) => + preferences.SetAsync("collection-view", CollectionViewModePreference.Serialize(mode), cancellationToken); +} diff --git a/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteMigrationRunner.cs b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteMigrationRunner.cs index 7b86d2f..d389d19 100644 --- a/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteMigrationRunner.cs +++ b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteMigrationRunner.cs @@ -5,13 +5,14 @@ namespace PersonalResourceWorkspace.Infrastructure.Persistence; -internal sealed class SqliteMigrationRunner(ISqliteConnectionFactory connectionFactory) : IApplicationInitializer +internal sealed class SqliteMigrationRunner(ISqliteConnectionFactory connectionFactory, IAppDataPathProvider pathProvider) : IApplicationInitializer { private const int FoundationMigrationVersion = 1; private const int CollectionsMigrationVersion = 2; private const int TrashCollectionMigrationVersion = 3; private const int ResourceVisualsMigrationVersion = 4; private const int CollectionVisualsMigrationVersion = 5; + private const int SettingsMigrationVersion = 6; private static readonly (string Name, string Glyph, string Color, int SortOrder, bool IsDefault)[] DefaultCollections = [ @@ -71,6 +72,23 @@ public async Task InitializeAsync(CancellationToken cancellationToken) await RecordMigrationAsync(connection, transaction, CollectionVisualsMigrationVersion, cancellationToken).ConfigureAwait(false); } + if (!await IsAppliedAsync(connection, transaction, SettingsMigrationVersion, cancellationToken).ConfigureAwait(false)) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "CREATE TABLE IF NOT EXISTS Settings (Key TEXT NOT NULL PRIMARY KEY, Value TEXT NOT NULL);"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + var legacyView = Path.Combine(pathProvider.GetLocalDataRoot(), "Settings", "collection-view.txt"); + if (File.Exists(legacyView)) + { + var value = await File.ReadAllTextAsync(legacyView, cancellationToken).ConfigureAwait(false); + command.CommandText = "INSERT OR IGNORE INTO Settings(Key, Value) VALUES ('collection-view', $value);"; + command.Parameters.AddWithValue("$value", string.Equals(value.Trim(), "list", StringComparison.OrdinalIgnoreCase) ? "list" : "grid"); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + await RecordMigrationAsync(connection, transaction, SettingsMigrationVersion, cancellationToken).ConfigureAwait(false); + } + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } catch diff --git a/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteUserPreferenceStore.cs b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteUserPreferenceStore.cs new file mode 100644 index 0000000..f509d36 --- /dev/null +++ b/src/PersonalResourceWorkspace.Infrastructure/Persistence/SqliteUserPreferenceStore.cs @@ -0,0 +1,25 @@ +using PersonalResourceWorkspace.Application.Abstractions; + +namespace PersonalResourceWorkspace.Infrastructure.Persistence; + +internal sealed class SqliteUserPreferenceStore(ISqliteConnectionFactory connectionFactory) : IUserPreferenceStore +{ + public async Task GetAsync(string key, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT Value FROM Settings WHERE Key = $key;"; + command.Parameters.AddWithValue("$key", key); + return await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + } + + public async Task SetAsync(string key, string value, CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO Settings(Key, Value) VALUES ($key, $value) ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;"; + command.Parameters.AddWithValue("$key", key); + command.Parameters.AddWithValue("$value", value); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/PersonalResourceWorkspace.Infrastructure/Storage/PortableDataDirectory.cs b/src/PersonalResourceWorkspace.Infrastructure/Storage/PortableDataDirectory.cs new file mode 100644 index 0000000..e1d1673 --- /dev/null +++ b/src/PersonalResourceWorkspace.Infrastructure/Storage/PortableDataDirectory.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using System.Security.Cryptography; +using Microsoft.Data.Sqlite; +using PersonalResourceWorkspace.Application.Abstractions; + +namespace PersonalResourceWorkspace.Infrastructure.Storage; + +public sealed class PortableDataDirectory : IPortableDataDirectory +{ + public const string EnvironmentVariable = "PERSONAL_RESOURCE_WORKSPACE_DATA_DIR"; + private readonly string _baseDirectory; + private readonly string _configurationPath; + private readonly string _root; + + public string Source { get; } + public bool IsOverridden => Source is "command line" or "environment"; + + public PortableDataDirectory() : this(AppContext.BaseDirectory, Environment.GetCommandLineArgs().Skip(1).ToArray(), Environment.GetEnvironmentVariable(EnvironmentVariable)) { } + + public PortableDataDirectory(string baseDirectory, string[] arguments, string? environmentValue) + { + _baseDirectory = Path.GetFullPath(baseDirectory); + _configurationPath = Path.Combine(_baseDirectory, "portable-config.json"); + string? selected = null; + for (var index = 0; index < arguments.Length; index++) + { + if (arguments[index] != "--data-dir") continue; + if (++index >= arguments.Length || string.IsNullOrWhiteSpace(arguments[index])) + throw new ArgumentException("--data-dir requires a directory path."); + selected = arguments[index]; + } + + if (selected is not null) Source = "command line"; + else if (!string.IsNullOrWhiteSpace(environmentValue)) { selected = environmentValue; Source = "environment"; } + else if (File.Exists(_configurationPath)) + { + using var document = JsonDocument.Parse(File.ReadAllText(_configurationPath)); + if (!document.RootElement.TryGetProperty("dataDirectory", out var value) || value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString())) + throw new InvalidDataException("portable-config.json must contain a nonempty dataDirectory string."); + selected = value.GetString(); + Source = "JSON"; + } + else Source = "default"; + + _root = Resolve(selected ?? "content"); + if (File.Exists(_root)) throw new IOException($"Data directory is a file: {_root}"); + } + + public string GetLocalDataRoot() => _root; + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + var legacy = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PersonalResourceWorkspace"); + if ((!Directory.Exists(_root) || !Directory.EnumerateFileSystemEntries(_root).Any()) && + Directory.Exists(legacy) && File.Exists(Path.Combine(legacy, "Data", "workspace.db")) && + !string.Equals(_root, legacy, StringComparison.OrdinalIgnoreCase)) + await CopyWorkspaceAsync(legacy, _root, cancellationToken).ConfigureAwait(false); + Directory.CreateDirectory(_root); + await CheckWritableAsync(_root, cancellationToken).ConfigureAwait(false); + } + + public async Task RelocateAsync(string destination, CancellationToken cancellationToken) + { + if (IsOverridden) throw new InvalidOperationException("The command line or environment overrides the saved data directory."); + var target = Resolve(destination); + if (string.Equals(target, _root, StringComparison.OrdinalIgnoreCase)) return; + if (target.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + _root.StartsWith(target + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new IOException("The destination must not contain the current data directory or be inside it."); + if (Directory.Exists(target) && Directory.EnumerateFileSystemEntries(target).Any()) + throw new IOException("The destination already contains files. Choose an empty directory."); + if (Directory.Exists(target) && (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + throw new IOException("The destination cannot be a symbolic link."); + + ValidateNoLinks(_root); + var sourceBytes = Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).Sum(file => new FileInfo(file).Length); + var targetDrive = new DriveInfo(Path.GetPathRoot(target)!); + if (targetDrive.AvailableFreeSpace < sourceBytes) + throw new IOException("The destination does not have enough free space."); + + await CopyWorkspaceAsync(_root, target, cancellationToken).ConfigureAwait(false); + var temporary = _configurationPath + ".tmp"; + try + { + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(new { dataDirectory = target }), cancellationToken).ConfigureAwait(false); + File.Move(temporary, _configurationPath, true); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + private string Resolve(string value) => Path.GetFullPath(Path.IsPathFullyQualified(value) ? value : Path.Combine(_baseDirectory, value)); + + private static async Task CheckWritableAsync(string directory, CancellationToken token) + { + var probe = Path.Combine(directory, $".write-probe-{Guid.NewGuid():N}"); + try { await File.WriteAllTextAsync(probe, string.Empty, token).ConfigureAwait(false); } + finally { if (File.Exists(probe)) File.Delete(probe); } + } + + private static async Task CopyWorkspaceAsync(string source, string destination, CancellationToken token) + { + if (Directory.Exists(destination) && Directory.EnumerateFileSystemEntries(destination).Any()) + throw new IOException("The destination is not empty."); + var staging = destination + $".copy-{Guid.NewGuid():N}"; + try + { + Directory.CreateDirectory(staging); + await CheckWritableAsync(staging, token).ConfigureAwait(false); + if ((File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0) + throw new IOException("The data directory cannot be a symbolic link."); + ValidateNoLinks(source); + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + token.ThrowIfCancellationRequested(); + var relative = Path.GetRelativePath(source, file); + if (relative.Split(Path.DirectorySeparatorChar).Any(segment => segment is "." or "..")) + throw new IOException("Unsafe source path."); + var info = new FileInfo(file); + if ((info.Attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException($"Symbolic links are not supported in the data directory: {file}"); + var target = Path.Combine(staging, relative); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (relative.Equals(Path.Combine("Data", "workspace.db"), StringComparison.OrdinalIgnoreCase)) + { + using var input = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = file, Mode = SqliteOpenMode.ReadOnly, Pooling = false }.ToString()); + using var output = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = target, Pooling = false }.ToString()); + input.Open(); output.Open(); input.BackupDatabase(output); + } + else if (!file.EndsWith("-wal", StringComparison.OrdinalIgnoreCase) && !file.EndsWith("-shm", StringComparison.OrdinalIgnoreCase)) + { + File.Copy(file, target); + using var originalStream = File.OpenRead(file); + using var copiedStream = File.OpenRead(target); + if (!SHA256.HashData(originalStream).SequenceEqual(SHA256.HashData(copiedStream))) + throw new IOException($"File verification failed: {relative}"); + } + } + var database = Path.Combine(staging, "Data", "workspace.db"); + if (File.Exists(Path.Combine(source, "Data", "workspace.db")) && !File.Exists(database)) + throw new IOException("Database copy is missing."); + if (File.Exists(database)) + { + using var verified = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = database, Mode = SqliteOpenMode.ReadOnly, Pooling = false }.ToString()); + verified.Open(); + using var check = verified.CreateCommand(); + check.CommandText = "PRAGMA quick_check;"; + if (!string.Equals(check.ExecuteScalar() as string, "ok", StringComparison.OrdinalIgnoreCase)) + throw new IOException("Database verification failed."); + } + if (Directory.Exists(destination)) Directory.Delete(destination); + Directory.Move(staging, destination); + } + catch + { + if (Directory.Exists(staging)) Directory.Delete(staging, true); + throw; + } + } + + private static void ValidateNoLinks(string directory) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + if ((File.GetAttributes(entry) & FileAttributes.ReparsePoint) != 0) + throw new IOException($"Symbolic links are not supported in the data directory: {entry}"); + if (Directory.Exists(entry)) ValidateNoLinks(entry); + } + } +} diff --git a/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs b/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs index a6e8159..6573d49 100644 --- a/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs +++ b/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs @@ -2,7 +2,6 @@ using PersonalResourceWorkspace.Application.Abstractions; using PersonalResourceWorkspace.Windows.Clipboard; using PersonalResourceWorkspace.Windows.Shell; -using PersonalResourceWorkspace.Windows.Storage; using PersonalResourceWorkspace.Windows.Visuals; namespace PersonalResourceWorkspace.Windows; @@ -11,8 +10,6 @@ public static class DependencyInjection { public static IServiceCollection AddWindowsPlatform(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PersonalResourceWorkspace.Windows/Storage/FileCollectionViewPreferenceStore.cs b/src/PersonalResourceWorkspace.Windows/Storage/FileCollectionViewPreferenceStore.cs deleted file mode 100644 index ce6c0d7..0000000 --- a/src/PersonalResourceWorkspace.Windows/Storage/FileCollectionViewPreferenceStore.cs +++ /dev/null @@ -1,38 +0,0 @@ -using PersonalResourceWorkspace.Application.Abstractions; -using PersonalResourceWorkspace.Application.Preferences; - -namespace PersonalResourceWorkspace.Windows.Storage; - -internal sealed class FileCollectionViewPreferenceStore(IAppDataPathProvider pathProvider) - : ICollectionViewPreferenceStore -{ - private readonly string _settingsPath = Path.Combine( - pathProvider.GetLocalDataRoot(), - "Settings", - "collection-view.txt"); - - public async Task LoadAsync(CancellationToken cancellationToken) - { - if (!File.Exists(_settingsPath)) - { - return CollectionViewMode.Grid; - } - - var value = await File.ReadAllTextAsync(_settingsPath, cancellationToken).ConfigureAwait(false); - return CollectionViewModePreference.Parse(value.Trim()); - } - - public async Task SaveAsync(CollectionViewMode mode, CancellationToken cancellationToken) - { - var directory = Path.GetDirectoryName(_settingsPath) - ?? throw new InvalidOperationException("The view settings path has no parent directory."); - Directory.CreateDirectory(directory); - - var temporaryPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp"; - await File.WriteAllTextAsync( - temporaryPath, - CollectionViewModePreference.Serialize(mode), - cancellationToken).ConfigureAwait(false); - File.Move(temporaryPath, _settingsPath, overwrite: true); - } -} diff --git a/src/PersonalResourceWorkspace.Windows/Storage/UnpackagedAppDataPathProvider.cs b/src/PersonalResourceWorkspace.Windows/Storage/UnpackagedAppDataPathProvider.cs deleted file mode 100644 index d305b50..0000000 --- a/src/PersonalResourceWorkspace.Windows/Storage/UnpackagedAppDataPathProvider.cs +++ /dev/null @@ -1,12 +0,0 @@ -using PersonalResourceWorkspace.Application.Abstractions; - -namespace PersonalResourceWorkspace.Windows.Storage; - -internal sealed class UnpackagedAppDataPathProvider : IAppDataPathProvider -{ - public string GetLocalDataRoot() - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(localAppData, "PersonalResourceWorkspace"); - } -} diff --git a/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/PortableDataDirectoryTests.cs b/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/PortableDataDirectoryTests.cs new file mode 100644 index 0000000..5b4a65f --- /dev/null +++ b/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/PortableDataDirectoryTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using PersonalResourceWorkspace.Infrastructure.Storage; + +namespace PersonalResourceWorkspace.Infrastructure.Tests.Persistence; + +public sealed class PortableDataDirectoryTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"prw-portable-{Guid.NewGuid():N}"); + + [Fact] + public void ResolvesSourcesInPriorityOrderRelativeToExecutable() + { + Directory.CreateDirectory(_root); + Assert.Equal(Path.Combine(_root, "content"), new PortableDataDirectory(_root, [], null).GetLocalDataRoot()); + File.WriteAllText(Path.Combine(_root, "portable-config.json"), "{\"dataDirectory\":\"json-data\"}"); + Assert.Equal(Path.Combine(_root, "json-data"), new PortableDataDirectory(_root, [], null).GetLocalDataRoot()); + Assert.Equal(Path.Combine(_root, "env-data"), new PortableDataDirectory(_root, [], "env-data").GetLocalDataRoot()); + var cli = new PortableDataDirectory(_root, ["--data-dir", "cli-data"], "env-data"); + Assert.Equal(Path.Combine(_root, "cli-data"), cli.GetLocalDataRoot()); + Assert.True(cli.IsOverridden); + } + + [Fact] + public async Task RelocateCopiesDatabaseAndAssetsThenUpdatesConfig() + { + var source = Path.Combine(_root, "content"); + Directory.CreateDirectory(Path.Combine(source, "Data")); + Directory.CreateDirectory(Path.Combine(source, "Data", "Visuals", "user")); + File.WriteAllText(Path.Combine(source, "Data", "Visuals", "user", "icon.txt"), "asset"); + using (var connection = new SqliteConnection($"Data Source={Path.Combine(source, "Data", "workspace.db")}")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE Sample (Value TEXT); INSERT INTO Sample VALUES ('preserved');"; + command.ExecuteNonQuery(); + } + + var manager = new PortableDataDirectory(_root, [], null); + var target = Path.Combine(_root, "moved"); + await manager.RelocateAsync(target, CancellationToken.None); + Assert.True(File.Exists(Path.Combine(source, "Data", "workspace.db"))); + Assert.Equal("asset", File.ReadAllText(Path.Combine(target, "Data", "Visuals", "user", "icon.txt"))); + using var copied = new SqliteConnection($"Data Source={Path.Combine(target, "Data", "workspace.db")}"); + copied.Open(); + using var query = copied.CreateCommand(); + query.CommandText = "SELECT Value FROM Sample;"; + Assert.Equal("preserved", query.ExecuteScalar()); + using var config = JsonDocument.Parse(File.ReadAllText(Path.Combine(_root, "portable-config.json"))); + Assert.Equal(target, config.RootElement.GetProperty("dataDirectory").GetString()); + } + + [Fact] + public async Task ExistingTargetIsNotOverwritten() + { + Directory.CreateDirectory(Path.Combine(_root, "content")); + var target = Path.Combine(_root, "existing"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "keep.txt"), "keep"); + var manager = new PortableDataDirectory(_root, [], null); + await Assert.ThrowsAsync(() => manager.RelocateAsync(target, CancellationToken.None)); + Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt"))); + Assert.False(File.Exists(Path.Combine(_root, "portable-config.json"))); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } +} diff --git a/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteMigrationTests.cs b/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteMigrationTests.cs index 0dd8af5..4d4fc4a 100644 --- a/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteMigrationTests.cs +++ b/tests/PersonalResourceWorkspace.Infrastructure.Tests/Persistence/SqliteMigrationTests.cs @@ -12,6 +12,20 @@ public sealed class SqliteMigrationTests : IAsyncLifetime public Task InitializeAsync() => Task.CompletedTask; + [Fact] + public async Task LegacyViewPreferenceMovesIntoDatabase() + { + var settings = Path.Combine(_temporaryRoot, "Settings"); + Directory.CreateDirectory(settings); + File.WriteAllText(Path.Combine(settings, "collection-view.txt"), "list"); + await using var provider = CreateServices(); + await provider.GetRequiredService().InitializeAsync(CancellationToken.None); + var preferences = provider.GetRequiredService(); + Assert.Equal("list", await preferences.GetAsync("collection-view", CancellationToken.None)); + await preferences.SetAsync("sort-mode", "modified", CancellationToken.None); + Assert.Equal("modified", await preferences.GetAsync("sort-mode", CancellationToken.None)); + } + public Task DisposeAsync() { SqliteConnection.ClearAllPools();