Author SHA1 Message Date
austinandClaude Opus 5 eee3c59f4f Add CLAUDE.md with build, test and architecture notes
Lint / REUSE compliance (push) Successful in 3s
Build and Test / Build and run tests (push) Failing after 2s
Lint / clang-format (push) Failing after 5s
Covers the build/test/lint commands (including running one test
function and a throwaway --db database), the CLI/GUI split, database
location resolution, the setStage rule, the stage vocabulary, the lane
-based Sankey layout and its geometric test, and the data-driven edit
form. REUSE.toml annotates it as CC0 like the other docs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:13 -05:00
austinandClaude Opus 5 18dd49aaba Stop the Sankey ribbons braiding: split, then rejoin
Ribbons crossed over each other on their way to the outcome column.
Every funnel stage sits alone in its column at the same height, so
ordering a node's ribbon slots by the other end's node height tied,
and the tie-break ordered them against the geometry. Early drop-offs
also left from mid-node, above later stages' drop-offs, and backward
history moves were drawn right to left over everything.

- Count one left-to-right path per application: the funnel stages it
  reached, then its current stage if that is an outcome. Ghosted then
  Rejected counts only as Rejected; moving back from Offer keeps Offer.
- Lane-based routing: drop-offs travel in lanes below each column's
  node, links skipping a stage travel above it; stacks are top-aligned
  and the whole block is centered. Every ribbon crossing a gap between
  columns shares its x endpoints, and slot/lane orders are kept equal at
  both ends of every gap, so ribbons can only cross where they rejoin
  the outcome nodes. Drop-off lanes stay level rather than rising.
- Outcomes are ordered by the average stage their ribbons come from,
  which keeps those last crossings to a minimum.
- sankeylayouttest now parses the drawn pathData and fails if any two
  ribbons overlap (the previous layout overlapped by up to 150px on the
  same data); adds forward-path counting and content-centering tests,
  replacing the slot-order and per-column-centering tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:13 -05:00
austinandClaude Opus 5 260704cf9b Add --db and let the user choose where the database lives
- `kareer --db <path>` 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:00 -05:00
austinandClaude Opus 5 aa3a31f014 Fix the edit form so Save persists stage changes
Changing an application's stage and pressing Save closed the form
without saving the stage: JobsDatabase::updateJob() deliberately skips
the stage column so every move lands in stage_history, but
JobEditModel::save() never followed up with setStage(). It now does,
as the CLI's update command always has.

Also:
- Validate that company and title are non-empty and say so, instead of
  saving a blank field
- Always set lastError on failure, clear it on the next attempt, and
  bind the page's error message to it
- Reload the form's values when jobsModel is assigned, since QML does
  not guarantee it is set before editingJobId
- Bind the salary spin boxes to the model and write back only on user
  edits; guard the notes field the same way
- Add jobeditmodeltest covering load order, edits, stage history,
  salaries round-tripping, adding, and validation errors

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:41:48 -05:00
austinandClaude Fable 5 92339853d3 Bump reuse-action to v5 so CI understands REUSE.toml
Lint / clang-format (push) Failing after 5s
Lint / REUSE compliance (push) Successful in 6s
Build and Test / Build and run tests (push) Failing after 2s
reuse-action@v3 runs reuse 3.x, which predates REUSE.toml and flagged
every file the annotations cover - the actual reason the REUSE job
never passed. v5 matches the reuse 5.x used to verify locally.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 19:27:02 -05:00
austinandClaude Fable 5 170129feec Fix the Lint CI workflow (broken since v0.1.2)
Lint / REUSE compliance (push) Failing after 3s
Build and Test / Build and run tests (push) Failing after 1s
Lint / clang-format (push) Failing after 5s
clang-format job: the flatpak CI image has no ECM on the host, so the
configure step that generated .clang-format could never run. Commit the
ECM-generated .clang-format instead (it is MIT, LICENSES/MIT.txt added),
drop the container, pin clang-format 22.1.8 from PyPI to match the
version the tree is formatted with, and reformat the sources to match.

REUSE job: add the missing CC0-1.0 license text, a copyright line for
the metainfo, REUSE.toml coverage for README/CONTRIBUTING/.gitignore,
and REUSE-Ignore markers around CONTRIBUTING.md's SPDX example so the
parser stops reading prose as a license declaration. reuse lint now
passes locally (54/54 files).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 19:23:01 -05:00
austinandClaude Fable 5 d9dce6f4d1 Add Arch PKGBUILD under dist/arch, pending AUR publication
Lint / clang-format (push) Failing after 1s
Lint / REUSE compliance (push) Failing after 2s
Build and Test / Build and run tests (push) Failing after 1s
The AUR is not accepting new accounts at the moment, so the package
(tested with makepkg, check() runs the full test suite) lives in-repo
until it can be pushed to aur.archlinux.org as 'kareer'.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 18:57:17 -05:00
austinandClaude Fable 5 506b66cfdd v0.1.3: redesign the Sankey pipeline layout
Lint / REUSE compliance (push) Failing after 13s
Build and Test / Build and run tests (push) Failing after 44s
Lint / clang-format (push) Failing after 57s
Build and Publish Flatpak / Build Flatpak (push) Failing after 14s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Spread columns by rank among the stages actually present, so sparse
  pipelines fill the width instead of leaving dead stretches
- Top-align columns: the funnel's success path runs level along the top
  and drop-off ribbons only ever descend
- Give each ribbon its slot per node side (outgoing by target height,
  incoming by source height) so ribbons stop braiding over each other
- Move the last column's labels to its left so they always stay
  on-screen, with a theme halo to stay legible over ribbons
- Never hide small-node labels; elide with tooltip instead
- Guarantee columns and ribbons fit the viewport (clamp-aware scale,
  spill-proof ribbon thickness)
- Debounce resize relayouts and stop re-querying SQLite on resize
- Canonicalize stage names on write (CLI "rejected" -> "Rejected") and
  repair pre-existing rows on database open
- Take Sankey node width/padding from Kirigami units
- Add QuickShapesPrivate configure-time guard for QtQuick.Shapes
- Extend sankeylayouttest: viewport fit, ribbon containment, slot
  ordering, sparse-column spread, canonicalization, degenerate sizes
- README: build-dependency and generator fixes, document SettingsPage

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 18:51:49 -05:00
austin 078343ff96 v0.1.2: Preferences page, About page, full icon set, redesigned edit form
- Rework the add/edit form onto a generic, catalog-driven field model
  (JobFieldCatalog + JobEditModel), replacing the old hand-written
  ApplicationEditDialog with ApplicationEditPage
- Add a Preferences page (reachable from the hamburger menu) with a
  KColorSchemeManager-backed color scheme switcher
- Add an About page reading from KAboutData
- Ship a full hicolor icon set (16-128px + scalable) via ecm_install_icons
  instead of a single flat SVG
- Restyle the edit form after KDE System Settings' Audio page:
  Kirigami.ListSectionHeader per category, flat separated rows, no card
  borders
- Explicitly declare Kirigami/KirigamiAddons/ColorScheme as CMake
  dependencies instead of relying on the QML import scanner alone
- Add KDEClangFormat/KDEGitCommitHooks, a CMakePresets.json, and a
  REUSE + clang-format lint CI workflow
- Reformat SPDX headers to include copyright, remove debug screenshot
  scaffolding
2026-07-03 16:25:42 -05:00
austin 753822c242 v0.1.1: run tests in CI, add REUSE compliance and CONTRIBUTING
- Add .github/workflows/test.yml, using flatpak-builder's run-tests
  option so PRs and pushes exercise ctest inside the same KDE SDK
  sandbox as release builds.
- Add REUSE.toml covering files that can't carry an inline SPDX header
  (.desktop, keys/*.asc).
- Add CONTRIBUTING.md documenting build/test/release steps.
2026-07-03 12:07:30 -05:00
61 changed files with 3986 additions and 497 deletions
+95
View File
@@ -0,0 +1,95 @@
---
# SPDX-FileCopyrightText: 2019 Christoph Cullmann <[email protected]>
# SPDX-FileCopyrightText: 2019 Gernot Gebhard <[email protected]>
#
# SPDX-License-Identifier: MIT
# This file got automatically created by ECM, do not edit
# See https://clang.llvm.org/docs/ClangFormatStyleOptions.html for the config options
# and https://community.kde.org/Policies/Frameworks_Coding_Style#Clang-format_automatic_code_formatting
# for clang-format tips & tricks
---
Language: JavaScript
DisableFormat: true
---
Language: Json
DisableFormat: false
IndentWidth: 4
---
# Style for C++
Language: Cpp
# base is WebKit coding style: https://webkit.org/code-style-guidelines/
# below are only things set that diverge from this style!
BasedOnStyle: WebKit
# enforce C++11 (e.g. for std::vector<std::vector<lala>>
Standard: Cpp11
# 4 spaces indent
TabWidth: 4
# 2 * 80 wide lines
ColumnLimit: 160
# sort includes inside line separated groups
SortIncludes: true
# break before braces on function, namespace and class definitions.
BreakBeforeBraces: Linux
# CrlInstruction *a;
PointerAlignment: Right
# horizontally aligns arguments after an open bracket.
AlignAfterOpenBracket: Align
# don't move all parameters to new line
AllowAllParametersOfDeclarationOnNextLine: false
# no single line functions
AllowShortFunctionsOnASingleLine: None
# no single line enums
AllowShortEnumsOnASingleLine: false
# always break before you encounter multi line strings
AlwaysBreakBeforeMultilineStrings: true
# don't move arguments to own lines if they are not all on the same
BinPackArguments: false
# don't move parameters to own lines if they are not all on the same
BinPackParameters: false
# In case we have an if statement with multiple lines the operator should be at the beginning of the line
# but we do not want to break assignments
BreakBeforeBinaryOperators: NonAssignment
# format C++11 braced lists like function calls
Cpp11BracedListStyle: true
# do not put a space before C++11 braced lists
SpaceBeforeCpp11BracedList: false
# remove empty lines
KeepEmptyLinesAtTheStartOfBlocks: false
# no namespace indentation to keep indent level low
NamespaceIndentation: None
# we use template< without space.
SpaceAfterTemplateKeyword: false
# Always break after template declaration
AlwaysBreakTemplateDeclarations: true
# macros for which the opening brace stays attached.
ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH, forever, Q_FOREVER, QBENCHMARK, QBENCHMARK_ONCE , wl_resource_for_each, wl_resource_for_each_safe ]
# keep lambda formatting multi-line if not empty
AllowShortLambdasOnASingleLine: Empty
# We do not want clang-format to put all arguments on a new line
AllowAllArgumentsOnNextLine: false
+16
View File
@@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
-->
## Reason for the change
<!-- What problem does this solve, or what does it add? -->
## Test plan
<!-- How did you verify this? Build/run steps, commands, etc. -->
## Screenshots
<!-- If this changes UI, include a before/after. -->
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Publish Flatpak
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: Lint
on:
pull_request: {}
push:
branches:
- main
jobs:
reuse:
name: REUSE compliance
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# v3 ships reuse 3.x, which predates REUSE.toml support and flags
# every file the annotations cover; v5 (reuse 5.x) understands it.
- name: REUSE Compliance Check
uses: fsfe/reuse-action@v5
clang-format:
name: clang-format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The .clang-format at the repo root is the one ECM's KDEClangFormat
# generates at configure time, committed so this check needs no KDE
# stack. Pinned to the clang-format major used to format the tree -
# output drifts between major versions.
- name: Install clang-format
run: pipx install clang-format==22.1.8
- name: Check formatting
run: |
find src autotests -name '*.h' -o -name '*.cpp' | \
xargs clang-format --dry-run --Werror
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Test
on:
pull_request: {}
push:
branches:
- main
jobs:
test:
name: Build and run tests
runs-on: ubuntu-latest
container:
# Ships the KDE Platform/Sdk + flatpak-builder. Bump the tag to match
# runtime-version in the manifest.
image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build from the checked-out commit
run: |
python3 - <<'PY'
import re
p = "io.github.toservetheking.Kareer.yml"
s = open(p).read()
s = re.sub(r" sources:.*\Z",
" sources:\n - type: dir\n path: .\n",
s, flags=re.S)
open(p, "w").write(s)
print(s)
PY
- name: Build and run tests
run: |
flatpak-builder --user --disable-rofiles-fuse --force-clean \
builddir io.github.toservetheking.Kareer.yml
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Kareer is a KDE-style job application tracker: C++20 backend, QML/Kirigami frontend, ECM/CMake build, Qt 6 + KDE Frameworks 6. Data is a local SQLite file. Every GUI action is also a `kareer` CLI subcommand so scripts can drive it headlessly. Licensed GPL-3.0-or-later, REUSE-compliant.
## Commands
```sh
# Configure + build (Debug, tests on). A configured build/ dir may already exist.
cmake --preset ninja-dev # or: cmake -B build -G Ninja
cmake --build build
./build/bin/kareer # GUI
./build/bin/kareer list --json # any recognized subcommand runs headless
# Tests (QtTest binaries; appstreamtest is added automatically by KDECMakeSettings)
ctest --test-dir build --output-on-failure
ctest --test-dir build -R sankeylayouttest # one test binary
./build/bin/sankeylayouttest layoutReflectsTransitionCounts # one test function
# Point the app at a throwaway DB (GUI or any subcommand; file created if missing)
./build/bin/kareer --db build/dev.sqlite add --company Acme --title Dev
./build/bin/kareer --db build/dev.sqlite
# Autotests use the env var instead: KAREER_DB_PATH=/tmp/x.sqlite
# Formatting: CI runs clang-format (pinned major 22) with the committed .clang-format
find src autotests -name '*.h' -o -name '*.cpp' | xargs clang-format --dry-run --Werror
# REUSE/SPDX check (CI): reuse lint
```
Configuring installs a clang-format git pre-commit hook via ECM if `clang-format` is on PATH. CI builds and tests inside the KDE Flatpak SDK (`flatpak-builder` with `run-tests: true` in the manifest), not a bare CMake build.
## Architecture
`src/` is intentionally flat: one class per concern, no subfolders. Backend classes are exposed to QML with `QML_ELEMENT` via the `io.github.toservetheking.Kareer` QML module declared in `src/CMakeLists.txt`. QML files are meant to be thin renderers; logic lives in C++ so it can be unit-tested.
**Entry point and CLI/GUI split.** `main.cpp` checks whether `argv[1]` is a known subcommand (`Cli::isSubcommand`). If so it builds a `QCoreApplication` and hands off to `Cli::run` in `clicommands.cpp`; otherwise it starts the Kirigami GUI. Adding a subcommand means adding it to both the list in `isSubcommand` and the dispatch in `run`, plus the README usage block.
**Database location.** `JobsDatabase::defaultPath()` resolves `--db` (stripped from argv in `main.cpp` before CLI/GUI routing) > `KAREER_DB_PATH` > the path the user chose in the GUI (`kareerrc` `[Database] Path`, loaded at startup for both GUI and CLI by `DatabaseLocation::loadConfiguredPath`) > `$XDG_DATA_HOME/kareer/kareer.sqlite`. On a GUI first run (nothing forced, file missing) `JobsDatabase::setSelectionPending(true)` makes default-constructed instances open nothing until `DatabaseSetupDialog.qml` picks a location. `DatabaseLocation` (QML singleton) writes the choice and emits `changed`; `Main.qml` then refreshes `JobsModel`, and every model calls `m_db.reopenIfPathChanged()` at the start of its refresh, so switching databases needs no restart.
**Persistence.** `JobsDatabase` is the only class that touches SQLite. Two tables: `jobs` and `stage_history`. Schema is created with `CREATE TABLE IF NOT EXISTS` in `migrate()`; there is no version number, so schema changes need an idempotent migration step there. Each `JobsDatabase` instance opens its own uniquely named `QSqlDatabase` connection, so `JobsModel`, `StatsModel` and `SankeyModel` each hold their own instance on the same file. Consequence: after a write through one model, the others do not know; QML wires this up (`DashboardPage` listens to `JobsModel.countChanged` and calls `statsModel.refresh()` and the Sankey `refresh()`).
**Stage changes must go through `setStage`.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition and `setStage` records every move, and that history is the sole input to the Sankey diagram. Bypassing `setStage` silently corrupts the pipeline view. The same applies to the GUI: `JobEditModel::save()` calls `updateJob` and then `setStage` when the stage combo changed (it once skipped the latter, silently dropping stage edits).
**Stage vocabulary.** `JobStage` (`jobstage.h`) is a closed, fixed list (Applied, Screening, Interview, Onsite, Offer, Accepted, Rejected, Withdrawn, Ghosted, plus the synthetic `Start`). It also owns each stage's Sankey column, in-column stacking order, color, and terminal flag. Input is canonicalized case-insensitively on write. Adding a stage touches this file, the migration's canonicalization loop, and the README.
**Sankey layout.** `SankeyModel::reload()` turns `stage_history` into one left-to-right path per job (Start, the funnel stages it reached in increasing column order, then its current stage if terminal), so backward/sideways moves never become ribbons and node values equal the number of jobs that reached them. `relayout()` computes all geometry, including SVG path strings for `QtQuick.Shapes` `PathSvg`; `SankeyDiagram.qml` only draws the `nodes`/`links` lists. Layout is lane-based: links to Rejected/Withdrawn/Ghosted are drop-offs that travel in under-lanes below each column's node; main-line links that skip a column travel in over-lanes above it; each column stack is top-aligned and the whole block is centered. Every ribbon crossing a given gap between columns uses the same x endpoints, so ribbons can only cross if their vertical order differs at the two ends of a gap; the lane/slot orderings are chosen to keep it equal everywhere except the final gap into the outcome nodes. `reload()` re-reads the DB; `relayout()` recomputes geometry from cached counts (used on resize via a debounce timer). `autotests/sankeylayouttest.cpp` checks this on the drawn geometry (it parses `pathData` and asserts ribbons never overlap) and points `KAREER_DB_PATH` at a temp file because `SankeyModel` always opens the default path.
**Edit form.** The add/edit form is data-driven rather than hand-written per field: `JobFieldCatalog` is the static list of categories and fields (id, label, row type, combo options, spin range); `JobEditModel` is a `QAbstractListModel` with one row per field holding the current values, and `ApplicationEditPage.qml` renders it with a `DelegateChooser` on `rowType`. Field ids must match the keys `JobsModel::mapFromJob` / `jobFromMap` use. Adding a form field means: `Job` struct, `JobsDatabase` columns and `jobFromQuery`, `JobsModel` roles and map conversion, `JobFieldCatalog`, and the CLI options.
**Window layout.** `Main.qml` uses a two-column `pageStack`: `ApplicationsPage` (sidebar list) is fixed, and the second column is swapped between `DashboardPage` and `ApplicationEditPage` with `pageStack.replace`. A single `JobsModel` instance is created in `Main.qml` and passed down as a property.
## Conventions
- Every source file starts with an SPDX header (`GPL-3.0-or-later` for code; docs like README/CONTRIBUTING are CC0). Files that cannot carry one are listed in `REUSE.toml`. CI fails on missing headers.
- Qt string style: `using namespace Qt::Literals::StringLiterals;` with `u"..."_s`; user-visible strings go through `i18n`/`i18nc`.
- `main.cpp` installs a message handler that drops a few known-benign Qt/Kirigami warnings; add to `isBenignFrameworkNoise` rather than silencing categories wholesale.
- Releasing: bump `project(... VERSION ...)` in `CMakeLists.txt`, add a `<release>` entry to the metainfo XML, update `pkgver`/checksum in `dist/arch/PKGBUILD`, then push a `v*` tag; `build.yml` builds, signs and publishes the Flatpak. The Flatpak manifest's `sources` block is rewritten by CI to build from the checkout.
- PRs use `.github/PULL_REQUEST_TEMPLATE.md`: reason for change, test plan, screenshots for UI changes.
+29 -3
View File
@@ -1,9 +1,11 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Kareer - a Kirigami/Qt job application tracker
cmake_minimum_required(VERSION 3.16)
project(kareer VERSION 0.1.0 LANGUAGES CXX)
project(kareer VERSION 0.1.3 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0)
@@ -15,7 +17,10 @@ include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMSetupVersion)
include(ECMInstallIcons)
include(FeatureSummary)
include(KDEClangFormat)
include(KDEGitCommitHooks)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -28,16 +33,36 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
Widgets
Qml
Quick
# 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
Crash
ColorScheme
)
find_package(KF6Kirigami ${REQUIRED_KF_VERSION} REQUIRED)
set_package_properties(KF6Kirigami PROPERTIES
TYPE REQUIRED
DESCRIPTION "KDE's next-gen UI framework"
)
find_package(KF6KirigamiAddons REQUIRED)
set_package_properties(KF6KirigamiAddons PROPERTIES
TYPE REQUIRED
DESCRIPTION "Add-on components for Kirigami"
)
ecm_setup_version(${PROJECT_VERSION}
@@ -45,6 +70,7 @@ ecm_setup_version(${PROJECT_VERSION}
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h"
)
add_subdirectory(icons)
add_subdirectory(src)
if(BUILD_TESTING)
@@ -55,11 +81,11 @@ install(PROGRAMS io.github.toservetheking.Kareer.desktop
DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES io.github.toservetheking.Kareer.metainfo.xml
DESTINATION ${KDE_INSTALL_METAINFODIR})
install(FILES icons/io.github.toservetheking.Kareer.svg
DESTINATION ${KDE_INSTALL_ICONDIR}/hicolor/scalable/apps)
install(FILES LICENSES/GPL-3.0-or-later.txt
DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME})
# Translations: add a po/ directory and re-enable ki18n_install(po) once present.
kde_configure_git_pre_commit_hook(CHECKS CLANG-FORMAT)
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
+20
View File
@@ -0,0 +1,20 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [
{
"name": "ninja-dev",
"displayName": "Ninja (dev)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"BUILD_TESTING": "ON"
}
}
]
}
+2
View File
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
+68
View File
@@ -0,0 +1,68 @@
# Contributing to Kareer
## Build
Kareer is a KDE/Kirigami application (C++20 + QML) built with CMake and
Extra CMake Modules. See the README for the full list of system packages
to install, then:
```sh
cmake -B build -G Ninja
cmake --build build
./build/bin/kareer
```
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
ctest --test-dir build --output-on-failure
```
Tests live in `autotests/` and link the core logic directly out of `src/`
(see `autotests/CMakeLists.txt`). CI runs the same suite on every pull
request via `.github/workflows/test.yml`, using `flatpak-builder`'s
`run-tests` option so the run happens inside the same KDE SDK sandbox as
release builds.
## Conventions
<!-- REUSE-IgnoreStart -->
- Every source file starts with `SPDX-License-Identifier: GPL-3.0-or-later`
(in whatever comment syntax fits the file type). Files that can't carry
an inline header — `.desktop`, `keys/*.asc` — are covered instead by
`REUSE.toml`.
<!-- REUSE-IgnoreEnd -->
- `src/` is intentionally flat: one class per concern, no `models/`,
`controllers/`, or `viewmodels/` subfolders.
- C++ backend classes are exposed to QML via `QML_ELEMENT`; QML views are
meant to stay thin renderers over that state, not hold logic themselves.
- `JobsDatabase` is the only class that touches the SQLite database
directly — route all persistence changes through it.
## Releasing
`.github/workflows/build.yml` builds, signs, and publishes a Flatpak
bundle whenever a `v*` tag is pushed. To cut a release: bump
`project(... VERSION ...)` in `CMakeLists.txt`, add a matching `<release>`
entry to the metainfo file, update the manifest's pinned `tag:`, then tag
and push.
+121
View File
@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+52 -9
View File
@@ -58,9 +58,23 @@ kareer stages
```
Run `kareer <command> --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 <path>` 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
@@ -94,25 +108,50 @@ flatpak install --user ./io.github.toservetheking.Kareer.flatpak
[GitHub Release]: https://github.com/toservetheking/Kareer/releases
On Arch, a `PKGBUILD` is maintained in [`dist/arch`](dist/arch/PKGBUILD)
(pending AUR publication - the AUR is not accepting new accounts right
now):
```sh
cd dist/arch && makepkg -si
```
## Building
Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the
CMake toolchain. On Arch/CachyOS:
```sh
sudo pacman -S --needed cmake extra-cmake-modules base-devel \
qt6-base qt6-declarative kirigami kirigami-addons \
ki18n kcoreaddons kiconthemes kcrash kitemmodels qqc2-desktop-style
sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \
qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \
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
cmake -B build -G Ninja
cmake -B build
cmake --build build
./build/bin/kareer
```
(If you have `ninja` installed, add `-G Ninja` to the configure step or
use the `ninja-dev` preset: `cmake --preset ninja-dev`.)
Run the tests with `ctest --test-dir build`.
## Architecture
@@ -128,8 +167,12 @@ Run the tests with `ctest --test-dir build`.
- `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey
geometry (node columns/stacking, ribbon SVG path data); QML only
draws what this hands back.
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
`delete`/`stats`/`stages` subcommands.
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
`ApplicationEditDialog` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer).
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer),
`SettingsPage` (the Preferences page).
+23
View File
@@ -0,0 +1,23 @@
version = 1
SPDX-PackageName = "Kareer"
SPDX-PackageSupplier = "ToServeTheKing <[email protected]>"
SPDX-PackageDownloadLocation = "https://github.com/toservetheking/Kareer"
[[annotations]]
path = [
"io.github.toservetheking.Kareer.desktop",
"keys/*.asc",
"icons/*.png",
]
SPDX-License-Identifier = "GPL-3.0-or-later"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
[[annotations]]
path = [
"README.md",
"CONTRIBUTING.md",
"CLAUDE.md",
".gitignore",
]
SPDX-License-Identifier = "CC0-1.0"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
+24
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
add_executable(jobsdatabasetest
@@ -37,3 +39,25 @@ target_link_libraries(sankeylayouttest PRIVATE
)
add_test(NAME sankeylayouttest COMMAND sankeylayouttest)
add_executable(jobeditmodeltest
jobeditmodeltest.cpp
../src/jobstage.cpp
../src/jobsdatabase.cpp
../src/jobsmodel.cpp
../src/jobeditmodel.cpp
../src/jobfieldcatalog.cpp
)
target_include_directories(jobeditmodeltest PRIVATE ../src)
target_link_libraries(jobeditmodeltest PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Sql
Qt6::Test
KF6::I18n
)
add_test(NAME jobeditmodeltest COMMAND jobeditmodeltest)
+177
View File
@@ -0,0 +1,177 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobeditmodel.h"
#include "job.h"
#include "jobsdatabase.h"
#include "jobsmodel.h"
#include <QTemporaryDir>
#include <QtTest>
#include <memory>
using namespace Qt::Literals::StringLiterals;
// JobsModel always opens JobsDatabase::defaultPath(), so each test points
// that at a fresh temporary file via the KAREER_DB_PATH override.
class JobEditModelTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init()
{
m_dir = std::make_unique<QTemporaryDir>();
QVERIFY(m_dir->isValid());
qputenv("KAREER_DB_PATH", (m_dir->path() + u"/test.sqlite"_s).toUtf8());
}
// QML does not guarantee the order ApplicationEditPage's bindings assign
// editingJobId and jobsModel; the form must load the job either way.
void loadsJobWhenIdIsSetBeforeModel()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
QCOMPARE(valueOf(edit, u"company"_s).toString(), u"Acme Corp"_s);
QCOMPARE(valueOf(edit, u"title"_s).toString(), u"Engineer"_s);
QCOMPARE(valueOf(edit, u"salaryMin"_s).toInt(), 100000);
}
void savesEditsToExistingJob()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
setValueOf(edit, u"title"_s, u"Senior Engineer"_s);
setValueOf(edit, u"notes"_s, u"Second round scheduled"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
QVERIFY(edit.lastError().isEmpty());
JobsDatabase db;
const auto job = db.jobById(id);
QVERIFY(job.has_value());
QCOMPARE(job->company, u"Acme Corp"_s);
QCOMPARE(job->title, u"Senior Engineer"_s);
QCOMPARE(job->notes, u"Second round scheduled"_s);
// Untouched salaries must survive the round trip through the form.
QCOMPARE(job->salaryMin, 100000);
QCOMPARE(job->salaryMax, -1);
}
void stageChangeIsSavedWithHistory()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
setValueOf(edit, u"stage"_s, u"Interview"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
const auto transitions = db.stageTransitions();
QCOMPARE(transitions.size(), 2);
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
QCOMPARE(transitions.last().toStage, u"Interview"_s);
}
void addsNewJob()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"company"_s, u"Globex"_s);
setValueOf(edit, u"title"_s, u"Designer"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
QCOMPARE(jobs.rowCount(), 1);
JobsDatabase db;
const QList<Job> all = db.allJobs();
QCOMPARE(all.size(), 1);
QCOMPARE(all.first().company, u"Globex"_s);
QCOMPARE(all.first().stage, u"Applied"_s);
}
void missingCompanyIsReported()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"title"_s, u"Designer"_s);
QVERIFY(!edit.save());
QVERIFY(!edit.lastError().isEmpty());
QCOMPARE(jobs.rowCount(), 0);
// A later successful save clears the error.
setValueOf(edit, u"company"_s, u"Globex"_s);
QVERIFY(edit.save());
QVERIFY(edit.lastError().isEmpty());
}
void saveWithoutModelReportsError()
{
JobEditModel edit;
QVERIFY(!edit.save());
QVERIFY(!edit.lastError().isEmpty());
}
private:
int seedJob()
{
JobsDatabase db;
Job job;
job.company = u"Acme Corp"_s;
job.title = u"Engineer"_s;
job.dateApplied = QDate(2026, 6, 1);
job.salaryMin = 100000;
job.stage = u"Applied"_s;
if (!db.addJob(job)) {
qWarning() << db.lastError();
return -1;
}
return job.id;
}
static int rowOf(const JobEditModel &edit, const QString &fieldId)
{
for (int row = 0; row < edit.rowCount(); ++row) {
if (edit.data(edit.index(row), JobEditModel::FieldIdRole).toString() == fieldId) {
return row;
}
}
return -1;
}
static QVariant valueOf(const JobEditModel &edit, const QString &fieldId)
{
return edit.data(edit.index(rowOf(edit, fieldId)), JobEditModel::ValueRole);
}
static void setValueOf(JobEditModel &edit, const QString &fieldId, const QVariant &value)
{
edit.setValue(rowOf(edit, fieldId), value);
}
std::unique_ptr<QTemporaryDir> m_dir;
};
QTEST_GUILESS_MAIN(JobEditModelTest)
#include "jobeditmodeltest.moc"
+7 -2
View File
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "job.h"
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsdatabase.h"
#include "job.h"
#include <QTemporaryDir>
#include <QtTest>
+505 -17
View File
@@ -1,10 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h"
#include "jobsdatabase.h"
#include "sankeymodel.h"
#include <QPointF>
#include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
#include <memory>
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points
@@ -13,6 +23,13 @@ class SankeyLayoutTest : public QObject
{
Q_OBJECT
// Defaults of SankeyModel::reload()/relayout(), which every test uses.
static constexpr qreal nodeWidth = 16.0;
static constexpr qreal rowPadding = 10.0;
// The label margin/floor baked into the layout (sankeymodel.cpp).
static constexpr qreal labelMargin = 8.0;
static constexpr qreal eps = 0.01;
private Q_SLOTS:
void init()
{
@@ -25,23 +42,13 @@ private Q_SLOTS:
{
JobsDatabase db;
auto makeJob = [&](const QString &stage) {
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
};
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Screening"));
makeJob(QStringLiteral("Rejected"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Screening"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.relayout(600, 400);
model.reload(600, 400);
QVERIFY(!model.isEmpty());
@@ -80,13 +87,494 @@ private Q_SLOTS:
QVERIFY(db.isOpen());
SankeyModel model;
model.relayout(400, 300);
model.reload(400, 300);
QVERIFY(model.isEmpty());
QVERIFY(model.nodes().isEmpty());
QVERIFY(model.links().isEmpty());
}
void geometryFitsViewport()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
QHash<qreal, qreal> columnHeights;
QHash<qreal, int> columnCounts;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
const qreal x = m.value(QStringLiteral("x")).toReal();
const qreal y = m.value(QStringLiteral("y")).toReal();
const qreal w = m.value(QStringLiteral("width")).toReal();
const qreal h = m.value(QStringLiteral("height")).toReal();
QVERIFY(x >= -eps);
QVERIFY(x + w <= size.width() + eps);
QVERIFY(y >= -eps);
QVERIFY(y + h <= size.height() + eps);
columnHeights[x] += h;
columnCounts[x] += 1;
// The label rect must lie inside the viewport too.
const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal();
if (m.value(QStringLiteral("labelOnRight")).toBool()) {
QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps);
} else {
QVERIFY(x - labelMargin - labelWidth >= -eps);
}
}
for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) {
const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1);
QVERIFY(it.value() + gaps <= size.height() + eps);
}
}
}
void ribbonsStayInsideNodes()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
QHash<QString, qreal> outboundTotal;
QHash<QString, qreal> inboundTotal;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
const qreal thickness = m.value(QStringLiteral("thickness")).toReal();
QVERIFY(thickness > 0);
const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString());
const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString());
const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal();
const qreal targetY = m.value(QStringLiteral("targetY")).toReal();
QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps);
QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps);
outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness;
inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness;
}
for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
}
void ribbonsDoNotCross_data()
{
QTest::addColumn<int>("seed");
QTest::newRow("dense pipeline") << int(DenseSeed);
QTest::newRow("links skipping stages") << int(SkipSeed);
QTest::newRow("back and sideways moves") << int(BackMoveSeed);
}
// The "braid" bug: ribbons twisting over each other. Checked on the drawn
// geometry itself: no two ribbons may overlap anywhere between their ends.
void ribbonsDoNotCross()
{
QFETCH(int, seed);
JobsDatabase db;
seedPipeline(db, seed);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(900, 500), QSizeF(400, 800)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
verifyNoOverlaps(model, std::numeric_limits<qreal>::max());
}
}
// With one node per outcome, ribbons from different stages into different
// outcomes can't always keep their order; any such crossing must stay in
// the final gap where they rejoin, never braid across the diagram.
void crossingsOnlyWhereRibbonsRejoin()
{
JobsDatabase db;
seedPipeline(db, LargeSeed);
SankeyModel model;
model.reload(900, 500);
QVERIFY(!model.isEmpty());
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QVERIFY(xs.size() >= 2);
verifyNoOverlaps(model, xs.at(xs.size() - 2) + nodeWidth);
}
// Each application is one left-to-right path: the funnel stages it
// reached, then its current stage if that is an outcome.
void historyCollapsesToForwardPaths()
{
JobsDatabase db;
seedPipeline(db, BackMoveSeed);
SankeyModel model;
model.reload(600, 300);
QHash<QString, int> links;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(),
m.value(QStringLiteral("value")).toInt());
}
const QHash<QString, int> expected{
{QStringLiteral("Start>Applied"), 4},
{QStringLiteral("Applied>Screening"), 2},
{QStringLiteral("Applied>Interview"), 2},
{QStringLiteral("Interview>Offer"), 1},
{QStringLiteral("Screening>Rejected"), 1},
{QStringLiteral("Screening>Withdrawn"), 1},
};
QCOMPARE(links, expected);
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
// Ghosted was superseded by Rejected, so it isn't an outcome anyone ended in.
QVERIFY(m.value(QStringLiteral("stage")).toString() != QStringLiteral("Ghosted"));
if (m.value(QStringLiteral("stage")).toString() == QStringLiteral("Start")) {
QCOMPARE(m.value(QStringLiteral("value")).toInt(), 4);
}
}
}
void contentIsVerticallyCentered()
{
JobsDatabase db;
seedPipeline(db, DenseSeed);
SankeyModel model;
model.reload(600, 300);
qreal top = std::numeric_limits<qreal>::max();
qreal bottom = std::numeric_limits<qreal>::lowest();
for (const QVariant &v : model.nodes()) {
const QVariantMap node = v.toMap();
top = qMin(top, node.value(QStringLiteral("y")).toReal());
bottom = qMax(bottom, node.value(QStringLiteral("y")).toReal() + node.value(QStringLiteral("height")).toReal());
}
for (const QVariant &v : model.links()) {
const Ribbon ribbon = parseRibbon(v.toMap());
for (const QList<QPointF> *edge : {&ribbon.top, &ribbon.bottom}) {
for (const QPointF &p : *edge) {
top = qMin(top, p.y());
bottom = qMax(bottom, p.y());
}
}
}
QVERIFY2(qAbs(top - (300.0 - bottom)) < 0.05, qPrintable(QStringLiteral("top margin %1, bottom margin %2").arg(top).arg(300.0 - bottom)));
}
void sparseColumnsFillWidth()
{
JobsDatabase db;
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
// Only Start, Applied and Rejected are present: their columns should
// spread evenly over the full width, not sit at canonical positions.
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QCOMPARE(xs.size(), 3);
QVERIFY(qAbs(xs.at(0)) < eps);
QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps);
QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps);
}
void caseVariantStageIsCanonicalized()
{
JobsDatabase db;
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
QVERIFY(db.setStage(job.id, QStringLiteral("rejected")));
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
QStringList stages;
for (const QVariant &v : model.nodes()) {
stages.append(v.toMap().value(QStringLiteral("stage")).toString());
}
QVERIFY(stages.contains(QStringLiteral("Rejected")));
QVERIFY(!stages.contains(QStringLiteral("rejected")));
// Pre-canonicalization rows already in the database are repaired the
// next time it is opened (migrate() normalization).
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup"));
raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite"));
QVERIFY(raw.open());
QSqlQuery q(raw);
QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'")));
QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'")));
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("fixup"));
JobsDatabase reopened;
QVERIFY(reopened.isOpen());
QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted"));
const auto transitions = reopened.stageTransitions();
for (const StageTransition &t : transitions) {
QVERIFY(t.toStage != QStringLiteral("ghosted"));
}
}
void degenerateSizesDoNotCrash()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(0, 0);
QVERIFY(model.isEmpty());
model.relayout(-5, 100);
QVERIFY(model.isEmpty());
model.relayout(5, 5);
model.relayout(2000, 2);
model.relayout(600, 300);
QVERIFY(!model.isEmpty());
}
private:
enum Seed {
DenseSeed,
SkipSeed,
BackMoveSeed,
LargeSeed,
};
static void seedPipeline(JobsDatabase &db, int seed)
{
const auto S = [](const char *stage) {
return QString::fromLatin1(stage);
};
switch (seed) {
case DenseSeed:
seedDensePipeline(db);
break;
case SkipSeed:
walkJob(db, {S("Interview"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
walkJob(db, {S("Screening"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Ghosted")});
walkJob(db, {});
addJobAt(db, S("Interview"));
break;
case BackMoveSeed:
walkJob(db, {S("Screening"), S("Ghosted"), S("Rejected")});
walkJob(db, {S("Interview"), S("Offer"), S("Interview")});
walkJob(db, {S("Rejected"), S("Interview")});
walkJob(db, {S("Screening"), S("Withdrawn")});
break;
case LargeSeed:
for (int i = 0; i < 12; ++i) {
walkJob(db, {});
}
for (int i = 0; i < 6; ++i) {
walkJob(db, {S("Ghosted")});
}
for (int i = 0; i < 5; ++i) {
walkJob(db, {S("Rejected")});
}
walkJob(db, {S("Withdrawn")});
for (int i = 0; i < 4; ++i) {
walkJob(db, {S("Screening"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Ghosted")});
walkJob(db, {S("Screening")});
for (int i = 0; i < 3; ++i) {
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Interview"), S("Withdrawn")});
walkJob(db, {S("Interview"), S("Onsite"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer")});
break;
}
}
static void addJobAt(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
job.stage = stage;
QVERIFY(db.addJob(job));
}
/// A ribbon's outline as drawn: its top and bottom edges, each sampled
/// left to right.
struct Ribbon {
QString name;
QList<QPointF> top;
QList<QPointF> bottom;
};
/// Parses the absolute M/C/L/Z path SankeyModel generates. The outline
/// runs along the top edge, drops straight down at the target, and runs
/// back along the bottom edge.
static Ribbon parseRibbon(const QVariantMap &link)
{
Ribbon ribbon;
ribbon.name = link.value(QStringLiteral("fromStage")).toString() + QStringLiteral("->") + link.value(QStringLiteral("toStage")).toString();
static const QRegularExpression token(QStringLiteral("[MCLZ]|-?\\d+(?:\\.\\d+)?"));
QStringList tokens;
auto it = token.globalMatch(link.value(QStringLiteral("pathData")).toString());
while (it.hasNext()) {
tokens.append(it.next().captured());
}
QList<QPointF> *edge = &ribbon.top;
QPointF current;
int i = 0;
// Read coordinates one at a time: argument evaluation order is
// unspecified, so QPointF(next(), next()) could swap x and y.
const auto nextPoint = [&]() {
const qreal x = tokens.value(i++).toDouble();
const qreal y = tokens.value(i++).toDouble();
return QPointF(x, y);
};
while (i < tokens.size()) {
const QString command = tokens.at(i++);
if (command == QLatin1String("M")) {
current = nextPoint();
edge->append(current);
} else if (command == QLatin1String("L")) {
const QPointF next = nextPoint();
if (edge == &ribbon.top && qAbs(next.x() - current.x()) < 1e-6 && qAbs(next.y() - current.y()) > 1e-6) {
edge = &ribbon.bottom; // the drop at the target
}
edge->append(next);
current = next;
} else if (command == QLatin1String("C")) {
const QPointF c1 = nextPoint();
const QPointF c2 = nextPoint();
const QPointF end = nextPoint();
constexpr int steps = 32;
for (int step = 1; step <= steps; ++step) {
const qreal t = static_cast<qreal>(step) / steps;
const qreal u = 1.0 - t;
edge->append(current * (u * u * u) + c1 * (3 * u * u * t) + c2 * (3 * u * t * t) + end * (t * t * t));
}
current = end;
}
}
std::reverse(ribbon.bottom.begin(), ribbon.bottom.end());
return ribbon;
}
static qreal yAt(const QList<QPointF> &edge, qreal x)
{
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
if (x >= a.x() && x <= b.x()) {
return b.x() - a.x() < 1e-9 ? a.y() : a.y() + (b.y() - a.y()) * (x - a.x()) / (b.x() - a.x());
}
}
return x < edge.first().x() ? edge.first().y() : edge.last().y();
}
/// Fails if any two ribbons overlap vertically anywhere in the x range
/// they share, left of xLimit (their shared end points excluded).
static void verifyNoOverlaps(const SankeyModel &model, qreal xLimit)
{
QList<Ribbon> ribbons;
for (const QVariant &v : model.links()) {
ribbons.append(parseRibbon(v.toMap()));
}
for (int a = 0; a < ribbons.size(); ++a) {
for (int b = a + 1; b < ribbons.size(); ++b) {
const Ribbon &ra = ribbons.at(a);
const Ribbon &rb = ribbons.at(b);
const qreal lo = qMax(ra.top.first().x(), rb.top.first().x()) + 0.25;
const qreal hi = std::min({ra.top.last().x(), rb.top.last().x(), xLimit}) - 0.25;
for (int step = 0; step <= 400 && lo < hi; ++step) {
const qreal x = lo + (hi - lo) * step / 400.0;
const qreal overlap = qMin(yAt(ra.bottom, x), yAt(rb.bottom, x)) - qMax(yAt(ra.top, x), yAt(rb.top, x));
QVERIFY2(overlap < 0.5, qPrintable(QStringLiteral("%1 and %2 overlap by %3px at x=%4").arg(ra.name, rb.name).arg(overlap).arg(x)));
}
}
}
}
static void makeJob(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
}
static void walkJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
for (const QString &stage : stages) {
QVERIFY(db.setStage(job.id, stage));
}
}
/// Populates every column, including all four terminal outcomes sharing
/// the last one.
static void seedDensePipeline(JobsDatabase &db)
{
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Ghosted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Withdrawn")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
}
std::unique_ptr<QTemporaryDir> m_dir;
};
+48
View File
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
# SPDX-License-Identifier: GPL-3.0-or-later
# Maintainer: Austin Bennett <austin at thebennett dot net>
pkgname=kareer
pkgver=0.1.3
pkgrel=1
pkgdesc="Job application tracker with a Sankey pipeline view and a scriptable CLI (Kirigami)"
arch=('x86_64')
url="https://github.com/toservetheking/Kareer"
license=('GPL-3.0-or-later')
depends=(
hicolor-icon-theme
kcolorscheme
kconfig
kcoreaddons
kcrash
ki18n
kiconthemes
kirigami
kirigami-addons
qqc2-desktop-style
qt6-base
qt6-declarative
)
makedepends=(
cmake
extra-cmake-modules
)
checkdepends=(appstream)
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('7c30d4ebf2375d151e5132a57ce5b51ae7772d78a798ffa62a640114d8ff1486')
build() {
cmake -B build -S "Kareer-$pkgver" \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
ecm_install_icons(ICONS
sc-apps-kareer.svg
128-apps-kareer.png
64-apps-kareer.png
48-apps-kareer.png
44-apps-kareer.png
32-apps-kareer.png
22-apps-kareer.png
16-apps-kareer.png
DESTINATION ${KDE_INSTALL_ICONDIR}
THEME hicolor
)
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: CC0-1.0 -->
<component type="desktop-application">
<id>io.github.toservetheking.Kareer</id>
@@ -64,6 +65,36 @@
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.3" date="2026-08-31">
<description>
<ul>
<li>Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible</li>
<li>Order ribbons at each stage to stop them braiding over each other</li>
<li>Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases</li>
<li>Debounce window-resize relayouts and stop re-reading the database on resize</li>
</ul>
</description>
</release>
<release version="0.1.2" date="2026-07-03">
<description>
<ul>
<li>Rework the add/edit form onto a generic, catalog-driven field model</li>
<li>Add a Preferences page with a color-scheme switcher</li>
<li>Add an About page</li>
<li>Ship a full hicolor icon set instead of a single scalable SVG</li>
<li>Match KDE System Settings' Audio page layout for the edit form</li>
</ul>
</description>
</release>
<release version="0.1.1" date="2026-07-03">
<description>
<ul>
<li>Run the test suite in CI on every pull request</li>
<li>Add REUSE license-compliance metadata</li>
<li>Add a CONTRIBUTING guide</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-07-03">
<description>
<p>Initial release.</p>
+17 -11
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
id: io.github.toservetheking.Kareer
runtime: org.kde.Platform
@@ -6,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
@@ -25,13 +30,14 @@ modules:
buildsystem: cmake-ninja
config-opts:
- -DCMAKE_BUILD_TYPE=Release
run-tests: true
sources:
# For Flathub / release builds, pin to a tag AND commit:
- type: git
url: https://github.com/toservetheking/Kareer.git
tag: v0.1.0
# commit: <fill in the exact commit SHA the tag points at>
# For local testing before the repo is pushed:
- type: dir
path: .
#
# For local testing before the repo is pushed, replace the source above with:
# - type: dir
# path: .
# For Flathub / release builds, pin to a tag AND commit instead:
# - type: git
# url: https://github.com/toservetheking/Kareer.git
# tag: v0.1.1
# commit: <fill in the exact commit SHA the tag points at>
+15 -1
View File
@@ -1,9 +1,12 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
add_executable(kareer
main.cpp
jobstage.cpp
jobsdatabase.cpp
jobfieldcatalog.cpp
clicommands.cpp
)
@@ -14,9 +17,11 @@ qt_add_qml_module(kareer
QML_FILES
qml/Main.qml
qml/ApplicationsPage.qml
qml/ApplicationEditDialog.qml
qml/ApplicationEditPage.qml
qml/DashboardPage.qml
qml/SankeyDiagram.qml
qml/SettingsPage.qml
qml/DatabaseSetupDialog.qml
SOURCES
jobsmodel.cpp
jobsmodel.h
@@ -24,6 +29,12 @@ qt_add_qml_module(kareer
statsmodel.h
sankeymodel.cpp
sankeymodel.h
jobeditmodel.cpp
jobeditmodel.h
appcolorscheme.cpp
appcolorscheme.h
databaselocation.cpp
databaselocation.h
)
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
@@ -36,11 +47,14 @@ target_link_libraries(kareer PRIVATE
Qt6::Quick
Qt6::QuickControls2
Qt6::Sql
KF6::ConfigCore
KF6::I18n
KF6::I18nQml
KF6::CoreAddons
KF6::IconThemes
KF6::Crash
KF6::ColorScheme
KF6::Kirigami
)
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
+34
View File
@@ -0,0 +1,34 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "appcolorscheme.h"
#include <KColorSchemeManager>
AppColorScheme::AppColorScheme(QObject *parent)
: QObject(parent)
, mSchemes(KColorSchemeManager::instance())
{
}
QAbstractItemModel *AppColorScheme::colorSchemesModel()
{
return mSchemes->model();
}
QString AppColorScheme::activeColorSchemeName() const
{
return mSchemes->activeSchemeName();
}
void AppColorScheme::setActiveColorSchemeName(const QString &name)
{
if (name == activeColorSchemeName()) {
return;
}
mSchemes->activateScheme(mSchemes->indexForScheme(name));
Q_EMIT activeColorSchemeNameChanged();
}
+40
View File
@@ -0,0 +1,40 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QAbstractItemModel>
#include <QObject>
#include <QQmlEngine>
class KColorSchemeManager;
/**
* Thin QML-facing wrapper around KColorSchemeManager: exposes the list of
* installed color schemes and the currently active one. KColorSchemeManager
* autosaves the active scheme itself, so there is nothing else to persist.
*/
class AppColorScheme : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QAbstractItemModel *colorSchemesModel READ colorSchemesModel CONSTANT)
Q_PROPERTY(QString activeColorSchemeName READ activeColorSchemeName WRITE setActiveColorSchemeName NOTIFY activeColorSchemeNameChanged)
public:
explicit AppColorScheme(QObject *parent = nullptr);
QAbstractItemModel *colorSchemesModel();
QString activeColorSchemeName() const;
void setActiveColorSchemeName(const QString &name);
Q_SIGNALS:
void activeColorSchemeNameChanged();
private:
KColorSchemeManager *mSchemes;
};
+37 -15
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "clicommands.h"
#include "job.h"
@@ -21,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);
@@ -154,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);
@@ -220,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});
@@ -267,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);
@@ -305,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);
@@ -405,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)));
@@ -438,7 +455,7 @@ int runStage(const QString &program, const QStringList &args)
if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*job));
} else {
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl;
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(job->stage) << Qt::endl;
}
return 0;
}
@@ -448,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);
@@ -485,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);
@@ -526,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);
@@ -548,18 +568,12 @@ int runStages(const QString &program, const QStringList &args)
int runHelp()
{
QTextStream out(stdout);
out << u"Usage: kareer <command> [options]\n\n"_s
<< u"Commands:\n"_s
<< u" add Add a new job application\n"_s
<< 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 <command> --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;
out << u"Usage: kareer <command> [options]\n\n"_s << u"Commands:\n"_s << u" add Add a new job application\n"_s
<< 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 <command> --help' for the options of a specific command.\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 <path> Use this database file (created if missing) instead of the configured one\n"_s;
return 0;
}
@@ -568,7 +582,15 @@ int runHelp()
bool Cli::isSubcommand(const QString &arg)
{
static const QSet<QString> subcommands{
u"add"_s, u"list"_s, u"show"_s, u"update"_s, u"stage"_s, u"delete"_s, u"stats"_s, u"stages"_s, u"help"_s,
u"add"_s,
u"list"_s,
u"show"_s,
u"update"_s,
u"stage"_s,
u"delete"_s,
u"stats"_s,
u"stages"_s,
u"help"_s,
};
return subcommands.contains(arg);
}
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QString>
+215
View File
@@ -0,0 +1,215 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KConfigGroup>
#include <KLocalizedString>
#include <KSharedConfig>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSqlDatabase>
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 &notice)
{
if (m_lastError == error && m_lastNotice == notice) {
return;
}
m_lastError = error;
m_lastNotice = notice;
Q_EMIT messageChanged();
}
+83
View File
@@ -0,0 +1,83 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QUrl>
/**
* 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 &notice);
QString m_missingPath;
QString m_lastError;
QString m_lastNotice;
};
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QDate>
+251
View File
@@ -0,0 +1,251 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobeditmodel.h"
#include <KLocalizedString>
#include <QDate>
using namespace Qt::Literals::StringLiterals;
using JobFieldCatalog::ComboRow;
using JobFieldCatalog::DateRow;
using JobFieldCatalog::Field;
using JobFieldCatalog::SpinBoxRow;
using JobFieldCatalog::TextAreaRow;
using JobFieldCatalog::TextRow;
namespace
{
/// The three salary fields share the "0 shown in the UI means unset, -1 stored" convention.
bool isSalaryField(const QString &id)
{
return id == QLatin1String("salaryMin") || id == QLatin1String("salaryMax") || id == QLatin1String("salaryExpectation");
}
}
JobEditModel::JobEditModel(QObject *parent)
: QAbstractListModel(parent)
, m_fields(JobFieldCatalog::fields())
{
resetValues();
}
int JobEditModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_fields.size();
}
QVariant JobEditModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_fields.size()) {
return {};
}
const Field &field = m_fields.at(index.row());
switch (role) {
case FieldIdRole:
return field.id;
case CategoryIdRole:
return field.categoryId;
case LabelRole:
return field.label;
case RowTypeRole:
return static_cast<int>(field.rowType);
case ValueRole:
return m_values.value(field.id);
case ComboOptionsRole:
return field.comboOptions;
case PlaceholderRole:
return field.placeholder;
case SpinMinRole:
return field.spinMin;
case SpinMaxRole:
return field.spinMax;
default:
return {};
}
}
QHash<int, QByteArray> JobEditModel::roleNames() const
{
return {
{FieldIdRole, "fieldId"},
{CategoryIdRole, "categoryId"},
{LabelRole, "label"},
{RowTypeRole, "rowType"},
{ValueRole, "value"},
{ComboOptionsRole, "comboOptions"},
{PlaceholderRole, "placeholder"},
{SpinMinRole, "spinMin"},
{SpinMaxRole, "spinMax"},
};
}
JobsModel *JobEditModel::jobsModel() const
{
return m_jobsModel;
}
void JobEditModel::setJobsModel(JobsModel *model)
{
if (m_jobsModel == model) {
return;
}
m_jobsModel = model;
// QML does not guarantee editingJobId is assigned after jobsModel; if it
// came first, the resetValues() it triggered had no model to load from
// and only filled new-job defaults.
resetValues();
Q_EMIT jobsModelChanged();
}
int JobEditModel::editingJobId() const
{
return m_editingJobId;
}
void JobEditModel::setEditingJobId(int id)
{
if (m_editingJobId == id) {
return;
}
m_editingJobId = id;
resetValues();
Q_EMIT editingJobIdChanged();
}
void JobEditModel::resetValues()
{
m_values.clear();
m_loadedStage.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_values[u"currency"_s] = u"USD"_s;
m_values[u"stage"_s] = u"Applied"_s;
m_values[u"dateApplied"_s] = QDate::currentDate();
m_values[u"remoteType"_s] = u"Unspecified"_s;
m_values[u"salaryMin"_s] = 0;
m_values[u"salaryMax"_s] = 0;
m_values[u"salaryExpectation"_s] = 0;
} else {
const QVariantMap data = m_jobsModel->jobData(m_editingJobId);
for (const Field &field : m_fields) {
QVariant value = data.value(field.id);
if (field.id == u"remoteType"_s && value.toString().isEmpty()) {
value = u"Unspecified"_s;
}
if (isSalaryField(field.id) && value.toInt() < 0) {
value = 0;
}
m_values[field.id] = value;
}
m_loadedStage = m_values.value(u"stage"_s).toString();
}
if (rowCount() > 0) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1));
}
}
QVariantList JobEditModel::categories() const
{
QVariantList result;
for (const JobFieldCatalog::Category &category : JobFieldCatalog::categories()) {
result.append(QVariantMap{{u"id"_s, category.id}, {u"title"_s, category.title}});
}
return result;
}
QString JobEditModel::lastError() const
{
return m_lastError;
}
void JobEditModel::setValue(int row, const QVariant &value)
{
if (row < 0 || row >= m_fields.size()) {
return;
}
m_values[m_fields.at(row).id] = value;
Q_EMIT dataChanged(index(row), index(row), {ValueRole});
}
bool JobEditModel::save()
{
setLastError(QString());
if (!m_jobsModel) {
setLastError(i18n("Cannot save: no applications list is attached to this form."));
return false;
}
if (m_values.value(u"company"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Company is required."));
return false;
}
if (m_values.value(u"title"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Job title is required."));
return false;
}
QVariantMap fields;
for (auto it = m_values.constBegin(); it != m_values.constEnd(); ++it) {
fields.insert(it.key(), it.value());
}
if (fields.value(u"remoteType"_s).toString() == u"Unspecified"_s) {
fields[u"remoteType"_s] = QString();
}
for (const QString &id : {u"salaryMin"_s, u"salaryMax"_s, u"salaryExpectation"_s}) {
if (fields.value(id).toInt() <= 0) {
fields[id] = -1;
}
}
if (m_editingJobId < 0) {
if (!m_jobsModel->addJob(fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
return true;
}
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
// updateJob() deliberately never writes the stage (so every stage change
// lands in stage_history); route a changed stage through setStage().
const QString stage = fields.value(u"stage"_s).toString();
if (!stage.isEmpty() && stage != m_loadedStage) {
if (!m_jobsModel->setStage(m_editingJobId, stage)) {
setLastError(m_jobsModel->lastError());
return false;
}
m_loadedStage = stage;
}
return true;
}
void JobEditModel::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
Q_EMIT lastErrorChanged();
}
bool JobEditModel::deleteJob()
{
if (!m_jobsModel || m_editingJobId < 0) {
return false;
}
return m_jobsModel->removeJob(m_editingJobId);
}
+85
View File
@@ -0,0 +1,85 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "jobfieldcatalog.h"
#include "jobsmodel.h"
#include <QAbstractListModel>
#include <QHash>
#include <QQmlEngine>
#include <QVariant>
/**
* Drives the add/edit form the way FlatKontrol's PermissionsController drives
* PermissionsPage: a generic row model (one row per JobFieldCatalog::Field)
* that QML renders via Repeater + DelegateChooser on rowType, instead of
* each field being hand-written in QML.
*/
class JobEditModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(JobsModel *jobsModel READ jobsModel WRITE setJobsModel NOTIFY jobsModelChanged)
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
public:
enum Roles {
FieldIdRole = Qt::UserRole + 1,
CategoryIdRole,
LabelRole,
RowTypeRole,
ValueRole,
ComboOptionsRole,
PlaceholderRole,
SpinMinRole,
SpinMaxRole,
};
Q_ENUM(Roles)
explicit JobEditModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
JobsModel *jobsModel() const;
void setJobsModel(JobsModel *model);
int editingJobId() const;
void setEditingJobId(int id);
QVariantList categories() const;
QString lastError() const;
/// Updates the value for the field at this row (called from the QML delegate).
Q_INVOKABLE void setValue(int row, const QVariant &value);
/// Persists the current values via jobsModel; true on success.
Q_INVOKABLE bool save();
Q_INVOKABLE bool deleteJob();
Q_SIGNALS:
void jobsModelChanged();
void editingJobIdChanged();
void lastErrorChanged();
private:
void resetValues();
void setLastError(const QString &error);
JobsModel *m_jobsModel = nullptr;
int m_editingJobId = -1;
QHash<QString, QVariant> m_values;
QString m_lastError;
QString m_loadedStage; ///< Stage as loaded from the database, to detect stage changes on save.
QList<JobFieldCatalog::Field> m_fields;
};
+55
View File
@@ -0,0 +1,55 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobfieldcatalog.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
namespace JobFieldCatalog
{
QList<Category> categories()
{
return {
{u"company"_s, QStringLiteral("Company")},
{u"pipeline"_s, QStringLiteral("Pipeline")},
{u"salary"_s, QStringLiteral("Salary")},
{u"details"_s, QStringLiteral("Additional Details")},
};
}
QList<Field> fields()
{
return {
{u"company"_s, u"company"_s, QStringLiteral("Company:"), TextRow, {}, {}, 0, 0},
{u"title"_s, u"company"_s, QStringLiteral("Job Title:"), TextRow, {}, {}, 0, 0},
{u"location"_s, u"company"_s, QStringLiteral("Location:"), TextRow, {}, {}, 0, 0},
{u"remoteType"_s,
u"company"_s,
QStringLiteral("Remote Type:"),
ComboRow,
{QStringLiteral("Unspecified"), QStringLiteral("Onsite"), QStringLiteral("Hybrid"), QStringLiteral("Remote")},
{},
0,
0},
{u"stage"_s, u"pipeline"_s, QStringLiteral("Stage:"), ComboRow, JobStage::canonicalStages(), {}, 0, 0},
{u"dateApplied"_s, u"pipeline"_s, QStringLiteral("Date Applied:"), DateRow, {}, {}, 0, 0},
{u"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryExpectation"_s, u"salary"_s, QStringLiteral("Your Expectation:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"currency"_s, u"salary"_s, QStringLiteral("Currency:"), TextRow, {}, {}, 0, 0},
{u"source"_s, u"details"_s, QStringLiteral("Source:"), TextRow, {}, QStringLiteral("Referral, LinkedIn, company site..."), 0, 0},
{u"url"_s, u"details"_s, QStringLiteral("Job Posting URL:"), TextRow, {}, {}, 0, 0},
{u"contact"_s, u"details"_s, QStringLiteral("Contact:"), TextRow, {}, {}, 0, 0},
{u"notes"_s, u"details"_s, QStringLiteral("Notes:"), TextAreaRow, {}, {}, 0, 0},
};
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
/**
* The static structure of the job edit form: which categories exist, and
* which fields belong to each, in display order. Mirrors FlatKontrol's
* PermissionCatalog - JobEditModel supplies the per-job values, this
* supplies the shape.
*/
namespace JobFieldCatalog
{
enum RowType {
TextRow = 0,
ComboRow,
SpinBoxRow,
DateRow,
TextAreaRow,
};
struct Category {
QString id;
QString title;
};
struct Field {
QString id; ///< Matches a Job/JobsModel field key (see JobsModel::mapFromJob).
QString categoryId;
QString label;
RowType rowType;
QStringList comboOptions; ///< Only meaningful for ComboRow.
QString placeholder; ///< Only meaningful for TextRow.
int spinMin = 0; ///< Only meaningful for SpinBoxRow.
int spinMax = 0;
};
QList<Category> categories();
QList<Field> fields();
}
+147 -12
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsdatabase.h"
#include "jobstage.h"
@@ -17,6 +22,10 @@ namespace
{
QAtomicInteger<int> s_connectionCounter{0};
QString s_pathOverride;
QString s_configuredPath;
bool s_selectionPending = false;
QVariant salaryToVariant(int value)
{
if (value < 0) {
@@ -33,6 +42,10 @@ int salaryFromVariant(const QVariant &value)
JobsDatabase::JobsDatabase()
{
if (s_selectionPending) {
m_lastError = u"No database selected"_s;
return;
}
init(defaultPath());
}
@@ -43,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()) {
@@ -50,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());
@@ -127,6 +229,26 @@ bool JobsDatabase::migrate()
return false;
}
// Rows written before stages were canonicalized on write (e.g.
// "rejected" via the CLI) would otherwise show up as separate Sankey
// nodes with default column/color. lower() is ASCII-only, which is fine:
// the stage vocabulary is ASCII.
for (const QString &stage : JobStage::canonicalStages()) {
for (const QString &statement : {
u"UPDATE jobs SET stage = :s WHERE stage != :s AND lower(stage) = lower(:s)"_s,
u"UPDATE stage_history SET to_stage = :s WHERE to_stage != :s AND lower(to_stage) = lower(:s)"_s,
u"UPDATE stage_history SET from_stage = :s WHERE from_stage != :s AND lower(from_stage) = lower(:s)"_s,
}) {
QSqlQuery normalize(db);
normalize.prepare(statement);
normalize.bindValue(u":s"_s, stage);
if (!normalize.exec()) {
m_lastError = normalize.lastError().text();
return false;
}
}
}
return true;
}
@@ -166,6 +288,9 @@ Job JobsDatabase::jobFromQuery(QSqlQuery &query) const
QList<Job> JobsDatabase::allJobs() const
{
QList<Job> 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()) {
@@ -179,6 +304,9 @@ QList<Job> JobsDatabase::allJobs() const
std::optional<Job> 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);
@@ -197,6 +325,7 @@ bool JobsDatabase::addJob(Job &job)
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
return false;
}
job.stage = JobStage::canonical(job.stage);
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -204,7 +333,8 @@ bool JobsDatabase::addJob(Job &job)
job.updatedAt = now;
QSqlQuery query(db);
query.prepare(uR"(
query.prepare(
uR"(
INSERT INTO jobs (company, title, location, remote_type, source, url, date_applied,
salary_min, salary_max, salary_expectation, currency, notes, contact,
stage, created_at, updated_at)
@@ -252,7 +382,8 @@ bool JobsDatabase::updateJob(const Job &job)
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
QSqlQuery query(db);
query.prepare(uR"(
query.prepare(
uR"(
UPDATE jobs SET company = :company, title = :title, location = :location,
remote_type = :remote_type, source = :source, url = :url,
date_applied = :date_applied, salary_min = :salary_min,
@@ -294,13 +425,14 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
return false;
}
const QString stage = JobStage::canonical(newStage);
const auto current = jobById(id);
if (!current) {
m_lastError = u"No job with id %1"_s.arg(id);
return false;
}
if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) {
if (current->stage.compare(stage, Qt::CaseInsensitive) == 0) {
return true;
}
@@ -309,7 +441,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
QSqlQuery query(db);
query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s);
query.bindValue(u":stage"_s, newStage);
query.bindValue(u":stage"_s, stage);
query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate));
query.bindValue(u":id"_s, id);
if (!query.exec()) {
@@ -321,7 +453,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
history.bindValue(u":job_id"_s, id);
history.bindValue(u":from_stage"_s, current->stage);
history.bindValue(u":to_stage"_s, newStage);
history.bindValue(u":to_stage"_s, stage);
history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate));
if (!history.exec()) {
m_lastError = history.lastError().text();
@@ -350,6 +482,9 @@ bool JobsDatabase::deleteJob(int id)
QList<StageTransition> JobsDatabase::stageTransitions() const
{
QList<StageTransition> 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()) {
+42 -3
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "job.h"
@@ -26,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;
@@ -54,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;
};
+7 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsmodel.h"
#include "jobstage.h"
@@ -12,6 +17,7 @@ JobsModel::JobsModel(QObject *parent)
void JobsModel::refresh()
{
m_db.reopenIfPathChanged();
beginResetModel();
m_jobs = m_db.allJobs();
endResetModel();
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "job.h"
+20 -3
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobstage.h"
#include <QHash>
@@ -27,6 +32,19 @@ bool isValid(const QString &stage)
return canonicalStages().contains(stage, Qt::CaseInsensitive);
}
QString canonical(const QString &stage)
{
if (stage.compare(QLatin1String(Start), Qt::CaseInsensitive) == 0) {
return QString::fromLatin1(Start);
}
for (const QString &candidate : canonicalStages()) {
if (stage.compare(candidate, Qt::CaseInsensitive) == 0) {
return candidate;
}
}
return stage;
}
int column(const QString &stage)
{
static const QHash<QString, int> columns{
@@ -86,8 +104,7 @@ QColor color(const QString &stage)
bool isTerminal(const QString &stage)
{
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn")
|| stage == QLatin1String("Ghosted");
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn") || stage == QLatin1String("Ghosted");
}
}
+11 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QColor>
@@ -20,6 +25,11 @@ QStringList canonicalStages();
bool isValid(const QString &stage);
/// Canonical-case spelling for a stage matched case-insensitively
/// ("rejected" -> "Rejected", "start" -> Start). Unknown input is returned
/// unchanged.
QString canonical(const QString &stage);
/// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three
/// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last
/// column so a rejection right after Applied is still a valid (longer) link.
+90 -10
View File
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "kareer-version.h"
#include "clicommands.h"
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KAboutData>
#include <KCrash>
@@ -12,6 +19,7 @@
#include <QApplication>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QFileInfo>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
@@ -20,12 +28,32 @@ using namespace Qt::Literals::StringLiterals;
// Filter out a couple of well-known benign framework artifacts rather than
// spamming every run. Everything else is passed through untouched.
static QtMessageHandler s_defaultMessageHandler = nullptr;
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
static bool isBenignFrameworkNoise(const QString &message)
{
// Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even
// for a trivial empty page, is harmless, and cannot be avoided from app code.
if (message.contains(QLatin1String("was not placed in the graphics scene"))) {
return true;
}
// An internal PageRow/StackView implementation detail, not something app
// code can influence.
if (message.contains(QLatin1String("StackView has detected conflicting anchors"))) {
return true;
}
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) {
return true;
}
return false;
}
static QtMessageHandler s_defaultMessageHandler = nullptr;
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (isBenignFrameworkNoise(message)) {
return;
}
// Malformed path data in third-party icon SVGs (system/app icon themes) is not
@@ -33,24 +61,55 @@ static void messageHandler(QtMsgType type, const QMessageLogContext &context, co
if (context.category && qstrcmp(context.category, "qt.svg") == 0) {
return;
}
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) {
return;
}
if (s_defaultMessageHandler) {
s_defaultMessageHandler(type, context, message);
}
}
/// Removes "--db <path>" / "--db=<path>" 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);
}
@@ -81,12 +140,33 @@ 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);
QObject::connect(&engine, &QQmlApplicationEngine::warnings, &engine, [](const QList<QQmlError> &warnings) {
for (const QQmlError &error : warnings) {
if (isBenignFrameworkNoise(error.description())) {
continue;
}
fprintf(stderr, "QML-WARNING: %s\n", qPrintable(error.toString()));
}
fflush(stderr);
});
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &engine, [](const QUrl &url) {
fprintf(stderr, "QML-OBJECT-CREATION-FAILED: %s\n", qPrintable(url.toString()));
fflush(stderr);
});
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
if (engine.rootObjects().isEmpty()) {
return -1;
-216
View File
@@ -1,216 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer
FormCard.FormCardDialog {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: editingJobId < 0 ? i18nc("@title:dialog", "Add Application") : i18nc("@title:dialog", "Edit Application")
standardButtons: QQC2.Dialog.Save | QQC2.Dialog.Cancel
function openForAdd(): void {
editingJobId = -1;
companyField.text = "";
titleField.text = "";
locationField.text = "";
remoteCombo.currentIndex = 0;
sourceField.text = "";
urlField.text = "";
dateField.value = new Date();
salaryMinField.value = 0;
salaryMaxField.value = 0;
salaryExpectationField.value = 0;
currencyField.text = "USD";
contactField.text = "";
notesField.text = "";
const stages = root.jobsModel.stages;
stageCombo.currentIndex = stages.indexOf("Applied");
errorLabel.text = "";
root.open();
}
function openForEdit(id: int): void {
editingJobId = id;
const data = root.jobsModel.jobData(id);
companyField.text = data.company;
titleField.text = data.title;
locationField.text = data.location;
remoteCombo.currentIndex = Math.max(0, remoteCombo.model.indexOf(data.remoteType));
sourceField.text = data.source;
urlField.text = data.url;
dateField.value = data.dateApplied;
salaryMinField.value = data.salaryMin > 0 ? data.salaryMin : 0;
salaryMaxField.value = data.salaryMax > 0 ? data.salaryMax : 0;
salaryExpectationField.value = data.salaryExpectation > 0 ? data.salaryExpectation : 0;
currencyField.text = data.currency;
contactField.text = data.contact;
notesField.text = data.notes;
const stages = root.jobsModel.stages;
stageCombo.currentIndex = Math.max(0, stages.indexOf(data.stage));
errorLabel.text = "";
root.open();
}
onAccepted: {
const fields = {
company: companyField.text,
title: titleField.text,
location: locationField.text,
remoteType: remoteCombo.currentIndex === 0 ? "" : remoteCombo.currentText,
source: sourceField.text,
url: urlField.text,
dateApplied: dateField.value,
salaryMin: salaryMinField.value > 0 ? salaryMinField.value : -1,
salaryMax: salaryMaxField.value > 0 ? salaryMaxField.value : -1,
salaryExpectation: salaryExpectationField.value > 0 ? salaryExpectationField.value : -1,
currency: currencyField.text,
contact: contactField.text,
notes: notesField.text,
stage: stageCombo.currentText,
};
const ok = root.editingJobId < 0 ? root.jobsModel.addJob(fields) : root.jobsModel.updateJob(root.editingJobId, fields);
if (!ok) {
errorLabel.text = root.jobsModel.lastError();
root.open();
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: companyField
label: i18nc("@label:textbox", "Company")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: titleField
label: i18nc("@label:textbox", "Job Title")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: locationField
label: i18nc("@label:textbox", "Location")
}
FormCard.FormDelegateSeparator {}
FormCard.FormComboBoxDelegate {
id: remoteCombo
text: i18nc("@label:listbox", "Remote Type")
model: ["Unspecified", "Onsite", "Hybrid", "Remote"]
}
}
FormCard.FormCard {
FormCard.FormComboBoxDelegate {
id: stageCombo
text: i18nc("@label:listbox", "Stage")
model: root.jobsModel.stages
}
FormCard.FormDelegateSeparator {}
FormCard.FormHeader {
title: i18nc("@title:group", "Date Applied")
}
FormCard.FormDateTimeDelegate {
id: dateField
dateTimeDisplay: FormCard.FormDateTimeDelegate.DateTimeDisplay.Date
}
}
FormCard.FormCard {
FormCard.FormSpinBoxDelegate {
id: salaryMinField
label: i18nc("@label:spinbox", "Salary Range Minimum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryMaxField
label: i18nc("@label:spinbox", "Salary Range Maximum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryExpectationField
label: i18nc("@label:spinbox", "Your Salary Expectation")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: currencyField
label: i18nc("@label:textbox", "Currency")
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: sourceField
label: i18nc("@label:textbox", "Source")
placeholderText: i18nc("@info:placeholder", "Referral, LinkedIn, company site...")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: urlField
label: i18nc("@label:textbox", "Job Posting URL")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: contactField
label: i18nc("@label:textbox", "Contact")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextAreaDelegate {
id: notesField
label: i18nc("@label:textbox", "Notes / Expectations")
}
}
FormCard.FormCard {
visible: root.editingJobId >= 0
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Delete Application")
icon.name: "edit-delete"
onClicked: deleteConfirmDialog.open()
}
}
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
visible: text.length > 0
}
Kirigami.PromptDialog {
id: deleteConfirmDialog
title: i18nc("@title", "Delete Application")
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
standardButtons: QQC2.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
onTriggered: {
root.jobsModel.removeJob(root.editingJobId);
deleteConfirmDialog.close();
root.close();
}
}
]
}
}
+323
View File
@@ -0,0 +1,323 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per category, flat rows indented under the
// header and separated by Kirigami.Separator, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.dateandtime as DateTime
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: root.editingJobId < 0 ? i18nc("@title", "Add Application") : i18nc("@title", "Edit Application")
signal done()
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Company", the first category.
bottomPadding: Kirigami.Units.gridUnit
JobEditModel {
id: editModel
jobsModel: root.jobsModel
editingJobId: root.editingJobId
}
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Cancel")
icon.name: "dialog-cancel"
onTriggered: root.done()
},
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
visible: root.editingJobId >= 0
onTriggered: deleteConfirmDialog.open()
},
Kirigami.Action {
text: i18nc("@action:button", "Save")
icon.name: "document-save"
// On failure, editModel.lastError is set and shown by errorLabel.
onTriggered: {
if (editModel.save()) {
root.done();
}
}
}
]
ColumnLayout {
spacing: 0
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
text: editModel.lastError
visible: text.length > 0
}
Repeater {
model: editModel.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: section.modelData.title
}
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: editModel
filterRoleName: "categoryId"
// No category id is a prefix of another, so this is an exact match.
filterString: section.modelData.id
}
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.largeSpacing
Repeater {
id: rowRepeater
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Text
DelegateChoice {
roleValue: 0
delegate: ColumnLayout {
id: textD
required property int index
required property string label
required property string value
required property string placeholder
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: textD.label
}
QQC2.TextField {
Layout.fillWidth: true
text: textD.value
placeholderText: textD.placeholder
onTextEdited: editModel.setValue(textD.sourceRow, text)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: textD.index !== rowRepeater.count - 1
}
}
}
// Combo box
DelegateChoice {
roleValue: 1
delegate: ColumnLayout {
id: comboD
required property int index
required property string label
required property string value
required property var comboOptions
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: comboD.label
}
QQC2.ComboBox {
Layout.fillWidth: true
model: comboD.comboOptions
currentIndex: comboD.comboOptions.indexOf(comboD.value)
onActivated: editModel.setValue(comboD.sourceRow, currentText)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: comboD.index !== rowRepeater.count - 1
}
}
}
// Spin box
DelegateChoice {
roleValue: 2
delegate: ColumnLayout {
id: spinD
required property int index
required property string label
required property var model
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: spinD.label
}
QQC2.SpinBox {
id: spinBox
Layout.fillWidth: true
from: spinD.model.spinMin
to: spinD.model.spinMax
stepSize: 1000
// Live binding so values loaded after the delegate is
// created still show up. Writing back only on
// valueModified (user edits) avoids a binding loop.
value: spinD.model.value
onValueModified: editModel.setValue(spinD.sourceRow, value)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: spinD.index !== rowRepeater.count - 1
}
}
}
// Date
DelegateChoice {
roleValue: 3
delegate: ColumnLayout {
id: dateD
required property int index
required property string label
required property var value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: dateD.label
}
QQC2.Button {
Layout.fillWidth: true
text: Qt.formatDate(dateD.value, Qt.ISODate)
icon.name: "view-calendar-day"
onClicked: {
datePopup.value = dateD.value;
datePopup.open();
}
DateTime.DatePopup {
id: datePopup
onAccepted: editModel.setValue(dateD.sourceRow, value)
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: dateD.index !== rowRepeater.count - 1
}
}
}
// Text area
DelegateChoice {
roleValue: 4
delegate: ColumnLayout {
id: areaD
required property int index
required property string label
required property string value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: areaD.label
}
QQC2.TextArea {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
wrapMode: TextEdit.Wrap
text: areaD.value
// TextArea has no textEdited signal; skip the echo from
// the model-driven binding and only write real edits.
onTextChanged: {
if (text !== areaD.value) {
editModel.setValue(areaD.sourceRow, text);
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: areaD.index !== rowRepeater.count - 1
}
}
}
}
}
}
}
}
}
Kirigami.PromptDialog {
id: deleteConfirmDialog
title: i18nc("@title", "Delete Application")
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
standardButtons: QQC2.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
onTriggered: {
editModel.deleteJob();
deleteConfirmDialog.close();
root.done();
}
}
]
}
}
+17 -30
View File
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Sidebar/detail two-column layout: a search-filtered list of applications
// with a RoundedItemDelegate + SubtitleContentItem delegate, where the
// currently-open entry stays highlighted.
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
@@ -12,7 +20,9 @@ Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
required property var editDialog
property int currentJobId: -1
signal editRequested(int jobId)
title: i18nc("@title", "Applications")
@@ -21,14 +31,6 @@ Kirigami.ScrollablePage {
onTextChanged: filteredJobs.filterString = text
}
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.editDialog.openForAdd()
}
]
KItemModels.KSortFilterProxyModel {
id: filteredJobs
sourceModel: root.jobsModel
@@ -49,32 +51,17 @@ Kirigami.ScrollablePage {
required property string company
required property string title
required property string stage
required property var dateApplied
required property int salaryMin
required property int salaryMax
required property string currency
text: jobDelegate.company
icon.source: "network-workgroup-symbolic"
highlighted: root.currentJobId === jobDelegate.jobId
contentItem: Delegates.SubtitleContentItem {
itemDelegate: jobDelegate
subtitle: {
const parts = [jobDelegate.title, jobDelegate.stage];
if (jobDelegate.dateApplied) {
parts.push(Qt.formatDate(jobDelegate.dateApplied, "yyyy-MM-dd"));
}
if (jobDelegate.salaryMin >= 0 || jobDelegate.salaryMax >= 0) {
let salary = jobDelegate.currency + " ";
salary += jobDelegate.salaryMin >= 0 ? jobDelegate.salaryMin : "?";
salary += "";
salary += jobDelegate.salaryMax >= 0 ? jobDelegate.salaryMax : "?";
parts.push(salary);
}
return parts.join(" · ");
}
subtitle: jobDelegate.title + " · " + jobDelegate.stage
}
onClicked: root.editDialog.openForEdit(jobDelegate.jobId)
onClicked: root.editRequested(jobDelegate.jobId)
}
Kirigami.PlaceholderMessage {
@@ -83,7 +70,7 @@ Kirigami.ScrollablePage {
visible: jobList.count === 0
icon.name: "office-address-book-symbolic"
text: i18n("No applications yet")
explanation: i18n("Use the Add Application button to log your first one.")
explanation: i18n("Use the Add Application button on the dashboard to log your first one.")
}
}
}
+17 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
import QtQuick
@@ -12,8 +17,18 @@ Kirigami.ScrollablePage {
required property JobsModel jobsModel
signal addRequested()
title: i18nc("@title", "Dashboard")
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.addRequested()
}
]
StatsModel {
id: statsModel
}
@@ -57,7 +72,7 @@ Kirigami.ScrollablePage {
}
QQC2.Label {
text: statCard.modelData.label
opacity: 0.7
color: Kirigami.Theme.disabledTextColor
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
+104
View File
@@ -0,0 +1,104 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
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 <filename>%1</filename> 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();
}
}
}
}
+94 -7
View File
@@ -1,8 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer
Kirigami.ApplicationWindow {
@@ -19,22 +25,94 @@ Kirigami.ApplicationWindow {
id: jobsModel
}
ApplicationEditDialog {
id: editDialog
jobsModel: 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();
}
}
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22
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
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 16
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
// Two static columns (applications + dashboard) - no dynamic page pushing.
globalDrawer: Kirigami.GlobalDrawer {
isMenu: true
actions: [
Kirigami.Action {
text: i18nc("@action:inmenu", "Preferences…")
icon.name: "configure"
onTriggered: root.pageStack.pushDialogLayer(settingsPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 24,
height: Kirigami.Units.gridUnit * 20,
modality: Qt.NonModal
})
},
Kirigami.Action {
text: i18nc("@action:inmenu", "About %1", root.title)
icon.name: "help-about"
onTriggered: root.pageStack.pushDialogLayer(aboutPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 30,
height: Kirigami.Units.gridUnit * 30,
modality: Qt.NonModal
})
}
]
}
Component {
id: aboutPageComponent
FormCard.AboutPage {}
}
Component {
id: settingsPageComponent
SettingsPage {}
}
// Two static columns: the applications list (sidebar) and the dashboard.
// "Add Application" (and clicking a row) replaces just the dashboard
// column with the edit form - the sidebar and its list are untouched.
pageStack.initialPage: [applicationsComponent, dashboardComponent]
function showDashboard(): void {
root.currentJobId = -1;
root.pageStack.currentIndex = 1;
root.pageStack.replace(dashboardComponent);
}
function showEditPage(jobId: int): void {
root.currentJobId = jobId;
root.pageStack.currentIndex = 1;
root.pageStack.replace(editPageComponent, {editingJobId: jobId});
}
Component {
id: applicationsComponent
ApplicationsPage {
jobsModel: jobsModel
editDialog: editDialog
currentJobId: root.currentJobId
onEditRequested: jobId => root.showEditPage(jobId)
}
}
@@ -42,6 +120,15 @@ Kirigami.ApplicationWindow {
id: dashboardComponent
DashboardPage {
jobsModel: jobsModel
onAddRequested: root.showEditPage(-1)
}
}
Component {
id: editPageComponent
ApplicationEditPage {
jobsModel: jobsModel
onDone: root.showDashboard()
}
}
}
+35 -10
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
import QtQuick
@@ -13,21 +18,29 @@ Item {
id: root
readonly property bool empty: sankeyModel.empty
readonly property real nodeWidth: Math.round(Kirigami.Units.gridUnit * 0.75)
readonly property real rowPadding: Kirigami.Units.gridUnit
SankeyModel {
id: sankeyModel
}
function refresh(): void {
if (root.width > 0 && root.height > 0) {
sankeyModel.relayout(root.width, root.height);
}
sankeyModel.reload(root.width, root.height, root.nodeWidth, root.rowPadding);
}
onWidthChanged: refresh()
onHeightChanged: refresh()
// Resizes only need fresh geometry, not a database re-read, and are
// debounced so a window drag doesn't rebuild every delegate per pixel.
onWidthChanged: relayoutTimer.restart()
onHeightChanged: relayoutTimer.restart()
Component.onCompleted: refresh()
Timer {
id: relayoutTimer
interval: 150
onTriggered: sankeyModel.relayout(root.width, root.height, root.nodeWidth, root.rowPadding)
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
@@ -41,7 +54,6 @@ Item {
model: sankeyModel.links
delegate: Shape {
required property var modelData
asynchronous: true
ShapePath {
fillColor: modelData.color
strokeColor: "transparent"
@@ -74,14 +86,27 @@ Item {
hoverEnabled: true
}
// Subtle backing so the label stays legible where ribbons pass
// underneath it.
Rectangle {
anchors.fill: labelHeading
anchors.margins: -Kirigami.Units.smallSpacing / 2
radius: 3
color: Kirigami.Theme.backgroundColor
opacity: 0.6
}
Kirigami.Heading {
id: labelHeading
level: 5
anchors.left: parent.right
anchors.left: nodeDelegate.modelData.labelOnRight ? parent.right : undefined
anchors.right: nodeDelegate.modelData.labelOnRight ? undefined : parent.left
anchors.leftMargin: Kirigami.Units.smallSpacing
anchors.rightMargin: Kirigami.Units.smallSpacing
anchors.verticalCenter: parent.verticalCenter
width: nodeDelegate.modelData.labelWidth
width: Math.min(nodeDelegate.modelData.labelWidth, implicitWidth)
horizontalAlignment: nodeDelegate.modelData.labelOnRight ? Text.AlignLeft : Text.AlignRight
text: nodeDelegate.modelData.label
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
elide: Text.ElideRight
}
}
+169
View File
@@ -0,0 +1,169 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per section, flat rows indented under the
// 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
Kirigami.ScrollablePage {
id: root
title: i18nc("@title:window", "Preferences")
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Appearance".
bottomPadding: Kirigami.Units.gridUnit
ColumnLayout {
width: root.width
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Appearance")
}
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:listbox", "Color scheme")
}
QQC2.ComboBox {
id: colorSchemeCombo
Layout.fillWidth: true
model: AppColorScheme.colorSchemesModel
textRole: "display"
delegate: QQC2.ItemDelegate {
id: schemeDelegate
required property var model
width: colorSchemeCombo.width
icon.source: "image://colorScheme/" + schemeDelegate.model.display
icon.color: "transparent"
text: schemeDelegate.model.display
highlighted: schemeDelegate.model.display === AppColorScheme.activeColorSchemeName
onClicked: {
AppColorScheme.activeColorSchemeName = schemeDelegate.model.display;
colorSchemeCombo.popup.close();
}
}
// Keep the closed-box label in sync without fighting the popup's own selection state.
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 <filename>%1</filename>. 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)
}
}
+583 -132
View File
@@ -1,23 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "sankeymodel.h"
#include "jobstage.h"
#include <KLocalizedString>
#include <QColor>
#include <QHash>
#include <QPair>
#include <QPointF>
#include <QSet>
#include <QVariantMap>
#include <algorithm>
#include <iterator>
#include <cmath>
#include <limits>
using namespace Qt::Literals::StringLiterals;
namespace
{
QString num(qreal v)
QString num(qreal value)
{
return QString::number(v, 'f', 2);
return QString::number(value, 'f', 2);
}
QString point(qreal x, qreal y)
@@ -25,17 +35,60 @@ QString point(qreal x, qreal y)
return num(x) + u","_s + num(y);
}
/// Cubic S-curve with horizontal tangents from the current point (xa, ya) to
/// (xb, yb). Every ribbon crossing the same gap between two columns uses the
/// same xa/xb, so two ribbons in the same vertical order at both ends of a
/// gap cannot cross inside it.
QString curveTo(qreal xa, qreal ya, qreal xb, qreal yb)
{
const qreal mid = (xa + xb) / 2.0;
return u" C"_s + point(mid, ya) + u" "_s + point(mid, yb) + u" "_s + point(xb, yb);
}
/// Outcomes other than Accepted: drop-offs end here. Accepted continues the
/// main line of the funnel instead.
bool isSink(const QString &stage)
{
return JobStage::isTerminal(stage) && stage != QLatin1String("Accepted");
}
struct NodeInfo {
QString stage;
int column = 0;
int rank = 0; ///< Index among the columns actually present.
int order = 0;
int value = 0;
bool sink = false;
qreal x = 0;
qreal y = 0;
qreal width = 0;
qreal height = 0;
};
struct LinkInfo {
QString from;
QString to;
int value = 0;
int fromIndex = 0;
int toIndex = 0;
int fromRank = 0;
int toRank = 0;
bool sink = false; ///< Ends in a drop-off outcome.
qreal thickness = 0;
qreal sourceY = 0;
qreal targetY = 0;
/// Top y of the ribbon in each column it passes through (fromRank+1 .. toRank-1).
QHash<int, qreal> laneY;
bool passesColumns() const
{
return toRank - fromRank > 1;
}
};
} // namespace
SankeyModel::SankeyModel(QObject *parent)
: QObject(parent)
@@ -57,128 +110,140 @@ bool SankeyModel::isEmpty() const
return m_nodes.isEmpty();
}
void SankeyModel::relayout(qreal width, qreal height)
void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
{
m_db.reopenIfPathChanged();
m_counts.clear();
// Each application is drawn once, as one left-to-right path: the funnel
// stages it reached in order, then its current stage if that is an
// outcome. Backward or sideways moves (Offer back to Interview, Ghosted
// then Rejected) are history, not flow; drawing them as ribbons would
// run right-to-left across everything else.
QList<int> jobOrder;
QHash<int, QStringList> stagesByJob;
for (const StageTransition &transition : m_db.stageTransitions()) {
const QString stage = JobStage::canonical(transition.toStage);
if (!JobStage::isValid(stage)) {
continue;
}
if (!stagesByJob.contains(transition.jobId)) {
jobOrder.append(transition.jobId);
}
stagesByJob[transition.jobId].append(stage);
}
for (int jobId : std::as_const(jobOrder)) {
const QStringList stages = stagesByJob.value(jobId);
QStringList path{QString::fromLatin1(JobStage::Start)};
int furthestColumn = JobStage::column(path.first());
for (const QString &stage : stages) {
if (JobStage::isTerminal(stage)) {
continue;
}
const int column = JobStage::column(stage);
if (column > furthestColumn) {
path.append(stage);
furthestColumn = column;
}
}
if (!stages.isEmpty() && JobStage::isTerminal(stages.last())) {
path.append(stages.last());
}
for (int i = 0; i + 1 < path.size(); ++i) {
++m_counts[{path.at(i), path.at(i + 1)}];
}
}
relayout(width, height, nodeWidth, padding);
}
void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal padding)
{
m_nodes.clear();
m_links.clear();
if (width <= 0 || height <= 0) {
if (width <= 0 || height <= 0 || nodeWidth <= 0 || m_counts.isEmpty()) {
Q_EMIT changed();
return;
}
const QList<StageTransition> transitions = m_db.stageTransitions();
QHash<QPair<QString, QString>, int> counts;
for (const StageTransition &t : transitions) {
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
counts[{from, t.toStage}] += 1;
}
if (counts.isEmpty()) {
Q_EMIT changed();
return;
}
padding = qMax<qreal>(0.0, padding);
//
// Graph. Only left-to-right links are laid out; reload() never produces
// anything else, but stay safe against arbitrary counts.
//
QHash<QString, int> inbound;
QHash<QString, int> outbound;
QSet<QString> stageNames;
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
QList<QPair<QString, QString>> linkKeys;
for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
const QString &from = it.key().first;
const QString &to = it.key().second;
if (it.value() <= 0 || from.isEmpty() || to.isEmpty() || JobStage::column(to) <= JobStage::column(from)) {
continue;
}
outbound[from] += it.value();
inbound[to] += it.value();
stageNames.insert(from);
stageNames.insert(to);
linkKeys.append(it.key());
}
if (stageNames.isEmpty()) {
Q_EMIT changed();
return;
}
//
// Nodes and the columns actually present.
//
QList<NodeInfo> nodeList;
nodeList.reserve(stageNames.size());
for (const QString &stage : std::as_const(stageNames)) {
NodeInfo n;
n.stage = stage;
n.column = JobStage::column(stage);
n.order = JobStage::orderInColumn(stage);
n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(n);
NodeInfo node;
node.stage = stage;
node.column = JobStage::column(stage);
node.order = JobStage::orderInColumn(stage);
node.sink = isSink(stage);
// Applications still sitting in a stage make its inflow exceed its
// outflow; the node must be tall enough for the larger side.
node.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(node);
}
std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) {
if (a.column != b.column) {
return a.column < b.column;
}
if (a.order != b.order) {
return a.order < b.order;
}
return a.stage < b.stage;
});
QHash<int, QList<int>> columnIndices;
int maxColumn = 0;
for (int i = 0; i < nodeList.size(); ++i) {
columnIndices[nodeList.at(i).column].append(i);
maxColumn = qMax(maxColumn, nodeList.at(i).column);
}
constexpr qreal nodeWidth = 16.0;
constexpr qreal padding = 10.0;
// A single vertical scale is shared by every column: the column with the
// largest total flow determines it, so no column can overflow the
// available height.
qreal scale = 1.0;
bool haveScale = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
int total = 0;
for (int idx : it.value()) {
total += nodeList.at(idx).value;
}
if (total <= 0) {
continue;
}
const qreal gaps = padding * qMax(0, it.value().size() - 1);
const qreal available = qMax<qreal>(1.0, height - gaps);
const qreal candidate = available / total;
if (!haveScale || candidate < scale) {
scale = candidate;
haveScale = true;
}
}
if (!haveScale) {
scale = 1.0;
}
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
const QList<int> &idxs = it.value();
qreal totalHeight = 0;
for (int idx : idxs) {
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0);
}
const qreal gaps = padding * qMax(0, idxs.size() - 1);
const qreal startY = qMax<qreal>(0.0, (height - totalHeight - gaps) / 2.0);
const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0;
qreal cursorY = startY;
for (int idx : idxs) {
NodeInfo &n = nodeList[idx];
n.x = x;
n.y = cursorY;
n.width = nodeWidth;
n.height = qMax<qreal>(n.value * scale, 2.0);
cursorY += n.height + padding;
QList<int> usedColumns;
for (const NodeInfo &node : std::as_const(nodeList)) {
if (!usedColumns.contains(node.column)) {
usedColumns.append(node.column);
}
}
std::sort(usedColumns.begin(), usedColumns.end());
const int rankCount = usedColumns.size();
QHash<QString, int> nodeIndexByStage;
for (int i = 0; i < nodeList.size(); ++i) {
nodeList[i].rank = usedColumns.indexOf(nodeList.at(i).column);
nodeIndexByStage.insert(nodeList.at(i).stage, i);
}
QHash<QString, qreal> sourceCursor;
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
targetCursor.insert(n.stage, n.y);
}
QList<QPair<QString, QString>> linkKeys = counts.keys();
//
// Links.
//
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
const int aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first);
@@ -188,72 +253,458 @@ void SankeyModel::relayout(qreal width, qreal height)
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
});
QList<LinkInfo> links;
links.reserve(linkKeys.size());
for (const auto &key : std::as_const(linkKeys)) {
const QString &from = key.first;
const QString &to = key.second;
const int value = counts.value(key);
const qreal thickness = qMax<qreal>(value * scale, 1.5);
LinkInfo link;
link.from = key.first;
link.to = key.second;
link.value = m_counts.value(key);
link.fromIndex = nodeIndexByStage.value(key.first);
link.toIndex = nodeIndexByStage.value(key.second);
link.fromRank = nodeList.at(link.fromIndex).rank;
link.toRank = nodeList.at(link.toIndex).rank;
link.sink = nodeList.at(link.toIndex).sink;
links.append(link);
}
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
//
// Order the drop-off outcomes top to bottom by where their applications
// dropped off: outcomes fed by later stages sit higher. Drop-off ribbons
// travel in lanes ordered the same way (latest stage innermost), so this
// keeps the ribbons' order when they rejoin as close as possible to the
// order they travelled in.
//
QHash<int, qreal> meanSourceRank;
{
QHash<int, qreal> weighted;
QHash<int, int> total;
for (const LinkInfo &link : std::as_const(links)) {
if (link.sink) {
weighted[link.toIndex] += static_cast<qreal>(link.value) * link.fromRank;
total[link.toIndex] += link.value;
}
}
for (auto it = total.constBegin(); it != total.constEnd(); ++it) {
meanSourceRank.insert(it.key(), weighted.value(it.key()) / it.value());
}
}
QList<int> sinkOrder;
for (int i = 0; i < nodeList.size(); ++i) {
if (nodeList.at(i).sink) {
sinkOrder.append(i);
}
}
std::sort(sinkOrder.begin(), sinkOrder.end(), [&](int a, int b) {
const qreal ma = meanSourceRank.value(a);
const qreal mb = meanSourceRank.value(b);
if (!qFuzzyCompare(ma + 1.0, mb + 1.0)) {
return ma > mb;
}
return a < b; // nodeList is already sorted by JobStage::orderInColumn().
});
QHash<int, int> sinkPosition;
for (int i = 0; i < sinkOrder.size(); ++i) {
sinkPosition.insert(sinkOrder.at(i), i);
}
const qreal y0Top = sourceCursor.value(from);
const qreal y0Bottom = y0Top + thickness;
sourceCursor[from] = y0Bottom;
//
// What each column holds, top to bottom:
//
// over-lanes main-line links skipping this column (e.g. Applied -> Interview)
// spine node the funnel stage (or Accepted)
// under-lanes drop-off links on their way to an outcome
// sinks the drop-off outcomes themselves (last column only)
//
// Lanes keep one order along their whole run, and a stage's drop-offs join
// the under-lanes on top (innermost), so drop-offs peel off each stage and
// nest around each other instead of braiding.
//
QList<QList<int>> overLanes(rankCount);
QList<QList<int>> underLanes(rankCount);
QList<QList<int>> spineNodes(rankCount);
QList<QList<int>> sinkNodes(rankCount);
const qreal y1Top = targetCursor.value(to);
const qreal y1Bottom = y1Top + thickness;
targetCursor[to] = y1Bottom;
for (int i = 0; i < links.size(); ++i) {
const LinkInfo &link = links.at(i);
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
(link.sink ? underLanes : overLanes)[rank].append(i);
}
}
for (int rank = 0; rank < rankCount; ++rank) {
std::sort(overLanes[rank].begin(), overLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
if (la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
return a < b;
});
std::sort(underLanes[rank].begin(), underLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
if (sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
}
for (int i = 0; i < nodeList.size(); ++i) {
if (!nodeList.at(i).sink) {
spineNodes[nodeList.at(i).rank].append(i);
}
}
for (int index : std::as_const(sinkOrder)) {
sinkNodes[nodeList.at(index).rank].append(index);
}
const qreal x0 = fromNode.x + fromNode.width;
const qreal x1 = toNode.x;
const qreal midX = (x0 + x1) / 2.0;
//
// Shared scale: the largest for which every column's stack fits.
//
// Link thickness is always exactly value * scale. Nodes get a small
// visual minimum height, which never changes link widths.
//
constexpr qreal minimumNodeHeight = 2.0;
const QString path = u"M"_s + point(x0, y0Top) + u" C"_s + point(midX, y0Top) + u" "_s + point(midX, y1Top) + u" "_s + point(x1, y1Top)
+ u" L"_s + point(x1, y1Bottom) + u" C"_s + point(midX, y1Bottom) + u" "_s + point(midX, y0Bottom) + u" "_s + point(x0, y0Bottom) + u" Z"_s;
const auto laneThickness = [&](const QList<int> &lanes, qreal scale) {
qreal sum = 0;
for (int index : lanes) {
sum += links.at(index).value * scale;
}
return sum;
};
const auto blockCount = [&](int rank) {
return static_cast<int>(!overLanes.at(rank).isEmpty()) + static_cast<int>(!underLanes.at(rank).isEmpty()) + spineNodes.at(rank).size()
+ sinkNodes.at(rank).size();
};
QList<qreal> minimumHeight(rankCount, 0.0);
for (int rank = 0; rank < rankCount; ++rank) {
const int nodeCount = spineNodes.at(rank).size() + sinkNodes.at(rank).size();
if (nodeCount > 0) {
const qreal available = qMax<qreal>(0.0, height - padding * qMax(0, blockCount(rank) - 1));
minimumHeight[rank] = qMin(minimumNodeHeight, available / nodeCount);
}
}
const auto stackHeight = [&](int rank, qreal scale) {
qreal used = padding * qMax(0, blockCount(rank) - 1);
used += laneThickness(overLanes.at(rank), scale) + laneThickness(underLanes.at(rank), scale);
for (const QList<int> *group : {&spineNodes.at(rank), &sinkNodes.at(rank)}) {
for (int index : *group) {
used += qMax(minimumHeight.at(rank), nodeList.at(index).value * scale);
}
}
return used;
};
const auto fits = [&](qreal scale) {
for (int rank = 0; rank < rankCount; ++rank) {
if (stackHeight(rank, scale) > height + 0.0001) {
return false;
}
}
return true;
};
QColor linkColor = JobStage::color(from);
linkColor.setAlphaF(0.5f);
qreal scale = 0.0;
if (fits(0.0)) {
qreal low = 0.0;
qreal high = 0.0;
for (const NodeInfo &node : std::as_const(nodeList)) {
if (node.value > 0) {
high = qMax(high, height / static_cast<qreal>(node.value));
}
}
for (int iteration = 0; iteration < 60; ++iteration) {
const qreal middle = (low + high) / 2.0;
if (fits(middle)) {
low = middle;
} else {
high = middle;
}
}
scale = low;
}
for (LinkInfo &link : links) {
link.thickness = link.value * scale;
}
for (int i = 0; i < nodeList.size(); ++i) {
NodeInfo &node = nodeList[i];
node.width = nodeWidth;
node.height = qMax(minimumHeight.at(node.rank), node.value * scale);
}
//
// Positions. Columns spread over the width by rank; every stack starts at
// the same top, and the tallest one is centered vertically.
//
qreal contentHeight = 0;
for (int rank = 0; rank < rankCount; ++rank) {
contentHeight = qMax(contentHeight, stackHeight(rank, scale));
}
const qreal top = qMax<qreal>(0.0, (height - contentHeight) / 2.0);
const qreal bottom = top + contentHeight;
QList<qreal> columnX(rankCount);
for (int rank = 0; rank < rankCount; ++rank) {
columnX[rank] = rankCount > 1 ? rank * (width - nodeWidth) / static_cast<qreal>(rankCount - 1) : (width - nodeWidth) / 2.0;
}
for (NodeInfo &node : nodeList) {
node.x = columnX.at(node.rank);
}
//
// Where each ribbon leaves its source, top to bottom: links skipping
// ahead over later stages, the link to the next stage, then drop-offs.
// Applications still sitting in the stage leave the bottom of the node
// empty. A column has at most one main-line node, so the next stage's
// position never needs to break a tie here.
//
QList<QList<int>> outgoing(nodeList.size());
QList<QList<int>> incoming(nodeList.size());
for (int i = 0; i < links.size(); ++i) {
outgoing[links.at(i).fromIndex].append(i);
incoming[links.at(i).toIndex].append(i);
}
const auto outCategory = [&](const LinkInfo &link) {
if (link.sink) {
return 2;
}
return link.passesColumns() ? 0 : 1;
};
const auto assignOutSlots = [&](int n) {
QList<int> &slotOrder = outgoing[n];
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
const int ca = outCategory(la);
const int cb = outCategory(lb);
if (ca != cb) {
return ca < cb;
}
if (ca == 0 && la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
if (ca == 2 && sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].sourceY = cursor;
cursor += links.at(index).thickness;
}
};
QList<qreal> sinkCursor(rankCount, top);
for (int rank = 0; rank < rankCount; ++rank) {
qreal cursor = top;
bool first = true;
const auto startBlock = [&]() {
if (!first) {
cursor += padding;
}
first = false;
};
if (!overLanes.at(rank).isEmpty()) {
startBlock();
for (int index : overLanes.at(rank)) {
links[index].laneY.insert(rank, cursor);
cursor += links.at(index).thickness;
}
}
for (int index : spineNodes.at(rank)) {
startBlock();
nodeList[index].y = cursor;
cursor += nodeList.at(index).height;
assignOutSlots(index);
}
if (!underLanes.at(rank).isEmpty()) {
// Drop-off lanes stay level instead of rising whenever the stage
// above them gets shorter: each lane sits no higher than it was in
// the previous column (or where it left its stage), and only moves
// down to make room. They still keep their order, and are pulled
// back up only as far as needed to stay inside the diagram.
startBlock();
const QList<int> &lanes = underLanes.at(rank);
for (int index : lanes) {
LinkInfo &link = links[index];
const qreal previous = link.fromRank == rank - 1 ? link.sourceY : link.laneY.value(rank - 1);
const qreal y = qMax(cursor, previous);
link.laneY.insert(rank, y);
cursor = y + link.thickness;
}
qreal limit = bottom;
for (auto it = lanes.crbegin(); it != lanes.crend(); ++it) {
LinkInfo &link = links[*it];
const qreal y = qMin(link.laneY.value(rank), limit - link.thickness);
link.laneY.insert(rank, y);
limit = y;
}
cursor = links.at(lanes.last()).laneY.value(rank) + links.at(lanes.last()).thickness;
}
sinkCursor[rank] = first ? cursor : cursor + padding;
}
//
// Drop-off outcomes: stacked in the last column, as close as possible to
// the height their ribbons arrive at, so rejoining stays shallow.
//
for (int rank = 0; rank < rankCount; ++rank) {
const QList<int> &sinks = sinkNodes.at(rank);
if (sinks.isEmpty()) {
continue;
}
qreal arrival = std::numeric_limits<qreal>::max();
for (int n : sinks) {
for (int index : std::as_const(incoming.at(n))) {
const LinkInfo &link = links.at(index);
arrival = qMin(arrival, link.passesColumns() ? link.laneY.value(link.toRank - 1) : link.sourceY);
}
}
qreal groupHeight = padding * (sinks.size() - 1);
for (int n : sinks) {
groupHeight += nodeList.at(n).height;
}
const qreal lowest = sinkCursor.at(rank);
const qreal highest = qMax(lowest, bottom - groupHeight);
qreal cursor = qBound(lowest, arrival, highest);
for (int n : sinks) {
nodeList[n].y = cursor;
cursor += nodeList.at(n).height + padding;
}
}
//
// Where each ribbon enters its target, top to bottom: for a stage, links
// arriving over earlier stages first, then the link from the previous
// stage; for a drop-off outcome, the latest stage first, matching the
// lane order the ribbons arrive in.
//
for (int n = 0; n < nodeList.size(); ++n) {
QList<int> &slotOrder = incoming[n];
const bool sink = nodeList.at(n).sink;
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (sink) {
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
return a < b;
}
if (la.passesColumns() != lb.passesColumns()) {
return la.passesColumns();
}
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
return a < b;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].targetY = cursor;
cursor += links.at(index).thickness;
}
}
//
// Ribbon paths: an S-curve through each gap between columns and a straight
// run across every column the ribbon passes.
//
for (const LinkInfo &link : std::as_const(links)) {
if (link.thickness <= 0.0) {
continue;
}
const NodeInfo &fromNode = nodeList.at(link.fromIndex);
const NodeInfo &toNode = nodeList.at(link.toIndex);
// Top edge, left to right: (x, y) at each column boundary.
QList<QPointF> edge;
edge.append({fromNode.x + fromNode.width, link.sourceY});
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
const qreal y = link.laneY.value(rank);
edge.append({columnX.at(rank), y});
edge.append({columnX.at(rank) + nodeWidth, y});
}
edge.append({toNode.x, link.targetY});
QString path = u"M"_s + point(edge.first().x(), edge.first().y());
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
// Odd steps cross a gap; even steps run straight across a column.
path += i % 2 == 1 ? curveTo(a.x(), a.y(), b.x(), b.y()) : u" L"_s + point(b.x(), b.y());
}
path += u" L"_s + point(edge.last().x(), edge.last().y() + link.thickness);
for (int i = edge.size() - 1; i > 0; --i) {
const QPointF &a = edge.at(i);
const QPointF &b = edge.at(i - 1);
path += i % 2 == 1 ? curveTo(a.x(), a.y() + link.thickness, b.x(), b.y() + link.thickness) : u" L"_s + point(b.x(), b.y() + link.thickness);
}
path += u" Z"_s;
QColor linkColor = JobStage::color(link.from);
linkColor.setAlphaF(0.5);
m_links.append(QVariantMap{
{u"fromStage"_s, from},
{u"toStage"_s, to},
{u"value"_s, value},
{u"fromStage"_s, link.from},
{u"toStage"_s, link.to},
{u"value"_s, link.value},
{u"pathData"_s, path},
{u"color"_s, linkColor},
{u"thickness"_s, link.thickness},
{u"sourceY"_s, link.sourceY},
{u"targetY"_s, link.targetY},
});
}
// Labels are drawn to the right of each node. Without a bound, a long
// label (e.g. "Applications") can run into the next column's node and
// its own label. Cap each node's label to the gap before the next
// distinct column actually in use (skipping empty columns), so text
// elides instead of overlapping.
QList<qreal> columnStarts;
for (const NodeInfo &n : std::as_const(nodeList)) {
if (!columnStarts.contains(n.x)) {
columnStarts.append(n.x);
}
}
std::sort(columnStarts.begin(), columnStarts.end());
//
// Nodes and their labels. Labels go right of every column but the last,
// whose labels go left.
//
constexpr qreal labelMargin = 8.0;
constexpr qreal minLabelWidth = 24.0;
const int lastRank = rankCount - 1;
for (const NodeInfo &n : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData());
for (const NodeInfo &node : std::as_const(nodeList)) {
const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData());
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x)));
const qreal nextColumnX = columnPos + 1 < columnStarts.size() ? columnStarts.at(columnPos + 1) : width;
const qreal labelWidth = qMax<qreal>(24.0, nextColumnX - (n.x + n.width) - 8.0);
const bool labelOnRight = node.rank < lastRank || lastRank == 0;
qreal labelWidth = 0.0;
if (lastRank == 0) {
labelWidth = width - (node.x + node.width) - labelMargin;
} else if (node.rank < lastRank) {
const qreal gap = columnX.at(node.rank + 1) - (node.x + node.width);
// The last gap is shared with the last column's labels, which
// are drawn on its left.
const qreal share = node.rank == lastRank - 1 ? 0.4 : 1.0;
labelWidth = gap * share - 2.0 * labelMargin;
} else {
const qreal gap = node.x - (columnX.at(node.rank - 1) + node.width);
labelWidth = gap * 0.6 - 2.0 * labelMargin;
}
labelWidth = qMax(labelWidth, minLabelWidth);
m_nodes.append(QVariantMap{
{u"stage"_s, n.stage},
{u"stage"_s, node.stage},
{u"label"_s, label},
{u"x"_s, n.x},
{u"y"_s, n.y},
{u"width"_s, n.width},
{u"height"_s, n.height},
{u"value"_s, n.value},
{u"color"_s, JobStage::color(n.stage)},
{u"x"_s, node.x},
{u"y"_s, node.y},
{u"width"_s, node.width},
{u"height"_s, node.height},
{u"value"_s, node.value},
{u"color"_s, JobStage::color(node.stage)},
{u"labelWidth"_s, labelWidth},
{u"labelOnRight"_s, labelOnRight},
});
}
+16 -4
View File
@@ -1,9 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "jobsdatabase.h"
#include <QHash>
#include <QObject>
#include <QPair>
#include <QQmlEngine>
#include <QVariantList>
@@ -34,15 +41,20 @@ public:
bool isEmpty() const;
public Q_SLOTS:
/// Recomputes node/link geometry to fit within (width, height) logical
/// pixels. Call whenever the data or the available viewport changes.
void relayout(qreal width, qreal height);
/// Re-reads the stage history from the database, then lays it out to fit
/// within (width, height) logical pixels. Call whenever the data changes.
void reload(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
/// Recomputes node/link geometry from the cached stage history. Call on
/// viewport resizes; unlike reload() this never touches the database.
void relayout(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
Q_SIGNALS:
void changed();
private:
JobsDatabase m_db;
QHash<QPair<QString, QString>, int> m_counts;
QVariantList m_nodes;
QVariantList m_links;
};
+7 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "statsmodel.h"
#include "jobstage.h"
@@ -12,6 +17,7 @@ StatsModel::StatsModel(QObject *parent)
void StatsModel::refresh()
{
m_db.reopenIfPathChanged();
m_jobs = m_db.allJobs();
Q_EMIT changed();
}
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "jobsdatabase.h"