Internal
Public Access
Add Import/Export for profile lists
File -> Export Profiles... writes all profiles (plus explicit, including empty, folders) to a JSON file. File -> Import Profiles... reads one back, recreates folders, and inserts profiles as new rows so IDs never collide with the destination database. No credentials are ever persisted on a Profile in the first place (only privateKeyPath, a filesystem path), so nothing sensitive is exposed by an exported file. Verified with a standalone headless round-trip test against isolated app-data databases (SSH + RDP profiles, nested folders, all fields); caught and fixed a bug where import was redundantly creating an explicit folder row per profile that didn't exist in the original export. Fixes #17. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,13 @@
|
|||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileDialog>
|
||||||
#include <QHeaderView>
|
#include <QHeaderView>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
@@ -85,6 +90,53 @@ bool profileHasTag(const Profile& profile, const QString& requestedTag)
|
|||||||
|
|
||||||
return false;
|
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)
|
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||||
@@ -930,3 +982,128 @@ void ProfilesWindow::createFolderInCurrentContext()
|
|||||||
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
|
||||||
createFolderInContext(folderPath);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public:
|
|||||||
|
|
||||||
void createProfileInCurrentContext();
|
void createProfileInCurrentContext();
|
||||||
void createFolderInCurrentContext();
|
void createFolderInCurrentContext();
|
||||||
|
void exportProfiles();
|
||||||
|
void importProfiles();
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void connectRequested(const Profile& profile);
|
void connectRequested(const Profile& profile);
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
|
||||||
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
|
||||||
fileMenu->addSeparator();
|
fileMenu->addSeparator();
|
||||||
|
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
|
||||||
|
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
|
||||||
|
fileMenu->addSeparator();
|
||||||
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
|
||||||
|
|
||||||
connect(newProfileAction,
|
connect(newProfileAction,
|
||||||
@@ -180,6 +183,14 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
&QAction::triggered,
|
&QAction::triggered,
|
||||||
this,
|
this,
|
||||||
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
|
[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(); });
|
connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); });
|
||||||
|
|
||||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
|
||||||
|
|||||||
Reference in New Issue
Block a user