From 260704cf9ba0c83d26e9d2f6ec44cffdcd3fa58d Mon Sep 17 00:00:00 2001 From: Austin Bennett Date: Fri, 11 Sep 2026 15:42:00 -0500 Subject: [PATCH] Add --db and let the user choose where the database lives - `kareer --db ` works for the GUI and every subcommand, before or after the subcommand name, and creates the file if missing. main() strips it from argv before CLI/GUI routing; every parser declares it so it shows in --help. - Path precedence: --db > KAREER_DB_PATH > the location chosen in the GUI (kareerrc [Database] Path, read at startup for GUI and CLI alike) > $XDG_DATA_HOME/kareer/kareer.sqlite. - First GUI run (nothing forced, no file yet, or the configured file has gone missing): DatabaseSetupDialog offers the default location, a folder of the user's choice, or an existing database used in place. Until then JobsDatabase opens nothing. - Preferences gains a Database section: Move to... (copies, leaves the original), Open Existing..., Use Default Location. Refuses foreign SQLite files and unwritable ones. - Models call JobsDatabase::reopenIfPathChanged() on refresh, so switching databases needs no restart. - Link KF6::ConfigCore; add a QuickDialogs2 configure-time guard; add kconfig to the Arch dependencies and a note to the Flatpak manifest. - README documents --db, the precedence, and the first-run choice, and gains Fedora 44 build dependencies; CONTRIBUTING gets a dev recipe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk --- CMakeLists.txt | 4 + CONTRIBUTING.md | 16 +++ README.md | 35 ++++- dist/arch/PKGBUILD | 1 + io.github.toservetheking.Kareer.yml | 9 +- src/CMakeLists.txt | 4 + src/clicommands.cpp | 18 ++- src/databaselocation.cpp | 215 ++++++++++++++++++++++++++++ src/databaselocation.h | 83 +++++++++++ src/jobsdatabase.cpp | 118 ++++++++++++++- src/jobsdatabase.h | 38 ++++- src/jobsmodel.cpp | 1 + src/main.cpp | 48 +++++++ src/qml/DatabaseSetupDialog.qml | 104 ++++++++++++++ src/qml/Main.qml | 21 +++ src/qml/SettingsPage.qml | 90 ++++++++++++ src/statsmodel.cpp | 1 + 17 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 src/databaselocation.cpp create mode 100644 src/databaselocation.h create mode 100644 src/qml/DatabaseSetupDialog.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 86c14eb..715079b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,12 +36,16 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS # QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time # guard only, nothing links against it. QuickShapesPrivate + # QtQuick.Dialogs runtime QML module (DatabaseSetupDialog.qml, + # SettingsPage.qml); configure-time guard only, nothing links against it. + QuickDialogs2 QuickControls2 Sql Test ) find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS + Config I18n CoreAddons IconThemes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57d064a..45db627 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,22 @@ Every GUI action is also available from the `kareer` CLI (`add|list|show|update|stage|delete|stats|stages`) — run `./build/bin/kareer --help` to see the subcommands. +To develop against a throwaway database instead of your real one, pass +`--db` (the file is created if it doesn't exist); it works for the GUI and +every subcommand: + +```sh +./build/bin/kareer --db build/dev.sqlite add --company Acme --title Engineer +./build/bin/kareer --db build/dev.sqlite +``` + +To see the first-run "where should the database live?" dialog, start with +empty config and data directories: + +```sh +XDG_CONFIG_HOME=$(mktemp -d) XDG_DATA_HOME=$(mktemp -d) ./build/bin/kareer +``` + ## Test ```sh diff --git a/README.md b/README.md index 21c7623..60d6499 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,23 @@ kareer stages ``` Run `kareer --help` for the full option list of any -subcommand. Data lives in `$XDG_DATA_HOME/kareer/kareer.sqlite` -(under Flatpak, that's sandboxed to the app's own data directory); set -`KAREER_DB_PATH` to point at a different file. +subcommand. + +### Where the data lives + +On first launch the GUI asks where to keep the database: the default +location, a folder you choose (a synced folder, say), or an existing +`kareer.sqlite` you already have, used where it is. You can move or switch +it later under Preferences. The CLI uses the same file. + +The database file is picked in this order: + +1. `--db ` on the command line (GUI or any subcommand; the file is + created if it doesn't exist): `kareer --db ~/test.sqlite list` +2. The `KAREER_DB_PATH` environment variable +3. The location chosen in the GUI (stored in `kareerrc`) +4. `$XDG_DATA_HOME/kareer/kareer.sqlite` (under Flatpak, that's sandboxed to + the app's own data directory) ### Wiring up a resume-builder tool @@ -110,10 +124,23 @@ CMake toolchain. On Arch/CachyOS: ```sh sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \ qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \ - ki18n kcoreaddons kiconthemes kcrash kitemmodels \ + ki18n kconfig kcoreaddons kiconthemes kcrash kitemmodels \ kcolorscheme qqc2-desktop-style ``` +On Fedora 44: + +```sh +sudo dnf install cmake ninja-build extra-cmake-modules gcc-c++ \ + qt6-qtbase-devel qt6-qtbase-private-devel \ + qt6-qtdeclarative-devel qt6-qtquickcontrols2-devel \ + vulkan-headers kf6-kirigami-devel \ + kf6-kirigami-addons-devel kf6-ki18n-devel kf6-kcoreaddons-devel \ + kf6-kconfig-devel \ + kf6-kiconthemes-devel kf6-kcrash-devel kf6-kitemmodels-devel \ + kf6-kcolorscheme-devel qqc2-desktop-style +``` + Then: ```sh diff --git a/dist/arch/PKGBUILD b/dist/arch/PKGBUILD index 5f081c5..3262282 100644 --- a/dist/arch/PKGBUILD +++ b/dist/arch/PKGBUILD @@ -12,6 +12,7 @@ license=('GPL-3.0-or-later') depends=( hicolor-icon-theme kcolorscheme + kconfig kcoreaddons kcrash ki18n diff --git a/io.github.toservetheking.Kareer.yml b/io.github.toservetheking.Kareer.yml index 81f0755..3b79b0a 100644 --- a/io.github.toservetheking.Kareer.yml +++ b/io.github.toservetheking.Kareer.yml @@ -8,9 +8,12 @@ runtime-version: '6.10' sdk: org.kde.Sdk command: kareer -# A job application tracker: no host filesystem, network, or portal access is -# needed. Everything it stores lives in the app's own XDG data directory, -# which Flatpak grants by default. +# A job application tracker: no host filesystem or network access is needed. +# By default everything lives in the app's own XDG data directory, which +# Flatpak grants. A database the user places elsewhere (first-run dialog or +# Preferences) is reached through the file chooser portal instead of a +# --filesystem permission; folders are picked for new/moved databases so +# SQLite can create its journal next to the file. finish-args: - --share=ipc - --socket=fallback-x11 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 65ff36c..182c853 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -21,6 +21,7 @@ qt_add_qml_module(kareer qml/DashboardPage.qml qml/SankeyDiagram.qml qml/SettingsPage.qml + qml/DatabaseSetupDialog.qml SOURCES jobsmodel.cpp jobsmodel.h @@ -32,6 +33,8 @@ qt_add_qml_module(kareer jobeditmodel.h appcolorscheme.cpp appcolorscheme.h + databaselocation.cpp + databaselocation.h ) target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR}) @@ -44,6 +47,7 @@ target_link_libraries(kareer PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Sql + KF6::ConfigCore KF6::I18n KF6::I18nQml KF6::CoreAddons diff --git a/src/clicommands.cpp b/src/clicommands.cpp index 31073b4..fc5ab44 100644 --- a/src/clicommands.cpp +++ b/src/clicommands.cpp @@ -26,6 +26,13 @@ using namespace Qt::Literals::StringLiterals; namespace { +/// --db is applied and stripped in main() before any parser runs; declaring +/// it here only documents it in each subcommand's --help. +void addDbOption(QCommandLineParser &parser) +{ + parser.addOption({u"db"_s, u"Use this database file instead of the configured one"_s, u"path"_s}); +} + QJsonValue optionalInt(int value) { return value < 0 ? QJsonValue() : QJsonValue(value); @@ -159,6 +166,7 @@ int runAdd(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Add a new job application"_s); parser.addHelpOption(); + addDbOption(parser); addCommonJobOptions(parser); parser.process(QStringList{program} + args); @@ -225,6 +233,7 @@ int runList(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"List job applications"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"stage"_s, u"Filter by stage"_s, u"stage"_s}); parser.addOption({u"company"_s, u"Filter by company (substring match)"_s, u"text"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); @@ -272,6 +281,7 @@ int runShow(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Show one job application"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.process(QStringList{program} + args); @@ -310,6 +320,7 @@ int runUpdate(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Update fields on an existing application"_s); parser.addHelpOption(); + addDbOption(parser); addCommonJobOptions(parser); parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.process(QStringList{program} + args); @@ -410,6 +421,7 @@ int runStage(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Move an application to a new stage"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.addPositionalArgument(u"stage"_s, u"New stage: %1"_s.arg(JobStage::canonicalStages().join(u", "_s))); @@ -453,6 +465,7 @@ int runDelete(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Delete an application"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"yes"_s, u"Confirm deletion"_s}); parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.process(QStringList{program} + args); @@ -490,6 +503,7 @@ int runStats(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"Summary statistics across all applications"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.process(QStringList{program} + args); @@ -531,6 +545,7 @@ int runStages(const QString &program, const QStringList &args) QCommandLineParser parser; parser.setApplicationDescription(u"List the canonical pipeline stages"_s); parser.addHelpOption(); + addDbOption(parser); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.process(QStringList{program} + args); @@ -557,7 +572,8 @@ int runHelp() << u" list List job applications\n"_s << u" show Show one job application\n"_s << u" update Update fields on an existing application\n"_s << u" stage Move an application to a new stage\n"_s << u" delete Delete an application\n"_s << u" stats Summary statistics\n"_s << u" stages List the canonical pipeline stages\n\n"_s << u"Run 'kareer --help' for the options of a specific command.\n"_s - << u"Running kareer with no command (or an unrecognized one) starts the GUI.\n"_s; + << u"Running kareer with no command (or an unrecognized one) starts the GUI.\n\n"_s << u"Global options:\n"_s + << u" --db Use this database file (created if missing) instead of the configured one\n"_s; return 0; } diff --git a/src/databaselocation.cpp b/src/databaselocation.cpp new file mode 100644 index 0000000..bed3ee1 --- /dev/null +++ b/src/databaselocation.cpp @@ -0,0 +1,215 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#include "databaselocation.h" + +#include "jobsdatabase.h" + +#include +#include +#include + +#include +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +constexpr auto ConfigFile = "kareerrc"; +constexpr auto ConfigGroup = "Database"; +constexpr auto ConfigKey = "Path"; +constexpr auto DatabaseFileName = "kareer.sqlite"; + +KConfigGroup databaseGroup() +{ + return KConfigGroup(KSharedConfig::openConfig(QString::fromLatin1(ConfigFile)), QString::fromLatin1(ConfigGroup)); +} + +QString folderFile(const QUrl &folder) +{ + return QDir(folder.toLocalFile()).filePath(QString::fromLatin1(DatabaseFileName)); +} +} + +DatabaseLocation::DatabaseLocation(QObject *parent) + : QObject(parent) +{ + if (JobsDatabase::selectionPending() && !JobsDatabase::configuredPath().isEmpty()) { + m_missingPath = JobsDatabase::configuredPath(); + } +} + +void DatabaseLocation::loadConfiguredPath() +{ + JobsDatabase::setConfiguredPath(databaseGroup().readEntry(ConfigKey, QString())); +} + +bool DatabaseLocation::needsSetup() +{ + return !JobsDatabase::hasForcedPath() && !QFileInfo::exists(JobsDatabase::defaultPath()); +} + +QString DatabaseLocation::path() const +{ + return JobsDatabase::selectionPending() ? QString() : JobsDatabase::defaultPath(); +} + +QString DatabaseLocation::standardPath() const +{ + return JobsDatabase::standardPath(); +} + +bool DatabaseLocation::setupPending() const +{ + return JobsDatabase::selectionPending(); +} + +QString DatabaseLocation::missingPath() const +{ + return m_missingPath; +} + +bool DatabaseLocation::overridden() const +{ + return JobsDatabase::hasForcedPath(); +} + +QString DatabaseLocation::lastError() const +{ + return m_lastError; +} + +QString DatabaseLocation::lastNotice() const +{ + return m_lastNotice; +} + +bool DatabaseLocation::useDefault() +{ + return switchTo(QString(), false); +} + +bool DatabaseLocation::createIn(const QUrl &folder) +{ + if (!folder.isLocalFile()) { + setMessages(i18n("Please choose a local folder."), QString()); + return false; + } + return switchTo(folderFile(folder), false); +} + +bool DatabaseLocation::useFile(const QUrl &file) +{ + if (!file.isLocalFile()) { + setMessages(i18n("Please choose a local file."), QString()); + return false; + } + return switchTo(file.toLocalFile(), true); +} + +bool DatabaseLocation::moveTo(const QUrl &folder) +{ + if (!folder.isLocalFile()) { + setMessages(i18n("Please choose a local folder."), QString()); + return false; + } + const QString source = JobsDatabase::defaultPath(); + const QString target = folderFile(folder); + if (QFileInfo(source).canonicalFilePath() == QFileInfo(target).canonicalFilePath()) { + setMessages(i18n("The database is already in that folder."), QString()); + return false; + } + if (QFileInfo::exists(target)) { + setMessages(i18n("%1 already exists. Choose another folder, or open that file instead.", target), QString()); + return false; + } + if (!QFile::copy(source, target)) { + setMessages(i18n("Could not copy the database to %1.", target), QString()); + return false; + } + if (!switchTo(target, true)) { + QFile::remove(target); + return false; + } + setMessages(QString(), i18n("Now using %1. The previous file at %2 was left in place.", target, source)); + return true; +} + +void DatabaseLocation::clearMessages() +{ + setMessages(QString(), QString()); +} + +bool DatabaseLocation::switchTo(const QString &path, bool mustExist) +{ + const QString effective = path.isEmpty() ? JobsDatabase::standardPath() : path; + + if (mustExist && !QFileInfo::exists(effective)) { + setMessages(i18n("%1 does not exist.", effective), QString()); + return false; + } + + // Refuse to add Kareer's tables to some unrelated SQLite file. + if (QFileInfo::exists(effective)) { + const QString connection = u"kareer_probe"_s; + bool foreign = false; + { + QSqlDatabase probe = QSqlDatabase::addDatabase(u"QSQLITE"_s, connection); + probe.setDatabaseName(effective); + probe.setConnectOptions(u"QSQLITE_OPEN_READONLY"_s); + if (probe.open()) { + const QStringList tables = probe.tables(); + foreign = !tables.isEmpty() && !tables.contains(u"jobs"_s); + } + probe.close(); + } + QSqlDatabase::removeDatabase(connection); + if (foreign) { + setMessages(i18n("%1 is not a Kareer database.", effective), QString()); + return false; + } + } + + { + // Opening creates the file and schema if needed; the write check + // catches files that can be read but not written (for example a + // single-file grant inside the Flatpak sandbox, where SQLite cannot + // create its journal next to the database). + JobsDatabase db(effective); + if (!db.isOpen() || !db.lastError().isEmpty() || !db.checkWritable()) { + setMessages(i18n("Could not use %1: %2", effective, db.lastError()), QString()); + return false; + } + } + + KConfigGroup group = databaseGroup(); + if (path.isEmpty()) { + group.deleteEntry(ConfigKey); + } else { + group.writeEntry(ConfigKey, path); + } + group.sync(); + + JobsDatabase::setConfiguredPath(path); + JobsDatabase::setSelectionPending(false); + m_missingPath.clear(); + setMessages(QString(), QString()); + Q_EMIT changed(); + return true; +} + +void DatabaseLocation::setMessages(const QString &error, const QString ¬ice) +{ + if (m_lastError == error && m_lastNotice == notice) { + return; + } + m_lastError = error; + m_lastNotice = notice; + Q_EMIT messageChanged(); +} diff --git a/src/databaselocation.h b/src/databaselocation.h new file mode 100644 index 0000000..ebd0ba4 --- /dev/null +++ b/src/databaselocation.h @@ -0,0 +1,83 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include +#include +#include + +/** + * Where the database lives, as chosen by the user: the first-run dialog and + * the Preferences page drive this. The choice is stored in kareerrc + * ([Database] Path) and pushed into JobsDatabase::setConfiguredPath(); the + * --db option and KAREER_DB_PATH still win over it (see JobsDatabase::defaultPath()). + * + * Emits changed() after every switch; Main.qml reacts by refreshing the + * models, which reopen themselves via JobsDatabase::reopenIfPathChanged(). + */ +class DatabaseLocation : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + /// The effective database file, or empty while the first-run choice is pending. + Q_PROPERTY(QString path READ path NOTIFY changed) + /// $XDG_DATA_HOME/kareer/kareer.sqlite. + Q_PROPERTY(QString standardPath READ standardPath CONSTANT) + Q_PROPERTY(bool setupPending READ setupPending NOTIFY changed) + /// The configured file that could not be found (so setup is being asked again), if any. + Q_PROPERTY(QString missingPath READ missingPath NOTIFY changed) + /// True when --db or KAREER_DB_PATH is in effect; the stored location is then ignored. + Q_PROPERTY(bool overridden READ overridden CONSTANT) + Q_PROPERTY(QString lastError READ lastError NOTIFY messageChanged) + Q_PROPERTY(QString lastNotice READ lastNotice NOTIFY messageChanged) + +public: + explicit DatabaseLocation(QObject *parent = nullptr); + + /// Reads kareerrc into JobsDatabase::setConfiguredPath(). Call once at + /// startup, for both the GUI and the CLI, so they share one database. + static void loadConfiguredPath(); + + /// True when the GUI should ask where the database lives: nothing forces + /// a path and the file it would open does not exist yet. + static bool needsSetup(); + + QString path() const; + QString standardPath() const; + bool setupPending() const; + QString missingPath() const; + bool overridden() const; + QString lastError() const; + QString lastNotice() const; + + /// Create (or reuse) the database at the standard location. + Q_INVOKABLE bool useDefault(); + /// Create (or reuse) kareer.sqlite inside the chosen folder. + Q_INVOKABLE bool createIn(const QUrl &folder); + /// Use an existing database file where it is. + Q_INVOKABLE bool useFile(const QUrl &file); + /// Copy the current database into the chosen folder and switch to the + /// copy. The original file is left in place. + Q_INVOKABLE bool moveTo(const QUrl &folder); + + Q_INVOKABLE void clearMessages(); + +Q_SIGNALS: + void changed(); + void messageChanged(); + +private: + /// Validates path, stores it (empty = standard location), and switches to it. + bool switchTo(const QString &path, bool mustExist); + void setMessages(const QString &error, const QString ¬ice); + + QString m_missingPath; + QString m_lastError; + QString m_lastNotice; +}; diff --git a/src/jobsdatabase.cpp b/src/jobsdatabase.cpp index 4ed6912..e2e8d7e 100644 --- a/src/jobsdatabase.cpp +++ b/src/jobsdatabase.cpp @@ -22,6 +22,10 @@ namespace { QAtomicInteger s_connectionCounter{0}; +QString s_pathOverride; +QString s_configuredPath; +bool s_selectionPending = false; + QVariant salaryToVariant(int value) { if (value < 0) { @@ -38,6 +42,10 @@ int salaryFromVariant(const QVariant &value) JobsDatabase::JobsDatabase() { + if (s_selectionPending) { + m_lastError = u"No database selected"_s; + return; + } init(defaultPath()); } @@ -48,6 +56,14 @@ JobsDatabase::JobsDatabase(const QString &path) JobsDatabase::~JobsDatabase() { + close(); +} + +void JobsDatabase::close() +{ + if (m_connectionName.isEmpty()) { + return; + } { QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); if (db.isValid()) { @@ -55,22 +71,103 @@ JobsDatabase::~JobsDatabase() } } QSqlDatabase::removeDatabase(m_connectionName); + m_connectionName.clear(); + m_path.clear(); } QString JobsDatabase::defaultPath() { - const QString overridePath = qEnvironmentVariable("KAREER_DB_PATH"); - if (!overridePath.isEmpty()) { - return overridePath; + if (!s_pathOverride.isEmpty()) { + return s_pathOverride; } - const QString dir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer"_s; - QDir().mkpath(dir); - return dir + u"/kareer.sqlite"_s; + const QString envPath = qEnvironmentVariable("KAREER_DB_PATH"); + if (!envPath.isEmpty()) { + return envPath; + } + if (!s_configuredPath.isEmpty()) { + return s_configuredPath; + } + return standardPath(); +} + +QString JobsDatabase::standardPath() +{ + return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer/kareer.sqlite"_s; +} + +void JobsDatabase::setPathOverride(const QString &path) +{ + s_pathOverride = path; +} + +void JobsDatabase::setConfiguredPath(const QString &path) +{ + s_configuredPath = path; +} + +QString JobsDatabase::configuredPath() +{ + return s_configuredPath; +} + +bool JobsDatabase::hasForcedPath() +{ + return !s_pathOverride.isEmpty() || !qEnvironmentVariableIsEmpty("KAREER_DB_PATH"); +} + +void JobsDatabase::setSelectionPending(bool pending) +{ + s_selectionPending = pending; +} + +bool JobsDatabase::selectionPending() +{ + return s_selectionPending; +} + +QString JobsDatabase::path() const +{ + return m_path; +} + +bool JobsDatabase::reopenIfPathChanged() +{ + if (s_selectionPending) { + return false; + } + const QString wanted = defaultPath(); + if (!m_path.isEmpty() && wanted == m_path) { + return false; + } + close(); + m_lastError.clear(); + init(wanted); + return true; +} + +bool JobsDatabase::checkWritable() +{ + if (!isOpen()) { + return false; + } + QSqlQuery query(QSqlDatabase::database(m_connectionName)); + if (!query.exec(u"PRAGMA user_version"_s) || !query.next()) { + m_lastError = query.lastError().text(); + return false; + } + const int version = query.value(0).toInt(); + query.finish(); + if (!query.exec(u"PRAGMA user_version = %1"_s.arg(version))) { + m_lastError = query.lastError().text(); + return false; + } + return true; } void JobsDatabase::init(const QString &path) { m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1)); + m_path = path; QDir().mkpath(QFileInfo(path).absolutePath()); @@ -191,6 +288,9 @@ Job JobsDatabase::jobFromQuery(QSqlQuery &query) const QList JobsDatabase::allJobs() const { QList jobs; + if (!isOpen()) { + return jobs; + } QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s); if (!query.exec()) { @@ -204,6 +304,9 @@ QList JobsDatabase::allJobs() const std::optional JobsDatabase::jobById(int id) const { + if (!isOpen()) { + return std::nullopt; + } QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s); query.bindValue(u":id"_s, id); @@ -379,6 +482,9 @@ bool JobsDatabase::deleteJob(int id) QList JobsDatabase::stageTransitions() const { QList transitions; + if (!isOpen()) { + return transitions; + } QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(u"SELECT job_id, from_stage, to_stage, changed_at FROM stage_history ORDER BY changed_at ASC, id ASC"_s); if (!query.exec()) { diff --git a/src/jobsdatabase.h b/src/jobsdatabase.h index a5d1e4b..22fdc6e 100644 --- a/src/jobsdatabase.h +++ b/src/jobsdatabase.h @@ -31,10 +31,42 @@ public: JobsDatabase(const JobsDatabase &) = delete; JobsDatabase &operator=(const JobsDatabase &) = delete; - /// Default location: $XDG_DATA_HOME/kareer/kareer.sqlite, overridable - /// with the KAREER_DB_PATH environment variable (used by autotests). + /// The database file a default-constructed instance opens. Resolved in + /// order: the --db override, the KAREER_DB_PATH environment variable + /// (used by autotests), the path configured in kareerrc, and finally + /// $XDG_DATA_HOME/kareer/kareer.sqlite. static QString defaultPath(); + /// $XDG_DATA_HOME/kareer/kareer.sqlite, ignoring every override. + static QString standardPath(); + + /// Set from the --db command-line option; wins over everything else. + static void setPathOverride(const QString &path); + + /// The user's chosen location (kareerrc); empty means standardPath(). + static void setConfiguredPath(const QString &path); + static QString configuredPath(); + + /// True when --db or KAREER_DB_PATH decides the path, so the configured + /// location has no effect. + static bool hasForcedPath(); + + /// While true (GUI first run, before the user has picked a location), a + /// default-constructed instance opens nothing and reports an error. + static void setSelectionPending(bool pending); + static bool selectionPending(); + + /// The file this instance opened, or empty if it opened nothing. + QString path() const; + + /// Reopens against defaultPath() if that no longer matches path() (the + /// user picked another location). Returns true if it reopened. + bool reopenIfPathChanged(); + + /// Performs a harmless write (rewrites the header's user_version), so + /// callers can tell a read-only file apart from a usable one. + bool checkWritable(); + bool isOpen() const; QString lastError() const; @@ -59,9 +91,11 @@ public: private: void init(const QString &path); + void close(); bool migrate(); Job jobFromQuery(class QSqlQuery &query) const; QString m_connectionName; + QString m_path; QString m_lastError; }; diff --git a/src/jobsmodel.cpp b/src/jobsmodel.cpp index 5b002bf..7a3b167 100644 --- a/src/jobsmodel.cpp +++ b/src/jobsmodel.cpp @@ -17,6 +17,7 @@ JobsModel::JobsModel(QObject *parent) void JobsModel::refresh() { + m_db.reopenIfPathChanged(); beginResetModel(); m_jobs = m_db.allJobs(); endResetModel(); diff --git a/src/main.cpp b/src/main.cpp index 571edaf..831cc1d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,8 @@ #include "kareer-version.h" #include "clicommands.h" +#include "databaselocation.h" +#include "jobsdatabase.h" #include #include @@ -17,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -63,12 +66,50 @@ static void messageHandler(QtMsgType type, const QMessageLogContext &context, co } } +/// Removes "--db " / "--db=" from argv (anywhere on the command +/// line, for both the GUI and every subcommand) and applies it as the +/// database override. Stripping it up front keeps "kareer --db x list" +/// routing to the CLI. Returns false if --db is missing its value. +static bool takeDbOption(int &argc, char **argv) +{ + int out = 1; + for (int in = 1; in < argc; ++in) { + const QString arg = QString::fromLocal8Bit(argv[in]); + QString value; + if (arg == u"--db"_s) { + if (in + 1 >= argc) { + fprintf(stderr, "kareer: --db requires a path\n"); + return false; + } + value = QString::fromLocal8Bit(argv[++in]); + } else if (arg.startsWith(u"--db="_s)) { + value = arg.mid(5); + } else { + argv[out++] = argv[in]; + continue; + } + if (value.isEmpty()) { + fprintf(stderr, "kareer: --db requires a path\n"); + return false; + } + JobsDatabase::setPathOverride(QFileInfo(value).absoluteFilePath()); + } + argv[out] = nullptr; + argc = out; + return true; +} + int main(int argc, char *argv[]) { s_defaultMessageHandler = qInstallMessageHandler(messageHandler); + if (!takeDbOption(argc, argv)) { + return 1; + } + if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) { QCoreApplication app(argc, argv); + DatabaseLocation::loadConfiguredPath(); return Cli::run(app); } @@ -99,9 +140,16 @@ int main(int argc, char *argv[]) QCommandLineParser parser; aboutData.setupCommandLine(&parser); + // Handled (and stripped) by takeDbOption(); declared here for --help. + parser.addOption(QCommandLineOption(u"db"_s, i18n("Use this database file instead of the configured one."), i18n("path"))); parser.process(app); aboutData.processCommandLine(&parser); + DatabaseLocation::loadConfiguredPath(); + // First run (or the configured file has gone missing): open nothing until + // the user picks a location in DatabaseSetupDialog. The CLI never waits. + JobsDatabase::setSelectionPending(DatabaseLocation::needsSetup()); + QQmlApplicationEngine engine; KLocalization::setupLocalizedContext(&engine); diff --git a/src/qml/DatabaseSetupDialog.qml b/src/qml/DatabaseSetupDialog.qml new file mode 100644 index 0000000..c227aee --- /dev/null +++ b/src/qml/DatabaseSetupDialog.qml @@ -0,0 +1,104 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +pragma ComponentBehavior: Bound + +// First-run choice of where the database lives. Shown by Main.qml while +// DatabaseLocation.setupPending is true; it cannot be dismissed until a +// location has been picked, since nothing can be saved before then. +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Dialogs +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard +import io.github.toservetheking.Kareer + +Kirigami.Dialog { + id: root + + title: i18nc("@title:window", "Welcome to Kareer") + showCloseButton: false + closePolicy: QQC2.Popup.NoAutoClose + modal: true + preferredWidth: Kirigami.Units.gridUnit * 26 + standardButtons: Kirigami.Dialog.NoButton + + onOpened: DatabaseLocation.clearMessages() + + ColumnLayout { + spacing: 0 + + QQC2.Label { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + wrapMode: Text.Wrap + text: DatabaseLocation.missingPath.length > 0 + ? xi18nc("@info", "The database at %1 could not be found. Choose where Kareer should keep your applications.", DatabaseLocation.missingPath) + : i18nc("@info", "Choose where Kareer should keep your applications.") + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + type: Kirigami.MessageType.Error + text: DatabaseLocation.lastError + visible: text.length > 0 + } + + FormCard.FormButtonDelegate { + Layout.fillWidth: true + icon.name: "document-new" + text: i18nc("@action:button", "Create in the default location") + description: DatabaseLocation.standardPath + onClicked: { + if (DatabaseLocation.useDefault()) { + root.close(); + } + } + } + + FormCard.FormButtonDelegate { + Layout.fillWidth: true + icon.name: "folder-new" + text: i18nc("@action:button", "Choose a folder…") + description: i18nc("@info", "Create a new database in a folder of your choice, for example a synced folder.") + onClicked: folderDialog.open() + } + + FormCard.FormButtonDelegate { + Layout.fillWidth: true + icon.name: "document-open" + text: i18nc("@action:button", "Open an existing database…") + description: i18nc("@info", "Use a kareer.sqlite file you already have, where it is.") + onClicked: fileDialog.open() + } + } + + FolderDialog { + id: folderDialog + title: i18nc("@title:window", "Choose a Folder for the Database") + onAccepted: { + if (DatabaseLocation.createIn(selectedFolder)) { + root.close(); + } + } + } + + FileDialog { + id: fileDialog + title: i18nc("@title:window", "Open Database") + fileMode: FileDialog.OpenFile + nameFilters: [i18nc("@item:inlistbox", "Kareer databases (*.sqlite)"), i18nc("@item:inlistbox", "All files (*)")] + onAccepted: { + if (DatabaseLocation.useFile(selectedFile)) { + root.close(); + } + } + } +} diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 837d771..1dc6a86 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -25,6 +25,27 @@ Kirigami.ApplicationWindow { id: jobsModel } + // The database moved (first-run choice or Preferences): reload the list, + // which cascades to the dashboard via countChanged, and leave any open + // edit form, since its job id belonged to the previous database. + Connections { + target: DatabaseLocation + function onChanged(): void { + jobsModel.refresh(); + root.showDashboard(); + } + } + + DatabaseSetupDialog { + id: setupDialog + } + + Component.onCompleted: { + if (DatabaseLocation.setupPending) { + setupDialog.open(); + } + } + // The job currently open in the edit form, so the sidebar can keep it highlighted. property int currentJobId: -1 diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index c33d397..0258e7d 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -11,6 +11,7 @@ pragma ComponentBehavior: Bound // header, no card/border around them. import QtQuick import QtQuick.Controls as QQC2 +import QtQuick.Dialogs import QtQuick.Layouts import org.kde.kirigami as Kirigami import io.github.toservetheking.Kareer @@ -75,5 +76,94 @@ Kirigami.ScrollablePage { displayText: AppColorScheme.activeColorSchemeName } } + + Kirigami.ListSectionHeader { + Layout.fillWidth: true + text: i18nc("@title:group", "Database") + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + QQC2.Label { + Layout.fillWidth: true + text: i18nc("@label", "Location") + } + QQC2.Label { + Layout.fillWidth: true + text: DatabaseLocation.path + wrapMode: Text.WrapAnywhere + opacity: 0.7 + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Information + text: i18nc("@info", "Set by the --db option or the KAREER_DB_PATH environment variable, so it can't be changed here.") + visible: DatabaseLocation.overridden + } + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: DatabaseLocation.lastError + visible: text.length > 0 + } + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Positive + text: DatabaseLocation.lastNotice + visible: text.length > 0 + } + + Flow { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + enabled: !DatabaseLocation.overridden + + QQC2.Button { + icon.name: "folder-move" + text: i18nc("@action:button", "Move to…") + QQC2.ToolTip.text: i18nc("@info:tooltip", "Copy the database into another folder and use the copy from now on. The current file is left in place.") + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: moveFolderDialog.open() + } + QQC2.Button { + icon.name: "document-open" + text: i18nc("@action:button", "Open Existing…") + onClicked: openFileDialog.open() + } + QQC2.Button { + icon.name: "edit-reset" + text: i18nc("@action:button", "Use Default Location") + visible: DatabaseLocation.path !== DatabaseLocation.standardPath + QQC2.ToolTip.text: xi18nc("@info:tooltip", "Switch to %1. Nothing is copied.", DatabaseLocation.standardPath) + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: DatabaseLocation.useDefault() + } + } + } + } + + Component.onCompleted: DatabaseLocation.clearMessages() + + FolderDialog { + id: moveFolderDialog + title: i18nc("@title:window", "Move Database To") + onAccepted: DatabaseLocation.moveTo(selectedFolder) + } + + FileDialog { + id: openFileDialog + title: i18nc("@title:window", "Open Database") + fileMode: FileDialog.OpenFile + nameFilters: [i18nc("@item:inlistbox", "Kareer databases (*.sqlite)"), i18nc("@item:inlistbox", "All files (*)")] + onAccepted: DatabaseLocation.useFile(selectedFile) } } diff --git a/src/statsmodel.cpp b/src/statsmodel.cpp index 3c43c83..ff35b5e 100644 --- a/src/statsmodel.cpp +++ b/src/statsmodel.cpp @@ -17,6 +17,7 @@ StatsModel::StatsModel(QObject *parent) void StatsModel::refresh() { + m_db.reopenIfPathChanged(); m_jobs = m_db.allJobs(); Q_EMIT changed(); }