Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f304850c7a | ||
|
|
ea4a3cc315 | ||
|
|
9b57fc7bcf | ||
|
|
fc9928aef8 | ||
|
|
eb47a589cb | ||
|
|
289246a6d0 | ||
|
|
1f1c628ee4 | ||
|
|
76c6e05ee0 | ||
|
|
eee3c59f4f | ||
|
|
18dd49aaba | ||
|
|
260704cf9b | ||
|
|
aa3a31f014 | ||
|
|
92339853d3 | ||
|
|
170129feec | ||
|
|
d9dce6f4d1 | ||
|
|
506b66cfdd | ||
|
|
078343ff96 |
@@ -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
|
||||
@@ -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. -->
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Build and Publish Flatpak
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Build and Test
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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 go through the history.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition, `setStage` records a single move, and `replaceStageHistory` rewrites a job's whole history in one transaction (sorted by time, consecutive repeats collapsed; it also sets `jobs.stage` to the last step and `date_applied` to the first step's date). That history is the sole input to the Sankey diagram, so never write `jobs.stage` any other way. The GUI edits history rather than a stage field: `StageHistoryModel` backs the edit form's Pipeline section, and `JobEditModel::save()` derives stage/date applied from it and calls `replaceStageHistory` for new jobs and for edited histories. `kareer history <id> [Stage=YYYY-MM-DD ...]` is the CLI equivalent.
|
||||
|
||||
**Auto-ghosting.** `JobsDatabase::ghostStaleApplications(days)` moves jobs still at Applied whose last activity (the later of `date_applied` and their latest `stage_history` entry) is more than `days` old to Ghosted via `setStage`, so it is recorded like any move. `AutoGhost` (QML singleton; `kareerrc` `[AutoGhost] Enabled/Days`, default on/30) runs it at startup for GUI and CLI in `main.cpp`, again from `Main.qml` after a database switch, and ~1.5 s after the Preferences setting settles; `ran(count)` makes `Main.qml` refresh and show a passive notification.
|
||||
|
||||
**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. The `pipeline` category deliberately has no catalog fields; `ApplicationEditPage.qml` renders `JobEditModel::history` there instead. 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.
|
||||
|
||||
**App icon.** `icons/` holds the hicolor set named after the app ID (`sc-apps-io.github.toservetheking.Kareer.svg` plus PNGs rendered from it); the desktop file, `setDesktopFileName`, and Flatpak's icon export all depend on that exact name. The SVG is also embedded (`qt_add_resources` in `src/CMakeLists.txt`) as the window-icon fallback for uninstalled runs. Keep the SVG to plain shapes and gradients: QtSvg ignores filters.
|
||||
|
||||
## 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.
|
||||
@@ -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.1 LANGUAGES CXX)
|
||||
project(kareer VERSION 0.1.6 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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
@@ -16,6 +16,22 @@ Every GUI action is also available from the `kareer` CLI
|
||||
(`add|list|show|update|stage|delete|stats|stages`) — run `./build/bin/kareer
|
||||
--help` to see the subcommands.
|
||||
|
||||
To develop against a throwaway database instead of your real one, pass
|
||||
`--db` (the file is created if it doesn't exist); it works for the GUI and
|
||||
every subcommand:
|
||||
|
||||
```sh
|
||||
./build/bin/kareer --db build/dev.sqlite add --company Acme --title Engineer
|
||||
./build/bin/kareer --db build/dev.sqlite
|
||||
```
|
||||
|
||||
To see the first-run "where should the database live?" dialog, start with
|
||||
empty config and data directories:
|
||||
|
||||
```sh
|
||||
XDG_CONFIG_HOME=$(mktemp -d) XDG_DATA_HOME=$(mktemp -d) ./build/bin/kareer
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
@@ -30,10 +46,12 @@ 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -15,12 +15,18 @@ applications without ever opening a window.
|
||||
salary expectation, notes, contact, and the date applied
|
||||
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
|
||||
Offer, Accepted, Rejected, Withdrawn, Ghosted) with full history of
|
||||
every transition
|
||||
every transition. The history is editable, with a date per step, so an
|
||||
application you log after the fact (applied, interviewed, rejected)
|
||||
keeps its whole path
|
||||
- A Sankey diagram of the whole pipeline, showing where applications
|
||||
progress and where they drop off
|
||||
- Dashboard summary stats: total applications, active count, offers,
|
||||
response rate
|
||||
- A full CLI (`kareer add|list|show|update|stage|delete|stats|stages`)
|
||||
- Applications with no response are marked Ghosted automatically:
|
||||
anything still at Applied with no activity for 30 days (adjustable, or
|
||||
off, under Preferences). The move is recorded in its history like any
|
||||
other, and the CLI notes it on stderr so `--json` output stays clean.
|
||||
- A full CLI (`kareer add|list|show|update|stage|history|delete|stats|stages`)
|
||||
for scripting
|
||||
|
||||
## Command line usage
|
||||
@@ -49,6 +55,10 @@ kareer show 1
|
||||
kareer update 1 --salary-max 175000
|
||||
kareer stage 1 Interview
|
||||
|
||||
# Show or rewrite an application's stage history, one Stage=date per step
|
||||
kareer history 1
|
||||
kareer history 1 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20
|
||||
|
||||
# Delete (requires --yes to actually happen)
|
||||
kareer delete 1 --yes
|
||||
|
||||
@@ -58,9 +68,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 +118,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 +177,16 @@ 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.
|
||||
- `stagehistorymodel.{h,cpp}` - the editable stage history shown in the edit form's Pipeline section.
|
||||
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
|
||||
`delete`/`stats`/`stages` subcommands.
|
||||
`history`/`delete`/`stats`/`stages` subcommands.
|
||||
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
|
||||
- `databaselocation.{h,cpp}` - where the database lives: the first-run choice and the Preferences Database section.
|
||||
- `autoghost.{h,cpp}` - marks applications with no response as Ghosted, and its Preferences setting.
|
||||
- `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), `DatabaseSetupDialog`
|
||||
(the first-run database choice).
|
||||
|
||||
@@ -7,6 +7,17 @@ SPDX-PackageDownloadLocation = "https://github.com/toservetheking/Kareer"
|
||||
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]>"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(jobsdatabasetest
|
||||
@@ -37,3 +39,26 @@ 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/stagehistorymodel.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)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
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);
|
||||
|
||||
// The history's first step shows the date applied, not when it was logged.
|
||||
StageHistoryModel *history = edit.history();
|
||||
QCOMPARE(history->rowCount(), 1);
|
||||
QCOMPARE(history->data(history->index(0), StageHistoryModel::StageRole).toString(), u"Applied"_s);
|
||||
QCOMPARE(history->data(history->index(0), StageHistoryModel::DateRole).toDate(), QDate(2026, 6, 1));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Moving the application on is adding a step to its history.
|
||||
StageHistoryModel *history = edit.history();
|
||||
history->appendStep();
|
||||
history->setStage(history->rowCount() - 1, u"Interview"_s);
|
||||
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
|
||||
|
||||
JobsDatabase db;
|
||||
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
|
||||
QCOMPARE(db.jobById(id)->dateApplied, QDate(2026, 6, 1));
|
||||
const auto transitions = db.stageTransitions();
|
||||
QCOMPARE(transitions.size(), 2);
|
||||
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
|
||||
QCOMPARE(transitions.last().toStage, u"Interview"_s);
|
||||
}
|
||||
|
||||
// Applied, interviewed, then rejected: logged in one go after the fact.
|
||||
void logsApplicationAfterTheFact()
|
||||
{
|
||||
JobsModel jobs;
|
||||
JobEditModel edit;
|
||||
edit.setJobsModel(&jobs);
|
||||
setValueOf(edit, u"company"_s, u"Allstate"_s);
|
||||
setValueOf(edit, u"title"_s, u"Software Engineer"_s);
|
||||
|
||||
StageHistoryModel *history = edit.history();
|
||||
const auto day = [](int month, int dayOfMonth) {
|
||||
return QDateTime(QDate(2026, month, dayOfMonth), QTime(9, 0));
|
||||
};
|
||||
history->setDate(0, day(8, 1));
|
||||
history->appendStep();
|
||||
history->setStage(1, u"Interview"_s);
|
||||
history->setDate(1, day(8, 15));
|
||||
history->appendStep();
|
||||
history->setStage(2, u"Rejected"_s);
|
||||
history->setDate(2, day(8, 20));
|
||||
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
|
||||
|
||||
JobsDatabase db;
|
||||
const QList<Job> all = db.allJobs();
|
||||
QCOMPARE(all.size(), 1);
|
||||
QCOMPARE(all.first().stage, u"Rejected"_s);
|
||||
QCOMPARE(all.first().dateApplied, QDate(2026, 8, 1));
|
||||
const QList<StageStep> steps = db.stageHistory(all.first().id);
|
||||
QCOMPARE(steps.size(), 3);
|
||||
QCOMPARE(steps.at(1).stage, u"Interview"_s);
|
||||
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
|
||||
QCOMPARE(steps.at(2).at.toLocalTime().date(), QDate(2026, 8, 20));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// A new form's text fields must start empty; a missing value showed up
|
||||
// in QML as the word "undefined".
|
||||
void newFormFieldsStartEmpty()
|
||||
{
|
||||
JobsModel jobs;
|
||||
JobEditModel edit;
|
||||
edit.setJobsModel(&jobs);
|
||||
for (int row = 0; row < edit.rowCount(); ++row) {
|
||||
const QVariant value = edit.data(edit.index(row), JobEditModel::ValueRole);
|
||||
const QString id = edit.data(edit.index(row), JobEditModel::FieldIdRole).toString();
|
||||
QVERIFY2(value.isValid(), qPrintable(id));
|
||||
}
|
||||
for (const QString &id : {u"company"_s, u"title"_s, u"location"_s, u"source"_s, u"url"_s, u"contact"_s, u"notes"_s}) {
|
||||
const QVariant value = valueOf(edit, id);
|
||||
QCOMPARE(value.typeId(), QMetaType::QString);
|
||||
QVERIFY2(value.toString().isEmpty(), qPrintable(id));
|
||||
}
|
||||
QCOMPARE(valueOf(edit, u"currency"_s).toString(), u"USD"_s);
|
||||
QCOMPARE(valueOf(edit, u"remoteType"_s).toString(), u"Unspecified"_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"
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
#include "job.h"
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
@@ -139,6 +146,150 @@ private Q_SLOTS:
|
||||
QVERIFY(!db.jobById(job.id).has_value());
|
||||
QVERIFY(db.stageTransitions().isEmpty());
|
||||
}
|
||||
|
||||
void ghostsStaleApplications()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
const QString path = dbPathIn(dir);
|
||||
JobsDatabase db(path);
|
||||
const QDateTime now(QDate(2026, 9, 11), QTime(12, 0), QTimeZone::UTC);
|
||||
const auto daysAgo = [&](int days) {
|
||||
return now.addDays(-days);
|
||||
};
|
||||
|
||||
// Still at Applied, nothing for 40 days: ghosted.
|
||||
const int stale = addJob(db, {});
|
||||
backdate(path, stale, {daysAgo(40)}, daysAgo(40).date());
|
||||
// Applied 10 days ago: too recent.
|
||||
const int fresh = addJob(db, {});
|
||||
backdate(path, fresh, {daysAgo(10)}, daysAgo(10).date());
|
||||
// Got a response (Screening) 40 days ago: not ours to ghost.
|
||||
const int screening = addJob(db, {QStringLiteral("Screening")});
|
||||
backdate(path, screening, {daysAgo(45), daysAgo(40)}, daysAgo(45).date());
|
||||
// Applied 60 days ago but only logged 5 days ago: the month starts at logging.
|
||||
const int loggedLate = addJob(db, {});
|
||||
backdate(path, loggedLate, {daysAgo(5)}, daysAgo(60).date());
|
||||
// Logged 40 days ago, date applied later edited to 10 days ago.
|
||||
const int appliedLater = addJob(db, {});
|
||||
backdate(path, appliedLater, {daysAgo(40)}, daysAgo(10).date());
|
||||
// Ghosted before, then moved back to Applied 5 days ago: a fresh month.
|
||||
const int reopened = addJob(db, {QStringLiteral("Ghosted"), QStringLiteral("Applied")});
|
||||
backdate(path, reopened, {daysAgo(60), daysAgo(50), daysAgo(5)}, daysAgo(60).date());
|
||||
|
||||
QCOMPARE(db.ghostStaleApplications(0, now), 0);
|
||||
QCOMPARE(db.ghostStaleApplications(30, now), 1);
|
||||
|
||||
QCOMPARE(db.jobById(stale)->stage, QStringLiteral("Ghosted"));
|
||||
for (int id : {fresh, loggedLate, appliedLater, reopened}) {
|
||||
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Applied"));
|
||||
}
|
||||
QCOMPARE(db.jobById(screening)->stage, QStringLiteral("Screening"));
|
||||
|
||||
// Recorded like any other move, so the pipeline shows it.
|
||||
bool recorded = false;
|
||||
for (const StageTransition &t : db.stageTransitions()) {
|
||||
recorded |= t.jobId == stale && t.fromStage == QStringLiteral("Applied") && t.toStage == QStringLiteral("Ghosted");
|
||||
}
|
||||
QVERIFY(recorded);
|
||||
|
||||
// Nothing left to do on a second run; a shorter threshold catches more.
|
||||
QCOMPARE(db.ghostStaleApplications(30, now), 0);
|
||||
QCOMPARE(db.ghostStaleApplications(7, now), 2); // fresh and appliedLater, both 10 days
|
||||
}
|
||||
|
||||
// Logging an application after the fact: Applied, Interview, Rejected.
|
||||
void replacesStageHistory()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
const int id = addJob(db, {});
|
||||
const auto at = [](int month, int day) {
|
||||
return QDateTime(QDate(2026, month, day), QTime(12, 0));
|
||||
};
|
||||
|
||||
// Out of order and with a repeated stage, as an editor might hand it over.
|
||||
QVERIFY2(db.replaceStageHistory(id,
|
||||
{{QStringLiteral("rejected"), at(8, 20)},
|
||||
{QStringLiteral("Applied"), at(8, 1)},
|
||||
{QStringLiteral("Interview"), at(8, 15)},
|
||||
{QStringLiteral("Interview"), at(8, 16)}}),
|
||||
qPrintable(db.lastError()));
|
||||
|
||||
const QList<StageStep> steps = db.stageHistory(id);
|
||||
QCOMPARE(steps.size(), 3);
|
||||
QCOMPARE(steps.at(0).stage, QStringLiteral("Applied"));
|
||||
QCOMPARE(steps.at(1).stage, QStringLiteral("Interview"));
|
||||
QCOMPARE(steps.at(2).stage, QStringLiteral("Rejected"));
|
||||
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
|
||||
|
||||
const auto job = db.jobById(id);
|
||||
QCOMPARE(job->stage, QStringLiteral("Rejected"));
|
||||
QCOMPARE(job->dateApplied, QDate(2026, 8, 1));
|
||||
|
||||
// The moves chain from the synthetic Start, so the pipeline sees the path.
|
||||
QStringList moves;
|
||||
for (const StageTransition &t : db.stageTransitions()) {
|
||||
if (t.jobId == id) {
|
||||
moves.append((t.fromStage.isEmpty() ? QStringLiteral("Start") : t.fromStage) + QStringLiteral(">") + t.toStage);
|
||||
}
|
||||
}
|
||||
QCOMPARE(moves, (QStringList{QStringLiteral("Start>Applied"), QStringLiteral("Applied>Interview"), QStringLiteral("Interview>Rejected")}));
|
||||
|
||||
// Bad input changes nothing.
|
||||
QVERIFY(!db.replaceStageHistory(id, {}));
|
||||
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), at(8, 1)}, {QStringLiteral("Bogus"), at(8, 2)}}));
|
||||
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), QDateTime()}}));
|
||||
QVERIFY(!db.replaceStageHistory(9999, {{QStringLiteral("Applied"), at(8, 1)}}));
|
||||
QCOMPARE(db.stageHistory(id).size(), 3);
|
||||
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Rejected"));
|
||||
}
|
||||
|
||||
private:
|
||||
static int addJob(JobsDatabase &db, const QStringList &stages)
|
||||
{
|
||||
Job job;
|
||||
job.company = QStringLiteral("Co");
|
||||
job.title = QStringLiteral("Title");
|
||||
if (!db.addJob(job)) {
|
||||
return -1;
|
||||
}
|
||||
for (const QString &stage : stages) {
|
||||
db.setStage(job.id, stage);
|
||||
}
|
||||
return job.id;
|
||||
}
|
||||
|
||||
/// Rewrites a job's history timestamps (oldest first, one per recorded
|
||||
/// move) and its date applied, through a separate connection.
|
||||
static void backdate(const QString &path, int jobId, const QList<QDateTime> &history, const QDate &applied)
|
||||
{
|
||||
{
|
||||
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("backdate"));
|
||||
raw.setDatabaseName(path);
|
||||
QVERIFY(raw.open());
|
||||
QSqlQuery ids(raw);
|
||||
QVERIFY(ids.exec(QStringLiteral("SELECT id FROM stage_history WHERE job_id = %1 ORDER BY id").arg(jobId)));
|
||||
QList<int> rows;
|
||||
while (ids.next()) {
|
||||
rows.append(ids.value(0).toInt());
|
||||
}
|
||||
QCOMPARE(rows.size(), history.size());
|
||||
QSqlQuery update(raw);
|
||||
for (int i = 0; i < rows.size(); ++i) {
|
||||
update.prepare(QStringLiteral("UPDATE stage_history SET changed_at = ? WHERE id = ?"));
|
||||
update.addBindValue(history.at(i).toString(Qt::ISODate));
|
||||
update.addBindValue(rows.at(i));
|
||||
QVERIFY(update.exec());
|
||||
}
|
||||
update.prepare(QStringLiteral("UPDATE jobs SET date_applied = ?, created_at = ? WHERE id = ?"));
|
||||
update.addBindValue(applied.toString(Qt::ISODate));
|
||||
update.addBindValue(history.first().toString(Qt::ISODate));
|
||||
update.addBindValue(jobId);
|
||||
QVERIFY(update.exec());
|
||||
raw.close();
|
||||
}
|
||||
QSqlDatabase::removeDatabase(QStringLiteral("backdate"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(JobsDatabaseTest)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.5
|
||||
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=('bc1011f9a013d8d2b266da0fed7cbb50f7410e71b007844bfb9598639504168c')
|
||||
|
||||
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
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,26 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# Named after the app ID, as the desktop file, the window icon and Flatpak
|
||||
# (which only exports icons prefixed with the app ID) all expect.
|
||||
# Image loaders (gdk-pixbuf, and so AppStream compose in the Flatpak build)
|
||||
# recognise an SVG by finding "<svg" in its first 256 bytes; past that the
|
||||
# file is "Unrecognized image file format" and the Flatpak build fails.
|
||||
file(READ sc-apps-io.github.toservetheking.Kareer.svg _kareer_svg_head LIMIT 256)
|
||||
if(NOT _kareer_svg_head MATCHES "<svg")
|
||||
message(FATAL_ERROR "icons/sc-apps-io.github.toservetheking.Kareer.svg: the <svg> tag must start within the first 256 bytes; move comments inside the element.")
|
||||
endif()
|
||||
|
||||
ecm_install_icons(ICONS
|
||||
sc-apps-io.github.toservetheking.Kareer.svg
|
||||
128-apps-io.github.toservetheking.Kareer.png
|
||||
64-apps-io.github.toservetheking.Kareer.png
|
||||
48-apps-io.github.toservetheking.Kareer.png
|
||||
44-apps-io.github.toservetheking.Kareer.png
|
||||
32-apps-io.github.toservetheking.Kareer.png
|
||||
22-apps-io.github.toservetheking.Kareer.png
|
||||
16-apps-io.github.toservetheking.Kareer.png
|
||||
DESTINATION ${KDE_INSTALL_ICONDIR}
|
||||
THEME hicolor
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- 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>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#63d4c7"/>
|
||||
<stop offset="1" stop-color="#1a7a70"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- rounded-square backdrop -->
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" ry="28" fill="url(#bg)"/>
|
||||
|
||||
<!-- briefcase -->
|
||||
<g>
|
||||
<rect x="34" y="58" width="60" height="42" rx="6" fill="#ffffff" fill-opacity="0.92"/>
|
||||
<rect x="52" y="46" width="24" height="14" rx="4" fill="none" stroke="#ffffff" stroke-opacity="0.92" stroke-width="6"/>
|
||||
<rect x="34" y="72" width="60" height="10" fill="#1a7a70" fill-opacity="0.35"/>
|
||||
<rect x="60" y="68" width="8" height="10" rx="2" fill="#1a7a70" fill-opacity="0.55"/>
|
||||
</g>
|
||||
|
||||
<!-- upward trend line, representing progress through the pipeline -->
|
||||
<polyline points="30,44 46,30 58,38 74,22 90,30" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polygon points="90,30 98,28 92,36" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,41 @@
|
||||
<?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">
|
||||
<!--
|
||||
A K for Kareer, drawn as its application pipeline: the stem is every
|
||||
application, splitting into a success path rising to an outcome and a
|
||||
drop-off falling away. Plain shapes and gradients only, so QtSvg (which
|
||||
ignores filters) renders it exactly as other renderers do.
|
||||
-->
|
||||
<!-- Keep this comment inside <svg>: loaders sniff for "<svg" in the first
|
||||
256 bytes, so a long header comment makes the file unrecognisable. -->
|
||||
<defs>
|
||||
<linearGradient id="backdrop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#4cbcf2"/>
|
||||
<stop offset="1" stop-color="#1c6ea9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="success" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#6ff0a0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="dropoff" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.92"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- shadow, rounded-square backdrop, and a soft highlight on its top half -->
|
||||
<rect x="10" y="12" width="108" height="108" rx="24" fill="#0b2a44" fill-opacity="0.28"/>
|
||||
<rect x="10" y="10" width="108" height="108" rx="24" fill="url(#backdrop)"/>
|
||||
<rect x="10" y="10" width="108" height="54" rx="24" fill="#ffffff" fill-opacity="0.06"/>
|
||||
|
||||
<!-- the arms of the K: success rising, drop-off falling -->
|
||||
<path d="M41,44 C67,44 67,20 95,20 L95,40 C67,40 67,64 41,64 Z" fill="url(#success)"/>
|
||||
<path d="M41,64 C67,64 67,88 95,88 L95,108 C67,108 67,84 41,84 Z" fill="url(#dropoff)"/>
|
||||
|
||||
<!-- the stem (all applications) and the two outcomes -->
|
||||
<rect x="27" y="20" width="14" height="88" rx="4" fill="#ffffff"/>
|
||||
<rect x="95" y="20" width="6" height="20" rx="2" fill="#4be07a"/>
|
||||
<rect x="95" y="88" width="6" height="20" rx="2" fill="#ffffff" fill-opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 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,58 @@
|
||||
<content_rating type="oars-1.1"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.6" date="2026-09-11">
|
||||
<description>
|
||||
<p>Ships everything listed for 0.1.5, which was never published because its build failed.</p>
|
||||
<ul>
|
||||
<li>Fix the new app icon not being recognised by software centers, which stopped the Flatpak from building</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.5" date="2026-09-11">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Edit an application's full stage history, with a date for each step, so an application you log after the fact keeps its whole path (for example applied, interviewed, rejected)</li>
|
||||
<li>Add a history command to show or replace a stage history from the command line</li>
|
||||
<li>Mark applications with no response as Ghosted after 30 days, adjustable or off in Preferences</li>
|
||||
<li>New app icon, now installed under the app ID so launchers and the Flatpak show it</li>
|
||||
<li>Fix the Add Application form filling empty fields with the word "undefined"</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.4" date="2026-09-11">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fix Save on the edit form silently dropping stage changes, and report missing company or title</li>
|
||||
<li>Rework the pipeline diagram so drop-offs split off each stage and rejoin their outcome without braiding</li>
|
||||
<li>Count each application once in the pipeline, so moving back a stage or being ghosted then rejected no longer draws extra ribbons</li>
|
||||
<li>Choose where the database lives on first run: the default location, a folder of your choice, or an existing database</li>
|
||||
<li>Move, open or reset the database location from Preferences</li>
|
||||
<li>Add a --db option to use a specific database file from the command line or the GUI</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
@@ -27,12 +32,12 @@ modules:
|
||||
- -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.1
|
||||
# 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.6
|
||||
# commit: <fill in the exact commit SHA the tag points at>
|
||||
|
||||
@@ -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,24 @@ qt_add_qml_module(kareer
|
||||
statsmodel.h
|
||||
sankeymodel.cpp
|
||||
sankeymodel.h
|
||||
jobeditmodel.cpp
|
||||
jobeditmodel.h
|
||||
stagehistorymodel.cpp
|
||||
stagehistorymodel.h
|
||||
appcolorscheme.cpp
|
||||
appcolorscheme.h
|
||||
databaselocation.cpp
|
||||
databaselocation.h
|
||||
autoghost.cpp
|
||||
autoghost.h
|
||||
)
|
||||
|
||||
# The app icon, embedded so the window and About page show it even when
|
||||
# running from the build directory; the installed hicolor icon wins when present.
|
||||
qt_add_resources(kareer "appicon"
|
||||
PREFIX "/icons"
|
||||
BASE "${CMAKE_SOURCE_DIR}/icons"
|
||||
FILES "${CMAKE_SOURCE_DIR}/icons/sc-apps-io.github.toservetheking.Kareer.svg"
|
||||
)
|
||||
|
||||
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
|
||||
@@ -36,11 +59,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})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "autoghost.h"
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <KConfigGroup>
|
||||
#include <KSharedConfig>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
int s_lastRunCount = 0;
|
||||
|
||||
KConfigGroup settingsGroup()
|
||||
{
|
||||
return KConfigGroup(KSharedConfig::openConfig(u"kareerrc"_s), u"AutoGhost"_s);
|
||||
}
|
||||
}
|
||||
|
||||
AutoGhost::AutoGhost(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_settleTimer.setSingleShot(true);
|
||||
m_settleTimer.setInterval(1500);
|
||||
connect(&m_settleTimer, &QTimer::timeout, this, &AutoGhost::run);
|
||||
}
|
||||
|
||||
int AutoGhost::runNow()
|
||||
{
|
||||
const KConfigGroup group = settingsGroup();
|
||||
if (!group.readEntry("Enabled", true)) {
|
||||
s_lastRunCount = 0;
|
||||
return 0;
|
||||
}
|
||||
JobsDatabase db;
|
||||
s_lastRunCount = db.ghostStaleApplications(group.readEntry("Days", DefaultDays));
|
||||
return s_lastRunCount;
|
||||
}
|
||||
|
||||
bool AutoGhost::enabled() const
|
||||
{
|
||||
return settingsGroup().readEntry("Enabled", true);
|
||||
}
|
||||
|
||||
void AutoGhost::setEnabled(bool enabled)
|
||||
{
|
||||
if (enabled == this->enabled()) {
|
||||
return;
|
||||
}
|
||||
KConfigGroup group = settingsGroup();
|
||||
group.writeEntry("Enabled", enabled);
|
||||
group.sync();
|
||||
Q_EMIT settingsChanged();
|
||||
m_settleTimer.start();
|
||||
}
|
||||
|
||||
int AutoGhost::days() const
|
||||
{
|
||||
return settingsGroup().readEntry("Days", DefaultDays);
|
||||
}
|
||||
|
||||
void AutoGhost::setDays(int days)
|
||||
{
|
||||
days = qMax(1, days);
|
||||
if (days == this->days()) {
|
||||
return;
|
||||
}
|
||||
KConfigGroup group = settingsGroup();
|
||||
group.writeEntry("Days", days);
|
||||
group.sync();
|
||||
Q_EMIT settingsChanged();
|
||||
m_settleTimer.start();
|
||||
}
|
||||
|
||||
int AutoGhost::lastRunCount() const
|
||||
{
|
||||
return s_lastRunCount;
|
||||
}
|
||||
|
||||
int AutoGhost::run()
|
||||
{
|
||||
m_settleTimer.stop();
|
||||
const int count = runNow();
|
||||
Q_EMIT ran(count);
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QTimer>
|
||||
|
||||
/**
|
||||
* Marks applications with no response as Ghosted: anything still at
|
||||
* Applied whose last activity is more than days() old (see
|
||||
* JobsDatabase::ghostStaleApplications()). The switch and the threshold live
|
||||
* in kareerrc ([AutoGhost] Enabled/Days) and are shown on the Preferences
|
||||
* page.
|
||||
*
|
||||
* runNow() is called at startup for both the GUI and the CLI; the GUI runs
|
||||
* it again after switching databases and shortly after the settings change,
|
||||
* and refreshes the models when ran() reports moved applications.
|
||||
*/
|
||||
class AutoGhost : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
|
||||
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY settingsChanged)
|
||||
Q_PROPERTY(int days READ days WRITE setDays NOTIFY settingsChanged)
|
||||
/// How many applications the most recent run moved to Ghosted.
|
||||
Q_PROPERTY(int lastRunCount READ lastRunCount NOTIFY ran)
|
||||
|
||||
public:
|
||||
static constexpr int DefaultDays = 30;
|
||||
|
||||
explicit AutoGhost(QObject *parent = nullptr);
|
||||
|
||||
/// Ghosts stale applications in the current database if enabled, and
|
||||
/// returns how many were moved.
|
||||
static int runNow();
|
||||
|
||||
bool enabled() const;
|
||||
void setEnabled(bool enabled);
|
||||
int days() const;
|
||||
void setDays(int days);
|
||||
int lastRunCount() const;
|
||||
|
||||
/// runNow(), then emits ran() with the number of applications moved.
|
||||
Q_INVOKABLE int run();
|
||||
|
||||
Q_SIGNALS:
|
||||
void settingsChanged();
|
||||
void ran(int count);
|
||||
|
||||
private:
|
||||
/// Settings changes run shortly after they settle, so stepping the
|
||||
/// threshold down doesn't ghost applications at every value in between.
|
||||
QTimer m_settleTimer;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -521,11 +540,80 @@ int runStats(const QString &program, const QStringList &args)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runHistory(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(
|
||||
u"Show an application's stage history, or replace it with the given steps, e.g.\n"
|
||||
u" kareer history 3 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20\n"
|
||||
u"The last step becomes the current stage and the first step's date the date applied."_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"steps"_s, u"Optional: Stage=YYYY-MM-DD for each step, replacing the history"_s, u"[steps...]"_s);
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
QTextStream err(stderr);
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.isEmpty()) {
|
||||
err << u"Error: usage: kareer history <id> [Stage=YYYY-MM-DD ...]"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
bool ok = false;
|
||||
const int id = positional.first().toInt(&ok);
|
||||
if (!ok) {
|
||||
err << u"Error: id must be an integer"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
JobsDatabase db;
|
||||
if (!db.jobById(id)) {
|
||||
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (positional.size() > 1) {
|
||||
QList<StageStep> steps;
|
||||
for (const QString &arg : positional.mid(1)) {
|
||||
const qsizetype separator = arg.indexOf(u'=');
|
||||
const QString stage = arg.left(separator);
|
||||
const QDate date = QDate::fromString(arg.mid(separator + 1), Qt::ISODate);
|
||||
if (separator < 0 || !JobStage::isValid(stage) || !date.isValid()) {
|
||||
err << u"Error: '%1' is not Stage=YYYY-MM-DD. Valid stages: %2"_s.arg(arg, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
// Midday local time, so the calendar date survives time-zone conversion.
|
||||
steps.append({stage, QDateTime(date, QTime(12, 0))});
|
||||
}
|
||||
if (!db.replaceStageHistory(id, steps)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const QList<StageStep> steps = db.stageHistory(id);
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
QJsonArray array;
|
||||
for (const StageStep &step : steps) {
|
||||
array.append(QJsonObject{{u"stage"_s, step.stage}, {u"date"_s, step.at.toLocalTime().date().toString(Qt::ISODate)}});
|
||||
}
|
||||
printJson(array);
|
||||
} else {
|
||||
QTextStream out(stdout);
|
||||
for (const StageStep &step : steps) {
|
||||
out << step.at.toLocalTime().date().toString(Qt::ISODate) << u" "_s << step.stage << Qt::endl;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 +636,13 @@ 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
|
||||
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"_s << u" history Show or replace an application's stage history\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;
|
||||
<< 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 +651,16 @@ 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"history"_s,
|
||||
u"help"_s,
|
||||
};
|
||||
return subcommands.contains(arg);
|
||||
}
|
||||
@@ -604,5 +696,8 @@ int Cli::run(QCoreApplication &app)
|
||||
if (subcommand == u"stages"_s) {
|
||||
return runStages(program, rest);
|
||||
}
|
||||
if (subcommand == u"history"_s) {
|
||||
return runHistory(program, rest);
|
||||
}
|
||||
return runHelp();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -6,7 +11,7 @@
|
||||
class QCoreApplication;
|
||||
|
||||
/**
|
||||
* Headless command-line interface: `kareer add|list|show|update|stage|delete|stats|stages ...`.
|
||||
* Headless command-line interface: `kareer add|list|show|update|stage|history|delete|stats|stages ...`.
|
||||
* Lets other tools (a resume generator, a shell script) log and query
|
||||
* applications without ever starting the Kirigami GUI.
|
||||
*/
|
||||
|
||||
@@ -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 ¬ice)
|
||||
{
|
||||
if (m_lastError == error && m_lastNotice == notice) {
|
||||
return;
|
||||
}
|
||||
m_lastError = error;
|
||||
m_lastNotice = notice;
|
||||
Q_EMIT messageChanged();
|
||||
}
|
||||
@@ -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 ¬ice);
|
||||
|
||||
QString m_missingPath;
|
||||
QString m_lastError;
|
||||
QString m_lastNotice;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -26,6 +31,12 @@ struct Job {
|
||||
QDateTime updatedAt;
|
||||
};
|
||||
|
||||
/// One step in an application's history: the stage it moved to, and when.
|
||||
struct StageStep {
|
||||
QString stage;
|
||||
QDateTime at;
|
||||
};
|
||||
|
||||
/// One recorded move from one stage to another (or from "Start" for the
|
||||
/// initial application), used to build the Sankey diagram.
|
||||
struct StageTransition {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
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_history(new StageHistoryModel(this))
|
||||
, 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();
|
||||
|
||||
if (m_editingJobId < 0 || !m_jobsModel) {
|
||||
m_history->load({{u"Applied"_s, QDateTime(QDate::currentDate(), QTime(12, 0)).toUTC()}});
|
||||
// Every field needs a value of its own type: a missing one reaches
|
||||
// QML as undefined, which a text field shows as the word "undefined"
|
||||
// (typing "a" then gave "undefineda").
|
||||
for (const Field &field : m_fields) {
|
||||
switch (field.rowType) {
|
||||
case TextRow:
|
||||
case TextAreaRow:
|
||||
m_values[field.id] = QString();
|
||||
break;
|
||||
case ComboRow:
|
||||
m_values[field.id] = field.comboOptions.value(0);
|
||||
break;
|
||||
case SpinBoxRow:
|
||||
m_values[field.id] = 0;
|
||||
break;
|
||||
case DateRow:
|
||||
m_values[field.id] = QDate::currentDate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
m_values[u"currency"_s] = u"USD"_s;
|
||||
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;
|
||||
}
|
||||
|
||||
QList<StageStep> steps = m_jobsModel->stageHistory(m_editingJobId);
|
||||
if (steps.isEmpty()) {
|
||||
steps.append({data.value(u"stage"_s).toString(), data.value(u"createdAt"_s).toDateTime()});
|
||||
}
|
||||
// The first step's recorded time is when the job was logged; show the
|
||||
// date actually applied instead, which is what that step stands for.
|
||||
const QDate applied = data.value(u"dateApplied"_s).toDate();
|
||||
if (applied.isValid()) {
|
||||
steps.first().at = QDateTime(applied, QTime(12, 0)).toUTC();
|
||||
}
|
||||
m_history->load(steps);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
StageHistoryModel *JobEditModel::history() const
|
||||
{
|
||||
return m_history;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// The history decides the current stage and the date applied.
|
||||
const QList<StageStep> steps = m_history->steps();
|
||||
fields[u"stage"_s] = steps.last().stage;
|
||||
fields[u"dateApplied"_s] = steps.first().at.toLocalTime().date();
|
||||
|
||||
if (m_editingJobId < 0) {
|
||||
const int id = m_jobsModel->addJob(fields);
|
||||
// A new application's history is always written in full, so one
|
||||
// logged after the fact (Applied, Interview, Rejected) keeps its path.
|
||||
if (id < 0 || !m_jobsModel->replaceStageHistory(id, steps)) {
|
||||
setLastError(m_jobsModel->lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// updateJob() deliberately never writes the stage; stage changes only
|
||||
// ever come from the history.
|
||||
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
|
||||
setLastError(m_jobsModel->lastError());
|
||||
return false;
|
||||
}
|
||||
if (m_history->isEdited()) {
|
||||
if (!m_jobsModel->replaceStageHistory(m_editingJobId, steps)) {
|
||||
setLastError(m_jobsModel->lastError());
|
||||
return false;
|
||||
}
|
||||
m_history->load(m_jobsModel->stageHistory(m_editingJobId));
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "jobfieldcatalog.h"
|
||||
#include "jobsmodel.h"
|
||||
#include "stagehistorymodel.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)
|
||||
/// The application's stage history (the Pipeline section): the last step
|
||||
/// is its current stage, the first step's date its date applied.
|
||||
Q_PROPERTY(StageHistoryModel *history READ history CONSTANT)
|
||||
|
||||
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;
|
||||
StageHistoryModel *history() 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;
|
||||
StageHistoryModel *m_history = nullptr;
|
||||
QList<JobFieldCatalog::Field> m_fields;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobfieldcatalog.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},
|
||||
|
||||
// The "pipeline" category has no plain fields: ApplicationEditPage
|
||||
// shows JobEditModel::history there (stage and date applied come
|
||||
// from the history's last and first steps).
|
||||
|
||||
{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},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -347,9 +479,154 @@ bool JobsDatabase::deleteJob(int id)
|
||||
return true;
|
||||
}
|
||||
|
||||
QList<StageStep> JobsDatabase::stageHistory(int jobId) const
|
||||
{
|
||||
QList<StageStep> steps;
|
||||
if (!isOpen()) {
|
||||
return steps;
|
||||
}
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(u"SELECT to_stage, changed_at FROM stage_history WHERE job_id = :id ORDER BY changed_at ASC, id ASC"_s);
|
||||
query.bindValue(u":id"_s, jobId);
|
||||
if (!query.exec()) {
|
||||
return steps;
|
||||
}
|
||||
while (query.next()) {
|
||||
steps.append({query.value(0).toString(), QDateTime::fromString(query.value(1).toString(), Qt::ISODate)});
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
bool JobsDatabase::replaceStageHistory(int jobId, QList<StageStep> steps)
|
||||
{
|
||||
if (!jobById(jobId)) {
|
||||
m_lastError = u"No job with id %1"_s.arg(jobId);
|
||||
return false;
|
||||
}
|
||||
if (steps.isEmpty()) {
|
||||
m_lastError = u"An application needs at least one stage"_s;
|
||||
return false;
|
||||
}
|
||||
for (StageStep &step : steps) {
|
||||
if (!JobStage::isValid(step.stage)) {
|
||||
m_lastError = u"Unknown stage '%1'"_s.arg(step.stage);
|
||||
return false;
|
||||
}
|
||||
if (!step.at.isValid()) {
|
||||
m_lastError = u"Missing date for stage '%1'"_s.arg(step.stage);
|
||||
return false;
|
||||
}
|
||||
step.stage = JobStage::canonical(step.stage);
|
||||
}
|
||||
std::stable_sort(steps.begin(), steps.end(), [](const StageStep &a, const StageStep &b) {
|
||||
return a.at < b.at;
|
||||
});
|
||||
QList<StageStep> collapsed;
|
||||
for (const StageStep &step : std::as_const(steps)) {
|
||||
if (collapsed.isEmpty() || collapsed.last().stage != step.stage) {
|
||||
collapsed.append(step);
|
||||
}
|
||||
}
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
if (!db.transaction()) {
|
||||
m_lastError = db.lastError().text();
|
||||
return false;
|
||||
}
|
||||
const auto fail = [&](const QSqlQuery &query) {
|
||||
m_lastError = query.lastError().text();
|
||||
db.rollback();
|
||||
return false;
|
||||
};
|
||||
|
||||
QSqlQuery query(db);
|
||||
query.prepare(u"DELETE FROM stage_history WHERE job_id = :id"_s);
|
||||
query.bindValue(u":id"_s, jobId);
|
||||
if (!query.exec()) {
|
||||
return fail(query);
|
||||
}
|
||||
QString previous;
|
||||
for (const StageStep &step : std::as_const(collapsed)) {
|
||||
query.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
|
||||
query.bindValue(u":job_id"_s, jobId);
|
||||
query.bindValue(u":from_stage"_s, previous.isEmpty() ? QVariant() : QVariant(previous));
|
||||
query.bindValue(u":to_stage"_s, step.stage);
|
||||
query.bindValue(u":changed_at"_s, step.at.toUTC().toString(Qt::ISODate));
|
||||
if (!query.exec()) {
|
||||
return fail(query);
|
||||
}
|
||||
previous = step.stage;
|
||||
}
|
||||
query.prepare(u"UPDATE jobs SET stage = :stage, date_applied = :date_applied, updated_at = :updated_at WHERE id = :id"_s);
|
||||
query.bindValue(u":stage"_s, collapsed.last().stage);
|
||||
query.bindValue(u":date_applied"_s, collapsed.first().at.toLocalTime().date().toString(Qt::ISODate));
|
||||
query.bindValue(u":updated_at"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||
query.bindValue(u":id"_s, jobId);
|
||||
if (!query.exec()) {
|
||||
return fail(query);
|
||||
}
|
||||
if (!db.commit()) {
|
||||
m_lastError = db.lastError().text();
|
||||
db.rollback();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int JobsDatabase::ghostStaleApplications(int days, const QDateTime &now)
|
||||
{
|
||||
if (days <= 0 || !isOpen()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
QList<int> stale;
|
||||
{
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(
|
||||
uR"(
|
||||
SELECT j.id, j.date_applied, j.created_at, MAX(h.changed_at)
|
||||
FROM jobs j LEFT JOIN stage_history h ON h.job_id = j.id
|
||||
WHERE j.stage = :stage
|
||||
GROUP BY j.id
|
||||
)"_s);
|
||||
query.bindValue(u":stage"_s, u"Applied"_s);
|
||||
if (!query.exec()) {
|
||||
m_lastError = query.lastError().text();
|
||||
return 0;
|
||||
}
|
||||
while (query.next()) {
|
||||
// A job logged today for an application sent weeks ago only
|
||||
// starts its month today, and one moved back to Applied gets a
|
||||
// fresh month: whichever happened last counts.
|
||||
QDateTime lastActivity = QDateTime::fromString(query.value(3).toString(), Qt::ISODate);
|
||||
if (!lastActivity.isValid()) {
|
||||
lastActivity = QDateTime::fromString(query.value(2).toString(), Qt::ISODate);
|
||||
}
|
||||
const QDate applied = QDate::fromString(query.value(1).toString(), Qt::ISODate);
|
||||
if (applied.isValid() && (!lastActivity.isValid() || applied.startOfDay() > lastActivity)) {
|
||||
lastActivity = applied.startOfDay();
|
||||
}
|
||||
if (lastActivity.isValid() && lastActivity.addDays(days) < now) {
|
||||
stale.append(query.value(0).toInt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int moved = 0;
|
||||
for (int id : std::as_const(stale)) {
|
||||
if (setStage(id, u"Ghosted"_s)) {
|
||||
++moved;
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
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()) {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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;
|
||||
|
||||
@@ -50,13 +87,32 @@ public:
|
||||
|
||||
bool deleteJob(int id);
|
||||
|
||||
/// The steps one application went through, oldest first.
|
||||
QList<StageStep> stageHistory(int jobId) const;
|
||||
|
||||
/// Rewrites an application's whole history in one transaction (used by
|
||||
/// the history editor, e.g. to log Applied -> Interview -> Rejected after
|
||||
/// the fact). Steps are sorted by time (stably), consecutive repeats of a
|
||||
/// stage collapse into the first, the job's stage becomes the last step's
|
||||
/// and its date applied the first step's date. Fails, changing nothing,
|
||||
/// if there are no steps, a stage is unknown, or a time is invalid.
|
||||
bool replaceStageHistory(int jobId, QList<StageStep> steps);
|
||||
|
||||
/// Moves every application still at Applied whose last activity (the
|
||||
/// later of its date applied and its last stage change) is more than
|
||||
/// `days` days before `now` to Ghosted, recorded through setStage() like
|
||||
/// any other move. Returns how many were moved; does nothing if days <= 0.
|
||||
int ghostStaleApplications(int days, const QDateTime &now = QDateTime::currentDateTimeUtc());
|
||||
|
||||
QList<StageTransition> stageTransitions() const;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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();
|
||||
@@ -161,14 +167,14 @@ QVariantMap JobsModel::jobData(int id) const
|
||||
return mapFromJob(*job);
|
||||
}
|
||||
|
||||
bool JobsModel::addJob(const QVariantMap &fields)
|
||||
int JobsModel::addJob(const QVariantMap &fields)
|
||||
{
|
||||
Job job = jobFromMap(fields);
|
||||
const bool ok = m_db.addJob(job);
|
||||
if (ok) {
|
||||
refresh();
|
||||
if (!m_db.addJob(job)) {
|
||||
return -1;
|
||||
}
|
||||
return ok;
|
||||
refresh();
|
||||
return job.id;
|
||||
}
|
||||
|
||||
bool JobsModel::updateJob(int id, const QVariantMap &fields)
|
||||
@@ -200,6 +206,20 @@ bool JobsModel::removeJob(int id)
|
||||
return ok;
|
||||
}
|
||||
|
||||
QList<StageStep> JobsModel::stageHistory(int id) const
|
||||
{
|
||||
return m_db.stageHistory(id);
|
||||
}
|
||||
|
||||
bool JobsModel::replaceStageHistory(int id, const QList<StageStep> &steps)
|
||||
{
|
||||
const bool ok = m_db.replaceStageHistory(id, steps);
|
||||
if (ok) {
|
||||
refresh();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
QString JobsModel::lastError() const
|
||||
{
|
||||
return m_db.lastError();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
@@ -55,11 +60,15 @@ public:
|
||||
/// Full record for one job, for prefilling the edit dialog.
|
||||
Q_INVOKABLE QVariantMap jobData(int id) const;
|
||||
|
||||
Q_INVOKABLE bool addJob(const QVariantMap &fields);
|
||||
/// Adds a job and returns its id, or -1 on failure (see lastError()).
|
||||
Q_INVOKABLE int addJob(const QVariantMap &fields);
|
||||
Q_INVOKABLE bool updateJob(int id, const QVariantMap &fields);
|
||||
Q_INVOKABLE bool setStage(int id, const QString &stage);
|
||||
Q_INVOKABLE bool removeJob(int id);
|
||||
|
||||
QList<StageStep> stageHistory(int id) const;
|
||||
bool replaceStageHistory(int id, const QList<StageStep> &steps);
|
||||
|
||||
Q_INVOKABLE QString lastError() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "kareer-version.h"
|
||||
|
||||
#include "autoghost.h"
|
||||
#include "clicommands.h"
|
||||
#include "databaselocation.h"
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <KAboutData>
|
||||
#include <KCrash>
|
||||
@@ -12,6 +20,7 @@
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QIcon>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQuickStyle>
|
||||
@@ -20,12 +29,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 +62,59 @@ 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();
|
||||
if (const int ghosted = AutoGhost::runNow(); ghosted > 0) {
|
||||
// stderr, so --json output on stdout stays machine-readable.
|
||||
fprintf(stderr, "kareer: marked %d application(s) with no response as Ghosted\n", ghosted);
|
||||
}
|
||||
return Cli::run(app);
|
||||
}
|
||||
|
||||
@@ -75,18 +139,42 @@ int main(int argc, char *argv[])
|
||||
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
|
||||
KAboutData::setApplicationData(aboutData);
|
||||
|
||||
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon::fromTheme(u"office-address-book"_s)));
|
||||
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon(u":/icons/sc-apps-io.github.toservetheking.Kareer.svg"_s)));
|
||||
|
||||
KCrash::initialize();
|
||||
|
||||
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());
|
||||
if (!JobsDatabase::selectionPending()) {
|
||||
AutoGhost::runNow(); // before the models load; Main.qml reports the count
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// The Pipeline category has no plain fields: it is the
|
||||
// application's stage history. The last step is its current
|
||||
// stage, the first step's date its date applied.
|
||||
ColumnLayout {
|
||||
visible: section.modelData.id === "pipeline"
|
||||
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("@info", "Every stage this application went through, oldest first. The first date is when you applied, and the last stage is where it stands now.")
|
||||
wrapMode: Text.Wrap
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: section.modelData.id === "pipeline" ? editModel.history : null
|
||||
|
||||
delegate: RowLayout {
|
||||
id: stepRow
|
||||
required property int index
|
||||
required property string stage
|
||||
required property var date
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.ComboBox {
|
||||
Layout.fillWidth: true
|
||||
model: editModel.history.stages
|
||||
currentIndex: editModel.history.stages.indexOf(stepRow.stage)
|
||||
onActivated: editModel.history.setStage(stepRow.index, currentText)
|
||||
Accessible.name: i18nc("@label:listbox", "Stage")
|
||||
}
|
||||
QQC2.Button {
|
||||
text: Qt.formatDate(stepRow.date, Qt.ISODate)
|
||||
icon.name: "view-calendar-day"
|
||||
Accessible.name: i18nc("@action:button", "Date of this stage")
|
||||
onClicked: {
|
||||
stepDatePopup.value = stepRow.date;
|
||||
stepDatePopup.open();
|
||||
}
|
||||
|
||||
DateTime.DatePopup {
|
||||
id: stepDatePopup
|
||||
onAccepted: editModel.history.setDate(stepRow.index, value)
|
||||
}
|
||||
}
|
||||
QQC2.ToolButton {
|
||||
icon.name: "list-remove"
|
||||
text: i18nc("@action:button", "Remove Step")
|
||||
display: QQC2.AbstractButton.IconOnly
|
||||
enabled: editModel.history.count > 1
|
||||
QQC2.ToolTip.text: text
|
||||
QQC2.ToolTip.visible: hovered
|
||||
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
onClicked: editModel.history.removeStep(stepRow.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Button {
|
||||
icon.name: "list-add"
|
||||
text: i18nc("@action:button", "Add Step")
|
||||
onClicked: editModel.history.appendStep()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
visible: rowRepeater.count > 0
|
||||
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();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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,113 @@ 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 {
|
||||
AutoGhost.run();
|
||||
jobsModel.refresh();
|
||||
root.showDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22
|
||||
// Applications with no response moved to Ghosted (at startup, after a
|
||||
// database switch, or after the Preferences setting changed).
|
||||
Connections {
|
||||
target: AutoGhost
|
||||
function onRan(count: int): void {
|
||||
if (count > 0) {
|
||||
jobsModel.refresh();
|
||||
root.announceGhosted(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function announceGhosted(count: int): void {
|
||||
root.showPassiveNotification(i18ncp("@info", "Marked %1 application with no response as Ghosted", "Marked %1 applications with no response as Ghosted", count));
|
||||
}
|
||||
|
||||
DatabaseSetupDialog {
|
||||
id: setupDialog
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (DatabaseLocation.setupPending) {
|
||||
setupDialog.open();
|
||||
} else if (AutoGhost.lastRunCount > 0) {
|
||||
root.announceGhosted(AutoGhost.lastRunCount);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +139,15 @@ Kirigami.ApplicationWindow {
|
||||
id: dashboardComponent
|
||||
DashboardPage {
|
||||
jobsModel: jobsModel
|
||||
onAddRequested: root.showEditPage(-1)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: editPageComponent
|
||||
ApplicationEditPage {
|
||||
jobsModel: jobsModel
|
||||
onDone: root.showDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.ListSectionHeader {
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@title:group", "Applications")
|
||||
}
|
||||
|
||||
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.Switch {
|
||||
id: autoGhostSwitch
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@option:check", "Mark applications with no response as Ghosted")
|
||||
checked: AutoGhost.enabled
|
||||
onToggled: AutoGhost.enabled = checked
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
enabled: autoGhostSwitch.checked
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "After")
|
||||
}
|
||||
QQC2.SpinBox {
|
||||
from: 1
|
||||
to: 365
|
||||
value: AutoGhost.days
|
||||
onValueModified: AutoGhost.days = value
|
||||
}
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "days without any activity")
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@info", "Only applications still at Applied are affected. The move is recorded in their history, and you can change the stage back at any time.")
|
||||
wrapMode: Text.Wrap
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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;
|
||||
}
|
||||
return a.order < b.order;
|
||||
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);
|
||||
//
|
||||
// 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 &node : std::as_const(nodeList)) {
|
||||
const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData());
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
std::sort(columnStarts.begin(), columnStarts.end());
|
||||
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.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);
|
||||
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},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "stagehistorymodel.h"
|
||||
|
||||
#include "jobstage.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
/// Midday local time, so the calendar date survives time-zone conversion.
|
||||
QDateTime atMidday(const QDate &date)
|
||||
{
|
||||
return QDateTime(date, QTime(12, 0)).toUTC();
|
||||
}
|
||||
|
||||
QString nextStage(const QString &stage)
|
||||
{
|
||||
static const QStringList funnel{u"Applied"_s, u"Screening"_s, u"Interview"_s, u"Onsite"_s, u"Offer"_s, u"Accepted"_s};
|
||||
if (JobStage::isTerminal(stage)) {
|
||||
return u"Screening"_s; // a closed application reopening
|
||||
}
|
||||
const int index = funnel.indexOf(stage);
|
||||
return funnel.value(index + 1, u"Rejected"_s);
|
||||
}
|
||||
}
|
||||
|
||||
StageHistoryModel::StageHistoryModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
}
|
||||
|
||||
int StageHistoryModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
return parent.isValid() ? 0 : m_steps.size();
|
||||
}
|
||||
|
||||
QVariant StageHistoryModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_steps.size()) {
|
||||
return {};
|
||||
}
|
||||
const StageStep &step = m_steps.at(index.row());
|
||||
switch (role) {
|
||||
case StageRole:
|
||||
return step.stage;
|
||||
case DateRole:
|
||||
return step.at.toLocalTime().date();
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> StageHistoryModel::roleNames() const
|
||||
{
|
||||
return {{StageRole, "stage"}, {DateRole, "date"}};
|
||||
}
|
||||
|
||||
QStringList StageHistoryModel::stages() const
|
||||
{
|
||||
return JobStage::canonicalStages();
|
||||
}
|
||||
|
||||
void StageHistoryModel::load(const QList<StageStep> &steps)
|
||||
{
|
||||
beginResetModel();
|
||||
m_steps = steps;
|
||||
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
|
||||
return a.at < b.at;
|
||||
});
|
||||
endResetModel();
|
||||
m_edited = false;
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
QList<StageStep> StageHistoryModel::steps() const
|
||||
{
|
||||
return m_steps;
|
||||
}
|
||||
|
||||
bool StageHistoryModel::isEdited() const
|
||||
{
|
||||
return m_edited;
|
||||
}
|
||||
|
||||
void StageHistoryModel::setStage(int row, const QString &stage)
|
||||
{
|
||||
if (row < 0 || row >= m_steps.size() || !JobStage::isValid(stage) || m_steps.at(row).stage == stage) {
|
||||
return;
|
||||
}
|
||||
m_steps[row].stage = JobStage::canonical(stage);
|
||||
Q_EMIT dataChanged(index(row), index(row), {StageRole});
|
||||
markEdited();
|
||||
}
|
||||
|
||||
void StageHistoryModel::setDate(int row, const QDateTime &when)
|
||||
{
|
||||
if (row < 0 || row >= m_steps.size() || !when.isValid()) {
|
||||
return;
|
||||
}
|
||||
const QDate date = when.toLocalTime().date();
|
||||
if (m_steps.at(row).at.toLocalTime().date() == date) {
|
||||
return;
|
||||
}
|
||||
m_steps[row].at = atMidday(date);
|
||||
sortByDate();
|
||||
markEdited();
|
||||
}
|
||||
|
||||
void StageHistoryModel::appendStep()
|
||||
{
|
||||
const QString stage = m_steps.isEmpty() ? u"Applied"_s : nextStage(m_steps.last().stage);
|
||||
// Never earlier than the current last step, so the new step stays last.
|
||||
QDateTime at = atMidday(QDate::currentDate());
|
||||
if (!m_steps.isEmpty() && m_steps.last().at > at) {
|
||||
at = m_steps.last().at;
|
||||
}
|
||||
beginInsertRows({}, m_steps.size(), m_steps.size());
|
||||
m_steps.append({stage, at});
|
||||
endInsertRows();
|
||||
Q_EMIT countChanged();
|
||||
markEdited();
|
||||
}
|
||||
|
||||
void StageHistoryModel::removeStep(int row)
|
||||
{
|
||||
if (row < 0 || row >= m_steps.size() || m_steps.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
beginRemoveRows({}, row, row);
|
||||
m_steps.removeAt(row);
|
||||
endRemoveRows();
|
||||
Q_EMIT countChanged();
|
||||
markEdited();
|
||||
}
|
||||
|
||||
void StageHistoryModel::sortByDate()
|
||||
{
|
||||
beginResetModel();
|
||||
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
|
||||
return a.at < b.at;
|
||||
});
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void StageHistoryModel::markEdited()
|
||||
{
|
||||
m_edited = true;
|
||||
Q_EMIT edited();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QQmlEngine>
|
||||
|
||||
/**
|
||||
* The editable stage history of one application, as shown in the edit
|
||||
* form's Pipeline section: one row per step (stage + date), kept in date
|
||||
* order. The last step is the application's current stage, and the first
|
||||
* step's date is its date applied. JobEditModel owns one and saves it with
|
||||
* JobsDatabase::replaceStageHistory().
|
||||
*/
|
||||
class StageHistoryModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Owned by JobEditModel")
|
||||
|
||||
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||
Q_PROPERTY(QStringList stages READ stages CONSTANT)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
StageRole = Qt::UserRole + 1,
|
||||
DateRole,
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit StageHistoryModel(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;
|
||||
|
||||
QStringList stages() const;
|
||||
|
||||
/// Replaces every step without marking the history as edited.
|
||||
void load(const QList<StageStep> &steps);
|
||||
QList<StageStep> steps() const;
|
||||
bool isEdited() const;
|
||||
|
||||
Q_INVOKABLE void setStage(int row, const QString &stage);
|
||||
/// Takes a JS Date from QML; only its (local) calendar date is kept.
|
||||
Q_INVOKABLE void setDate(int row, const QDateTime &when);
|
||||
/// Appends a step dated today, defaulting to the stage that usually
|
||||
/// comes next.
|
||||
Q_INVOKABLE void appendStep();
|
||||
/// Removes a step; the last remaining step can't be removed.
|
||||
Q_INVOKABLE void removeStep(int row);
|
||||
|
||||
Q_SIGNALS:
|
||||
void countChanged();
|
||||
/// Emitted after any change made through the invokables.
|
||||
void edited();
|
||||
|
||||
private:
|
||||
void sortByDate();
|
||||
void markEdited();
|
||||
|
||||
QList<StageStep> m_steps;
|
||||
bool m_edited = false;
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||