Initial commit: Kareer, a Kirigami job application tracker
C++/Qt6/KDE Frameworks 6 app with a SQLite-backed data layer, a Sankey-diagram dashboard, and a full CLI (add/list/show/update/stage/ delete/stats/stages) for scripting from other tools.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Build and Publish Flatpak
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write # create releases / upload the bundle
|
||||
pages: write # deploy the hosted Flatpak repo
|
||||
id-token: write # required by actions/deploy-pages
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Flatpak
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Ships the KDE Platform/Sdk + flatpak-builder. Bump the tag to match
|
||||
# runtime-version in the manifest.
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build from the checked-out commit
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
p = "io.github.toservetheking.Kareer.yml"
|
||||
s = open(p).read()
|
||||
s = re.sub(r" sources:.*\Z",
|
||||
" sources:\n - type: dir\n path: .\n",
|
||||
s, flags=re.S)
|
||||
open(p, "w").write(s)
|
||||
print(s)
|
||||
PY
|
||||
|
||||
- name: Import GPG signing key
|
||||
env:
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
# Keep the keyring OUT of the workspace: the manifest's dir source
|
||||
# copies the checkout, and gpg-agent sockets are "special files".
|
||||
export GNUPGHOME="$RUNNER_TEMP/gnupg"
|
||||
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
|
||||
printf '%s\n' "$FLATPAK_GPG_PRIVATE_KEY" | gpg --batch --import
|
||||
FPR=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')
|
||||
echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV"
|
||||
echo "GPG_FPR=$FPR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build repo and bundle (signed)
|
||||
run: |
|
||||
flatpak-builder --user --disable-rofiles-fuse --force-clean \
|
||||
--default-branch=stable --repo=repo \
|
||||
--gpg-sign="$GPG_FPR" --gpg-homedir="$GNUPGHOME" \
|
||||
builddir io.github.toservetheking.Kareer.yml
|
||||
flatpak build-update-repo repo --generate-static-deltas \
|
||||
--gpg-sign="$GPG_FPR" --gpg-homedir="$GNUPGHOME"
|
||||
flatpak build-bundle repo \
|
||||
io.github.toservetheking.Kareer.flatpak \
|
||||
io.github.toservetheking.Kareer stable \
|
||||
--gpg-sign="$GPG_FPR" --gpg-homedir="$GNUPGHOME"
|
||||
|
||||
- name: Add repo landing page and .flatpakrepo
|
||||
run: |
|
||||
GPGKEY=$(gpg --homedir "$GNUPGHOME" --export "$GPG_FPR" | base64 -w0)
|
||||
cat > repo/kareer.flatpakrepo <<EOF
|
||||
[Flatpak Repo]
|
||||
Title=Kareer
|
||||
Url=https://toservetheking.github.io/Kareer/
|
||||
Homepage=https://github.com/toservetheking/Kareer
|
||||
Description=Track your job applications
|
||||
GPGKey=$GPGKEY
|
||||
EOF
|
||||
cat > repo/index.html <<'EOF'
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Kareer Flatpak repository</title>
|
||||
<h1>Kareer</h1>
|
||||
<p>Add the repository and install:</p>
|
||||
<pre>flatpak remote-add --if-not-exists --user kareer https://toservetheking.github.io/Kareer/kareer.flatpakrepo
|
||||
flatpak install --user kareer io.github.toservetheking.Kareer</pre>
|
||||
<p>Or download the single-file bundle from the
|
||||
<a href="https://github.com/toservetheking/Kareer/releases">Releases page</a>.</p>
|
||||
EOF
|
||||
|
||||
- name: Upload bundle artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flatpak-bundle
|
||||
path: io.github.toservetheking.Kareer.flatpak
|
||||
|
||||
- name: Attach bundle to release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: io.github.toservetheking.Kareer.flatpak
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: repo
|
||||
|
||||
deploy-pages:
|
||||
name: Deploy hosted repo to Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Build output
|
||||
/build/
|
||||
/builddir/
|
||||
# Flatpak build artifacts
|
||||
/.flatpak-builder/
|
||||
/repo/
|
||||
*.flatpak
|
||||
# Editor / misc
|
||||
*.user
|
||||
*.autosave
|
||||
@@ -0,0 +1,65 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Kareer - a Kirigami/Qt job application tracker
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(kareer VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(REQUIRED_QT_VERSION 6.6.0)
|
||||
set(REQUIRED_KF_VERSION 6.5.0)
|
||||
|
||||
find_package(ECM ${REQUIRED_KF_VERSION} REQUIRED NO_MODULE)
|
||||
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||
|
||||
include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||
include(ECMSetupVersion)
|
||||
include(FeatureSummary)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
|
||||
Core
|
||||
Gui
|
||||
Widgets
|
||||
Qml
|
||||
Quick
|
||||
QuickControls2
|
||||
Sql
|
||||
Test
|
||||
)
|
||||
|
||||
find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
|
||||
I18n
|
||||
CoreAddons
|
||||
IconThemes
|
||||
Crash
|
||||
)
|
||||
|
||||
ecm_setup_version(${PROJECT_VERSION}
|
||||
VARIABLE_PREFIX KAREER
|
||||
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h"
|
||||
)
|
||||
|
||||
add_subdirectory(src)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_subdirectory(autotests)
|
||||
endif()
|
||||
|
||||
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.
|
||||
|
||||
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
|
||||
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Kareer
|
||||
|
||||
A job application tracker built the KDE way - C++ with a QML/Kirigami
|
||||
frontend, ECM/CMake, and KDE Frameworks 6. Every application you log is
|
||||
stored in a local SQLite database, and every stage change is kept as
|
||||
history so the whole pipeline can be visualized as a Sankey diagram.
|
||||
|
||||
Everything the GUI can do is also available from the command line, so
|
||||
other tools - a resume generator, a job-search script - can log
|
||||
applications without ever opening a window.
|
||||
|
||||
## Features
|
||||
|
||||
- Track company, title, location, remote type, source, salary range,
|
||||
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
|
||||
- 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`)
|
||||
for scripting
|
||||
|
||||
## Command line usage
|
||||
|
||||
Running `kareer` with no arguments (or an unrecognized first argument)
|
||||
starts the GUI. A recognized first argument runs headlessly instead -
|
||||
handy for other tools to call directly.
|
||||
|
||||
```sh
|
||||
# Add an application (stage defaults to Applied, date defaults to today)
|
||||
kareer add --company "Acme Corp" --title "Senior Software Engineer" \
|
||||
--location "Remote" --remote remote --source "LinkedIn" \
|
||||
--salary-min 140000 --salary-max 170000 --salary-expectation 160000 \
|
||||
--notes "Great team, async-friendly"
|
||||
|
||||
# Machine-readable output for scripting (prints the new record, including its id)
|
||||
kareer add --company "Acme Corp" --title "Senior Software Engineer" --json
|
||||
|
||||
# List / filter
|
||||
kareer list
|
||||
kareer list --stage Interview
|
||||
kareer list --company Acme --json
|
||||
|
||||
# Show, update, and move through the pipeline
|
||||
kareer show 1
|
||||
kareer update 1 --salary-max 175000
|
||||
kareer stage 1 Interview
|
||||
|
||||
# Delete (requires --yes to actually happen)
|
||||
kareer delete 1 --yes
|
||||
|
||||
# Summary stats and the canonical stage list
|
||||
kareer stats
|
||||
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.
|
||||
|
||||
### Wiring up a resume-builder tool
|
||||
|
||||
Any script that generates or sends out a resume can log the
|
||||
application in the same step:
|
||||
|
||||
```sh
|
||||
kareer add --company "$COMPANY" --title "$TITLE" --url "$POSTING_URL" \
|
||||
--source "resume-builder" --json
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Tagged releases are built by CI into a single-file bundle (attached to
|
||||
each [GitHub Release]) and a hosted Flatpak repository on GitHub Pages:
|
||||
|
||||
```sh
|
||||
flatpak remote-add --if-not-exists --user kareer \
|
||||
https://toservetheking.github.io/Kareer/kareer.flatpakrepo
|
||||
flatpak install --user kareer io.github.toservetheking.Kareer
|
||||
```
|
||||
|
||||
The repository and bundle are GPG-signed; the public key is embedded in the
|
||||
`.flatpakrepo` (and available at [`keys/kareer.asc`](keys/kareer.asc)).
|
||||
|
||||
Or install the downloaded bundle directly:
|
||||
|
||||
```sh
|
||||
flatpak install --user ./io.github.toservetheking.Kareer.flatpak
|
||||
```
|
||||
|
||||
[GitHub Release]: https://github.com/toservetheking/Kareer/releases
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
cmake -B build -G Ninja
|
||||
cmake --build build
|
||||
./build/bin/kareer
|
||||
```
|
||||
|
||||
Run the tests with `ctest --test-dir build`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `job.h` - the `Job` and `StageTransition` plain data structs.
|
||||
- `jobstage.{h,cpp}` - the fixed stage vocabulary: ordering, Sankey
|
||||
column/stacking position, and color.
|
||||
- `jobsdatabase.{h,cpp}` - SQLite storage (via QtSql) for applications
|
||||
and stage history; the only class that touches the database.
|
||||
- `jobsmodel.{h,cpp}` - `QAbstractListModel` wrapper over
|
||||
`JobsDatabase` for the QML application list and edit dialog.
|
||||
- `statsmodel.{h,cpp}` - summary counters for the dashboard.
|
||||
- `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.
|
||||
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
|
||||
`delete`/`stats`/`stages` subcommands.
|
||||
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
|
||||
`ApplicationEditDialog` (add/edit/delete form), `DashboardPage`
|
||||
(stat cards + pipeline), `SankeyDiagram` (the renderer).
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(jobsdatabasetest
|
||||
jobsdatabasetest.cpp
|
||||
../src/jobstage.cpp
|
||||
../src/jobsdatabase.cpp
|
||||
)
|
||||
|
||||
target_include_directories(jobsdatabasetest PRIVATE ../src)
|
||||
|
||||
target_link_libraries(jobsdatabasetest PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Sql
|
||||
Qt6::Test
|
||||
KF6::I18n
|
||||
)
|
||||
|
||||
add_test(NAME jobsdatabasetest COMMAND jobsdatabasetest)
|
||||
|
||||
add_executable(sankeylayouttest
|
||||
sankeylayouttest.cpp
|
||||
../src/jobstage.cpp
|
||||
../src/jobsdatabase.cpp
|
||||
../src/sankeymodel.cpp
|
||||
)
|
||||
|
||||
target_include_directories(sankeylayouttest PRIVATE ../src)
|
||||
|
||||
target_link_libraries(sankeylayouttest PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Qml
|
||||
Qt6::Sql
|
||||
Qt6::Test
|
||||
KF6::I18n
|
||||
)
|
||||
|
||||
add_test(NAME sankeylayouttest COMMAND sankeylayouttest)
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString dbPathIn(const QTemporaryDir &dir)
|
||||
{
|
||||
return dir.path() + QStringLiteral("/test.sqlite");
|
||||
}
|
||||
}
|
||||
|
||||
class JobsDatabaseTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void addAndRetrieve()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
QVERIFY(dir.isValid());
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
QVERIFY(db.isOpen());
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("Acme Corp");
|
||||
job.title = QStringLiteral("Software Engineer");
|
||||
job.dateApplied = QDate(2026, 6, 1);
|
||||
job.salaryMin = 120000;
|
||||
job.salaryMax = 150000;
|
||||
|
||||
QVERIFY(db.addJob(job));
|
||||
QVERIFY(job.id > 0);
|
||||
QCOMPARE(job.stage, QStringLiteral("Applied"));
|
||||
QVERIFY(job.createdAt.isValid());
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QVERIFY(fetched.has_value());
|
||||
QCOMPARE(fetched->company, QStringLiteral("Acme Corp"));
|
||||
QCOMPARE(fetched->salaryMin, 120000);
|
||||
QCOMPARE(fetched->salaryMax, 150000);
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Applied"));
|
||||
|
||||
const auto transitions = db.stageTransitions();
|
||||
QCOMPARE(transitions.size(), 1);
|
||||
QVERIFY(transitions.first().fromStage.isEmpty());
|
||||
QCOMPARE(transitions.first().toStage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void rejectsUnknownStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("Acme");
|
||||
job.title = QStringLiteral("Engineer");
|
||||
job.stage = QStringLiteral("Bogus");
|
||||
|
||||
QVERIFY(!db.addJob(job));
|
||||
QVERIFY(!db.lastError().isEmpty());
|
||||
}
|
||||
|
||||
void updateJobDoesNotTouchStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
Job edited = *db.jobById(job.id);
|
||||
edited.company = QStringLiteral("Updated Co");
|
||||
edited.stage = QStringLiteral("Rejected"); // updateJob must ignore this
|
||||
QVERIFY(db.updateJob(edited));
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QCOMPARE(fetched->company, QStringLiteral("Updated Co"));
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void stageTransitionsRecordHistory()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening")));
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening"))); // no-op: same stage
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Rejected")));
|
||||
|
||||
const auto transitions = db.stageTransitions();
|
||||
// Start->Applied, Applied->Screening, Screening->Rejected (the no-op adds nothing)
|
||||
QCOMPARE(transitions.size(), 3);
|
||||
QCOMPARE(transitions.at(1).fromStage, QStringLiteral("Applied"));
|
||||
QCOMPARE(transitions.at(1).toStage, QStringLiteral("Screening"));
|
||||
QCOMPARE(transitions.at(2).fromStage, QStringLiteral("Screening"));
|
||||
QCOMPARE(transitions.at(2).toStage, QStringLiteral("Rejected"));
|
||||
|
||||
const auto fetched = db.jobById(job.id);
|
||||
QCOMPARE(fetched->stage, QStringLiteral("Rejected"));
|
||||
}
|
||||
|
||||
void setStageRejectsUnknownStage()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
|
||||
QVERIFY(!db.setStage(job.id, QStringLiteral("Bogus")));
|
||||
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Applied"));
|
||||
}
|
||||
|
||||
void deleteCascadesHistory()
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
JobsDatabase db(dbPathIn(dir));
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("A");
|
||||
job.title = QStringLiteral("B");
|
||||
QVERIFY(db.addJob(job));
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("Screening")));
|
||||
|
||||
QVERIFY(db.deleteJob(job.id));
|
||||
QVERIFY(!db.jobById(job.id).has_value());
|
||||
QVERIFY(db.stageTransitions().isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(JobsDatabaseTest)
|
||||
#include "jobsdatabasetest.moc"
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
#include "sankeymodel.h"
|
||||
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <memory>
|
||||
|
||||
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points
|
||||
// that at a fresh temporary file via the KAREER_DB_PATH override.
|
||||
class SankeyLayoutTest : 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() + QStringLiteral("/test.sqlite")).toUtf8());
|
||||
}
|
||||
|
||||
void layoutReflectsTransitionCounts()
|
||||
{
|
||||
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"));
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(600, 400);
|
||||
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
const QVariantList nodes = model.nodes();
|
||||
const QVariantList links = model.links();
|
||||
|
||||
QHash<QString, QVariantMap> nodeByStage;
|
||||
for (const QVariant &v : nodes) {
|
||||
const QVariantMap m = v.toMap();
|
||||
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
|
||||
QVERIFY(m.value(QStringLiteral("x")).toReal() >= 0);
|
||||
QVERIFY(m.value(QStringLiteral("y")).toReal() >= 0);
|
||||
QVERIFY(m.value(QStringLiteral("width")).toReal() > 0);
|
||||
QVERIFY(m.value(QStringLiteral("height")).toReal() > 0);
|
||||
}
|
||||
|
||||
QVERIFY(nodeByStage.contains(QStringLiteral("Start")));
|
||||
QCOMPARE(nodeByStage.value(QStringLiteral("Start")).value(QStringLiteral("value")).toInt(), 4);
|
||||
|
||||
int totalLinkValue = 0;
|
||||
for (const QVariant &v : links) {
|
||||
totalLinkValue += v.toMap().value(QStringLiteral("value")).toInt();
|
||||
}
|
||||
// Start->Applied(4), Applied->Screening(1), Applied->Rejected(1)
|
||||
QCOMPARE(totalLinkValue, 6);
|
||||
|
||||
const qreal startX = nodeByStage.value(QStringLiteral("Start")).value(QStringLiteral("x")).toReal();
|
||||
for (auto it = nodeByStage.constBegin(); it != nodeByStage.constEnd(); ++it) {
|
||||
QVERIFY(it.value().value(QStringLiteral("x")).toReal() >= startX);
|
||||
}
|
||||
}
|
||||
|
||||
void emptyDatabaseProducesEmptyLayout()
|
||||
{
|
||||
JobsDatabase db;
|
||||
QVERIFY(db.isOpen());
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(400, 300);
|
||||
QVERIFY(model.isEmpty());
|
||||
QVERIFY(model.nodes().isEmpty());
|
||||
QVERIFY(model.links().isEmpty());
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<QTemporaryDir> m_dir;
|
||||
};
|
||||
|
||||
QTEST_GUILESS_MAIN(SankeyLayoutTest)
|
||||
#include "sankeylayouttest.moc"
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Kareer
|
||||
GenericName=Job Application Tracker
|
||||
Comment=Track your job applications
|
||||
Icon=io.github.toservetheking.Kareer
|
||||
Exec=kareer
|
||||
Terminal=false
|
||||
Categories=Qt;KDE;Office;
|
||||
Keywords=job;career;applications;tracker;resume;
|
||||
StartupWMClass=kareer
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: CC0-1.0 -->
|
||||
<component type="desktop-application">
|
||||
<id>io.github.toservetheking.Kareer</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<name>Kareer</name>
|
||||
<summary>Track your job applications</summary>
|
||||
|
||||
<keywords>
|
||||
<keyword>job</keyword>
|
||||
<keyword>career</keyword>
|
||||
<keyword>applications</keyword>
|
||||
<keyword>tracker</keyword>
|
||||
<keyword>resume</keyword>
|
||||
<keyword>sankey</keyword>
|
||||
</keywords>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Kareer is a Kirigami application for tracking job applications from
|
||||
first contact through to an offer. It records the details that matter -
|
||||
company, title, salary range and expectations, source, and dates - and
|
||||
every stage change is kept as history.
|
||||
</p>
|
||||
<p>
|
||||
A Sankey diagram visualizes the whole pipeline at a glance, showing
|
||||
where applications progress and where they drop off between stages
|
||||
such as Applied, Screening, Interview and Offer.
|
||||
</p>
|
||||
<p>
|
||||
Every action is also available from the command line, so applications
|
||||
can be logged automatically by other tools (for example a resume
|
||||
generator) without opening the GUI.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.toservetheking.Kareer.desktop</launchable>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/toservetheking/Kareer/main/screenshots/dashboard.png</image>
|
||||
<caption>The application pipeline as a Sankey diagram</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#63d4c7</color>
|
||||
<color type="primary" scheme_preference="dark">#1a7a70</color>
|
||||
</branding>
|
||||
|
||||
<url type="homepage">https://github.com/toservetheking/Kareer</url>
|
||||
<url type="bugtracker">https://github.com/toservetheking/Kareer/issues</url>
|
||||
|
||||
<provides>
|
||||
<binary>kareer</binary>
|
||||
</provides>
|
||||
|
||||
<developer id="io.github.toservetheking">
|
||||
<name>toservetheking</name>
|
||||
</developer>
|
||||
|
||||
<content_rating type="oars-1.1"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-07-03">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
id: io.github.toservetheking.Kareer
|
||||
runtime: org.kde.Platform
|
||||
# Use the latest Flathub KDE runtime available at submission time.
|
||||
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.
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
- --socket=wayland
|
||||
- --device=dri
|
||||
|
||||
cleanup:
|
||||
- /include
|
||||
- /lib/pkgconfig
|
||||
- '*.a'
|
||||
|
||||
modules:
|
||||
- name: kareer
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DCMAKE_BUILD_TYPE=Release
|
||||
sources:
|
||||
# For Flathub / release builds, pin to a tag AND commit:
|
||||
- type: git
|
||||
url: https://github.com/toservetheking/Kareer.git
|
||||
tag: v0.1.0
|
||||
# commit: <fill in the exact commit SHA the tag points at>
|
||||
#
|
||||
# For local testing before the repo is pushed, replace the source above with:
|
||||
# - type: dir
|
||||
# path: .
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEakfWaRYJKwYBBAHaRw8BAQdAeTDVjSBRfl7PC2J2kdBfMcFhM7Wy4NZjHlBL
|
||||
MMfMku60K0thcmVlciBSZXBvIFNpZ25pbmcgPGF1c3RpbkB0aGViZW5uZXR0Lm5l
|
||||
dD6IkAQTFgoAOBYhBKT1FTjUnkJe4tSdYxMgA720DasXBQJqR9ZpAhsDBQsJCAcC
|
||||
BhUKCQgLAgQWAgMBAh4BAheAAAoJEBMgA720DasXaRcA/jH747DVBf/Pr3Gcidea
|
||||
UFkBiqFpffhLe7yNwRPiIkSPAQDWRMJUiqdc62xfbB3nAkwjFkfc2PbkoIQVIqkT
|
||||
+A8PCbg4BGpH1mkSCisGAQQBl1UBBQEBB0D4wBsedCcc7fFQT13CzFFxgWYRMH77
|
||||
wMVnC0rpn+hjQAMBCAeIeAQYFgoAIBYhBKT1FTjUnkJe4tSdYxMgA720DasXBQJq
|
||||
R9ZpAhsMAAoJEBMgA720DasXdbMA/juVtDE7ZIoV1oIyTrVgPeJAzQVb4x97nH+h
|
||||
RGfw2OVxAQCyyPiwkaCj9OOBNG6QgyzMlupsNTHVytq/cduJZ5zZBA==
|
||||
=/9pv
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(kareer
|
||||
main.cpp
|
||||
jobstage.cpp
|
||||
jobsdatabase.cpp
|
||||
clicommands.cpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(kareer
|
||||
URI io.github.toservetheking.Kareer
|
||||
VERSION 1.0
|
||||
RESOURCE_PREFIX /qt/qml
|
||||
QML_FILES
|
||||
qml/Main.qml
|
||||
qml/ApplicationsPage.qml
|
||||
qml/ApplicationEditDialog.qml
|
||||
qml/DashboardPage.qml
|
||||
qml/SankeyDiagram.qml
|
||||
SOURCES
|
||||
jobsmodel.cpp
|
||||
jobsmodel.h
|
||||
statsmodel.cpp
|
||||
statsmodel.h
|
||||
sankeymodel.cpp
|
||||
sankeymodel.h
|
||||
)
|
||||
|
||||
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
|
||||
|
||||
target_link_libraries(kareer PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Widgets
|
||||
Qt6::Qml
|
||||
Qt6::Quick
|
||||
Qt6::QuickControls2
|
||||
Qt6::Sql
|
||||
KF6::I18n
|
||||
KF6::I18nQml
|
||||
KF6::CoreAddons
|
||||
KF6::IconThemes
|
||||
KF6::Crash
|
||||
)
|
||||
|
||||
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
|
||||
@@ -0,0 +1,608 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "clicommands.h"
|
||||
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
#include "jobstage.h"
|
||||
#include "statsmodel.h"
|
||||
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDate>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
#include <QTextStream>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QJsonValue optionalInt(int value)
|
||||
{
|
||||
return value < 0 ? QJsonValue() : QJsonValue(value);
|
||||
}
|
||||
|
||||
QJsonObject jobToJson(const Job &job)
|
||||
{
|
||||
QJsonObject o;
|
||||
o["id"_L1] = job.id;
|
||||
o["company"_L1] = job.company;
|
||||
o["title"_L1] = job.title;
|
||||
o["location"_L1] = job.location;
|
||||
o["remoteType"_L1] = job.remoteType;
|
||||
o["source"_L1] = job.source;
|
||||
o["url"_L1] = job.url;
|
||||
o["dateApplied"_L1] = job.dateApplied.isValid() ? QJsonValue(job.dateApplied.toString(Qt::ISODate)) : QJsonValue();
|
||||
o["salaryMin"_L1] = optionalInt(job.salaryMin);
|
||||
o["salaryMax"_L1] = optionalInt(job.salaryMax);
|
||||
o["salaryExpectation"_L1] = optionalInt(job.salaryExpectation);
|
||||
o["currency"_L1] = job.currency;
|
||||
o["notes"_L1] = job.notes;
|
||||
o["contact"_L1] = job.contact;
|
||||
o["stage"_L1] = job.stage;
|
||||
o["createdAt"_L1] = job.createdAt.toString(Qt::ISODate);
|
||||
o["updatedAt"_L1] = job.updatedAt.toString(Qt::ISODate);
|
||||
return o;
|
||||
}
|
||||
|
||||
void printJson(const QJsonValue &value)
|
||||
{
|
||||
const QJsonDocument doc = value.isArray() ? QJsonDocument(value.toArray()) : QJsonDocument(value.toObject());
|
||||
QTextStream(stdout) << QString::fromUtf8(doc.toJson(QJsonDocument::Compact)) << Qt::endl;
|
||||
}
|
||||
|
||||
void printJobHuman(const Job &job, QTextStream &out)
|
||||
{
|
||||
out << u"#"_s << job.id << u" "_s << job.company << u" — "_s << job.title << u" ["_s << job.stage << u"]"_s << Qt::endl;
|
||||
if (!job.location.isEmpty() || !job.remoteType.isEmpty()) {
|
||||
out << u" Location: "_s << job.location;
|
||||
if (!job.remoteType.isEmpty()) {
|
||||
out << u" ("_s << job.remoteType << u")"_s;
|
||||
}
|
||||
out << Qt::endl;
|
||||
}
|
||||
if (job.dateApplied.isValid()) {
|
||||
out << u" Applied: "_s << job.dateApplied.toString(Qt::ISODate) << Qt::endl;
|
||||
}
|
||||
if (job.salaryMin >= 0 || job.salaryMax >= 0) {
|
||||
out << u" Salary: "_s;
|
||||
if (job.salaryMin >= 0) {
|
||||
out << job.salaryMin;
|
||||
}
|
||||
if (job.salaryMin >= 0 && job.salaryMax >= 0) {
|
||||
out << u"–"_s;
|
||||
}
|
||||
if (job.salaryMax >= 0) {
|
||||
out << job.salaryMax;
|
||||
}
|
||||
out << u" "_s << job.currency << Qt::endl;
|
||||
}
|
||||
if (job.salaryExpectation >= 0) {
|
||||
out << u" Expectation: "_s << job.salaryExpectation << u" "_s << job.currency << Qt::endl;
|
||||
}
|
||||
if (!job.source.isEmpty()) {
|
||||
out << u" Source: "_s << job.source << Qt::endl;
|
||||
}
|
||||
if (!job.url.isEmpty()) {
|
||||
out << u" URL: "_s << job.url << Qt::endl;
|
||||
}
|
||||
if (!job.contact.isEmpty()) {
|
||||
out << u" Contact: "_s << job.contact << Qt::endl;
|
||||
}
|
||||
if (!job.notes.isEmpty()) {
|
||||
out << u" Notes: "_s << job.notes << Qt::endl;
|
||||
}
|
||||
}
|
||||
|
||||
bool normalizeRemoteType(const QString &input, QString &out, QString &error)
|
||||
{
|
||||
if (input.isEmpty()) {
|
||||
out.clear();
|
||||
return true;
|
||||
}
|
||||
static const QStringList canonical{u"Onsite"_s, u"Hybrid"_s, u"Remote"_s};
|
||||
for (const QString &candidate : canonical) {
|
||||
if (candidate.compare(input, Qt::CaseInsensitive) == 0) {
|
||||
out = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
error = u"Invalid --remote value '%1' (expected onsite, hybrid, or remote)"_s.arg(input);
|
||||
return false;
|
||||
}
|
||||
|
||||
void addCommonJobOptions(QCommandLineParser &parser)
|
||||
{
|
||||
parser.addOption({u"company"_s, u"Company name"_s, u"company"_s});
|
||||
parser.addOption({u"title"_s, u"Job title"_s, u"title"_s});
|
||||
parser.addOption({u"location"_s, u"Location"_s, u"location"_s});
|
||||
parser.addOption({u"remote"_s, u"Remote type: onsite, hybrid, or remote"_s, u"type"_s});
|
||||
parser.addOption({u"source"_s, u"Where this lead came from (referral, LinkedIn, ...)"_s, u"source"_s});
|
||||
parser.addOption({u"url"_s, u"Job posting URL"_s, u"url"_s});
|
||||
parser.addOption({u"date-applied"_s, u"Date applied, YYYY-MM-DD (default: today)"_s, u"date"_s});
|
||||
parser.addOption({u"salary-min"_s, u"Posted salary range minimum"_s, u"amount"_s});
|
||||
parser.addOption({u"salary-max"_s, u"Posted salary range maximum"_s, u"amount"_s});
|
||||
parser.addOption({u"salary-expectation"_s, u"Your stated salary expectation"_s, u"amount"_s});
|
||||
parser.addOption({u"currency"_s, u"Currency code (default: USD)"_s, u"code"_s});
|
||||
parser.addOption({u"notes"_s, u"Free-text notes / expectations"_s, u"text"_s});
|
||||
parser.addOption({u"contact"_s, u"Recruiter or contact name"_s, u"name"_s});
|
||||
parser.addOption({u"stage"_s, u"Pipeline stage (default: Applied)"_s, u"stage"_s});
|
||||
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
|
||||
}
|
||||
|
||||
bool parseSalaryOption(QCommandLineParser &parser, const QString &option, int &target, QTextStream &err)
|
||||
{
|
||||
if (!parser.isSet(option)) {
|
||||
return true;
|
||||
}
|
||||
bool ok = false;
|
||||
const int value = parser.value(option).toInt(&ok);
|
||||
if (!ok || value < 0) {
|
||||
err << u"Error: --%1 must be a non-negative integer"_s.arg(option) << Qt::endl;
|
||||
return false;
|
||||
}
|
||||
target = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
int runAdd(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Add a new job application"_s);
|
||||
parser.addHelpOption();
|
||||
addCommonJobOptions(parser);
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
QTextStream err(stderr);
|
||||
|
||||
if (!parser.isSet(u"company"_s) || !parser.isSet(u"title"_s)) {
|
||||
err << u"Error: --company and --title are required"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
Job job;
|
||||
job.company = parser.value(u"company"_s);
|
||||
job.title = parser.value(u"title"_s);
|
||||
job.location = parser.value(u"location"_s);
|
||||
job.source = parser.value(u"source"_s);
|
||||
job.url = parser.value(u"url"_s);
|
||||
job.contact = parser.value(u"contact"_s);
|
||||
job.notes = parser.value(u"notes"_s);
|
||||
job.currency = parser.isSet(u"currency"_s) ? parser.value(u"currency"_s) : u"USD"_s;
|
||||
job.stage = parser.isSet(u"stage"_s) ? parser.value(u"stage"_s) : u"Applied"_s;
|
||||
|
||||
QString remoteError;
|
||||
if (!normalizeRemoteType(parser.value(u"remote"_s), job.remoteType, remoteError)) {
|
||||
err << remoteError << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (parser.isSet(u"date-applied"_s)) {
|
||||
job.dateApplied = QDate::fromString(parser.value(u"date-applied"_s), Qt::ISODate);
|
||||
if (!job.dateApplied.isValid()) {
|
||||
err << u"Error: --date-applied must be YYYY-MM-DD"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
job.dateApplied = QDate::currentDate();
|
||||
}
|
||||
|
||||
if (!parseSalaryOption(parser, u"salary-min"_s, job.salaryMin, err) || !parseSalaryOption(parser, u"salary-max"_s, job.salaryMax, err)
|
||||
|| !parseSalaryOption(parser, u"salary-expectation"_s, job.salaryExpectation, err)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!JobStage::isValid(job.stage)) {
|
||||
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(job.stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
JobsDatabase db;
|
||||
if (!db.addJob(job)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
printJson(jobToJson(job));
|
||||
} else {
|
||||
QTextStream(stdout) << u"Added application #%1: %2 — %3 (%4)"_s.arg(job.id).arg(job.company, job.title, job.stage) << Qt::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runList(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"List job applications"_s);
|
||||
parser.addHelpOption();
|
||||
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});
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
JobsDatabase db;
|
||||
QList<Job> jobs = db.allJobs();
|
||||
|
||||
if (parser.isSet(u"stage"_s)) {
|
||||
const QString stage = parser.value(u"stage"_s);
|
||||
jobs.removeIf([&](const Job &j) {
|
||||
return j.stage.compare(stage, Qt::CaseInsensitive) != 0;
|
||||
});
|
||||
}
|
||||
if (parser.isSet(u"company"_s)) {
|
||||
const QString needle = parser.value(u"company"_s);
|
||||
jobs.removeIf([&](const Job &j) {
|
||||
return !j.company.contains(needle, Qt::CaseInsensitive);
|
||||
});
|
||||
}
|
||||
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
QJsonArray arr;
|
||||
for (const Job &j : std::as_const(jobs)) {
|
||||
arr.append(jobToJson(j));
|
||||
}
|
||||
printJson(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
QTextStream out(stdout);
|
||||
if (jobs.isEmpty()) {
|
||||
out << u"No applications found."_s << Qt::endl;
|
||||
return 0;
|
||||
}
|
||||
for (const Job &j : std::as_const(jobs)) {
|
||||
out << u"#"_s << j.id << u" "_s << j.company << u" — "_s << j.title << u" ["_s << j.stage << u"] "_s
|
||||
<< (j.dateApplied.isValid() ? j.dateApplied.toString(Qt::ISODate) : u"?"_s) << Qt::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runShow(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Show one job application"_s);
|
||||
parser.addHelpOption();
|
||||
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);
|
||||
|
||||
QTextStream err(stderr);
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.isEmpty()) {
|
||||
err << u"Error: missing application id"_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;
|
||||
const auto job = db.jobById(id);
|
||||
if (!job) {
|
||||
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
printJson(jobToJson(*job));
|
||||
} else {
|
||||
QTextStream out(stdout);
|
||||
printJobHuman(*job, out);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runUpdate(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Update fields on an existing application"_s);
|
||||
parser.addHelpOption();
|
||||
addCommonJobOptions(parser);
|
||||
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
QTextStream err(stderr);
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.isEmpty()) {
|
||||
err << u"Error: missing application id"_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;
|
||||
const auto existing = db.jobById(id);
|
||||
if (!existing) {
|
||||
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
Job job = *existing;
|
||||
if (parser.isSet(u"company"_s)) {
|
||||
job.company = parser.value(u"company"_s);
|
||||
}
|
||||
if (parser.isSet(u"title"_s)) {
|
||||
job.title = parser.value(u"title"_s);
|
||||
}
|
||||
if (parser.isSet(u"location"_s)) {
|
||||
job.location = parser.value(u"location"_s);
|
||||
}
|
||||
if (parser.isSet(u"source"_s)) {
|
||||
job.source = parser.value(u"source"_s);
|
||||
}
|
||||
if (parser.isSet(u"url"_s)) {
|
||||
job.url = parser.value(u"url"_s);
|
||||
}
|
||||
if (parser.isSet(u"contact"_s)) {
|
||||
job.contact = parser.value(u"contact"_s);
|
||||
}
|
||||
if (parser.isSet(u"notes"_s)) {
|
||||
job.notes = parser.value(u"notes"_s);
|
||||
}
|
||||
if (parser.isSet(u"currency"_s)) {
|
||||
job.currency = parser.value(u"currency"_s);
|
||||
}
|
||||
if (parser.isSet(u"remote"_s)) {
|
||||
QString remoteError;
|
||||
if (!normalizeRemoteType(parser.value(u"remote"_s), job.remoteType, remoteError)) {
|
||||
err << remoteError << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (parser.isSet(u"date-applied"_s)) {
|
||||
const QDate d = QDate::fromString(parser.value(u"date-applied"_s), Qt::ISODate);
|
||||
if (!d.isValid()) {
|
||||
err << u"Error: --date-applied must be YYYY-MM-DD"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
job.dateApplied = d;
|
||||
}
|
||||
if (!parseSalaryOption(parser, u"salary-min"_s, job.salaryMin, err) || !parseSalaryOption(parser, u"salary-max"_s, job.salaryMax, err)
|
||||
|| !parseSalaryOption(parser, u"salary-expectation"_s, job.salaryExpectation, err)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!db.updateJob(job)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (parser.isSet(u"stage"_s)) {
|
||||
const QString stage = parser.value(u"stage"_s);
|
||||
if (!JobStage::isValid(stage)) {
|
||||
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!db.setStage(id, stage)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const auto updated = db.jobById(id);
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
printJson(jobToJson(*updated));
|
||||
} else {
|
||||
QTextStream(stdout) << u"Updated application #%1"_s.arg(id) << Qt::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runStage(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Move an application to a new stage"_s);
|
||||
parser.addHelpOption();
|
||||
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)));
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
QTextStream err(stderr);
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.size() < 2) {
|
||||
err << u"Error: usage: kareer stage <id> <stage>"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
bool ok = false;
|
||||
const int id = positional.at(0).toInt(&ok);
|
||||
if (!ok) {
|
||||
err << u"Error: id must be an integer"_s << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
const QString stage = positional.at(1);
|
||||
if (!JobStage::isValid(stage)) {
|
||||
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
JobsDatabase db;
|
||||
if (!db.setStage(id, stage)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const auto job = db.jobById(id);
|
||||
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;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runDelete(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Delete an application"_s);
|
||||
parser.addHelpOption();
|
||||
parser.addOption({u"yes"_s, u"Confirm deletion"_s});
|
||||
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
QTextStream err(stderr);
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.isEmpty()) {
|
||||
err << u"Error: missing application id"_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;
|
||||
}
|
||||
|
||||
if (!parser.isSet(u"yes"_s)) {
|
||||
err << u"Refusing to delete application #%1 without --yes"_s.arg(id) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
JobsDatabase db;
|
||||
if (!db.deleteJob(id)) {
|
||||
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
QTextStream(stdout) << u"Deleted application #%1"_s.arg(id) << Qt::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runStats(const QString &program, const QStringList &args)
|
||||
{
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(u"Summary statistics across all applications"_s);
|
||||
parser.addHelpOption();
|
||||
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
StatsModel stats;
|
||||
const QVariantMap counts = stats.stageCounts();
|
||||
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
QJsonObject o;
|
||||
o["totalApplications"_L1] = stats.totalApplications();
|
||||
o["activeApplications"_L1] = stats.activeApplications();
|
||||
o["offerCount"_L1] = stats.offerCount();
|
||||
o["acceptedCount"_L1] = stats.acceptedCount();
|
||||
o["rejectedCount"_L1] = stats.rejectedCount();
|
||||
o["responseRate"_L1] = stats.responseRate();
|
||||
o["offerRate"_L1] = stats.offerRate();
|
||||
QJsonObject stageCounts;
|
||||
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
|
||||
stageCounts[it.key()] = it.value().toInt();
|
||||
}
|
||||
o["stageCounts"_L1] = stageCounts;
|
||||
printJson(o);
|
||||
return 0;
|
||||
}
|
||||
|
||||
QTextStream out(stdout);
|
||||
out << u"Total applications: %1"_s.arg(stats.totalApplications()) << Qt::endl;
|
||||
out << u"Active: %1"_s.arg(stats.activeApplications()) << Qt::endl;
|
||||
out << u"Offers: %1 Accepted: %2 Rejected: %3"_s.arg(stats.offerCount()).arg(stats.acceptedCount()).arg(stats.rejectedCount()) << Qt::endl;
|
||||
out << u"Response rate: %1% Offer rate: %2%"_s.arg(stats.responseRate(), 0, 'f', 1).arg(stats.offerRate(), 0, 'f', 1) << Qt::endl;
|
||||
out << u"By stage:"_s << Qt::endl;
|
||||
for (const QString &stage : JobStage::canonicalStages()) {
|
||||
out << u" %1: %2"_s.arg(stage, -12).arg(counts.value(stage).toInt()) << 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();
|
||||
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
|
||||
parser.process(QStringList{program} + args);
|
||||
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
QJsonArray arr;
|
||||
for (const QString &s : JobStage::canonicalStages()) {
|
||||
arr.append(s);
|
||||
}
|
||||
printJson(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
QTextStream out(stdout);
|
||||
for (const QString &s : JobStage::canonicalStages()) {
|
||||
out << s << Qt::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runHelp()
|
||||
{
|
||||
QTextStream out(stdout);
|
||||
out << u"Usage: kareer <command> [options]\n\n"_s
|
||||
<< u"Commands:\n"_s
|
||||
<< u" add Add a new job application\n"_s
|
||||
<< u" list List job applications\n"_s
|
||||
<< u" show Show one job application\n"_s
|
||||
<< u" update Update fields on an existing application\n"_s
|
||||
<< u" stage Move an application to a new stage\n"_s
|
||||
<< u" delete Delete an application\n"_s
|
||||
<< u" stats Summary statistics\n"_s
|
||||
<< u" stages List the canonical pipeline stages\n\n"_s
|
||||
<< u"Run 'kareer <command> --help' for the options of a specific command.\n"_s
|
||||
<< u"Running kareer with no command (or an unrecognized one) starts the GUI.\n"_s;
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
return subcommands.contains(arg);
|
||||
}
|
||||
|
||||
int Cli::run(QCoreApplication &app)
|
||||
{
|
||||
const QStringList allArgs = app.arguments();
|
||||
const QString program = allArgs.value(0);
|
||||
const QString subcommand = allArgs.value(1);
|
||||
const QStringList rest = allArgs.mid(2);
|
||||
|
||||
if (subcommand == u"add"_s) {
|
||||
return runAdd(program, rest);
|
||||
}
|
||||
if (subcommand == u"list"_s) {
|
||||
return runList(program, rest);
|
||||
}
|
||||
if (subcommand == u"show"_s) {
|
||||
return runShow(program, rest);
|
||||
}
|
||||
if (subcommand == u"update"_s) {
|
||||
return runUpdate(program, rest);
|
||||
}
|
||||
if (subcommand == u"stage"_s) {
|
||||
return runStage(program, rest);
|
||||
}
|
||||
if (subcommand == u"delete"_s) {
|
||||
return runDelete(program, rest);
|
||||
}
|
||||
if (subcommand == u"stats"_s) {
|
||||
return runStats(program, rest);
|
||||
}
|
||||
if (subcommand == u"stages"_s) {
|
||||
return runStages(program, rest);
|
||||
}
|
||||
return runHelp();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class QCoreApplication;
|
||||
|
||||
/**
|
||||
* Headless command-line interface: `kareer add|list|show|update|stage|delete|stats|stages ...`.
|
||||
* Lets other tools (a resume generator, a shell script) log and query
|
||||
* applications without ever starting the Kirigami GUI.
|
||||
*/
|
||||
namespace Cli
|
||||
{
|
||||
/// True if the first non-option argument names one of our subcommands, i.e.
|
||||
/// whether main() should route to run() instead of starting the GUI.
|
||||
bool isSubcommand(const QString &arg);
|
||||
|
||||
/// Dispatches to the matching subcommand handler and returns a process exit code.
|
||||
int run(QCoreApplication &app);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
/// A single job application and everything tracked about it.
|
||||
struct Job {
|
||||
int id = -1;
|
||||
QString company;
|
||||
QString title;
|
||||
QString location;
|
||||
QString remoteType; ///< "Onsite", "Hybrid", "Remote", or empty if unknown.
|
||||
QString source; ///< Where the lead came from (referral, LinkedIn, ...).
|
||||
QString url;
|
||||
QDate dateApplied;
|
||||
int salaryMin = -1; ///< -1 means unset.
|
||||
int salaryMax = -1;
|
||||
int salaryExpectation = -1;
|
||||
QString currency = QStringLiteral("USD");
|
||||
QString notes; ///< Free text: expectations, interview notes, etc.
|
||||
QString contact;
|
||||
QString stage;
|
||||
QDateTime createdAt;
|
||||
QDateTime updatedAt;
|
||||
};
|
||||
|
||||
/// One recorded move from one stage to another (or from "Start" for the
|
||||
/// initial application), used to build the Sankey diagram.
|
||||
struct StageTransition {
|
||||
int jobId = -1;
|
||||
QString fromStage; ///< Empty means JobStage::Start.
|
||||
QString toStage;
|
||||
QDateTime changedAt;
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "jobsdatabase.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
#include <QAtomicInteger>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QStandardPaths>
|
||||
#include <QVariant>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
QAtomicInteger<int> s_connectionCounter{0};
|
||||
|
||||
QVariant salaryToVariant(int value)
|
||||
{
|
||||
if (value < 0) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int salaryFromVariant(const QVariant &value)
|
||||
{
|
||||
return value.isNull() ? -1 : value.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
JobsDatabase::JobsDatabase()
|
||||
{
|
||||
init(defaultPath());
|
||||
}
|
||||
|
||||
JobsDatabase::JobsDatabase(const QString &path)
|
||||
{
|
||||
init(path);
|
||||
}
|
||||
|
||||
JobsDatabase::~JobsDatabase()
|
||||
{
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
|
||||
if (db.isValid()) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
QSqlDatabase::removeDatabase(m_connectionName);
|
||||
}
|
||||
|
||||
QString JobsDatabase::defaultPath()
|
||||
{
|
||||
const QString overridePath = qEnvironmentVariable("KAREER_DB_PATH");
|
||||
if (!overridePath.isEmpty()) {
|
||||
return overridePath;
|
||||
}
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer"_s;
|
||||
QDir().mkpath(dir);
|
||||
return dir + u"/kareer.sqlite"_s;
|
||||
}
|
||||
|
||||
void JobsDatabase::init(const QString &path)
|
||||
{
|
||||
m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1));
|
||||
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(u"QSQLITE"_s, m_connectionName);
|
||||
db.setDatabaseName(path);
|
||||
if (!db.open()) {
|
||||
m_lastError = db.lastError().text();
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery pragma(db);
|
||||
pragma.exec(u"PRAGMA foreign_keys = ON"_s);
|
||||
|
||||
if (!migrate()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool JobsDatabase::migrate()
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
QSqlQuery query(db);
|
||||
|
||||
if (!query.exec(uR"(
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
location TEXT,
|
||||
remote_type TEXT,
|
||||
source TEXT,
|
||||
url TEXT,
|
||||
date_applied TEXT,
|
||||
salary_min INTEGER,
|
||||
salary_max INTEGER,
|
||||
salary_expectation INTEGER,
|
||||
currency TEXT,
|
||||
notes TEXT,
|
||||
contact TEXT,
|
||||
stage TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
)"_s)) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!query.exec(uR"(
|
||||
CREATE TABLE IF NOT EXISTS stage_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
from_stage TEXT,
|
||||
to_stage TEXT NOT NULL,
|
||||
changed_at TEXT NOT NULL
|
||||
)
|
||||
)"_s)) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JobsDatabase::isOpen() const
|
||||
{
|
||||
return QSqlDatabase::database(m_connectionName, false).isOpen();
|
||||
}
|
||||
|
||||
QString JobsDatabase::lastError() const
|
||||
{
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
Job JobsDatabase::jobFromQuery(QSqlQuery &query) const
|
||||
{
|
||||
Job job;
|
||||
job.id = query.value(u"id"_s).toInt();
|
||||
job.company = query.value(u"company"_s).toString();
|
||||
job.title = query.value(u"title"_s).toString();
|
||||
job.location = query.value(u"location"_s).toString();
|
||||
job.remoteType = query.value(u"remote_type"_s).toString();
|
||||
job.source = query.value(u"source"_s).toString();
|
||||
job.url = query.value(u"url"_s).toString();
|
||||
job.dateApplied = QDate::fromString(query.value(u"date_applied"_s).toString(), Qt::ISODate);
|
||||
job.salaryMin = salaryFromVariant(query.value(u"salary_min"_s));
|
||||
job.salaryMax = salaryFromVariant(query.value(u"salary_max"_s));
|
||||
job.salaryExpectation = salaryFromVariant(query.value(u"salary_expectation"_s));
|
||||
job.currency = query.value(u"currency"_s).toString();
|
||||
job.notes = query.value(u"notes"_s).toString();
|
||||
job.contact = query.value(u"contact"_s).toString();
|
||||
job.stage = query.value(u"stage"_s).toString();
|
||||
job.createdAt = QDateTime::fromString(query.value(u"created_at"_s).toString(), Qt::ISODate);
|
||||
job.updatedAt = QDateTime::fromString(query.value(u"updated_at"_s).toString(), Qt::ISODate);
|
||||
return job;
|
||||
}
|
||||
|
||||
QList<Job> JobsDatabase::allJobs() const
|
||||
{
|
||||
QList<Job> jobs;
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s);
|
||||
if (!query.exec()) {
|
||||
return jobs;
|
||||
}
|
||||
while (query.next()) {
|
||||
jobs.append(jobFromQuery(query));
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
std::optional<Job> JobsDatabase::jobById(int id) const
|
||||
{
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s);
|
||||
query.bindValue(u":id"_s, id);
|
||||
if (!query.exec() || !query.next()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return jobFromQuery(query);
|
||||
}
|
||||
|
||||
bool JobsDatabase::addJob(Job &job)
|
||||
{
|
||||
if (job.stage.isEmpty()) {
|
||||
job.stage = QStringLiteral("Applied");
|
||||
}
|
||||
if (!JobStage::isValid(job.stage)) {
|
||||
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
job.createdAt = now;
|
||||
job.updatedAt = now;
|
||||
|
||||
QSqlQuery query(db);
|
||||
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)
|
||||
VALUES (:company, :title, :location, :remote_type, :source, :url, :date_applied,
|
||||
:salary_min, :salary_max, :salary_expectation, :currency, :notes, :contact,
|
||||
:stage, :created_at, :updated_at)
|
||||
)"_s);
|
||||
query.bindValue(u":company"_s, job.company);
|
||||
query.bindValue(u":title"_s, job.title);
|
||||
query.bindValue(u":location"_s, job.location);
|
||||
query.bindValue(u":remote_type"_s, job.remoteType);
|
||||
query.bindValue(u":source"_s, job.source);
|
||||
query.bindValue(u":url"_s, job.url);
|
||||
query.bindValue(u":date_applied"_s, job.dateApplied.isValid() ? job.dateApplied.toString(Qt::ISODate) : QVariant());
|
||||
query.bindValue(u":salary_min"_s, salaryToVariant(job.salaryMin));
|
||||
query.bindValue(u":salary_max"_s, salaryToVariant(job.salaryMax));
|
||||
query.bindValue(u":salary_expectation"_s, salaryToVariant(job.salaryExpectation));
|
||||
query.bindValue(u":currency"_s, job.currency);
|
||||
query.bindValue(u":notes"_s, job.notes);
|
||||
query.bindValue(u":contact"_s, job.contact);
|
||||
query.bindValue(u":stage"_s, job.stage);
|
||||
query.bindValue(u":created_at"_s, job.createdAt.toString(Qt::ISODate));
|
||||
query.bindValue(u":updated_at"_s, job.updatedAt.toString(Qt::ISODate));
|
||||
|
||||
if (!query.exec()) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
job.id = query.lastInsertId().toInt();
|
||||
|
||||
QSqlQuery history(db);
|
||||
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, NULL, :to_stage, :changed_at)"_s);
|
||||
history.bindValue(u":job_id"_s, job.id);
|
||||
history.bindValue(u":to_stage"_s, job.stage);
|
||||
history.bindValue(u":changed_at"_s, job.createdAt.toString(Qt::ISODate));
|
||||
if (!history.exec()) {
|
||||
m_lastError = history.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JobsDatabase::updateJob(const Job &job)
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
QSqlQuery query(db);
|
||||
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,
|
||||
salary_max = :salary_max, salary_expectation = :salary_expectation,
|
||||
currency = :currency, notes = :notes, contact = :contact,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
)"_s);
|
||||
query.bindValue(u":company"_s, job.company);
|
||||
query.bindValue(u":title"_s, job.title);
|
||||
query.bindValue(u":location"_s, job.location);
|
||||
query.bindValue(u":remote_type"_s, job.remoteType);
|
||||
query.bindValue(u":source"_s, job.source);
|
||||
query.bindValue(u":url"_s, job.url);
|
||||
query.bindValue(u":date_applied"_s, job.dateApplied.isValid() ? job.dateApplied.toString(Qt::ISODate) : QVariant());
|
||||
query.bindValue(u":salary_min"_s, salaryToVariant(job.salaryMin));
|
||||
query.bindValue(u":salary_max"_s, salaryToVariant(job.salaryMax));
|
||||
query.bindValue(u":salary_expectation"_s, salaryToVariant(job.salaryExpectation));
|
||||
query.bindValue(u":currency"_s, job.currency);
|
||||
query.bindValue(u":notes"_s, job.notes);
|
||||
query.bindValue(u":contact"_s, job.contact);
|
||||
query.bindValue(u":updated_at"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||
query.bindValue(u":id"_s, job.id);
|
||||
|
||||
if (!query.exec()) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
if (query.numRowsAffected() < 1) {
|
||||
m_lastError = u"No job with id %1"_s.arg(job.id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JobsDatabase::setStage(int id, const QString &newStage)
|
||||
{
|
||||
if (!JobStage::isValid(newStage)) {
|
||||
m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
|
||||
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":updated_at"_s, now.toString(Qt::ISODate));
|
||||
query.bindValue(u":id"_s, id);
|
||||
if (!query.exec()) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery history(db);
|
||||
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":changed_at"_s, now.toString(Qt::ISODate));
|
||||
if (!history.exec()) {
|
||||
m_lastError = history.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool JobsDatabase::deleteJob(int id)
|
||||
{
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(u"DELETE FROM jobs WHERE id = :id"_s);
|
||||
query.bindValue(u":id"_s, id);
|
||||
if (!query.exec()) {
|
||||
m_lastError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
if (query.numRowsAffected() < 1) {
|
||||
m_lastError = u"No job with id %1"_s.arg(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QList<StageTransition> JobsDatabase::stageTransitions() const
|
||||
{
|
||||
QList<StageTransition> 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()) {
|
||||
return transitions;
|
||||
}
|
||||
while (query.next()) {
|
||||
StageTransition t;
|
||||
t.jobId = query.value(0).toInt();
|
||||
t.fromStage = query.value(1).toString();
|
||||
t.toStage = query.value(2).toString();
|
||||
t.changedAt = QDateTime::fromString(query.value(3).toString(), Qt::ISODate);
|
||||
transitions.append(t);
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <optional>
|
||||
|
||||
class QSqlDatabase;
|
||||
|
||||
/**
|
||||
* SQLite-backed storage for job applications and their stage history.
|
||||
*
|
||||
* Every JobsDatabase instance owns its own named QSqlDatabase connection
|
||||
* (Qt's SQL connections are identified by name, not by object identity),
|
||||
* so multiple instances - e.g. in tests - never collide.
|
||||
*/
|
||||
class JobsDatabase
|
||||
{
|
||||
public:
|
||||
JobsDatabase();
|
||||
explicit JobsDatabase(const QString &path);
|
||||
~JobsDatabase();
|
||||
|
||||
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).
|
||||
static QString defaultPath();
|
||||
|
||||
bool isOpen() const;
|
||||
QString lastError() const;
|
||||
|
||||
QList<Job> allJobs() const;
|
||||
std::optional<Job> jobById(int id) const;
|
||||
|
||||
/// Inserts a new job. On success, job.id/createdAt/updatedAt are filled
|
||||
/// in and an initial Start -> job.stage transition is recorded.
|
||||
bool addJob(Job &job);
|
||||
|
||||
/// Updates every field except stage (use setStage for that, so every
|
||||
/// stage change is captured in the history).
|
||||
bool updateJob(const Job &job);
|
||||
|
||||
/// Moves a job to newStage, recording the transition. A no-op (but still
|
||||
/// successful) if the job is already in newStage.
|
||||
bool setStage(int id, const QString &newStage);
|
||||
|
||||
bool deleteJob(int id);
|
||||
|
||||
QList<StageTransition> stageTransitions() const;
|
||||
|
||||
private:
|
||||
void init(const QString &path);
|
||||
bool migrate();
|
||||
Job jobFromQuery(class QSqlQuery &query) const;
|
||||
|
||||
QString m_connectionName;
|
||||
QString m_lastError;
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "jobsmodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
JobsModel::JobsModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
|
||||
void JobsModel::refresh()
|
||||
{
|
||||
beginResetModel();
|
||||
m_jobs = m_db.allJobs();
|
||||
endResetModel();
|
||||
Q_EMIT countChanged();
|
||||
}
|
||||
|
||||
int JobsModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) {
|
||||
return 0;
|
||||
}
|
||||
return m_jobs.size();
|
||||
}
|
||||
|
||||
QVariant JobsModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_jobs.size()) {
|
||||
return {};
|
||||
}
|
||||
const Job &job = m_jobs.at(index.row());
|
||||
switch (role) {
|
||||
case IdRole:
|
||||
return job.id;
|
||||
case CompanyRole:
|
||||
return job.company;
|
||||
case TitleRole:
|
||||
return job.title;
|
||||
case LocationRole:
|
||||
return job.location;
|
||||
case RemoteTypeRole:
|
||||
return job.remoteType;
|
||||
case SourceRole:
|
||||
return job.source;
|
||||
case UrlRole:
|
||||
return job.url;
|
||||
case DateAppliedRole:
|
||||
return job.dateApplied;
|
||||
case SalaryMinRole:
|
||||
return job.salaryMin;
|
||||
case SalaryMaxRole:
|
||||
return job.salaryMax;
|
||||
case SalaryExpectationRole:
|
||||
return job.salaryExpectation;
|
||||
case CurrencyRole:
|
||||
return job.currency;
|
||||
case NotesRole:
|
||||
return job.notes;
|
||||
case ContactRole:
|
||||
return job.contact;
|
||||
case StageRole:
|
||||
return job.stage;
|
||||
case StageColorRole:
|
||||
return JobStage::color(job.stage);
|
||||
case CreatedAtRole:
|
||||
return job.createdAt;
|
||||
case UpdatedAtRole:
|
||||
return job.updatedAt;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> JobsModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{IdRole, "jobId"},
|
||||
{CompanyRole, "company"},
|
||||
{TitleRole, "title"},
|
||||
{LocationRole, "location"},
|
||||
{RemoteTypeRole, "remoteType"},
|
||||
{SourceRole, "source"},
|
||||
{UrlRole, "url"},
|
||||
{DateAppliedRole, "dateApplied"},
|
||||
{SalaryMinRole, "salaryMin"},
|
||||
{SalaryMaxRole, "salaryMax"},
|
||||
{SalaryExpectationRole, "salaryExpectation"},
|
||||
{CurrencyRole, "currency"},
|
||||
{NotesRole, "notes"},
|
||||
{ContactRole, "contact"},
|
||||
{StageRole, "stage"},
|
||||
{StageColorRole, "stageColor"},
|
||||
{CreatedAtRole, "createdAt"},
|
||||
{UpdatedAtRole, "updatedAt"},
|
||||
};
|
||||
}
|
||||
|
||||
QStringList JobsModel::stages() const
|
||||
{
|
||||
return JobStage::canonicalStages();
|
||||
}
|
||||
|
||||
Job JobsModel::jobFromMap(const QVariantMap &fields)
|
||||
{
|
||||
Job job;
|
||||
job.company = fields.value(u"company"_s).toString();
|
||||
job.title = fields.value(u"title"_s).toString();
|
||||
job.location = fields.value(u"location"_s).toString();
|
||||
job.remoteType = fields.value(u"remoteType"_s).toString();
|
||||
job.source = fields.value(u"source"_s).toString();
|
||||
job.url = fields.value(u"url"_s).toString();
|
||||
|
||||
const QVariant dateValue = fields.value(u"dateApplied"_s);
|
||||
job.dateApplied = dateValue.canConvert<QDate>() ? dateValue.toDate() : QDate::fromString(dateValue.toString(), Qt::ISODate);
|
||||
|
||||
job.salaryMin = fields.value(u"salaryMin"_s, -1).toInt();
|
||||
job.salaryMax = fields.value(u"salaryMax"_s, -1).toInt();
|
||||
job.salaryExpectation = fields.value(u"salaryExpectation"_s, -1).toInt();
|
||||
job.currency = fields.value(u"currency"_s, u"USD"_s).toString();
|
||||
if (job.currency.isEmpty()) {
|
||||
job.currency = u"USD"_s;
|
||||
}
|
||||
job.notes = fields.value(u"notes"_s).toString();
|
||||
job.contact = fields.value(u"contact"_s).toString();
|
||||
job.stage = fields.value(u"stage"_s).toString();
|
||||
return job;
|
||||
}
|
||||
|
||||
QVariantMap JobsModel::mapFromJob(const Job &job)
|
||||
{
|
||||
return {
|
||||
{u"id"_s, job.id},
|
||||
{u"company"_s, job.company},
|
||||
{u"title"_s, job.title},
|
||||
{u"location"_s, job.location},
|
||||
{u"remoteType"_s, job.remoteType},
|
||||
{u"source"_s, job.source},
|
||||
{u"url"_s, job.url},
|
||||
{u"dateApplied"_s, job.dateApplied},
|
||||
{u"salaryMin"_s, job.salaryMin},
|
||||
{u"salaryMax"_s, job.salaryMax},
|
||||
{u"salaryExpectation"_s, job.salaryExpectation},
|
||||
{u"currency"_s, job.currency},
|
||||
{u"notes"_s, job.notes},
|
||||
{u"contact"_s, job.contact},
|
||||
{u"stage"_s, job.stage},
|
||||
{u"createdAt"_s, job.createdAt},
|
||||
{u"updatedAt"_s, job.updatedAt},
|
||||
};
|
||||
}
|
||||
|
||||
QVariantMap JobsModel::jobData(int id) const
|
||||
{
|
||||
const auto job = m_db.jobById(id);
|
||||
if (!job) {
|
||||
return {};
|
||||
}
|
||||
return mapFromJob(*job);
|
||||
}
|
||||
|
||||
bool JobsModel::addJob(const QVariantMap &fields)
|
||||
{
|
||||
Job job = jobFromMap(fields);
|
||||
const bool ok = m_db.addJob(job);
|
||||
if (ok) {
|
||||
refresh();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool JobsModel::updateJob(int id, const QVariantMap &fields)
|
||||
{
|
||||
Job job = jobFromMap(fields);
|
||||
job.id = id;
|
||||
const bool ok = m_db.updateJob(job);
|
||||
if (ok) {
|
||||
refresh();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool JobsModel::setStage(int id, const QString &stage)
|
||||
{
|
||||
const bool ok = m_db.setStage(id, stage);
|
||||
if (ok) {
|
||||
refresh();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool JobsModel::removeJob(int id)
|
||||
{
|
||||
const bool ok = m_db.deleteJob(id);
|
||||
if (ok) {
|
||||
refresh();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
QString JobsModel::lastError() const
|
||||
{
|
||||
return m_db.lastError();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QList>
|
||||
#include <QQmlEngine>
|
||||
|
||||
/**
|
||||
* List of all job applications, newest first, backed by JobsDatabase.
|
||||
* QML reads jobs through model roles and writes through the invokable
|
||||
* methods, which go straight to the database and then refresh in place.
|
||||
*/
|
||||
class JobsModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||
Q_PROPERTY(QStringList stages READ stages CONSTANT)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
IdRole = Qt::UserRole + 1,
|
||||
CompanyRole,
|
||||
TitleRole,
|
||||
LocationRole,
|
||||
RemoteTypeRole,
|
||||
SourceRole,
|
||||
UrlRole,
|
||||
DateAppliedRole,
|
||||
SalaryMinRole,
|
||||
SalaryMaxRole,
|
||||
SalaryExpectationRole,
|
||||
CurrencyRole,
|
||||
NotesRole,
|
||||
ContactRole,
|
||||
StageRole,
|
||||
StageColorRole,
|
||||
CreatedAtRole,
|
||||
UpdatedAtRole,
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit JobsModel(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;
|
||||
|
||||
/// Full record for one job, for prefilling the edit dialog.
|
||||
Q_INVOKABLE QVariantMap jobData(int id) const;
|
||||
|
||||
Q_INVOKABLE bool 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);
|
||||
|
||||
Q_INVOKABLE QString lastError() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
void refresh();
|
||||
|
||||
Q_SIGNALS:
|
||||
void countChanged();
|
||||
|
||||
private:
|
||||
static Job jobFromMap(const QVariantMap &fields);
|
||||
static QVariantMap mapFromJob(const Job &job);
|
||||
|
||||
JobsDatabase m_db;
|
||||
QList<Job> m_jobs;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "jobstage.h"
|
||||
|
||||
#include <QHash>
|
||||
|
||||
namespace JobStage
|
||||
{
|
||||
|
||||
QStringList canonicalStages()
|
||||
{
|
||||
static const QStringList stages{
|
||||
QStringLiteral("Applied"),
|
||||
QStringLiteral("Screening"),
|
||||
QStringLiteral("Interview"),
|
||||
QStringLiteral("Onsite"),
|
||||
QStringLiteral("Offer"),
|
||||
QStringLiteral("Accepted"),
|
||||
QStringLiteral("Rejected"),
|
||||
QStringLiteral("Withdrawn"),
|
||||
QStringLiteral("Ghosted"),
|
||||
};
|
||||
return stages;
|
||||
}
|
||||
|
||||
bool isValid(const QString &stage)
|
||||
{
|
||||
return canonicalStages().contains(stage, Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
int column(const QString &stage)
|
||||
{
|
||||
static const QHash<QString, int> columns{
|
||||
{QStringLiteral("Applied"), 1},
|
||||
{QStringLiteral("Screening"), 2},
|
||||
{QStringLiteral("Interview"), 3},
|
||||
{QStringLiteral("Onsite"), 4},
|
||||
{QStringLiteral("Offer"), 5},
|
||||
{QStringLiteral("Accepted"), 6},
|
||||
{QStringLiteral("Rejected"), 6},
|
||||
{QStringLiteral("Withdrawn"), 6},
|
||||
{QStringLiteral("Ghosted"), 6},
|
||||
};
|
||||
if (stage == QLatin1String(Start)) {
|
||||
return 0;
|
||||
}
|
||||
return columns.value(stage, 1);
|
||||
}
|
||||
|
||||
int orderInColumn(const QString &stage)
|
||||
{
|
||||
static const QHash<QString, int> order{
|
||||
{QStringLiteral("Accepted"), 0},
|
||||
{QStringLiteral("Offer"), 1},
|
||||
{QStringLiteral("Onsite"), 2},
|
||||
{QStringLiteral("Interview"), 3},
|
||||
{QStringLiteral("Screening"), 4},
|
||||
{QStringLiteral("Applied"), 5},
|
||||
{QStringLiteral("Rejected"), 6},
|
||||
{QStringLiteral("Withdrawn"), 7},
|
||||
{QStringLiteral("Ghosted"), 8},
|
||||
};
|
||||
if (stage == QLatin1String(Start)) {
|
||||
return -1;
|
||||
}
|
||||
return order.value(stage, 99);
|
||||
}
|
||||
|
||||
QColor color(const QString &stage)
|
||||
{
|
||||
static const QHash<QString, QColor> colors{
|
||||
{QStringLiteral("Applied"), QColor(0x3d, 0xae, 0xe9)},
|
||||
{QStringLiteral("Screening"), QColor(0x2e, 0xc4, 0xb6)},
|
||||
{QStringLiteral("Interview"), QColor(0x9b, 0x59, 0xb6)},
|
||||
{QStringLiteral("Onsite"), QColor(0x8e, 0x44, 0xad)},
|
||||
{QStringLiteral("Offer"), QColor(0xf3, 0x9c, 0x12)},
|
||||
{QStringLiteral("Accepted"), QColor(0x27, 0xae, 0x60)},
|
||||
{QStringLiteral("Rejected"), QColor(0xe7, 0x4c, 0x3c)},
|
||||
{QStringLiteral("Withdrawn"), QColor(0x95, 0xa5, 0xa6)},
|
||||
{QStringLiteral("Ghosted"), QColor(0x7f, 0x8c, 0x8d)},
|
||||
};
|
||||
if (stage == QLatin1String(Start)) {
|
||||
return QColor(0x5c, 0x63, 0x70);
|
||||
}
|
||||
return colors.value(stage, QColor(0x5c, 0x63, 0x70));
|
||||
}
|
||||
|
||||
bool isTerminal(const QString &stage)
|
||||
{
|
||||
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn")
|
||||
|| stage == QLatin1String("Ghosted");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
/**
|
||||
* The fixed set of stages an application can be in. Kept as a small closed
|
||||
* vocabulary (rather than free text) so the Sankey diagram's columns and
|
||||
* stacking order stay stable no matter what data is loaded.
|
||||
*/
|
||||
namespace JobStage
|
||||
{
|
||||
/// Stages in pipeline order. "Start" is a synthetic node (not a real stage,
|
||||
/// never stored on a Job) representing "before the first recorded stage".
|
||||
constexpr auto Start = "Start";
|
||||
|
||||
QStringList canonicalStages();
|
||||
|
||||
bool isValid(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.
|
||||
int column(const QString &stage);
|
||||
|
||||
/// Stacking order of nodes within a column (top to bottom).
|
||||
int orderInColumn(const QString &stage);
|
||||
|
||||
/// Stable color used for both the node box and its outgoing links.
|
||||
QColor color(const QString &stage);
|
||||
|
||||
bool isTerminal(const QString &stage);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "kareer-version.h"
|
||||
|
||||
#include "clicommands.h"
|
||||
|
||||
#include <KAboutData>
|
||||
#include <KCrash>
|
||||
#include <KIconTheme>
|
||||
#include <KLocalizedQmlContext>
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QIcon>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQuickStyle>
|
||||
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
// Malformed path data in third-party icon SVGs (system/app icon themes) is not
|
||||
// actionable from here; drop the noise rather than spam every render.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
s_defaultMessageHandler = qInstallMessageHandler(messageHandler);
|
||||
|
||||
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
|
||||
QCoreApplication app(argc, argv);
|
||||
return Cli::run(app);
|
||||
}
|
||||
|
||||
KIconTheme::initTheme();
|
||||
|
||||
QApplication app(argc, argv);
|
||||
KLocalizedString::setApplicationDomain(QByteArrayLiteral("kareer"));
|
||||
QCoreApplication::setOrganizationName(u"toservetheking"_s);
|
||||
|
||||
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
|
||||
QQuickStyle::setStyle(u"org.kde.desktop"_s);
|
||||
QQuickStyle::setFallbackStyle(u"Fusion"_s);
|
||||
}
|
||||
|
||||
KAboutData aboutData(u"kareer"_s,
|
||||
i18nc("@title", "Kareer"),
|
||||
QStringLiteral(KAREER_VERSION_STRING),
|
||||
i18n("Track your job applications"),
|
||||
KAboutLicense::GPL_V3,
|
||||
i18n("© 2026 Kareer contributors"));
|
||||
aboutData.addAuthor(u"toservetheking"_s, i18nc("@label", "Author"), u"[email protected]"_s);
|
||||
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)));
|
||||
|
||||
KCrash::initialize();
|
||||
|
||||
QCommandLineParser parser;
|
||||
aboutData.setupCommandLine(&parser);
|
||||
parser.process(app);
|
||||
aboutData.processCommandLine(&parser);
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
|
||||
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
|
||||
if (engine.rootObjects().isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kitemmodels as KItemModels
|
||||
import org.kde.kirigamiaddons.delegates as Delegates
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ScrollablePage {
|
||||
id: root
|
||||
|
||||
required property JobsModel jobsModel
|
||||
required property var editDialog
|
||||
|
||||
title: i18nc("@title", "Applications")
|
||||
|
||||
titleDelegate: Kirigami.SearchField {
|
||||
Layout.fillWidth: true
|
||||
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
|
||||
filterRoleName: "company"
|
||||
filterCaseSensitivity: Qt.CaseInsensitive
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: jobList
|
||||
model: filteredJobs
|
||||
currentIndex: -1
|
||||
|
||||
delegate: Delegates.RoundedItemDelegate {
|
||||
id: jobDelegate
|
||||
|
||||
required property int index
|
||||
required property int jobId
|
||||
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
|
||||
|
||||
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(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: root.editDialog.openForEdit(jobDelegate.jobId)
|
||||
}
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.gridUnit * 4
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls as QQC2
|
||||
import org.kde.kirigami as Kirigami
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ScrollablePage {
|
||||
id: root
|
||||
|
||||
required property JobsModel jobsModel
|
||||
|
||||
title: i18nc("@title", "Dashboard")
|
||||
|
||||
StatsModel {
|
||||
id: statsModel
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.jobsModel
|
||||
function onCountChanged() {
|
||||
statsModel.refresh();
|
||||
sankeyDiagram.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: statsModel.refresh()
|
||||
|
||||
ColumnLayout {
|
||||
width: root.width
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: root.width > Kirigami.Units.gridUnit * 30 ? 4 : 2
|
||||
columnSpacing: Kirigami.Units.largeSpacing
|
||||
rowSpacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{label: i18n("Total Applications"), value: String(statsModel.totalApplications)},
|
||||
{label: i18n("Active"), value: String(statsModel.activeApplications)},
|
||||
{label: i18n("Offers"), value: String(statsModel.offerCount)},
|
||||
{label: i18n("Response Rate"), value: Math.round(statsModel.responseRate) + "%"},
|
||||
]
|
||||
delegate: Kirigami.AbstractCard {
|
||||
id: statCard
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
Kirigami.Heading {
|
||||
level: 2
|
||||
text: statCard.modelData.value
|
||||
}
|
||||
QQC2.Label {
|
||||
text: statCard.modelData.label
|
||||
opacity: 0.7
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Heading {
|
||||
level: 3
|
||||
text: i18nc("@title:group", "Pipeline")
|
||||
}
|
||||
|
||||
SankeyDiagram {
|
||||
id: sankeyDiagram
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 20
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import org.kde.kirigami as Kirigami
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ApplicationWindow {
|
||||
id: root
|
||||
|
||||
title: i18nc("@title:window", "Kareer")
|
||||
|
||||
minimumWidth: Kirigami.Units.gridUnit * 32
|
||||
minimumHeight: Kirigami.Units.gridUnit * 24
|
||||
width: Kirigami.Units.gridUnit * 50
|
||||
height: Kirigami.Units.gridUnit * 36
|
||||
|
||||
JobsModel {
|
||||
id: jobsModel
|
||||
}
|
||||
|
||||
ApplicationEditDialog {
|
||||
id: editDialog
|
||||
jobsModel: jobsModel
|
||||
}
|
||||
|
||||
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22
|
||||
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
|
||||
|
||||
// Two static columns (applications + dashboard) - no dynamic page pushing.
|
||||
pageStack.initialPage: [applicationsComponent, dashboardComponent]
|
||||
|
||||
Component {
|
||||
id: applicationsComponent
|
||||
ApplicationsPage {
|
||||
jobsModel: jobsModel
|
||||
editDialog: editDialog
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: dashboardComponent
|
||||
DashboardPage {
|
||||
jobsModel: jobsModel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Controls as QQC2
|
||||
import org.kde.kirigami as Kirigami
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
// All the layout math (columns, stacking, ribbon paths) lives in SankeyModel;
|
||||
// this component only draws the rectangles and PathSvg shapes it hands back.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property bool empty: sankeyModel.empty
|
||||
|
||||
SankeyModel {
|
||||
id: sankeyModel
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (root.width > 0 && root.height > 0) {
|
||||
sankeyModel.relayout(root.width, root.height);
|
||||
}
|
||||
}
|
||||
|
||||
onWidthChanged: refresh()
|
||||
onHeightChanged: refresh()
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.gridUnit * 4
|
||||
visible: root.empty
|
||||
icon.name: "office-chart-line-symbolic"
|
||||
text: i18n("No applications yet")
|
||||
explanation: i18n("Once you add applications, their pipeline will appear here.")
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: sankeyModel.links
|
||||
delegate: Shape {
|
||||
required property var modelData
|
||||
asynchronous: true
|
||||
ShapePath {
|
||||
fillColor: modelData.color
|
||||
strokeColor: "transparent"
|
||||
PathSvg {
|
||||
path: modelData.pathData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: sankeyModel.nodes
|
||||
delegate: Rectangle {
|
||||
id: nodeDelegate
|
||||
required property var modelData
|
||||
|
||||
x: modelData.x
|
||||
y: modelData.y
|
||||
width: modelData.width
|
||||
height: modelData.height
|
||||
radius: 2
|
||||
color: modelData.color
|
||||
|
||||
QQC2.ToolTip.visible: nodeMouse.containsMouse
|
||||
QQC2.ToolTip.text: `${modelData.label} (${modelData.value})`
|
||||
|
||||
MouseArea {
|
||||
id: nodeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
Kirigami.Heading {
|
||||
level: 5
|
||||
anchors.left: parent.right
|
||||
anchors.leftMargin: Kirigami.Units.smallSpacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: nodeDelegate.modelData.labelWidth
|
||||
text: nodeDelegate.modelData.label
|
||||
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "sankeymodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QHash>
|
||||
#include <QPair>
|
||||
#include <QSet>
|
||||
#include <QVariantMap>
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace
|
||||
{
|
||||
QString num(qreal v)
|
||||
{
|
||||
return QString::number(v, 'f', 2);
|
||||
}
|
||||
|
||||
QString point(qreal x, qreal y)
|
||||
{
|
||||
return num(x) + u","_s + num(y);
|
||||
}
|
||||
|
||||
struct NodeInfo {
|
||||
QString stage;
|
||||
int column = 0;
|
||||
int order = 0;
|
||||
int value = 0;
|
||||
qreal x = 0;
|
||||
qreal y = 0;
|
||||
qreal width = 0;
|
||||
qreal height = 0;
|
||||
};
|
||||
}
|
||||
|
||||
SankeyModel::SankeyModel(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
QVariantList SankeyModel::nodes() const
|
||||
{
|
||||
return m_nodes;
|
||||
}
|
||||
|
||||
QVariantList SankeyModel::links() const
|
||||
{
|
||||
return m_links;
|
||||
}
|
||||
|
||||
bool SankeyModel::isEmpty() const
|
||||
{
|
||||
return m_nodes.isEmpty();
|
||||
}
|
||||
|
||||
void SankeyModel::relayout(qreal width, qreal height)
|
||||
{
|
||||
m_nodes.clear();
|
||||
m_links.clear();
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
QHash<QString, int> inbound;
|
||||
QHash<QString, int> outbound;
|
||||
QSet<QString> stageNames;
|
||||
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
|
||||
const QString &from = it.key().first;
|
||||
const QString &to = it.key().second;
|
||||
outbound[from] += it.value();
|
||||
inbound[to] += it.value();
|
||||
stageNames.insert(from);
|
||||
stageNames.insert(to);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
QHash<QString, int> nodeIndexByStage;
|
||||
for (int i = 0; i < nodeList.size(); ++i) {
|
||||
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();
|
||||
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);
|
||||
if (aFrom != bFrom) {
|
||||
return aFrom < bFrom;
|
||||
}
|
||||
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
|
||||
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
|
||||
|
||||
const qreal y0Top = sourceCursor.value(from);
|
||||
const qreal y0Bottom = y0Top + thickness;
|
||||
sourceCursor[from] = y0Bottom;
|
||||
|
||||
const qreal y1Top = targetCursor.value(to);
|
||||
const qreal y1Bottom = y1Top + thickness;
|
||||
targetCursor[to] = y1Bottom;
|
||||
|
||||
const qreal x0 = fromNode.x + fromNode.width;
|
||||
const qreal x1 = toNode.x;
|
||||
const qreal midX = (x0 + x1) / 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;
|
||||
|
||||
QColor linkColor = JobStage::color(from);
|
||||
linkColor.setAlphaF(0.5f);
|
||||
|
||||
m_links.append(QVariantMap{
|
||||
{u"fromStage"_s, from},
|
||||
{u"toStage"_s, to},
|
||||
{u"value"_s, value},
|
||||
{u"pathData"_s, path},
|
||||
{u"color"_s, linkColor},
|
||||
});
|
||||
}
|
||||
|
||||
// Labels are drawn to the right of each node. Without a bound, a long
|
||||
// label (e.g. "Applications") can run into the next column's node and
|
||||
// its own label. Cap each node's label to the gap before the next
|
||||
// distinct column actually in use (skipping empty columns), so text
|
||||
// elides instead of overlapping.
|
||||
QList<qreal> columnStarts;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
if (!columnStarts.contains(n.x)) {
|
||||
columnStarts.append(n.x);
|
||||
}
|
||||
}
|
||||
std::sort(columnStarts.begin(), columnStarts.end());
|
||||
|
||||
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);
|
||||
|
||||
m_nodes.append(QVariantMap{
|
||||
{u"stage"_s, n.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"labelWidth"_s, labelWidth},
|
||||
});
|
||||
}
|
||||
|
||||
Q_EMIT changed();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QVariantList>
|
||||
|
||||
/**
|
||||
* Turns the recorded stage_history transitions into a laid-out Sankey
|
||||
* diagram: a column per pipeline stage, nodes sized by how many
|
||||
* applications passed through them, and ribbon-shaped links (as SVG path
|
||||
* data, ready for QtQuick.Shapes' PathSvg) sized by transition counts.
|
||||
*
|
||||
* All geometry is computed here rather than in QML so the layout itself is
|
||||
* unit-testable (see autotests/sankeylayouttest.cpp) and the QML side stays
|
||||
* a plain renderer.
|
||||
*/
|
||||
class SankeyModel : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(QVariantList nodes READ nodes NOTIFY changed)
|
||||
Q_PROPERTY(QVariantList links READ links NOTIFY changed)
|
||||
Q_PROPERTY(bool empty READ isEmpty NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit SankeyModel(QObject *parent = nullptr);
|
||||
|
||||
QVariantList nodes() const;
|
||||
QVariantList links() const;
|
||||
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);
|
||||
|
||||
Q_SIGNALS:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
JobsDatabase m_db;
|
||||
QVariantList m_nodes;
|
||||
QVariantList m_links;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include "statsmodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
StatsModel::StatsModel(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
|
||||
void StatsModel::refresh()
|
||||
{
|
||||
m_jobs = m_db.allJobs();
|
||||
Q_EMIT changed();
|
||||
}
|
||||
|
||||
int StatsModel::totalApplications() const
|
||||
{
|
||||
return m_jobs.size();
|
||||
}
|
||||
|
||||
int StatsModel::activeApplications() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const Job &job : m_jobs) {
|
||||
if (!JobStage::isTerminal(job.stage)) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int StatsModel::offerCount() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const Job &job : m_jobs) {
|
||||
if (job.stage == QLatin1String("Offer") || job.stage == QLatin1String("Accepted")) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int StatsModel::acceptedCount() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const Job &job : m_jobs) {
|
||||
if (job.stage == QLatin1String("Accepted")) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int StatsModel::rejectedCount() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const Job &job : m_jobs) {
|
||||
if (job.stage == QLatin1String("Rejected")) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
double StatsModel::responseRate() const
|
||||
{
|
||||
if (m_jobs.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
int responded = 0;
|
||||
for (const Job &job : m_jobs) {
|
||||
if (job.stage != QLatin1String("Applied")) {
|
||||
++responded;
|
||||
}
|
||||
}
|
||||
return 100.0 * responded / m_jobs.size();
|
||||
}
|
||||
|
||||
double StatsModel::offerRate() const
|
||||
{
|
||||
if (m_jobs.isEmpty()) {
|
||||
return 0.0;
|
||||
}
|
||||
return 100.0 * offerCount() / m_jobs.size();
|
||||
}
|
||||
|
||||
QVariantMap StatsModel::stageCounts() const
|
||||
{
|
||||
QVariantMap counts;
|
||||
for (const QString &stage : JobStage::canonicalStages()) {
|
||||
counts.insert(stage, 0);
|
||||
}
|
||||
for (const Job &job : m_jobs) {
|
||||
counts[job.stage] = counts.value(job.stage, 0).toInt() + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
#include <QVariantMap>
|
||||
|
||||
/**
|
||||
* Summary counters for the dashboard, computed on demand from JobsDatabase.
|
||||
* QML calls refresh() whenever the underlying jobs may have changed (the
|
||||
* dashboard becoming visible is enough - this is cheap for the data sizes a
|
||||
* personal tracker deals with).
|
||||
*/
|
||||
class StatsModel : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(int totalApplications READ totalApplications NOTIFY changed)
|
||||
Q_PROPERTY(int activeApplications READ activeApplications NOTIFY changed)
|
||||
Q_PROPERTY(int offerCount READ offerCount NOTIFY changed)
|
||||
Q_PROPERTY(int acceptedCount READ acceptedCount NOTIFY changed)
|
||||
Q_PROPERTY(int rejectedCount READ rejectedCount NOTIFY changed)
|
||||
Q_PROPERTY(double responseRate READ responseRate NOTIFY changed)
|
||||
Q_PROPERTY(double offerRate READ offerRate NOTIFY changed)
|
||||
Q_PROPERTY(QVariantMap stageCounts READ stageCounts NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit StatsModel(QObject *parent = nullptr);
|
||||
|
||||
int totalApplications() const;
|
||||
int activeApplications() const;
|
||||
int offerCount() const;
|
||||
int acceptedCount() const;
|
||||
int rejectedCount() const;
|
||||
double responseRate() const;
|
||||
double offerRate() const;
|
||||
QVariantMap stageCounts() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
void refresh();
|
||||
|
||||
Q_SIGNALS:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
JobsDatabase m_db;
|
||||
QList<Job> m_jobs;
|
||||
};
|
||||
Reference in New Issue
Block a user