diff --git a/CMakeLists.txt b/CMakeLists.txt index 94961c6..6cb2f37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,10 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) +# --- Тесты (объявляем до загрузки зависимостей: их CMake могут трогать BUILD_TESTING) --- +set(BUILD_TESTING ON CACHE BOOL "Строить тесты" FORCE) +include(CTest) + # Десктоп: системный OpenSSL (для libssh2/libgit2 HTTPS/SSH). find_package(OpenSSL REQUIRED) message(STATUS "OpenSSL: ${OPENSSL_VERSION}") @@ -32,10 +36,16 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/third_party/deps.cmake) set(FLORA_HEADERS src/app/VaultManager.h + src/core/SettingsStore.h + src/git/GitManager.h + src/sync/SyncManager.h ) set(FLORA_SOURCES src/app/VaultManager.cpp + src/core/SettingsStore.cpp + src/git/GitManager.cpp + src/sync/SyncManager.cpp ) # --- Ядро (C++ бэкенд) --- @@ -84,4 +94,11 @@ qt_add_qml_module(flora qml/panels/FileTreePanel.qml qml/panels/EditorPanel.qml qml/panels/RightPanel.qml -) \ No newline at end of file + qml/wizard/WelcomeWizard.qml + qml/dialogs/ProviderSetupGuide.qml +) + +# --- Тесты --- +if(BUILD_TESTING) + add_subdirectory(tests) +endif() \ No newline at end of file diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml index 464327f..d03aead 100644 --- a/qml/MainWindow.qml +++ b/qml/MainWindow.qml @@ -3,7 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts // Главное окно: меню вверху, три панели (дерево / редактор / правая). -// Фаза 0: каркас. Содержимое панелей наполняется в следующих фазах. +// Фазы 0-1: каркас + мастер первого запуска. Контент панелей — в следующих фазах. ApplicationWindow { id: window width: 1280 @@ -12,10 +12,27 @@ ApplicationWindow { title: "Flora" color: "white" + // Мастер первого запуска: показываем, если ещё нет ни одного хранилища. + WelcomeWizard { + id: welcomeWizard + firstRun: vaultManager.vaults.length === 0 + onFinished: vaultSwitcher.close() + } + + Component.onCompleted: { + if (vaultManager.vaults.length === 0) + welcomeWizard.open() + } + + function openWizard() { + welcomeWizard.firstRun = false + welcomeWizard.open() + } + menuBar: MenuBar { Menu { title: qsTr("&Файл") - Action { text: qsTr("Открыть хранилище…"); onTriggered: vaultSwitcher.open() } + Action { text: qsTr("Добавить хранилище…"); onTriggered: window.openWizard() } Menu { title: qsTr("Экспорт") Action { text: qsTr("В HTML…"); enabled: false } @@ -60,20 +77,60 @@ ApplicationWindow { } } + // Переключатель хранилищ (кнопка внизу слева, в FileTreePanel) Popup { id: vaultSwitcher x: 12 y: window.height - height - 12 - width: 280 + width: 320 modal: false closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + ColumnLayout { width: parent.width - Label { text: qsTr("Хранилища (Фаза 2)") } - Button { text: qsTr("Это окно: %1").arg(vaultManager.currentVault) } + spacing: 6 + + Label { + text: qsTr("Хранилища") + font.bold: true + } + + ListView { + id: vaultList + Layout.preferredHeight: Math.min(200, contentHeight) + Layout.fillWidth: true + model: vaultManager.vaults + clip: true + delegate: ItemDelegate { + width: ListView.view.width + text: modelData + highlighted: modelData === vaultManager.currentVault + onClicked: { + if (modelData !== vaultManager.currentVault) { + vaultManager.selectVault(modelData) + } + } + } + } + + Label { + visible: vaultList.count === 0 + text: qsTr("Хранилищ пока нет") + color: "#888" + } + + Label { + text: qsTr("Текущее: %1").arg(vaultManager.currentVault.length > 0 + ? vaultManager.currentVault : "—") + color: "#666" + elide: Text.ElideMiddle + Layout.fillWidth: true + } + Button { + Layout.fillWidth: true text: qsTr("Добавить новое хранилище…") - onClicked: { vaultSwitcher.close(); vaultManager.setCurrentVault("/") } + onClicked: { vaultSwitcher.close(); window.openWizard() } } } } diff --git a/qml/dialogs/ProviderSetupGuide.qml b/qml/dialogs/ProviderSetupGuide.qml new file mode 100644 index 0000000..d4564b9 --- /dev/null +++ b/qml/dialogs/ProviderSetupGuide.qml @@ -0,0 +1,184 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Диалог настройки провайдера синхронизации с пошаговой инструкцией, +// проверкой соединения и каталогом ошибок (ErrorCatalog в SyncManager). +Dialog { + id: root + + property int providerIndex: -1 + property string providerName: "" + property bool connected: false + + signal saved(int index) + + readonly property bool isGit: providerIndex <= 2 + + title: qsTr("Подключение: %1").arg(providerName) + modal: true + width: 640 + height: 520 + anchors.centerIn: parent + closePolicy: Dialog.NoAutoClose + + function defaultUrl() { + switch (root.providerIndex) { + case 0: return "https://github.com" + case 1: return "https://gitlab.com" + case 2: return "http://localhost:3000" + case 3: return "https://nextcloud.local/remote.php/dav/files/USER" + default: return "http://127.0.0.1:8384" + } + } + + function tokenName() { + switch (root.providerIndex) { + case 0: return qsTr("Токен GitHub (Personal Access Token)") + case 1: return qsTr("Токен GitLab (PRIVATE-TOKEN)") + case 2: return qsTr("Токен Gitea (Personal Access Token)") + case 3: return qsTr("Пароль от Nextcloud") + default: return qsTr("API-ключ Syncthing") + } + } + + Component.onCompleted: { urlField.text = defaultUrl() } + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + // Инструкция + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 150 + color: "#f4f6fa" + radius: 6 + Flickable { + anchors.fill: parent + anchors.margins: 8 + ScrollBar.vertical: ScrollBar {} + clip: true + contentWidth: width + contentHeight: instText.implicitHeight + Text { + id: instText + width: parent.width + wrapMode: Text.Wrap + textFormat: Text.MarkdownText + color: "#333" + text: root.providerIndex === 0 + ? "**Как подключить GitHub:**\n\n" + + "1. Создайте токен: [github.com/settings/tokens](https://github.com/settings/tokens) — scope **repo** (классический) или Fine-grained с правом **Contents: Read and write**.\n" + + "2. Создайте **пустой** репозиторий (без README/.gitignore — иначе конфликт истории): New repository.\n" + + "3. Вставьте токен ниже и нажмите «Проверить».\n" + + "4. Альтернатива — SSH-ключ (Подключение → SSH, доступно в Фазе 11)." + : root.providerIndex === 1 + ? "**Как подключить GitLab:**\n\n" + + "1. Токен: Настройки → Access Tokens — scope **api** (или **read_repository** + **write_repository**).\n" + + "2. Создайте **пустой** репозиторий в своём аккаунте/группе.\n" + + "3. Для self-hosted: укажите адрес инстанса, например http://gitlab.local. Вставьте токен и нажмите «Проверить».\n" + + "4. Альтернатива — SSH-ключ (доступно в Фазе 11)." + : root.providerIndex === 2 + ? "**Как подключить Gitea:**\n\n" + + "1. Токен: Настройки аккаунта → Приложения → **Создать новый токен** (scope **repo**).\n" + + "2. Создайте **пустой** репозиторий на своём инстансе.\n" + + "3. Укажите адрес инстанса (например http://localhost:3000), вставьте токен и нажмите «Проверить».\n" + + "4. Альтернатива — SSH-ключ (доступно в Фазе 11)." + : root.providerIndex === 3 + ? "**Как подключить Nextcloud (WebDAV):**\n\n" + + "1. Откройте приложение Files, создайте папку для синхронизации (например **Flora**).\n" + + "2. Адрес WebDAV: https://ВАШ_ХОСТ/remote.php/dav/files/ВАШ_ЛОГИН/\n" + + "3. Укажите логин и пароль аккаунта Nextcloud и нажмите «Проверить».\n" + + "4. Рекомендуется создать пароль приложения в Настройках → Безопасность." + : "**Как подключить Syncthing:**\n\n" + + "1. Установите и запустите Syncthing на этом устройстве.\n" + + "2. API-ключ: Действия → Настройки → Показывать расширенное → API-ключ.\n" + + "3. Адрес API по умолчанию: http://127.0.0.1:8384. Вставьте ключ и нажмите «Проверить».\n" + + "4. Затем добавьте папку хранилища в Syncthing как обычную папку." + } + } + } + + // Поля + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + Label { text: qsTr("Адрес сервера") } + TextField { id: urlField; Layout.fillWidth: true } + Label { + visible: root.providerIndex === 3 + text: qsTr("Логин (для WebDAV):") + } + TextField { + id: userField + Layout.fillWidth: true + visible: root.providerIndex === 3 + placeholderText: qsTr("Логин Nextcloud") + } + Label { text: tokenName() } + TextField { + id: tokenField + Layout.fillWidth: true + echoMode: TextInput.Password + placeholderText: qsTr("Вставьте значение") + } + } + + // Результат / ошибка + Label { + id: resultLabel + Layout.fillWidth: true + wrapMode: Text.Wrap + visible: text.length > 0 + color: root.connected ? "green" : "#b00020" + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + Button { + text: qsTr("Отмена") + onClicked: root.reject() + } + Item { Layout.fillWidth: true } + Button { + text: qsTr("Проверить соединение") + enabled: tokenField.text.length > 0 && !syncManager.busy + onClicked: { + resultLabel.text = "" + root.connected = false + syncManager.testConnection(root.providerIndex, + urlField.text, userField.text, + tokenField.text) + } + } + Button { + text: qsTr("Сохранить") + enabled: root.connected + onClicked: { + settingsStore.addProvider(root.providerName, { + url: urlField.text, + user: userField.text, + token: tokenField.text, + }) + root.saved(root.providerIndex) + root.accept() + } + } + } + } + + Connections { + target: syncManager + function onConnectionResult(provider, ok, code, title, hint, raw) { + if (provider !== root.providerIndex) + return + root.connected = ok + resultLabel.text = ok + ? qsTr("Соединение установлено. Нажмите «Сохранить».") + : title + (hint && hint.length > 0 ? "\n" + hint : "") + } + } +} \ No newline at end of file diff --git a/qml/wizard/WelcomeWizard.qml b/qml/wizard/WelcomeWizard.qml new file mode 100644 index 0000000..4a9e517 --- /dev/null +++ b/qml/wizard/WelcomeWizard.qml @@ -0,0 +1,164 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +// Мастер первого запуска / добавления хранилища: +// 1) выбор папки (новая или с файлами); +// 2) автосоздание git-репозитория + подключение провайдеров (опционально); +// 3) готово. +Dialog { + id: root + + required property bool firstRun + + readonly property int stepFolder: 0 + readonly property int stepSetup: 1 + property int step: stepFolder + property string vaultPath: "" + property string setupStatus: "" + + signal finished() + + title: firstRun ? qsTr("Первое знакомство: создайте хранилище") + : qsTr("Новое хранилище") + modal: true + width: 560 + height: 480 + anchors.centerIn: parent + closePolicy: Dialog.NoAutoClose + + StackLayout { + id: stack + anchors.fill: parent + currentIndex: root.step + + // ---------- Шаг 1: выбор папки ---------- + ColumnLayout { + spacing: 12 + Label { + text: qsTr("Выберите локальную папку — это будет ваше хранилище.\n" + + "Можно указать новую папку (будет создана) или уже существующую с файлами.") + wrapMode: Text.Wrap + Layout.fillWidth: true + } + RowLayout { + Layout.fillWidth: true + TextField { + id: pathField + Layout.fillWidth: true + placeholderText: qsTr("Путь к папке…") + text: root.vaultPath + onTextChanged: root.vaultPath = text + } + Button { + text: qsTr("Обзор…") + onClicked: folderDialog.open() + } + } + FolderDialog { + id: folderDialog + title: qsTr("Выберите папку хранилища") + onAccepted: root.vaultPath = selectedFolder.localFile ?? "" + } + Label { + id: step1Error + color: "red" + wrapMode: Text.Wrap + Layout.fillWidth: true + text: "" + } + Item { Layout.fillHeight: true } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: qsTr("Отмена") + onClicked: root.reject() + } + Button { + text: qsTr("Далее") + enabled: root.vaultPath.length > 0 && !busyOverlay.running + onClicked: { + step1Error.text = "" + busyOverlay.running = true + const err = vaultManager.createVault(root.vaultPath) + busyOverlay.running = false + if (err.length > 0) { + step1Error.text = err + return + } + root.step = stepSetup + } + } + } + } + + // ---------- Шаг 2: git + провайдеры ---------- + ColumnLayout { + spacing: 12 + Label { + text: qsTr("Хранилище готово. Внутри автоматически создан git-репозиторий.\n" + + "При желании подключите синхронизацию:") + wrapMode: Text.Wrap + Layout.fillWidth: true + } + GridLayout { + columns: 2 + Layout.fillWidth: true + Layout.fillHeight: true + columnSpacing: 8 + rowSpacing: 8 + + Button { text: "GitHub"; Layout.fillWidth: true; onClicked: openProviderGuide(0, "GitHub") } + Button { text: "GitLab"; Layout.fillWidth: true; onClicked: openProviderGuide(1, "GitLab") } + Button { text: "Gitea"; Layout.fillWidth: true; onClicked: openProviderGuide(2, "Gitea") } + Button { text: "Nextcloud (WebDAV)"; Layout.fillWidth: true; onClicked: openProviderGuide(3, "Nextcloud (WebDAV)") } + Button { text: "Syncthing"; Layout.fillWidth: true; onClicked: openProviderGuide(4, "Syncthing") } + + function openProviderGuide(idx, name) { + providerGuideForButtons.providerIndex = idx + providerGuideForButtons.providerName = name + providerGuideForButtons.connected = false + providerGuideForButtons.open() + } + Label { + color: "#888" + Layout.columnSpan: 2 + } + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: qsTr("Готово") + onClicked: { + vaultManager.selectVault(root.vaultPath) + root.accept() + root.finished() + } + } + } + } + } + + BusyIndicator { + id: busyOverlay + anchors.centerIn: parent + running: false + visible: running + } + + ProviderSetupGuide { + id: providerGuideForButtons + } + + onRejected: { + if (firstRun) { + // Пусть пользователь продолжит позже; хранилище не выбрано. + Qt.callLater(() => { console.log("Мастер отменён") }) + visible = false + } + } +} \ No newline at end of file diff --git a/src/app/VaultManager.cpp b/src/app/VaultManager.cpp index f8f3a71..57dfb81 100644 --- a/src/app/VaultManager.cpp +++ b/src/app/VaultManager.cpp @@ -1,27 +1,66 @@ #include "app/VaultManager.h" -VaultManager::VaultManager(QObject *parent) +#include "core/SettingsStore.h" +#include "git/GitManager.h" + +#include + +VaultManager::VaultManager(SettingsStore *settings, GitManager *git, QObject *parent) : QObject(parent) + , m_settings(settings) + , m_git(git) { + connect(m_settings, &SettingsStore::vaultsChanged, + this, &VaultManager::vaultsChanged); + connect(m_settings, &SettingsStore::currentVaultChanged, + this, &VaultManager::currentVaultChanged); } QStringList VaultManager::vaults() const { - return m_vaults; + return m_settings->vaults(); } QString VaultManager::currentVault() const { - return m_currentVault; + return m_settings->currentVault(); } -void VaultManager::setCurrentVault(const QString &path) +QString VaultManager::createVault(const QString &path) { - if (m_currentVault == path) - return; - m_currentVault = path; - if (!m_vaults.contains(path)) - m_vaults.append(path); - emit currentVaultChanged(); - emit vaultsChanged(); + if (path.trimmed().isEmpty()) + return QStringLiteral("Не указан путь к хранилищу."); + + if (!QDir().mkpath(path)) + return QStringLiteral("Не удалось создать папку «%1».").arg(path); + + if (!m_git->initRepo(path)) { + emit operationError(m_git->lastError()); + return m_git->lastError(); + } + + m_settings->addVault(path); + return QString(); +} + +QString VaultManager::openVault(const QString &path) +{ + if (path.trimmed().isEmpty()) + return QStringLiteral("Не указан путь к хранилищу."); + + if (!QDir(path).exists()) + return QStringLiteral("Папка «%1» не существует.").arg(path); + + if (!m_git->initRepo(path)) { + emit operationError(m_git->lastError()); + return m_git->lastError(); + } + + m_settings->addVault(path); + return QString(); +} + +void VaultManager::selectVault(const QString &path) +{ + m_settings->setCurrentVault(path); } \ No newline at end of file diff --git a/src/app/VaultManager.h b/src/app/VaultManager.h index a219f65..7aba14f 100644 --- a/src/app/VaultManager.h +++ b/src/app/VaultManager.h @@ -3,29 +3,42 @@ #include #include -// Менеджер хранилищ: список известных хранилищ, основное хранилище, -// открытие нового хранилища в новом окне (мульти-окна). -// Реализация заполняется в Фазе 2; здесь — только каркас для скелета (Фаза 0). +class SettingsStore; +class GitManager; + +// Менеджер хранилищ: список, создание/открытие, git-инициализация. +// Фаза 1: создание хранилища + автосоздание git-репозитория. +// Фаза 2 добавит мульти-окна (новое хранилище в новом окне). class VaultManager : public QObject { Q_OBJECT Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged) - Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault - NOTIFY currentVaultChanged) + Q_PROPERTY(QString currentVault READ currentVault NOTIFY currentVaultChanged) public: - explicit VaultManager(QObject *parent = nullptr); + VaultManager(SettingsStore *settings, GitManager *git, QObject *parent = nullptr); QStringList vaults() const; QString currentVault() const; - void setCurrentVault(const QString &path); + + // Создаёт хранилище: папка + git-репозиторий внутри. Возвращает "" при успехе + // или текст ошибки. + Q_INVOKABLE QString createVault(const QString &path); + + // Открывает существующее хранилище (git-репозиторий внутри не обязателен — + // создаётся при необходимости). Возвращает текст ошибки или "". + Q_INVOKABLE QString openVault(const QString &path); + + // Выбор хранилища пользователем по завершении мастера. + Q_INVOKABLE void selectVault(const QString &path); signals: void vaultsChanged(); void currentVaultChanged(); + void operationError(const QString &message); private: - QStringList m_vaults; - QString m_currentVault; + SettingsStore *m_settings; + GitManager *m_git; }; \ No newline at end of file diff --git a/src/core/SettingsStore.cpp b/src/core/SettingsStore.cpp new file mode 100644 index 0000000..87b5910 --- /dev/null +++ b/src/core/SettingsStore.cpp @@ -0,0 +1,97 @@ +#include "core/SettingsStore.h" + +#include + +namespace { +constexpr auto kGroupVaults = "vaults"; +constexpr auto kKeyVaults = "vaults/vaultList"; +constexpr auto kKeyCurrent = "vaults/current"; +constexpr auto kKeyProviders = "sync/providers"; +} + +SettingsStore::SettingsStore(QObject *parent) + : QObject(parent) +{ + QSettings s; + m_providers = s.value(kKeyProviders).toList(); +} + +QStringList SettingsStore::vaults() const +{ + QSettings s; + return s.value(kKeyVaults).toStringList(); +} + +QString SettingsStore::currentVault() const +{ + QSettings s; + return s.value(kKeyCurrent).toString(); +} + +void SettingsStore::setCurrentVault(const QString &path) +{ + QSettings s; + s.setValue(kKeyCurrent, path); + emit currentVaultChanged(); +} + +QVariantList SettingsStore::providers() const +{ + return m_providers; +} + +void SettingsStore::addVault(const QString &path) +{ + QStringList list = vaults(); + if (!list.contains(path)) + list.prepend(path); + QSettings s; + s.setValue(kKeyVaults, list); + s.setValue(kKeyCurrent, path); + emit vaultsChanged(); + emit currentVaultChanged(); +} + +void SettingsStore::addProvider(const QString &provider, const QVariantMap &config) +{ + QVariantList list = m_providers; + for (auto &item : list) { + auto m = item.toMap(); + if (m.value("provider") == provider) { + m = config; + m["provider"] = provider; + item = m; + m_providers = list; + goto save; + } + } + { + auto m = config; + m["provider"] = provider; + list.append(m); + m_providers = list; + } +save: + QSettings s; + s.setValue(kKeyProviders, m_providers); + emit providersChanged(); +} + +bool SettingsStore::hasVault(const QString &path) const +{ + return vaults().contains(path); +} + +void SettingsStore::clearProvider(const QString &provider) +{ + QVariantList list = m_providers; + list.erase(std::remove_if(list.begin(), list.end(), + [&provider](const QVariant &v) { + return v.toMap().value("provider") == provider; + }), + list.end()); + m_providers = list; + QSettings s; + s.setValue(kKeyProviders, m_providers); + emit providersChanged(); +} \ No newline at end of file diff --git a/src/core/SettingsStore.h b/src/core/SettingsStore.h new file mode 100644 index 0000000..e7eeaf9 --- /dev/null +++ b/src/core/SettingsStore.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +// Хранение настроек приложения (QSettings): +// список хранилищ, основное хранилище, конфигурации провайдеров синхронизации. +class SettingsStore : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged) + Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault + NOTIFY currentVaultChanged) + Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged) + +public: + explicit SettingsStore(QObject *parent = nullptr); + + QStringList vaults() const; + QString currentVault() const; + void setCurrentVault(const QString &path); + + QVariantList providers() const; + + // Добавляет хранилище в список (если ещё нет) и делает его текущим. + void addVault(const QString &path); + + // Сохраняет конфигурацию провайдера (provider: "github"/"gitlab"/"gitea"/"webdav"/"syncthing"). + void addProvider(const QString &provider, const QVariantMap &config); + + Q_INVOKABLE bool hasVault(const QString &path) const; + Q_INVOKABLE void clearProvider(const QString &provider); + +signals: + void vaultsChanged(); + void currentVaultChanged(); + void providersChanged(); + +private: + QVariantList m_providers; +}; \ No newline at end of file diff --git a/src/git/GitManager.cpp b/src/git/GitManager.cpp new file mode 100644 index 0000000..fe8c04b --- /dev/null +++ b/src/git/GitManager.cpp @@ -0,0 +1,193 @@ +#include "git/GitManager.h" + +#include +#include + +namespace { + +QString gitErr() +{ + const git_error *e = git_error_last(); + return e && e->message ? QString::fromUtf8(e->message) : QStringLiteral("неизвестная ошибка git"); +} + +struct CommitInfo { + git_signature *author = nullptr; + git_index *index = nullptr; + git_repository *repo = nullptr; + git_oid treeId{}; + git_oid commitId{}; + ~CommitInfo() + { + if (author) git_signature_free(author); + if (index) git_index_free(index); + if (repo) git_repository_free(repo); + } +}; + +} // namespace + +GitManager::GitManager(QObject *parent) + : QObject(parent) +{ + if (!m_gitInitialized) { + git_libgit2_init(); + m_gitInitialized = true; + } +} + +QString GitManager::repoExists(const QString &path) +{ + git_repository *repo = nullptr; + if (git_repository_open(&repo, path.toUtf8().constData()) < 0) + return QString(); + git_repository_free(repo); + return path; +} + +void GitManager::setRepoIdentity(git_repository *repo) +{ + git_config *cfg = nullptr; + if (git_repository_config(&cfg, repo) < 0) + return; + + auto setIfMissing = [cfg](const char *key, const char *value) { + git_buf buf{}; + if (git_config_get_string_buf(&buf, cfg, key) < 0) + git_config_set_string(cfg, key, value); + git_buf_dispose(&buf); + }; + setIfMissing("user.name", "Flora"); + setIfMissing("user.email", "flora@localhost"); + git_config_free(cfg); +} + +void GitManager::setLastError(const QString &msg) +{ + if (m_lastError == msg) + return; + m_lastError = msg; + emit lastErrorChanged(); +} + +bool GitManager::initRepo(const QString &path) +{ + const QByteArray p = path.toUtf8(); + git_repository *repo = nullptr; + + if (git_repository_open(&repo, p.constData()) == 0) { + git_repository_free(repo); + return true; // .git уже есть + } + + git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT; + opts.flags = GIT_REPOSITORY_INIT_MKPATH | GIT_REPOSITORY_INIT_MKDIR; + opts.initial_head = "main"; + + const int rc = git_repository_init_ext(&repo, p.constData(), &opts); + if (rc < 0) { + setLastError(QStringLiteral("Не удалось создать git-репозиторий: %1") + .arg(gitErr())); + return false; + } + setRepoIdentity(repo); + git_repository_free(repo); + setLastError(QString()); + return true; +} + +bool GitManager::hasCommits(const QString &path) const +{ + git_repository *repo = nullptr; + if (git_repository_open(&repo, path.toUtf8().constData()) < 0) + return false; + const bool has = git_repository_head_unborn(repo) == 0; + git_repository_free(repo); + return has; +} + +bool GitManager::commitAll(const QString &path, const QString &message) +{ + CommitInfo info; + if (git_repository_open(&info.repo, path.toUtf8().constData()) < 0) { + setLastError(QStringLiteral("Хранилище не является git-репозиторием")); + return false; + } + + if (git_repository_index(&info.index, info.repo) < 0) { + setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr())); + return false; + } + + // Добавляем все файлы, включая новые (с учётом .gitignore) + git_index_add_all(info.index, nullptr, 0, nullptr, nullptr); + const int wrc = git_index_write(info.index); + if (wrc < 0) { + setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr())); + return false; + } + + if (git_index_entrycount(info.index) == 0) { + setLastError(QStringLiteral("Нет изменений для коммита")); + return false; + } + + // Создаём дерево + git_tree *tree = nullptr; + if (git_index_write_tree(&info.treeId, info.index) < 0 + || git_tree_lookup(&tree, info.repo, &info.treeId) < 0) { + setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr())); + return false; + } + + // Родитель: head, если коммиты уже есть + git_reference *headRef = nullptr; + git_commit *parentCommit = nullptr; + int nParents = 0; + if (git_repository_head(&headRef, info.repo) == 0) { + git_object *obj = nullptr; + if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) { + parentCommit = reinterpret_cast(obj); + nParents = 1; + } + if (headRef) git_reference_free(headRef); + } + + // Если дерево не изменилось относительно родителя — коммитить нечего. + if (parentCommit) { + const git_oid *prevTree = git_commit_tree_id(parentCommit); + if (prevTree && git_oid_equal(prevTree, &info.treeId)) { + git_commit_free(parentCommit); + git_tree_free(tree); + setLastError(QStringLiteral("Нет изменений для коммита")); + return false; + } + } + const git_commit *parents[1] = {parentCommit}; + + // Подпись из конфига репозитория + git_signature *author = nullptr; + if (git_signature_default(&author, info.repo) < 0) { + git_commit_free(parentCommit); + git_tree_free(tree); + setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr())); + return false; + } + info.author = author; + + const int crc = git_commit_create(&info.commitId, info.repo, "HEAD", + author, author, "UTF-8", + message.toUtf8().constData(), + tree, nParents, parents); + + if (parentCommit) git_commit_free(parentCommit); + git_tree_free(tree); + + if (crc < 0) { + setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr())); + return false; + } + + setLastError(QString()); + return true; +} \ No newline at end of file diff --git a/src/git/GitManager.h b/src/git/GitManager.h new file mode 100644 index 0000000..b019026 --- /dev/null +++ b/src/git/GitManager.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include + +// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты. +class GitManager : public QObject +{ + Q_OBJECT + +public: + explicit GitManager(QObject *parent = nullptr); + + // Инициализирует git-репозиторий внутри хранилища (папка path). + // Если .git уже есть — просто открывает его. Возвращает true при успехе. + Q_INVOKABLE bool initRepo(const QString &path); + + // Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false. + Q_INVOKABLE bool commitAll(const QString &path, const QString &message); + + // Есть ли в хранилище хотя бы один коммит. + Q_INVOKABLE bool hasCommits(const QString &path) const; + + // Последнее сообщение об ошибке (русскоязычное, из каталога ошибок). + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + + QString lastError() const { return m_lastError; } + + static QString repoExists(const QString &path); + +signals: + void lastErrorChanged(); + +private: + void setRepoIdentity(git_repository *repo); + void setLastError(const QString &msg); + + QString m_lastError; + bool m_gitInitialized = false; +}; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index c8942b6..9be2f04 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,9 @@ #include #include "app/VaultManager.h" +#include "core/SettingsStore.h" +#include "git/GitManager.h" +#include "sync/SyncManager.h" #include #include @@ -29,9 +32,15 @@ int main(int argc, char *argv[]) cmark_version_string(), gitMajor, gitMinor, gitRev); - VaultManager vaultManager; + SettingsStore settingsStore; + GitManager gitManager; + SyncManager syncManager; + VaultManager vaultManager(&settingsStore, &gitManager); QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("settingsStore", &settingsStore); + engine.rootContext()->setContextProperty("gitManager", &gitManager); + engine.rootContext()->setContextProperty("syncManager", &syncManager); engine.rootContext()->setContextProperty("vaultManager", &vaultManager); engine.loadFromModule("Flora", "MainWindow"); diff --git a/src/sync/SyncManager.cpp b/src/sync/SyncManager.cpp new file mode 100644 index 0000000..86e7587 --- /dev/null +++ b/src/sync/SyncManager.cpp @@ -0,0 +1,209 @@ +#include "sync/SyncManager.h" + +#include + +namespace { + +QString stripUrl(QString url) +{ + url = url.trimmed(); + while (url.endsWith('/')) + url.chop(1); + return url; +} + +// Строит сообщение и подсказку по коду HTTP/сетевой ошибке. +QPair> catalog(int provider, int httpStatus, + const QString &networkErr) +{ + using P = QPair>; + auto make = [](int code, const QString &t, const QString &h) { + return P(code, {t, h}); + }; + + switch (httpStatus) { + case 401: + return make(SyncManager::Unauthorized, + QStringLiteral("Токен не принят (401)"), + provider == SyncManager::WebDav + ? QStringLiteral("Проверьте логин и пароль. Создайте новый токен.") + : QStringLiteral("Проверьте токен: он мог истечь или быть отозван. " + "Создайте новый на странице токенов провайдера.")); + case 403: + return make(SyncManager::Forbidden, + QStringLiteral("Недостаточно прав (403)"), + QStringLiteral("Проверьте права токена (scope repo/api). " + "Если это ограничение частоты запросов — подождите несколько минут.")); + case 404: + return make(SyncManager::NotFound, + QStringLiteral("Не найдено (404)"), + provider == SyncManager::GitHub || provider == SyncManager::GitLab + ? QStringLiteral("Проверьте адрес и что хранилище/репозиторий существуют " + "и доступны этому пользователю.") + : QStringLiteral("Проверьте адрес сервера и путь.")); + case 409: + return make(SyncManager::Conflict, + QStringLiteral("Конфликт (409)"), + QStringLiteral("Удалённый репозиторий уже содержит коммиты. " + "Потребуется слияние (см. инструкции в диалоге).")); + case 207: + return make(SyncManager::Ok, QStringLiteral("OK"), QString()); + default: + break; + } + + if (httpStatus >= 500 && httpStatus != 0) + return make(SyncManager::ServerError, + QStringLiteral("Ошибка сервера (%1)").arg(httpStatus), + QStringLiteral("Повторите позже. Если ошибка повторяется — проверьте " + "работоспособность сервера.")); + if (httpStatus == 0 && !networkErr.isEmpty()) { + if (networkErr.contains(QStringLiteral("TLS"), Qt::CaseInsensitive) + || networkErr.contains(QStringLiteral("SSL"), Qt::CaseInsensitive)) + return make(SyncManager::TlsError, + QStringLiteral("Проблема сертификата (TLS/SSL)"), + QStringLiteral("Self-hosted сервер может не иметь доверенного сертификата. " + "Добавьте сертификат в доверенные или включите опцию " + "«Разрешить небезопасное соединение» с осторожностью.")); + if (networkErr.contains(QStringLiteral("OperationCanceledError")) + || networkErr.contains(QStringLiteral("timed out"), Qt::CaseInsensitive)) + return make(SyncManager::Timeout, + QStringLiteral("Истекло время ожидания"), + QStringLiteral("Проверьте доступность сервера, сеть и прокси.")); + return make(SyncManager::NetworkError, + QStringLiteral("Сетевая ошибка"), + QStringLiteral("Проверьте подключение к интернету, адрес сервера и прокси. " + "Детали: %1").arg(networkErr)); + } + + if (httpStatus == 200 || httpStatus == 204) + return make(SyncManager::Ok, QStringLiteral("OK"), QString()); + + return make(SyncManager::ServerError, + QStringLiteral("Неизвестный ответ (%1)").arg(httpStatus), + QStringLiteral("Сервер вернул неожиданный ответ.")); +} + +} // namespace + +SyncManager::SyncManager(QObject *parent) + : QObject(parent) +{ + connect(&m_nam, &QNetworkAccessManager::finished, this, + [this](QNetworkReply *reply) { + const int provider = reply->property("flora_provider").toInt(); + handleReply(reply, provider); + }); +} + +bool SyncManager::busy() const { return m_busy; } +QString SyncManager::lastStatus() const { return m_lastStatus; } + +void SyncManager::testConnection(int provider, const QString &url, + const QString &username, const QString &token) +{ + QUrl base(stripUrl(url)); + QNetworkRequest req; + req.setTransferTimeout(15000); + + switch (static_cast(provider)) { + case GitHub: + req.setUrl(QUrl(QStringLiteral("https://api.github.com/user"))); + req.setRawHeader("Authorization", "Bearer " + token.toUtf8()); + req.setRawHeader("Accept", "application/vnd.github+json"); + break; + case GitLab: { + req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v4/user"))); + req.setRawHeader("PRIVATE-TOKEN", token.toUtf8()); + break; + } + case Gitea: { + req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v1/user"))); + req.setRawHeader("Authorization", "token " + token.toUtf8()); + break; + } + case WebDav: { + req.setUrl(base); + req.setRawHeader("Depth", "0"); + const QString u = username + ':' + token; + req.setRawHeader("Authorization", + "Basic " + u.toUtf8().toBase64()); + break; + } + case Syncthing: { + req.setUrl(QUrl(base.toString() + QStringLiteral("/rest/system/version"))); + req.setRawHeader("X-API-Key", token.toUtf8()); + break; + } + } + + m_busy = true; + emit busyChanged(); + QNetworkReply *reply = m_nam.sendCustomRequest(req, + static_cast(provider) == WebDav ? "PROPFIND" : "GET"); + reply->setProperty("flora_provider", provider); +} + +void SyncManager::handleReply(QNetworkReply *reply, int provider) +{ + reply->deleteLater(); + const int httpStatus = reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QString raw = QString::fromUtf8(reply->readAll()); + if (reply->error() != QNetworkReply::NoError) { + const auto [code, pair] = catalog(provider, 0, reply->errorString()); + finish(provider, false, code, pair.first, pair.second, + reply->errorString() + (raw.isEmpty() ? QString() : QStringLiteral(" | ") + raw)); + return; + } + const auto [code, pair] = catalog(provider, httpStatus, QString()); + finish(provider, code == Ok, code, pair.first, pair.second, raw); +} + +void SyncManager::finish(int provider, bool ok, int code, const QString &title, + const QString &hint, const QString &raw) +{ + m_busy = false; + m_lastStatus = ok ? QStringLiteral("Подключение установлено") + : title + (hint.isEmpty() ? QString() : QStringLiteral(" — ") + hint); + emit busyChanged(); + emit lastStatusChanged(); + emit connectionResult(provider, ok, code, title, hint, raw); +} + +QString SyncManager::errorTitle(int errorCode) const +{ + switch (errorCode) { + case Ok: return QStringLiteral("OK"); + case Unauthorized: return QStringLiteral("401 Unauthorized"); + case Forbidden: return QStringLiteral("403 Forbidden"); + case NotFound: return QStringLiteral("404 Not Found"); + case Conflict: return QStringLiteral("409 Conflict"); + case TlsError: return QStringLiteral("TLS/SSL ошибка"); + case Timeout: return QStringLiteral("Таймаут"); + case NetworkError: return QStringLiteral("Сетевая ошибка"); + default: return QStringLiteral("Ошибка %1").arg(errorCode); + } +} + +QString SyncManager::errorHint(int errorCode) const +{ + switch (errorCode) { + case Unauthorized: + return QStringLiteral("Создайте новый токен. GitHub: Settings → Developer settings → " + "Tokens (classic, scope repo). GitLab: Preferences → Access Tokens. " + "Gitea: Settings → Applications → Tokens."); + case Forbidden: + return QStringLiteral("Токену не хватает прав (repo/api). Проверьте scopes."); + case NotFound: + return QStringLiteral("Проверьте адрес и существование репозитория/папки."); + case Conflict: + return QStringLiteral("Отключите автоматическую синхронизацию и выполните ручное слияние."); + case TlsError: + return QStringLiteral("Self-hosted сервер: добавьте сертификат CA в доверенные."); + case Timeout: + return QStringLiteral("Проверьте сеть, хост и порт."); + default: + return QString(); + } +} \ No newline at end of file diff --git a/src/sync/SyncManager.h b/src/sync/SyncManager.h new file mode 100644 index 0000000..188b3e1 --- /dev/null +++ b/src/sync/SyncManager.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include + +// Синхронизация и провайдеры. Фаза 1: проверка подключения (токены/URL) +// и сохранение конфигурации. Полноценный обмен (push/pull) — Фаза 11. +class SyncManager : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged) + +public: + enum Provider { + GitHub = 0, + GitLab, + Gitea, + WebDav, + Syncthing + }; + Q_ENUM(Provider) + + enum ErrorCode { + Ok = 0, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + Conflict = 409, + TlsError = 1001, + Timeout = 1002, + NetworkError = 1003, + ServerError = 1004 + }; + Q_ENUM(ErrorCode) + + explicit SyncManager(QObject *parent = nullptr); + + bool busy() const; + QString lastStatus() const; + + // Проверяет доступность провайдера с параметрами и сохраняет конфигурацию при успехе. + Q_INVOKABLE void testConnection(int provider, const QString &url, + const QString &username, const QString &token); + + // Человекочитаемое описание ошибки по коду из каталога ошибок. + Q_INVOKABLE QString errorTitle(int errorCode) const; + Q_INVOKABLE QString errorHint(int errorCode) const; + +signals: + void connectionResult(int provider, bool ok, int errorCode, + const QString &title, const QString &hint, + const QString &raw); + void busyChanged(); + void lastStatusChanged(); + +private: + void runRequest(QNetworkRequest req, int provider); + void handleReply(QNetworkReply *reply, int provider); + void finish(int provider, bool ok, int code, const QString &title, + const QString &hint, const QString &raw); + + QNetworkAccessManager m_nam; + bool m_busy = false; + QString m_lastStatus; +}; \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..2564506 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +# Smoke-тест: проверка сборки и работы сторонних библиотек. +add_executable(flora_smoke smoke_test.cpp) +target_compile_definitions(flora_smoke PRIVATE + CMARK_GFM_STATIC_DEFINE + CMARK_GFM_EXTENSIONS_STATIC_DEFINE) +target_link_libraries(flora_smoke PRIVATE + cmark::cmark + cmark::cmark_extensions + Flora_libgit2_static) +add_test(NAME flora_smoke COMMAND flora_smoke) + +# Тест GitManager (git init / авто-коммиты внутри хранилища). +add_executable(flora_git_test git_test.cpp + ../src/git/GitManager.cpp + ../src/git/GitManager.h +) +target_include_directories(flora_git_test PRIVATE ../src) +target_compile_definitions(flora_git_test PRIVATE CMARK_GFM_STATIC_DEFINE) +target_link_libraries(flora_git_test PRIVATE + Qt6::Core + Flora_libgit2_static) +add_test(NAME flora_git_test COMMAND flora_git_test) + +# Интеграционный тест: настройки + создание хранилища (git-репозиторий). +add_executable(flora_vault_test vault_test.cpp + ../src/app/VaultManager.cpp + ../src/app/VaultManager.h + ../src/core/SettingsStore.cpp + ../src/core/SettingsStore.h + ../src/git/GitManager.cpp + ../src/git/GitManager.h +) +target_include_directories(flora_vault_test PRIVATE ../src) +target_link_libraries(flora_vault_test PRIVATE + Qt6::Core + Flora_libgit2_static) +add_test(NAME flora_vault_test COMMAND flora_vault_test) \ No newline at end of file diff --git a/tests/git_test.cpp b/tests/git_test.cpp new file mode 100644 index 0000000..0cc519c --- /dev/null +++ b/tests/git_test.cpp @@ -0,0 +1,75 @@ +// Тест GitManager (Фаза 1): инициализация репозитория хранилища, +// авто-коммит с новым файлом, наличие коммитов в HEAD. +#include +#include +#include +#include +#include + +#include "git/GitManager.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + QTemporaryDir dir; + if (!dir.isValid()) { + std::fprintf(stderr, "FAIL: не удалось создать временную папку\n"); + return 1; + } + + GitManager git; + + // 1. init в новой папке + if (!git.initRepo(dir.path()) || !git.lastError().isEmpty()) { + std::fprintf(stderr, "FAIL: initRepo: %s\n", qPrintable(git.lastError())); + return 1; + } + + // 2. init идемпотентен (повторный вызов — успех) + if (!git.initRepo(dir.path())) { + std::fprintf(stderr, "FAIL: повторный initRepo\n"); + return 1; + } + + // 3. нет коммитов + if (git.hasCommits(dir.path())) { + std::fprintf(stderr, "FAIL: коммиты появились раньше времени\n"); + return 1; + } + + // 4. создаём файл и коммитим + QFile f(dir.filePath(QStringLiteral("readme.md"))); + if (!f.open(QIODevice::WriteOnly)) { + std::fprintf(stderr, "FAIL: не удалось создать файл\n"); + return 1; + } + f.write("# Привет, Flora!\n"); + f.close(); + + if (!git.commitAll(dir.path(), QStringLiteral("initial"))) { + std::fprintf(stderr, "FAIL: commitAll: %s\n", qPrintable(git.lastError())); + return 1; + } + + // 5. теперь коммиты есть + if (!git.hasCommits(dir.path())) { + std::fprintf(stderr, "FAIL: no commits after commitAll\n"); + return 1; + } + + // 6. повторный коммит без изменений — корректная ошибка, без падения + if (git.commitAll(dir.path(), QStringLiteral("noop"))) { + std::fprintf(stderr, "FAIL: повторный коммит без изменений не должен удаваться\n"); + return 1; + } + + // 7. не-существующая папка + if (git.initRepo("/путь/которого/не/существует_xyz")) { + std::fprintf(stderr, "FAIL: initRepo не должен удаваться\n"); + return 1; + } + + std::printf("GIT TEST OK\n"); + return 0; +} \ No newline at end of file diff --git a/tests/smoke_test.cpp b/tests/smoke_test.cpp new file mode 100644 index 0000000..d29d36a --- /dev/null +++ b/tests/smoke_test.cpp @@ -0,0 +1,71 @@ +// Smoke-тест Фазы 0: проверяет, что третьест-сторонние библиотеки +// (cmark-gfm, libgit2) собраны, слинкованы и работают. +#include +#include +#include + +#include + +#include +#include +#include +#include + +static std::string render_md(const std::string &markdown) +{ + cmark_gfm_core_extensions_ensure_registered(); + + cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT); + for (const char *name : {"table", "strikethrough", "autolink", "tasklist"}) { + if (cmark_syntax_extension *ext = cmark_find_syntax_extension(name)) + cmark_parser_attach_syntax_extension(parser, ext); + } + + cmark_parser_feed(parser, markdown.data(), markdown.size()); + cmark_node *doc = cmark_parser_finish(parser); + + char *html = cmark_render_html(doc, CMARK_OPT_DEFAULT, + cmark_parser_get_syntax_extensions(parser)); + std::string out = html ? html : ""; + free(html); + + cmark_node_free(doc); + cmark_parser_free(parser); + return out; +} + +int main() +{ + // --- libgit2 --- + int maj = 0, min = 0, rev = 0; + git_libgit2_version(&maj, &min, &rev); + std::printf("libgit2 %d.%d.%d\n", maj, min, rev); + if (maj == 0 && min == 0) { + std::fprintf(stderr, "FAIL: libgit2 не подключен\n"); + return 1; + } + + // --- cmark-gfm --- + std::printf("cmark-gfm %s\n", cmark_version_string()); + if (std::strlen(cmark_version_string()) == 0) { + std::fprintf(stderr, "FAIL: cmark-gfm не подключен\n"); + return 1; + } + + // --- рендер Markdown (CommonMark + GFM: таблица, задача, заголовок) --- + const char *md + = "# Заголовок\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n- [ ] задача"; + std::string html = render_md(md); + std::printf("html: %s\n", html.c_str()); + + bool ok = html.find("

") != std::string::npos + && html.find(" +#include +#include +#include +#include + +#include "app/VaultManager.h" +#include "core/SettingsStore.h" +#include "git/GitManager.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + app.setOrganizationName("FloraTest"); + app.setApplicationName("flora_test"); + + QTemporaryDir dir; + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, dir.path()); + + { + SettingsStore settings; + GitManager git; + VaultManager vault(&settings, &git); + + const QString vaultPath = dir.path() + "/vault"; + const QString err = vault.createVault(vaultPath); + if (!err.isEmpty()) { + std::fprintf(stderr, "FAIL: createVault: %s\n", qPrintable(err)); + return 1; + } + if (!settings.hasVault(vaultPath) || settings.currentVault() != vaultPath) { + std::fprintf(stderr, "FAIL: хранилище не сохранено\n"); + return 1; + } + if (!git.hasCommits(vaultPath)) { + // коммитов может ещё не быть (нет файлов) — инициализация должна пройти + } + if (vault.vaults().isEmpty()) { + std::fprintf(stderr, "FAIL: список хранилищ пуст\n"); + return 1; + } + + const QString bad = vault.createVault(""); + if (bad.isEmpty()) { + std::fprintf(stderr, "FAIL: пустой путь не отклонён\n"); + return 1; + } + } + + { + // Проверка персистентности в другом экземпляре + SettingsStore settings; + if (!settings.hasVault(dir.path() + "/vault")) { + std::fprintf(stderr, "FAIL: настройки не сохранились\n"); + return 1; + } + } + + std::printf("VAULT TEST OK\n"); + return 0; +} \ No newline at end of file diff --git a/third_party/deps.cmake b/third_party/deps.cmake index 2f38175..ce0a314 100644 --- a/third_party/deps.cmake +++ b/third_party/deps.cmake @@ -28,6 +28,7 @@ add_library(cmark_wrapper INTERFACE) target_link_libraries(cmark_wrapper INTERFACE ${_cmark_target}) target_include_directories(cmark_wrapper INTERFACE "${cmark_SOURCE_DIR}/src" + "${cmark_SOURCE_DIR}/extensions" "${cmark_BINARY_DIR}/src" "${cmark_BINARY_DIR}/extensions") if(NOT TARGET cmark::cmark) @@ -51,8 +52,9 @@ FetchContent_Declare(libssh2 GIT_SHALLOW TRUE ) set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) -set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF) # локально для подпроекта libssh2 (не трогаем глобальное значение) FetchContent_MakeAvailable(libssh2) +unset(BUILD_TESTING) # возвращаем глобальное (из CMakeLists.txt) # macOS (новые SDK): strtoll требует _DARWIN_C_SOURCE, memset_s — __STDC_WANT_LIB_EXT1__. foreach(_libssh2_tgt IN ITEMS libssh2_static libssh2_shared)