Фаза 1: мастер первого запуска — SettingsStore, GitManager (auto-init+коммиты), SyncManager (проверка соединений+ErrorCatalog), WelcomeWizard/ProviderSetupGuide; тесты smoke/git/vault (12/12)
This commit is contained in:
@@ -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
|
||||
qml/wizard/WelcomeWizard.qml
|
||||
qml/dialogs/ProviderSetupGuide.qml
|
||||
)
|
||||
|
||||
# --- Тесты ---
|
||||
if(BUILD_TESTING)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
+63
-6
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-11
@@ -1,27 +1,66 @@
|
||||
#include "app/VaultManager.h"
|
||||
|
||||
VaultManager::VaultManager(QObject *parent)
|
||||
#include "core/SettingsStore.h"
|
||||
#include "git/GitManager.h"
|
||||
|
||||
#include <QDir>
|
||||
|
||||
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);
|
||||
}
|
||||
+22
-9
@@ -3,29 +3,42 @@
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
// Менеджер хранилищ: список известных хранилищ, основное хранилище,
|
||||
// открытие нового хранилища в новом окне (мульти-окна).
|
||||
// Реализация заполняется в Фазе 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;
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "core/SettingsStore.h"
|
||||
|
||||
#include <QSettings>
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
#include <QVariantList>
|
||||
|
||||
// Хранение настроек приложения (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;
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
#include "git/GitManager.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
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<git_commit *>(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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include <git2.h>
|
||||
|
||||
// Обёртка над 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;
|
||||
};
|
||||
+10
-1
@@ -4,6 +4,9 @@
|
||||
#include <QCommandLineParser>
|
||||
|
||||
#include "app/VaultManager.h"
|
||||
#include "core/SettingsStore.h"
|
||||
#include "git/GitManager.h"
|
||||
#include "sync/SyncManager.h"
|
||||
|
||||
#include <cmark-gfm.h>
|
||||
#include <git2.h>
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "sync/SyncManager.h"
|
||||
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
|
||||
QString stripUrl(QString url)
|
||||
{
|
||||
url = url.trimmed();
|
||||
while (url.endsWith('/'))
|
||||
url.chop(1);
|
||||
return url;
|
||||
}
|
||||
|
||||
// Строит сообщение и подсказку по коду HTTP/сетевой ошибке.
|
||||
QPair<int, QPair<QString, QString>> catalog(int provider, int httpStatus,
|
||||
const QString &networkErr)
|
||||
{
|
||||
using P = QPair<int, QPair<QString, QString>>;
|
||||
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>(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>(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
|
||||
// Синхронизация и провайдеры. Фаза 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;
|
||||
};
|
||||
@@ -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)
|
||||
@@ -0,0 +1,75 @@
|
||||
// Тест GitManager (Фаза 1): инициализация репозитория хранилища,
|
||||
// авто-коммит с новым файлом, наличие коммитов в HEAD.
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <cstdio>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Smoke-тест Фазы 0: проверяет, что третьест-сторонние библиотеки
|
||||
// (cmark-gfm, libgit2) собраны, слинкованы и работают.
|
||||
#include <cmark-gfm.h>
|
||||
#include <cmark-gfm-extension_api.h>
|
||||
#include <cmark-gfm-core-extensions.h>
|
||||
|
||||
#include <git2.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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("<h1>") != std::string::npos
|
||||
&& html.find("<table") != std::string::npos
|
||||
&& html.find("<input") != std::string::npos;
|
||||
if (!ok) {
|
||||
std::fprintf(stderr, "FAIL: GFM-рендер (заголовок/таблица/задача)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::printf("SMOKE TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Интеграционный тест Фазы 1: SettingsStore + VaultManager + GitManager.
|
||||
// Используем временную папку для QSettings (INI-формат), чтобы не трогать реальные.
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
#include <QDir>
|
||||
#include <QSettings>
|
||||
#include <cstdio>
|
||||
|
||||
#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;
|
||||
}
|
||||
Vendored
+3
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user