diff --git a/src/profiles_window.cpp b/src/profiles_window.cpp index bb8d20e..1538d29 100644 --- a/src/profiles_window.cpp +++ b/src/profiles_window.cpp @@ -7,8 +7,13 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include #include #include @@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag) return false; } + +constexpr int kProfileExportFormatVersion = 1; + +QJsonObject profileToJson(const Profile& profile) +{ + QJsonObject object; + object.insert(QStringLiteral("name"), profile.name); + object.insert(QStringLiteral("host"), profile.host); + object.insert(QStringLiteral("port"), profile.port); + object.insert(QStringLiteral("username"), profile.username); + object.insert(QStringLiteral("domain"), profile.domain); + object.insert(QStringLiteral("folderPath"), profile.folderPath); + object.insert(QStringLiteral("protocol"), profile.protocol); + object.insert(QStringLiteral("authMode"), profile.authMode); + object.insert(QStringLiteral("privateKeyPath"), profile.privateKeyPath); + object.insert(QStringLiteral("knownHostsPolicy"), profile.knownHostsPolicy); + object.insert(QStringLiteral("rdpSecurityMode"), profile.rdpSecurityMode); + object.insert(QStringLiteral("rdpPerformanceProfile"), profile.rdpPerformanceProfile); + object.insert(QStringLiteral("tags"), profile.tags); + return object; +} + +// Deliberately excludes id (import always creates new rows -- an imported +// profile's id has no meaning in the destination database) and any +// credential material (none is ever persisted on Profile in the first +// place, see ProfileRepository). +Profile profileFromJson(const QJsonObject& object) +{ + Profile profile; + profile.name = object.value(QStringLiteral("name")).toString(); + profile.host = object.value(QStringLiteral("host")).toString(); + profile.port = object.value(QStringLiteral("port")).toInt(22); + profile.username = object.value(QStringLiteral("username")).toString(); + profile.domain = object.value(QStringLiteral("domain")).toString(); + profile.folderPath = object.value(QStringLiteral("folderPath")).toString(); + profile.protocol = object.value(QStringLiteral("protocol")).toString(QStringLiteral("SSH")); + profile.authMode = object.value(QStringLiteral("authMode")).toString(QStringLiteral("Password")); + profile.privateKeyPath = object.value(QStringLiteral("privateKeyPath")).toString(); + profile.knownHostsPolicy = + object.value(QStringLiteral("knownHostsPolicy")).toString(QStringLiteral("Ask")); + profile.rdpSecurityMode = + object.value(QStringLiteral("rdpSecurityMode")).toString(QStringLiteral("Negotiate")); + profile.rdpPerformanceProfile = + object.value(QStringLiteral("rdpPerformanceProfile")).toString(QStringLiteral("Balanced")); + profile.tags = object.value(QStringLiteral("tags")).toString(); + return profile; +} } ProfilesWindow::ProfilesWindow(QWidget* parent) @@ -930,3 +982,128 @@ void ProfilesWindow::createFolderInCurrentContext() const QString folderPath = folderPathForItem(m_profilesTree->currentItem()); createFolderInContext(folderPath); } + +void ProfilesWindow::exportProfiles() +{ + const QString fileName = QFileDialog::getSaveFileName( + this, QStringLiteral("Export Profiles"), QStringLiteral("orbithub-profiles.json"), + QStringLiteral("JSON Files (*.json)")); + if (fileName.isEmpty()) { + return; + } + + QJsonArray folders; + for (const QString& folderPath : m_repository->listFolders()) { + folders.append(folderPath); + } + + QJsonArray profiles; + for (const Profile& profile : m_repository->listProfiles()) { + profiles.append(profileToJson(profile)); + } + + QJsonObject root; + root.insert(QStringLiteral("orbithubProfileExport"), kProfileExportFormatVersion); + root.insert(QStringLiteral("folders"), folders); + root.insert(QStringLiteral("profiles"), profiles); + + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + QMessageBox::warning(this, + QStringLiteral("Export Profiles"), + QStringLiteral("Failed to write %1: %2") + .arg(fileName, file.errorString())); + return; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + file.close(); + + QMessageBox::information(this, + QStringLiteral("Export Profiles"), + QStringLiteral("Exported %1 profile(s) to %2.") + .arg(profiles.size()) + .arg(fileName)); +} + +void ProfilesWindow::importProfiles() +{ + const QString fileName = QFileDialog::getOpenFileName( + this, QStringLiteral("Import Profiles"), QString(), QStringLiteral("JSON Files (*.json)")); + if (fileName.isEmpty()) { + return; + } + + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, + QStringLiteral("Import Profiles"), + QStringLiteral("Failed to read %1: %2") + .arg(fileName, file.errorString())); + return; + } + + QJsonParseError parseError{}; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); + file.close(); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + QMessageBox::warning(this, + QStringLiteral("Import Profiles"), + QStringLiteral("%1 is not a valid OrbitHub profile export: %2") + .arg(fileName, parseError.errorString())); + return; + } + + const QJsonObject root = doc.object(); + if (!root.value(QStringLiteral("profiles")).isArray()) { + QMessageBox::warning(this, + QStringLiteral("Import Profiles"), + QStringLiteral("%1 does not contain a profile list.").arg(fileName)); + return; + } + + for (const QJsonValue& folderValue : root.value(QStringLiteral("folders")).toArray()) { + const QString folderPath = folderValue.toString(); + if (!folderPath.isEmpty()) { + m_repository->createFolder(folderPath); + } + } + + const QJsonArray profilesArray = root.value(QStringLiteral("profiles")).toArray(); + int imported = 0; + QStringList failures; + for (const QJsonValue& profileValue : profilesArray) { + if (!profileValue.isObject()) { + continue; + } + const Profile profile = profileFromJson(profileValue.toObject()); + if (profile.name.isEmpty() || profile.host.isEmpty()) { + failures.push_back(QStringLiteral("(unnamed profile): missing name or host")); + continue; + } + // No need to createFolder(profile.folderPath) here: the tree view + // already synthesizes folder nodes from a profile's own folderPath + // (see ProfilesWindow::loadProfiles). Explicit profile_folders rows + // are only for folders with no profiles in them, and those are + // already recreated above from the export's top-level "folders" + // list -- doing it again per-profile would just add spurious + // entries not present in the original export. + if (m_repository->createProfile(profile).has_value()) { + ++imported; + } else { + failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError())); + } + } + + loadProfiles(); + + QString summary = QStringLiteral("Imported %1 of %2 profile(s) from %3.") + .arg(imported) + .arg(profilesArray.size()) + .arg(fileName); + if (!failures.isEmpty()) { + summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n'))); + QMessageBox::warning(this, QStringLiteral("Import Profiles"), summary); + } else { + QMessageBox::information(this, QStringLiteral("Import Profiles"), summary); + } +} diff --git a/src/profiles_window.h b/src/profiles_window.h index aacd797..6727137 100644 --- a/src/profiles_window.h +++ b/src/profiles_window.h @@ -32,6 +32,8 @@ public: void createProfileInCurrentContext(); void createFolderInCurrentContext(); + void exportProfiles(); + void importProfiles(); signals: void connectRequested(const Profile& profile); diff --git a/src/session_window.cpp b/src/session_window.cpp index 79ca042..f6ea03c 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -170,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent) QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile")); QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder")); fileMenu->addSeparator(); + QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles...")); + QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles...")); + fileMenu->addSeparator(); QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit")); connect(newProfileAction, @@ -180,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent) &QAction::triggered, this, [this]() { m_profilesWidget->createFolderInCurrentContext(); }); + connect(importProfilesAction, + &QAction::triggered, + this, + [this]() { m_profilesWidget->importProfiles(); }); + connect(exportProfilesAction, + &QAction::triggered, + this, + [this]() { m_profilesWidget->exportProfiles(); }); connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); }); QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));