[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: flowkeeper-org\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build subflow\n\non:\n  workflow_call:\n    inputs:\n      os:\n        required: true\n        type: string\n      compiler:\n        required: true\n        type: string\n    secrets:\n      MAC_SIGN_CERT:\n        required: true\n      MAC_SIGN_PASSWORD:\n        required: true\n      MAC_KEYCHAIN_PASSWORD:\n        required: true\n\njobs:\n  build:\n    runs-on: ${{ inputs.os }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n\n      - name: Install dependencies\n        env:\n          BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_SIGN_CERT }}\n          P12_PASSWORD: ${{ secrets.MAC_SIGN_PASSWORD }}\n          KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}\n        shell: bash\n        run: |\n          export FK_VERSION=$(scripts/common/get-version.sh)\n          echo \"FK_VERSION=$FK_VERSION\" >> $GITHUB_ENV\n\n          if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n            scripts/macos/install-create-dmg.sh\n            scripts/macos/install-certificates.sh\n            export BINARY_EXTENSION=\"\"\n            echo \"BINARY_EXTENSION=\" >> $GITHUB_ENV\n          elif [[ \"$OSTYPE\" == \"msys\" ]]; then\n            # Disable Defender for the workspace directory\n            echo \"Will disable Defender\"\n            echo \"powershell -inputformat none -outputformat none -NonInteractive -Command Add-MpPreference -ExclusionPath $(cmd //c cd)\"\n            powershell -inputformat none -outputformat none -NonInteractive -Command Add-MpPreference -ExclusionPath \"$(cmd //c cd)\"\n            scripts/windows/install-innosetup.sh\n            export BINARY_EXTENSION=\".exe\"\n            echo \"BINARY_EXTENSION=.exe\" >> $GITHUB_ENV\n          else\n            scripts/linux/appimage/install-appimage.sh\n            export BINARY_EXTENSION=\"\"\n            echo \"BINARY_EXTENSION=\" >> $GITHUB_ENV\n          fi\n          pip install -r requirements.txt\n\n          if [[ \"${{ inputs.compiler}}\" == \"nuitka\" ]]; then\n            pip install nuitka\n          else\n            pip install pyinstaller\n          fi\n\n      - name: Prepare sources\n        shell: bash\n        run: |\n          scripts/common/generate-resources.sh\n          rm -rf build dist\n          mkdir -p build dist\n\n      - name: Package builds (PyInstaller)\n        if: ${{ inputs.compiler == 'pyinstaller' }}\n        shell: bash\n        run: |\n          if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n            pyinstaller scripts/common/pyinstaller/normal.spec --distpath=build -- --sign\n            echo \"Built PyInstaller for macOS\"\n            ls -al build/\n          else\n            pyinstaller scripts/common/pyinstaller/portable.spec --distpath=build\n            pyinstaller scripts/common/pyinstaller/normal.spec --distpath=build\n            echo \"Built PyInstaller for Linux or Windows\"\n            ls -al build/\n          fi\n\n      - name: Package builds (Nuitka)\n        if: ${{ inputs.compiler == 'nuitka' }}\n        uses: Nuitka/Nuitka-Action@main\n        with:\n          nuitka-version: \"2.6.8\"\n          script-name: src/fk/desktop/desktop.py\n          mode: ${{ startsWith(inputs.os, 'macOS') && 'app' || 'onefile' }}\n          enable-plugins: pyside6\n          include-qt-plugins: multimedia\n          clang: ${{ startsWith(inputs.os, 'windows') && 'on' || '' }}\n          windows-console-mode: disable\n          windows-icon-from-ico: res/flowkeeper.ico\n          macos-app-icon: flowkeeper.icns\n          macos-signed-app-name: org.flowkeeper.Flowkeeper\n          macos-app-name: Flowkeeper\n          macos-sign-identity: \"Developer ID Application: Constantine Kulak (ELWZ9S676C)\"\n          macos-sign-notarization: true \n          macos-app-protected-resource: \"com.apple.security.cs.allow-unsigned-executable-memory:true\"\n          macos-app-version: \"${{ env.FK_VERSION }}\"\n          product-name: Flowkeeper\n          product-version: \"${{ env.FK_VERSION }}\"\n          file-description: ${{ startsWith(inputs.os, 'windows') && 'Flowkeeper' || 'Flowkeeper is a Pomodoro Technique desktop timer for power users' }}\n          copyright: Copyright (c) 2023 Constantine Kulak <contact@flowkeeper.org>\n          output-dir: 'build'\n          output-file: 'Flowkeeper'\n        env:\n          PYTHONPATH: src\n          KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}\n\n      - name: Create installers\n        env:\n          NOTARIZATION_PASSWORD: ${{ secrets.MAC_NOTARIZATION_PASSWORD }}\n          NOTARIZATION_ID: ${{ secrets.MAC_NOTARIZATION_ID }}\n          NOTARIZATION_TEAM: ${{ secrets.MAC_NOTARIZATION_TEAM }}\n        shell: bash\n        run: |\n          echo \"Prepare dist directory\"\n          mkdir -p dist/standalone\n          PREFIX=\"dist/flowkeeper-${FK_VERSION}-${{ inputs.os }}-${{ inputs.compiler }}\"\n          echo \"Will create artifacts with $PREFIX prefix\"\n\n          if [[ \"${{ inputs.compiler}}\" == \"nuitka\" ]]; then\n            if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n              echo \"macOS doesn't support portable binaries\"\n              mv build/desktop.app dist/standalone/Flowkeeper.app\n            else\n              mv build/Flowkeeper* $PREFIX-portable${BINARY_EXTENSION}\n              mv build/desktop.dist/* dist/standalone\n            fi\n            if [[ \"$OSTYPE\" == \"linux\"* ]]; then\n              mv dist/standalone/Flowkeeper.bin dist/standalone/Flowkeeper\n            fi\n          else\n            if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n              echo \"macOS doesn't support portable binaries\"\n              mv build/Flowkeeper.app dist/standalone\n            else\n              mv build/Flowkeeper${BINARY_EXTENSION} $PREFIX-portable${BINARY_EXTENSION}\n              mv build/flowkeeper/* dist/standalone\n            fi\n          fi\n          echo \"Moved ${{ inputs.compiler }} binaries to dist/standalone\"\n          find dist/\n\n          if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n            echo \"Building macOS DMG installer\"\n            scripts/macos/create-dmg.sh\n            scripts/macos/notarize-dmg.sh\n            mv dist/Flowkeeper.dmg \"$PREFIX-installer.dmg\"\n          elif [[ \"$OSTYPE\" == \"msys\" ]]; then\n            echo \"Building Windows setup.exe installer\"\n            scripts/windows/package-installer.sh\n            mv dist/setup.exe \"$PREFIX-installer.exe\"\n          else\n            echo \"Building Debian \"fat\" DEB package\"\n            scripts/linux/debian/package-deb.sh\n            mv dist/flowkeeper.deb \"$PREFIX-package.deb\"\n\n            echo \"Building Debian \"lean\" DEB package\"\n            scripts/linux/debian/package-deb-min.sh\n            mv dist/flowkeeper-min.deb \"$PREFIX-min-package.deb\"\n\n            # Temporarily disable AppImage build\n            echo \"Building AppImage package\"\n            scripts/linux/appimage/package-appimage.sh\n            mv dist/Flowkeeper-*.AppImage \"$PREFIX.AppImage\"\n          fi\n\n          echo \"Zipping the standalone directory\"\n          cd dist/standalone\n          if [[ \"$OSTYPE\" == \"msys\" ]]; then\n            echo \"No zip on Windows\"\n            powershell Compress-Archive \"./*\" \"../../$PREFIX-standalone.zip\"\n          else\n            zip -9 -r \"../../$PREFIX-standalone.zip\" ./*\n          fi\n          cd ../..\n\n          echo \"Cleaning /dist up\"\n          rm -rf dist/standalone\n\n      - name: Archive the binaries\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist-${{ inputs.os }}-${{ inputs.compiler }}-all\n          path: |\n            dist\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Tests and checks\n\non:\n  pull_request:\n    branches:\n      - main\n  push:\n    branches:\n      - main\n\npermissions:\n  checks: write\n\njobs:\n  run-tests:\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.11'\n      - name: Create Virtual Environment\n        run: |\n          python3 -m venv venv\n          source venv/bin/activate\n      - name: Install dependencies\n        run: |\n          pip install -r requirements-test.txt\n      - name: Generate resources\n        run: |\n          scripts/common/generate-resources.sh\n      - name: Run unit tests for fk.core\n        run: |\n          PYTHONPATH=src coverage run -m xmlrunner -o test-results discover -v fk.tests\n      - name: Publish test report\n        uses: mikepenz/action-junit-report@v4\n        with:\n          report_paths: 'test-results/TEST-*.xml'\n          include_passed: true\n      - name: Upload code coverage to coveralls.io\n        run: coveralls --service=github\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - name: Trigger e2e tests (external)\n        run: |\n          curl --version\n\n  remote-pipeline:\n    runs-on: ubuntu-22.04\n    needs: run-tests\n    steps:\n      - name: Trigger remote job\n        env:\n          REMOTE_URL: ${{ secrets.REMOTE_URL }}\n          REMOTE_USER_TOKEN: ${{ secrets.REMOTE_USER_TOKEN }}\n          REMOTE_JOB_TOKEN: ${{ secrets.REMOTE_MAIN_JOB_TOKEN }}\n        run: |\n          curl -I -u \"github-trigger:$REMOTE_USER_TOKEN\" \"$REMOTE_URL/job/gh-main/build?token=$REMOTE_JOB_TOKEN\" 2>/dev/null > /dev/null\n"
  },
  {
    "path": ".github/workflows/manual-all.yml",
    "content": "name: Manual build - all\n\non:\n  workflow_dispatch:\n\njobs:\n  call-build:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-24.04, ubuntu-22.04, ubuntu-24.04-arm, ubuntu-22.04-arm, macos-15, macos-14, macos-13, windows-2025, windows-2022]\n        compiler: [nuitka, pyinstaller]\n        exclude:\n          - os: macos-13\n            compiler: nuitka\n    uses: flowkeeper-org/fk-desktop/.github/workflows/build.yml@main\n    with:\n      os: ${{ matrix.os }}\n      compiler: ${{ matrix.compiler }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/manual-one.yml",
    "content": "name: Manual build - one\n\non:\n  workflow_dispatch:\n    inputs:\n      os:\n        description: 'Operating system'\n        required: true\n        default: 'ubuntu-24.04'\n        type: choice\n        options:\n          - ubuntu-24.04\n          - ubuntu-22.04\n          - ubuntu-24.04-arm\n          - ubuntu-22.04-arm\n          - macos-15\n          - macos-14\n          - macos-13\n          - windows-2025\n          - windows-2022\n      compiler:\n        description: 'Compiler'\n        required: true\n        default: 'nuitka'\n        type: choice\n        options:\n          - nuitka\n          - pyinstaller\n\njobs:\n  call-build:\n    uses: flowkeeper-org/fk-desktop/.github/workflows/build.yml@main\n    with:\n      os: ${{ inputs.os }}\n      compiler: ${{ inputs.compiler }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release new version\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n\npermissions:\n  contents: write\n  checks: write\n\njobs:\n  call-build:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-24.04, ubuntu-22.04, ubuntu-24.04-arm, ubuntu-22.04-arm, macos-15, macos-14, macos-13, windows-2025, windows-2022]\n        compiler: [nuitka, pyinstaller]\n        exclude:\n          - os: macos-13\n            compiler: nuitka\n    uses: flowkeeper-org/fk-desktop/.github/workflows/build.yml@main\n    with:\n      os: ${{ matrix.os }}\n      compiler: ${{ matrix.compiler }}\n    secrets: inherit\n\n  release:\n    needs: call-build\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-24.04, ubuntu-22.04, ubuntu-24.04-arm, ubuntu-22.04-arm, macos-15, macos-14, macos-13, windows-2025, windows-2022]\n        compiler: [nuitka, pyinstaller]\n        exclude:\n          - os: macos-13\n            compiler: nuitka\n    runs-on: ubuntu-latest\n    steps:\n      - name: Download artifacts\n        uses: actions/download-artifact@v4\n        with:\n          name: dist-${{ matrix.os }}-${{ matrix.compiler }}-all\n          path: .\n      - name: Release\n        uses: softprops/action-gh-release@v1\n        with:\n          files: \"./*\"\n"
  },
  {
    "path": ".gitignore",
    "content": ".venv\nvenv\nvenv*\n__pycache__\nbuild\ndist\n.coverage\n.DS_Store\nhtmlcov\nsrc/fk/desktop/resources.py\ntest-results\nFlowkeeper.dmg\nnotary-key.txt\nflowkeeper.icns\n*.log\n"
  },
  {
    "path": ".idea/.gitignore",
    "content": "# Default ignored files\n/shelf/\n/workspace.xml\n# Editor-based HTTP Client requests\n/httpRequests/\n# Datasource local storage ignored files\n/dataSources/\n/dataSources.local.xml\n"
  },
  {
    "path": ".idea/dictionaries/project.xml",
    "content": "<component name=\"ProjectDictionaryState\">\n  <dictionary name=\"project\">\n    <words>\n      <w>flowkeeper</w>\n      <w>workitem</w>\n    </words>\n  </dictionary>\n</component>"
  },
  {
    "path": ".idea/flowkeeper-python.iml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<module type=\"PYTHON_MODULE\" version=\"4\">\n  <component name=\"NewModuleRootManager\">\n    <content url=\"file://$MODULE_DIR$\">\n      <sourceFolder url=\"file://$MODULE_DIR$/src\" isTestSource=\"false\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/.venv\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/venv\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/dist\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/build\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/htmlcov\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/test-results\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/venv38\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/src/fk/tests/fixtures\" />\n      <excludeFolder url=\"file://$MODULE_DIR$/venv\" />\n    </content>\n    <orderEntry type=\"jdk\" jdkName=\"Python 3.13 (fk-desktop)\" jdkType=\"Python SDK\" />\n    <orderEntry type=\"sourceFolder\" forTests=\"false\" />\n  </component>\n</module>"
  },
  {
    "path": ".idea/inspectionProfiles/profiles_settings.xml",
    "content": "<component name=\"InspectionProjectProfileManager\">\n  <settings>\n    <option name=\"PROJECT_PROFILE\" value=\"Default\" />\n    <option name=\"USE_PROJECT_PROFILE\" value=\"false\" />\n    <version value=\"1.0\" />\n  </settings>\n</component>"
  },
  {
    "path": ".idea/misc.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"Black\">\n    <option name=\"sdkName\" value=\"Python 3.13 virtualenv at ~/projects/fk-desktop/venv\" />\n  </component>\n  <component name=\"ProjectRootManager\" version=\"2\" project-jdk-name=\"Python 3.13 (fk-desktop)\" project-jdk-type=\"Python SDK\" />\n  <component name=\"PythonCompatibilityInspectionAdvertiser\">\n    <option name=\"version\" value=\"3\" />\n  </component>\n</project>"
  },
  {
    "path": ".idea/modules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectModuleManager\">\n    <modules>\n      <module fileurl=\"file://$PROJECT_DIR$/.idea/flowkeeper-python.iml\" filepath=\"$PROJECT_DIR$/.idea/flowkeeper-python.iml\" />\n    </modules>\n  </component>\n</project>"
  },
  {
    "path": ".idea/vcs.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"VcsDirectoryMappings\">\n    <mapping directory=\"\" vcs=\"Git\" />\n  </component>\n</project>"
  },
  {
    "path": ".idea/watcherTasks.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectTasksOptions\">\n    <TaskOptions isEnabled=\"true\">\n      <option name=\"arguments\" value=\"\" />\n      <option name=\"checkSyntaxErrors\" value=\"false\" />\n      <option name=\"description\" />\n      <option name=\"exitCodeBehavior\" value=\"ERROR\" />\n      <option name=\"fileExtension\" value=\"*\" />\n      <option name=\"immediateSync\" value=\"true\" />\n      <option name=\"name\" value=\"Qt resources\" />\n      <option name=\"output\" value=\"\" />\n      <option name=\"outputFilters\">\n        <array />\n      </option>\n      <option name=\"outputFromStdout\" value=\"false\" />\n      <option name=\"program\" value=\"$ProjectFileDir$/scripts/common/generate-resources.sh\" />\n      <option name=\"runOnExternalChanges\" value=\"true\" />\n      <option name=\"scopeName\" value=\"/res\" />\n      <option name=\"trackOnlyRoot\" value=\"false\" />\n      <option name=\"workingDir\" value=\"$ProjectFileDir$\" />\n      <envs />\n    </TaskOptions>\n  </component>\n</project>"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Flowkeeper\n\n![Pipeline status](https://github.com/flowkeeper-org/fk-desktop/actions/workflows/main.yml/badge.svg?branch=main \"Pipeline status\")\n[![Coverage Status](https://coveralls.io/repos/github/flowkeeper-org/fk-desktop/badge.svg?branch=main)](https://coveralls.io/github/flowkeeper-org/fk-desktop?branch=main)\n[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=flowkeeper-org_fk-desktop&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=flowkeeper-org_fk-desktop)\n[![OBS Build Result](https://build.opensuse.org/projects/home:flowkeeper/packages/flowkeeper/badge.svg?type=default)](https://build.opensuse.org/package/show/home:flowkeeper/flowkeeper)\n\nFlowkeeper is an independent Pomodoro Technique desktop timer for power users. It is a \nsimple tool, which focuses on doing one thing well. It is Free Software with open source. \n\nVisit [flowkeeper.org](https://flowkeeper.org) for screenshots, downloads and FAQ.\n\nIf you used it, I will appreciate it if you take a minute to \n[provide some feedback](https://www.producthunt.com/products/flowkeeper/reviews/new). \nYour constructive criticism is welcome!\n\n![Flowkeeper screenshot](doc/fk-simple.png \"Flowkeeper screenshot\")\n\n## Building\n\nFlowkeeper has a single major dependency -- Qt 6.7.0, which in turn requires Python 3.9 or later. To create \ninstallers and binary packages we build Flowkeeper on Ubuntu 22.04 using Python 3.11 and 6.7.0. We also\ntest Flowkeeper with the latest Qt 6.8.x on OpenSUSE Tumbleweed.\n\n### Building for Linux and macOS\n\nOn some lean distributions like a minimal installation of Debian 12, you \nmight need to install `libxcb-cursor0` first, e.g.\n\n```shell\nsudo apt install libxcb-cursor0\n```\n\nCreate a virtual environment and install dependencies:\n\n```shell\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements.txt\n```\n\nNote that `requirements.txt` contains ALL libraries and tools needed to run, test and\ncreate installers. You can use `requirements-run.txt` if you only want to debug\nFlowkeeper locally, or `requirements-build.txt` if you also want to create distributable /\nportable bundles.\n\nThen you need to \"generate resources\", which means converting data files in `/res` directory into\nthe corresponding Python classes. Whenever you make changes to files in `/res` directory, you need\nto rerun this command, too:\n\n```shell\nbuild/common/generate-resources.sh\n```\n\nFrom here you can start coding. If you want to build an installer, refer to the CI/CD pipeline in\n`.github/workflows/build.yml`. For example, if you want to build a DEB file, you'd need to execute \n`pyinstaller installer/normal-build.spec` and then `./package-deb.sh`. \n\nIf you see this error on openSUSE with Qt 6.7.x:\n\n```\nNo QtMultimedia backends found. Only QMediaDevices, QAudioDevice, QSoundEffect, QAudioSink, and QAudioSource are available.\n```\n\nthen install `libatomic1`:\n\n```shell\nsudo zypper install libatomic1\n```\n\n### Building for Windows\n\nConsult the above section for details. In short, install Python 3.11. Then:\n\n```shell\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements.txt\n```\n\nGenerate resources:\n\n```shell\ncd res\npyside6-rcc --project -o resources.qrc\npyside6-rcc -g python resources.qrc -o \"../src/fk/desktop/resources.py\"\n```\n\nPackage as a distributable / portable bundle (OPTIONAL):\n\n```shell\npyinstaller installer\\portable-build.spec\npyinstaller installer\\normal-build.spec\n```\n\n## Testing Flowkeeper\n\nTo execute Flowkeeper:\n\n```shell\nPYTHONPATH=src python -m fk.desktop.desktop\n```\n\nTo run unit tests w/test coverage (install requirements from \n`requirements.txt` or `requirements-test.txt` first):\n\n```shell\nPYTHONPATH=src python -m coverage run -m unittest discover -v fk.tests\npython -m coverage html\n```\n\nTo execute end-to-end tests:\n\n```shell\nPYTHONPATH=src python -m fk.desktop.desktop --e2e\n```\n\n## Technical details\n\n- [Design considerations](doc/design.md)\n- [Data model](doc/data-model.md)\n- [Strategies](doc/strategies.md)\n- [Events](doc/events.md)\n- [UI actions](doc/actions.md)\n- [CI/CD pipeline](doc/pipeline.md)\n- [Building for Alpine Linux](doc/build-alpine.md)\n- [Building for FreeBSD](doc/build-freebsd.md)\n\n## Copyright\n\nCopyright (c) 2023 - 2024 Constantine Kulak.\n\nThis program is free software: you can redistribute it and/or modify\nit under the terms of the GNU General Public License as published by\nthe Free Software Foundation; either version 3 of the License, or\n(at your option) any later version.\n\nThis program is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\nGNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License\nalong with this program.  If not, see <https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "TODO.md",
    "content": "# To do before release\n\n4. Fonts -- backlogs use default font \n5. Fonts -- status uses default font \n6. Fonts -- focus mode uses default font. Default focus becomes the same after double-clicking. \n8. Backlogs toggle icon doesn't change its color on theme change \n9. Rows height doesn't recalculate on fonts change\n\n# Tests\n\n## Windows binaries\n\n1. No sound on Windows with Nuitka\n2. \"standalone\" directory in ZIPs \n3. Sign binaries in standalone ZIPs and repack\n\n## Linux binaries\n\n1. KUbuntu 24.04 doesn't support deb-min installer (Qt 6.4.2 max, same for Debian)\n2. Ubuntu 22.04 ships with Qt 6.2.4 (Universe repo) -- check all for Pyside6\n3. The \"fat\" versions has GTK / default theme\n4. No sound for Nuitka, same as Windows\n5. Keyboard doesn't work with PyInstaller binaries on openSUSE -- both 22 and 24\n\n### AppImage\n\n### Flatpak\n\n### openSUSE installer\n\n## macOS binaries\n\n1. On Ventura 13 / x86 and ARM, both Nuitka and PyInstaller -- no signature\n2. \"Too many values to unpack\" when launching Settings, even after settings reset\n3. No sound for Nuitka, same as Windows\n"
  },
  {
    "path": "doc/actions.md",
    "content": "# UI: Actions\n\n## Application\n- ('application.settings', \"Settings\", 'F10', None, Application.show_settings_dialog)\n- ('application.quit', \"Quit\", 'Ctrl+Q', None, Application.quit_local)\n- ('application.import', \"Import data...\", 'Ctrl+I', None, Application.show_import_wizard)\n- ('application.export', \"Export data...\", 'Ctrl+E', None, Application.show_export_wizard)\n- ('application.about', \"About\", '', None, Application.show_about)\n\n## BacklogTableView\n- ('backlogs_table.newBacklog', \"New Backlog\", 'Ctrl+N', None, BacklogTableView.create_backlog)\n- ('backlogs_table.renameBacklog', \"Rename Backlog\", 'Ctrl+R', None, BacklogTableView.rename_selected_backlog)\n- ('backlogs_table.deleteBacklog', \"Delete Backlog\", 'F8', None, BacklogTableView.delete_selected_backlog)\n- ('backlogs_table.newBacklogFromIncomplete', \"New Backlog From Incomplete\", 'Ctrl+M', \"tool-add-prefilled\", BacklogTableView.create_backlog_from_incomplete)\n\n## WorkitemTableView\n- ('workitems_table.newItem', \"New Item\", 'Ins', None, WorkitemTableView.create_workitem)\n- ('workitems_table.renameItem', \"Rename Item\", 'F6', None, WorkitemTableView.rename_selected_workitem)\n- ('workitems_table.deleteItem', \"Delete Item\", 'Del', None, WorkitemTableView.delete_selected_workitem)\n- ('workitems_table.startItem', \"Start Item\", 'Ctrl+S', 'tool-next', WorkitemTableView.start_selected_workitem)\n- ('workitems_table.completeItem', \"Complete Item\", 'Ctrl+P', 'tool-complete', WorkitemTableView.complete_selected_workitem)\n- ('workitems_table.addPomodoro', \"Add Pomodoro\", 'Ctrl++', None, WorkitemTableView.add_pomodoro)\n- ('workitems_table.removePomodoro', \"Remove Pomodoro\", 'Ctrl+-', None, WorkitemTableView.remove_pomodoro)\n- ('workitems_table.hideCompleted', \"Hide Completed Items\", '', None, WorkitemTableView._toggle_hide_completed_workitems, True, True)\n\n## FocusWidget\n- ('focus.voidPomodoro', \"Void Pomodoro\", 'Ctrl+V', \"tool-void\", FocusWidget._void_pomodoro)\n- ('focus.nextPomodoro', \"Next Pomodoro\", None, \"tool-next\", FocusWidget._next_pomodoro)\n- ('focus.completeItem', \"Complete Item\", None, \"tool-complete\", FocusWidget._complete_item)\n\n## MainWindow\n- ('window.focusMode', \"Focus Mode\", None, \"tool-show-timer-only\", MainWindow.toggle_focus_mode)\n- ('window.showMainWindow', \"Show Main Window\", None, \"tool-show-timer-only\", MainWindow.show_window)\n- ('window.showBacklogs', \"Backlogs\", 'Ctrl+B', 'tool-backlogs', MainWindow.show_about)\n- ('window.showUsers', \"Team\", 'Ctrl+T', 'tool-teams', MainWindow.toggle_users)\n- ('window.showSearch', \"Search...\", 'Ctrl+F', '', MainWindow.show_search)\n"
  },
  {
    "path": "doc/build-alpine.md",
    "content": "# Building for Alpine Linux\n\nFlowkeeper's CI pipeline runs PyInstaller on Ubuntu and thus generates binaries which rely on glibc. \nAlpine is based on musl, so you'd get \"symbol not found\" errors in runtime if you try to run any of the\n\"official\" binaries.\n\nYou can still use Flowkeeper with Alpine. We tested it with the edge release + Xfce. Instructions:\n\n1. Install `py3-pyside6` package via `apk`. This is the only tricky bit. We couldn't install PySide6 \nvia pip from inside the venv, as we'd normally do.\n2. Clone this repo and create a Python Virtual Environment, *which uses system packages*:\n`python3 -m venv venv --system-site-packages`\n3. The rest of the steps are the same as for any other Linux OS\n\n"
  },
  {
    "path": "doc/data-model.md",
    "content": "# Data model\n\nFlowkeeper data model is strictly hierarchical:\n\n- Tenant: AbstractDataContainer\n  - User: AbstractDataContainer\n    - Backlog: AbstractDataContainer\n      - Workitem: AbstractDataContainer\n        - Pomodoro: AbstractDataItem\n\n`AbstractDataContainer` acts as a `dict<uid, T>`, and `AbstractDataItem` represents a domain object with \n`uid`, `parent`, `create_date` and `last_modified_date`. \n\nDue to its tree nature, sharing backlogs and workitems should be implemented via symlinks.\n"
  },
  {
    "path": "doc/design.md",
    "content": "# Flowkeeper design considerations\n\n## Client / server architecture\n\n1. Flowkeeper clients are \"fat\", and the backends are \"thin\". All messages are sent by \nthe clients, while the servers are passive. This is done to simplify servers, allow generic \nmessaging protocols like XMPP or AMQP, and enable \"dumb\" backends like plain files. \n2. A client may safely disconnect or shut down at any moment. Most importantly, there can \nbe _zero_ clients running in the middle of a Pomodoro. A client which \"reconnects\" will \nsee the Pomodoro in the correct state, as if it was running on the server. A Pomodoro, \nwhich ended while the clients were offline, is considered completed successfully.\n3. The server should work correctly with all \"business\" content e2e-encrypted. Any \nunencrypted messages are all related to the client's communication, i.e. Authenticate,\nPing/Pong, Error, Replay, and DeleteAccount.\n4. As a consequence of (2) and (3), all messages in the system are recorded end-user events.\nNeither the client, nor the server generate any \"business\" events of their own. This makes\nclient synchronization much easier, since the events like FinishPomodoro are computed and \nfired internally, and never go on the wire.\n\n## Event sourcing data model\n\nWhen a client connects to a backend, it replays all events since the last known state.\n\n5. Two clients must synchronize their changes in real time, meaning that they can't make\nconflicting changes offline. This is achieved via message sequencing. This design \nconsideration is temporary, and will be removed in the future, as we allow \"offline mode\"\nfor connected sources. In the future, data from multiple clients can be merged via one of \ntwo Import mechanisms.\n6. Some messages might be missing from the end of the list (e.g. the client is offline and\nthey haven't arrived yet), but we can't have gaps in the middle of the history. It means\nthat the history must always be consistent. If we detect an inconsistency in the hisory\n(e.g. 5 minutes of rest start on a pomodoro in \"new\" state, i.e. we missed the \"work\" state\ncompletely), such inconsistencies result in the parsing failure, crashing the client.\nWe don't try to \"fix\" the history by adding records retroactively. \n7. The history is immutable, but we can create a new one, which is a compressed version\nof the original, as long as it results in the exact same final state of the data model.\n8. If a user tries to delete or complete a workitem in the middle of its own pomodoro, the\ncore will void this pomodoro, emitting correct events.\n9. The history preserves all data, so we don't have to be too careful about deleting things.\nIf a backlog, workitem or user is deleted -- the object simply gets deleted. We don't use\n\"is_deleted\" flags, and we don't move things to \"orphaned\" storage. If we need to restore\na deleted object -- we'll find a way how to do it by processing the history.\n10. Strategies are only executed as a result of users' actions or timer events. Client\nstartup or shutdown won't add any strategies to the history.\n11. The Timer never fires \"in the past\".\n12. All Pomodoros run and end implicitly. They can only be started and voided explicitly.\n"
  },
  {
    "path": "doc/events.md",
    "content": "# Events\n\nWhenever anything changes in the underlying data model, Flowkeeper emits events. To emit an event, the class needs\nto subclass `AbstractEventSource`. All UI updates should be based on those events.\n\n- AbstractEventSource\n  - `BeforeUserCreate(user_identity: str, user_name: str)`, `AfterUserCreate(user: User)`\n  - `BeforeUserDelete(user: User)`, `AfterUserDelete(--//--)`\n  - `BeforeUserRename(user: User, old_name: str, new_name: str)`, `AfterUserRename(--//--)`\n  - `BeforeBacklogCreate(backlog_name: str, backlog_owner: User, backlog_uid: str)`, `AfterBacklogCreate(backlog: Backlog)`\n  - `BeforeBacklogDelete(backlog: Backlog)`, `AfterBacklogDelete(--//--)`\n  - `BeforeBacklogRename(backlog: Backlog, old_name: str, new_name: str)`, `AfterBacklogRename(--//--)`\n  - `BeforeWorkitemCreate(backlog_uid: str, workitem_uid: str, workitem_name: str)`, `AfterWorkitemCreate(workitem: Workitem)`\n  - `BeforeWorkitemComplete(workitem: Workitem, target_state: str)`, `AfterWorkitemComplete(--//--)`\n  - `BeforeWorkitemStart(pomodoro: Pomodoro, workitem: Workitem, work_duration: int)`, `AfterWorkitemStart(--//--)`\n  - `BeforeWorkitemDelete(workitem: Workitem)`, `AfterWorkitemDelete(--//--)`\n  - `BeforeWorkitemRename(workitem: Workitem, old_name: str, new_name: str)`, `AfterWorkitemRename(--//--)`\n  - `BeforePomodoroAdd(workitem: Workitem, num_pomodoros: int)`, `AfterPomodoroAdd(--//--)`\n  - `BeforePomodoroRemove(workitem: Workitem, num_pomodoros: int, pomodoros: List<Pomodoro>)`, `AfterPomodoroRemove(--//--)`\n  - `BeforePomodoroWorkStart(pomodoro: Pomodoro, workitem: Workitem, work_duration: int)`, `AfterPomodoroWorkStart(--//--)`\n  - `BeforePomodoroRestStart(pomodoro: Pomodoro, workitem: Workitem, rest_duration: int)`, `AfterPomodoroRestStart(--//--)`\n  - `BeforePomodoroComplete(pomodoro: Pomodoro, workitem: Workitem, target_state: str)`, `AfterPomodoroComplete`\n  - `SourceMessagesRequested()`, `SourceMessagesProcessed()`\n  - `BeforeMessageProcessed(strategy: AbstractStrategy, auto: Bool)`, `AfterMessageProcessed(--//--)`\n  - `PongReceived(uid: str)`\n\n- AbstractSettings\n  - `BeforeSettingsChanged(old_values: dict[str, str], new_values: dict[str, str])`, `AfterSettingsChanged(--//--)`\n\n- AbstractTableView\n  - `BeforeSelectionChanged(before: AbstractDataItem, after: AbstractDataItem)`, `AfterSelectionChanged(--//--)`\n\n- Application\n  - `AfterFontsChanged(main_font: QFont, header_font: QFont, application: Application)`\n  - `AfterSourceChanged(source: AbstractEventSource)`\n\n- Heartbeat\n  - `WentOnline(ping: int)`, `WentOffline(after: int, last_received: datetime)`\n\n- PomodoroTimer\n  - `TimerTick(timer: PomodoroTimer)`\n  - `TimerWorkStart(timer: PomodoroTimer)`\n  - `TimerWorkComplete(timer: PomodoroTimer)`\n  - `TimerRestComplete(timer: PomodoroTimer, pomodoro: Pomodoro, workitem: Workitem)`\n\nThe listeners can also pass the `carry` parameter. It is used for carrying some metadata through\nthe strategy -- event sequence. For example, when a user creates a workitem, the `CreateWorkitemStrategy`\nis executed with `carry=\"edit\"`, the core data model is updated, and the `WorkitemModel` gets updated, \ntoo. After that, the `AfterWorkitemCreate` event fires, carrying the `edit` parameter. In the \ncorresponding listener, we make the new table row editable, so that the user can update it immediately.\n\nThe mandatory `event` parameter for the callbacks \ncontains the event name.\n"
  },
  {
    "path": "doc/pipeline.md",
    "content": "## CI/CD Pipeline\n\n![Schematics of the Flowkeeper CI/CD pipeline](fk-pipeline.svg \"Flowkeeper CI/CD pipeline\")\n"
  },
  {
    "path": "doc/release.md",
    "content": "# Releasing Flowkeeper\n\n- Run unit and e2e tests in all available VMs.\n- Run the build pipeline and check Windows binaries via Virustotal.\n- Collect screenshots from all supported environments, upload them to website repo.\n- Prepare the release page for the website. Record screenshots and GIFs for new features.\n- Prepare a release announcement for LinkedIn, Reddit, Discord, Telegram, and mailing list.\n- Review CHANGELOG.txt and update the date.\n- Review and merge the rc PR into main.\n- Create a new tag + release in GitHub, mark it as a draft.\n- Wait for the release pipeline to complete.\n- Trigger private Jenkins pipeline to sign Windows binaries.\n- Check the binaries via Virustotal one more time.\n- Remove the \"draft\" flag from GitHub release.\n- Check website -- it should pick up changes automatically.\n- Update download links on the website, if needed.\n- Update OBS repo.\n- Update Flatpak repo.\n- Reply and close related GitHub issues.\n- Distribute the release announcement on LinkedIn, Reddit, Discord, Telegram, and mailing list.\n- Write about new Flowkeeper features in r/kde, r/opensource, r/Windows10, r/Windows11, r/windows, \nr/macapps, r/Python, r/QtFramework, r/debian, r/openSUSE, r/linux, r/pomodoro, r/ProductivityApps.\n\n# Qt6 versions\n\nLast updated: **9 June 2025** for Flowkeeper **1.0.0**.\n\n| OS                    | Released   | EOL        | Python  | Qt 6  | PySide6 | Running options | Comments              |\n|-----------------------|------------|------------|---------|-------|---------|-----------------|-----------------------|\n| Debian Bullseye 11    | 2021-08-14 | 2024-08-14 | 3.9     | N/A   |         |                 | 6.4.2 is in backports |\n| Debian Bookworm 12    | 2023-06-10 | 2026-06-10 | 3.11    | 6.4.2 |         |                 |                       |\n| Debian Trixie 13      | 2025-..-.. | N/A        | 3.12    | 6.8.2 |         |                 |                       |\n| Debian Sid            | N/A        | N/A        | 3.13    | 6.8.2 |         |                 |                       |\n| Ubuntu Focal 20.04    | 2020-04-23 | 2025-05-29 | 3.8.2   | N/A   |         |                 |                       |\n| Ubuntu Jammy 22.04    | 2022-04-21 | 2027-06-01 | 3.10.6  | 6.2.4 |         |                 |                       |\n| Ubuntu Noble 24.04    | 2024-04-25 | 2029-05-31 | 3.12.3  | 6.4.2 |         |                 |                       |\n| Ubuntu Oracular 24.10 | 2024-10-10 | 2025-07-.. | 3.12.6  | 6.6.2 |         |                 |                       |\n| Ubuntu Plucky 25.04   | 2025-04-17 | 2026-01-.. | 3.13.3  | 6.8.3 |         |                 |                       |\n| Fedora 40             | 2024-04-23 | 2025-05-13 | 3.12.3  | 6.6.2 |         |                 |                       |\n| Fedora 41             | 2024-10-29 | 2025-11-19 | 3.13.0  | 6.7.2 |         |                 |                       |\n| Fedora 42             | 2025-04-15 | 2026-05-16 | 3.13.2  | 6.8.2 |         |                 |                       |\n| RHEL 8                | 2019-05-07 | 2029-..    | 3.6     | N/A   |         |                 |                       |\n| RHEL 9                | 2022-05-17 | 2032-..    | 3.12.9  | N/A   |         |                 |                       |\n| RHEL 10               | 2025-05-20 | 2035-..    | 3.12.9  | 6.8.1 |         |                 |                       |\n| openSUSE Leap 15.6    | 2024-06-10 | 2025-12-.. | 3.11    | 6.6.3 |         |                 |                       |\n| openSUSE Tumbleweed   | N/A        | N/A        | 3.13    | 6.9.0 |         |                 |                       |\n| Slackware 15          | 2022-02-02 | N/A        | 3.9.10  | N/A   |         |                 |                       |\n| Slackware Current     | N/A        | N/A        | 3.12.10 | 6.8.3 |         |                 |                       |\n\nHere *Running options* are:\n- **S**: Run directly from source (git clone, generate-resources.sh, run.sh)\n- **V**: Run from source in a Python Virtual Environment (venv)\n- **D**: Flowkeeper is available in a distro packages repository\n- **3**: Flowkeeper is available in a 3rd-party packages repository\n- **P**: A portable binary or standalone ZIP from flowkeeper.org\n- **I**: An installer (e.g. DEB) from flowkeeper.org\n- **F**: Flatpak (org.flowkeeper.Flowkeeper on Flathub)\n- **A**: AppImage from flowkeeper.org works\n\nThis table needs to be updated with:\n1. PySide6 versions\n2. Shell command to install those\n3. How to run Flowkeeper there (see running options above)\n"
  },
  {
    "path": "doc/strategies.md",
    "content": "# Strategies\n\nYou may find those as _Strategies_ in the code. They correspond to the end-user actions /\ndata mutations. Each command takes two or three parameters.\n\nAll data objects are keyed with `UID`, which is an arbitrary string, typically a GUID.\n\nApart from \"business data\", each command has a few metadata fields, associated with it:\n- Sequence number, used for ordering and checking uniqueness\n- Execution timestamp\n- Execution user\n\n## User strategies\n\nNote that users have emails as IDs.\n\n- `CreateUser(\"<EMAIL>\", \"<USER_NAME>\")` - Fails if a user with this email already \nexists, or a non-System user tries to execute this strategy. Emits `BeforeUserCreate` / \n`AfterUserCreate` events.\n- `DeleteUser(\"<EMAIL>\", \"\")` - Deletes a user **recursively**, i.e. executes\n`DeleteBacklogStrategy` for each of the child backlogs. Fails if a user with a given \nemail is not found, if a non-System user tries to execute this strategy, or if we are\ntrying to delete a System user. Emits `BeforeUserDelete` / `AfterUserDelete` events.\n- `RenameUser(\"<EMAIL>\", \"<NEW_NAME>\")` - Fails if a user with a given email is not found,\nif a non-System user tries to execute this strategy, or if we are trying to rename a System \nuser. Emits `BeforeUserRename` / `AfterUserRename` events.\n\n## Backlog strategies\n\n- `CreateBacklog(\"<UID>\", \"<BACKLOG_NAME>\")` - Fails if a backlog with this UID already \nexists for the calling user. Emits `BeforeBacklogCreate` / `AfterBacklogCreate` events.\n- `DeleteBacklog(\"<UID>\", \"\")` - Deletes a backlog **recursively**, i.e. executes\n`DeleteWorkitemStrategy` for each of the child workitems. Fails if a backlog with a given \nUID is not found for the calling user. Emits `BeforeBacklogDelete` / `AfterBacklogDelete` \nevents.\n- `RenameBacklog(\"<UID>\", \"<NEW_NAME>\")` - Fails if a backlog with a given UID is not found \nfor the calling user. Emits `BeforeBacklogRename` / `AfterBacklogRename` events.\n\n## Workitem strategies\n\n- `CreateWorkitem(\"<WORKITEM_UID>\", \"<BACKLOG_UID>\", \"<WORKITEM_NAME>\")` - Fails if a backlog \nwith this UID is not found or if a workitem with this UID already exists in that backlog. \nEmits `BeforeWorkitemCreate` / `AfterWorkitemCreate` events.\n- `DeleteWorkitem(\"<UID>\", \"\")` - Deletes a workitem **recursively**, i.e. executes\n`VoidPomodoroStrategy` for each of the running pomodoros first. Fails if a workitem with a given \nUID is not found in any backlog. Emits `BeforeWorkitemDelete` / `AfterWorkitemDelete` events.\n- `RenameWorkitem(\"<UID>\", \"<NEW_NAME>\")` - Fails if a workitem with a given UID is not found in\nany backlog or if it is sealed (finished or canceled). Doesn't do anything if the new name is \nidentical to the old one, otherwise emits `BeforeWorkitemRename` / `AfterWorkitemRename` events.\n- `CompleteWorkitem(\"<UID>\", \"<STATE>\")` - Seals the workitem with a given state (`finished` or \n`canceled`) **recursively**, i.e. executes `VoidPomodoroStrategy` for each of the running \npomodoros, if any. Fails if a workitem with a given UID is not found in any backlog, if the \ntarget state is neither `finished` nor `canceled`, or if the workitem is already sealed. Emits \n`BeforeWorkitemComplete` / `AfterWorkitemComplete` events.\n\n## Pomodoro strategies\n\nIndividual pomodoros don't have their own UIDs for simplicity. Although UIDs exist in runtime,\nthey are generated on the fly and not persisted.\n\n- `AddPomodoroStrategy(\"<WORKITEM_UID>\", \"<ADDED_COUNT>\")` - Fails if the number of added \npomodoros is less than 1, or if the workitem with specified UID is not found or sealed. Emits \n`BeforePomodoroAdd` / `AfterPomodoroAdd` events.\n- `RemovePomodoroStrategy(\"<WORKITEM_UID>\", \"<REMOVED_COUNT>\")` - Fails if the number of removed \npomodoros is less than 1, or if the workitem with specified UID is not found or sealed, or if \nthere's not enough startable (`new` state) pomodoros in the workitem. Emits \n`BeforePomodoroRemove` / `AfterPomodoroRemove` events.\n- `VoidPomodoroStrategy(\"<WORKITEM_UID>\")` - Fails if the workitem with specified UID is not \nfound or sealed, or has no running pomodoros. Emits `BeforePomodoroComplete` / \n`AfterPomodoroComplete` events with target state `canceled`.\n- `StartWorkStrategy(\"<WORKITEM_UID>\", \"<WORK_DURATION_IN_SECONDS>\", \"<REST_DURATION_IN_SECONDS>\")` - \nFails if the workitem with specified UID is not found or sealed, or has no startable (`new`) pomodoros. \nIf the specified work duration is `0`, then the default value at the pomodoro creation moment is used. \nIf a Workitem is not yet running, it switches into `running` state, emitting a pair of \n`BeforeWorkitemStart` / `AfterWorkitemStart` events. If the specified rest duration is `0`, then the \ndefault value at the pomodoro creation moment is used.As long as it doesn't fail, this strategy \nemits `BeforePomodoroWorkStart` / `AfterPomodoroWorkStart` events.\n\n\"Internal\" strategies, triggered by the timer and auto-seal mechanism. Those are not registered\nin the strategy factory / event sources, thus cannot appear in persisted form:\n\n- `FinishPomodoroInternalStrategy(\"<WORKITEM_UID>\")` - Fails if the workitem with specified UID is not \nfound or sealed, or has no running pomodoros. Emits `BeforePomodoroComplete` / \n`AfterPomodoroComplete` events with target state `finished`.\n- `StartRestInternalStrategy(\"<WORKITEM_UID>\")` - Fails if the workitem \nwith specified UID is not found or is not running, or has no in-work (`work` state) pomodoros. \nEmits `BeforePomodoroRestStart` / `AfterPomodoroRestStart` events.\n\n## Server strategies\n\nAll below strategies are used with server-based event sources only, and are not persisted.\n\n- `Authenticate(\"<EMAIL>\", \"<TOKEN>\")` - This must be the first strategy sent by the client to \nthe server, otherwise the latter closes the communication channel. Note that it doesn't specify \ntoken's format, leaving it to the authentication implementation.\n- `Replay(\"<AFTER_SEQUENCE>\")` - Used for requesting the replay of the strategies from the\nserver, starting from, but not including, `#AFTER_SEQUENCE`. The server may respond with one\nor more messages with event history. \n- `ReplayCompleted(\"\")` - Used by the server to signal the last strategy in the replayed list.\n- `Error(\"<ERROR_CODE>\", \"<ERROR_MESSAGE>\")` - Sent by the server to report an error, e.g. wrong\ncredentials passed to `Authenticate` strategy. Flowkeeper Desktop raises a UI exception when\nexecuting this strategy. This results in a message popup and a request to file a bug in GitHub.\n- `PingStrategy(\"<UID>\", \"\")` - The client sends this to verify connection to the server. It\nexpects to receive a `PongStrategy` response with the matching UID immediately after. If the\nclient doesn't receive a pong in a timely matter, it should switch to Offline / read-only mode.\n- `PongStrategy(\"<UID>\", \"\")` - Sent by the server as a reply to `PingStrategy`. If an Offline \nclient receives a matching pong, it should switch back to Online mode.\n"
  },
  {
    "path": "requirements-test.txt",
    "content": "-r requirements.txt\ncoverage\ncoveralls\nassertpy\nunittest-xml-reporting\npillow\n"
  },
  {
    "path": "requirements.txt",
    "content": "# Stay at 6.7.0 due to a macOS bug in 6.7.1 and 6.7.2, which says \"No QtMultimedia backends found\".\n# 6.7.3 has another regression.\n# PySide6==6.8.1\nPySide6\nsemantic-version\ncryptography\nkeyring\n"
  },
  {
    "path": "res/CHANGELOG.txt",
    "content": "### v1.0.2 (26 September 2025)\n\nThis version addresses a couple of new bugs, one of which is quite annoying:\n\n- Can't void a pomodoro or record an interruption in non-latest backlogs (#199, #195).\n- An occasional exception when waking up from sleep (#197).\n\n### v1.0.1 (30 August 2025)\n\nThis version addresses all known bugs that we had in GitHub:\n\n- Miscellaneous font issues (#136).\n- Backlogs toggle icon doesn't change its color on theme change (#138).\n- Voiding a pomodoro that has already finished (#149).\n- Focus mode lost window title on Wayland (#152).\n- Timezone not handled in task tooltips (#156).\n- Tutorial bubbles don't follow underlying windows (#162).\n- Pin/unpin icon is visible on Wayland (#185).\n- Unhandled TypeError while clicking \"Surprise me\" button repeatedly (#188).\n- \"Long break\" keeps on triggering while it is deactivated in the configuration (#192).\n\nAlso, Windows binaries are now compiled using Clang to reduce antivirus false positives.\n\n### v1.0.0 (14 July 2025)\n\nThis version brings Flowkeeper even closer to the original Pomodoro Technique definition from Francesco Cirillo's book.\nPomodoro interruptions are now handled as the book prescribes, and we introduced support for long breaks. One major\ndeviation from the Technique in this version is the ability to track unfocused time. As usual there's a bunch of\nmiscellaneous quality-of-life improvements, bugfixes and better support for Linux.\n\nNew features:\n\n- Tracking unfocused time -- try to start a work item with no pomodoros (#94, #98).\n- Long breaks and working in series, see Settings > Series and breaks (#53).\n- Dragging work items between backlogs (#60).\n- Recording interruptions (#75).\n- Voided pomodoros are displayed as ticks, and completed ones are crossed out to better match the Book (#41, #92).\n- Import from CSV and GitHub, try Ctrl+I (#125).\n- Hovering over pomodoros displays a detailed log of your work (#93).\n- \"Contact us\" submenu to facilitate feedback collection (#111).\n- Flowkeeper window now hides automatically on auto-start (#102).\n- New font selector for macOS, which supports Apple system font (#113).\n- Resting music starts playing from the right position if we restart Flowkeeper.\n\nTechnical improvements:\n\n- Standard data and log directories on Linux, macOS and Windows (#65).\n- Three new ways to get Flowkeeper:\n - Install it from Flathub (#63),\n - Install from OBS for openSUSE Tumbleweed (#127),\n - Download a portable AppImage from flowkeeper.org or GitHub Releases.\n- Flowkeeper now supports Linux on arm64 / aarch64.\n- Added support for Qt 6.8.x.\n- A preview of Flowkeeper CLI is now available in the sources (#46).\n- Ignoring own modifications when \"watch changes\" is enabled (#130).\n- Improved technical documentation (#80, #87).\n- Complete drag & drop rewrite to accommodate moving work items between backlogs.\n- Flowkeeper binaries are now built using Nuitka in addition to PyInstaller (#114).\n- Madelene music composition changed its format from mp3 to m4a/aac.\n- Removed support for Ubuntu 20.04, as GitHub Actions deprecated it.\n\nBug fixes:\n\n- Selecting directory as log file (#108).\n- Window icon on Wayland (#110).\n- Line breaks in work items and backlogs (#132).\n- Changing audio devices while Flowkeeper is running (#120).\n\n### v0.9.1 (15 January 2025)\n\nThis is a bugfix release, it has no new features.\n\n- [Bugfix] Main window doesn't restore correctly on Hyprland (#48).\n- [Bugfix] Unhandled SystemError if Flowkeeper is upgraded while a pomodoro is running (#62).\n- [Bugfix] Broken fonts / squares instead of characters on Ubuntu 23.10 (#68).\n- [Bugfix] \"Unhandled JSONDecodeError\" behind the proxy (#69, #73).\n- [Bugfix] Flowkeeper crashes when you select a directory as a data file (#70).\n- [Bugfix] Error when trying to start another pomodoro while the timer is running (#72, #74).\n- [Bugfix] On Windows, the main window close button is disabled (#77).\n- [Bugfix] Flowkeeper doesn't switch to focus mode after one completed pomodoro (#79).\n- [Bugfix] Unhandled AttributeError when computer wakes up from sleep while playing audio (#81).\n- [Bugfix] There's no sound until you change Audio settings once (#85).\n- [Bugfix] Able to click \"next pomodoro\" after marking workitem complete (#88).\n- [Technical] Enhanced bug reporting - GitHub issues now include info about versions.\n- [Technical] New command-line flag: --debug, enables debug logs for this session.\n- [Technical] System proxy settings are applied automatically.\n- [Technical] Using embedded Noto Sans font by default, see Settings > Fonts.\n\n### v0.9.0 (30 December 2024)\n- You can now drag backlogs and work items to reorder them. Backlogs are not reordered automatically anymore.\n- Unplanned work items are now highlighted with asterisk (*).\n- You can now choose between two timer widgets -- \"Classic\" and \"Minimalistic\".\n- Quick configuration wizard when you open Flowkeeper for the first time.\n- Moved \"Hide completed items\" to the toolbar (#42).\n- In \"minimalistic\" mode all focus mode actions are under the \"stopwatch\" menu.\n- You can now configure Flowkeeper to execute programs on different events, see Settings > Integration (#40).\n- All buttons and icons now have tooltips with shortcuts in them (#43, #52).\n- Journaling-friendly Work Summary (F3) -- more output formats and enhanced usability (#45, #49).\n- New setting: General > Single Flowkeeper instance (#50).\n- Miscellaneous UI improvements, notably new tray icon visualization (#44, #51, #39).\n- You can now enable main menu in Settings > Appearance.\n- [Bugfix] Couldn't open Settings when there's no audio devices (#38).\n\n### v0.8.1 (13 November 2024)\n- [Bugfix] Fixed a bug in tutorial step 8.\n\n### v0.8.0 (4 November 2024)\n- You can now use #tags in work items. You can turn it off in Settings > General.\n- When #tags are enabled, work item text wraps if it doesn't fit on one line. This is the default behavior.\n- Embedded resting music - \"Madelene\" by Lobo Loco, with kind permission from its author (CC-BY-NC-ND).\n- Selectable audio output in Settings > Audio.\n- [Technical] Flowkeeper displays window title in the focus mode on Wayland automatically.\n- [Technical] StartWork strategy now also carries planned rest duration.\n- [Technical] Smart import now preserves timestamps and history.\n- [Technical] We can now import stuff which happened in the past.\n- [Bugfix] Fixed \"user already exists\" import error.\n- [Bugfix] Fixed incorrect pomodoro states after import.\n- [Bugfix] Fixed incorrect timestamps in smart import.\n- [Bugfix] Fixed incorrect work / rest durations in smart import.\n\n### v0.7.1 (13 September 2024)\n- [Bugfix] Fixed import/export wizard look & feel on Windows 11 with Qt 6.7.x.\n- [Bugfix] Fixed \"No QtMultimedia backends found\" issue on macOS.\n\n### v0.7.0 (10 September 2024)\n- Disabled data sync and e2e encryption features.\n- Removed public references to any semi-implemented features.\n- Documented the full manual UAT test suite and executed it.\n- The hole in the timer widget now displays correctly.\n- New \"Compress\" action in the File source settings (click it for details).\n- New \"Detect automatically (Default)\" theme.\n- Added \"New Backlog From Incomplete\" action to speed up planning.\n- New \"Work Summary\" feature for making human-readable reports like time sheets.\n- Special appearance defaults for Gnome to match its quirks.\n- Minor improvements in Statistics (different bar colors, better theming)\n- [Bugfix] Fixed the 00:00 bug when the user opened the client right when the work ends.\n- [Bugfix] Fixed a rare bug when we tried to read a file which was written at the same time.\n- [Bugfix] Fixed an \"Invalid key\" bug in Import.\n- [Bugfix] Fixed a bug where workitem actions' visibility was wrong after external changes.\n- [Technical] Removed CompletePomodoro and StartRest strategies.\n- [Technical] Removed \"Auto-seal items after\" setting.\n- [Technical] Split the README.md into several files.\n\n### v0.6.3 (1 September 2024)\n- Added settings to disable data sync and e2e encryption features\n- [Bugfix] Fixed \"Sign in\" button on some Linux, e.g. Debian Sid.\n- [Technical] GitHub pipeline now signs and notarizes macOS builds.\n- [Technical] GitHub pipeline now signs Windows binaries automatically.\n- [Technical] A macOS build for x86.\n- [Technical] Simplified the code for events prioritization.\n- [Technical] Listed all testable Use Cases.\n- [Technical] Created a farm of VMs for running e2e tests, tested as-is.\n\n### v0.6.2 (1 August 2024)\n- [Bugfix] OAuth login on Windows (QTBUG-124333).\n\n### v0.6.1 (31 July 2024)\n- Brand new interactive tutorial.\n- The window loses its frame in Focus mode, with double-click and move.\n- The window can be pinned to stay always-on-top.\n- New appearance settings (always on top, window title in focus mode).\n- Users may now ignore \"A keyring is not available\" error.\n- [Technical] macOS DMG installer is now properly signed and notarized.\n- [Technical] macOS now asks to unlock login keychain only once.\n- [Technical] Screenshots collection via e2e tests.\n- [Technical] Enabled SonarCloud static code analysis + \"code smells\" GitHub badge.\n\n### v0.6.0 (21 June 2024)\n- Implemented the Statistics feature AKA Pomodoro Health.\n- [Bugfix] Fixed NoKeyringError on Kubuntu 22.04.\n- [Technical] Removed dependency on typing.Self, which required Python 3.11+.\n\n### v0.5.1 (14 June 2024)\n- Simplified Connection settings.\n- Gradient can be now selected in the Settings popup.\n- [Bugfix] Fixed an error with the passwords stored in the OS keychain.\n- [Bugfix] Fixed Settings not detecting the changes correctly.\n- [Technical] Now the user can choose to ignore errors for the Local File data source.\n- [Technical] Buttons like \"Sign out\" can now change Settings window state.\n- [Technical] Alternative keyrings (keyrings.alt package) are added as a fallback.\n\n### v0.5.0 (9 June 2024)\n- Support for end-to-end encryption using Fernet (mandatory for flowkeeper.org).\n- Encrypted content can be mixed with plaintext.\n- Support for encryption and compression in Import and Export.\n- Import now works in a new \"smart merge\" mode, which should prevent duplicate pomodoros.\n- You can now sign up to flowkeeper.org from the desktop app.\n- You can now request flowkeeper.org account deletion from Settings.\n- You can now change appearance theme on the fly.\n- New color themes.\n- New fonts can now be applied without application restart.\n- You can now start another pomodoro by clicking the lime \"play\" icon in the system tray.\n- New Focus-mode button to complete the current item while it's running.\n- [Bugfix] Fixed workitem search.\n- [Bugfix] Fixed \"Start another pomodoro?\" logic.\n- [Bugfix] Modal dialogs open shrunk on secondary displays on Linux.\n- [Technical] Restructured the resources, icons and colors.\n- [Technical] Sharing timer state with teammates (no support in the UI yet).\n- [Technical] Introduced Serializer and Cryptograph facilities.\n- [Technical] Simplified tray icon and Focus view implementation.\n- [Technical] E2e tests handle exceptions correctly now.\n- [Technical] Introduced a configurable logger.\n- [Technical] New facility for creating timer displays - AbstractTimerDisplay.\n- [Technical] Migrated to the latest Qt 6.7.\n- [Technical] The secrets are now stored in the OS keychain.\n\n### v0.4.1 (25 April 2024)\n- Added configurable toolbars.\n- Improved performance of changing the data source when there are large backlogs.\n- [Bugfix] Fixed the actions state update.\n- [Bugfix] Fixed binary builds for Wayland-enabled Linux. Now 22.04 is the minimal supported Ubuntu version.\n- [Technical] Started working on the e2e test farm triggered from the GitHub pipeline.\n- [Technical] Added an \"info overlay\" feature, which can be used for walkthroughs. Disabled the Tutorial by default.\n- [Technical] Upgraded to Qt 6.7.0.\n- [Technical] Release pipeline now runs unit tests.\n\n### v0.4.0 (18 April 2024)\n- Changes to data source settings do not require Flowkeeper restart.\n- Some rare and complex bugs are now fixed thanks to improved Event Source handling.\n- [Technical] Rest and work durations are now floating-point numbers.\n- [Technical] Windows dev. environment is now supported and documented.\n- [Technical] First working end-to-end tests providing 61% UI code coverage.\n- [Technical] Unit test coverage for `fk.core` module increased to 68%.\n\n### v0.3.3 (5 April 2024)\n- Added a tutorial / quickstart wizard.\n- If something goes terribly wrong on startup, Flowkeeper asks to reset the settings.\n- Removed deprecated settings and the progress bar, which looked wrong on Windows.\n- All table items now have tooltips.\n- When installed on Windows or macOS, Flowkeeper now launches much faster.\n- CI/CD pipeline now also builds a DEB installer for Debian-based systems.\n- Ubuntu 20.04 is now supported (used to require 22.04+)\n- Minor improvements to the Settings UI.\n- [Technical] Settings are now set in bulk.\n- [Technical] Gradient generator now has a fallback, just in case.\n- [Technical] Wrote new unit tests.\n- [Technical] Implemented Ephemeral event source, useful for the unit tests.\n- [Technical] Started working on end-to-end (e2e) tests.\n- [Technical] CI pipeline now collects unit test coverage.\n- [Bugfix] Fixed gradient generation which was failing on Windows and Mac sometimes.\n- [Bugfix] Timer window now resizes correctly on Windows.\n- [Bugfix] Void Pomodoro button is now displayed correctly.\n\n### v0.3.2 (14 March 2024)\n- Flowkeeper checks for updates against GitHub (configurable).\n- Flowkeeper shuts down automatically when important settings are changed.\n- The filter for completed items is now stored in Settings.\n- [Bugfix] Backlog auto-ordering is corrected.\n- [Bugfix] Completed items now hide immediately if the filter is enabled.\n- [Technical] App version is parsed from the changelog.\n- [Technical] Event sequence errors are ignored by default.\n- [Technical] New troubleshooting mechanism (\"backlog dump\") via Ctrl+D.\n\n### v0.3.1 (10 March 2024)\n- Flowkeeper.org authentication via Google OAuth.\n- [Known bug] The app crashes after you authenticate with Google. Just restart it.\n- [Technical] Pomodoros expired offline now finish successfully.\n- [Technical] All pomodoros now complete implicitly. CompletePomodoro strategy is deprecated.\n\n### v0.2.1 (22 February 2024)\n- Fixed work item actions visibility\n- Keyboard shortcuts are now configurable in Settings\n\n### v0.2.0 (20 February 2024)\n- Support for gradient timer header background.\n- Better resizing for the header background images.\n- Better error handling with \"Submit an Issue\" option.\n- Placeholders for empty tables.\n- Asynchronous event sources to support \"Loading...\" mode.\n- Backlog progress bar is now updated automatically on pomodoro actions.\n- Window configuration is now persisted.\n- Import errors can be now ignored.\n- Server connection state in the window title + icon.\n- Websocket heartbeat to keep connection alive.\n- New settings for sounds volume.\n- [Technical] Updated documentation.\n- [Technical] Major UI code refactoring, should make the app easier to maintain.\n- [Technical] Mini-apps to test UI components independently.\n- [Technical] Moved Actions from .ui file to the code.\n\n### v0.1.3 (25 December 2023)\n- Multiple bugfixes.\n- Configurable fonts and timer header background.\n- Themes: Light, Dark and Mixed.\n\n### v0.1.2, (11 December 2023)\n- Unit tests.\n- Websocket authentication.\n\n### v0.1.1, (6 December 2023)\n- Packaged for Windows, Mac and Linux.\n\n### v0.1.0, (27 November 2023)\n- First public version.\n"
  },
  {
    "path": "res/CREDITS.txt",
    "content": "Constantine Kulak <https://github.com/co-stig>: Flowkeeper author.\n\nMarco Sarti <marco@elogiclab.com>: AUR package maintainer.\n\nfaveoled <https://github.com/faveoled>: Created the first Flatpak package for Flowkeeper, which we adopted.\n\nFlowkeeper is built using Qt Community Edition by The Qt Company <https://www.qt.io/licensing>.\n\nAll monochrome icons, including Pomodoro symbols are Google Material Icons <https://fonts.google.com/icons>.\n\nFlowkeeper logo (red tomato icon): \"Tomate\" by Andreas Preuss AKA marauder <https://openclipart.org/detail/117469/tomate>.\n\nPomodoro and Pomodoro Technique are registered trademarks of Francesco Cirillo <https://www.pomodorotechnique.com/pomodoro-trademark-guidelines.php>.\n\nEmbedded sounds (bell and tick): Cannot find the original author. I used the same sounds in the original Flowkeeper (v1) in 2010.\n\nEmbedded resting music: Madelene (ID 1315), kindly provided by Lobo Loco <https://www.musikbrause.de> under CC-BY-NC-ND (Creative Commons Attribution NonCommercial NoDerivs).\n\nFlowkeeper is hosted on GitHub <https://github.com/flowkeeper-org/fk-desktop>, with its CI/CD based on GitHub Actions.\n\nWe use Coverage.py by Ned Batchelder <https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt> for collecting unit test coverage.\n\nThe build pipeline automatically uploads test coverage results to coveralls.io under its \"Open Source\" plan <https://coveralls.io/terms>.\n\nSonarCloud (Free Edition) <https://sonarcloud.io> provides us useful code quality metrics and the \"code smells\" GitHub badge.\n\nJetBrains PyCharm (Community Edition) <https://www.jetbrains.com/pycharm/> is our IDE of choice.\n\nFlowkeeper uses \"python-semanticversion\" library by Raphaël Barrois <https://github.com/rbarrois/python-semanticversion/blob/master/LICENSE> to check for updates.\n\nFlowkeeper uses \"keyring\" library by Jason R. Coombs <https://github.com/jaraco/keyring?tab=MIT-1-ov-file#readme> for storing secrets in the OS-native keychain / secret storage.\n\nFlowkeeper uses \"cryptography\" library by Paul Kehrer <https://github.com/pyca/cryptography/blob/main/LICENSE> for its Fernet end-to-end encryption algorithm.\n\nFlowkeeper uses \"assertpy\" library by Activision Publishing, Inc. <https://github.com/assertpy/assertpy/blob/main/LICENSE> and \"unittest-xml-reporting\" by\nDaniel Fernandes Martins <https://github.com/xmlrunner/unittest-xml-reporting/blob/master/LICENSE> for writing and executing unit tests.\n\nPython Pillow library by Jeffrey A. Clark and contributors <https://github.com/python-pillow/Pillow/blob/main/LICENSE> is used for taking screenshots in the end-to-end tests.\n\nTo produce desktop binaries for Linux, macOS and Windows we use PyInstaller <https://github.com/pyinstaller/pyinstaller?tab=License-1-ov-file#readme>.\n\nThe Windows installer is created using Inno Setup by Jordan Russell <https://jrsoftware.org/files/is/license.txt>.\n\nThe Debian installer is created using dpkg-deb tool <https://www.apt-browse.org/browse/ubuntu/bionic/main/amd64/dpkg/1.19.0.5ubuntu2/file/usr/share/doc/dpkg/copyright>.\n\nThe embedded font is Noto Sans, licensed under the SIL Open Font License, Version 1.1. Copyright 2022 The Noto Project Authors <https://github.com/notofonts/latin-greek-cyrillic>.\n\nLinux package repositories are provided by (awesome!) openSUSE Build Service <https://build.opensuse.org/>.\n"
  },
  {
    "path": "res/LICENSE.txt",
    "content": "GNU General Public License\n==========================\n\n_Version 3, 29 June 2007_\n_Copyright © 2007 Free Software Foundation, Inc. &lt;<http://fsf.org/>&gt;_\n\nEveryone is permitted to copy and distribute verbatim copies of this license\ndocument, but changing it is not allowed.\n\n## Preamble\n\nThe GNU General Public License is a free, copyleft license for software and other\nkinds of works.\n\nThe licenses for most software and other practical works are designed to take away\nyour freedom to share and change the works. By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change all versions of a\nprogram--to make sure it remains free software for all its users. We, the Free\nSoftware Foundation, use the GNU General Public License for most of our software; it\napplies also to any other work released this way by its authors. You can apply it to\nyour programs, too.\n\nWhen we speak of free software, we are referring to freedom, not price. Our General\nPublic Licenses are designed to make sure that you have the freedom to distribute\ncopies of free software (and charge for them if you wish), that you receive source\ncode or can get it if you want it, that you can change the software or use pieces of\nit in new free programs, and that you know you can do these things.\n\nTo protect your rights, we need to prevent others from denying you these rights or\nasking you to surrender the rights. Therefore, you have certain responsibilities if\nyou distribute copies of the software, or if you modify it: responsibilities to\nrespect the freedom of others.\n\nFor example, if you distribute copies of such a program, whether gratis or for a fee,\nyou must pass on to the recipients the same freedoms that you received. You must make\nsure that they, too, receive or can get the source code. And you must show them these\nterms so they know their rights.\n\nDevelopers that use the GNU GPL protect your rights with two steps: **(1)** assert\ncopyright on the software, and **(2)** offer you this License giving you legal permission\nto copy, distribute and/or modify it.\n\nFor the developers' and authors' protection, the GPL clearly explains that there is\nno warranty for this free software. For both users' and authors' sake, the GPL\nrequires that modified versions be marked as changed, so that their problems will not\nbe attributed erroneously to authors of previous versions.\n\nSome devices are designed to deny users access to install or run modified versions of\nthe software inside them, although the manufacturer can do so. This is fundamentally\nincompatible with the aim of protecting users' freedom to change the software. The\nsystematic pattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable. Therefore, we have designed\nthis version of the GPL to prohibit the practice for those products. If such problems\narise substantially in other domains, we stand ready to extend this provision to\nthose domains in future versions of the GPL, as needed to protect the freedom of\nusers.\n\nFinally, every program is threatened constantly by software patents. States should\nnot allow patents to restrict development and use of software on general-purpose\ncomputers, but in those that do, we wish to avoid the special danger that patents\napplied to a free program could make it effectively proprietary. To prevent this, the\nGPL assures that patents cannot be used to render the program non-free.\n\nThe precise terms and conditions for copying, distribution and modification follow.\n\n## TERMS AND CONDITIONS\n\n### 0. Definitions\n\n“This License” refers to version 3 of the GNU General Public License.\n\n“Copyright” also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n“The Program” refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as “you”. “Licensees” and\n“recipients” may be individuals or organizations.\n\nTo “modify” a work means to copy from or adapt all or part of the work in\na fashion requiring copyright permission, other than the making of an exact copy. The\nresulting work is called a “modified version” of the earlier work or a\nwork “based on” the earlier work.\n\nA “covered work” means either the unmodified Program or a work based on\nthe Program.\n\nTo “propagate” a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for infringement under\napplicable copyright law, except executing it on a computer or modifying a private\ncopy. Propagation includes copying, distribution (with or without modification),\nmaking available to the public, and in some countries other activities as well.\n\nTo “convey” a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through a computer\nnetwork, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays “Appropriate Legal Notices” to the\nextent that it includes a convenient and prominently visible feature that **(1)**\ndisplays an appropriate copyright notice, and **(2)** tells the user that there is no\nwarranty for the work (except to the extent that warranties are provided), that\nlicensees may convey the work under this License, and how to view a copy of this\nLicense. If the interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n### 1. Source Code\n\nThe “source code” for a work means the preferred form of the work for\nmaking modifications to it. “Object code” means any non-source form of a\nwork.\n\nA “Standard Interface” means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of interfaces\nspecified for a particular programming language, one that is widely used among\ndevelopers working in that language.\n\nThe “System Libraries” of an executable work include anything, other than\nthe work as a whole, that **(a)** is included in the normal form of packaging a Major\nComponent, but which is not part of that Major Component, and **(b)** serves only to\nenable use of the work with that Major Component, or to implement a Standard\nInterface for which an implementation is available to the public in source code form.\nA “Major Component”, in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system (if any) on which\nthe executable work runs, or a compiler used to produce the work, or an object code\ninterpreter used to run it.\n\nThe “Corresponding Source” for a work in object code form means all the\nsource code needed to generate, install, and (for an executable work) run the object\ncode and to modify the work, including scripts to control those activities. However,\nit does not include the work's System Libraries, or general-purpose tools or\ngenerally available free programs which are used unmodified in performing those\nactivities but which are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for the work, and\nthe source code for shared libraries and dynamically linked subprograms that the work\nis specifically designed to require, such as by intimate data communication or\ncontrol flow between those subprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users can regenerate\nautomatically from other parts of the Corresponding Source.\n\nThe Corresponding Source for a work in source code form is that same work.\n\n### 2. Basic Permissions\n\nAll rights granted under this License are granted for the term of copyright on the\nProgram, and are irrevocable provided the stated conditions are met. This License\nexplicitly affirms your unlimited permission to run the unmodified Program. The\noutput from running a covered work is covered by this License only if the output,\ngiven its content, constitutes a covered work. This License acknowledges your rights\nof fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not convey, without\nconditions so long as your license otherwise remains in force. You may convey covered\nworks to others for the sole purpose of having them make modifications exclusively\nfor you, or provide you with facilities for running those works, provided that you\ncomply with the terms of this License in conveying all material for which you do not\ncontrol copyright. Those thus making or running the covered works for you must do so\nexclusively on your behalf, under your direction and control, on terms that prohibit\nthem from making any copies of your copyrighted material outside their relationship\nwith you.\n\nConveying under any other circumstances is permitted solely under the conditions\nstated below. Sublicensing is not allowed; section 10 makes it unnecessary.\n\n### 3. Protecting Users' Legal Rights From Anti-Circumvention Law\n\nNo covered work shall be deemed part of an effective technological measure under any\napplicable law fulfilling obligations under article 11 of the WIPO copyright treaty\nadopted on 20 December 1996, or similar laws prohibiting or restricting circumvention\nof such measures.\n\nWhen you convey a covered work, you waive any legal power to forbid circumvention of\ntechnological measures to the extent such circumvention is effected by exercising\nrights under this License with respect to the covered work, and you disclaim any\nintention to limit operation or modification of the work as a means of enforcing,\nagainst the work's users, your or third parties' legal rights to forbid circumvention\nof technological measures.\n\n### 4. Conveying Verbatim Copies\n\nYou may convey verbatim copies of the Program's source code as you receive it, in any\nmedium, provided that you conspicuously and appropriately publish on each copy an\nappropriate copyright notice; keep intact all notices stating that this License and\nany non-permissive terms added in accord with section 7 apply to the code; keep\nintact all notices of the absence of any warranty; and give all recipients a copy of\nthis License along with the Program.\n\nYou may charge any price or no price for each copy that you convey, and you may offer\nsupport or warranty protection for a fee.\n\n### 5. Conveying Modified Source Versions\n\nYou may convey a work based on the Program, or the modifications to produce it from\nthe Program, in the form of source code under the terms of section 4, provided that\nyou also meet all of these conditions:\n\n* **a)** The work must carry prominent notices stating that you modified it, and giving a\nrelevant date.\n* **b)** The work must carry prominent notices stating that it is released under this\nLicense and any conditions added under section 7. This requirement modifies the\nrequirement in section 4 to “keep intact all notices”.\n* **c)** You must license the entire work, as a whole, under this License to anyone who\ncomes into possession of a copy. This License will therefore apply, along with any\napplicable section 7 additional terms, to the whole of the work, and all its parts,\nregardless of how they are packaged. This License gives no permission to license the\nwork in any other way, but it does not invalidate such permission if you have\nseparately received it.\n* **d)** If the work has interactive user interfaces, each must display Appropriate Legal\nNotices; however, if the Program has interactive interfaces that do not display\nAppropriate Legal Notices, your work need not make them do so.\n\nA compilation of a covered work with other separate and independent works, which are\nnot by their nature extensions of the covered work, and which are not combined with\nit such as to form a larger program, in or on a volume of a storage or distribution\nmedium, is called an “aggregate” if the compilation and its resulting\ncopyright are not used to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work in an aggregate\ndoes not cause this License to apply to the other parts of the aggregate.\n\n### 6. Conveying Non-Source Forms\n\nYou may convey a covered work in object code form under the terms of sections 4 and\n5, provided that you also convey the machine-readable Corresponding Source under the\nterms of this License, in one of these ways:\n\n* **a)** Convey the object code in, or embodied in, a physical product (including a\nphysical distribution medium), accompanied by the Corresponding Source fixed on a\ndurable physical medium customarily used for software interchange.\n* **b)** Convey the object code in, or embodied in, a physical product (including a\nphysical distribution medium), accompanied by a written offer, valid for at least\nthree years and valid for as long as you offer spare parts or customer support for\nthat product model, to give anyone who possesses the object code either **(1)** a copy of\nthe Corresponding Source for all the software in the product that is covered by this\nLicense, on a durable physical medium customarily used for software interchange, for\na price no more than your reasonable cost of physically performing this conveying of\nsource, or **(2)** access to copy the Corresponding Source from a network server at no\ncharge.\n* **c)** Convey individual copies of the object code with a copy of the written offer to\nprovide the Corresponding Source. This alternative is allowed only occasionally and\nnoncommercially, and only if you received the object code with such an offer, in\naccord with subsection 6b.\n* **d)** Convey the object code by offering access from a designated place (gratis or for\na charge), and offer equivalent access to the Corresponding Source in the same way\nthrough the same place at no further charge. You need not require recipients to copy\nthe Corresponding Source along with the object code. If the place to copy the object\ncode is a network server, the Corresponding Source may be on a different server\n(operated by you or a third party) that supports equivalent copying facilities,\nprovided you maintain clear directions next to the object code saying where to find\nthe Corresponding Source. Regardless of what server hosts the Corresponding Source,\nyou remain obligated to ensure that it is available for as long as needed to satisfy\nthese requirements.\n* **e)** Convey the object code using peer-to-peer transmission, provided you inform\nother peers where the object code and Corresponding Source of the work are being\noffered to the general public at no charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded from the\nCorresponding Source as a System Library, need not be included in conveying the\nobject code work.\n\nA “User Product” is either **(1)** a “consumer product”, which\nmeans any tangible personal property which is normally used for personal, family, or\nhousehold purposes, or **(2)** anything designed or sold for incorporation into a\ndwelling. In determining whether a product is a consumer product, doubtful cases\nshall be resolved in favor of coverage. For a particular product received by a\nparticular user, “normally used” refers to a typical or common use of\nthat class of product, regardless of the status of the particular user or of the way\nin which the particular user actually uses, or expects or is expected to use, the\nproduct. A product is a consumer product regardless of whether the product has\nsubstantial commercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n“Installation Information” for a User Product means any methods,\nprocedures, authorization keys, or other information required to install and execute\nmodified versions of a covered work in that User Product from a modified version of\nits Corresponding Source. The information must suffice to ensure that the continued\nfunctioning of the modified object code is in no case prevented or interfered with\nsolely because modification has been made.\n\nIf you convey an object code work under this section in, or with, or specifically for\nuse in, a User Product, and the conveying occurs as part of a transaction in which\nthe right of possession and use of the User Product is transferred to the recipient\nin perpetuity or for a fixed term (regardless of how the transaction is\ncharacterized), the Corresponding Source conveyed under this section must be\naccompanied by the Installation Information. But this requirement does not apply if\nneither you nor any third party retains the ability to install modified object code\non the User Product (for example, the work has been installed in ROM).\n\nThe requirement to provide Installation Information does not include a requirement to\ncontinue to provide support service, warranty, or updates for a work that has been\nmodified or installed by the recipient, or for the User Product in which it has been\nmodified or installed. Access to a network may be denied when the modification itself\nmaterially and adversely affects the operation of the network or violates the rules\nand protocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided, in accord with\nthis section must be in a format that is publicly documented (and with an\nimplementation available to the public in source code form), and must require no\nspecial password or key for unpacking, reading or copying.\n\n### 7. Additional Terms\n\n“Additional permissions” are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions. Additional\npermissions that are applicable to the entire Program shall be treated as though they\nwere included in this License, to the extent that they are valid under applicable\nlaw. If additional permissions apply only to part of the Program, that part may be\nused separately under those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option remove any\nadditional permissions from that copy, or from any part of it. (Additional\npermissions may be written to require their own removal in certain cases when you\nmodify the work.) You may place additional permissions on material, added by you to a\ncovered work, for which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you add to a\ncovered work, you may (if authorized by the copyright holders of that material)\nsupplement the terms of this License with terms:\n\n* **a)** Disclaiming warranty or limiting liability differently from the terms of\nsections 15 and 16 of this License; or\n* **b)** Requiring preservation of specified reasonable legal notices or author\nattributions in that material or in the Appropriate Legal Notices displayed by works\ncontaining it; or\n* **c)** Prohibiting misrepresentation of the origin of that material, or requiring that\nmodified versions of such material be marked in reasonable ways as different from the\noriginal version; or\n* **d)** Limiting the use for publicity purposes of names of licensors or authors of the\nmaterial; or\n* **e)** Declining to grant rights under trademark law for use of some trade names,\ntrademarks, or service marks; or\n* **f)** Requiring indemnification of licensors and authors of that material by anyone\nwho conveys the material (or modified versions of it) with contractual assumptions of\nliability to the recipient, for any liability that these contractual assumptions\ndirectly impose on those licensors and authors.\n\nAll other non-permissive additional terms are considered “further\nrestrictions” within the meaning of section 10. If the Program as you received\nit, or any part of it, contains a notice stating that it is governed by this License\nalong with a term that is a further restriction, you may remove that term. If a\nlicense document contains a further restriction but permits relicensing or conveying\nunder this License, you may add to a covered work material governed by the terms of\nthat license document, provided that the further restriction does not survive such\nrelicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you must place, in\nthe relevant source files, a statement of the additional terms that apply to those\nfiles, or a notice indicating where to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the form of a\nseparately written license, or stated as exceptions; the above requirements apply\neither way.\n\n### 8. Termination\n\nYou may not propagate or modify a covered work except as expressly provided under\nthis License. Any attempt otherwise to propagate or modify it is void, and will\nautomatically terminate your rights under this License (including any patent licenses\ngranted under the third paragraph of section 11).\n\nHowever, if you cease all violation of this License, then your license from a\nparticular copyright holder is reinstated **(a)** provisionally, unless and until the\ncopyright holder explicitly and finally terminates your license, and **(b)** permanently,\nif the copyright holder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is reinstated permanently\nif the copyright holder notifies you of the violation by some reasonable means, this\nis the first time you have received notice of violation of this License (for any\nwork) from that copyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the licenses of\nparties who have received copies or rights from you under this License. If your\nrights have been terminated and not permanently reinstated, you do not qualify to\nreceive new licenses for the same material under section 10.\n\n### 9. Acceptance Not Required for Having Copies\n\nYou are not required to accept this License in order to receive or run a copy of the\nProgram. Ancillary propagation of a covered work occurring solely as a consequence of\nusing peer-to-peer transmission to receive a copy likewise does not require\nacceptance. However, nothing other than this License grants you permission to\npropagate or modify any covered work. These actions infringe copyright if you do not\naccept this License. Therefore, by modifying or propagating a covered work, you\nindicate your acceptance of this License to do so.\n\n### 10. Automatic Licensing of Downstream Recipients\n\nEach time you convey a covered work, the recipient automatically receives a license\nfrom the original licensors, to run, modify and propagate that work, subject to this\nLicense. You are not responsible for enforcing compliance by third parties with this\nLicense.\n\nAn “entity transaction” is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an organization, or\nmerging organizations. If propagation of a covered work results from an entity\ntransaction, each party to that transaction who receives a copy of the work also\nreceives whatever licenses to the work the party's predecessor in interest had or\ncould give under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if the predecessor\nhas it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the rights granted or\naffirmed under this License. For example, you may not impose a license fee, royalty,\nor other charge for exercise of rights granted under this License, and you may not\ninitiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging\nthat any patent claim is infringed by making, using, selling, offering for sale, or\nimporting the Program or any portion of it.\n\n### 11. Patents\n\nA “contributor” is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The work thus\nlicensed is called the contributor's “contributor version”.\n\nA contributor's “essential patent claims” are all patent claims owned or\ncontrolled by the contributor, whether already acquired or hereafter acquired, that\nwould be infringed by some manner, permitted by this License, of making, using, or\nselling its contributor version, but do not include claims that would be infringed\nonly as a consequence of further modification of the contributor version. For\npurposes of this definition, “control” includes the right to grant patent\nsublicenses in a manner consistent with the requirements of this License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free patent license\nunder the contributor's essential patent claims, to make, use, sell, offer for sale,\nimport and otherwise run, modify and propagate the contents of its contributor\nversion.\n\nIn the following three paragraphs, a “patent license” is any express\nagreement or commitment, however denominated, not to enforce a patent (such as an\nexpress permission to practice a patent or covenant not to sue for patent\ninfringement). To “grant” such a patent license to a party means to make\nsuch an agreement or commitment not to enforce a patent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license, and the\nCorresponding Source of the work is not available for anyone to copy, free of charge\nand under the terms of this License, through a publicly available network server or\nother readily accessible means, then you must either **(1)** cause the Corresponding\nSource to be so available, or **(2)** arrange to deprive yourself of the benefit of the\npatent license for this particular work, or **(3)** arrange, in a manner consistent with\nthe requirements of this License, to extend the patent license to downstream\nrecipients. “Knowingly relying” means you have actual knowledge that, but\nfor the patent license, your conveying the covered work in a country, or your\nrecipient's use of the covered work in a country, would infringe one or more\nidentifiable patents in that country that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or arrangement, you\nconvey, or propagate by procuring conveyance of, a covered work, and grant a patent\nlicense to some of the parties receiving the covered work authorizing them to use,\npropagate, modify or convey a specific copy of the covered work, then the patent\nlicense you grant is automatically extended to all recipients of the covered work and\nworks based on it.\n\nA patent license is “discriminatory” if it does not include within the\nscope of its coverage, prohibits the exercise of, or is conditioned on the\nnon-exercise of one or more of the rights that are specifically granted under this\nLicense. You may not convey a covered work if you are a party to an arrangement with\na third party that is in the business of distributing software, under which you make\npayment to the third party based on the extent of your activity of conveying the\nwork, and under which the third party grants, to any of the parties who would receive\nthe covered work from you, a discriminatory patent license **(a)** in connection with\ncopies of the covered work conveyed by you (or copies made from those copies), or **(b)**\nprimarily for and in connection with specific products or compilations that contain\nthe covered work, unless you entered into that arrangement, or that patent license\nwas granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting any implied\nlicense or other defenses to infringement that may otherwise be available to you\nunder applicable patent law.\n\n### 12. No Surrender of Others' Freedom\n\nIf conditions are imposed on you (whether by court order, agreement or otherwise)\nthat contradict the conditions of this License, they do not excuse you from the\nconditions of this License. If you cannot convey a covered work so as to satisfy\nsimultaneously your obligations under this License and any other pertinent\nobligations, then as a consequence you may not convey it at all. For example, if you\nagree to terms that obligate you to collect a royalty for further conveying from\nthose to whom you convey the Program, the only way you could satisfy both those terms\nand this License would be to refrain entirely from conveying the Program.\n\n### 13. Use with the GNU Affero General Public License\n\nNotwithstanding any other provision of this License, you have permission to link or\ncombine any covered work with a work licensed under version 3 of the GNU Affero\nGeneral Public License into a single combined work, and to convey the resulting work.\nThe terms of this License will continue to apply to the part which is the covered\nwork, but the special requirements of the GNU Affero General Public License, section\n13, concerning interaction through a network will apply to the combination as such.\n\n### 14. Revised Versions of this License\n\nThe Free Software Foundation may publish revised and/or new versions of the GNU\nGeneral Public License from time to time. Such new versions will be similar in spirit\nto the present version, but may differ in detail to address new problems or concerns.\n\nEach version is given a distinguishing version number. If the Program specifies that\na certain numbered version of the GNU General Public License “or any later\nversion” applies to it, you have the option of following the terms and\nconditions either of that numbered version or of any later version published by the\nFree Software Foundation. If the Program does not specify a version number of the GNU\nGeneral Public License, you may choose any version ever published by the Free\nSoftware Foundation.\n\nIf the Program specifies that a proxy can decide which future versions of the GNU\nGeneral Public License can be used, that proxy's public statement of acceptance of a\nversion permanently authorizes you to choose that version for the Program.\n\nLater license versions may give you additional or different permissions. However, no\nadditional obligations are imposed on any author or copyright holder as a result of\nyour choosing to follow a later version.\n\n### 15. Disclaimer of Warranty\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.\nEXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER\nEXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE\nQUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE\nDEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n### 16. Limitation of Liability\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY\nCOPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS\nPERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,\nINCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE\nPROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE\nOR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE\nWITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n### 17. Interpretation of Sections 15 and 16\n\nIf the disclaimer of warranty and limitation of liability provided above cannot be\ngiven local legal effect according to their terms, reviewing courts shall apply local\nlaw that most closely approximates an absolute waiver of all civil liability in\nconnection with the Program, unless a warranty or assumption of liability accompanies\na copy of the Program in return for a fee.\n\n_END OF TERMS AND CONDITIONS_\n"
  },
  {
    "path": "res/about.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  ~ Flowkeeper - Pomodoro timer for power users and teams\n  ~ Copyright (c) 2023 Constantine Kulak\n  ~\n  ~ This program is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation; either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ This program is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.\n  -->\n\n<ui version=\"4.0\">\n <class>Dialog</class>\n <widget class=\"QDialog\" name=\"Dialog\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>650</width>\n    <height>650</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>About</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <item>\n    <layout class=\"QHBoxLayout\" name=\"hl\">\n     <item>\n      <widget class=\"QLabel\" name=\"icon\">\n       <property name=\"text\">\n        <string/>\n       </property>\n       <property name=\"scaledContents\">\n        <bool>false</bool>\n       </property>\n       <property name=\"alignment\">\n        <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <layout class=\"QVBoxLayout\" name=\"vl\">\n       <item>\n        <widget class=\"QLabel\" name=\"title\">\n         <property name=\"font\">\n          <font>\n           <pointsize>24</pointsize>\n          </font>\n         </property>\n         <property name=\"text\">\n          <string>Flowkeeper</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"version\">\n         <property name=\"text\">\n          <string>Version</string>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"description\">\n         <property name=\"text\">\n          <string>Flowkeeper is an independent and free Pomodoro Technique desktop timer for power users.</string>\n         </property>\n         <property name=\"wordWrap\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"linkWebsite\">\n         <property name=\"text\">\n          <string>[Website](https://flowkeeper.org/)</string>\n         </property>\n         <property name=\"textFormat\">\n          <enum>Qt::MarkdownText</enum>\n         </property>\n         <property name=\"openExternalLinks\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QLabel\" name=\"linkGithub\">\n         <property name=\"text\">\n          <string>[GitHub](https://github.com/flowkeeper-org/)</string>\n         </property>\n         <property name=\"textFormat\">\n          <enum>Qt::MarkdownText</enum>\n         </property>\n         <property name=\"openExternalLinks\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n       <item>\n        <spacer name=\"hs\">\n         <property name=\"orientation\">\n          <enum>Qt::Horizontal</enum>\n         </property>\n         <property name=\"sizeHint\" stdset=\"0\">\n          <size>\n           <width>40</width>\n           <height>20</height>\n          </size>\n         </property>\n        </spacer>\n       </item>\n      </layout>\n     </item>\n    </layout>\n   </item>\n   <item>\n    <widget class=\"QTabWidget\" name=\"tabs\">\n     <property name=\"currentIndex\">\n      <number>0</number>\n     </property>\n     <widget class=\"QWidget\" name=\"tab_notes\">\n      <attribute name=\"title\">\n       <string>What's new</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_2\">\n       <item>\n        <widget class=\"QTextEdit\" name=\"notes\">\n         <property name=\"readOnly\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_credits\">\n      <attribute name=\"title\">\n       <string>Credits</string>\n      </attribute>\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n       <item>\n        <widget class=\"QTextEdit\" name=\"credits\">\n         <property name=\"readOnly\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n      </layout>\n     </widget>\n     <widget class=\"QWidget\" name=\"tab_license\">\n      <attribute name=\"title\">\n       <string>License</string>\n      </attribute>\n      <layout class=\"QHBoxLayout\" name=\"horizontalLayout\">\n       <item>\n        <widget class=\"QTextEdit\" name=\"license\">\n         <property name=\"readOnly\">\n          <bool>true</bool>\n         </property>\n        </widget>\n       </item>\n      </layout>\n     </widget>\n    </widget>\n   </item>\n   <item>\n    <widget class=\"QDialogButtonBox\" name=\"buttons\">\n     <property name=\"orientation\">\n      <enum>Qt::Horizontal</enum>\n     </property>\n     <property name=\"standardButtons\">\n      <set>QDialogButtonBox::Close</set>\n     </property>\n    </widget>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections>\n  <connection>\n   <sender>buttons</sender>\n   <signal>accepted()</signal>\n   <receiver>Dialog</receiver>\n   <slot>accept()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>248</x>\n     <y>254</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>157</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n  <connection>\n   <sender>buttons</sender>\n   <signal>rejected()</signal>\n   <receiver>Dialog</receiver>\n   <slot>reject()</slot>\n   <hints>\n    <hint type=\"sourcelabel\">\n     <x>316</x>\n     <y>260</y>\n    </hint>\n    <hint type=\"destinationlabel\">\n     <x>286</x>\n     <y>274</y>\n    </hint>\n   </hints>\n  </connection>\n </connections>\n</ui>\n"
  },
  {
    "path": "res/core.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  ~ Flowkeeper - Pomodoro timer for power users and teams\n  ~ Copyright (c) 2023 Constantine Kulak\n  ~\n  ~ This program is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation; either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ This program is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.\n  -->\n\n<ui version=\"4.0\">\n <class>MainWindow</class>\n <widget class=\"QMainWindow\" name=\"MainWindow\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>960</width>\n    <height>650</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>Flowkeeper</string>\n  </property>\n  <property name=\"windowIcon\">\n   <iconset>\n    <normaloff>:/flowkeeper.png</normaloff>:/flowkeeper.png</iconset>\n  </property>\n  <widget class=\"QWidget\" name=\"rootLayout\">\n   <layout class=\"QVBoxLayout\" name=\"rootLayoutInternal\">\n    <property name=\"spacing\">\n     <number>0</number>\n    </property>\n    <property name=\"leftMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"topMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"rightMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"bottomMargin\">\n     <number>0</number>\n    </property>\n    <item>\n     <widget class=\"QWidget\" name=\"mainLayout\" native=\"true\">\n      <layout class=\"QHBoxLayout\" name=\"mainLayoutInternal\">\n       <property name=\"spacing\">\n        <number>0</number>\n       </property>\n       <property name=\"leftMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>0</number>\n       </property>\n       <item>\n        <widget class=\"QWidget\" name=\"left_toolbar\" native=\"true\">\n         <layout class=\"QVBoxLayout\" name=\"verticalLayout_3\">\n          <item>\n           <layout class=\"QVBoxLayout\" name=\"left_toolbar_layout\">\n            <item>\n             <widget class=\"QToolButton\" name=\"toolBacklogs\">\n              <property name=\"text\">\n               <string/>\n              </property>\n              <property name=\"iconSize\">\n               <size>\n                <width>32</width>\n                <height>32</height>\n               </size>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <widget class=\"QToolButton\" name=\"toolTeams\">\n              <property name=\"text\">\n               <string/>\n              </property>\n              <property name=\"iconSize\">\n               <size>\n                <width>32</width>\n                <height>32</height>\n               </size>\n              </property>\n             </widget>\n            </item>\n            <item>\n             <spacer name=\"verticalSpacer\">\n              <property name=\"orientation\">\n               <enum>Qt::Vertical</enum>\n              </property>\n              <property name=\"sizeHint\" stdset=\"0\">\n               <size>\n                <width>20</width>\n                <height>40</height>\n               </size>\n              </property>\n             </spacer>\n            </item>\n            <item>\n             <widget class=\"QToolButton\" name=\"toolSettings\">\n              <property name=\"text\">\n               <string/>\n              </property>\n              <property name=\"iconSize\">\n               <size>\n                <width>32</width>\n                <height>32</height>\n               </size>\n              </property>\n             </widget>\n            </item>\n           </layout>\n          </item>\n         </layout>\n        </widget>\n       </item>\n       <item>\n        <widget class=\"QSplitter\" name=\"splitter\">\n         <property name=\"orientation\">\n          <enum>Qt::Horizontal</enum>\n         </property>\n         <property name=\"handleWidth\">\n          <number>0</number>\n         </property>\n         <widget class=\"QWidget\" name=\"leftTableLayout\" native=\"true\">\n          <layout class=\"QVBoxLayout\" name=\"leftTableLayoutInternal\">\n           <property name=\"spacing\">\n            <number>0</number>\n           </property>\n           <property name=\"leftMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"topMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"rightMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"bottomMargin\">\n            <number>0</number>\n           </property>\n          </layout>\n         </widget>\n         <widget class=\"QWidget\" name=\"rightTableLayout\" native=\"true\">\n          <layout class=\"QVBoxLayout\" name=\"rightTableLayoutInternal\">\n           <property name=\"spacing\">\n            <number>0</number>\n           </property>\n           <property name=\"leftMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"topMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"rightMargin\">\n            <number>0</number>\n           </property>\n           <property name=\"bottomMargin\">\n            <number>0</number>\n           </property>\n           <item>\n            <layout class=\"QHBoxLayout\" name=\"searchBar\">\n             <property name=\"spacing\">\n              <number>0</number>\n             </property>\n            </layout>\n           </item>\n          </layout>\n         </widget>\n        </widget>\n       </item>\n      </layout>\n     </widget>\n    </item>\n   </layout>\n  </widget>\n  <widget class=\"QStatusBar\" name=\"statusBar\"/>\n  <widget class=\"QMenuBar\" name=\"menuBar\">\n   <property name=\"geometry\">\n    <rect>\n     <x>0</x>\n     <y>0</y>\n     <width>960</width>\n     <height>35</height>\n    </rect>\n   </property>\n   <property name=\"nativeMenuBar\">\n    <bool>true</bool>\n   </property>\n  </widget>\n </widget>\n <tabstops />\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "res/icons/dark/index.theme",
    "content": "[Icon Theme]\nName=dark\nInherits=hicolor\nDirectories=24x24\n\n[24x24]\nSize=24\n"
  },
  {
    "path": "res/icons/light/index.theme",
    "content": "[Icon Theme]\nName=light\nInherits=hicolor\nDirectories=24x24\n\n[24x24]\nSize=24\n"
  },
  {
    "path": "res/icons/mixed/index.theme",
    "content": "[Icon Theme]\nName=mixed\nInherits=hicolor\nDirectories=24x24\n\n[24x24]\nSize=24\n"
  },
  {
    "path": "res/stats.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\n  ~ Flowkeeper - Pomodoro timer for power users and teams\n  ~ Copyright (c) 2023 Constantine Kulak\n  ~\n  ~ This program is free software: you can redistribute it and/or modify\n  ~ it under the terms of the GNU General Public License as published by\n  ~ the Free Software Foundation; either version 3 of the License, or\n  ~ (at your option) any later version.\n  ~\n  ~ This program is distributed in the hope that it will be useful,\n  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of\n  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n  ~ GNU General Public License for more details.\n  ~\n  ~ You should have received a copy of the GNU General Public License\n  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.\n  -->\n\n<ui version=\"4.0\">\n <class>StatsWindow</class>\n <widget class=\"QDialog\" name=\"StatsWindow\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>1000</width>\n    <height>700</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>Pomodoro Health</string>\n  </property>\n   <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n    <property name=\"spacing\">\n     <number>0</number>\n    </property>\n    <property name=\"leftMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"topMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"rightMargin\">\n     <number>0</number>\n    </property>\n    <property name=\"bottomMargin\">\n     <number>0</number>\n    </property>\n    <item>\n     <layout class=\"QVBoxLayout\" name=\"statsHeader\">\n      <property name=\"spacing\">\n       <number>0</number>\n      </property>\n      <property name=\"leftMargin\">\n       <number>15</number>\n      </property>\n      <property name=\"topMargin\">\n       <number>10</number>\n      </property>\n      <property name=\"rightMargin\">\n       <number>15</number>\n      </property>\n      <property name=\"bottomMargin\">\n       <number>15</number>\n      </property>\n      <item>\n       <widget class=\"QLabel\" name=\"statsHeaderText\">\n        <property name=\"text\">\n         <string>Completed $done out of $total</string>\n        </property>\n        <property name=\"textFormat\">\n         <enum>Qt::RichText</enum>\n        </property>\n        <property name=\"margin\">\n         <number>0</number>\n        </property>\n       </widget>\n      </item>\n      <item>\n       <widget class=\"QLabel\" name=\"statsHeaderSubtext\">\n        <property name=\"text\">\n         <string>Average over $from -- $to</string>\n        </property>\n        <property name=\"margin\">\n         <number>0</number>\n        </property>\n       </widget>\n      </item>\n     </layout>\n    </item>\n    <item>\n     <widget class=\"QWidget\" name=\"statsMain\" native=\"true\">\n      <layout class=\"QVBoxLayout\" name=\"verticalLayout_4\">\n       <property name=\"spacing\">\n        <number>0</number>\n       </property>\n       <property name=\"leftMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"topMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"rightMargin\">\n        <number>0</number>\n       </property>\n       <property name=\"bottomMargin\">\n        <number>0</number>\n       </property>\n       <item>\n        <layout class=\"QVBoxLayout\" name=\"statsControls\">\n         <property name=\"spacing\">\n          <number>0</number>\n         </property>\n         <property name=\"topMargin\">\n          <number>0</number>\n         </property>\n         <item>\n          <layout class=\"QHBoxLayout\" name=\"periodLayout\">\n           <property name=\"spacing\">\n            <number>0</number>\n           </property>\n           <property name=\"leftMargin\">\n            <number>15</number>\n           </property>\n           <property name=\"topMargin\">\n            <number>15</number>\n           </property>\n           <property name=\"rightMargin\">\n            <number>15</number>\n           </property>\n           <property name=\"bottomMargin\">\n            <number>15</number>\n           </property>\n           <item>\n            <widget class=\"QToolButton\" name=\"prev\">\n             <property name=\"text\">\n              <string>&lt;&lt; Prev</string>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <spacer name=\"hs1\">\n             <property name=\"orientation\">\n              <enum>Qt::Horizontal</enum>\n             </property>\n             <property name=\"sizeHint\" stdset=\"0\">\n              <size>\n               <width>40</width>\n               <height>20</height>\n              </size>\n             </property>\n            </spacer>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"day\">\n             <property name=\"text\">\n              <string>Day</string>\n             </property>\n             <property name=\"checkable\">\n              <bool>true</bool>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"week\">\n             <property name=\"text\">\n              <string>Week</string>\n             </property>\n             <property name=\"checkable\">\n              <bool>true</bool>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"month\">\n             <property name=\"text\">\n              <string>Month</string>\n             </property>\n             <property name=\"checkable\">\n              <bool>true</bool>\n             </property>\n             <property name=\"checked\">\n              <bool>true</bool>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"month6\">\n             <property name=\"text\">\n              <string>6 Months</string>\n             </property>\n             <property name=\"checkable\">\n              <bool>true</bool>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"year\">\n             <property name=\"text\">\n              <string>Year</string>\n             </property>\n             <property name=\"checkable\">\n              <bool>true</bool>\n             </property>\n            </widget>\n           </item>\n           <item>\n            <spacer name=\"hs2\">\n             <property name=\"orientation\">\n              <enum>Qt::Horizontal</enum>\n             </property>\n             <property name=\"sizeHint\" stdset=\"0\">\n              <size>\n               <width>40</width>\n               <height>20</height>\n              </size>\n             </property>\n            </spacer>\n           </item>\n           <item>\n            <widget class=\"QToolButton\" name=\"next\">\n             <property name=\"text\">\n              <string>Next &gt;&gt;</string>\n             </property>\n            </widget>\n           </item>\n          </layout>\n         </item>\n        </layout>\n       </item>\n       <item>\n        <layout class=\"QVBoxLayout\" name=\"statsGraph\"/>\n       </item>\n      </layout>\n     </widget>\n    </item>\n   </layout>\n  </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "res/style-beach.json",
    "content": "{\n  \"ICON_THEME\": \"mixed\",\n\n  \"FOCUS_TEXT_COLOR\": \"#ffffff\",\n  \"FOCUS_BG_COLOR\": \"#F038FF\",\n  \"FOCUS_BORDER_COLOR\": \"#F038FF\",\n\n  \"BORDER_COLOR\": \"#EEEEEE\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#E2EF70\",\n  \"SECONDARY_BG_COLOR\": \"#70E4EF\",\n  \"SELECTION_BG_COLOR\": \"#FFFFFF\",\n\n  \"TOOLBAR_BG_COLOR\": \"#3772FF\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#3772FF\"\n}\n"
  },
  {
    "path": "res/style-dark.json",
    "content": "{\n  \"ICON_THEME\": \"dark\",\n\n  \"FOCUS_TEXT_COLOR\": \"#ffffff\",\n  \"FOCUS_BG_COLOR\": \"#27282e\",\n  \"FOCUS_BORDER_COLOR\": \"#000000\",\n\n  \"BORDER_COLOR\": \"#000000\",\n\n  \"TABLE_TEXT_COLOR\": \"#ced0d6\",\n  \"TABLE_CROSSOUT_COLOR\": \"#555555\",\n  \"PRIMARY_BG_COLOR\": \"#1e1f22\",\n  \"SECONDARY_BG_COLOR\": \"#2b2d30\",\n  \"SELECTION_BG_COLOR\": \"#43454a\",\n\n  \"TOOLBAR_BG_COLOR\": \"#44484C\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#64686C\"\n}\n"
  },
  {
    "path": "res/style-desert.json",
    "content": "{\n  \"ICON_THEME\": \"mixed\",\n\n  \"FOCUS_TEXT_COLOR\": \"#ffffff\",\n  \"FOCUS_BG_COLOR\": \"#D52941\",\n  \"FOCUS_BORDER_COLOR\": \"#FCD581\",\n\n  \"BORDER_COLOR\": \"#FCD581\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#ffffff\",\n  \"SECONDARY_BG_COLOR\": \"#FFF8E8\",\n  \"SELECTION_BG_COLOR\": \"#FCD581\",\n\n  \"TOOLBAR_BG_COLOR\": \"#FCD581\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#990D35\"\n}\n"
  },
  {
    "path": "res/style-highlight.json",
    "content": "{\n  \"ICON_THEME\": \"dark\",\n\n  \"FOCUS_TEXT_COLOR\": \"#FFFFFF\",\n  \"FOCUS_BG_COLOR\": \"#EF6306\",\n  \"FOCUS_BORDER_COLOR\": \"#000000\",\n\n  \"BORDER_COLOR\": \"#000000\",\n\n  \"TABLE_TEXT_COLOR\": \"#FFFFFF\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#1e1f22\",\n  \"SECONDARY_BG_COLOR\": \"#2b2d30\",\n  \"SELECTION_BG_COLOR\": \"#EF6306\",\n\n  \"TOOLBAR_BG_COLOR\": \"#44484C\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#EF6306\"\n}\n"
  },
  {
    "path": "res/style-light.json",
    "content": "{\n  \"ICON_THEME\": \"light\",\n\n  \"FOCUS_TEXT_COLOR\": \"#000000\",\n  \"FOCUS_BG_COLOR\": \"#f7f8fa\",\n  \"FOCUS_BORDER_COLOR\": \"#d7d8da\",\n\n  \"BORDER_COLOR\": \"#d7d8da\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#ffffff\",\n  \"SECONDARY_BG_COLOR\": \"#f7f8fa\",\n  \"SELECTION_BG_COLOR\": \"#cfdefc\",\n\n  \"TOOLBAR_BG_COLOR\": \"#f7f8fa\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#dfe1e5\"\n}\n"
  },
  {
    "path": "res/style-lime.json",
    "content": "{\n  \"ICON_THEME\": \"light\",\n\n  \"FOCUS_TEXT_COLOR\": \"#000000\",\n  \"FOCUS_BG_COLOR\": \"#E0FF4F\",\n  \"FOCUS_BORDER_COLOR\": \"#A7CC00\",\n\n  \"BORDER_COLOR\": \"#d7d8da\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#FEFFFE\",\n  \"SECONDARY_BG_COLOR\": \"#E0ECF5\",\n  \"SELECTION_BG_COLOR\": \"#A3C6E1\",\n\n  \"TOOLBAR_BG_COLOR\": \"#FF6663\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#E0FF4F\"\n}\n"
  },
  {
    "path": "res/style-mixed.json",
    "content": "{\n  \"ICON_THEME\": \"mixed\",\n\n  \"FOCUS_TEXT_COLOR\": \"#ffffff\",\n  \"FOCUS_BG_COLOR\": \"#27282e\",\n  \"FOCUS_BORDER_COLOR\": \"#535553\",\n\n  \"BORDER_COLOR\": \"#d7d8da\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#ffffff\",\n  \"SECONDARY_BG_COLOR\": \"#f7f8fa\",\n  \"SELECTION_BG_COLOR\": \"#cfdefc\",\n\n  \"TOOLBAR_BG_COLOR\": \"#44484C\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#64686C\"\n}\n"
  },
  {
    "path": "res/style-motel.json",
    "content": "{\n  \"ICON_THEME\": \"mixed\",\n\n  \"FOCUS_TEXT_COLOR\": \"#FFFFFF\",\n  \"FOCUS_BG_COLOR\": \"#343233\",\n  \"FOCUS_BORDER_COLOR\": \"#343233\",\n\n  \"BORDER_COLOR\": \"#FFFFFF\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#FFEEF2\",\n  \"SECONDARY_BG_COLOR\": \"#FFE4F3\",\n  \"SELECTION_BG_COLOR\": \"#FFC8FB\",\n\n  \"TOOLBAR_BG_COLOR\": \"#FF92C2\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#343233\"\n}\n"
  },
  {
    "path": "res/style-purple.json",
    "content": "{\n  \"ICON_THEME\": \"dark\",\n\n  \"FOCUS_TEXT_COLOR\": \"#E9EDE9\",\n  \"FOCUS_BG_COLOR\": \"#2D2327\",\n  \"FOCUS_BORDER_COLOR\": \"#45364B\",\n\n  \"BORDER_COLOR\": \"#45364B\",\n\n  \"TABLE_TEXT_COLOR\": \"#E9EDE9\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#413347\",\n  \"SECONDARY_BG_COLOR\": \"#5A4063\",\n  \"SELECTION_BG_COLOR\": \"#2D2327\",\n\n  \"TOOLBAR_BG_COLOR\": \"#606880\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#2D2327\"\n}\n"
  },
  {
    "path": "res/style-resort.json",
    "content": "{\n  \"ICON_THEME\": \"mixed\",\n\n  \"FOCUS_TEXT_COLOR\": \"#FFFFFF\",\n  \"FOCUS_BG_COLOR\": \"#F5AB00\",\n  \"FOCUS_BORDER_COLOR\": \"#F5AB00\",\n\n  \"BORDER_COLOR\": \"#FFFFFF\",\n\n  \"TABLE_TEXT_COLOR\": \"#000000\",\n  \"TABLE_CROSSOUT_COLOR\": \"#777777\",\n  \"PRIMARY_BG_COLOR\": \"#FFFFFF\",\n  \"SECONDARY_BG_COLOR\": \"#F7EDDE\",\n  \"SELECTION_BG_COLOR\": \"#2DC7FF\",\n\n  \"TOOLBAR_BG_COLOR\": \"#2DC7FF\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#0081AF\"\n}\n"
  },
  {
    "path": "res/style-template.qss",
    "content": "QWidget {\n    font-family: $FONT_MAIN_FAMILY;\n    font-size: $FONT_MAIN_SIZE;\n}\n\nQMainWindow, #StatsWindow, #FocusBackground {\n    background-color: $FOCUS_BG_COLOR;\n    color: $TABLE_TEXT_COLOR;\n}\n\n#headerText, #statsHeaderText {\n    color: $FOCUS_TEXT_COLOR;\n    font-family: $FONT_HEADER_FAMILY;\n    font-size: $FONT_HEADER_SIZE;\n}\n\n#headerSubtext, #statsHeaderSubtext {\n    color: $FOCUS_TEXT_COLOR;\n}\n\n#headerSubSubtext {\n    color: $FOCUS_TEXT_COLOR;\n    font-size: $FONT_SUBTEXT_SIZE;\n}\n\n#statusBar {\n    color: $TABLE_TEXT_COLOR;\n    background-color: $TOOLBAR_BG_COLOR;\n}\n\n#footerLabel {\n    color: $TABLE_TEXT_COLOR;\n    background-color: $PRIMARY_BG_COLOR;\n    padding: 4px;\n}\n\n#search {\n    padding-left: 20px;\n    padding-top: 3px;\n    padding-bottom: 3px;\n    background: no-repeat left url(:/icons/$ICON_THEME/24x24/tool-search.svg);\n    color: $TABLE_TEXT_COLOR;\n    background-color: $PRIMARY_BG_COLOR;\n}\n\n#left_toolbar {\n    color: $TABLE_TEXT_COLOR;\n    background-color: $TOOLBAR_BG_COLOR;\n    border: none;\n    border-right: 1px solid $BORDER_COLOR;\n}\n\n#workitems_table, #users_table, #backlogs_table, #backlogs_widget, #workitems_widget {\n    color: $TABLE_TEXT_COLOR;\n    border: none;\n    padding: 10px;\n}\n\n#workitems_table::item:selected, #users_table::item:selected, #backlogs_table::item:selected, #workitems_table QLineEdit {\n    color: $TABLE_TEXT_COLOR;\n    background-color: $SELECTION_BG_COLOR;\n    border: none;\n}\n\n#users_table, #tags_table {\n    border-top: 0px solid $BORDER_COLOR;\n    border-right: 1px solid $BORDER_COLOR;\n}\n\n#backlogs_table, #users_table, #tags_table, #backlogs_widget {\n    background-color: $SECONDARY_BG_COLOR;\n    border-right: 1px solid $BORDER_COLOR;\n}\n\n#workitems_widget, #statsView, #statsMain {\n    background-color: $PRIMARY_BG_COLOR;\n}\n\n#workitems_table {\n    background-color: $PRIMARY_BG_COLOR;\n    padding: 10px 10px 10px 8px;\n}\n\nQToolButton {\n    background: none;\n    border-radius: 5px;\n    color: $TABLE_TEXT_COLOR;\n}\n\n#toolBacklogs:checked {\n    background: none;\n    color: $TABLE_TEXT_COLOR;\n}\n\nQToolButton:checked {\n    background: $TOOLBAR_CHECKED_BG_COLOR;\n    color: $FOCUS_TEXT_COLOR;\n}\n\nQScrollBar {\n    height: 0px;\n    width: 0px;\n}\n\nQSplitter::handle {\n    background-color: $PRIMARY_BG_COLOR;\n}\n\nInfoOverlayContent {\n    background-color: #3366cc;\n}\n\nInfoOverlay #overlay_text, #prev_button {\n    color: #ffffff;\n}\n\n#timer {\n    qproperty-fg_color: $FOCUS_TEXT_COLOR;\n    qproperty-bg_color: $FOCUS_BG_COLOR;\n}\n\n.tag_label {\n    background: none;\n    color: $TABLE_TEXT_COLOR;\n    border-radius: 5px;\n    border: 0px solid $BORDER_COLOR;\n    padding: 0px 6px 0px 6px;\n}\n\n.tag_label:checked {\n    color: $TABLE_TEXT_COLOR;\n    background-color: $SELECTION_BG_COLOR;\n    border: none;\n}\n\n#tags_table {\n    padding: 10px 10px 10px 10px;\n}\n\n#work_summary_results {\n    font-family: \"Source Code Pro\", Hack, \"Ubuntu Mono\", \"Noto Mono\", \"Roboto Mono\", \"Droid Sans Mono\", \"Monaco\", Consolas, \"Lucida Console\", \"Courier New\", Monospace;\n}\n\n#trayDark {\n    background-color: #17242c;\n    border: 1px solid #555555;\n}\n\n#trayLight {\n    background-color: #d6dff3;\n    border: 1px solid #555555;\n}\n\n#warning {\n    color: red;\n}\n"
  },
  {
    "path": "res/style-terra.json",
    "content": "{\n  \"ICON_THEME\": \"dark\",\n\n  \"FOCUS_TEXT_COLOR\": \"#ffffff\",\n  \"FOCUS_BG_COLOR\": \"#2D381A\",\n  \"FOCUS_BORDER_COLOR\": \"#2D381A\",\n\n  \"BORDER_COLOR\": \"#2D381A\",\n\n  \"TABLE_TEXT_COLOR\": \"#ffffff\",\n  \"TABLE_CROSSOUT_COLOR\": \"#AAAAAA\",\n  \"PRIMARY_BG_COLOR\": \"#5F8349\",\n  \"SECONDARY_BG_COLOR\": \"#426B69\",\n  \"SELECTION_BG_COLOR\": \"#2D381A\",\n\n  \"TOOLBAR_BG_COLOR\": \"#2A4849\",\n  \"TOOLBAR_CHECKED_BG_COLOR\": \"#2D381A\"\n}\n"
  },
  {
    "path": "res/summary.ui",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ui version=\"4.0\">\n <class>WorkSummaryWindow</class>\n <widget class=\"QDialog\" name=\"WorkSummaryWindow\">\n  <property name=\"geometry\">\n   <rect>\n    <x>0</x>\n    <y>0</y>\n    <width>600</width>\n    <height>500</height>\n   </rect>\n  </property>\n  <property name=\"windowTitle\">\n   <string>Work Summary</string>\n  </property>\n  <layout class=\"QVBoxLayout\" name=\"verticalLayout\">\n   <property name=\"spacing\">\n    <number>0</number>\n   </property>\n   <property name=\"leftMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"topMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"rightMargin\">\n    <number>0</number>\n   </property>\n   <property name=\"bottomMargin\">\n    <number>0</number>\n   </property>\n   <item>\n    <layout class=\"QVBoxLayout\" name=\"layout\">\n     <property name=\"spacing\">\n      <number>10</number>\n     </property>\n     <property name=\"leftMargin\">\n      <number>15</number>\n     </property>\n     <property name=\"topMargin\">\n      <number>15</number>\n     </property>\n     <property name=\"rightMargin\">\n      <number>15</number>\n     </property>\n     <property name=\"bottomMargin\">\n      <number>15</number>\n     </property>\n     <item>\n      <widget class=\"QComboBox\" name=\"format\"/>\n     </item>\n     <item>\n      <widget class=\"QComboBox\" name=\"period\"/>\n     </item>\n     <item>\n      <widget class=\"QCheckBox\" name=\"view_time_spent\">\n       <property name=\"text\">\n        <string>Include time spent</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QCheckBox\" name=\"view_backlogs\">\n       <property name=\"text\">\n        <string>Include information about backlogs</string>\n       </property>\n      </widget>\n     </item>\n     <item>\n      <widget class=\"QTextEdit\" name=\"work_summary_results\"/>\n     </item>\n     <item>\n      <widget class=\"QDialogButtonBox\" name=\"buttons\">\n       <property name=\"standardButtons\">\n        <set>QDialogButtonBox::Close|QDialogButtonBox::Save</set>\n       </property>\n      </widget>\n     </item>\n    </layout>\n   </item>\n  </layout>\n </widget>\n <resources/>\n <connections/>\n</ui>\n"
  },
  {
    "path": "run-tests.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\nsource venv/bin/activate\nPYTHONPATH=src python -m coverage run -m unittest discover -v fk.tests\npython -m coverage html # --include=\"src/fk/core/*\"\n\n# Simple test run\n# PYTHONPATH=src python -m unittest discover -v fk.tests\n"
  },
  {
    "path": "run.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nPYTHONPATH=src python3 -m fk.desktop.desktop \"$@\"\n\n"
  },
  {
    "path": "scripts/README.md",
    "content": "# Scripts\n\n## Directory structure\n\n- `.github/` - GitHub Actions pipelines definitions, not packaged\n- `doc/` - Technical docs, not packaged\n- `res/` - Resources, which are compiled to `src/fk/desktop/resources.py` using `generate-resources.sh`\n  - `icons/` - In-program icons\n  - `img/` -- For e2e testing only, can be stripped out\n  - `sound/` -- Music and WAVs, packaged\n  - `flowkeeper.icns` -- macOS icons, can be stripped out on Windows and Linux\n  - * -- all other files needs to be packaged \n- `scripts/` - Build scripts for all operating systems, not packaged. See `README.md` in subdirectories.\n- `src/` - PYTHONPATH for executing Flowkeeper, packaged\n  - `fk/` \n    - `core/` - The core logic, which allows writing GUI and CLI apps. Only depends on `semantic-versioning`\n    - `desktop/` - Qt windows, packaged\n      - `desktop.py` - The entry point for Flowkeeper Desktop GUI\n    - `e2e/` - End-to-end tests and screenshot generator scripts, not packaged\n    - `qt/` - Qt-specific logic, including widgets, delegates, etc. Packaged.\n    - `tests/` - Unit tests for `core/` module, not packaged\n    - `tools/` - Command-line tools, mainly for testing, not packaged\n      - `cli.py` - The entry point for Flowkeeper CLI, can be packaged with `core/` module\n- `build/` - Temporary files created when building Flowkeeper packages, should be in `.gitignore`\n  - `desktop.build` - Temp dir by Nuitka, can be deleted\n  - `desktop.onefile.build` - Temp dir by Nuitka, can be deleted\n  - `desktop.dist` - A standalone package by Nuitka, to be packaged\n    - `Flowkeeper.bin` - The entry point for standalone build by Nuitka\n    - * -- all other files are libraries\n  - `Flowkeeper` - A one-file portable binary by Nuitka, to be packaged\n- `dist/` - Resulting Flowkeeper building artifacts, should be in `.gitignore`\n  - `standalone/` - Standalone build package, depending on the OS and compiler, which can be zipped\n  - `flowkeeper-x.y.z-macOS-latest-nuitka-installer.dmg` - DMG built with Nuitka\n  - `flowkeeper-x.y.z-macOS-latest-pyinstaller-installer.dmg` - DMG built with PyInstaller\n  - `flowkeeper-x.y.z-macOS-latest-nuitka-portable` - macOS portable binary\n  - `flowkeeper-x.y.z-windows-latest-nuitka-installer.exe` - Windows installer built with Nuitka\n  - `flowkeeper-x.y.z-windows-latest-nuitka-portable.exe` - Windows portable EXE built with Nuitka\n  - `flowkeeper-x.y.z-windows-latest-pyinstaller-installer.exe` - Windows installer built with PyInstaller\n  - `flowkeeper-x.y.z-windows-latest-pyinstaller-portable.exe` - Windows portable EXE built with PyInstaller\n  - `flowkeeper-x.y.z-ubuntu-latest-nuitka-min-package.deb`\n  - `flowkeeper-x.y.z-ubuntu-latest-nuitka-package.deb`\n  - `flowkeeper-x.y.z-ubuntu-latest-nuitka-portable`\n  - `flowkeeper-x.y.z-ubuntu-latest-pyinstaller-min-package.deb`\n  - `flowkeeper-x.y.z-ubuntu-latest-pyinstaller-package.deb`\n  - `flowkeeper-x.y.z-ubuntu-latest-pyinstaller-portable`\n- `venv/` - Virtual env for building purposes, in `.gitignore`\n- `README.md` - The main README file, not packaged\n- `requirements.txt` - Requirements file for running, building and testing Flowkeeper, not packaged\n- `run.sh` - A convenience shell script for running Flowkeeper in dev environment, not packaged\n- `run-tests.sh` - A convenience shell script for unit tests, not packaged\n- `LICENSE` - GPLv3 license file\n\n## Installing dependencies for build\n\nAll operating systems:\n\n- Install Git\n- Install Python 3.11 for Qt 6.7, or 3.12+ for 6.8\n\nWindows:\n\n- Install InnoSetup: `scripts/windows/install-innosetup.sh`\n\nmacOS:\n\n- Install create-dmg utility and provision certificates for signing code. This requires env \nvariables and secrets only available in GitHub Actions. When building locally you don't \nhave to run `install-certificates.sh`.\n\n```bash\nscripts/macos/install-create-dmg.sh\nscripts/macos/install-certificates.sh\n```\n\nLinux:\n\nInstall AppImage Tool:\n\n```bash\nscripts/linux/appimage/install-appimage.sh\n```\n\n## Building\n\nNote that all commands here are Bash. On Windows you have to use Git Bash, not WSL. Otherwise,\nbuild scripts won't be able to detect Windows environment.\n\n### Create a virtual environment and install Python requirements: \n\nLinux and macOS:\n\n```bash\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements-build.txt\n```\n\nWindows:\n\n```bash\npython -m venv venv\nvenv/Scripts/activate\npip install -r requirements-build.txt\n```\n\n### Generate resources\n\nThis will create `src/fk/desktop/resources.py` and `flowkeeper.icns` for macOS.\n\n```bash\nscripts/common/generate-resources.sh\n```\n\n### Create binary packages\n\nTODO: Complete this section\n\nWith Nuitka: ``\n\nWith PyInstaller: ``"
  },
  {
    "path": "scripts/bsd/README.md",
    "content": "# Building for FreeBSD\n\nWe successfully built and tested Flowkeeper on FreeBSD:\n\n```\npkg install python3 git devel/pyside6 devel/pyside6-tools py311-keyring py311-semantic-version\ngit clone https://github.com/flowkeeper-org/fk-desktop.git\ncd fk-desktop/res\n/usr/local/bin/pyside6/rcc --project -o resources.qrc\n/usr/local/bin/pyside6/rcc -g python resources.qrc -o ../src/fk/desktop/resources.py\ncd ..\nPYTHONPATH=src python3.11 -m fk.desktop.desktop\n```\n\nTested with:\n\n- KDE 5.27.11 on X11\n- FreeBSD 14.1 RELEASE\n- Flowkeeper v1.0.0\n- Python 3.11.11\n- Qt 6.8.2 (xcb)\n"
  },
  {
    "path": "scripts/common/generate-resources.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nsource venv/bin/activate\n\nset -e\n\nif [[ \"$OSTYPE\" == \"msys\" ]]; then\n  alias \"pyside6-rcc=$(pwd)/venv/Lib/site-packages/PySide6/rcc\"\nelif [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n  scripts/macos/create-icons.sh\n  echo \"Generated icns file for macOS\"\n  ls -al\nfi\n\ncd res\nqrc=\"resources.qrc\"\npyside6-rcc --project -o \"$qrc\"\npyside6-rcc -g python \"$qrc\" -o \"../src/fk/desktop/resources.py\"\nrm \"$qrc\"\n"
  },
  {
    "path": "scripts/common/get-version.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nVERSION_REGEX='^### v(.+) \\(.*$'\nVERSION_LINE=$(head --lines=1 res/CHANGELOG.txt)\nif [[ $VERSION_LINE =~ $VERSION_REGEX ]]; then\n    echo \"${BASH_REMATCH[1]}\"\nelse\n    echo \"N/A\"\nfi\n"
  },
  {
    "path": "scripts/common/pyinstaller/entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "scripts/common/pyinstaller/normal.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nimport argparse\n\nparser = argparse.ArgumentParser()\nparser.add_argument(\"--sign\", action=\"store_true\")\noptions = parser.parse_args()\n\na = Analysis(\n    ['../../../src/fk/desktop/desktop.py'],\n    pathex=[],\n    binaries=[],\n    datas=[],\n    hiddenimports=[],\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=[],\n    noarchive=False,\n)\npyz = PYZ(a.pure)\n\nexe = EXE(\n    pyz,\n    a.scripts,\n    [],\n    exclude_binaries=True,\n    name='Flowkeeper',\n    debug=False,\n    bootloader_ignore_signals=False,\n    strip=False,\n    upx=True,\n    console=False,\n    disable_windowed_traceback=False,\n    argv_emulation=False,\n    target_arch=None,\n    codesign_identity=('Developer ID Application: Constantine Kulak (ELWZ9S676C)' if options.sign else None),\n    entitlements_file=('/Users/runner/work/fk-desktop/fk-desktop/scripts/common/pyinstaller/entitlements.plist' if options.sign else None),\n    icon=['../../../res/flowkeeper.ico'],\n)\ncoll = COLLECT(\n    exe,\n    a.binaries,\n    a.datas,\n    strip=False,\n    upx=True,\n    upx_exclude=[],\n    name='flowkeeper',\n)\napp = BUNDLE(\n    coll,\n    name='Flowkeeper.app',\n    icon='../../../flowkeeper.icns',\n    bundle_identifier='org.flowkeeper.Flowkeeper',\n)"
  },
  {
    "path": "scripts/common/pyinstaller/portable.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\n#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nfrom PyInstaller.utils.hooks import collect_all\n\ndatas = []\nbinaries = []\nhiddenimports = []\ntmp_ret = collect_all('fk')\ndatas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]\n\n\na = Analysis(\n    ['../../../src/fk/desktop/desktop.py'],\n    pathex=['../../../src'],\n    binaries=binaries,\n    datas=datas,\n    hiddenimports=hiddenimports,\n    hookspath=[],\n    hooksconfig={},\n    runtime_hooks=[],\n    excludes=[],\n    noarchive=True,\n)\npyz = PYZ(a.pure)\n\nexe = EXE(\n    pyz,\n    a.scripts,\n    a.binaries,\n    a.datas,\n    [('v', None, 'OPTION')],\n    name='Flowkeeper',\n    debug=True,\n    bootloader_ignore_signals=False,\n    strip=False,\n    upx=True,\n    upx_exclude=[],\n    runtime_tmpdir=None,\n    console=False,\n    disable_windowed_traceback=False,\n    argv_emulation=False,\n    target_arch=None,\n    codesign_identity=None,\n    entitlements_file=None,\n    icon=['../../../res/flowkeeper.ico'],\n)\n"
  },
  {
    "path": "scripts/linux/appimage/install-appimage.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# AppImage installer\nsudo wget \"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$(uname -m).AppImage\" -O /usr/local/bin/appimagetool\nsudo chmod +x /usr/local/bin/appimagetool\n"
  },
  {
    "path": "scripts/linux/appimage/package-appimage.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# In the next version(s) think of installing it in /opt/Flowkeeper instead\n\n# 1. Prepare temp folder\ncd build\nrm -rf AppDir\nmkdir AppDir\necho \"1. Prepared temp folder\"\n\n# 2. Copy application files\nmkdir -p AppDir/usr/lib/flowkeeper\nmkdir -p AppDir/usr/share/icons/hicolor/{48x48,1024x1024}/apps\nmkdir -p AppDir/usr/share/metainfo\nmkdir -p AppDir/usr/share/applications\ncp -r ../dist/standalone/* AppDir/usr/lib/flowkeeper/\ncp ../res/flowkeeper.png AppDir/usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png\ncp ../flowkeeper-48x48.png AppDir/usr/share/icons/hicolor/48x48/apps/org.flowkeeper.Flowkeeper.png\ncp ../scripts/linux/common/org.flowkeeper.Flowkeeper.metainfo.xml AppDir/usr/share/metainfo/org.flowkeeper.Flowkeeper.appdata.xml\necho \"2. Copied application files\"\n\n# 3. Create a desktop shortcut\nexport FK_AUTOSTART_ARGS=\"\"\n< ../scripts/linux/common/org.flowkeeper.Flowkeeper.desktop envsubst > AppDir/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\ncd AppDir\nln -s usr/share/applications/org.flowkeeper.Flowkeeper.desktop org.flowkeeper.Flowkeeper.desktop\necho \"3. Created a desktop shortcut:\"\ncat org.flowkeeper.Flowkeeper.desktop\ncd ..\n\n# 4. Create AppRun symlink\ncd AppDir\nln -s ./usr/lib/flowkeeper/Flowkeeper ./AppRun\ncd ..\necho \"4. Created AppRun symlink\"\n\n# 5. Create .DirIcon symlink\ncd AppDir\nln -s usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png ./.DirIcon\nln -s usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png ./org.flowkeeper.Flowkeeper.png\ncd ..\necho \"5. Create .DirIcon symlink\"\n\n# 6. Build AppImage file\nls -al AppDir/\nappimagetool AppDir\necho \"6. Built AppImage file: $(ls ./*.AppImage)\"\n\nmv ./*.AppImage ../dist\n"
  },
  {
    "path": "scripts/linux/common/flowkeeper",
    "content": "#!/bin/bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nPYTHONPATH=/usr/lib/flowkeeper:/usr/libexec/flowkeeper python3 -m fk.desktop.desktop $@\n"
  },
  {
    "path": "scripts/linux/common/org.flowkeeper.Flowkeeper.desktop",
    "content": "[Desktop Entry]\nName=Flowkeeper\nComment=Pomodoro Technique desktop timer for power users\nExec=/usr/bin/flowkeeper $FK_AUTOSTART_ARGS\nIcon=org.flowkeeper.Flowkeeper\nTerminal=false\nType=Application\nCategories=Utility;\n"
  },
  {
    "path": "scripts/linux/common/org.flowkeeper.Flowkeeper.metainfo.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<component type=\"desktop-application\">\n    <id>org.flowkeeper.Flowkeeper</id>\n\n    <name>Flowkeeper</name>\n\n    <summary>Pomodoro timer for power users</summary>\n\n    <url type=\"homepage\">https://flowkeeper.org</url>\n\n    <metadata_license>MIT</metadata_license>\n\n    <project_license>GPL-3.0-only</project_license>\n\n    <description>\n        <p>\n            Flowkeeper is a Pomodoro timer with a &quot;classic&quot; cross-platform UI paradigm (desktop-first, no Electron).\n            With its keyboard shortcuts and advanced settings, Flowkeeper is optimized for power users. It stays as\n            close as possible to the Pomodoro Technique definition and format from the original book by Francesco\n            Cirillo.\n        </p>\n    </description>\n\n    <launchable type=\"desktop-id\">org.flowkeeper.Flowkeeper.desktop</launchable>\n\n    <screenshots>\n        <screenshot type=\"default\">\n            <image>https://flowkeeper.org/images/releases/v1.0.1/Linux/fulls/19-main-dark-window-border.png</image>\n        </screenshot>\n        <screenshot>\n            <image>https://flowkeeper.org/images/releases/v1.0.1/Linux/fulls/14-stats-month-window-border.png</image>\n        </screenshot>\n        <screenshot>\n            <image>https://flowkeeper.org/images/releases/v1.0.1/Linux/fulls/16-work-summary-window-border.png</image>\n        </screenshot>\n    </screenshots>\n\n    <branding>\n      <color type=\"primary\" scheme_preference=\"light\">#f6f5f4</color>\n      <color type=\"primary\" scheme_preference=\"dark\">#241f31</color>\n    </branding>\n\n    <provides>\n        <binary>flowkeeper</binary>\n    </provides>\n\n    <releases>\n        <release version=\"1.0.2\" date=\"2025-09-26\">\n            <description>\n                <p>Bugfix: Can't void a pomodoro or record an interruption in non-latest backlogs (#199, #195).</p>\n                <p>Bugfix: An occasional exception when waking up from sleep (#197).</p>\n            </description>\n        </release>\n    </releases>\n\n    <developer id=\"org.flowkeeper\">\n        <name>Flowkeeper Team</name>\n    </developer>\n\n    <content_rating type=\"oars-1.0\" />\n</component>\n"
  },
  {
    "path": "scripts/linux/debian/debian-control",
    "content": "Package: flowkeeper\nVersion: $FK_VERSION\nMaintainer: Constantine Kulak\nArchitecture: amd64\nDescription: Flowkeeper is a free Pomodoro Technique desktop timer for power users.\n"
  },
  {
    "path": "scripts/linux/debian/debian-control-min",
    "content": "Package: flowkeeper\nVersion: $FK_VERSION\nMaintainer: Constantine Kulak\nArchitecture: amd64\nDescription: Flowkeeper is a free Pomodoro Technique desktop timer for power users.\nDepends: python3-pyside6.qtcore(>= 6.7.0), python3-pyside6.qtwidgets(>= 6.7.0), python3-pyside6.qtgui(>= 6.7.0), python3-pyside6.qtnetwork(>= 6.7.0), python3-pyside6.qtnetworkauth(>= 6.7.0), python3-pyside6.qtmultimedia(>= 6.7.0), python3-pyside6.qtsvg(>= 6.7.0), python3-pyside6.qtwebsockets(>= 6.7.0), python3-pyside6.qtuitools(>= 6.7.0), python3-pyside6.qtasyncio(>= 6.7.0), python3-pyside6.qtcharts(>= 6.7.0), python3-semantic-version, python3-cryptography, python3-keyring\n"
  },
  {
    "path": "scripts/linux/debian/package-deb-min.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# In the next version(s) think of installing it in /opt/Flowkeeper instead\n\n# 1. Get the version\necho \"1. Version = $FK_VERSION\"\n\n# 2. Prepare temp folder\ndist=\"build/deb\"\nrm -rf \"$dist\"\nmkdir \"$dist\"\necho \"2. Prepared temp folder\"\n\n# 3. Copy application files\nmkdir -p \"$dist/usr/lib/flowkeeper\"\ncp -r src/* \"$dist/usr/lib/flowkeeper/\"\n\nmkdir -p \"$dist/usr/share/icons/hicolor/1024x1024/apps\"\nmkdir -p \"$dist/usr/share/icons/hicolor/48x48/apps\"\ncp res/flowkeeper.png \"$dist/usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png\"\ncp flowkeeper-48x48.png \"$dist/usr/share/icons/hicolor/48x48/apps/org.flowkeeper.Flowkeeper.png\"\n\nmkdir -p \"$dist/usr/bin\"\ncp scripts/linux/common/flowkeeper \"$dist/usr/bin/flowkeeper\"\necho \"3. Copied application files\"\n\n# 4. Create a desktop shortcut\nmkdir -p \"$dist/usr/share/applications\"\nexport FK_AUTOSTART_ARGS=\"\"\n< scripts/linux/common/org.flowkeeper.Flowkeeper.desktop envsubst > \"$dist/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\"\necho \"4. Created a desktop shortcut:\"\ncat \"$dist/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\"\n\n# 5. Create another one for autostart (with --autostart argument)\nmkdir -p \"$dist/etc/xdg/autostart\"\nexport FK_AUTOSTART_ARGS=\"--autostart\"\n< scripts/linux/common/org.flowkeeper.Flowkeeper.desktop envsubst > \"$dist/etc/xdg/autostart/org.flowkeeper.Flowkeeper.desktop\"\necho \"5. Added it to autostart\"\n\n# 6. Create metadata\nmkdir \"$dist/DEBIAN\"\n< scripts/linux/debian/debian-control-min envsubst > \"$dist/DEBIAN/control\"\necho \"6. Created metadata\"\ncat \"$dist/DEBIAN/control\"\n\n# 7. Build DEB file\ndpkg-deb --build \"$dist\" dist/flowkeeper-min.deb\necho \"7. Built DEB file\"\n"
  },
  {
    "path": "scripts/linux/debian/package-deb.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# In the next version(s) think of installing it in /opt/Flowkeeper instead\n\n# 1. Get the version\necho \"1. Version = $FK_VERSION\"\n\n# 2. Prepare temp folder\ncd build\nrm -rf deb\nmkdir deb\necho \"2. Prepared temp folder\"\n\n# 3. Copy application files\nmkdir -p deb/usr/lib/flowkeeper\nmkdir -p deb/usr/share/icons/hicolor/{48x48,1024x1024}/apps\ncp -r ../dist/standalone/* deb/usr/lib/flowkeeper/\ncp ../res/flowkeeper.png deb/usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png\ncp ../flowkeeper-48x48.png deb/usr/share/icons/hicolor/48x48/apps/org.flowkeeper.Flowkeeper.png\necho \"3. Copied application files\"\n\n# 4. Create a desktop shortcut\nmkdir -p deb/usr/share/applications\nexport FK_AUTOSTART_ARGS=\"\"\n< ../scripts/linux/common/org.flowkeeper.Flowkeeper.desktop envsubst > deb/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\necho \"4. Created a desktop shortcut:\"\ncat deb/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\n\n# 5. Create another one for autostart (with --autostart argument)\nmkdir -p deb/etc/xdg/autostart\nexport FK_AUTOSTART_ARGS=\"--autostart\"\n< ../scripts/linux/common/org.flowkeeper.Flowkeeper.desktop envsubst > deb/etc/xdg/autostart/org.flowkeeper.Flowkeeper.desktop\necho \"5. Added it to autostart\"\n\n# 6. Create a relative symlink in /usr/bin\nmkdir -p deb/usr/bin\ncd deb/usr/bin\nln -s ../lib/flowkeeper/Flowkeeper ./flowkeeper\ncd ../../..\necho \"6. Create a relative symlink in /usr/bin\"\n\n# 7. Create metadata\nmkdir deb/DEBIAN\n< ../scripts/linux/debian/debian-control envsubst > deb/DEBIAN/control\necho \"7. Created metadata\"\ncat deb/DEBIAN/control\n\n# 8. Build DEB file\ndpkg-deb --build deb ../dist/flowkeeper.deb\necho \"8. Built DEB file\"\n"
  },
  {
    "path": "scripts/linux/flatpak/README.md",
    "content": "# Flowkeeper in Flatpak\n\n- End-user link: https://flathub.org/apps/org.flowkeeper.Flowkeeper\n- Fork repo: https://github.com/flowkeeper-org/org.flowkeeper.Flowkeeper\n- Upstream repo: https://github.com/flathub/org.flowkeeper.Flowkeeper\n\n## Build locally\n\n```shell\nflatpak-builder --force-clean --user --install-deps-from=flathub --repo=repo --install builddir org.flowkeeper.Flowkeeper.yaml\nflatpak run org.flowkeeper.Flowkeeper\nflatpak uninstall org.flowkeeper.Flowkeeper\n```\n\nUpdate the fork repo and open a PR to upstream. Wait till the build pipeline passes.\n"
  },
  {
    "path": "scripts/linux/obs/README.md",
    "content": "# Updating OBS build\n\nStep 1: Download the latest tar.gz\n\nStep 2: Update `flowkeeper.spec` if needed\n\nStep 3: Update the changelog\n\nStep 4: Commit the changes:\n\n```bash\nosc delete <old.tar.gz>\nosc add *\nosc commit\n```\n\nStep 5: Check the build job status\n\nStep 6: Test it: `sudo zypper install flowkeeper`\n"
  },
  {
    "path": "scripts/linux/obs/obs.spec",
    "content": "#\n# spec file for package flowkeeper\n#\n#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\n\nName:           flowkeeper\nVersion:        1.0.0\nRelease:        0\nSummary:        Pomodoro Technique desktop timer for power users\nLicense:        GPL-3.0-only\nGroup:          Productivity/Text/Utilities\nURL:            https://flowkeeper.org/\nSource0:        https://github.com/flowkeeper-org/fk-desktop/release/fk-desktop-%{version}.tar.gz\nBuildRequires:  python3-pyside6\nBuildRequires:  python3-semantic_version\nBuildRequires:  python3-cryptography\nBuildRequires:  python3-keyring\nRequires:       python3-pyside6\nRequires:       python3-semantic_version\nRequires:       python3-cryptography\nRequires:       python3-keyring\nBuildArch:      noarch\n\n%description\nFlowkeeper is a Pomodoro timer with a \"classic\" cross-platform UI paradigm\n(desktop-first, no Electron). With its keyboard shortcuts and advanced settings,\nFlowkeeper is optimized for power users. It stays as close as possible to the\nPomodoro Technique definition and format from the original book by Francesco\nCirillo.\n\nFlowkeeper stores data in $XDG_DATA_HOME/Flowkeeper.\n\n%prep\n%setup -q -n \"fk-desktop-%{version}\"\n\n%build\ncd res\nqrc=\"resources.qrc\"\n/usr/libexec/qt6/rcc --project -o \"$qrc\"\n/usr/libexec/qt6/rcc -g python \"$qrc\" -o \"../src/fk/desktop/resources.py\"\nrm \"$qrc\"\n\n%install\nmkdir -p \"%{buildroot}%{_libexecdir}/flowkeeper\"\ncp -r src/* \"%{buildroot}%{_libexecdir}/flowkeeper/\"\n\nmkdir -p \"%{buildroot}%{_datadir}/icons/hicolor/1024x1024/apps\"\nmkdir -p \"%{buildroot}%{_datadir}/icons/hicolor/48x48/apps\"\ncp -av res/flowkeeper.png \"%{buildroot}%{_datadir}/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png\"\ncp -av flowkeeper-48x48.png \"%{buildroot}%{_datadir}/icons/hicolor/48x48/apps/org.flowkeeper.Flowkeeper.png\"\n\nmkdir -p \"%{buildroot}%{_bindir}\"\ncp -av installer/flowkeeper \"%{buildroot}%{_bindir}/flowkeeper\"\necho \"3. Copied application files\"\n\nmkdir -p \"%{buildroot}%{_datadir}/applications\"\nexport FK_AUTOSTART_ARGS=\"\"\n< installer/org.flowkeeper.Flowkeeper.desktop envsubst > \"%{buildroot}%{_datadir}/applications/org.flowkeeper.Flowkeeper.desktop\"\n\n%check\n\n%files\n%doc README.md\n%license LICENSE\n%{_datadir}/applications/org.flowkeeper.Flowkeeper.desktop\n%{_datadir}/icons/hicolor/\n%{_libexecdir}/flowkeeper/\n%{_bindir}/flowkeeper\n\n%changelog\n-------------------------------------------------------------------\nMon Feb 17 15:46:00 UTC 2025 - Constantine Kulak <contact@flowkeeper.org>\n\n- Ability to track unfocused time, try to start a work item with no pomodoros (#94, #98).\n- Ability to drag work items between backlogs (#60).\n- Voided pomodoros are displayed as ticks, and completed ones are displayed as crosses to better match the Book (#41, #92).\n- Hovering over pomodoros displays a detailed log of your work (#93).\n- Flowkeeper window now hides automatically on auto-start (#102).\n- Standard data and log directories are used on Linux, macOS and Windows (#65).\n- Import from CSV, try Ctrl+I (#125).\n- You can now find Flowkeeper on Flathub (#63).\n- We now build Flowkeeper for ARM (arm64 / aarch64).\n- We now build an AppImage binary for Flowkeeper.\n- \"Contact us\" submenu to facilitate feedback collection (#111).\n- [Technical] Added support for Qt 6.8.1.\n- [Bugfix] Window icon on Wayland (#110).\n\n"
  },
  {
    "path": "scripts/linux/package-nuitka.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\nsource venv/bin/activate\n\nFK_VERSION=$(scripts/common/get-version.sh)\n\nPYTHONPATH=src python3 -m nuitka \\\n  --onefile \\\n  --enable-plugin=pyside6 \\\n  --include-qt-plugins=multimedia \\\n  --product-name=Flowkeeper \\\n  --product-version=\"$FK_VERSION\" \\\n  --output-dir=build \\\n  --output-file=Flowkeeper \\\n  src/fk/desktop/desktop.py\n"
  },
  {
    "path": "scripts/linux/rpm/package-rpm-min.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# In the next version(s) think of installing it in /opt/Flowkeeper instead\n\n# 1. Get the version\nVERSION_REGEX='^### v(.+) \\(.*$'\nVERSION_LINE=$(head --lines=1 res/CHANGELOG.txt)\nif [[ $VERSION_LINE =~ $VERSION_REGEX ]]; then\n\texport FK_VERSION=\"${BASH_REMATCH[1]}\"\nelse\n\texport FK_VERSION=\"N/A\"\nfi\necho \"1. Version = $FK_VERSION\"\n\n# 2. Prepare temp folder\ndist=\"dist/rpm\"\nrm -rf \"$dist\"\nmkdir -p \"$dist\"\necho \"2. Prepared temp folder\"\n\n# 3. Copy application files\nmkdir -p \"$dist/usr/lib/flowkeeper\"\ncp -r src/* \"$dist/usr/lib/flowkeeper/\"\n\nmkdir -p \"$dist/usr/share/icons/hicolor/1024x1024/apps\"\nmkdir -p \"$dist/usr/share/icons/hicolor/48x48/apps\"\ncp res/flowkeeper.png \"$dist/usr/share/icons/hicolor/1024x1024/apps/org.flowkeeper.Flowkeeper.png\"\ncp flowkeeper-48x48.png \"$dist/usr/share/icons/hicolor/48x48/apps/org.flowkeeper.Flowkeeper.png\"\n\nmkdir -p \"$dist/usr/bin\"\ncp installer/flowkeeper \"$dist/usr/bin/flowkeeper\"\necho \"3. Copied application files\"\n\n# 4. Create a desktop shortcut\nmkdir -p \"$dist/usr/share/applications\"\nexport FK_AUTOSTART_ARGS=\"\"\n< installer/org.flowkeeper.Flowkeeper envsubst > \"$dist/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\"\necho \"4. Created a desktop shortcut:\"\ncat \"$dist/usr/share/applications/org.flowkeeper.Flowkeeper.desktop\"\n\n# 5. Create another one for autostart (with --autostart argument)\nmkdir -p \"$dist/etc/xdg/autostart\"\nexport FK_AUTOSTART_ARGS=\"--autostart\"\n< installer/org.flowkeeper.Flowkeeper envsubst > \"$dist/etc/xdg/autostart/org.flowkeeper.Flowkeeper.desktop\"\necho \"5. Added it to autostart\"\n"
  },
  {
    "path": "scripts/macos/create-dmg.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\necho \"Creating DMG\"\nls -al dist/\n\ncreate-dmg \\\n  --volname \"Flowkeeper Installer\" \\\n  --volicon \"flowkeeper.icns\" \\\n  --window-pos 200 120 \\\n  --window-size 800 400 \\\n  --icon-size 100 \\\n  --icon \"Flowkeeper.app\" 200 190 \\\n  --hide-extension \"Flowkeeper.app\" \\\n  --app-drop-link 600 185 \\\n  \"dist/Flowkeeper.dmg\" \\\n  \"dist/standalone\"\n"
  },
  {
    "path": "scripts/macos/create-icons.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# Adapted from https://apple.stackexchange.com/questions/402621/convert-png-image-icon-to-icns-file-macos\nmkdir tmp.iconset\nsips -z 16 16     res/flowkeeper.png --out tmp.iconset/icon_16x16.png\nsips -z 32 32     res/flowkeeper.png --out tmp.iconset/icon_16x16@2x.png\nsips -z 32 32     res/flowkeeper.png --out tmp.iconset/icon_32x32.png\nsips -z 64 64     res/flowkeeper.png --out tmp.iconset/icon_32x32@2x.png\nsips -z 128 128   res/flowkeeper.png --out tmp.iconset/icon_128x128.png\nsips -z 256 256   res/flowkeeper.png --out tmp.iconset/icon_128x128@2x.png\nsips -z 256 256   res/flowkeeper.png --out tmp.iconset/icon_256x256.png\nsips -z 512 512   res/flowkeeper.png --out tmp.iconset/icon_256x256@2x.png\nsips -z 512 512   res/flowkeeper.png --out tmp.iconset/icon_512x512.png\ncp res/flowkeeper.png tmp.iconset/icon_512x512@2x.png\niconutil -c icns tmp.iconset\nrm -R tmp.iconset\nmv tmp.icns flowkeeper.icns\n"
  },
  {
    "path": "scripts/macos/install-certificates.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development\n\n# create variables\nCERTIFICATE_PATH=\"$RUNNER_TEMP/build_certificate.p12\"\nKEYCHAIN_PATH=\"$RUNNER_TEMP/app-signing.keychain-db\"\n\n# import certificate and provisioning profile from secrets\necho -n \"$BUILD_CERTIFICATE_BASE64\" | base64 --decode -o \"$CERTIFICATE_PATH\"\n\n# create temporary keychain\nsecurity create-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\nsecurity set-keychain-settings -lut 21600 \"$KEYCHAIN_PATH\"\nsecurity unlock-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n\n# import certificate to keychain\nsecurity import \"$CERTIFICATE_PATH\" -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k \"$KEYCHAIN_PATH\"\nsecurity set-key-partition-list -S \"apple-tool:,apple:\" -k \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\nsecurity list-keychain -d user -s \"$KEYCHAIN_PATH\"\n"
  },
  {
    "path": "scripts/macos/install-create-dmg.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\nif ! command -v create-dmg 2>&1 >/dev/null; then\n  echo \"create-dmg is not found, will try to install\"\n  brew install create-dmg\nfi\n"
  },
  {
    "path": "scripts/macos/notarize-dmg.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\necho \"Create the notary key\"\nxcrun notarytool store-credentials \"notary-key\" --apple-id \"$NOTARIZATION_ID\" --team-id \"$NOTARIZATION_TEAM\" --password \"$NOTARIZATION_PASSWORD\"\n\necho \"Send the DMG for notarization\"\nxcrun notarytool submit \"dist/Flowkeeper.dmg\" --keychain-profile \"notary-key\" --wait\n\necho \"Submission history\"\nxcrun notarytool log a65c29d5-dcff-48e5-a132-dc6c2b31cf84 --keychain-profile \"notary-key\"\nxcrun notarytool info a65c29d5-dcff-48e5-a132-dc6c2b31cf84 --keychain-profile \"notary-key\"\n\n"
  },
  {
    "path": "scripts/macos/package-macos-pkg.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\necho \"Example PKG build script for macOS, needed for submitting Flowkeeper to App Store\"\n\nsudo productbuild --component ./dist/Flowkeeper.app \\\n  /Applications \\\n  --sign \"Developer ID Installer: Constantine Kulak (ELWZ9S676C)\" \\\n  --product ./dist/Flowkeeper.app/Contents/Info.plist \\\n  ./dist/Flowkeeper.pkg"
  },
  {
    "path": "scripts/macos/package-nuitka.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n# Step 0 - Enter venv\nsource venv/bin/activate\n\n# Step 1 - Cleanup\nrm -rf build dist Flowkeeper.dmg\n\n# Check if $HOME/Library/Caches/Nuitka/downloads/ccache/v4.2.1/ exists, and download it from\n# https://nuitka.net/ccache/v4.2.1/ccache-4.2.1.zip if needed\n\nFK_VERSION=$(scripts/common/get-version.sh)\n\n# Step 2 - Create and sign an installer\nPYTHONPATH=src python3 -m nuitka \\\n  --standalone \\\n  --enable-plugin=pyside6 \\\n  --macos-app-icon=flowkeeper.icns \\\n  --macos-create-app-bundle \\\n  --macos-signed-app-name=org.flowkeeper.Flowkeeper \\\n  --macos-app-version=\"$FK_VERSION\" \\\n  --macos-app-name=Flowkeeper \\\n  --macos-sign-identity=\"Developer ID Application: Constantine Kulak (ELWZ9S676C)\" \\\n  --product-name=Flowkeeper \\\n  --product-version=\"$FK_VERSION\" \\\n  --output-dir=build \\\n  --output-file=Flowkeeper \\\n  src/fk/desktop/Flowkeeper.py\n\n# Step 3 - Create a DMG image\nrm -rf dist/flowkeeper\ncreate-dmg \\\n  --volname \"Flowkeeper Installer\" \\\n  --volicon \"flowkeeper.icns\" \\\n  --window-pos 200 120 \\\n  --window-size 800 400 \\\n  --icon-size 100 \\\n  --icon \"Flowkeeper.app\" 200 190 \\\n  --hide-extension \"Flowkeeper.app\" \\\n  --app-drop-link 600 185 \\\n  \"Flowkeeper.dmg\" \\\n  \"dist/standalone\"\n\n# Step 4 - Notarize the DMG\nxcrun notarytool submit dist/Flowkeeper.dmg --keychain-profile \"notary-key\" --wait\n"
  },
  {
    "path": "scripts/windows/generate-resources-windows.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\nPATH=$PATH:$(pwd)/venv/Lib/site-packages/PySide6\n\ncd res\nqrc=\"resources.qrc\"\nrcc --project -o \"$qrc\"\nrcc -g python \"$qrc\" -o \"../src/fk/desktop/resources.py\"\nrm \"$qrc\"\n"
  },
  {
    "path": "scripts/windows/install-innosetup.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\necho \"Downloading InnoSetup\"\npowershell Invoke-WebRequest -Uri \"https://files.jrsoftware.org/is/6/innosetup-6.2.2.exe\" -OutFile \"innosetup.exe\"\necho \"Launching InnoSetup\"\ncmd \"/c start /wait innosetup.exe /VERYSILENT /CURRENTUSER /SUPPRESSMSGBOXES /NOICONS\"\necho \"Installed\"\n"
  },
  {
    "path": "scripts/windows/package-installer.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Flowkeeper - Pomodoro timer for power users and teams\n# Copyright (c) 2023 Constantine Kulak\n#\n# This program is free software: you can redistribute it and/or modify\n# it under the terms of the GNU General Public License as published by\n# the Free Software Foundation; either version 3 of the License, or\n# (at your option) any later version.\n#\n# This program is distributed in the hope that it will be useful,\n# but WITHOUT ANY WARRANTY; without even the implied warranty of\n# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n# GNU General Public License for more details.\n#\n# You should have received a copy of the GNU General Public License\n# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n#\n\nset -e\n\n\"$HOME/AppData/Local/Programs/Inno Setup 6/ISCC.exe\" scripts/windows/windows-installer.iss\nmv dist/mysetup.exe dist/setup.exe\n"
  },
  {
    "path": "scripts/windows/windows-installer.iss",
    "content": "[Setup]\nAppName=Flowkeeper\nAppVersion={#GetEnv('FK_VERSION')}\nAppPublisher=flowkeeper.org\nAppPublisherURL=https://flowkeeper.org\nAppSupportURL=https://flowkeeper.org\nAppUpdatesURL=https://flowkeeper.org\nDefaultDirName={userpf}\\Flowkeeper\nDefaultGroupName=Flowkeeper\nSetupIconFile=res\\flowkeeper.ico\nUninstallDisplayIcon={app}\\Flowkeeper.exe\nPrivilegesRequired=lowest\nUninstallDisplayName=Flowkeeper\nSourceDir=..\\..\nOutputDir=dist\n\n[Tasks]\nName: \"desktopicon\"; Description: \"Create a &desktop icon\"; GroupDescription: \"Additional icons:\"\nName: \"autostart\"; Description: \"Launch Flowkeeper when the system boots\"; GroupDescription: \"Additional icons:\"\n\n[Files]\nSource: \"dist\\standalone\\*\"; DestDir: \"{app}\"; Flags: ignoreversion recursesubdirs createallsubdirs\n\n[Icons]\nName: \"{group}\\Flowkeeper\"; Filename: \"{app}\\Flowkeeper.exe\"\nName: \"{userdesktop}\\Flowkeeper\"; Filename: \"{app}\\Flowkeeper.exe\"; Tasks: desktopicon\nName: \"{userstartup}\\Flowkeeper\"; Parameters: \"--autostart\"; Filename: \"{app}\\Flowkeeper.exe\"; Tasks: autostart\n\n[Run]\nFilename: \"{app}\\Flowkeeper.exe\"; Description: \"Launch Flowkeeper\"; Flags: nowait postinstall skipifsilent\n"
  },
  {
    "path": "src/fk/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/core/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/core/abstract_cryptograph.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport secrets\nimport string\nfrom abc import ABC, abstractmethod\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.events import AfterSettingsChanged\n\n\nclass AbstractCryptograph(ABC):\n    _settings: AbstractSettings\n    key: str\n    enabled: bool\n\n    def __init__(self, settings: AbstractSettings):\n        self._settings = settings\n        self.key = self._settings.get('Source.encryption_key!')\n        self.enabled = self._settings.is_e2e_encryption_enabled()\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n        if settings.get('Source.encryption_key!') == '':\n            self._generate_key()\n\n    def _generate_key(self) -> None:\n        # UC-2: Launching FK for the first time, a random e2e encryption key is generated\n        key = ''.join(\n            secrets.choice(string.ascii_letters + string.digits) for _ in range(20)\n        )\n        self._settings.set({'Source.encryption_key!': key})\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        self.enabled = self._settings.is_e2e_encryption_enabled()\n        if 'Source.encryption_key!' in new_values:\n            self.key = new_values['Source.encryption_key!']\n            self._on_key_changed()\n\n    @abstractmethod\n    def _on_key_changed(self) -> None:\n        pass\n\n    @abstractmethod\n    def encrypt(self, s: str) -> str:\n        pass\n\n    @abstractmethod\n    def decrypt(self, s: str) -> str:\n        pass\n"
  },
  {
    "path": "src/fk/core/abstract_data_container.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Iterable, Generic, TypeVar\n\nfrom fk.core.abstract_data_item import AbstractDataItem\n\nTChild = TypeVar('TChild', bound=AbstractDataItem)\nTParent = TypeVar('TParent', bound=AbstractDataItem)\n\n\nclass AbstractDataContainer(AbstractDataItem[TParent], Generic[TChild, TParent]):\n    _name: str\n    _children: dict[str, TChild]\n    _children_with_order: list[TChild]\n\n    def __init__(self,\n                 name: str,\n                 parent: TParent,\n                 uid: str,\n                 create_date: datetime.datetime):\n        super().__init__(uid=uid, parent=parent, create_date=create_date)\n        self._name = name\n        self._children = dict()\n        self._children_with_order = list()\n\n    def __getitem__(self, uid: str) -> TChild:\n        return self._children[uid]\n\n    def __contains__(self, uid: str):\n        return uid in self._children\n\n    def __setitem__(self, uid: str, value: TChild):\n        if uid not in self._children:\n            self._children[uid] = value\n            self._children_with_order.append(value)\n\n    def __delitem__(self, uid: str):\n        old = self._children.get(uid, None)\n        del self._children[uid]\n        self._children_with_order.remove(old)\n\n    def __iter__(self) -> Iterable[str]:\n        for child in self._children_with_order:\n            yield child.get_uid()\n\n    def __len__(self):\n        return len(self._children_with_order)\n\n    def values(self) -> list[TChild]:\n        return self._children_with_order\n\n    def keys(self) -> Iterable[str]:\n        for child in self._children_with_order:\n            yield child.get_uid()\n\n    def names(self) -> list[str]:\n        return [child.get_name() for child in self.values()]\n\n    def get_name(self) -> str:\n        return self._name\n\n    def set_name(self, new_name: str) -> None:\n        self._name = new_name\n\n    def move_child(self, child: TChild, index_to: int) -> None:\n        index_from = self._children_with_order.index(child)\n        self._children_with_order.insert(index_to if index_to <= index_from else index_to - 1,\n                                         self._children_with_order.pop(index_from))\n\n    def get(self, key: str, default: TChild = None) -> TChild:\n        if key in self._children:\n            return self._children[key]\n        else:\n            return default\n\n    def supports_children(self) -> bool:\n        return True\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        if len(self) > 0:\n            children = f'\\n'.join(child.dump(indent + '  ', mask_uid, mask_last_modified) for child in self.values())\n        else:\n            children = f'{indent}  - <Empty>'\n        return f'{super().dump(indent, mask_uid, mask_last_modified)}\\n' \\\n               f'{indent}  Name: {self._name}\\n' \\\n               f'{indent}  Children:\\n' \\\n               f'{children}'\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['name'] = self._name\n        return d\n"
  },
  {
    "path": "src/fk/core/abstract_data_item.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nimport uuid\nfrom abc import ABC\nfrom typing import Iterable, TypeVar, Generic\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef generate_uid() -> str:\n    return str(uuid.uuid4())\n\n\ndef generate_unique_name(prefix: str, names: Iterable) -> str:\n    # UC-3: An incremental index is appended to the new backlog / WI name, if there's an existing duplicate\n    check = prefix\n    n = 1\n    while check in names:\n        check = f\"{prefix} {n}\"\n        n += 1\n    return check\n\n\nTParent = TypeVar('TParent', bound='AbstractDataItem')\n\n\nclass AbstractDataItem(ABC, Generic[TParent]):\n    _uid: str\n    _parent: TParent | None\n    _create_date: datetime.datetime\n    _last_modified_date: datetime.datetime\n\n    def __init__(self,\n                 uid: str,\n                 parent: TParent | None,\n                 create_date: datetime.datetime):\n        self._uid = uid\n        self._parent = parent\n        self._create_date = create_date\n        self._last_modified_date = create_date\n\n    def get_uid(self) -> str:\n        return self._uid\n\n    # Default implementation delegates to the parent\n    def get_owner(self) -> 'User':\n        # UC-3: All data objects are owned by a User, or delegated to their parent\n        return self._parent.get_owner() if self._parent is not None else None\n\n    def get_parent(self) -> TParent:\n        return self._parent\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        owner = self.get_owner()\n        owner_name = owner.get_uid() if owner is not None else 'N/A'\n        parent_uid = self._parent.get_uid() if self._parent is not None else 'N/A'\n        return f'{indent}- Class: {self.__class__.__name__}\\n' \\\n               f'{indent}  UID: {\"<MASKED>\" if mask_uid else self._uid}\\n' \\\n               f'{indent}  Owner: {owner_name}\\n' \\\n               f'{indent}  Parent UID: {\"<MASKED>\" if mask_uid else parent_uid}\\n' \\\n               f'{indent}  Create date: {self._create_date}\\n' \\\n               f'{indent}  Last modified: {\"<MASKED>\" if mask_last_modified else self._last_modified_date}'\n\n    def to_dict(self) -> dict:\n        return {\n            'uid': self._uid,\n            'create_date': self._create_date,\n            'last_modified_date': self._last_modified_date,\n        }\n\n    def get_create_date(self) -> datetime.datetime:\n        return self._create_date\n\n    def get_last_modified_date(self) -> datetime.datetime:\n        return self._last_modified_date\n\n    # Call this every time something changes\n    def item_updated(self, date: datetime.datetime = None):\n        # UC-2: Update timestamps propagate to parents. The latest timestamp is kept, they can't decrease.\n        if date is None:\n            date = datetime.datetime.now(datetime.timezone.utc)\n        # Some actions may happen retroactively, although it is unusual, so let's display a warning\n        if self._last_modified_date is None or self._last_modified_date < date:\n            self._last_modified_date = date\n        if self._parent is not None:\n            self._parent.item_updated(date)\n\n    def supports_children(self) -> bool:\n        return False\n\n    def change_parent(self, new_parent: TParent) -> None:\n        if self._parent is not None and self._parent.supports_children():\n            del self._parent[self._uid]\n            new_parent[self._uid] = self\n        self._parent = new_parent\n"
  },
  {
    "path": "src/fk/core/abstract_event_emitter.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport inspect\nimport logging\nimport re\nfrom typing import Callable\n\nfrom fk.core.events import register_event\n\nlogger = logging.getLogger(__name__)\n\n\ndef _callback_display(callback) -> str:\n    if inspect.ismethod(callback):\n        return f'{callback.__self__.__class__.__name__}[{id(callback.__self__)}].{callback.__name__}'\n    else:\n        return f'Function {callback.__name__}'\n\n\nclass AbstractEventEmitter:\n    _muted: bool\n    _connections_1: dict[str, list[Callable]]\n    # UC-2: Certain event consumers can be notified at the end\n    _connections_2: dict[str, list[Callable]]\n    _last: set[Callable]\n    _callback_invoker: Callable\n\n    def __init__(self, allowed_events: list[str], callback_invoker: Callable):\n        self._muted = False\n        self._callback_invoker = callback_invoker\n        self._connections_1 = dict()\n        self._connections_2 = dict()\n        self._last = set()\n        for event in allowed_events:\n            self._connections_1[event] = list[Callable]()\n            self._connections_2[event] = list[Callable]()\n        # We need to do it in the separate loop, because registration might already trigger subscriptions\n        for event in allowed_events:\n            register_event(event, self)\n\n    # Event subscriptions. Here event_pattern can contain * characters\n    # and other regex syntax.\n    def on(self, event_pattern: str, callback: Callable, last: bool = False) -> None:\n        regex = re.compile(event_pattern.replace('*', '.*'))\n        for event in self._connections_1:   # _connections_2 has the same list\n            if regex.match(event):\n                # UC-2: Event consumers are notified in the order of subscription\n                if not last and callback not in self._connections_1[event]:\n                    if logger.isEnabledFor(logging.DEBUG):\n                        logger.debug(f' # {_callback_display(callback)} subscribed to {self.__class__.__name__}.{event}')\n                    self._connections_1[event].append(callback)\n                elif last and callback not in self._connections_2[event]:\n                    if logger.isEnabledFor(logging.DEBUG):\n                        logger.debug(f' # {_callback_display(callback)} subscribed to {self.__class__.__name__}.{event} as the LAST handler')\n                    self._connections_2[event].append(callback)\n\n    def cancel(self, event_pattern: str) -> None:\n        regex = re.compile(event_pattern.replace('*', '.*'))\n        for event in self._connections_1:\n            if regex.match(event):\n                self._connections_1[event].clear()\n                self._connections_2[event].clear()\n\n    def unsubscribe(self, callback: Callable) -> None:\n        for callables in self._connections_1.values():\n            if callback in callables:\n                callables.remove(callback)\n        for callables in self._connections_2.values():\n            if callback in callables:\n                callables.remove(callback)\n\n    def unsubscribe_one(self, callback: Callable, event_pattern: str) -> None:\n        regex = re.compile(event_pattern.replace('*', '.*'))\n        for event in self._connections_1:\n            if regex.match(event):\n                if callback in self._connections_1[event]:\n                    self._connections_1[event].remove(callback)\n                if callback in self._connections_2[event]:\n                    self._connections_2[event].remove(callback)\n\n    def _emit(self, event: str, params: dict[str, any], carry: any = None) -> None:\n        if not self._is_muted():\n            params['event'] = event\n            if carry is not None:\n                params['carry'] = carry\n            for callback in self._connections_1[event]:\n                if logger.isEnabledFor(logging.DEBUG):\n                    logger.debug(f' ! {_callback_display(callback)}(' + str(params) + ')')\n                self._callback_invoker(callback, **params)\n            for callback in self._connections_2[event]:\n                if logger.isEnabledFor(logging.DEBUG):\n                    logger.debug(f' ! {_callback_display(callback)}(' + str(params) + ')')\n                self._callback_invoker(callback, **params)\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(' < ' + self.__class__.__name__ + '._emit(' + event + ')')\n\n    def _is_muted(self) -> bool:\n        return self._muted\n\n    def unmute(self) -> None:\n        logger.debug('Unmuting events')\n        self._muted = False\n\n    def mute(self) -> None:\n        logger.debug('Muting events')\n        self._muted = True\n"
  },
  {
    "path": "src/fk/core/abstract_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport logging\nfrom abc import ABC, abstractmethod\nfrom datetime import timedelta\nfrom typing import Iterable, Callable, TypeVar, Generic\n\nfrom fk.core import events\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_serializer import AbstractSerializer\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog import Backlog\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_TRACKER\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy\nfrom fk.core.tag import Tag\nfrom fk.core.tenant import ADMIN_USER, Tenant\nfrom fk.core.timer_data import TimerData\nfrom fk.core.timer_strategies import TimerRingInternalStrategy, StartTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.user_strategies import CreateUserStrategy, AutoSealInternalStrategy\nfrom fk.core.workitem import Workitem\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot', bound=Tenant)\n\n\nclass AbstractEventSource(AbstractEventEmitter, ABC, Generic[TRoot]):\n\n    _serializer: AbstractSerializer\n    _settings: AbstractSettings\n    _cryptograph: AbstractCryptograph\n    _last_seq: int\n    _estimated_count: int\n    _ignore_invalid_sequences: bool\n    _ignore_errors: bool\n\n    def __init__(self,\n                 serializer: AbstractSerializer,\n                 settings: AbstractSettings,\n                 cryptograph: AbstractCryptograph):\n        AbstractEventEmitter.__init__(self, [\n            events.BeforeUserCreate,\n            events.AfterUserCreate,\n            events.BeforeUserDelete,\n            events.AfterUserDelete,\n            events.BeforeUserRename,\n            events.AfterUserRename,\n            events.BeforeBacklogCreate,\n            events.AfterBacklogCreate,\n            events.BeforeBacklogDelete,\n            events.AfterBacklogDelete,\n            events.BeforeBacklogRename,\n            events.AfterBacklogRename,\n            events.BeforeBacklogReorder,\n            events.AfterBacklogReorder,\n            events.BeforeWorkitemCreate,\n            events.AfterWorkitemCreate,\n            events.BeforeWorkitemComplete,\n            events.AfterWorkitemComplete,\n            events.BeforeWorkitemStart,\n            events.AfterWorkitemStart,\n            events.BeforeWorkitemDelete,\n            events.AfterWorkitemDelete,\n            events.BeforeWorkitemRename,\n            events.AfterWorkitemRename,\n            events.BeforeWorkitemReorder,\n            events.AfterWorkitemReorder,\n            events.BeforeWorkitemMove,\n            events.AfterWorkitemMove,\n            events.BeforePomodoroAdd,\n            events.AfterPomodoroAdd,\n            events.BeforePomodoroRemove,\n            events.AfterPomodoroRemove,\n            events.BeforePomodoroWorkStart,\n            events.AfterPomodoroWorkStart,\n            events.BeforePomodoroRestStart,\n            events.AfterPomodoroRestStart,\n            events.BeforePomodoroComplete,\n            events.AfterPomodoroComplete,\n            events.BeforePomodoroVoided,\n            events.AfterPomodoroVoided,\n            events.BeforePomodoroInterrupted,\n            events.AfterPomodoroInterrupted,\n            events.TagCreated,\n            events.TagDeleted,\n            events.TagContentChanged,\n            events.SourceMessagesRequested,\n            events.SourceMessagesProcessed,\n            events.BeforeMessageProcessed,\n            events.AfterMessageProcessed,\n            events.PongReceived,\n            events.TimerWorkStart,\n            events.TimerRestComplete,\n            events.TimerWorkComplete,\n        ], settings.invoke_callback)\n        # TODO - Generate client uid for each connection. This will help us do master/slave for strategies.\n        self._serializer = serializer\n        self._settings = settings\n        self._cryptograph = cryptograph\n        self._last_seq = 0\n        self._estimated_count = 0\n        self._ignore_invalid_sequences = settings.get('Source.ignore_invalid_sequence') == 'True'\n        self._ignore_errors = settings.get('Source.ignore_errors') == 'True'\n\n    # Override\n    @abstractmethod\n    def get_data(self) -> TRoot:\n        pass\n\n    # Override\n    @abstractmethod\n    def get_name(self) -> str:\n        pass\n\n    def get_config_parameter(self, name: str) -> str:\n        return self._settings.get(name)\n\n    def set_config_parameters(self, values: dict[str, str]) -> None:\n        self._settings.set(values)\n\n    # Assuming those strategies have been already executed. We do not replay them here.\n    # Override\n    @abstractmethod\n    def _append(self, strategies: list[AbstractStrategy[TRoot]]) -> None:\n        pass\n\n    # This will initiate connection, which will trigger replay\n    @abstractmethod\n    def start(self, mute_events: bool = True) -> None:\n        pass\n\n    def _auto_seal_at_the_end(self, last_executed: AbstractStrategy) -> None:\n        if last_executed is not None:\n            sealant = AutoSealInternalStrategy(last_executed.get_sequence(),\n                                               datetime.datetime.now(datetime.timezone.utc),\n                                               last_executed.get_user_identity(),\n                                               [],\n                                               self.get_settings(),\n                                               None)\n            self.execute_prepared_strategy(sealant, True, False)\n\n    def _auto_seal(self, strategy: AbstractStrategy[TRoot], second_time: bool = False):\n        timer: TimerData = strategy.get_user(self.get_data()).get_timer()\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f'Auto-sealing, timer: {timer}, second time: {second_time}')\n        if second_time:\n            pass\n        if timer.is_ticking():\n            expected_timer_ring = timer.get_next_state_change()\n            if expected_timer_ring is not None:\n                if logger.isEnabledFor(logging.DEBUG):\n                    logger.debug(f'Expected to ring at {expected_timer_ring}, comparing to {strategy.get_when()}')\n                # Adding one-second margin to account for the series mode, where the next pomodoro starts automatically\n                # on timer immediately after the previous one finishes. In this case we depend on the timers accuracy,\n                # which in practice results in ~0.3s errors back and forth. Sometimes this results in the \"Can't start\n                # next pomodoro because the timer is still ticking\" errors when loading strategies.\n                if strategy.get_when() + timedelta(seconds=1) >= expected_timer_ring:\n                    # Timer rings, maybe even twice\n                    strategy.execute_another(self._emit,\n                                             self.get_data(),\n                                             TimerRingInternalStrategy,\n                                             [],\n                                             expected_timer_ring)\n                    if logger.isEnabledFor(logging.DEBUG):\n                        logger.debug(f'Rang the timer, resulting state: {timer}')\n                    if timer.is_ticking():\n                        if second_time:\n                            logger.error(f'The timer is still ticking after stopping it twice. Strategy: {strategy}')\n                            raise Exception(\"The timer refuses to ring. \"\n                                            \"This should never happen, please report it as a bug.\")\n                        self._auto_seal(strategy, True)\n\n    def execute_prepared_strategy(self,\n                                  strategy: AbstractStrategy[TRoot],\n                                  auto: bool = False,\n                                  persist: bool = False) -> None:\n        if strategy.requires_sealing():\n            self._auto_seal(strategy)\n\n        params = {\n            'strategy': strategy,\n            'auto': auto,\n            'persist': persist,\n        }\n        # UC-2: All executed strategies are wrapped in BeforeMessageProcessed / AfterMessageProcessed events.\n        self._emit(events.BeforeMessageProcessed, params)\n\n        try:\n            strategy.execute(self._emit, self.get_data())\n            self._estimated_count += 1\n            if persist:\n                self._append([strategy])\n                # UC-2: Strategy sequence is incremented only after it is persisted\n                self._last_seq = strategy.get_sequence()   # Only save it if all went well\n        finally:\n            # UC-2: AfterMessageProcessed is triggered after the strategy is persisted, no matter what\n            self._emit(events.AfterMessageProcessed, params)\n\n    def execute(self,\n                strategy_class: type[AbstractStrategy[TRoot]],\n                params: list[str],\n                persist: bool = True,\n                when: datetime.datetime = None,\n                auto: bool = False,\n                carry: any = None) -> None:\n        # This method is called when the user does something in the UI on THIS instance\n        # TODO: Get username from the login provider instead\n        if when is None:\n            when = datetime.datetime.now(datetime.timezone.utc)\n        s = strategy_class(\n            self._last_seq + 1,\n            when,\n            self._settings.get_username(),  # UC-2: Strategy owner is taken from the source settings\n            params,\n            self._settings,\n            carry)\n        self.execute_prepared_strategy(s, auto, persist)\n\n    def users(self) -> Iterable[User]:\n        for user in self.get_data().values():\n            yield user\n\n    def backlogs(self) -> Iterable[Backlog]:\n        for user in self.get_data().values():\n            for backlog in user.values():\n                yield backlog\n\n    def tags(self) -> Iterable[Tag]:\n        for user in self.get_data().values():\n            for tag in user.get_tags().values():\n                yield tag\n\n    def workitems(self) -> Iterable[Workitem]:\n        for backlog in self.backlogs():\n            for workitem in backlog.values():\n                yield workitem\n\n    def find_workitem(self, uid: str) -> Workitem | None:\n        for workitem in self.workitems():\n            if workitem.get_uid() == uid:\n                return workitem\n\n    def find_backlog(self, uid: str) -> Backlog | None:\n        for backlog in self.backlogs():\n            if backlog.get_uid() == uid:\n                return backlog\n\n    def find_tag(self, uid: str) -> Tag | None:\n        for tag in self.tags():\n            if tag.get_uid() == uid:\n                return tag\n\n    def find_user(self, identity: str) -> User | None:\n        for user in self.users():\n            if user.get_identity() == identity:\n                return user\n\n    def pomodoros(self) -> Iterable[Pomodoro]:\n        for workitem in self.workitems():\n            for pomodoro in workitem.values():\n                yield pomodoro\n\n    @abstractmethod\n    def clone(self, new_root: TRoot) -> AbstractEventSource[TRoot]:\n        pass\n\n    def _sequence_error(self, prev: int, next_: int) -> None:\n        raise Exception(f\"Strategies must go in sequence. \"\n                        f\"Received {next_} after {prev}. \"\n                        f\"To attempt a repair go to Settings > Connection > Repair.\")\n\n    @abstractmethod\n    def disconnect(self):\n        pass\n\n    def get_settings(self) -> AbstractSettings:\n        return self._settings\n\n    @abstractmethod\n    def send_ping(self) -> str | None:\n        pass\n\n    @abstractmethod\n    def can_connect(self):\n        pass\n\n    @abstractmethod\n    def repair(self) -> tuple[list[str], str | None]:\n        pass\n\n    def connect(self):\n        raise Exception('Connect is not supported on this type of event source')\n\n    def get_init_strategy(self, emit: Callable[[str, dict[str, any], any], None]) -> AbstractStrategy[TRoot]:\n        return CreateUserStrategy(1,\n                                  datetime.datetime.now(datetime.timezone.utc),\n                                  ADMIN_USER,\n                                  [self._settings.get_username(), self._settings.get_fullname()],\n                                  self._settings)\n\n    def get_last_sequence(self):\n        return self._last_seq\n\n\n# ********************* Misc. Utils *********************\n\n\ndef start_workitem(workitem: Workitem, source: AbstractEventSource) -> None:\n    settings = source.get_settings()\n\n    # TODO: Move this entire piece of logic into StartTimerStrategy\n    if len(workitem) == 0 or workitem.is_tracker():\n        # This is going to be a tracker workitem\n        source.execute(AddPomodoroStrategy, [\n            workitem.get_uid(),\n            \"1\",\n            POMODORO_TYPE_TRACKER\n        ])\n        source.execute(StartTimerStrategy, [\n            workitem.get_uid(),\n        ])\n    else:\n        rest_duration = None\n\n        if settings.get('Pomodoro.long_break_algorithm') == 'simple':\n            timer: TimerData = source.get_data().get_current_user().get_timer()\n            pomodoro_in_series = timer.get_pomodoro_in_series()\n            if pomodoro_in_series >= int(settings.get('Pomodoro.long_break_each')) - 1:\n                logger.debug('The user starts a workitem. A long break is suggested after it is completed.')\n                rest_duration = \"0\"\n\n        if rest_duration is None:  # Default to standard duration\n            logger.debug('The user starts a workitem. A short break is suggested after it is completed.')\n            rest_duration = settings.get('Pomodoro.default_rest_duration')\n\n        source.execute(StartTimerStrategy, [\n            workitem.get_uid(),\n            settings.get('Pomodoro.default_work_duration'),\n            rest_duration,\n        ])\n"
  },
  {
    "path": "src/fk/core/abstract_filesystem_watcher.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom abc import ABC, abstractmethod\nfrom typing import Callable\n\n\nclass AbstractFilesystemWatcher(ABC):\n    @abstractmethod\n    def watch(self, filename: str, callback: Callable[[str], None]):\n        pass\n\n    @abstractmethod\n    def unwatch(self, filename: str) -> None:\n        pass\n\n    @abstractmethod\n    def unwatch_all(self) -> None:\n        pass\n"
  },
  {
    "path": "src/fk/core/abstract_serializer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom abc import ABC, abstractmethod\nfrom typing import TypeVar, Generic\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\n\nT = TypeVar('T')\nTRoot = TypeVar('TRoot')\n\n\ndef sanitize_user_input(s: str) -> str:\n    return s.replace('\\n', ' ').replace('\\r', '')\n\n\nclass AbstractSerializer(ABC, Generic[T, TRoot]):\n    _settings: AbstractSettings\n    _cryptograph: AbstractCryptograph\n\n    def __init__(self, settings: AbstractSettings | None, cryptograph: AbstractCryptograph | None):\n        self._settings = settings\n        self._cryptograph = cryptograph\n\n    @abstractmethod\n    def serialize(self, s: AbstractStrategy[TRoot]) -> T:\n        pass\n\n    @abstractmethod\n    def deserialize(self, t: T) -> AbstractStrategy[TRoot] | None:\n        pass\n"
  },
  {
    "path": "src/fk/core/abstract_settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport os\nfrom abc import ABC, abstractmethod\nfrom pathlib import Path\nfrom typing import Iterable, Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.events import get_all_events\nfrom fk.core.sandbox import get_sandbox_type\n\nlogger = logging.getLogger(__name__)\n\n\ndef _get_desktop() -> [str]:\n    return [s.lower() for s in os.environ.get('XDG_SESSION_DESKTOP', '').split(':')]\n\n\ndef _is_gnome() -> bool:\n    return 'gnome' in _get_desktop()\n\n\ndef _always_show(_) -> bool:\n    return True\n\n\ndef _never_show(_) -> bool:\n    return False\n\n\ndef _show_for_simple_long_breaks(values: dict[str, str]) -> bool:\n    return values['Pomodoro.long_break_algorithm'] == 'simple'\n\n\ndef _show_for_smart_long_breaks(values: dict[str, str]) -> bool:\n    return values['Pomodoro.long_break_algorithm'] == 'smart'\n\n\ndef _show_for_gradient_eyecandy(values: dict[str, str]) -> bool:\n    return values['Application.eyecandy_type'] == 'gradient'\n\n\ndef _show_for_image_eyecandy(values: dict[str, str]) -> bool:\n    return values['Application.eyecandy_type'] == 'image'\n\n\ndef _show_for_file_source(values: dict[str, str]) -> bool:\n    return values['Source.type'] == 'local'\n\n\ndef _hide_for_ephemeral_source(values: dict[str, str]) -> bool:\n    return values['Source.type'] != 'ephemeral'\n\n\ndef _show_for_websocket_source(values: dict[str, str]) -> bool:\n    return values['Source.type'] in ('websocket', 'flowkeeper.org', 'flowkeeper.pro')\n\n\ndef _show_when_encryption_is_enabled(values: dict[str, str]) -> bool:\n    return values['Source.type'] in ('flowkeeper.org', 'flowkeeper.pro') \\\n        or values['Source.encryption_enabled'] == 'True'\n\n\ndef _show_when_encryption_is_optional(values: dict[str, str]) -> bool:\n    return values['Source.type'] in ('websocket', 'local', 'ephemeral')\n\n\ndef _show_for_custom_websocket_source(values: dict[str, str]) -> bool:\n    return values['Source.type'] == 'websocket'\n\n\ndef _show_for_basic_auth(values: dict[str, str]) -> bool:\n    return _show_for_websocket_source(values) and values['WebsocketEventSource.auth_type'] == 'basic'\n\n\ndef _show_for_google_auth(values: dict[str, str]) -> bool:\n    return _show_for_websocket_source(values) and values['WebsocketEventSource.auth_type'] == 'google'\n\n\ndef _show_if_play_alarm_enabled(values: dict[str, str]) -> bool:\n    return values['Application.play_alarm_sound'] == 'True'\n\n\ndef _show_if_signed_in(values: dict[str, str]) -> bool:\n    return _show_for_google_auth(values) and values['WebsocketEventSource.username'] != 'user@local.host'\n\n\ndef _show_if_signed_out(values: dict[str, str]) -> bool:\n    return _show_for_google_auth(values) and values['WebsocketEventSource.username'] == 'user@local.host'\n\n\ndef _show_if_play_rest_enabled(values: dict[str, str]) -> bool:\n    return values['Application.play_rest_sound'] == 'True'\n\n\ndef _show_if_madelene(values: dict[str, str]) -> bool:\n    return _show_if_play_rest_enabled(values) and values['Application.rest_sound_file'] == 'qrc:/sound/Madelene.m4a'\n\n\ndef _show_if_play_tick_enabled(values: dict[str, str]) -> bool:\n    return values['Application.play_tick_sound'] == 'True'\n\n\ndef _show_for_flatpak(values: dict[str, str]) -> bool:\n    return get_sandbox_type() == 'Flatpak'\n\n\ndef _hide_for_sandbox(values: dict[str, str]) -> bool:\n    return get_sandbox_type() is None\n\n\ndef _is_tiling_wm() -> bool:\n    wm = _get_desktop()\n    return ('hyprland' in wm\n            or 'i3' in wm\n            or 'awesome' in wm)\n\n\ndef prepare_file_for_writing(filename):\n    (Path(filename) / '..').resolve().mkdir(parents=True, exist_ok=True)\n\n\nclass AbstractSettings(AbstractEventEmitter, ABC):\n    # Category -> [(id, type, display, default, options, visibility)]\n    _definitions: dict[str, list[tuple[str, str, str, str, list[any], Callable[[dict[str, str]], bool]]]]\n    _defaults: dict[str, str]\n    _callback_invoker: Callable\n\n    def __init__(self,\n                 default_data_dir: str,\n                 default_logs_dir: str,\n                 callback_invoker: Callable,\n                 is_wayland: bool | None = None):\n        AbstractEventEmitter.__init__(self, [\n            events.BeforeSettingsChanged,\n            events.AfterSettingsChanged,\n        ], callback_invoker)\n\n        self._callback_invoker = callback_invoker\n\n        self._defaults = dict()\n        self._definitions = {\n            'General': [\n                ('Pomodoro.default_work_duration', 'duration', 'Default work duration', str(25 * 60), [1, 120 * 60], _always_show),\n                ('Pomodoro.default_rest_duration', 'duration', 'Default rest duration', str(5 * 60), [1, 60 * 60], _always_show),\n                ('Application.hide_completed', 'bool', 'Hide completed items', 'False', [], _never_show),\n                ('', 'separator', '', '', [], _always_show),\n                ('Application.feature_tags', 'bool', 'Display #tags', 'True', [], _always_show),\n                ('', 'separator', '', '', [], _always_show),\n                ('Application.check_updates', 'bool', 'Check for updates', 'True', [], _hide_for_sandbox),\n                ('Application.ignored_updates', 'str', 'Ignored updates', '', [], _never_show),\n                ('Application.singleton', 'bool', 'Single Flowkeeper instance', 'False', [], _hide_for_sandbox),\n                ('Application.hide_on_autostart', 'bool', 'Hide on autostart', 'True', [], _always_show),\n                ('', 'separator', '', '', [], _always_show),\n                ('Application.shortcuts', 'shortcuts', 'Shortcuts', '{}', [], _always_show),\n                ('Application.enable_teams', 'bool', 'Enable teams functionality', 'False', [], _never_show),\n                ('Application.show_tutorial', 'bool', 'Show tutorial on start', 'True', [], _never_show),\n                ('Application.completed_tutorial_steps', 'str', 'Completed tutrial steps', '', [], _never_show),\n                ('', 'separator', '', '', [], _always_show),\n                ('Logger.level', 'choice', 'Log level', 'WARNING', [\n                    \"ERROR:Errors only\",\n                    \"WARNING:Errors and warnings\",\n                    \"DEBUG:Verbose (use it for troubleshooting)\",\n                ], _always_show),\n                ('Logger.filename', 'file', 'Log filename', str(Path(default_logs_dir) / 'flowkeeper.log'), [], _always_show),\n                ('Application.ignore_keyring_errors', 'bool', 'Ignore keyring errors', 'False', [], _never_show),\n                ('Application.feature_connect', 'bool', 'Enable Connect feature', 'False', [], _never_show),\n                ('Application.feature_keyring', 'bool', 'Enable Keyring feature', 'False', [], _never_show),\n                ('Application.work_summary_settings', 'str', 'Work Summary UI settings', '{}', [], _never_show),\n                ('Application.last_version', 'str', 'Last Flowkeeper version', '0.0.1', [], _never_show),\n            ],\n            'Series and breaks': [\n                ('Pomodoro.long_break_algorithm', 'choice', 'Take a long break', 'simple', [\n                    'simple:After [N] completed pomodoros',\n                    # 'smart:After focusing for [X] time within the last [Y] hours',\n                    # 'done:After completing a series of pomodoros',\n                    'never:Never (let me decide)',\n                ], _always_show),\n                ('Pomodoro.long_break_each', 'int', 'N = ', '4', [1, 100], _show_for_simple_long_breaks),\n                ('Pomodoro.long_break_focus', 'duration', 'X = ', str(3 * 30 * 60), [1, 24 * 60 * 60], _show_for_smart_long_breaks),\n                ('Pomodoro.long_break_within', 'duration', 'Y = ', str(4 * 30 * 60), [1, 24 * 60 * 60], _show_for_smart_long_breaks),\n                ('', 'separator', '', '', [], _always_show),\n                ('Pomodoro.start_next_automatically', 'bool', 'Work in series', 'False', [], _always_show),\n                ('Pomodoro.series_explanation', 'label', ' ', 'In the series mode Flowkeeper will start the next\\n'\n                                                              'planned pomodoro in the same work item automatically.', [], _always_show),\n            ],\n            'Connection': [\n                ('Source.fullname', 'str', 'User full name', 'Local User', [], _never_show),\n                ('Source.type', 'choice', 'Data source', 'local', [\n                    \"local:Local file (offline)\",\n                    \"flowkeeper.org:Flowkeeper.org (EXPERIMENTAL)\",\n                    #\"flowkeeper.pro:Flowkeeper.pro\",\n                    \"websocket:Self-hosted server (EXPERIMENTAL)\",\n                    \"ephemeral:Ephemeral (in-memory, for testing purposes)\",\n                ], _always_show),\n                ('Source.ignore_errors', 'bool', 'Ignore errors', 'True', [], _always_show),\n                ('Source.ignore_invalid_sequence', 'bool', 'Ignore invalid sequences', 'True', [], _always_show),\n                ('', 'separator', '', '', [], _hide_for_ephemeral_source),\n                ('FileEventSource.filename', 'file', 'Data file', str(Path(default_data_dir) / 'flowkeeper-data.txt'), ['*.txt'], _show_for_file_source),\n                ('FileEventSource.watch_changes', 'bool', 'Watch changes', 'False', [], _show_for_file_source),\n                ('FileEventSource.repair', 'button', 'Repair', '', [], _show_for_file_source),\n                ('FileEventSource.compress', 'button', 'Compress', '', [], _show_for_file_source),\n                # UC-2: Setting \"Server URL\" is only shown for the \"Self-hosted server\" data source\n                ('WebsocketEventSource.url', 'str', 'Server URL', 'ws://localhost:8888/ws', [], _show_for_custom_websocket_source),\n                # UC-2: Setting \"Authentication\" is only shown for the \"Self-hosted server\" or \"Flowkeeper.org\" data sources\n                ('WebsocketEventSource.auth_type', 'choice', 'Authentication', 'google', [\n                    \"basic:Simple username and password\",\n                    \"google:Google account (more secure)\",\n                ], _show_for_websocket_source),\n                # UC-2: Setting \"User email\" is only shown for the \"Simple username and password\" authentication type\n                ('WebsocketEventSource.username', 'email', 'User email', 'user@local.host', [], _show_for_basic_auth),\n                ('WebsocketEventSource.consent', 'bool', 'Consent for this username', 'False', [], _never_show),\n                # UC-2: Setting \"Password\" is only shown for the \"Simple username and password\" authentication type\n                ('WebsocketEventSource.password!', 'secret', 'Password', '', [], _show_for_basic_auth),\n                ('WebsocketEventSource.refresh_token!', 'secret', 'OAuth Refresh Token', '', [], _never_show),\n                # UC-2: Button \"Sign in\" is only shown if the user is signed out, otherwise \"Sign out\" is shown\n                ('WebsocketEventSource.authenticate', 'button', 'Sign in', '', [], _show_if_signed_out),\n                ('WebsocketEventSource.logout', 'button', 'Sign out', '', [], _show_if_signed_in),\n                # UC-2: Button \"Delete my account\" is only shown if the user is signed in\n                ('WebsocketEventSource.delete_account', 'button', 'Delete my account', '', ['warning'], _show_if_signed_in),\n                ('Source.encryption_separator', 'separator', '', '', [], _always_show),\n                # UC-2: Setting \"End-to-end encryption\" is only shown if the data source is \"Local file\", \"Self-hosted server\" or \"Ephemeral\"\n                ('Source.encryption_enabled', 'bool', 'End-to-end encryption', 'False', [], _show_when_encryption_is_optional),\n                # UC-2: Setting \"End-to-end encryption key\" is only shown if \"End-to-end encryption\" is checked, or if the data source is \"Flowkeeper.org\"\n                ('Source.encryption_key!', 'key', 'End-to-end encryption key', '', [], _show_when_encryption_is_enabled),\n                ('Source.encryption_key_cache!', 'secret', 'Encryption key cache', '', [], _never_show),\n            ],\n            'Appearance': [\n                ('Application.timer_ui_mode', 'choice', 'When timer starts', 'keep' if _is_tiling_wm() else 'focus', [\n                    \"keep:Keep application window as-is\",\n                    \"focus:Switch to focus mode\",\n                    \"minimize:Hide application window\",\n                ], _always_show),\n                ('Application.always_on_top', 'bool', 'Always on top', 'False', [], _never_show if is_wayland else _always_show),\n                ('Application.focus_flavor', 'choice', 'Focus bar flavor', 'minimal', ['classic:Classic (with buttons)',\n                                                                                       'minimal:Minimalistic (with context menu)'], _always_show),\n                ('Application.tray_icon_flavor', 'choice', 'Tray icon flavor', 'classic-dark', ['thin-light:Thin, light background',\n                                                                                                'thin-dark:Thin, dark background',\n                                                                                                'classic-light:Classic, light background',\n                                                                                                'classic-dark:Classic, dark background'], _always_show),\n                ('Application.show_window_title', 'bool', 'Focus window title', str(_is_gnome() or is_wayland), [], _never_show if is_wayland else _always_show),\n                ('Application.theme', 'choice', 'Theme', 'auto', [\n                    \"auto:Detect automatically (Default)\",\n                    \"light:Light\",\n                    \"dark:Dark\",\n                    \"mixed:Mixed dark & light\",\n                    \"desert:Desert\",\n                    \"beach:Beach volley\",\n                    \"terra:Terra\",\n                    \"motel:Motel\",\n                    \"lime:Sneakers\",\n                    \"resort:Sea resort\",\n                    \"purple:Purple rain\",\n                    \"highlight:Highlight\",\n                ], _always_show),\n                ('Application.quit_on_close', 'bool', 'Quit on close', str(_is_gnome()), [], _always_show),\n                ('Application.show_main_menu', 'bool', 'Show main menu', 'False', [], _always_show),\n                ('Application.show_status_bar', 'bool', 'Show status bar', 'False', [], _never_show),\n                ('Application.show_toolbar', 'bool', 'Show toolbar', 'True', [], _always_show),\n                ('Application.show_left_toolbar', 'bool', 'Show left toolbar', 'True', [], _always_show),\n                ('Application.show_tray_icon', 'bool', 'Show tray icon', 'True', [], _always_show),\n                ('Application.eyecandy_type', 'choice', 'Header background', 'gradient', [\n                    \"default:Default\",\n                    \"image:Image\",\n                    \"gradient:Gradient\",\n                ], _always_show),\n                # UC-3: Setting \"Background image\" is only shown if \"Header background\" = \"Image\"\n                ('Application.eyecandy_image', 'file', 'Background image', ':/img/bg.jpg', ['*.png;*.jpg'], _show_for_image_eyecandy),\n                # UC-3: Setting \"Color scheme\" and button \"Surprise me!\" are only shown if \"Header background\" = \"Gradient\"\n                ('Application.eyecandy_gradient', 'choice', 'Color scheme', 'NorseBeauty', ['NorseBeauty:NorseBeauty'], _show_for_gradient_eyecandy),\n                ('Application.eyecandy_gradient_generate', 'button', 'Surprise me!', '', [], _show_for_gradient_eyecandy),\n                ('Application.window_width', 'int', 'Main window width', '700', [5, 5000], _never_show),\n                ('Application.window_height', 'int', 'Main window height', '500', [5, 5000], _never_show),\n                ('Application.window_splitter_width', 'int', 'Splitter width', '200', [0, 5000], _never_show),\n                ('Application.backlogs_visible', 'bool', 'Show backlogs', 'True', [], _never_show),\n                ('Application.users_visible', 'bool', 'Show users', 'False', [], _never_show),\n                ('Application.last_selected_backlog', 'str', 'Last selected backlog', '', [], _never_show),\n                ('Application.table_row_height', 'int', 'Table row height', '30', [0, 5000], _never_show),\n                ('Application.show_click_here_hint', 'bool', 'Show \"Click here\" hint', 'True', [], _never_show),\n            ],\n            'Fonts': [\n                ('Application.font_main_family', 'font', 'Main font family', 'Noto Sans', [], _always_show),\n                ('Application.font_main_size', 'int', 'Main font size', '10', [3, 48], _always_show),\n                ('Application.font_header_family', 'font', 'Title font family', 'Noto Sans', [], _always_show),\n                ('Application.font_header_size', 'int', 'Title font size', '24', [3, 72], _always_show),\n            ],\n            'Audio': [\n                # UC-3: Settings \"sound file\" and \"volume %\" are only shown when the corresponding \"Play ... sound\" settings are checked\n                ('Application.play_alarm_sound', 'bool', 'Play alarm sound', 'True', [], _always_show),\n                ('Application.alarm_sound_file', 'file', 'Alarm sound file', 'qrc:/sound/bell.wav', ['*.wav;*.mp3;*.m4a'], _show_if_play_alarm_enabled),\n                ('Application.alarm_sound_volume', 'int', 'Alarm volume %', '100', [0, 100], _show_if_play_alarm_enabled),\n                ('separator', 'separator', '', '', [], _always_show),\n                ('Application.play_rest_sound', 'bool', 'Play \"rest\" sound', 'True', [], _always_show),\n                ('Application.rest_sound_file', 'file', '\"Rest\" sound file', 'qrc:/sound/Madelene.m4a', ['*.wav;*.mp3;*.m4a'], _show_if_play_rest_enabled),\n                ('Application.rest_sound_copyright', 'label', 'Copyright', 'Embedded music - \"Madelene (ID 1315)\", (C) Lobo Loco\\n<https://www.musikbrause.de>, CC-BY-NC-ND', [], _show_if_madelene),\n                ('Application.rest_sound_volume', 'int', 'Rest volume %', '66', [0, 100], _show_if_play_rest_enabled),\n                ('separator', 'separator', '', '', [], _always_show),\n                ('Application.play_tick_sound', 'bool', 'Play ticking sound', 'True', [], _always_show),\n                ('Application.tick_sound_file', 'file', 'Ticking sound file', 'qrc:/sound/tick.wav', ['*.wav;*.mp3;*.m4a'], _show_if_play_tick_enabled),\n                ('Application.tick_sound_volume', 'int', 'Ticking volume %', '50', [0, 100], _show_if_play_tick_enabled),\n                ('separator', 'separator', '', '', [], _always_show),\n                ('Application.audio_output', 'choice', 'Output device', '#none', ['#none:No audio outputs detected'], _always_show),\n            ],\n            'Integration': [\n                ('Integration.callbacks_label', 'label', '', 'You can run a program for every event in the system. You can use Python f{}\\n'\n                                                             'syntax for variable substitution:\\n'\n                                                             '$ espeak \"Deleted work item {workitem.get_name()}\"\\n'\n                                                             '$ echo \"Received {event}. Available variables: {dir()}\"\\n'\n                                                             'WARNING: Placeholders are substituted as-is, without any sanitization.', [], _always_show),\n                ('Integration.flatpak_spawn', 'bool', 'Prefix commands with flatpak-spawn --host', 'True', [], _show_for_flatpak),\n                ('Integration.flatpak_spawn_label', 'label', '', 'IMPORTANT: To run commands on the host (outside of Flatpak sandbox) you have to check\\n'\n                                                                 'the above checkbox and then grant Flowkeeper access to dbus. This has a major impact\\n'\n                                                                 'on the sandbox security, so do this only when strictly necessary:\\n'\n                                                                 '$ flatpak override --user --talk-name=org.freedesktop.Flatpak org.flowkeeper.Flowkeeper', [], _show_for_flatpak),\n                ('Integration.callbacks', 'keyvalue', '', '{}', get_all_events(), _always_show),\n            ],\n        }\n        for lst in self._definitions.values():\n            for s in lst:\n                self._defaults[s[0]] = s[3]\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug('Filled defaults', self._defaults)\n\n    def invoke_callback(self, fn: Callable, **kwargs) -> None:\n        self._callback_invoker(fn, **kwargs)\n\n    @abstractmethod\n    def set(self, values: dict[str, str], force_fire=False) -> None:\n        pass\n\n    @abstractmethod\n    def is_set(self, name: str) -> bool:\n        pass\n\n    @abstractmethod\n    def get(self, name: str) -> str:\n        # Note that there's no default value -- we can get it from self._defaults\n        pass\n\n    @abstractmethod\n    def clear(self) -> None:\n        pass\n\n    @abstractmethod\n    def location(self) -> str:\n        pass\n\n    def get_username(self) -> str:\n        # UC-3: Username for local and ephemeral sources is \"user@local.host\". All strategies are executed on behalf of this user. It means that we can't have more than one user locally.\n        if self.get('Source.type') == 'local' or self.get('Source.type') == 'ephemeral':\n            return 'user@local.host'\n        else:\n            return self.get('WebsocketEventSource.username')\n\n    def is_team_supported(self) -> bool:\n        return self.get('Source.type') != 'local' and self.get('Application.enable_teams') == 'True'\n\n    def is_remote_source(self) -> bool:\n        return self.get('Source.type') in ('websocket', 'flowkeeper.org', 'flowkeeper.pro')\n\n    def get_fullname(self) -> str:\n        return self.get('Source.fullname')\n\n    def get_work_duration(self) -> float:\n        return float(self.get('Pomodoro.default_work_duration'))\n\n    def get_rest_duration(self) -> float:\n        return float(self.get('Pomodoro.default_rest_duration'))\n\n    def get_categories(self) -> Iterable[str]:\n        return self._definitions.keys()\n\n    def get_settings(self, category) -> Iterable[tuple[str, str, str, str, list[any], Callable[[dict[str, str]], bool]]]:\n        return [\n            (\n                option_id,\n                option_type,\n                option_display,\n                self.get(option_id) if option_type != 'separator' else '',\n                option_options,\n                option_visible\n            )\n            for option_id, option_type, option_display, option_default, option_options, option_visible\n            in self._definitions[category]\n        ]\n\n    def _get_property(self, option_id, n) -> str:\n        for cat in self._definitions.values():\n            for opt in cat:\n                if opt[0] == option_id:\n                    return opt[n]\n        raise Exception(f'Invalid option {option_id}')\n\n    def hide(self, option_id: str) -> None:\n        # UC-2: Some of the settings can be hidden in runtime in addition to the \"normal\" checks\n        for cat in self._definitions.values():\n            for i, opt in enumerate(cat):\n                if opt[0] == option_id:\n                    mutable = list(opt)\n                    mutable[5] = _never_show\n                    cat[i] = tuple(mutable)\n                    return\n        raise Exception(f'Invalid option {option_id}')\n\n    def get_type(self, option_id) -> str:\n        return self._get_property(option_id, 1)\n\n    def get_display_name(self, option_id) -> str:\n        return self._get_property(option_id, 2)\n\n    def get_configuration(self, option_id) -> list[any]:\n        return self._get_property(option_id, 4)\n\n    def reset_to_defaults(self) -> None:\n        # It seems to be sufficient just to clear all settings -- then defaults will be\n        # used when we do .get(name)\n        # to_set = dict[str, str]()\n        # for lst in self._definitions.values():\n        #     for option_id, option_type, option_display, option_default, option_options, option_visible in lst:\n        #         to_set[option_id] = option_default\n        self.clear()\n        # self.set(to_set)\n\n    def is_e2e_encryption_enabled(self) -> bool:\n        return _show_when_encryption_is_enabled({\n            'Source.encryption_enabled': self.get('Source.encryption_enabled'),\n            'Source.type': self.get('Source.type')\n        })\n\n    @abstractmethod\n    def is_keyring_enabled(self) -> bool:\n        pass\n\n    @abstractmethod\n    def get_auto_theme(self) -> str:\n        pass\n\n    def get_theme(self) -> str:\n        raw = self.get('Application.theme')\n        return self.get_auto_theme() if raw == 'auto' else raw\n\n    def update_default(self, name: str, value: str) -> None:\n        old = self._defaults[name]\n        self._defaults[name] = value\n        logger.debug(f'Updated default {name} from {old} to {value}. Got: {self.get(name)}')\n\n    @abstractmethod\n    def init_audio_outputs(self):\n        pass\n\n    @abstractmethod\n    def init_gradients(self):\n        pass\n\n    @abstractmethod\n    def init_fonts(self):\n        pass\n\n    @abstractmethod\n    def init_appearance(self):\n        pass\n\n    @abstractmethod\n    def init_network_access(self):\n        pass\n"
  },
  {
    "path": "src/fk/core/abstract_strategy.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nfrom abc import ABC, abstractmethod\nfrom typing import Callable, Type, Generic, TypeVar\n\nfrom fk.core import events\nfrom fk.core.abstract_data_item import AbstractDataItem\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\nTRoot = TypeVar('TRoot', bound=AbstractDataItem)\n\n\nclass AbstractStrategy(ABC, Generic[TRoot]):\n    _seq: int\n    _when: datetime.datetime\n    _params: list[str]\n    _settings: AbstractSettings\n    _user_identity: str\n    _carry: any\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        self._seq = seq\n        self._when = when\n        self._user_identity = user_identity\n        self._params = params\n        self._settings = settings\n        self._carry = carry\n\n    def get_name(self) -> str:\n        name = self.__class__.__name__\n        # UC-3: Strategy names correspond to Python class names without trailing \"...Strategy\"\n        return name[0:len(name) - 8]\n\n    def get_when(self) -> datetime.datetime:\n        return self._when\n\n    def get_user_identity(self) -> str:\n        return self._user_identity\n\n    def replace_user_identity(self, user_identity: str) -> None:\n        self._user_identity = user_identity\n\n    def get_sequence(self) -> int:\n        return self._seq\n\n    def update_sequence(self, new_seq: int) -> None:\n        self._seq = new_seq\n\n    def encryptable(self) -> bool:\n        # UC-3: All strategies should be e2e-encrypted by default\n        return True\n\n    def requires_sealing(self) -> bool:\n        # UC-3: Strategies don't require auto-sealing by default\n        return False\n\n    @abstractmethod\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: TRoot) -> None:\n        pass\n\n    def get_params(self):\n        return self._params\n\n    # This is for \"auto-executed\" strategies only. Those won't be persisted. It doesn't support auto-sealing, but\n    # that's OK since it is always called in the context of another strategy, which was auto-sealed.\n    def execute_another(self,\n                        emit: Callable[[str, dict[str, any], any], None],\n                        data: TRoot,\n                        cls: Type[AbstractStrategy[TRoot]],\n                        params: list[str],\n                        when_override: datetime.datetime | None = None,\n                        user_override: str | None = None) -> None:\n        strategy = cls(self._seq,\n                       self._when if when_override is None else when_override,\n                       self._user_identity if user_override is None else user_override,\n                       params,\n                       self._settings)\n        params = {\n            'strategy': strategy,\n            'auto': True,\n            'persist': False,\n        }\n        emit(events.BeforeMessageProcessed, params, self._carry)\n        strategy.execute(emit, data)\n        emit(events.AfterMessageProcessed, params, self._carry)\n\n    # Convenience methods\n\n    def get_user(self, data: Tenant, fail_if_not_found: bool = True) -> User | None:\n        if self._user_identity in data:\n            return data.get_user(self._user_identity)\n\n        if fail_if_not_found:\n            raise Exception(f'User \"{self._user_identity}\" not found')\n        else:\n            return None\n"
  },
  {
    "path": "src/fk/core/abstract_timer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom abc import ABC, abstractmethod\nfrom typing import Callable\n\n\nclass AbstractTimer(ABC):\n    @abstractmethod\n    def schedule(self,\n                 ms: float,\n                 callback: Callable[[dict, datetime.datetime], None],\n                 params: dict | None,\n                 once: bool = False) -> None:\n        pass\n\n    @abstractmethod\n    def cancel(self) -> None:\n        pass\n"
  },
  {
    "path": "src/fk/core/abstract_timer_display.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import AfterWorkitemDelete, AfterWorkitemComplete, AfterPomodoroRemove, TimerWorkStart, \\\n    TimerWorkComplete, TimerRestComplete, SourceMessagesProcessed\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.timer_data import TimerData\nfrom fk.core.workitem import Workitem\n\nlogger = logging.getLogger(__name__)\n\n\nclass AbstractTimerDisplay:\n    \"\"\"A timer can be in one of the five modes -- undefined, idle, working, resting, long-resting and ready AKA\n    \"Ready to start another Pomodoro?\" mode.\"\"\"\n\n    _source_holder: EventSourceHolder\n    _timer: PomodoroTimer\n    _continue_workitem: Workitem | None\n    _last_pomodoro: Pomodoro | None\n    _mode: str\n\n    @property\n    def timer(self) -> TimerData:\n        return self._source_holder.get_source().get_data().get_current_user().get_timer()\n\n    def __init__(self,\n                 timer: PomodoroTimer,\n                 source_holder: EventSourceHolder):\n        self._source_holder = source_holder\n        self._timer = timer\n        self._continue_workitem = None\n        self._last_pomodoro = None\n        self._mode = 'undefined'\n\n        if timer is not None:\n            timer.on(PomodoroTimer.TimerTick, self._on_tick)\n\n        if source_holder is not None:\n            source_holder.on(AfterSourceChanged, self._on_source_changed)\n\n    def _set_mode(self, mode):\n        old_mode = self._mode\n        if old_mode != mode:\n            # Check forbidden mode transitions\n            # UC-2: Timer displays (tray and focus) will throw an error if we transition from resting or long-resting to working, or from idle to ready, or from undefined to ready\n            if (old_mode == 'resting' and mode == 'working') or \\\n                    (old_mode == 'long-resting' and mode == 'working') or \\\n                    (old_mode == 'long-resting' and mode == 'resting') or \\\n                    (old_mode == 'resting' and mode == 'long-resting') or \\\n                    (old_mode == 'idle' and mode == 'ready') or \\\n                    (old_mode == 'undefined' and mode == 'ready'):\n                raise Exception(f'Encountered impossible timer mode change from {old_mode} to {mode}')\n            self._mode = mode\n            self.mode_changed(old_mode, mode)\n            logger.debug(f'{self.objectName()}: Timer display mode changed from {old_mode} to {mode}')\n            if mode == 'working' or mode == 'resting' or mode == 'long-resting':\n                self._on_tick()\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        self._continue_workitem = None\n        self._last_pomodoro = None\n        self._set_mode('undefined')\n        source.on(SourceMessagesProcessed, self._on_timer_initialized)\n        source.on(AfterWorkitemComplete, self._on_workitem_complete_or_delete)\n        source.on(AfterWorkitemDelete, self._on_workitem_complete_or_delete)\n        source.on(AfterPomodoroRemove, self._on_pomodoro_remove)\n        source.on(TimerWorkStart, self._on_work_start)\n        source.on(TimerWorkComplete, self._on_work_complete)\n        source.on(TimerRestComplete, self._on_rest_complete)\n\n    # We call this method if the timer display is (re)created after all messages has already fired,\n    #  e.g. when the user changes tray icon appearance settings in the middle of a pomodoro.\n    def initialized(self):\n        self._on_source_changed('manual', self._source_holder.get_source())\n        self._on_timer_initialized()\n\n    def _on_timer_initialized(self, **kwargs) -> None:\n        timer = self.timer\n        if timer.is_resting():\n            self._on_work_complete(timer.get_running_pomodoro())\n        elif timer.is_working():\n            self._on_work_start(timer)\n        else:\n            self._continue_workitem = None\n            self._set_mode('idle')\n\n    def _on_tick(self, **kwargs) -> None:\n        timer = self.timer\n        pomodoro = timer.get_running_pomodoro()\n        if pomodoro is None:\n            # This may happen if a tick was canceled, but still fired\n            return\n        timer.update_remaining_duration(None)\n        if pomodoro.get_type() == POMODORO_TYPE_NORMAL and not pomodoro.is_long_break():\n            state = 'Focus' if timer.is_working() else 'Rest'\n            state_text = f\"{state}: {timer.format_remaining_duration()} left\"\n            self.tick(pomodoro,\n                      state_text,\n                      timer.get_remaining_duration(),\n                      timer.get_planned_duration(),\n                      self._mode)\n        elif pomodoro.get_type() == POMODORO_TYPE_TRACKER:\n            self.tick(pomodoro,\n                      f'Tracking: {timer.format_elapsed_work_duration()}',\n                      pomodoro.get_elapsed_work_duration(),\n                      0,\n                      'tracking')\n        elif pomodoro.is_long_break():\n            self.tick(pomodoro,\n                      f'Long break: {timer.format_elapsed_rest_duration()}',\n                      pomodoro.get_elapsed_rest_duration(),\n                      0,\n                      'long-resting')\n\n\n    def _on_work_start(self, timer: TimerData, **kwargs) -> None:\n        # UC-3: Timer display goes into \"working\" state when work period starts\n        self._continue_workitem = timer.get_running_workitem()\n        self._last_pomodoro = timer.get_running_pomodoro()\n        self._set_mode('working')\n\n    def _on_work_complete(self, pomodoro: Pomodoro, **kwargs) -> None:\n        # UC-3: Timer display goes into \"resting\" state when work period completes\n        self._continue_workitem = pomodoro.get_parent()\n        self._last_pomodoro = pomodoro\n        self._set_mode('long-resting' if pomodoro.is_long_break() else 'resting')\n\n    def _on_rest_complete(self, pomodoro: Pomodoro, **kwargs) -> None:\n        # UC-1: Timer display goes into \"ready for next pomodoro\" state when rest completes, and the workitem has startable pomodoros\n        if self._continue_workitem is not None and pomodoro.get_parent().is_startable():\n            self._set_mode('ready')\n        else:\n            self._continue_workitem = None\n            self._set_mode('idle')\n\n    def _on_workitem_complete_or_delete(self, workitem: Workitem, **kwargs) -> None:\n        # UC-1: Timer display goes into idle state if the active workitem is deleted or completed\n        if workitem == self._continue_workitem:\n            self._continue_workitem = None\n            self._set_mode('idle')\n\n    def _on_pomodoro_remove(self, workitem: Workitem, **kwargs) -> None:\n        # UC-1: Timer display goes into idle state from \"ready for next pomodoro\" state if that pomodoro is deleted\n        if workitem == self._continue_workitem and self.timer.is_idling() and not workitem.is_startable():\n            self._continue_workitem = None\n            self._set_mode('idle')\n\n    # Override those in the child widgets\n\n    def tick(self, pomodoro: Pomodoro, state_text: str, my_value: float, my_max: float, mode: str) -> None:\n        pass\n\n    def mode_changed(self, old_mode: str, new_mode: str) -> None:\n        pass\n\n    def kill(self) -> None:\n        if self._timer is not None:\n            self._timer.unsubscribe(self._on_tick)\n        if self._source_holder is not None:\n            self._source_holder.unsubscribe(self._on_source_changed)\n            source = self._source_holder.get_source()\n            if source is not None:\n                source.unsubscribe(self._on_timer_initialized)\n                source.unsubscribe(self._on_workitem_complete_or_delete)\n                source.unsubscribe(self._on_workitem_complete_or_delete)\n                source.unsubscribe(self._on_pomodoro_remove)\n                source.unsubscribe(self._on_work_start)\n                source.unsubscribe(self._on_work_complete)\n                source.unsubscribe(self._on_rest_complete)\n"
  },
  {
    "path": "src/fk/core/backlog.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nfrom typing import Iterable, Tuple\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.workitem import Workitem\n\n\nclass Backlog(AbstractDataContainer[Workitem, 'User']):\n    \"\"\"Backlog is a named list of workitems, belonging to a User.\"\"\"\n    _date_work_started: datetime.datetime | None\n\n    def __init__(self,\n                 name: str,\n                 user: 'User',\n                 uid: str,\n                 create_date: datetime.datetime):\n        super().__init__(name=name, parent=user, uid=uid, create_date=create_date)\n        self._date_work_started = None\n\n    def __str__(self):\n        return f'Backlog \"{self._name}\"'\n\n    def get_running_workitem(self) -> Tuple[Workitem, Pomodoro] | Tuple[None, None]:\n        for workitem in self.values():\n            for pomodoro in workitem.values():\n                if pomodoro.is_running():\n                    return workitem, pomodoro\n        return None, None\n\n    def get_incomplete_workitems(self) -> Iterable[Workitem]:\n        for workitem in self.values():\n            if not workitem.is_sealed():\n                yield workitem\n\n    def is_today(self) -> bool:\n        # \"Today\" = Backlog date corresponds to today's date\n        # UC-3: The backlog is marked as \"today\" if it was created on the same date as today (day, month, year)\n        return datetime.date.today() == self.get_create_date().date()\n\n    def get_owner(self) -> 'User':\n        return self._parent\n\n    def get_start_date(self) -> datetime.datetime | None:\n        return self._date_work_started\n\n    def update_start_date(self, when: datetime.datetime) -> None:\n        if self._date_work_started is None or self._date_work_started > when:\n            self._date_work_started = when\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['date_work_started'] = self._date_work_started\n        return d\n"
  },
  {
    "path": "src/fk/core/backlog_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog import Backlog\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.workitem_strategies import DeleteWorkitemStrategy\n\n\n# CreateBacklog(\"123-456-789\", \"The first backlog\")\n@strategy\nclass CreateBacklogStrategy(AbstractStrategy[Tenant]):\n    _backlog_uid: str\n    _backlog_name: str\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._backlog_uid = params[0]\n        self._backlog_name = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n        # UC-2: An exception is raised if we try to create a User, Backlog or Workitem with a duplicate UID within its direct parent\n        if self._backlog_uid in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" already exists')\n\n        emit(events.BeforeBacklogCreate, {\n            'backlog_name': self._backlog_name,\n            'backlog_owner': user,\n            'backlog_uid': self._backlog_uid,\n        }, self._carry)\n        backlog = Backlog(self._backlog_name, user, self._backlog_uid, self._when)\n        user[self._backlog_uid] = backlog\n        backlog.item_updated(self._when)    # This will also update the User\n        emit(events.AfterBacklogCreate, {\n            'backlog': backlog\n        }, self._carry)\n\n\n# DeleteBacklog(\"123-456-789\", \"\")\n@strategy\nclass DeleteBacklogStrategy(AbstractStrategy[Tenant]):\n    _backlog_uid: str\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._backlog_uid = params[0]\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n\n        # UC-2: Trying to delete a User, Backlog or Workitem by ID which doesn't exist in its direct parent, will throw an exception\n        if self._backlog_uid not in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" not found')\n        backlog = user[self._backlog_uid]\n\n        params = {\n            'backlog': backlog\n        }\n        emit(events.BeforeBacklogDelete, params, self._carry)\n\n        # UC-1: Deleting a backlog will first delete all children workitems recursively\n        # First delete all workitems recursively\n        for workitem in list(backlog.values()):\n            self.execute_another(emit,\n                                 data,\n                                 DeleteWorkitemStrategy,\n                                 [workitem.get_uid()])\n        backlog.item_updated(self._when)    # This will also update the User\n\n        # Now we can delete the backlog itself\n        del user[self._backlog_uid]\n\n        # UC-3: The strategies which do something recursively, wrap inner logic in the Before/After events\n        emit(events.AfterBacklogDelete, params, self._carry)\n\n\n# RenameBacklog(\"123-456-789\", \"New name\")\n@strategy\nclass RenameBacklogStrategy(AbstractStrategy[Tenant]):\n    _backlog_uid: str\n    _backlog_new_name: str\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._backlog_uid = params[0]\n        self._backlog_new_name = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n        # UC-2: Trying to rename a User, Backlog or Workitem by ID which doesn't exist in its direct parent, will throw an exception\n        if self._backlog_uid not in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" not found')\n        backlog = user[self._backlog_uid]\n\n        params = {\n            'backlog': backlog,\n            'old_name': backlog.get_name(),\n            'new_name': self._backlog_new_name,\n        }\n        emit(events.BeforeBacklogRename, params, self._carry)\n        backlog.set_name(self._backlog_new_name)\n        backlog.item_updated(self._when)\n        emit(events.AfterBacklogRename, params, self._carry)\n\n\n# ReorderBacklog(\"123-456-789\", \"0\")\n@strategy\nclass ReorderBacklogStrategy(AbstractStrategy[Tenant]):\n    _backlog_uid: str\n    _new_index: int\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._backlog_uid = params[0]\n        self._new_index = int(params[1])\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n        # UC-2: Trying to reorder a User, Backlog or Workitem by ID which doesn't exist in its direct parent, will throw an exception\n        if self._backlog_uid not in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" not found')\n        backlog = user[self._backlog_uid]\n\n        params = {\n            'backlog': backlog,\n            'new_index': self._new_index,\n        }\n        emit(events.BeforeBacklogReorder, params, self._carry)\n        user.move_child(backlog, self._new_index)\n        user.item_updated(self._when)\n        emit(events.AfterBacklogReorder, params, self._carry)\n"
  },
  {
    "path": "src/fk/core/ephemeral_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport logging\nfrom typing import TypeVar, Iterable\n\nfrom fk.core import events\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.simple_serializer import SimpleSerializer\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\nclass EphemeralEventSource(AbstractEventSource[TRoot]):\n    _data: TRoot\n    _content: list[str]\n\n    def __init__(self,\n                 settings: AbstractSettings,\n                 cryptograph: AbstractCryptograph,\n                 root: TRoot):\n        super().__init__(SimpleSerializer(settings, cryptograph),\n                         settings,\n                         cryptograph)\n        self._data = root\n        # UC-3: Ephemeral data source is not persisted across executions\n        self._content = list()\n\n    def start(self, mute_events=True) -> None:\n        logger.debug(f'Ephemeral event source -- starting. Muting events -- {mute_events}')\n        self._emit(events.SourceMessagesRequested, dict())\n        if mute_events:\n            self.mute()\n\n        # UC-3: Ephemeral source always starts with a CreateUser strategy, based on the username from the settings\n        strategy = self.get_init_strategy(self._emit)\n        self._content.append(f'{strategy}')\n        self.execute_prepared_strategy(strategy)\n\n        if mute_events:\n            self.unmute()\n        self._emit(events.SourceMessagesProcessed, {'source': self})\n\n    def _append(self, strategies: list[AbstractStrategy[TRoot]]) -> None:\n        for s in strategies:\n            self._content.append(str(s))\n\n    def get_name(self) -> str:\n        return \"Ephemeral\"\n\n    def get_data(self) -> TRoot:\n        return self._data\n\n    def clone(self, new_root: TRoot, existing_strategies: Iterable[AbstractStrategy[TRoot]] | None = None) -> EphemeralEventSource[TRoot]:\n        return EphemeralEventSource[TRoot](self._settings,\n                                           self._cryptograph,\n                                           new_root)\n\n    def disconnect(self):\n        self._content.clear()\n\n    def send_ping(self) -> str | None:\n        raise Exception(\"EphemeralEventSource does not support send_ping()\")\n\n    def can_connect(self):\n        return False\n\n    def dump(self):\n        for s in self._content:\n            logger.debug(s)\n\n    def repair(self) -> tuple[list[str], str | None]:\n        return list(), None\n\n"
  },
  {
    "path": "src/fk/core/event_source_factory.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nfrom typing import Callable, TypeVar, Generic\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.tenant import Tenant\n\nTRoot = TypeVar('TRoot')\n\n\nclass EventSourceFactory(Generic[TRoot]):\n    _source_producers: dict[str, Callable[[AbstractSettings, AbstractCryptograph, TRoot], AbstractEventSource[TRoot]]]\n    _instance: EventSourceFactory[TRoot] = None\n\n    def __init__(self):\n        self._source_producers = dict()\n\n    def is_valid(self, name: str) -> bool:\n        return name in self._source_producers\n\n    def get_producer(self, name: str) -> Callable[[AbstractSettings, AbstractCryptograph, TRoot], AbstractEventSource[TRoot]]:\n        return self._source_producers.get(name)\n\n    def register_producer(self,\n                          name: str,\n                          producer: Callable[[AbstractSettings, AbstractCryptograph, TRoot], AbstractEventSource[TRoot]]) -> None:\n        self._source_producers[name] = producer\n\n    @staticmethod\n    def get_event_source_factory() -> EventSourceFactory[Tenant]:\n        if EventSourceFactory._instance is None:\n            EventSourceFactory._instance = EventSourceFactory[Tenant]()\n        return EventSourceFactory._instance\n"
  },
  {
    "path": "src/fk/core/event_source_holder.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom typing import TypeVar, Generic\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.event_source_factory import EventSourceFactory\nfrom fk.core.tenant import Tenant\n\nBeforeSourceChanged = \"BeforeSourceChanged\"\nAfterSourceChanged = \"AfterSourceChanged\"\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\nclass EventSourceHolder(AbstractEventEmitter, Generic[TRoot]):\n    _settings: AbstractSettings\n    _cryptograph: AbstractCryptograph\n    _source: AbstractEventSource[TRoot] | None\n\n    def __init__(self, settings: AbstractSettings, cryptograph: AbstractCryptograph):\n        super().__init__(allowed_events=[BeforeSourceChanged, AfterSourceChanged],\n                         callback_invoker=settings.invoke_callback)\n        self._settings = settings\n        self._cryptograph = cryptograph\n        self._source = None\n\n    def close_current_source(self) -> None:\n        # Unsubscribe everyone from the orphan source, so that we don't receive double events.\n        # If the new source is requested because settings change, this will happen before the change.\n        if self._source is not None:\n            self._source.cancel('*')\n            self._source.disconnect()\n\n    def request_new_source(self) -> AbstractEventSource[TRoot]:\n        source_type = self._settings.get('Source.type')\n        logger.debug(f'EventSourceHolder: Recreating event source of type {source_type}')\n        if not EventSourceFactory.get_event_source_factory().is_valid(source_type):\n            # We want to check it earlier, before we unsubscribe the old source\n            raise Exception(f\"Source type {source_type} not supported\")\n\n        # UC-3: When the user changes data source settings, a couple of Before / AfterSourceChanged events fires\n        self._emit(BeforeSourceChanged, {\n            'source': self._source\n        })\n\n        producer = EventSourceFactory.get_event_source_factory().get_producer(source_type)\n        logger.debug(f'EventSourceHolder: About to create new source using producer {producer} with cryptograph {self._cryptograph}')\n        # UC-3: An empty new data structure is created for each new event source request\n        self._source = producer(\n            self._settings,\n            self._cryptograph,\n            Tenant(self._settings))\n        logger.debug(f'EventSourceHolder: Source object created. You need to start it yourself!')\n\n        self._emit(AfterSourceChanged, {\n            'source': self._source\n        })\n        return self._source\n\n    def get_source(self) -> AbstractEventSource[TRoot] | None:\n        return self._source\n\n    def get_settings(self) -> AbstractSettings:\n        return self._settings\n"
  },
  {
    "path": "src/fk/core/events.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom typing import Callable\n\n# TODO: Move those into the classes where we fire them\n\nBeforeUserCreate = \"BeforeUserCreate\"\nAfterUserCreate = \"AfterUserCreate\"\nBeforeUserDelete = \"BeforeUserDelete\"\nAfterUserDelete = \"AfterUserDelete\"\nBeforeUserRename = \"BeforeUserRename\"\nAfterUserRename = \"AfterUserRename\"\n\nBeforeBacklogCreate = \"BeforeBacklogCreate\"\nAfterBacklogCreate = \"AfterBacklogCreate\"\nBeforeBacklogDelete = \"BeforeBacklogDelete\"\nAfterBacklogDelete = \"AfterBacklogDelete\"\nBeforeBacklogRename = \"BeforeBacklogRename\"\nAfterBacklogRename = \"AfterBacklogRename\"\nBeforeBacklogReorder = \"BeforeBacklogReorder\"\nAfterBacklogReorder = \"AfterBacklogReorder\"\n\nBeforeWorkitemCreate = \"BeforeWorkitemCreate\"\nAfterWorkitemCreate = \"AfterWorkitemCreate\"\nBeforeWorkitemComplete = \"BeforeWorkitemComplete\"\nAfterWorkitemComplete = \"AfterWorkitemComplete\"\nBeforeWorkitemStart = \"BeforeWorkitemStart\"\nAfterWorkitemStart = \"AfterWorkitemStart\"\nBeforeWorkitemDelete = \"BeforeWorkitemDelete\"\nAfterWorkitemDelete = \"AfterWorkitemDelete\"\nBeforeWorkitemRename = \"BeforeWorkitemRename\"\nAfterWorkitemRename = \"AfterWorkitemRename\"\nBeforeWorkitemReorder = \"BeforeWorkitemReorder\"\nAfterWorkitemReorder = \"AfterWorkitemReorder\"\nBeforeWorkitemMove = \"BeforeWorkitemMove\"\nAfterWorkitemMove = \"AfterWorkitemMove\"\n\nBeforePomodoroAdd = \"BeforePomodoroAdd\"\nAfterPomodoroAdd = \"AfterPomodoroAdd\"\nBeforePomodoroRemove = \"BeforePomodoroRemove\"\nAfterPomodoroRemove = \"AfterPomodoroRemove\"\nBeforePomodoroWorkStart = \"BeforePomodoroWorkStart\"\nAfterPomodoroWorkStart = \"AfterPomodoroWorkStart\"\nBeforePomodoroRestStart = \"BeforePomodoroRestStart\"\nAfterPomodoroRestStart = \"AfterPomodoroRestStart\"\nBeforePomodoroComplete = \"BeforePomodoroComplete\"\nAfterPomodoroComplete = \"AfterPomodoroComplete\"\nBeforePomodoroVoided = \"BeforePomodoroVoided\"\nAfterPomodoroVoided = \"AfterPomodoroVoided\"\nBeforePomodoroInterrupted = \"BeforePomodoroInterrupted\"\nAfterPomodoroInterrupted = \"AfterPomodoroInterrupted\"\n\nTagCreated = \"TagCreated\"\nTagDeleted = \"TagDeleted\"\nTagContentChanged = \"TagContentChanged\"\n\nSourceMessagesRequested = \"SourceMessagesRequested\"\nSourceMessagesProcessed = \"SourceMessagesProcessed\"\n\nBeforeMessageProcessed = \"BeforeMessageProcessed\"\nAfterMessageProcessed = \"AfterMessageProcessed\"\n\nPongReceived = \"PongReceived\"\n\nBeforeSettingsChanged = \"BeforeSettingsChanged\"\nAfterSettingsChanged = \"AfterSettingsChanged\"\n\nBeforeTenantRename = \"BeforeTenantRename\"\nAfterTenantRename = \"AfterTenantRename\"\nBeforeTenantDelete = \"BeforeTenantDelete\"\nAfterTenantDelete = \"AfterTenantDelete\"\nBeforeTenantCreate = \"BeforeTenantCreate\"\nAfterTenantCreate = \"AfterTenantCreate\"\n\nWentOnline = \"WentOnline\"\nWentOffline = \"WentOffline\"\n\nTimerWorkStart = \"TimerWorkStart\"\nTimerWorkComplete = \"TimerWorkComplete\"\nTimerRestComplete = \"TimerRestComplete\"\n\n\nclass EmittedEvent:\n    event: str\n    emitter: object     # TODO: See if we can store a weak reference instead\n\n    def __init__(self, event: str, emitter: object):\n        self.event = event\n        self.emitter = emitter\n\n    def __str__(self):\n        return f'{self.emitter.__class__.__name__}.{self.event}'\n\n\n# We need this \"ad-hoc\" callback mechanism for emitters, because our own AbstractEventEmitter subsystem hasn't\n# initialized completely yet. It is used by the IntegrationExecutor, which needs to be able to subscribe to\n# any events from any emitters immediately after the latter are constructed. So this IntegrationExecutor\n# subscribes to all emitters which were constructed BEFORE the IntegrationExecutor was created, and then uses\n# this callback mechanism to subscribe to whatever emitters are created after the IntegrationExecutor is created.\nALL_EVENTS: dict[str, EmittedEvent] = dict()\nALL_EVENTS_STR: set[str] = set()\nALL_EMITTERS: set[object] = set()\nemitter_added_callback: Callable[[object], None] = None\n\n\ndef set_emitter_added_callback(callback: Callable[[object], None]) -> None:\n    global emitter_added_callback\n    emitter_added_callback = callback\n\n\ndef register_event(event: str, emitter: object):\n    e = EmittedEvent(event, emitter)\n    ALL_EVENTS[str(e)] = e\n    ALL_EVENTS_STR.add(str(e))\n    if emitter not in ALL_EMITTERS:\n        ALL_EMITTERS.add(emitter)\n        if emitter_added_callback is not None:\n            emitter_added_callback(emitter)\n\n\ndef get_all_events() -> set[str]:\n    # TODO: See if we can remove duplicates and weak references at the same time here\n    return ALL_EVENTS_STR\n"
  },
  {
    "path": "src/fk/core/fernet_cryptograph.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport base64\nimport logging\n\nfrom cryptography.fernet import Fernet\nfrom cryptography.hazmat.primitives import hashes\nfrom cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\n\nlogger = logging.getLogger(__name__)\n\n\nclass FernetCryptograph(AbstractCryptograph):\n    _fernet: Fernet\n\n    def __init__(self, settings: AbstractSettings):\n        super().__init__(settings)\n        # UC-2: The \"final\" e2e encryption key is cached in the keychain\n        cached_key = self._settings.get('Source.encryption_key_cache!')\n        self._fernet = self._create_fernet(cached_key)\n\n    def _create_fernet(self, cached_key) -> Fernet:\n        if cached_key is None or cached_key == '':\n            logger.debug(f'There is no cached key, will generate it')\n            kdf = PBKDF2HMAC(\n                algorithm=hashes.SHA256(),\n                length=32,\n                salt=b'e1a7a49b5bad75ec81fcb8cded4bbc0c',   # TODO: GitHub Security complains about hardcoded salt --\n                                                            #  see if we can fix it somehow\n                iterations=480000,\n            )\n            key = base64.urlsafe_b64encode(kdf.derive(self.key.encode('utf-8')))\n            self._settings.set({'Source.encryption_key_cache!': key.decode('utf-8')})\n        else:\n            key = cached_key.encode('utf-8')\n        # TODO: This doesn't look safe -- check other occurrences to ensure we don't log credentials,\n        #  since we store them in the keychain\n        logger.debug(f'Fernet encryption key: {key}')\n        return Fernet(key)\n\n    def _on_key_changed(self) -> None:\n        self._fernet = self._create_fernet('')\n\n    def encrypt(self, s: str) -> str:\n        return self._fernet.encrypt(\n            s.encode('utf-8')\n        ).decode('utf-8')\n\n    def decrypt(self, s: str) -> str:\n        return self._fernet.decrypt(\n            s.encode('utf-8')\n        ).decode('utf-8')\n"
  },
  {
    "path": "src/fk/core/file_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport logging\nimport os\nimport time\nfrom collections import deque\nfrom os import path\nfrom typing import TypeVar, Iterable\n\nfrom fk.core import events\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_filesystem_watcher import AbstractFilesystemWatcher\nfrom fk.core.abstract_settings import AbstractSettings, prepare_file_for_writing\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog_strategies import CreateBacklogStrategy, DeleteBacklogStrategy, RenameBacklogStrategy\nfrom fk.core.import_export import compressed_strategies\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, RemovePomodoroStrategy, AddInterruptionStrategy\nfrom fk.core.simple_serializer import SimpleSerializer\nfrom fk.core.tenant import Tenant, ADMIN_USER\nfrom fk.core.timer_strategies import StartWorkStrategy, StartTimerStrategy\nfrom fk.core.user_strategies import DeleteUserStrategy, CreateUserStrategy, RenameUserStrategy\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, DeleteWorkitemStrategy, RenameWorkitemStrategy, \\\n    CompleteWorkitemStrategy, ReorderWorkitemStrategy, MoveWorkitemStrategy\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\nclass FileEventSource(AbstractEventSource[TRoot]):\n    _data: TRoot\n    _watcher: AbstractFilesystemWatcher | None\n    _existing_strategies: Iterable[AbstractStrategy] | None\n    _last_strategy: AbstractStrategy | None\n\n    def __init__(self,\n                 settings: AbstractSettings,\n                 cryptograph: AbstractCryptograph,\n                 root: TRoot,\n                 filesystem_watcher: AbstractFilesystemWatcher = None,\n                 existing_strategies: Iterable[AbstractStrategy] | None = None):\n        super().__init__(SimpleSerializer(settings, cryptograph),\n                         settings,\n                         cryptograph)\n        logger.debug(f'Created FileEventSource with serializer {self._serializer}')\n        self._data = root\n        self._watcher = None\n        self._existing_strategies = existing_strategies\n        self._last_strategy = None\n        if self._is_watch_changes() and filesystem_watcher is not None:\n            self._watcher = filesystem_watcher\n            self._watcher.watch(self._get_filename(), self._on_file_change)\n\n    def get_last_strategy(self) -> AbstractStrategy | None:\n        return self._last_strategy\n\n    def _on_file_change(self, filename: str) -> None:\n        # This method is called when we get updates from \"remote\"\n        logger.info(f'Data file content changed: {filename}')\n        # UC-1: File event source: If file watching is enabled, the strategies with the sequence > last_seq are executed\n        # UC-3: Any event source fires all events for the incremental processing\n        # We open the file as r+ to make sure that another process finished writing and\n        # released the file handler. By default, OSes won't allow concurrent writes to the\n        # file, so if something is still writing into it, then this call will fail.\n        with open(filename, 'r+', encoding='UTF-8') as file:\n            last_executed = None\n            for line in file:\n                try:\n                    strategy = self._serializer.deserialize(line)\n                    if strategy is None:\n                        continue\n                    self._last_strategy = strategy\n                    seq = strategy.get_sequence()\n                    if seq > self._last_seq:\n                        if not self._ignore_invalid_sequences and seq != self._last_seq + 1:\n                            self._sequence_error(self._last_seq, seq)\n                        self._last_seq = seq\n                        if logger.isEnabledFor(logging.DEBUG):\n                            logger.debug(f\"Will execute new strategy: {strategy}\")\n                        # UC-1: For any event source, whenever it executes a strategy with seq_num != last_seq + 1, and \"ignore sequence\" settings is disables, it fails\n                        self.execute_prepared_strategy(strategy)\n                        last_executed = strategy\n                except Exception as ex:\n                    if self._ignore_errors:\n                        logger.warning(f'Error processing {line} (ignored)', exc_info=ex)\n                    else:\n                        raise ex\n                finally:\n                    if last_executed is not None:\n                        self._auto_seal_at_the_end(last_executed)\n\n    def _get_filename(self) -> str:\n        return self.get_config_parameter(\"FileEventSource.filename\")\n\n    def _is_watch_changes(self) -> bool:\n        return self.get_config_parameter(\"FileEventSource.watch_changes\") == \"True\"\n\n    def start(self, mute_events: bool = True, fail_early: bool = False) -> None:\n        if self._existing_strategies is None:\n            self._process_from_file(mute_events)\n        else:\n            self._process_from_existing(fail_early)\n\n    # This method is called when we repair an existing data source\n    def _process_from_existing(self, fail_early: bool) -> None:\n        # UC-2: File event source can be created from the existing pre-parsed strategies.\n        #  It behaves just like normal \"read file\", but without the deserialization checks.\n        #  It checks sequences and triggers SourceMessagesRequested events.\n        #  All events are muted during processing.\n        self._emit(events.SourceMessagesRequested, dict())\n        self.mute()\n        is_first = True\n        last_executed = None\n        seq = 1\n        for strategy in self._existing_strategies:\n            try:\n                if strategy is None:\n                    continue\n                strategy._settings = self._settings\n                self._last_strategy = strategy\n\n                seq = strategy.get_sequence()\n                if is_first:\n                    is_first = False\n                else:\n                    if (fail_early or not self._ignore_invalid_sequences) and seq != self._last_seq + 1:\n                        self._sequence_error(self._last_seq, seq)\n                self._last_seq = seq\n                self.execute_prepared_strategy(strategy)\n                last_executed = strategy\n            except Exception as ex:\n                if self._ignore_errors and not fail_early:\n                    logger.warning(f'Error processing {strategy} (ignored)', exc_info=ex)\n                else:\n                    raise ex\n        self._auto_seal_at_the_end(last_executed)\n        self.unmute()\n        self._emit(events.SourceMessagesProcessed, {'source': self})\n\n    def _process_from_file(self, mute_events=True) -> None:\n        # This method is called when we read the history\n        self._emit(events.SourceMessagesRequested, dict(), None)\n        if mute_events:\n            self.mute()\n\n        filename = self._get_filename()\n        if path.isdir(filename):\n            raise IsADirectoryError(f'{filename} is a directory. Expected a filename.')\n        elif not path.isfile(filename):\n            prepare_file_for_writing(filename)\n            with open(filename, 'w', encoding='UTF-8') as f:\n                s = self.get_init_strategy(self._emit)\n                f.write(f'{self._serializer.serialize(s)}\\n')\n                logger.info(f'Created empty data file {filename}')\n                # UC-1: The file event source always creates a new file with CreateUser strategy, if it doesn't exist\n\n        is_first = True\n        last_executed = None\n        seq = 1\n        logger.info(f'FileEventSource: Reading file {filename}')\n        with open(filename, encoding='UTF-8') as f:\n            # TODO: If we wrap this for into a generator, we'll be able to reuse a this entire loop\n            #  with _process_from_existing() and _on_file_change()\n            for line in f:\n                try:\n                    strategy = self._serializer.deserialize(line)\n                    if strategy is None:\n                        continue\n                    self._last_strategy = strategy\n\n                    if is_first:\n                        is_first = False\n                    else:\n                        seq = strategy.get_sequence()\n                        if not self._ignore_invalid_sequences and seq != self._last_seq + 1:\n                            self._sequence_error(self._last_seq, seq)\n                    # UC-3: Strategies may start with any sequence number\n                    self._last_seq = seq\n                    self.execute_prepared_strategy(strategy)\n                    last_executed = strategy\n                except Exception as ex:\n                    if self._ignore_errors:\n                        logger.warning(f'Error processing {line} (ignored)', exc_info=ex)\n                    else:\n                        raise ex\n        logger.debug('FileEventSource: Processed file content, will unmute events now')\n\n        # UC-1: The last strategy is auto-sealed after execution to ensure that the timer rings offline, if needed\n        self._auto_seal_at_the_end(last_executed)\n\n        # UC-1: Any event source mutes its events for the duration of the first parsing and for the export/import\n        if mute_events:\n            self.unmute()\n        self._emit(events.SourceMessagesProcessed, {'source': self})\n\n    def repair(self) -> tuple[list[str], str | None]:\n        # This method attempts some basic repairs, trying to save as much\n        # data as possible:\n        # 0. Reorder strategies by date\n        # 1. Remove duplicate creations\n        # 2. Create non-existent users on first reference\n        # 3. Create non-existent backlogs on first reference\n        # 4. Create non-existent workitems on first reference\n        # 5. Renumber strategies\n        # 6. Restart and remove failing strategies\n        # Perform 5 -- 6 in the loop\n        # It will overwrite existing data file and will create a backup with \"-backup-<date>\" suffix\n\n        # UC-1: File event source can repair any broken source file. It reorders strategies by date, removes duplicates, creates missing data objects on first reference, renumbers strategies, and deletes failing ones.\n        # UC-1: After the repair, the file source is guaranteed to load successfully without errors or warnings\n        # UC-3: File source repair generates backup files with \"-backup-<date>\" suffix\n\n        log = list()\n        changes: int = 0\n        original_watcher = self._watcher\n        self._watcher = None\n\n        # Read strategies and repair in one pass\n        strategies: deque[AbstractStrategy] = deque()\n        all_users: dict[str, set[str]] = dict()\n        all_backlogs: dict[str, set[str]] = dict()\n        all_workitems: set[str] = set()\n        repaired_backlog: str | None = None\n\n        parsed = list[AbstractStrategy]()\n        with open(self._get_filename(), encoding='UTF-8') as f:\n            for line in f:\n                try:\n                    s = self._serializer.deserialize(line)\n                    if s:\n                        parsed.append(s)\n                except Exception as ex:\n                    log.append(f'Skipped invalid strategy ({ex}): {line}')\n                    changes += 1\n                    continue\n\n        # Reorder strategies by timestamp\n        sorted_strategies = sorted(parsed, key=lambda x: x.get_when())\n        for i, s in enumerate(parsed):\n            if sorted_strategies[i] != s:\n                changes += 1\n                log.append(f'Reordered strategies')\n                parsed = deque[AbstractStrategy](sorted_strategies)\n                break\n\n        for s in parsed:\n            t = type(s)\n\n            # Create users on the first reference\n            uid = s.get_user_identity()\n            if t is not CreateUserStrategy and uid not in all_users:\n                strategies.append(CreateUserStrategy(1,\n                                                     s._when,\n                                                     ADMIN_USER,\n                                                     [uid, f\"[Repaired] {uid}\"],\n                                                     self._settings))\n                all_users[uid] = set()\n                log.append(f'Created a missing user on first reference: {uid}')\n                changes += 1\n            if t is CreateUserStrategy:\n                cast: CreateUserStrategy = s\n                uid = cast.get_target_user_identity()\n                if uid in all_users:   # Remove duplicate creation\n                    log.append(f'Skipped a duplicate user: {uid}')\n                    changes += 1\n                    continue\n                all_users[uid] = set()\n            elif t is DeleteUserStrategy:\n                cast: DeleteUserStrategy = s\n                uid = cast.get_target_user_identity()\n                if uid not in all_users:\n                    log.append(f'Skipped deletion of a non-existent user: {uid}')\n                    changes += 1\n                    continue\n\n                # Remove all user's backlogs with their content recursively\n                for backlog_uid in all_users[uid]:\n                    if backlog_uid in all_backlogs:\n                        for workitem_uid in all_backlogs[backlog_uid]:\n                            if workitem_uid in all_workitems:\n                                all_workitems.remove(workitem_uid)\n                        del all_backlogs[backlog_uid]\n                del all_users[uid]\n\n            elif t is RenameUserStrategy:\n                cast: RenameUserStrategy = s\n                uid = cast.get_target_user_identity()\n                if uid not in all_users:\n                    strategies.append(CreateUserStrategy(1,\n                                                         s._when,\n                                                         ADMIN_USER,\n                                                         [uid, f\"[Repaired] {uid}\"],\n                                                         self._settings))\n                    all_users[uid] = set()\n                    log.append(f'Created a missing user on first reference: {uid}')\n                    changes += 1\n\n            # Create backlogs on the first reference\n            elif t is CreateBacklogStrategy:\n                cast: CreateBacklogStrategy = s\n                uid = cast.get_backlog_uid()\n                if uid in all_backlogs:   # Remove duplicate creation\n                    log.append(f'Skipped a duplicate backlog: {uid}')\n                    changes += 1\n                    continue\n                all_backlogs[uid] = set()\n            elif t is DeleteBacklogStrategy:\n                cast: DeleteBacklogStrategy = s\n                uid = cast.get_backlog_uid()\n                if uid not in all_backlogs:\n                    log.append(f'Skipped deletion of a non-existent backlog: {uid}')\n                    changes += 1\n                    continue\n\n                # Remove all child workitems recursively\n                for workitem_uid in all_backlogs[uid]:\n                    if workitem_uid in all_workitems:\n                        all_workitems.remove(workitem_uid)\n                del all_backlogs[uid]\n\n            elif t is RenameBacklogStrategy or t is CreateWorkitemStrategy:\n                cast: RenameBacklogStrategy | CreateWorkitemStrategy = s\n                uid = cast.get_backlog_uid()\n                if uid not in all_backlogs:\n                    strategies.append(CreateBacklogStrategy(1,\n                                                            s._when,\n                                                            s._user_identity,\n                                                            [uid, f\"[Repaired] {uid}\"],\n                                                            self._settings))\n                    all_backlogs[uid] = set()\n                    all_users[s._user_identity].add(uid)\n                    log.append(f'Created a missing backlog on first reference: {uid}')\n                    changes += 1\n                if t is CreateWorkitemStrategy:\n                    cast: CreateWorkitemStrategy = s\n                    uid = cast.get_workitem_uid()\n                    if uid in all_workitems:  # Remove duplicate creation\n                        log.append(f'Skipped a duplicate workitem: {uid}')\n                        changes += 1\n                        continue\n                    all_workitems.add(uid)\n                    all_backlogs[cast.get_backlog_uid()].add(uid)\n\n            elif t is DeleteWorkitemStrategy:\n                cast: DeleteWorkitemStrategy = s\n                uid = cast.get_workitem_uid()\n                if uid not in all_workitems:\n                    log.append(f'Skipped deletion of a non-existent workitem: {uid}')\n                    changes += 1\n                    continue\n                all_workitems.remove(uid)\n\n            # Create workitems on the first reference. All those strategies assume an existing workitem.\n            elif t is RenameWorkitemStrategy or \\\n                    t is CompleteWorkitemStrategy or \\\n                    t is MoveWorkitemStrategy or \\\n                    t is ReorderWorkitemStrategy or \\\n                    t is StartWorkStrategy or \\\n                    t is StartTimerStrategy or \\\n                    t is AddInterruptionStrategy or \\\n                    t is AddPomodoroStrategy or \\\n                    t is RemovePomodoroStrategy:\n                cast: RenameWorkitemStrategy = s\n                uid = cast.get_workitem_uid()\n                if uid not in all_workitems:\n                    if repaired_backlog is None:\n                        repaired_backlog = generate_uid()\n                        strategies.append(CreateBacklogStrategy(1,\n                                                                s._when,\n                                                                s._user_identity,\n                                                                [repaired_backlog, '[Repaired] Orphan workitems'],\n                                                                self._settings))\n                        all_backlogs[repaired_backlog] = set()\n                        all_users[s._user_identity].add(repaired_backlog)\n                        log.append(f'Created a backlog for orphan workitems: {repaired_backlog}')\n                        changes += 1\n                    strategies.append(CreateWorkitemStrategy(1,\n                                                             s._when,\n                                                             s._user_identity,\n                                                             [uid, repaired_backlog, f\"[Repaired] {uid}\"],\n                                                             self._settings))\n                    all_workitems.add(uid)\n                    all_backlogs[repaired_backlog].add(uid)\n                    log.append(f'Created a missing workitem on first reference: {uid}')\n                    changes += 1\n\n            # Handle duplicates and other logic\n\n            strategies.append(s)\n\n        # Now we need to ensure data consistency somehow. Even though all workitems and backlogs might be there,\n        # we may still have an issue with removing too many pomodoros or starting sealed workitems. To fix those,\n        # it would be easier to just skip the strategies which throw exceptions on parse.\n        while True:\n            # Renumber strategies\n            seq = strategies[0].get_sequence()\n            for s in strategies:\n                if s is None:\n                    continue\n                if s.get_sequence() != seq:\n                    s._seq = seq\n                    changes += 1\n                seq += 1\n            log.append(f'Renumbered strategies up to {seq}')\n\n            # Restart and remove failing strategies\n            new_source = self.clone(Tenant(self._settings), strategies)\n            try:\n                new_source.start(fail_early=True)\n                log.append(f'Tested successfully')\n                break   # No exceptions mean we repaired successfully\n            except Exception as ex:\n                failed = new_source.get_last_strategy()\n                log.append(f'Tested with an error: {ex}. Removed failed strategy: {failed.__class__.__name__}')\n                strategies.remove(failed)\n                changes += 1\n\n        if changes > 0:\n            log.append(f'Made {changes} changes in total')\n            # UC-2: File event source repair won't do any changes if the source file is correct\n            backup_filename = self._overwrite_file(strategies, log)\n        else:\n            log.append(f'No changes were made')\n            backup_filename = None\n\n        self._watcher = original_watcher\n        # UC-3: File event source repair returns the log of all changes it made\n        return log, backup_filename\n\n    def _overwrite_file(self, strategies: Iterable[AbstractStrategy], log: list[str]) -> str:\n        filename = self._get_filename()\n        date = round(time.time() * 1000)\n        backup_filename = f\"{filename}-backup-{date}\"\n        os.rename(filename, backup_filename)\n        log.append(f'Created backup file {backup_filename}')\n\n        # Write it back\n        with open(filename, 'w', encoding='UTF-8') as f:\n            for s in strategies:\n                f.write(self._serializer.serialize(s) + '\\n')\n        log.append(f'Overwritten original file {filename}')\n        return backup_filename\n\n    def _append(self, strategies: list[AbstractStrategy]) -> None:\n        # TODO: If compression is enabled and <base>-complete.<ext> file exists,\n        #  then append to both files at the same time.\n        # UC-2: For file source, new strategies get appended to the file immediately after execution\n        if self._watcher is not None:\n            self._watcher.unwatch(self._get_filename())\n        try:\n            with open(self._get_filename(), 'a', encoding='UTF-8') as f:\n                for s in strategies:\n                    f.write(self._serializer.serialize(s) + '\\n')\n        finally:\n            if self._watcher is not None:\n                self._watcher.watch(self._get_filename(), self._on_file_change)\n\n    def get_name(self) -> str:\n        return \"File\"\n\n    def get_data(self) -> TRoot:\n        return self._data\n\n    def _count_valid_strategies(self) -> int:\n        valid_count = 0\n        with open(self._get_filename(), encoding='UTF-8') as f:\n            for line in f:\n                try:\n                    self._serializer.deserialize(line)\n                    valid_count += 1\n                except Exception as ex:\n                    pass    # We just want to count valid strategies in the original file\n        return valid_count\n\n    def compress(self) -> list[str]:\n        # 1. Creates a full log copy in <base>-complete.<ext>, if it doesn't exist yet.\n        # 2. Rewrites the data file by recreating the CURRENT list of backlogs / workitems.\n        #    The last strategy's sequence ID will stay the same, and the previous IDs will\n        #    be recalculated backwards.\n        # 3. Timestamps will correspond to the latest modification dates.\n\n        # UC-1: File event source can compress source files. It removes inaccessible strategies (deleted, encrypted, repeated, etc.), invisible to the end user, and renumbers strategies.\n        # UC-1: After compression, the file source is guaranteed to load successfully, faster, and without errors or warnings\n        # UC-3: File source compression generates backup files with \"-backup-<date>\" suffix\n\n        log = list()\n\n        valid_count = self._count_valid_strategies()\n        strategies = list(compressed_strategies(self))\n        savings = valid_count - len(strategies)\n        if valid_count > 0 and savings > 0:\n            savings_percentage = round(100.0 * savings / valid_count)\n            log.append(f'The compressed file contains {savings_percentage}% fewer strategies')\n            # UC-3: File event source compression won't do any changes if there's no savings\n            self._overwrite_file(strategies, log)\n        else:\n            log.append(f'No changes were made - the data is already compressed')\n\n        # UC-3: File event source compression returns the log with the % of strategy savings\n        return log\n\n    def clone(self, new_root: TRoot, existing_strategies: Iterable[AbstractStrategy] | None = None) -> FileEventSource[TRoot]:\n        return FileEventSource[TRoot](self._settings,\n                                      self._cryptograph,\n                                      new_root,\n                                      self._watcher,\n                                      existing_strategies)\n\n    def disconnect(self):\n        if self._watcher is not None:\n            self._watcher.unwatch(self._get_filename())\n\n    def send_ping(self) -> str | None:\n        raise Exception(\"FileEventSource does not support send_ping()\")\n\n    def can_connect(self):\n        return False\n"
  },
  {
    "path": "src/fk/core/import_export.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nfrom os import path\nfrom typing import Iterable, Callable, TypeVar\n\nfrom fk.core import events\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_serializer import AbstractSerializer\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy, RenameBacklogStrategy\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, AddInterruptionStrategy\nfrom fk.core.simple_serializer import SimpleSerializer\nfrom fk.core.tags import sanitize_tag\nfrom fk.core.tenant import ADMIN_USER\nfrom fk.core.timer_strategies import StopTimerStrategy, StartTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.user_strategies import CreateUserStrategy, RenameUserStrategy\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, CompleteWorkitemStrategy, RenameWorkitemStrategy\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\ndef _export_message_processed(source: AbstractEventSource[TRoot],\n                              another: AbstractEventSource[TRoot],\n                              export_file,\n                              progress_callback: Callable[[int, int], None],\n                              every: int,\n                              strategy: AbstractStrategy[TRoot],\n                              export_serializer: AbstractSerializer) -> None:\n    serialized = export_serializer.serialize(strategy)\n    export_file.write(f'{serialized}\\n')\n    if another._estimated_count % every == 0:\n        # UC-2: Export progress is displayed through the progress bar\n        progress_callback(another._estimated_count, source._estimated_count)\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f' - {another._estimated_count} out of {source._estimated_count}')\n\n\ndef _export_completed(source: AbstractEventSource[TRoot],\n                      another: AbstractEventSource[TRoot],\n                      export_file,\n                      completion_callback: Callable[[int], None]) -> None:\n    export_file.close()\n    completion_callback(another._estimated_count)\n\n\ndef compressed_strategies(source: AbstractEventSource[TRoot]) -> Iterable[AbstractStrategy]:\n    \"\"\"The minimal list of strategies required to get the same end result\"\"\"\n    # UC-2: Export can compress strategies to the bare minimum without losing crucial timestamps.\n\n    strategies = list[AbstractStrategy]()\n\n    for user in source.get_data().values():\n        if user.is_system_user():\n            continue\n        strategies.append(\n            CreateUserStrategy(0,\n                               user.get_create_date(),\n                               ADMIN_USER,\n                                 [user.get_identity(), user.get_name()],\n                                 source.get_settings()))\n\n        for backlog in user.values():\n            strategies.append(\n                CreateBacklogStrategy(0,\n                                      backlog.get_create_date(),\n                                      user.get_identity(),\n                                        [backlog.get_uid(), backlog.get_name()],\n                                        source.get_settings()))\n\n            for workitem in backlog.values():\n                strategies.append(\n                    CreateWorkitemStrategy(0,\n                                           workitem.get_create_date(),\n                                           user.get_identity(),\n                                             [workitem.get_uid(), backlog.get_uid(), workitem.get_name()],\n                                             source.get_settings()))\n\n                for pomodoro in workitem.values():\n                    # We could create all at once, but then we'd lose the information about unplanned pomodoros\n                    strategies.append(\n                        AddPomodoroStrategy(0,\n                                            pomodoro.get_create_date(),\n                                            user.get_identity(),\n                                              [workitem.get_uid(), '1', pomodoro.get_type()],\n                                              source.get_settings()))\n\n                    for interruption in pomodoro.values():\n                        strategies.append(\n                            AddInterruptionStrategy(0,\n                                                    interruption.get_create_date(),\n                                                    user.get_identity(),\n                                                    [\n                                                        workitem.get_uid(),\n                                                        interruption.get_reason() if interruption.get_reason() is not None else '',\n                                                        str(interruption.get_duration().total_seconds()) if interruption.get_duration() is not None else ''],\n                                                    source.get_settings()))\n\n                for interval in workitem.get_intervals():\n                    strategies.append(\n                        StartTimerStrategy(0,\n                                           interval.get_started(),\n                                           user.get_identity(),\n                                           [\n                                               workitem.get_uid(),\n                                               str(interval.get_work_duration()),\n                                               str(interval.get_rest_duration())],\n                                           source.get_settings()))\n\n                    if interval.is_ended_manually():\n                        strategies.append(\n                            StopTimerStrategy(0,\n                                              interval.get_ended(),\n                                              user.get_identity(),\n                                              [],\n                                              source.get_settings()))\n\n                if workitem.is_sealed():\n                    strategies.append(\n                        CompleteWorkitemStrategy(0,\n                                                 workitem.get_last_modified_date(),\n                                                 user.get_identity(),\n                                                 [\n                                                     workitem.get_uid(),\n                                                     'finished'],\n                                                 source.get_settings()))\n\n    strategies.sort(key=lambda x: x.get_when())\n    seq = 1\n    for s in strategies:\n        s.update_sequence(seq)\n        seq += 1\n        yield s\n\n\ndef merge_strategies(source: AbstractEventSource[TRoot],\n                     data: TRoot) -> Iterable[AbstractStrategy]:\n    \"\"\"The list of strategies required to merge self with another, used in import. Here source is the \"into\" / combined\n    source, and \"data\" is the result of loading import file into a new empty source.\"\"\"\n    # UC-2: Import can be done incrementally (\"smart import\"). Such an import should never delete anything. Items with the same UIDs will be merged together.\n\n    # Prepare maps to speed up imports, otherwise we'll have to loop a lot\n    existing_backlogs = dict[str, Backlog]()\n    for backlog in source.backlogs():\n        existing_backlogs[backlog.get_uid()] = backlog\n    existing_workitems = dict[str, Workitem]()\n    for workitem in source.workitems():\n        existing_workitems[workitem.get_uid()] = workitem\n\n    strategies = list[AbstractStrategy]()\n\n    seq = source.get_last_sequence() + 1\n    for user in data.values():\n        if user.is_system_user():\n            continue\n        if user.get_identity() not in source.get_data():\n            yield CreateUserStrategy(seq, user.get_create_date(), ADMIN_USER,\n                                     [user.get_identity(), user.get_name()],\n                                     source.get_settings())\n            seq += 1\n        else:\n            # Check if it was renamed\n            existing_user: User = source.get_data()[user.get_identity()]\n            if user.get_name() != existing_user.get_name():\n                if user.get_last_modified_date() > existing_user.get_last_modified_date():\n                    yield RenameUserStrategy(seq, user.get_last_modified_date(), ADMIN_USER,\n                                             [user.get_identity(), user.get_name()],\n                                             source.get_settings())\n                    seq += 1\n\n        for backlog in user.values():\n            if backlog.get_uid() not in existing_backlogs:\n                yield CreateBacklogStrategy(seq, backlog.get_create_date(), user.get_identity(),\n                                            [backlog.get_uid(), backlog.get_name()],\n                                            source.get_settings())\n                seq += 1\n            else:\n                # Check if it was renamed\n                existing_backlog = existing_backlogs[backlog.get_uid()]\n                if backlog.get_name() != existing_backlog.get_name():\n                    if backlog.get_last_modified_date() > existing_backlog.get_last_modified_date():\n                        yield RenameBacklogStrategy(seq, backlog.get_last_modified_date(), user.get_identity(),\n                                                    [backlog.get_uid(), backlog.get_name()],\n                                                    source.get_settings())\n                        seq += 1\n\n            for workitem in backlog.values():\n                if workitem.get_uid() not in existing_workitems:\n                    yield CreateWorkitemStrategy(seq, workitem.get_create_date(), user.get_identity(),\n                                                 [workitem.get_uid(), backlog.get_uid(), workitem.get_name()],\n                                                 source.get_settings())\n                    seq += 1\n                    existing_workitem = source.find_workitem(workitem.get_uid())\n                else:\n                    # Check if it was renamed\n                    existing_workitem = existing_workitems[workitem.get_uid()]\n                    if workitem.get_name() != existing_workitem.get_name():\n                        if workitem.get_last_modified_date() > existing_workitem.get_last_modified_date():\n                            # UC-3: Smart import will rename any named data objects if their last modification date is later in the imported file\n                            yield RenameWorkitemStrategy(seq, workitem.get_last_modified_date(), user.get_identity(),\n                                                         [workitem.get_uid(), workitem.get_name()],\n                                                         source.get_settings())\n                            seq += 1\n\n                # Merge pomodoros by adding the new ones and completing some, if needed\n                num_pomodoros_to_add = len(workitem) - len(existing_workitem)\n                if num_pomodoros_to_add > 0:\n                    for p_old in list(workitem.values())[-num_pomodoros_to_add:]:\n                        # UC-2: Smart import would result in the max(existing, imported) number of pomodoros for each workitem\n                        yield AddPomodoroStrategy(seq,\n                                                  p_old.get_create_date(),\n                                                  user.get_identity(),\n                                                  [\n                                                      workitem.get_uid(),\n                                                      '1',\n                                                      p_old.get_type()],\n                                                  source.get_settings())\n                        seq += 1\n\n                # From here on we are adding strategies to the list instead of yielding them directly,\n                # because they must be sorted by time. StartTimer on another workitem might come in the\n                # middle between StartTimer on _this_ one.\n\n                # We start with interruptions, because for the voided pomodoros their timestamps are\n                # identical to StopTimer ones, so if we do the Intervals first, we'd have \"workitem\n                # is not running\" errors.\n                pomodoros_old = list(existing_workitem.values())\n                for i, p_new in enumerate(workitem.values()):\n                    # Here we rely on the fact that because we yielded AddPomodoros above, there\n                    # are at least len(workitem) pomodoros now, so we won't go out of array bounds\n                    p_old = pomodoros_old[i]\n\n                    # Import interruptions similarly to pomodoros\n                    for interruption in p_new.values():\n                        if interruption not in p_old.values():\n                            strategies.append(\n                                AddInterruptionStrategy(0,\n                                                        interruption.get_create_date(),\n                                                        user.get_identity(),\n                                                        [\n                                                            workitem.get_uid(),\n                                                            interruption.get_reason() if interruption.get_reason() is not None else '',\n                                                            str(interruption.get_duration().total_seconds()) if interruption.get_duration() is not None else ''],\n                                                        source.get_settings()))\n\n                for interval in workitem.get_intervals():\n                    if interval not in existing_workitem.get_intervals():\n                        strategies.append(\n                            StartTimerStrategy(0,\n                                               interval.get_started(),\n                                               user.get_identity(),\n                                               [\n                                                   workitem.get_uid(),\n                                                   str(interval.get_work_duration()),\n                                                   str(interval.get_rest_duration())],\n                                               source.get_settings()))\n\n                        if interval.is_ended_manually():\n                            strategies.append(\n                                StopTimerStrategy(0,\n                                                  interval.get_ended(),\n                                                  user.get_identity(),\n                                                  [],\n                                                  source.get_settings()))\n\n                if workitem.is_sealed() and (existing_workitem is None or not existing_workitem.is_sealed()):\n                    strategies.append(\n                        CompleteWorkitemStrategy(0,\n                                                 workitem.get_last_modified_date(),\n                                                 user.get_identity(),\n                                                 [\n                                                     workitem.get_uid(),\n                                                     'finished'],\n                                                 source.get_settings()))\n\n        strategies.sort(key=lambda x: x.get_when())\n        for s in strategies:\n            s.update_sequence(seq)\n            seq += 1\n            yield s\n\n\ndef _export_compressed(source: AbstractEventSource[TRoot],\n                       another: AbstractEventSource[TRoot],\n                       export_file,\n                       completion_callback: Callable[[int], None],\n                       export_serializer: AbstractSerializer) -> None:\n    for strategy in compressed_strategies(source):\n        serialized = export_serializer.serialize(strategy)\n        export_file.write(f'{serialized}\\n')\n    _export_completed(source, another, export_file, completion_callback)\n\n\ndef export(source: AbstractEventSource[TRoot],\n           filename: str,\n           new_root: TRoot,\n           encrypt: bool,\n           compress: bool,\n           start_callback: Callable[[int], None],\n           progress_callback: Callable[[int, int], None],\n           completion_callback: Callable[[int], None]) -> None:\n    export_serializer = create_export_serializer(source, encrypt)\n    another = source.clone(new_root)\n    every = max(int(source._estimated_count / 100), 1)\n    export_file = open(filename, 'w', encoding='UTF-8')\n\n    if compress:\n        another.on(events.SourceMessagesProcessed,\n                   lambda **kwargs: _export_compressed(source,\n                                                       another,\n                                                       export_file,\n                                                       completion_callback,\n                                                       export_serializer))\n    else:\n        another.on(events.AfterMessageProcessed,\n                   lambda strategy, auto, **kwargs: None if auto else _export_message_processed(source,\n                                                                                                another,\n                                                                                                export_file,\n                                                                                                progress_callback,\n                                                                                                every,\n                                                                                                strategy,\n                                                                                                export_serializer))\n        another.on(events.SourceMessagesProcessed,\n                   lambda **kwargs: _export_completed(source,\n                                                      another,\n                                                      export_file,\n                                                      completion_callback))\n\n    start_callback(source._estimated_count)\n    another.start(mute_events=False)\n\n\ndef create_export_serializer(source: AbstractEventSource[TRoot], encrypt=False) -> AbstractSerializer:\n    if encrypt:\n        return SimpleSerializer(source.get_settings(), source._cryptograph)\n    else:\n        # UC-2: The user can choose to export data unencrypted\n        # UC-2: The user can choose to export data encrypted. The current e2e encryption key is used.\n        return SimpleSerializer(source.get_settings(), NoCryptograph(source.get_settings()))\n\n\ndef import_(source: AbstractEventSource[TRoot],\n            filename: str,\n            ignore_errors: bool,\n            merge: bool,\n            start_callback: Callable[[int], None],\n            progress_callback: Callable[[int, int], None],\n            completion_callback: Callable[[int], None]) -> None:\n    if merge:\n        # 1. Read import file by doing a classic import on an ephemeral source\n        settings = MockSettings(username=source.get_settings().get_username(),\n                                source_type='ephemeral')\n        new_source_holder = EventSourceHolder(settings, NoCryptograph(settings))\n        import_classic(new_source_holder.request_new_source(),\n                       filename,\n                       ignore_errors,\n                       start_callback,\n                       progress_callback,\n                       lambda total: _merge_sources(source,  # Step 2 is done there\n                                                    new_source_holder,\n                                                    ignore_errors,\n                                                    completion_callback))\n    else:\n        import_classic(source,\n                       filename,\n                       ignore_errors,\n                       start_callback,\n                       progress_callback,\n                       completion_callback)\n\n\ndef import_github_issues(source: AbstractEventSource[TRoot],\n                         name: str,\n                         issues: list[object],\n                         tag_creator: bool,\n                         tag_assignee: bool,\n                         tag_labels: bool,\n                         tag_milestone: bool,\n                         tag_state: bool) -> str:\n    log = ''\n    found: Backlog = None\n\n    user: User = source.get_data().get_current_user()\n    for b in user.values():\n        if b.get_name() == name:\n            found = b\n            log = f'Found existing backlog \"{name}\"\\n'\n            break\n\n    if found is None:\n        b_uid = generate_uid()\n        source.execute(CreateBacklogStrategy, [b_uid, name])\n        found = user[b_uid]\n        log = f'Created backlog \"{name}\"\\n'\n\n    created = 0\n    updated = 0\n    skipped = 0\n    for issue in issues:\n        title = f'{issue[\"number\"]} - {issue[\"title\"]}'\n\n        # Check if such workitem already exists\n        existing: Workitem = None\n        for wi in found.values():\n            if wi.get_name().startswith(title):\n                existing = wi\n                break\n\n        if existing is not None and existing.is_sealed():\n            skipped += 1\n            continue    # Nothing we can do with it\n\n        tags = ''\n        if tag_creator and issue.get('user', None) is not None:\n            tags += ' #' + sanitize_tag(issue['user']['login'])\n        if tag_assignee and issue.get('assignee', None) is not None:\n            tags += ' #' + sanitize_tag(issue['assignee']['login'])\n        if tag_labels and issue.get('labels', None) is not None:\n            for label in issue['labels']:\n                tags += ' #' + sanitize_tag(label['name'])\n        if tag_milestone and issue.get('milestone', None) is not None:\n            tags += ' #' + sanitize_tag(issue['milestone']['title'])\n        if tag_state and issue.get('state', None) is not None:\n            tags += ' #' + sanitize_tag(issue['state'])\n        if tags != '':\n            title += f' [ {tags[1:]} ]'\n\n        if existing is not None and existing.get_name() == title:\n            skipped += 1\n            continue    # Nothing to do\n\n        if existing is None:\n            w_uid = generate_uid()\n            source.execute(CreateWorkitemStrategy, [w_uid, found.get_uid(), title])\n            created += 1\n        else:\n            source.execute(RenameWorkitemStrategy, [existing.get_uid(), title])\n            updated += 1\n\n\n    if created == 0:\n        log += 'Did not create any new work items\\n'\n    else:\n        log += f'Created {created} work items\\n'\n\n    if skipped > 0:\n        log += f'Skipped {skipped} existing work items\\n'\n\n    if updated > 0:\n        log += f'Updated {updated} existing work items\\n'\n\n    return log\n\n\ndef import_simple(source: AbstractEventSource[TRoot],\n                  tasks: dict[str, list[object]]) -> str:\n    log = ''\n    user: User = source.get_data().get_current_user()\n\n    for name in tasks.keys():\n        found: Backlog = None\n        for b in user.values():\n            if b.get_name() == name:\n                found = b\n                log = f'Found existing backlog \"{name}\"\\n'\n                break\n\n        if found is None:\n            b_uid = generate_uid()\n            source.execute(CreateBacklogStrategy, [b_uid, name])\n            found = user[b_uid]\n            log = f'Created backlog \"{name}\"\\n'\n\n        created = 0\n        skipped = 0\n        completed = 0\n        for task in tasks[name]:\n            title = task[0]\n            state = task[1]\n\n            # Check if such workitem already exists\n            existing: Workitem = None\n            for wi in found.values():\n                if wi.get_name() == title:\n                    existing = wi\n                    break\n\n            if existing is None:\n                w_uid = generate_uid()\n                source.execute(CreateWorkitemStrategy, [w_uid, found.get_uid(), title])\n                existing = found[w_uid]\n                created += 1\n            else:\n                w_uid = existing.get_uid()\n                skipped += 1\n\n            if state == 'completed' and not existing.is_sealed():\n                source.execute(CompleteWorkitemStrategy, [w_uid, 'finished'])\n                completed += 1\n\n        if created == 0:\n            log += ' - Did not create any new work items\\n'\n        else:\n            log += f' - Created {created} work items\\n'\n\n        if skipped > 0:\n            log += f' - Skipped {skipped} existing work items\\n'\n\n        if completed > 0:\n            log += f' - Marked {completed} work items as completed\\n'\n\n        log += '\\n'\n\n    return log\n\n\ndef _merge_sources(existing_source,\n                   new_source_holder,\n                   ignore_errors,\n                   completion_callback: Callable[[int], None]) -> None:\n    # 2. Execute the \"merge\" sequence of strategies obtained via source.merge_strategies()\n    count = 0\n    # UC-3: Any import mutes all events on the existing event source for the duration of the import\n    existing_source.mute()\n    for strategy in merge_strategies(existing_source, new_source_holder.get_source().get_data()):\n        try:\n            existing_source.execute_prepared_strategy(strategy, False, True)\n        except Exception as e:\n            if ignore_errors:\n                logger.warning(f'Error while importing data, ignoring: {e}')\n            else:\n                raise e\n        count += 1\n    existing_source.unmute()\n    new_source_holder.close_current_source()\n    completion_callback(count)\n\n\ndef import_classic(source: AbstractEventSource[TRoot],\n                   filename: str,\n                   ignore_errors: bool,\n                   start_callback: Callable[[int], None],\n                   progress_callback: Callable[[int, int], None],\n                   completion_callback: Callable[[int], None]) -> None:\n    # UC-1: Classic import replays strategies from the input file as-is and can therefore delete objects\n    # UC-1: Classic import will create duplicates for pomodoros on non-sealed workitems\n    # Note that this method ignores sequences and will import even \"broken\" files\n    if not path.isfile(filename):\n        # UC-3: Imports should fail if a non-existent filename is supplied\n        raise Exception(f'File {filename} not found')\n\n    with open(filename, 'rb') as f:\n        total = sum(1 for _ in f)\n        every = max(int(total / 100), 1)\n\n    start_callback(total)\n    source.mute()\n\n    user_identity = source.get_settings().get_username()\n    i = 0\n\n    if source.find_user(user_identity) is None:\n        # UC-3: Before classic import, if a user configured in Settings is not in the data model, it is created automatically\n        i += 1\n        strategy = CreateUserStrategy(i,\n                                      datetime.datetime.now(datetime.timezone.utc),\n                                      ADMIN_USER,\n                                      [user_identity, source.get_settings().get_fullname()],\n                                      source.get_settings())\n        try:\n            source.execute_prepared_strategy(strategy, False, True)\n        except Exception as e:\n            if ignore_errors:\n                logger.warning('Ignored an error while importing', exc_info=e)\n            else:\n                raise e\n\n    # With encrypt=True it will try to deserialize as much as possible\n    export_serializer = create_export_serializer(source, True)\n    # UC-2: Classic import will try to import as many strategies as possible, even if the file is encrypted with another key\n\n    with open(filename, encoding='UTF-8') as f:\n        for line in f:\n            try:\n                strategy = export_serializer.deserialize(line)\n                strategy.replace_user_identity(user_identity)\n                # UC-3: Classic import replaces user identity on the imported strategies with the current user\n                if strategy is None or type(strategy) is CreateUserStrategy:\n                    # UC-3: Classic import ignores CreateUser strategies\n                    continue\n                i += 1\n                source.execute_prepared_strategy(strategy, False, True)\n                if i % every == 0:\n                    progress_callback(i, total)\n            except Exception as e:\n                if ignore_errors:\n                    logger.warning('Ignored an error while importing', exc_info=e)\n                else:\n                    raise e\n\n    source.unmute()\n    completion_callback(total)\n"
  },
  {
    "path": "src/fk/core/integration_executor.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport shlex\nfrom subprocess import Popen\n\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.events import AfterSettingsChanged, ALL_EVENTS, set_emitter_added_callback\nfrom fk.core.sandbox import get_sandbox_type\n\nlogger = logging.getLogger(__name__)\n\n\nclass IntegrationExecutor:\n    _settings: AbstractSettings\n    _subscribed: dict[str, str]\n\n    def __init__(self, settings: AbstractSettings):\n        super().__init__()\n        self._settings = settings\n        self._subscribed = dict()\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n        set_emitter_added_callback(self._on_emitter_added)\n        self._resync_subscriptions_from_settings()\n\n    def _on_emitter_added(self, emitter: object):\n        self._resync_subscriptions_from_settings()\n\n    def _on_setting_changed(self, new_values: dict[str, str], **kwargs):\n        if 'Integration.callbacks' in new_values:\n            self._sync_subscriptions(json.loads(new_values['Integration.callbacks']))\n\n    def _resync_subscriptions_from_settings(self) -> None:\n        self._sync_subscriptions(json.loads(self._settings.get('Integration.callbacks')))\n\n    def _sync_subscriptions(self, new_conf: dict[str, str]) -> None:\n        for event in new_conf:\n            if event in self._subscribed:\n                if self._subscribed[event] != new_conf[event]:\n                    self._subscribed[event] = new_conf[event]\n            else:\n                if event in ALL_EVENTS:     # The corresponding emitter might not have initialized yet\n                    emitter: AbstractEventEmitter = ALL_EVENTS[event].emitter\n                    emitter.on(ALL_EVENTS[event].event,\n                               lambda **kwargs: self.on_event(event, **kwargs),\n                               True)\n                    if logger.isEnabledFor(logging.DEBUG):\n                        logger.debug(f'Subscribed to {event}')\n                    self._subscribed[event] = new_conf[event]\n        to_delete: set[str] = set()\n        for event in self._subscribed:\n            if event not in new_conf:\n                emitter: AbstractEventEmitter = ALL_EVENTS[event].emitter\n                emitter.unsubscribe_one(self.on_event, ALL_EVENTS[event].event)\n                if logger.isEnabledFor(logging.DEBUG):\n                    logger.debug(f'Unsubscribed from {event}')\n                to_delete.add(event)\n        for event in to_delete:\n            del self._subscribed[event]\n\n    def on_event(self, full_event, **kwargs):\n        if full_event in self._subscribed:\n            command = self._subscribed[full_event]\n            formatted = eval(f\"f'{command}'\", kwargs)   # Kwargs parameter here is important -- it limits eval() scope\n            # This is a safer, but less flexible alternative:\n            # formatted = command.format(**kwargs)\n            args = shlex.split(formatted)\n            if get_sandbox_type() == 'Flatpak' and self._settings.get('Integration.flatpak_spawn') == 'True':\n                # Use flatpak-spawn to execute commands outside the sandbox\n                args.insert(0, '--host')\n                args.insert(0, 'flatpak-spawn')\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f'Received event {full_event}. Executing: {args}')\n            Popen(args)\n"
  },
  {
    "path": "src/fk/core/interruption.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport logging\n\nfrom fk.core.abstract_data_item import AbstractDataItem\n\nlogger = logging.getLogger(__name__)\n\n\nclass Interruption(AbstractDataItem['Pomodoro']):\n    _reason: str | None\n    _duration: datetime.timedelta | None\n    _void: bool\n\n    def __init__(self,\n                 reason: str | None,\n                 duration: datetime.timedelta | None,\n                 void: bool,\n                 uid: str,\n                 pomodoro: 'Pomodoro',\n                 create_date: datetime.datetime):\n        super().__init__(uid=uid, parent=pomodoro, create_date=create_date)\n        self._reason = reason\n        self._duration = duration\n        self._void = void\n\n    def __str__(self):\n        if self._reason:\n            return f\"'[{self._reason}]\"\n        else:\n            return f\"'\"\n\n    def get_reason(self) -> str | None:\n        return self._reason\n\n    def get_duration(self) -> datetime.timedelta | None:\n        return self._duration\n\n    def is_void(self) -> bool:\n        return (self._void or\n                self._reason is not None and self._reason.startswith('Pomodoro voided') or\n                self._reason == 'Voided automatically because you completed the workitem while the timer was running.')\n\n    def get_parent(self) -> 'Pomodoro':\n        return self._parent\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, True, mask_last_modified)}\\n' \\\n               f'{indent}  Reason: {self._reason if self._reason else \"<None>\"}\\n' \\\n               f'{indent}  Void: {self._void}\\n' \\\n               f'{indent}  Duration: {self._duration if self._duration else \"<None>\"}'\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['reason'] = self._reason\n        d['duration'] = self._duration\n        d['void'] = self._void\n        return d\n\n    def __eq__(self, other: Interruption) -> bool:\n        # We can't rely on UIDs here, as those are auto-generated\n        return (self._reason == other._reason\n                and self._create_date == other._create_date\n                and self._duration == other._duration)\n"
  },
  {
    "path": "src/fk/core/mock_settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom pathlib import Path\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\n\nlogger = logging.getLogger(__name__)\n\n\ndef invoke_direct(fn, **kwargs):\n    fn(**kwargs)\n\n\nclass MockSettings(AbstractSettings):\n    _settings: dict[str, str]\n\n    def __init__(self, filename=None, username=None, source_type=\"local\"):\n        super().__init__(Path.home().absolute(),\n                         Path.home().absolute(),\n                         invoke_direct)\n        # UC-3: Mock settings used for testing predefine a fixed encryption key\n        # UC-3: Mock settings do not persist between application / test restarts\n        self._settings = {\n            'Source.type': source_type,\n            'FileEventSource.filename': filename,\n            'WebsocketEventSource.username': username,\n            'Source.encryption_enabled': 'False',\n            'Source.encryption_key!': 'oBokryM75NwBXkKVa3bY',\n            'Source.encryption_key_cache!': '_pQAnZe3fKCdq-kLNuoYAq5uUxe-Rb1-8C_vYqN0oyw=',\n        }\n\n    def get(self, name: str) -> str:\n        if name in self._settings:\n            return self._settings[name]\n        else:\n            return self._defaults[name]\n\n    def is_set(self, name: str) -> bool:\n        return name in self._settings\n\n    def set(self, values: dict[str, str], force_fire=False) -> None:\n        old_values: dict[str, str] = dict()\n        for name in values.keys():\n            old_value = self.get(name) if name in self._settings else None\n            if old_value != values[name] or force_fire:\n                old_values[name] = old_value\n        params = {\n            'old_values': old_values,\n            'new_values': values,\n        }\n        self._emit(events.BeforeSettingsChanged, params, None)\n        for name in old_values.keys():  # This is not a typo, we've just filtered this list\n            # to only contain settings which actually changed.\n            self._settings[name] = values[name]\n        self._emit(events.AfterSettingsChanged, params, None)\n\n    def location(self) -> str:\n        return \"N/A\"\n\n    def clear(self) -> None:\n        self._settings = {}\n\n    def get_displayed_settings(self) -> list[str]:\n        res = list()\n        for tab_name in self.get_categories():\n            logger.debug(f'Category: {tab_name}')\n            settings = self.get_settings(tab_name)\n            values = dict()\n            for s in settings:\n                values[s[0]] = s[3]\n            for option_id, option_type, option_display, option_value, option_options, option_visible in settings:\n                if option_visible(values) and option_type not in ('separator', 'button'):\n                    logger.debug(f' - {option_display}: {option_value}')\n                    res.append(option_id)\n        return res\n\n    def is_keyring_enabled(self) -> bool:\n        return True  # Storing credentials in memory is safe, as long as we don't persist them\n\n    def get_auto_theme(self) -> str:\n        return 'mixed'\n\n    def init_gradients(self):\n        pass\n\n    def init_audio_outputs(self):\n        pass\n\n    def init_fonts(self):\n        pass\n\n    def init_appearance(self):\n        pass\n\n    def init_network_access(self):\n        pass\n"
  },
  {
    "path": "src/fk/core/no_cryptograph.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\n\n\nclass NoCryptograph(AbstractCryptograph):\n    def __init__(self, settings: AbstractSettings):\n        # We don't call super() on purpose here, so that it doesn't try to generate keys\n        self._settings = settings\n        self.enabled = False\n\n    def _on_key_changed(self) -> None:\n        self.enabled = False\n\n    def encrypt(self, s: str) -> str:\n        return s\n\n    def decrypt(self, s: str) -> str:\n        return s\n"
  },
  {
    "path": "src/fk/core/pomodoro.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport logging\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.interruption import Interruption\n\nlogger = logging.getLogger(__name__)\n\n\nPOMODORO_TYPE_NORMAL = 'normal'\nPOMODORO_TYPE_TRACKER = 'tracker'\nPOMODORO_TYPE_COUNTER = 'counter'\n\n\nclass Pomodoro(AbstractDataContainer[Interruption, 'Workitem']):\n    _is_planned: bool\n    _state: str\n    _type: str\n    _work_duration: float\n    _rest_duration: float\n    _date_work_started: datetime.datetime | None\n    _date_rest_started: datetime.datetime | None\n    _date_completed: datetime.datetime | None\n\n    # State is one of the following: new, work, rest, finished\n    def __init__(self,\n                 number: int,\n                 is_planned: bool,\n                 state: str,\n                 work_duration: float,\n                 rest_duration: float,\n                 type_: str,\n                 uid: str,\n                 workitem: 'Workitem',\n                 create_date: datetime.datetime):\n        super().__init__(name=f'Pomodoro {number}', uid=uid, parent=workitem, create_date=create_date)\n        self._is_planned = is_planned\n        self._state = state\n        self._type = type_\n        self._work_duration = work_duration\n        self._rest_duration = rest_duration\n        self._date_work_started = None\n        self._date_rest_started = None\n        self._date_completed = None\n\n    def __str__(self):\n        if self.is_startable():\n            char = '[ ]' if self._is_planned else '( )'\n        elif self.is_running():\n            # Here we don't distinguish between work and rest\n            char = '[#]' if self._is_planned else '(#)'\n        elif self.is_finished():\n            char = '[v]' if self._is_planned else '(v)'\n        else:\n            raise Exception(f'Invalid pomodoro state: {self._state}')\n        return char\n\n    def update_work_duration(self, work_duration: float) -> None:\n        if self.is_startable():\n            self._work_duration = work_duration\n        else:\n            raise Exception(f'Trying to update work duration of a pomodoro in state {self._state}')\n\n    def get_state(self) -> str:\n        return self._state\n\n    def get_work_start_date(self) -> datetime.datetime:\n        return self._date_work_started\n\n    def get_rest_start_date(self) -> datetime.datetime:\n        if self._type == POMODORO_TYPE_NORMAL:\n            return self._date_rest_started\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't support rest\")\n\n    def update_rest_duration(self, rest_duration: float) -> None:\n        if self._type == POMODORO_TYPE_NORMAL:\n            if self.is_startable() or self.is_working():\n                self._rest_duration = rest_duration\n            else:\n                raise Exception(f'Trying to update rest duration of a pomodoro in state {self._state}')\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't support rest\")\n\n    def seal(self, when: datetime.datetime) -> None:\n        if self._type == POMODORO_TYPE_NORMAL:\n            if self.is_resting():\n                if self._rest_duration == 0:\n                    self.get_parent().end_interval(when)\n                self._state = 'finished'\n                self._date_completed = when\n                self._last_modified_date = when\n            elif self.is_working():\n                # This is a rare corner case, which we may encounter in the field. The client went down while\n                # the pomodoro was in work, and came back up when it was in rest. The timer then transitioned\n                # it to Finished state correctly. This results in a missing \"StartRest\" historical record.\n                # We can work around this situation reliably if the Finish happened \"after\" the work + rest\n                # should've finished (take into account some little margin for error, just a few seconds).\n                if when > self.planned_end_of_rest() - datetime.timedelta(seconds=5):\n                    logger.debug(f\"Warning - skipped rest for a pomodoro on {self.get_parent().get_name()}, but still \"\n                                 \"authorized its completion (transition happened when the client was offline)\")\n                    self._state = 'finished'\n                    self._date_completed = when\n                    self._last_modified_date = when\n            else:\n                raise Exception(f'Cannot seal normal pomodoro from {self._state}')\n        elif self._type == POMODORO_TYPE_TRACKER:\n            if self.is_working():\n                self._state = 'finished'\n                self._date_completed = when\n                self._last_modified_date = when\n                self.get_parent().end_interval(when)\n            else:\n                raise Exception(f'Cannot seal tracker pomodoro from {self._state}')\n        elif self._type == POMODORO_TYPE_COUNTER:\n            raise Exception(f'Cannot seal counter pomodoro')\n\n    def void(self, when: datetime.datetime) -> None:\n        if self._type == POMODORO_TYPE_NORMAL:\n            if self.is_resting() or self.is_working():\n                self._state = 'new'\n                self._last_modified_date = when\n                self.get_parent().end_interval(when)\n            else:\n                raise Exception(f'Cannot void normal pomodoro from {self._state}')\n        else:\n            raise Exception(f'Cannot void pomodoro of type {self._type}')\n\n    def start_work(self, when: datetime.datetime) -> None:\n        self.get_parent().add_interval(when, self._work_duration, self._rest_duration)\n        if self._type != POMODORO_TYPE_COUNTER:\n            self._state = 'work'\n            self._date_work_started = when\n            self._last_modified_date = when\n        else:\n            raise Exception(f\"Pomodoros of type counter don't have work\")\n\n    def start_rest(self, when: datetime.datetime) -> None:\n        if self._type == POMODORO_TYPE_NORMAL:\n            self._state = 'rest'\n            self._date_rest_started = when\n            self._last_modified_date = when\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't support rest\")\n\n    def is_running(self) -> bool:\n        return self._state == 'work' or self._state == 'rest'\n\n    def is_startable(self) -> bool:\n        return self._state == 'new'\n\n    def is_working(self) -> bool:\n        return self._state == 'work'\n\n    def is_resting(self) -> bool:\n        return self._state == 'rest'\n\n    def is_long_break(self) -> bool:\n        return self._type == POMODORO_TYPE_NORMAL and self._state == 'rest' and self._rest_duration == 0\n\n    def is_finished(self) -> bool:\n        return self._state == 'finished'\n\n    def get_elapsed_work_duration(self, when: datetime.datetime = None) -> float:\n        if self._date_work_started is not None:\n            if self.is_working() or self.is_resting():\n                now = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n            elif self.is_finished() and self.get_type() == POMODORO_TYPE_TRACKER:\n                now = self._last_modified_date\n            elif self.is_finished() and self.get_type() == POMODORO_TYPE_NORMAL:\n                now = self._date_rest_started\n            else:\n                raise Exception(f'Cannot get elapsed work duration for a {self.get_type()} pomodoro in state {self.get_state()}')\n            return max((now - self._date_work_started).total_seconds(), 0)\n        else:\n            return 0\n\n    def get_elapsed_rest_duration(self, when: datetime.datetime = None) -> float:\n        if self._date_rest_started is not None:\n            if self.is_resting():\n                now = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n            elif self.is_finished():\n                now = self._last_modified_date\n            else:\n                raise Exception(f'Cannot get elapsed rest duration for a {self.get_type()} pomodoro in state {self.get_state()}')\n            return max((now - self._date_rest_started).total_seconds(), 0)\n        else:\n            return 0\n\n    def get_work_duration(self, when: datetime.datetime = None) -> float:\n        if self._type == POMODORO_TYPE_NORMAL:\n            return self._work_duration\n        elif self._type == POMODORO_TYPE_TRACKER:\n            if self.is_working():\n                return self.get_elapsed_work_duration(when)\n            elif self.is_startable():\n                return 0\n            else:\n                return max((self._date_completed - self._date_work_started).total_seconds(), 0)\n        elif self._type == POMODORO_TYPE_COUNTER:\n            raise Exception(f\"Pomodoros of type counter don't have work\")\n\n    def get_rest_duration(self) -> float:\n        if self._type == POMODORO_TYPE_NORMAL:\n            return self._rest_duration\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't have rest\")\n\n    def get_type(self):\n        return self._type\n\n    def remaining_time_in_current_state(self, when: datetime.datetime | None) -> float:\n        if self._type == POMODORO_TYPE_NORMAL:\n            # Remaining time in the current state in seconds.\n            # Can be negative, if it has expired.\n            # Will be 0 if it hasn't started yet.\n            if self.is_working():\n                now = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n                return max((self.planned_end_of_work() - now).total_seconds(), 0)\n            elif self.is_resting():\n                now = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n                return max((self.planned_end_of_rest() - now).total_seconds(), 0)\n            else:\n                return 0\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't have remaining time\")\n\n    def remaining_minutes_in_current_state_str(self, when: datetime.datetime) -> str:\n        seconds = self.remaining_time_in_current_state(when)\n        if seconds == 0:\n            return 'N/A'\n        m = seconds / 60\n        if m < 1:\n            return \"Less than a minute\"\n        else:\n            return f\"{round(m)} minutes\"\n\n    def planned_end_of_work(self) -> datetime.datetime:\n        if self._type == POMODORO_TYPE_NORMAL:\n            if self._date_work_started is None:\n                return None\n            return self._date_work_started + datetime.timedelta(seconds=self._work_duration)\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't have planned time\")\n\n    def planned_end_of_rest(self) -> datetime.datetime:\n        if self._type == POMODORO_TYPE_NORMAL:\n            if self._date_work_started is None:\n                return None\n            return self.planned_end_of_work() + datetime.timedelta(seconds=self._rest_duration)\n        else:\n            raise Exception(f\"Pomodoros of type {self._type} don't support rest\")\n\n    def get_parent(self) -> 'Workitem':\n        return self._parent\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, True, mask_last_modified)}\\n' \\\n               f'{indent}  Type: {self._type}\\n' \\\n               f'{indent}  State: {self._state}\\n' \\\n               f'{indent}  Is planned: {self._is_planned}\\n' \\\n               f'{indent}  Work duration: {self._work_duration}\\n' \\\n               f'{indent}  Rest duration: {self._rest_duration}\\n' \\\n               f'{indent}  Work started: {self._date_work_started}\\n' \\\n               f'{indent}  Rest started: {self._date_rest_started}\\n' \\\n               f'{indent}  Completed: {self._date_completed}'\n\n    def add_interruption(self, reason: str | None, duration: datetime.timedelta | None, void: bool, when: datetime.datetime) -> None:\n        uid = generate_uid()\n        self[uid] = Interruption(reason, duration, void, uid, self, when)\n\n    def is_planned(self) -> bool:\n        return self._is_planned\n\n    def get_timer(self) -> 'TimerData':\n        return self.get_parent().get_parent().get_parent().get_timer()\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['is_planned'] = self._is_planned\n        d['state'] = self._state\n        d['type'] = self._type\n        d['work_duration'] = self._work_duration\n        d['rest_duration'] = self._rest_duration\n        d['date_work_started'] = self._date_work_started\n        d['date_rest_started'] = self._date_rest_started\n        d['date_completed'] = self._date_completed\n        return d\n"
  },
  {
    "path": "src/fk/core/pomodoro_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER, POMODORO_TYPE_COUNTER\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\n\n\n# AddPomodoro(\"123-456-789\", \"1\", [\"normal\"])\n@strategy\nclass AddPomodoroStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _num_pomodoros: int\n    _type: str\n    _settings: AbstractSettings\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._num_pomodoros = int(params[1])\n        self._type = params[2] if len(params) > 2 else POMODORO_TYPE_NORMAL\n        self._settings = settings\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if self._num_pomodoros < 1:\n            raise Exception(f'Cannot add {self._num_pomodoros} pomodoro')\n\n        if self._type not in [POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER, POMODORO_TYPE_COUNTER]:\n            raise Exception(f'Unsupported pomodoro type: {self._type}')\n\n        workitem: Workitem | None = None\n        user: User = data[self._user_identity]\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        if workitem.is_sealed():\n            raise Exception(f'Workitem \"{self._workitem_uid}\" is sealed')\n\n        params = {\n            'workitem': workitem,\n            'num_pomodoros': self._num_pomodoros,\n            'pomodoro_type': self._type,\n        }\n        emit(events.BeforePomodoroAdd, params, self._carry)\n        workitem.add_pomodoro(\n            self._num_pomodoros,\n            float(self._settings.get('Pomodoro.default_work_duration')) if self._type == POMODORO_TYPE_NORMAL else 0,\n            float(self._settings.get('Pomodoro.default_rest_duration')) if self._type == POMODORO_TYPE_NORMAL else 0,\n            self._type,\n            self._when)\n        workitem.item_updated(self._when)\n        emit(events.AfterPomodoroAdd, params, self._carry)\n\n\n# RemovePomodoro(\"123-456-789\", \"1\")\n@strategy\nclass RemovePomodoroStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _num_pomodoros: int\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._num_pomodoros = int(params[1])\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if self._num_pomodoros < 1:\n            raise Exception(f'Cannot remove {self._num_pomodoros} pomodoro')\n\n        workitem: Workitem | None = None\n        user: User = data[self._user_identity]\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        if workitem.is_sealed():\n            raise Exception(f'Workitem \"{self._workitem_uid}\" is sealed')\n\n        # Check that we have enough \"new\" pomodoro to remove\n        to_remove = list[Pomodoro]()\n        for p in reversed(workitem.values()):\n            if p.is_startable():\n                to_remove.append(p)\n                if len(to_remove) == self._num_pomodoros:\n                    break\n        if len(to_remove) < self._num_pomodoros:\n            raise Exception(f'Only {len(to_remove)} pomodoro is available, cannot remove {self._num_pomodoros}')\n\n        params = {\n            'workitem': workitem,\n            'num_pomodoros': self._num_pomodoros,\n            'pomodoros': to_remove\n        }\n        emit(events.BeforePomodoroRemove, params, self._carry)\n        for p in to_remove:\n            workitem.remove_pomodoro(p)\n        workitem.item_updated(self._when)\n        emit(events.AfterPomodoroRemove, params, self._carry)\n\n\n# AddInterruption(\"123-456-789\", \"reason\", [\"123.45\"])\n@strategy\nclass AddInterruptionStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _reason: str | None\n    _duration: datetime.timedelta | None\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._reason = params[1]\n        if len(params) > 2 and params[2]:\n            self._duration = datetime.timedelta(seconds=float(params[2]))\n        else:\n            self._duration = None\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n\n        # TODO: Make it timer strategy. Pass timer object into those strategies.\n        #  Use timer instead of looking for pomodoros.\n        workitem: Workitem | None = None\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        if not workitem.has_running_pomodoro():\n            raise Exception(f'Workitem \"{self._workitem_uid}\" is not running')\n\n        for pomodoro in workitem.values():\n            if pomodoro.is_running():\n                params = {\n                    'workitem': workitem,\n                    'pomodoro': pomodoro,\n                    'reason': self._reason,\n                    'duration': self._duration,\n                }\n                emit(events.BeforePomodoroInterrupted, params, self._carry)\n                pomodoro.add_interruption(self._reason, self._duration, False, self._when)\n                pomodoro.item_updated(self._when)\n                emit(events.AfterPomodoroInterrupted, params, self._carry)\n                return\n\n        raise Exception(f'No running pomodoros in \"{self._workitem_uid}\"')\n"
  },
  {
    "path": "src/fk/core/sandbox.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport os\nimport platform\n\n\ndef get_sandbox_type() -> str | None:\n    # See https://stackoverflow.com/questions/75274925/is-there-a-way-to-find-out-if-i-am-running-inside-a-flatpak-appimage-or-another\n    if platform.system() == 'Linux':\n        if os.environ.get('container') is not None:\n            return 'Flatpak'\n        elif os.environ.get('SNAP') is not None:\n            return 'Snap'\n        elif os.environ.get('APPIMAGE') is not None:\n            return 'AppImage'\n    return None\n"
  },
  {
    "path": "src/fk/core/simple_serializer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport re\nfrom datetime import datetime\nfrom typing import TypeVar\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_serializer import AbstractSerializer\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.strategy_factory import STRATEGIES\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\nclass SimpleSerializer(AbstractSerializer[str, TRoot]):\n    REGEX = re.compile(r'([1-9][0-9]*)\\s*,\\s*'\n                       r'([0-9: .\\-+]+)\\s*,\\s*'\n                       r'([\\w\\-.]+@(?:[\\w-]+\\.)+[\\w-]{2,4})\\s*:\\s*'\n                       r'([a-zA-Z]+)\\s*\\(\\s*'\n                       r'\"((?:[^\"\\\\]|\\\\\"|\\\\\\\\)*)?\"\\s*(?:,\\s*'\n                       r'\"((?:[^\"\\\\]|\\\\\"|\\\\\\\\)*)\")?\\s*(?:,\\s*'\n                       r'\"((?:[^\"\\\\]|\\\\\"|\\\\\\\\)*)\"\\s*)*\\)')\n\n    def __init__(self, settings: AbstractSettings, cryptograph: AbstractCryptograph):\n        super().__init__(settings, cryptograph)\n\n    @staticmethod\n    def escape_parameter(value):\n        return value.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')\n\n    def serialize(self, s: AbstractStrategy) -> str:\n        # Escape params\n        escaped = [SimpleSerializer.escape_parameter(p) for p in s.get_params()]\n        if len(escaped) < 2:\n            escaped.append(\"\")\n        if len(escaped) < 2:\n            escaped.append(\"\")\n        params = '\"' + '\", \"'.join(escaped) + '\"'\n        plaintext = f'{s.get_sequence()}, {s.get_when()}, {s.get_user_identity()}: {s.get_name()}({params})'\n        if self._cryptograph.enabled and s.encryptable():\n            return '+' + self._cryptograph.encrypt(plaintext)\n        else:\n            return plaintext\n\n    def deserialize(self, t: str) -> AbstractStrategy[TRoot] | None:\n        if t.startswith('+'):\n            plaintext = self._cryptograph.decrypt(t[1:])\n        else:\n            plaintext = t\n\n        # Empty strings and comments are special cases\n        if plaintext.strip() == '' or plaintext.startswith('#'):\n            # UC-3: For all event sources, empty lines and #comments are ignored\n            # UC-3: Empty lines and comments do not survive export, repair or data compaction\n            return None\n\n        # TODO: Remove this once the server is updated\n        if plaintext == 'ReplayCompleted()':\n            return STRATEGIES['ReplayCompleted'](0, None, None, [], self._settings, self._cryptograph)\n\n        m = self.REGEX.search(plaintext)\n        if m is not None:\n            name = m.group(4)\n            if name not in STRATEGIES:\n                raise Exception(f\"Unknown strategy: {name}\")\n\n            seq = int(m.group(1))\n            when = datetime.fromisoformat(m.group(2))\n            user = m.group(3)\n            params = list(filter(lambda p: p is not None, m.groups()[4:]))\n            params = [p.replace('\\\\\"', '\"').replace('\\\\\\\\', '\\\\') for p in params]\n\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f\"Deserialized string to strategy: '{seq}' / '{when}' / '{user}' / '{name}' / {params}\")\n\n            return STRATEGIES[name](seq, when, user, params, self._settings, self._cryptograph)\n        else:\n            raise Exception(f\"Bad syntax: {plaintext}\")\n\n    def __str__(self):\n        return f'SimpleSerializer with settings {self._settings} and cryptograph {self._cryptograph}'\n"
  },
  {
    "path": "src/fk/core/strategy_factory.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport re\nfrom typing import Type, TypeVar\n\nfrom fk.core.abstract_strategy import AbstractStrategy\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\nSTRATEGY_CLASS_NAME_REGEX = re.compile(r'([A-Z][a-zA-Z]*)Strategy')\nSTRATEGIES = dict[str, Type[AbstractStrategy[TRoot]]]()\n\n\ndef strategy(cls: Type[AbstractStrategy[TRoot]]):\n    m = STRATEGY_CLASS_NAME_REGEX.search(cls.__name__)\n    if m is not None:\n        name = m.group(1)\n        logger.debug(f'Registering strategy {name} -> {cls.__name__}')\n        STRATEGIES[name] = cls\n        return cls\n    else:\n        raise Exception(f\"Invalid strategy class name: {cls.__name__}\")\n"
  },
  {
    "path": "src/fk/core/tag.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport logging\nfrom typing import Set\n\nfrom fk.core.abstract_data_item import AbstractDataItem\nfrom fk.core.workitem import Workitem\n\nlogger = logging.getLogger(__name__)\n\n\nclass Tag(AbstractDataItem['Tags']):\n    _workitems: Set[Workitem]\n\n    def __init__(self,\n                 name: str,\n                 user: 'User',\n                 create_date: datetime.datetime):\n        super().__init__(uid=name,\n                         parent=user.get_tags(),\n                         create_date=create_date)\n        self._workitems = set[Workitem]()\n\n    def __str__(self):\n        return f'#{self.get_uid()}'\n\n    def get_workitems(self) -> Set[Workitem]:\n        return self._workitems\n\n    def add_workitem(self, workitem: Workitem) -> None:\n        self._workitems.add(workitem)\n\n    def remove_workitem(self, workitem: Workitem) -> None:\n        self._workitems.remove(workitem)\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, mask_uid, mask_last_modified)}\\n' \\\n               f'{indent} - Name: {self.get_uid()}'\n"
  },
  {
    "path": "src/fk/core/tags.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.tag import Tag\n\n\ndef sanitize_tag(tag: str) -> str:\n    return ''.join(filter(str.isalnum, tag))\n\n\nclass Tags(AbstractDataContainer[Tag, 'User']):\n    def __init__(self, user: 'User'):\n        super().__init__(f'Tags',\n                         user,\n                         f'tags-{user.get_identity()}',\n                         user.get_create_date())\n\n    def __str__(self):\n        return f'Tags {self.get_name()}'\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, mask_uid, mask_last_modified)}\\n' \\\n               f'{indent} - Tags'\n"
  },
  {
    "path": "src/fk/core/tenant.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.user import User\n\nADMIN_USER = 'admin@local.host'\n\n\nclass Tenant(AbstractDataContainer[User, None]):\n    \"\"\"Tenant is the root of the data hierarchy in Flowkeeper Client.\n    It contains users and has no parent.\"\"\"\n\n    _settings: AbstractSettings\n\n    def __init__(self, settings: AbstractSettings):\n        super().__init__('Flowkeeper Desktop Client',\n                         None,\n                         '0',\n                         datetime.datetime.now(datetime.timezone.utc))\n        self._settings = settings\n        self[ADMIN_USER] = User(\n            self,\n            ADMIN_USER,\n            'System',\n            datetime.datetime.now(datetime.timezone.utc),\n            True\n        )\n\n    def get_settings(self) -> AbstractSettings:\n        return self._settings\n\n    def get_user(self, identity: str) -> User:\n        return self[identity]\n\n    def get_current_user(self) -> User:\n        return self[self._settings.get_username()]\n"
  },
  {
    "path": "src/fk/core/timer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom fk.core import events\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_timer import AbstractTimer\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER\nfrom fk.core.timer_data import TimerData\nfrom fk.core.timer_strategies import TimerRingInternalStrategy\n\nlogger = logging.getLogger(__name__)\n\n\nclass PomodoroTimer(AbstractEventEmitter):\n    _tick_timer: AbstractTimer\n    _transition_timer: AbstractTimer\n    _source_holder: EventSourceHolder\n    _tick_counter: int\n\n    @property\n    def timer(self) -> TimerData:\n        return self._source_holder.get_source().get_data().get_current_user().get_timer()\n\n    # Emitted events\n    TimerTick = \"TimerTick\"\n\n    def __init__(self,\n                 tick_timer: AbstractTimer,\n                 transition_timer: AbstractTimer,\n                 settings: AbstractSettings,\n                 source_holder: EventSourceHolder):\n        super().__init__([self.TimerTick],\n                         settings.invoke_callback)\n        logger.debug('PomodoroTimer: Initializing')\n        self._tick_timer = tick_timer\n        self._transition_timer = transition_timer\n        self._source_holder = source_holder\n        self._tick_counter = 0\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        logger.debug('PomodoroTimer: Initialized')\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        source.on(events.SourceMessagesProcessed, self._refresh)\n        source.on(events.AfterPomodoroWorkStart, self._handle_pomodoro_work_start)\n        source.on(events.AfterPomodoroRestStart, self._handle_pomodoro_rest_start)\n        source.on(events.AfterPomodoroComplete, self._handle_pomodoro_complete)\n        source.on(events.AfterPomodoroVoided, self._handle_pomodoro_complete)\n\n    def _refresh(self, event: str | None = None, when: datetime.datetime | None = None, **kwargs) -> None:\n        timer = self.timer\n        logger.debug(f'PomodoroTimer: Refreshing. Timer: {timer}')\n        pomodoro: Pomodoro | None = timer.get_running_pomodoro()\n\n        if pomodoro is None:\n            logger.debug('PomodoroTimer: Currently idle')\n            self._transition_timer.cancel()\n            self._tick_timer.cancel()\n        elif pomodoro is not None:\n            self._transition_timer.cancel()\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                timer.update_remaining_duration(None)\n                if timer.get_remaining_duration() > 0:\n                    self._schedule_tick()\n                    if pomodoro.is_working():\n                        logger.debug(f'PomodoroTimer: Is working')\n                        self._schedule_transition(\n                            timer.get_remaining_duration() * 1000,\n                            pomodoro,\n                            'rest')\n                    elif pomodoro.is_resting():\n                        logger.debug(f'PomodoroTimer: Is resting')\n                        self._schedule_transition(\n                            timer.get_remaining_duration() * 1000,\n                            pomodoro,\n                            'finished')\n                    else:\n                        raise Exception(f'Unexpected running state: {pomodoro.get_state()}')\n            elif pomodoro.get_type() == POMODORO_TYPE_TRACKER or pomodoro.is_long_break():\n                self._schedule_tick()\n\n    def _schedule_tick(self) -> None:\n        self._tick_counter = 0\n        self._tick_timer.schedule(990, self._handle_tick, None)\n\n    def _handle_tick(self, params: dict | None, when: datetime.datetime | None = None) -> None:\n        timer = self.timer\n        if timer.is_ticking():\n            self._emit(PomodoroTimer.TimerTick, {\n                'timer': timer,\n                'counter': self._tick_counter,\n            }, None)\n            self._tick_counter += 1\n        else:\n            logger.warning('Pomodoro timer is ticking while the data suggests that it should not')\n\n    def _schedule_transition(self,\n                             ms: float,\n                             target_pomodoro: Pomodoro,\n                             target_state: str) -> None:\n        logger.debug(f'PomodoroTimer: Scheduled transition to {target_state} in {ms / 1000} seconds')\n        self._transition_timer.schedule(ms, self._handle_transition, {\n            'target_pomodoro': target_pomodoro,\n            'target_state': target_state,\n        }, True)\n        logger.debug(f'PomodoroTimer: Done - Scheduled transition to {target_state} in {ms / 1000} seconds')\n\n    def _handle_transition(self, params: dict | None, when: datetime.datetime | None) -> None:\n        timer = self.timer\n        target_pomodoro: Pomodoro = params['target_pomodoro']\n        target_state: str = params['target_state']\n        logger.debug(f'PomodoroTimer: Handling transition from {timer.get_state()} to {target_state}')\n        if target_pomodoro.is_finished():\n            logger.debug(f\"We've already sealed this pomodoro, nothing else to do\")\n            return\n        if target_state == 'rest' or target_state == 'finished':\n            # Getting fresh rest duration in case it changed since the pomodoro was created.\n            # Note that we get the fresh work duration as soon as the work starts (see get_work_duration()).\n            logger.debug(f\"Will execute TimerRingInternalStrategy()\")\n            self._source_holder.get_source().execute(\n                TimerRingInternalStrategy,\n                [],\n                persist=False,\n                when=when)\n            logger.debug(f\"PomodoroTimer: Executed TimerRingInternalStrategy\")\n        elif target_state == 'new':\n            logger.debug(f\"PomodoroTimer: Pomodoro is voided, nothing else to do\")\n        else:\n            raise Exception(f\"Unexpected scheduled transition state: {target_state}\")\n        logger.debug(f'PomodoroTimer: Done - Handling transition to {target_state}')\n\n    def _handle_pomodoro_work_start(self,\n                                    pomodoro: Pomodoro,\n                                    work_duration: float,\n                                    **kwargs) -> None:\n        # We can be here either if our user started work immediately, of if we received an external strategy\n        logger.debug(f'Handling work start')\n        if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n            duration = pomodoro.remaining_time_in_current_state(None)\n            self._schedule_transition(duration * 1000, pomodoro, 'rest')\n        self._schedule_tick()\n        logger.debug(f'PomodoroTimer: Done - Handling work start')\n\n    def _handle_pomodoro_rest_start(self,\n                                    pomodoro: Pomodoro,\n                                    rest_duration: float,\n                                    **kwargs) -> None:\n        logger.debug(f'PomodoroTimer: Handling rest start')\n        if rest_duration > 0:\n            duration = pomodoro.remaining_time_in_current_state(None)\n            self._schedule_transition(duration * 1000, pomodoro, 'finished')\n        else:\n            logger.debug(f'PomodoroTimer: Long break - did not schedule automatic transition to finished')\n        logger.debug(f'PomodoroTimer: Done - Handling rest start')\n\n    def _handle_pomodoro_complete(self,\n                                  pomodoro: Pomodoro,\n                                  **kwargs) -> None:\n        logger.debug(f'PomodoroTimer: Handling pomodoro complete or void')\n        # It might look better to just check for the terminal conditions directly in the handlers instead\n        # of canceling timers here. We are going the latter path to account for scenarios when stuff gets\n        # void because of cascading deletes. For instance, if we delete a Workitem with a running pomodoro,\n        # the latter is canceled automatically. If we were to check this in the handler, we would've been\n        # ticking and running stuff against zombie objects, which might be dangerous form the data\n        # consistency point of view.\n        self._transition_timer.cancel()\n        logger.debug('PomodoroTimer: Canceled transition timer')\n        self._tick_timer.cancel()\n        logger.debug(f'PomodoroTimer: Done - Handling pomodoro complete or void')\n"
  },
  {
    "path": "src/fk/core/timer_data.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom fk.core.abstract_data_item import AbstractDataItem, generate_uid\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL\nfrom fk.core.workitem import Workitem\n\nlogger = logging.getLogger(__name__)\n\n\nclass TimerData(AbstractDataItem['User']):\n    # State is one of the following: work, rest, idle\n    _state: str | None\n    _pomodoro: Pomodoro | None\n    _planned_duration: float\n    _remaining_duration: float\n    _last_state_change: datetime.datetime | None\n    _next_state_change: datetime.datetime | None\n    _last_date: datetime.date\n    _pomodoro_in_series: int\n\n    def __init__(self,\n                 user: 'User',\n                 create_date: datetime.datetime):\n        super().__init__(uid=generate_uid(), parent=user, create_date=create_date)\n        self._state = 'idle'\n        self._pomodoro = None\n        self._planned_duration = 0\n        self._remaining_duration = 0\n        self._last_state_change = None\n        self._next_state_change = None\n        self._last_date = datetime.date.today()\n        self._pomodoro_in_series = 0\n\n    def get_running_pomodoro(self) -> Pomodoro | None:\n        return self._pomodoro\n\n    def get_running_workitem(self) -> Workitem | None:\n        return self._pomodoro.get_parent() if self._pomodoro is not None else None\n\n    def get_state(self) -> str:\n        return self._state\n\n    def idle(self, when: datetime.datetime | None = None) -> None:\n        self._state = 'idle'\n        self._pomodoro = None\n        self._planned_duration = 0\n        self._remaining_duration = 0\n        self._last_state_change = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n        self._next_state_change = None\n        self.item_updated(when)\n        logger.debug(f'Timer: Transitioned to idle at {self._last_state_change}')\n\n    def work(self, pomodoro: Pomodoro, work_duration: float, when: datetime.datetime | None = None) -> None:\n        self._state = 'work'\n        self._pomodoro = pomodoro\n        self._planned_duration = work_duration\n        self._remaining_duration = work_duration\n        self._last_state_change = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n        if work_duration and pomodoro.get_type() == POMODORO_TYPE_NORMAL:   # It might be 0 for tracker workitems\n            self._next_state_change = self._last_state_change + datetime.timedelta(seconds=work_duration)\n        else:\n            self._next_state_change = None\n        self.item_updated(when)\n        logger.debug(f'Timer: Transitioned to work at {self._last_state_change}. '\n                     f'Next state change: {self._next_state_change}')\n\n    def _refresh_today(self, when: datetime.datetime | None = None):\n        if when is None:\n            when = datetime.datetime.now()\n        today = when.date()\n        if self._last_date != today:\n            self._last_date = today\n            self._pomodoro_in_series = 0\n            logger.debug('Reset pomodoro series because we started a new day')\n\n    def rest(self, rest_duration: float, when: datetime.datetime | None = None) -> None:\n        self._state = 'rest'\n        self._planned_duration = rest_duration\n        self._remaining_duration = rest_duration\n        self._last_state_change = datetime.datetime.now(datetime.timezone.utc) if when is None else when\n\n        self._refresh_today(when)  # Reset the series, if needed\n\n        if rest_duration > 0 and self._pomodoro.get_type() == POMODORO_TYPE_NORMAL:   # It might be 0 for long / unlimited breaks\n            self._next_state_change = self._last_state_change + datetime.timedelta(seconds=rest_duration)\n            self._pomodoro_in_series += 1  # Increment the number of completed pomodoros in series\n        else:\n            self._next_state_change = None\n            self._pomodoro_in_series = 0  # We started a long break, can now reset the series\n\n        self.item_updated(when)\n\n        logger.debug(f'Timer: Transitioned to rest at {self._last_state_change}. '\n                     f'Next state change: {self._next_state_change}. '\n                     f'Pomodoros in series: {self._pomodoro_in_series}.')\n\n    def is_working(self) -> bool:\n        return self._state == 'work'\n\n    def is_resting(self) -> bool:\n        return self._state == 'rest'\n\n    def is_idling(self) -> bool:\n        return self._state == 'idle'\n\n    def is_ticking(self) -> bool:\n        return self._state != 'idle'\n\n    def get_planned_duration(self) -> int:\n        return self._planned_duration\n\n    def get_remaining_duration(self) -> float:\n        return self._remaining_duration\n\n    def get_next_state_change(self) -> datetime.datetime | None:\n        return self._next_state_change\n\n    # There's no \"when\" parameter, because it assumes we call update_remaining_duration first\n    def format_remaining_duration(self) -> str:\n        remaining_duration = self.get_remaining_duration()     # This is always >= 0\n        remaining_minutes = str(int(remaining_duration / 60)).zfill(2)\n        remaining_seconds = str(int(remaining_duration % 60)).zfill(2)\n        return f'{remaining_minutes}:{remaining_seconds}'\n\n    def format_elapsed_work_duration(self, when: datetime.datetime | None = None) -> str:\n        if self._pomodoro is None:\n            return 'N/A'\n        else:\n            elapsed_duration = int(self._pomodoro.get_elapsed_work_duration(when))\n            td = datetime.timedelta(seconds=elapsed_duration)\n            return f'{td}'\n\n    def format_elapsed_rest_duration(self, when: datetime.datetime | None = None) -> str:\n        if self._pomodoro is None:\n            return 'N/A'\n        else:\n            elapsed_duration = int(self._pomodoro.get_elapsed_rest_duration(when))\n            td = datetime.timedelta(seconds=elapsed_duration)\n            return f'{td}'\n\n    def __str__(self) -> str:\n        s = 'no pomodoro'\n        if self._pomodoro is not None:\n            s = 'workitem' + str(self._pomodoro.get_parent())\n        return f'Timer for user {self.get_parent().get_identity()}, {s}. ' \\\n               f'State \"{self._state}\", ' \\\n               f'started at {self._last_state_change}, ' \\\n               f'next ring at {self._next_state_change}'\n\n    def update_remaining_duration(self, when: datetime.datetime | None):\n        if self._next_state_change is not None:\n            now = when if when is not None else datetime.datetime.now(datetime.timezone.utc)\n            if now < self._next_state_change:\n                self._remaining_duration = (self._next_state_change - now).total_seconds()\n            else:\n                self._remaining_duration = 0\n        self.item_updated(when)\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['state'] = self._state\n        d['pomodoro'] = self._pomodoro.get_uid() if self._pomodoro is not None else None\n        d['planned_duration'] = self._planned_duration\n        d['remaining_duration'] = self._remaining_duration\n        d['last_state_change'] = self._last_state_change\n        d['next_state_change'] = self._next_state_change\n        return d\n\n    def get_pomodoro_in_series(self) -> int:\n        self._refresh_today()\n        return self._pomodoro_in_series\n"
  },
  {
    "path": "src/fk/core/timer_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER\nfrom fk.core.pomodoro_strategies import AddInterruptionStrategy\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.timer_data import TimerData\nfrom fk.core.workitem import Workitem\n\n\n# StartTimer(\"123-456-789\", [\"1500\", [\"300\"]])\n@strategy\nclass StartTimerStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _work_duration: float\n    _rest_duration: float\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        if len(params) >= 2 and params[1] != '':\n            self._work_duration = float(params[1])\n            if len(params) >= 3 and params[2] != '':\n                self._rest_duration = float(params[2])\n            else:\n                self._rest_duration = 0.0  # There will be a long break\n        else:\n            self._work_duration = 0.0  # This will be a tracker\n            self._rest_duration = 0.0\n\n    def get_workitem(self,\n                     data: Tenant,\n                     uid: str,\n                     fail_if_not_found: bool = True,\n                     fail_if_sealed: bool = False) -> Workitem | None:\n        for backlog in self.get_user(data).values():\n            if uid in backlog:\n                workitem: Workitem = backlog[uid]\n                if fail_if_sealed and workitem.is_sealed():\n                    raise Exception(f'Cannot start timer at {self.get_sequence()} because workitem \"{uid}\" is sealed')\n                return workitem\n\n        if fail_if_not_found:\n            raise Exception(f'Cannot start timer at at {self.get_sequence()} because workitem \"{uid}\" not found')\n        else:\n            return None\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        timer: TimerData = self.get_user(data).get_timer()\n        if timer.is_ticking():\n            raise Exception(f'Cannot start timer at {self.get_sequence()} for workitem {self._workitem_uid}, '\n                            f'because it is already running for \"{timer.get_running_workitem()}\"')\n\n        workitem: Workitem = self.get_workitem(data, self._workitem_uid, True, True)\n\n        for pomodoro in workitem.values():\n            if pomodoro.is_startable():\n                params = {\n                    'pomodoro': pomodoro,\n                    'workitem': workitem,\n                    'work_duration': self._work_duration,\n                    'rest_duration': self._rest_duration,\n                }\n                if not workitem.is_running():\n                    emit(events.BeforeWorkitemStart, params, self._carry)\n                    workitem.start(self._when)\n                    emit(events.AfterWorkitemStart, params, self._carry)\n                emit(events.BeforePomodoroWorkStart, params, self._carry)\n\n                if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                    pomodoro.update_work_duration(self._work_duration)\n                    pomodoro.update_rest_duration(self._rest_duration)\n\n                pomodoro.start_work(self._when)\n                pomodoro.item_updated(self._when)\n\n                timer.work(pomodoro, pomodoro.get_work_duration(), self._when)\n                emit(events.TimerWorkStart, {\n                    'timer': timer,\n                }, self._carry)\n\n                emit(events.AfterPomodoroWorkStart, params, self._carry)\n                return\n\n        raise Exception(f'Cannot start timer at {self.get_sequence()} because there are no startable pomodoros in \"{self._workitem_uid}\"')\n\n\n# StopTimer(\"\")\n# This strategy assumes an explicit stop by the end user. Timer rings do not produce this strategy.\n@strategy\nclass StopTimerStrategy(AbstractStrategy[Tenant]):\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        timer: TimerData = self.get_user(data).get_timer()\n        if timer.is_idling():\n            raise Exception(f'Cannot stop the timer at {self.get_sequence()}, because it is not running')\n\n        pomodoro = timer.get_running_pomodoro()\n\n        if pomodoro.get_type() not in [POMODORO_TYPE_TRACKER, POMODORO_TYPE_NORMAL]:\n            raise Exception(f'Cannot stop the timer at {self.get_sequence()} for a running pomodoro of type {pomodoro.get_type()}')\n\n        if pomodoro.get_type() == POMODORO_TYPE_NORMAL and (pomodoro.get_rest_duration() > 0 or pomodoro.is_working()):\n            # Stopping a normal running pomodoro with predefined rest duration means voiding it\n            params = {\n                'pomodoro': pomodoro,\n                'reason': 'Voided automatically because you completed the workitem while the timer was running.',\n            }\n            emit(events.BeforePomodoroVoided, params, self._carry)\n            pomodoro.void(self._when)\n            pomodoro.item_updated(self._when)\n\n            timer.idle(self._when)\n            timer.item_updated(self._when)\n            emit(events.TimerRestComplete, {\n                'timer': timer,\n                'pomodoro': pomodoro,\n            }, self._carry)\n\n            emit(events.AfterPomodoroVoided, params, self._carry)\n        else:\n            # We either stop a pomodoro with unlimited rest period, during rest; or a tracker -- it's a\n            # normal completion then\n            params = {\n                'pomodoro': pomodoro,\n            }\n            emit(events.BeforePomodoroComplete, params, self._carry)\n            pomodoro.seal(self._when)\n            pomodoro.item_updated(self._when)\n\n            timer.idle(self._when)\n            timer.item_updated(self._when)\n            emit(events.TimerRestComplete, {\n                'timer': timer,\n                'pomodoro': pomodoro,\n            }, self._carry)\n\n            emit(events.AfterPomodoroComplete, params, self._carry)\n\n\nclass TimerRingInternalStrategy(AbstractStrategy[Tenant]):\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        timer: TimerData = self.get_user(data).get_timer()\n        if timer.is_idling():\n            raise Exception(f'The timer rings at {self.get_sequence()}, but it was not running')\n\n        pomodoro: Pomodoro = timer.get_running_pomodoro()\n        if timer.is_working():\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                params = {\n                    'pomodoro': pomodoro,\n                    'rest_duration': pomodoro.get_rest_duration(),\n                }\n                emit(events.BeforePomodoroRestStart, params, self._carry)\n                pomodoro.start_rest(self._when)\n                pomodoro.item_updated(self._when)\n                timer.rest(pomodoro.get_rest_duration(), self._when)\n                timer.item_updated(self._when)\n            else:\n                raise Exception(f'The timer should not ring at {self.get_sequence()} for a tracker pomodoro')\n\n            emit(events.TimerWorkComplete, {\n                'timer': timer,\n                'pomodoro': pomodoro,\n            }, self._carry)\n\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                emit(events.AfterPomodoroRestStart, params, self._carry)\n            else:\n                emit(events.AfterPomodoroComplete, params, self._carry)\n\n        elif timer.is_resting():\n            params = {\n                'timer': timer,\n                'pomodoro': pomodoro,\n            }\n            emit(events.BeforePomodoroComplete, params, self._carry)\n\n            pomodoro.seal(self._when)\n            pomodoro.item_updated(self._when)\n\n            timer.idle(self._when)\n            timer.item_updated(self._when)\n            emit(events.TimerRestComplete, {\n                'timer': timer,\n                'pomodoro': pomodoro,\n            }, self._carry)\n\n            emit(events.AfterPomodoroComplete, params, self._carry)\n\n\n######################################################\n################## DEPRECATED STUFF ##################\n######################################################\n\n# StartWork(\"123-456-789\", \"1500\", [\"300\"])\n# DEPRECATED, use StartTimerStrategy instead\n@strategy\nclass StartWorkStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _work_duration: float\n    _rest_duration: float\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._work_duration = float(params[1])\n        if len(params) == 3:\n            self._rest_duration = float(params[2])\n        else:\n            self._rest_duration = settings.get_rest_duration()\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        self.execute_another(emit,\n                             data,\n                             StartTimerStrategy,\n                             [self._workitem_uid, self._work_duration, self._rest_duration])\n\n\n# VoidPomodoro(\"123-456-789\")\n# DEPRECATED, use StopTimerStrategy instead\n@strategy\nclass VoidPomodoroStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        self.execute_another(emit,\n                             data,\n                             AddInterruptionStrategy,\n                             [self._workitem_uid, f'Pomodoro voided'])\n        self.execute_another(emit,\n                             data,\n                             StopTimerStrategy,\n                             [])\n\n\n# FinishTracking(\"123-456-789\")\n# DEPRECATED, use StopTimerStrategy instead\n@strategy\nclass FinishTrackingStrategy(AbstractStrategy[Tenant]):\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        self.execute_another(emit,\n                             data,\n                             StopTimerStrategy,\n                             [])\n"
  },
  {
    "path": "src/fk/core/user.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.backlog import Backlog\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER\nfrom fk.core.tags import Tags\nfrom fk.core.timer_data import TimerData\n\n\nclass User(AbstractDataContainer[Backlog, 'Tenant']):\n    _is_system_user: bool\n    _tags: Tags\n    _timer: TimerData\n\n    def __init__(self,\n                 data: 'Tenant',\n                 identity: str,\n                 name: str,\n                 create_date: datetime.datetime,\n                 is_system_user: bool):\n        super().__init__(name, data, identity, create_date)\n        self._is_system_user = is_system_user\n        self._tags = Tags(self)\n        self._timer = TimerData(self, create_date)\n\n    def __str__(self):\n        return f'User \"{self.get_name()} <{self.get_uid()}>\"'\n\n    def get_identity(self) -> str:\n        return self.get_uid()\n\n    def is_system_user(self) -> bool:\n        return self._is_system_user\n\n    # Returns (state, total remaining). State can be Focus, Rest and Idle\n    def get_state(self, when: datetime.datetime) -> (str, int):\n        p = self._timer.get_running_pomodoro()\n        if p is not None and p.get_type() == POMODORO_TYPE_NORMAL and p.is_working():\n            return f\"Focus\", p.remaining_minutes_in_current_state_str(when)\n        elif p is not None and p.get_type() == POMODORO_TYPE_NORMAL and p.is_resting():\n            return \"Rest\", p.remaining_minutes_in_current_state_str(when)\n        elif p is not None and p.get_type() == POMODORO_TYPE_TRACKER:\n            return \"Tracking\", 0\n        else:\n            return \"Idle\", 0\n\n    def get_tags(self) -> Tags:\n        return self._tags\n\n    def get_timer(self) -> TimerData:\n        return self._timer\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, mask_uid, mask_last_modified)}\\n' \\\n               f'{indent}  System user: {self._is_system_user}'\n        # TODO: Dump tags and timer\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['is_system_user'] = self._is_system_user\n        return d\n"
  },
  {
    "path": "src/fk/core/user_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog_strategies import DeleteBacklogStrategy\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\n\ndef is_system_user(user_identity: str):\n    return user_identity == 'admin@local.host'\n\n\n# CreateUser(\"alice@example.com\", \"Alice Cooper\")\n@strategy\nclass CreateUserStrategy(AbstractStrategy[Tenant]):\n    _target_user_identity: str\n    _user_name: str\n\n    def get_target_user_identity(self) -> str:\n        return self._target_user_identity\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._target_user_identity = params[0]\n        self._user_name = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if not is_system_user(self._user_identity):\n            raise Exception(f'A non-System user \"{self._user_identity}\" tries to create user \"{self._user_identity}\"')\n        if self._target_user_identity in data:\n            raise Exception(f'User \"{self._target_user_identity}\" already exists')\n        emit(events.BeforeUserCreate, {\n            'user_identity': self._target_user_identity,\n            'user_name': self._user_name,\n        }, self._carry)\n        user = User(data, self._target_user_identity, self._user_name, self._when, False)\n        data[self._target_user_identity] = user\n        user.item_updated(self._when)   # This will also update the Tenant\n        emit(events.AfterUserCreate, {\n            'user': user\n        }, self._carry)\n\n\n# DeleteUser(\"alice@example.com\", \"\")\n@strategy\nclass DeleteUserStrategy(AbstractStrategy[Tenant]):\n    _target_user_identity: str\n\n    def get_target_user_identity(self) -> str:\n        return self._target_user_identity\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._target_user_identity = params[0]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if self._target_user_identity not in data:\n            raise Exception(f'User \"{self._target_user_identity}\" not found')\n        if data[self._target_user_identity].is_system_user():\n            raise Exception(f'Not allowed to delete System user')\n        if not is_system_user(self._user_identity):\n            raise Exception(f'A non-System user \"{self._user_identity}\" '\n                            f'tries to delete user \"{self._target_user_identity}\"')\n\n        user: User = data[self._target_user_identity]\n        params = {\n            'user': user\n        }\n        emit(events.BeforeUserDelete, params, self._carry)\n\n        # Cascade delete all backlogs first\n        for backlog in user.values():\n            self.execute_another(emit,\n                                 data,\n                                 DeleteBacklogStrategy,\n                                 [backlog.get_uid()],\n                                 user_override=self._target_user_identity)  # Delete on behalf of target user\n        user.item_updated(self._when)\n\n        # Now delete the user\n        del data[self._target_user_identity]\n        emit(events.AfterUserDelete, params, self._carry)\n\n\n# RenameUser(\"alice@example.com\", \"Alice Cooper\")\n@strategy\nclass RenameUserStrategy(AbstractStrategy[Tenant]):\n    _target_user_identity: str\n    _new_user_name: str\n\n    def get_target_user_identity(self) -> str:\n        return self._target_user_identity\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._target_user_identity = params[0]\n        self._new_user_name = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if self._target_user_identity not in data:\n            raise Exception(f'User \"{self._target_user_identity}\" not found')\n        if data[self._target_user_identity].is_system_user():\n            raise Exception(f'Not allowed to rename System user')\n        if not is_system_user(self._user_identity):\n            raise Exception(f'A non-System user \"{self._user_identity}\" '\n                            f'tries to rename user \"{self._target_user_identity}\"')\n\n        user: User = data[self._target_user_identity]\n        old_name = user.get_name()\n        params = {\n            'user': user,\n            'old_name': old_name,\n            'new_name': self._new_user_name,\n        }\n        emit(events.BeforeUserRename, params, self._carry)\n        user._name = self._new_user_name\n        user.item_updated(self._when)\n        emit(events.AfterUserRename, params, self._carry)\n\n\nclass AutoSealInternalStrategy(AbstractStrategy[Tenant]):\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        pass"
  },
  {
    "path": "src/fk/core/workitem.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport re\nimport textwrap\nfrom collections.abc import Set\nfrom typing import Iterable\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_TRACKER\n\nTAG_REGEX = re.compile('#(\\\\w+)')\n\n\nclass Interval:\n    _started: datetime.datetime\n    _ended: datetime.datetime | None\n    _work_duration: float\n    _rest_duration: float\n\n    def __init__(self, started: datetime.datetime, work_duration: float, rest_duration: float, ended: datetime.datetime | None = None):\n        self._started = started\n        self._ended = ended\n        self._work_duration = work_duration\n        self._rest_duration = rest_duration\n\n    def end(self, when: datetime.datetime):\n        self._ended = when\n\n    def get_started(self) -> datetime.datetime:\n        return self._started\n\n    def is_ended_manually(self) -> bool:\n        return self._ended is not None\n\n    def get_ended(self) -> datetime.datetime:\n        return self._ended\n\n    def get_work_duration(self) -> float:\n        return self._work_duration\n\n    def get_rest_duration(self) -> float:\n        return self._rest_duration\n\n    def __str__(self) -> str:\n        return f'From {self._started} to {self._ended} [{self._work_duration} / {self._rest_duration}]'\n\n    def __eq__(self, other: Interval) -> bool:\n        return (self._ended == other._ended\n                and self._started == other._started\n                and self._work_duration == other._work_duration\n                and self._rest_duration == other._rest_duration)\n\n\nclass Workitem(AbstractDataContainer[Pomodoro, 'Backlog']):\n    # State is one of the following: new, running, finished, canceled\n    _state: str\n    _date_work_started: datetime.datetime | None\n    _date_work_ended: datetime.datetime | None\n    _intervals: list[Interval]\n\n    def __init__(self,\n                 name: str,\n                 uid: str,\n                 backlog: 'Backlog',\n                 create_date: datetime.datetime):\n        super().__init__(name=name, parent=backlog, uid=uid, create_date=create_date)\n        self._state = 'new'\n        self._date_work_started = None\n        self._date_work_ended = None\n        self._intervals = list()\n\n    def __str__(self):\n        if self._state == 'new':\n            char = ' '\n        elif self._state == 'running':\n            char = '*'\n        elif self._state == 'finished':\n            char = '✓'\n        elif self._state == 'canceled':\n            char = 'X'\n        else:\n            raise Exception(f'Invalid workitem state:{self._state}')\n\n        return f' - [{char}] {self._name} {\"\".join([str(p) for p in self.values()])}'\n\n    def seal(self, target_state: str, when: datetime.datetime) -> None:\n        if target_state in ('finished', 'canceled'):\n            self._state = target_state\n            self._date_work_ended = when\n        else:\n            raise Exception(f'Invalid workitem state: {target_state}')\n\n    def add_pomodoro(self,\n                     num_pomodoros: int,\n                     default_work_duration: float,\n                     default_rest_duration: float,\n                     type_: str,\n                     when: datetime.datetime) -> None:\n        is_planned = not self.is_running()\n        existing = len(self)\n        for i in range(num_pomodoros):\n            # At the planning stage we create Pomodoros with the default work and rest\n            # durations, because that's the best info we have. However, when we start\n            # a Pomodoro, this duration can be updated.\n            # Also, note that here we don't emit AddPomodoro events.\n            uid = generate_uid()\n            self[uid] = Pomodoro(\n                existing + i + 1,\n                is_planned,\n                'new',\n                default_work_duration,\n                default_rest_duration,\n                type_,\n                uid,\n                self,\n                when)\n\n    def remove_pomodoro(self, pomodoro: Pomodoro) -> None:\n        del self[pomodoro.get_uid()]\n\n    def is_running(self) -> bool:\n        return self._state == 'running'\n\n    def has_running_pomodoro(self) -> bool:\n        return self.get_running_pomodoro() is not None\n\n    def get_running_pomodoro(self) -> Pomodoro | None:\n        for p in self.values():\n            if p.is_running():\n                return p\n        return None\n\n    def is_sealed(self) -> bool:\n        return self._state in ('finished', 'canceled')\n\n    def is_planned(self) -> bool:\n        backlog_start_date = self.get_parent().get_start_date()\n        if backlog_start_date is None:\n            return True\n        else:\n            return self.get_create_date() <= backlog_start_date\n\n    def is_startable(self) -> bool:\n        if not self.is_sealed():\n            for p in self.values():\n                if p.is_startable():\n                    return True\n        return False\n\n    def start(self, when: datetime.datetime) -> None:\n        self._state = 'running'\n        self._date_work_started = when\n        self.get_parent().update_start_date(when)\n\n    def add_interval(self, start: datetime.datetime, work_duration: float, rest_duration: float):\n        self._intervals.append(Interval(start, work_duration, rest_duration))\n\n    def end_interval(self, when: datetime.datetime):\n        self._intervals[-1].end(when)\n\n    def dump(self, indent: str = '', mask_uid: bool = False, mask_last_modified: bool = False) -> str:\n        return f'{super().dump(indent, mask_uid, mask_last_modified)}\\n' \\\n               f'{indent}  Intervals: {[str(i) for i in self._intervals]}\\n' \\\n               f'{indent}  State: {self._state}\\n' \\\n               f'{indent}  Work started: {self._date_work_started}\\n' \\\n               f'{indent}  Work ended: {self._date_work_ended}'\n\n    def get_work_start_date(self) -> datetime.datetime:\n        return self._date_work_started\n\n    def get_incomplete_pomodoros(self) -> Iterable[Pomodoro]:\n        for pomodoro in self.values():\n            if pomodoro.is_startable():\n                yield pomodoro\n\n    def get_tags(self) -> Set[str]:\n        res = set[str]()\n        for t in TAG_REGEX.finditer(self._name):\n            res.add(t.group(1).lower())\n        return res\n\n    def get_display_name(self) -> str:\n        return textwrap.shorten(self.get_name(), width=60, placeholder='...')\n\n    def get_short_display_name(self) -> str:\n        return textwrap.shorten(self.get_name(), width=30, placeholder='...')\n\n    def get_total_elapsed_time(self) -> datetime.timedelta:\n        total = sum([p.get_elapsed_work_duration() for p in self.values()])\n        return datetime.timedelta(seconds=round(total))\n\n    def is_tracker(self) -> bool:\n        for p in self.values():\n            if p.get_type() == POMODORO_TYPE_TRACKER:\n                return True\n        return False\n\n    def get_intervals(self) -> Iterable[Interval]:\n        return self._intervals\n\n    def to_dict(self) -> dict:\n        d = super().to_dict()\n        d['date_work_started'] = self._date_work_started\n        d['date_work_ended'] = self._date_work_ended\n        d['state'] = self._state\n        d['intervals'] = self._intervals\n        return d\n"
  },
  {
    "path": "src/fk/core/workitem_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom typing import Callable\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog import Backlog\nfrom fk.core.pomodoro_strategies import AddInterruptionStrategy\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tag import Tag\nfrom fk.core.tenant import Tenant\nfrom fk.core.timer_strategies import StopTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\n\n\n# CreateWorkitem(\"123-456-789\", \"234-567-890\", \"Wake up\")\n@strategy\nclass CreateWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _backlog_uid: str\n    _workitem_name: str\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._backlog_uid = params[1]\n        self._workitem_name = params[2]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        user: User = data[self._user_identity]\n        if self._backlog_uid not in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" not found')\n        backlog = user[self._backlog_uid]\n\n        if self._workitem_uid in backlog:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" already exists')\n\n        emit(events.BeforeWorkitemCreate, {\n            'backlog_uid': self._backlog_uid,\n            'workitem_uid': self._workitem_uid,\n            'workitem_name': self._workitem_name,\n        }, self._carry)\n        workitem = Workitem(\n            self._workitem_name,\n            self._workitem_uid,\n            backlog,\n            self._when,\n        )\n        backlog[self._workitem_uid] = workitem\n        workitem.item_updated(self._when)   # This will also update the Backlog\n\n        # Update tags\n        for tag in workitem.get_tags():\n            if tag not in user.get_tags():\n                new_tag = Tag(tag, user, self._when)\n                user.get_tags()[tag] = new_tag\n                emit(events.TagCreated, {\"tag\": new_tag}, self._carry)\n            tag_object = user.get_tags()[tag]\n            if workitem not in tag_object.get_workitems():\n                tag_object.add_workitem(workitem)\n                emit(events.TagContentChanged, {\"tag\": tag_object}, self._carry)\n\n        emit(events.AfterWorkitemCreate, {\n            'workitem': workitem,\n        }, self._carry)\n\n\n# DeleteWorkitem(\"123-456-789\")\n@strategy\nclass DeleteWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        workitem: Workitem | None = None\n        user: User = data[self._user_identity]\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        params = {\n            'workitem': workitem\n        }\n        emit(events.BeforeWorkitemDelete, params, self._carry)\n\n        if workitem.has_running_pomodoro():\n            # No need to add an interruption like we do in CompleteWorkitemStrategy, because it is deleted anyway\n            self.execute_another(emit, data, StopTimerStrategy, [])\n\n        workitem.item_updated(self._when)   # Update Backlog\n\n        # Update tags\n        tags_to_delete = set[Tag]()\n        for tag in user.get_tags().values():\n            if workitem in tag.get_workitems():\n                tag.remove_workitem(workitem)\n                emit(events.TagContentChanged, {\"tag\": tag}, self._carry)\n                if len(tag.get_workitems()) == 0:\n                    tags_to_delete.add(tag)\n        for tag_to_delete in tags_to_delete:\n            del user.get_tags()[tag_to_delete.get_uid()]\n            emit(events.TagDeleted, {\"tag\": tag_to_delete}, self._carry)\n\n        del workitem.get_parent()[self._workitem_uid]\n\n        emit(events.AfterWorkitemDelete, params, self._carry)\n\n\n# RenameWorkitem(\"123-456-789\", \"Wake up\")\n@strategy\nclass RenameWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _new_workitem_name: str\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._new_workitem_name = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        workitem: Workitem | None = None\n        user: User = data[self._user_identity]\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        if self._new_workitem_name == workitem.get_name():\n            # Nothing to do here\n            return\n\n        if workitem.is_sealed():\n            raise Exception(f'Cannot rename sealed workitem \"{self._workitem_uid}\"')\n\n        params = {\n            'workitem': workitem,\n            'old_name': workitem.get_name(),\n            'new_name': self._new_workitem_name,\n        }\n        emit(events.BeforeWorkitemRename, params, self._carry)\n\n        old_tags = workitem.get_tags()\n        workitem.set_name(self._new_workitem_name)\n        workitem.item_updated(self._when)\n        new_tags = workitem.get_tags()\n\n        # Update tags\n        for new_tag in new_tags:\n            if new_tag not in old_tags:\n                # A new tag was added\n                if new_tag not in user.get_tags():\n                    new_tag_object = Tag(new_tag, user, self._when)\n                    user.get_tags()[new_tag] = new_tag_object\n                    emit(events.TagCreated, {\"tag\": new_tag_object}, self._carry)\n                tag_object = user.get_tags()[new_tag]\n                if workitem not in tag_object.get_workitems():\n                    tag_object.add_workitem(workitem)\n                    emit(events.TagContentChanged, {\"tag\": tag_object}, self._carry)\n        tags_to_delete = set[Tag]()\n        fired_for = set[Tag]()\n        for old_tag in old_tags:\n            if old_tag not in new_tags:\n                # An old tag was removed\n                if old_tag in user.get_tags():\n                    old_tag_object = user.get_tags()[old_tag]\n                    if workitem in old_tag_object.get_workitems():\n                        old_tag_object.remove_workitem(workitem)\n                        emit(events.TagContentChanged, {\"tag\": old_tag_object}, self._carry)\n                        if len(old_tag_object.get_workitems()) == 0:\n                            tags_to_delete.add(old_tag_object)\n        for tag_to_delete in tags_to_delete:\n            del user.get_tags()[tag_to_delete.get_uid()]\n            emit(events.TagDeleted, {\"tag\": tag_to_delete}, self._carry)\n\n        emit(events.AfterWorkitemRename, params, self._carry)\n\n\n# CompleteWorkitem(\"123-456-789\", \"canceled\")\n@strategy\nclass CompleteWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _target_state: str\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._target_state = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        workitem: Workitem | None = None\n        user: User = data[self._user_identity]\n        for backlog in user.values():\n            if self._workitem_uid in backlog:\n                workitem = backlog[self._workitem_uid]\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        if workitem.is_sealed():\n            raise Exception(f'Cannot complete already sealed workitem \"{self._workitem_uid}\"')\n\n        params = {\n            'workitem': workitem,\n            'target_state': self._target_state,\n        }\n        emit(events.BeforeWorkitemComplete, params, self._carry)\n\n        # First void pomodoros if needed\n        if workitem.has_running_pomodoro():\n            if not workitem.get_running_pomodoro().is_long_break() and not workitem.is_tracker():\n                self.execute_another(emit,\n                                     data,\n                                     AddInterruptionStrategy,\n                                     [\n                                         self.get_workitem_uid(),\n                                         f'The item was marked completed before Pomodoro rang'])\n            self.execute_another(emit, data, StopTimerStrategy, [])\n\n        # Now complete the workitem itself\n        workitem.seal(self._target_state, self._when)\n        workitem.item_updated(self._when)\n        emit(events.AfterWorkitemComplete, params, self._carry)\n\n\n# ReorderWorkitem(\"123-456-789\", \"0\")\n@strategy\nclass ReorderWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _new_index: int\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._new_index = int(params[1])\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        workitem: Workitem | None = None\n        backlog: Backlog | None = None\n        user: User = data[self._user_identity]\n        for b in user.values():\n            if self._workitem_uid in b:\n                workitem = b[self._workitem_uid]\n                backlog = b\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        params = {\n            'workitem': workitem,\n            'new_index': self._new_index,\n        }\n        emit(events.BeforeWorkitemReorder, params, self._carry)\n        backlog.move_child(workitem, self._new_index)\n        backlog.item_updated(self._when)\n        emit(events.AfterWorkitemReorder, params, self._carry)\n\n\n# MoveWorkitem(\"123-456-789\", \"234-567-890\")\n@strategy\nclass MoveWorkitemStrategy(AbstractStrategy[Tenant]):\n    _workitem_uid: str\n    _backlog_uid: str\n\n    def get_workitem_uid(self) -> str:\n        return self._workitem_uid\n\n    def get_backlog_uid(self) -> str:\n        return self._backlog_uid\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._workitem_uid = params[0]\n        self._backlog_uid = params[1]\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        workitem: Workitem | None = None\n        old_backlog: Backlog | None = None\n        user: User = data[self._user_identity]\n\n        if self._backlog_uid not in user:\n            raise Exception(f'Backlog \"{self._backlog_uid}\" not found')\n        new_backlog: Backlog = user[self._backlog_uid]\n\n        if old_backlog == new_backlog:\n            # Nothing to do\n            return\n\n        for b in user.values():\n            if self._workitem_uid in b:\n                workitem = b[self._workitem_uid]\n                old_backlog = b\n                break\n\n        if workitem is None:\n            raise Exception(f'Workitem \"{self._workitem_uid}\" not found')\n\n        params = {\n            'workitem': workitem,\n            'old_backlog': old_backlog,\n            'new_backlog': new_backlog,\n        }\n        emit(events.BeforeWorkitemMove, params, self._carry)\n        workitem.change_parent(new_backlog)\n        old_backlog.item_updated(self._when)\n        workitem.item_updated(self._when)   # Update Backlog\n        emit(events.AfterWorkitemMove, params, self._carry)\n"
  },
  {
    "path": "src/fk/desktop/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/desktop/application.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport json\nimport logging\nimport platform\nimport secrets\nimport sys\nimport traceback\nimport urllib\nimport webbrowser\nfrom pathlib import Path\nfrom typing import Callable\n\nfrom PySide6 import QtCore\nfrom PySide6.QtCore import QFile, Signal, QStandardPaths\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont, QFontMetrics, QGradient, QIcon, QColor\nfrom PySide6.QtGui import QFontDatabase\nfrom PySide6.QtNetwork import QNetworkProxyFactory\nfrom PySide6.QtNetwork import QTcpServer, QHostAddress\nfrom PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QCheckBox\nfrom semantic_version import Version\n\nfrom fk.core import events\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings, prepare_file_for_writing\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.event_source_factory import EventSourceFactory\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import AfterSettingsChanged, BeforeSettingsChanged\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.integration_executor import IntegrationExecutor\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.sandbox import get_sandbox_type\nfrom fk.core.tenant import Tenant\nfrom fk.desktop.desktop_strategies import DeleteAccountStrategy\nfrom fk.desktop.export_wizard import ExportWizard\nfrom fk.desktop.import_wizard import ImportWizard\nfrom fk.desktop.settings import SettingsDialog\nfrom fk.desktop.stats_window import StatsWindow\nfrom fk.desktop.work_summary_window import WorkSummaryWindow\nfrom fk.qt.about_window import AboutWindow\nfrom fk.qt.actions import Actions\nfrom fk.qt.app_version import get_latest_version, get_current_version\nfrom fk.qt.heartbeat import Heartbeat\nfrom fk.qt.oauth import authenticate, AuthenticationRecord, open_url\nfrom fk.qt.qt_filesystem_watcher import QtFilesystemWatcher\nfrom fk.qt.qt_invoker import invoke_in_main_thread\nfrom fk.qt.qt_settings import QtSettings\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.threaded_event_source import ThreadedEventSource\n\nlogger = logging.getLogger(__name__)\n\nAfterFontsChanged = \"AfterFontsChanged\"\nNewReleaseAvailable = \"NewReleaseAvailable\"\n\n\ndef setting_requires_new_source(name: str) -> bool:\n    return name == 'Source.type' or \\\n        name.startswith('WebsocketEventSource.') or \\\n        name.startswith('FileEventSource.') or \\\n        name == 'Source.ignore_errors' or \\\n        name == 'Source.ignore_invalid_sequence' or \\\n        name == 'Source.encryption_enabled' or \\\n        name == 'Source.encryption_key!'\n\nclass Application(QApplication, AbstractEventEmitter):\n    _settings: AbstractSettings\n    _cryptograph: AbstractCryptograph\n    _font_main: QFont\n    _font_header: QFont\n    _embedded_font_family: str | None\n    _row_height: int\n    _source_holder: EventSourceHolder | None\n    _heartbeat: Heartbeat | None\n    _version_timer: QtTimer\n    _integration_executor: IntegrationExecutor\n    _current_version: Version\n\n    upgraded = Signal(Version)\n\n    def __init__(self, args: [str]):\n        super().__init__(args,\n                         allowed_events=[AfterFontsChanged, NewReleaseAvailable],\n                         callback_invoker=invoke_in_main_thread)\n        # It's important to import Common theme very early, because we need it to get app version, etc.\n        # noinspection PyUnresolvedReferences\n        import fk.desktop.resources\n        self._current_version = get_current_version()\n\n        self.setDesktopFileName('org.flowkeeper.Flowkeeper')    # This makes KDE on Wayland use the correct icon\n        self.setApplicationName('flowkeeper')\n        self.setApplicationDisplayName('Flowkeeper')\n        self.setApplicationVersion(str(self._current_version))\n\n        if '--version' in self.arguments():\n            # This might be useful on Windows or macOS, which store their settings in some obscure locations\n            print(f'Flowkeeper v{self._current_version}')\n            sys.exit(0)\n\n        self._register_source_producers()\n        self._heartbeat = None\n        self._embedded_font_family = None\n        QNetworkProxyFactory.setUseSystemConfiguration(True)\n\n        # It's important to initialize settings after the QApplication\n        # has been constructed, as it uses default QFont and other\n        # OS-specific values\n        if self.is_e2e_mode():\n            self._settings = QtSettings('flowkeeper-desktop-e2e')\n            self._settings.reset_to_defaults()\n            self._initialize_logger()\n            if self._settings.is_keyring_enabled():\n                self._cryptograph = FernetCryptograph(self._settings)\n            else:\n                self._cryptograph = NoCryptograph(self._settings)\n            if self.is_screenshot_mode():\n                from fk.e2e.screenshots_e2e import ScreenshotE2eTest\n                test = ScreenshotE2eTest(self)\n            else:\n                from fk.e2e.backlog_e2e import BacklogE2eTest\n                test = BacklogE2eTest(self)\n            sys.excepthook = test.on_exception\n            test.start()\n        else:\n            sys.excepthook = self.on_exception\n            if self.is_testing_mode():\n                self._settings = QtSettings('flowkeeper-desktop-testing')\n                self._settings.reset_to_defaults()\n                self._settings.set({\n                    'FileEventSource.filename': str(Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)) / 'flowkeeper-testing.txt'),\n                    'Application.show_tutorial': 'False',\n                    'Application.check_updates': 'False',\n                    'Pomodoro.default_work_duration': '5',\n                    'Pomodoro.default_rest_duration': '5',\n                    'Application.play_alarm_sound': 'False',\n                    'Application.play_rest_sound': 'False',\n                    'Application.play_tick_sound': 'False',\n                    'Logger.filename': str(Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.CacheLocation)) / 'flowkeeper-testing.log'),\n                    'Logger.level': 'DEBUG',\n                    'Source.encryption_key!': 'test key',\n                })\n            else:\n                self._settings = QtSettings()\n                if self._settings.get('Application.singleton') == 'True' and self.is_another_instance_running():\n                    logger.warning(f'Another instance of Flowkeeper is running - exiting')\n                    sys.exit(3)\n            self._initialize_logger()\n            if self._settings.is_keyring_enabled():\n                self._cryptograph = FernetCryptograph(self._settings)\n            else:\n                self._cryptograph = NoCryptograph(self._settings)\n        self._settings.on(BeforeSettingsChanged, self._before_settings_changed)\n        self._settings.on(AfterSettingsChanged, self._after_settings_changed)\n\n        # Quit app on close\n        quit_on_close = (self._settings.get('Application.quit_on_close') == 'True')\n        self.setQuitOnLastWindowClosed(quit_on_close)\n\n        # Fonts, styles, etc.\n        self.refresh_theme_and_fonts()\n        self._row_height = self._auto_resize()\n\n        # Version checks\n        self._version_timer = QtTimer('Version checker')\n        self.on(NewReleaseAvailable, self.on_new_version)\n        if self._settings.get('Application.check_updates') == 'True':\n            self._version_timer.schedule(5000, self.check_version, None, True)\n\n        QtTimer('Upgrade checker').schedule(1000, self._check_upgrade, None, True)\n\n        self._source_holder = EventSourceHolder(self._settings, self._cryptograph)\n        self._source_holder.on(AfterSourceChanged, self._on_source_changed, True)\n\n        # Heartbeat\n        self._heartbeat = Heartbeat(self._source_holder, 3000, 500)\n        self._heartbeat.on(events.WentOffline, self._on_went_offline)\n        self._heartbeat.on(events.WentOnline, self._on_went_online)\n\n        self._integration_executor = IntegrationExecutor(self._settings)\n\n    def _get_versions(self):\n        return (f'- Flowkeeper: {self._current_version}\\n'\n                f'- Qt: {QtCore.__version__} ({self.platformName()})\\n'\n                f'- Python: {sys.version}\\n'\n                f'- Platform: {platform.system()} {platform.release()} {platform.version()}\\n'\n                f'- Sandbox: {get_sandbox_type()}\\n'\n                f'- Kernel: {platform.platform()}\\n')\n\n    def _initialize_logger(self):\n        debug = '--debug' in self.arguments()\n\n        log_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n        root = logging.getLogger()\n\n        # 0. Set the overall log level that would apply to ALL handlers\n        root.setLevel(logging.DEBUG if debug else self._settings.get('Logger.level'))\n\n        # 1. Remove existing handlers, if any\n        for existing_handle in root.handlers:\n            existing_handle.close()\n        root.handlers.clear()\n\n        # 2. Check that the entire logger file path exists\n        filename = self._settings.get('Logger.filename')\n        logfile = Path(filename)\n        if logfile.is_dir():    # Fixing #108 - a rare case when the user selects directory as log filename\n            logfile /= 'flowkeeper.log'\n            filename = logfile.absolute()\n        prepare_file_for_writing(filename)\n\n        # 3. Add FILE handler for whatever the user configured\n        file_handler = logging.FileHandler(filename=filename)\n        file_handler.setFormatter(log_format)\n        file_handler.setLevel(logging.DEBUG if debug else self._settings.get('Logger.level'))\n        root.handlers.append(file_handler)\n\n        # 4. Add STDIO handler for warnings and errors\n        stdio_handler = logging.StreamHandler(sys.stdout)\n        stdio_handler.setFormatter(log_format)\n        stdio_handler.setLevel(logging.DEBUG if debug else logging.WARNING)\n        root.handlers.append(stdio_handler)\n\n        logger.debug(f'Versions: \\n{self._get_versions()}')\n\n    def _check_upgrade(self, event: str, when: datetime.datetime | None = None):\n        from_version = Version(self._settings.get('Application.last_version'))\n        if self._current_version != from_version:\n            to_set = {'Application.last_version': str(self._current_version)}\n            logger.info(f'We execute for the first time after upgrade from {from_version} to {self._current_version}')\n            if from_version.major == 0 and 10 > from_version.minor > 0:\n                logger.debug(f'Upgrading from 0.9.1 or older, checking data filename')\n                if not self._settings.is_set('FileEventSource.filename'):\n                    old_filename = Path.home() / 'flowkeeper-data.txt'\n                    if old_filename.exists():\n                        logger.debug(f'Default filename is used and the file exists -- will keep using it')\n                        to_set['FileEventSource.filename'] = str(old_filename.absolute())\n            self.upgraded.emit(from_version)\n            self._settings.set(to_set)\n\n    def initialize_source(self):\n        self._source_holder.request_new_source()\n\n    def _register_source_producers(self):\n        def local_source_producer(settings: AbstractSettings, cryptograph: AbstractCryptograph, root: Tenant):\n            inner_source = FileEventSource[Tenant](settings, cryptograph, root, QtFilesystemWatcher())\n            return ThreadedEventSource(inner_source, self)\n\n        EventSourceFactory.get_event_source_factory().register_producer('local', local_source_producer)\n\n        def ephemeral_source_producer(settings: AbstractSettings, cryptograph: AbstractCryptograph, root: Tenant):\n            inner_source = EphemeralEventSource[Tenant](settings, cryptograph, root)\n            return ThreadedEventSource(inner_source, self)\n\n        EventSourceFactory.get_event_source_factory().register_producer('ephemeral', ephemeral_source_producer)\n\n        # Uncomment it in v1.0.0\n        # def websocket_source_producer(settings: AbstractSettings, cryptograph: AbstractCryptograph, root: Tenant):\n        #     return WebsocketEventSource[Tenant](settings, cryptograph, self, root)\n\n        # EventSourceFactory.get_event_source_factory().register_producer('websocket', websocket_source_producer)\n        # EventSourceFactory.get_event_source_factory().register_producer('flowkeeper.org', websocket_source_producer)\n        # EventSourceFactory.get_event_source_factory().register_producer('flowkeeper.pro', websocket_source_producer)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        try:\n            logger.debug(f'Application: Received AfterSourceChanged for {source}')\n            logger.debug(f'Application: Starting the event source')\n            source.start()\n            logger.debug(f'Application: Event source started successfully')\n        except Exception as e:\n            logger.error(f'Application: Error on source change', exc_info=e)\n            raise e\n\n    def is_e2e_mode(self):\n        return '--e2e' in self.arguments()\n\n    def is_hide_on_start(self):\n        return ('--autostart' in self.arguments() and\n                self.get_settings().get('Application.hide_on_autostart') == 'True' and\n                self.get_settings().get('Application.show_tray_icon') == 'True')\n\n    def is_screenshot_mode(self):\n        return '--screenshots' in self.arguments()\n\n    def is_testing_mode(self):\n        return '--testing' in self.arguments()\n\n    def _on_went_offline(self, event, after: int, last_received: datetime.datetime) -> None:\n        # TODO -- lock the UI\n        logger.warning(f'WARNING - We detected that the client went offline after {after}ms. Last '\n                       f'time we heard from the server was {last_received}')\n\n    def _on_went_online(self, event, ping: int) -> None:\n        # TODO -- unlock the UI\n        logger.info(f'We are (back) online with the roundtrip delay of {ping}ms')\n\n    def get_settings(self):\n        return self._settings\n\n    def get_source_holder(self):\n        return self._source_holder\n\n    def get_theme_variables(self) -> dict[str, str]:\n        theme = self._settings.get_theme()\n        var_file = QFile(f\":/style-{theme}.json\")\n        var_file.open(QFile.OpenModeFlag.ReadOnly)\n        variables = json.loads(var_file.readAll().toStdString())\n        var_file.close()\n        variables['FONT_HEADER_FAMILY'] = self._settings.get('Application.font_header_family')\n        variables['FONT_MAIN_FAMILY'] = self._settings.get('Application.font_main_family')\n        variables['FONT_HEADER_SIZE'] = self._settings.get('Application.font_header_size') + 'pt'\n        variables['FONT_MAIN_SIZE'] = self._settings.get('Application.font_main_size') + 'pt'\n        variables['FONT_SUBTEXT_SIZE'] = str(float(self._settings.get('Application.font_main_size')) * 0.75) + 'pt'\n        return variables\n\n    def get_icon_theme(self):\n        return self.get_theme_variables()['ICON_THEME']\n\n    # noinspection PyUnresolvedReferences\n    def refresh_theme_and_fonts(self):\n        logger.debug('Refreshing theme and fonts')\n\n        self._load_embedded_font()\n\n        template_file = QFile(\":/style-template.qss\")\n        template_file.open(QFile.OpenModeFlag.ReadOnly)\n        qss = template_file.readAll().toStdString()\n        template_file.close()\n\n        variables = self.get_theme_variables()\n\n        for name in variables:\n            value = variables[name]\n            qss = qss.replace(f'${name}', value)\n\n        QIcon.setThemeName(variables['ICON_THEME'])\n\n        self.setStyleSheet(qss)\n        logger.debug('Stylesheet loaded')\n\n        # In Qt 6.7.x it is important to do this AFTER we load the stylesheet, otherwise the fonts\n        # are not loaded correctly at startup\n        self._initialize_fonts()\n\n    def _load_embedded_font(self):\n        # First import embedded font into Qt fonts database\n        embedded_font_id = QFontDatabase.addApplicationFont(\":/NotoSans.ttf\")\n        families = QFontDatabase.applicationFontFamilies(embedded_font_id)\n        if len(families) > 0:\n            self._embedded_font_family = families[0]\n\n    def _initialize_fonts(self) -> (QFont, QFont):\n        default_header_size = int(self._settings.get('Application.font_header_size'))\n        logger.debug(f'Header font: {self._settings.get(\"Application.font_header_family\")}, size {default_header_size}')\n        self._font_header = QFont(self._settings.get('Application.font_header_family'), default_header_size)\n        if self._font_header is None:\n            self._font_header = QFont()\n            new_size = int(self._font_header.pointSize() * 24.0 / 9)\n            self._font_header.setPointSize(new_size)\n\n        default_main_size = int(self._settings.get('Application.font_main_size'))\n        logger.debug(f'Main font: {self._settings.get(\"Application.font_main_family\")}, size {default_main_size}')\n        self._font_main = QFont(self._settings.get('Application.font_main_family'), default_main_size)\n        if self._font_main is None:\n            self._font_main = QFont()\n\n        self.setFont(self._font_main)\n\n        logger.debug(f'Initialized main font: {self._font_main.family()} / {self._font_main.pointSize()}')\n        logger.debug(f'Initialized header font: {self._font_header.family()} / {self._font_header.pointSize()}')\n\n        # Notify everyone\n        self._emit(AfterFontsChanged, {\n            'main_font': self._font_main,\n            'header_font': self._font_header,\n            'application': self\n        })\n\n        self._auto_resize()\n\n    def _auto_resize(self) -> int:\n        h: int = QFontMetrics(self._font_main).height() + 8\n        # users_table.verticalHeader().setDefaultSectionSize(h)\n        # backlogs_table.verticalHeader().setDefaultSectionSize(h)\n        # workitems_table.verticalHeader().setDefaultSectionSize(h)\n        # Save it to Settings, so that we can use this value when\n        # calculating display hints for the Pomodoro Delegate.\n        # As of now, this requires app restart to apply.\n        self._settings.set({'Application.table_row_height': str(h)})\n        return h\n\n    def on_exception(self, exc_type, exc_value, exc_trace):\n        to_log = \"\".join(traceback.format_exception(exc_type, exc_value, exc_trace))\n        logger.error(f\"Global exception handler. Full log: {to_log}\")\n        if (QMessageBox().critical(self.activeWindow(),\n                                   \"Unexpected error\",\n                                   f\"{exc_type.__name__}: {exc_value}\\nWe will appreciate it if you click Open to report it on GitHub.\",\n                                   QMessageBox.StandardButton.Ok,\n                                   QMessageBox.StandardButton.Open)\n                == QMessageBox.StandardButton.Open):\n            versions = self._get_versions().replace('#', '# ')\n            params = urllib.parse.urlencode({\n                'labels': 'exception',\n                'title': f'Unhandled {exc_type.__name__}',\n                'body': f'**Please explain here what you were doing**\\n\\n'\n                        f'Versions:\\n'\n                        f'{versions}\\n'\n                        f'Stack trace:\\n'\n                        f'```\\n{to_log}```'\n            })\n            webbrowser.open(f\"https://github.com/flowkeeper-org/fk-desktop/issues/new?{params}\")\n\n    def bad_file_for_file_source(self):\n        filename = self.get_settings().get('FileEventSource.filename')\n        if (QMessageBox().critical(self.activeWindow(),\n                                   \"Bad data file\",\n                                   f\"The data file you chose ({filename}) is a directory. Please select a valid file.\",\n                                   QMessageBox.StandardButton.Open)\n                == QMessageBox.StandardButton.Open):\n            SettingsDialog.do_browse_simple(filename,\n                                            lambda v: self.get_settings().set({'FileEventSource.filename': v}))\n\n    def get_main_font(self):\n        return self._font_main\n\n    def get_header_font(self):\n        return self._font_header\n\n    def get_row_height(self):\n        return self._row_height\n\n    def _before_settings_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        for name in new_values.keys():\n            if setting_requires_new_source(name):\n                # UC-1: Before a new event source is created, the old one unsubscribes all listeners and disconnects\n                logger.debug(f'Close old event source before settings change')\n                self._source_holder.close_current_source()\n                return\n\n    def _after_settings_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        logger.debug(f'Settings changed from {old_values} to {new_values}')\n\n        request_ui_refresh = False\n        request_new_source = False\n        request_logger_change = False\n\n        for name in new_values.keys():\n            if setting_requires_new_source(name):\n                request_new_source = True\n            elif name == 'Application.quit_on_close':\n                self.setQuitOnLastWindowClosed(new_values[name] == 'True')\n            elif name == 'Application.theme' or 'Application.font_' in name:\n                request_ui_refresh = True\n            elif name == 'Application.check_updates':\n                if new_values[name] == 'True':\n                    self._version_timer.schedule(2000, self.check_version, None, True)\n            elif name.startswith('Logger.'):\n                request_logger_change = True\n\n        if request_ui_refresh:\n            logger.debug(f'Refreshing theme and fonts twice because of a setting change')\n            self.refresh_theme_and_fonts()\n            # With Qt 6.7.x on Windows we need to do it twice, otherwise the\n            # fonts apply only the next time we change the setting. It's a Qt bug.\n            self.refresh_theme_and_fonts()\n\n        if request_new_source:\n            logger.debug(f'Requesting new source because of a setting change')\n            # We've already closed the old one in BeforeSettingsChanged handler\n            self._source_holder.request_new_source()\n\n        if request_logger_change:\n            logger.debug(f'Reinitializing the logger because of a setting change')\n            self._initialize_logger()\n\n    def is_another_instance_running(self) -> bool:\n        server = QTcpServer(self)\n        server.setMaxPendingConnections(0)\n        if server.listen(QHostAddress.SpecialAddress.Any, 11501):\n            logger.debug(f'Could create a TCP listener on port {server.serverPort()}')\n            return False\n        else:\n            return True\n\n    def show_settings_dialog(self):\n        SettingsDialog(\n            self.activeWindow(),  # TODO: To avoid that... shall we make all those functions part of the main window?\n            self._settings,\n            {\n                'FileEventSource.repair': self.repair_file_event_source,\n                'FileEventSource.compress': self.compress_file_event_source,\n                'Application.eyecandy_gradient_generate': self.generate_gradient,\n                'WebsocketEventSource.authenticate': self.sign_in,\n                'WebsocketEventSource.logout': self.sign_out,\n                'WebsocketEventSource.delete_account': self.delete_account,\n            }).show()\n\n    def repair_file_event_source(self, _, callback: Callable) -> bool:\n        if QMessageBox().warning(self.activeWindow(),\n                                 \"Confirmation\",\n                                 f\"Are you sure you want to repair the data source? \"\n                                 f\"This action will\\n\"\n                                 f\"1. Reorder operations according to their timestamps,\\n\"\n                                 f\"2. Remove duplicates,\\n\"\n                                 f\"3. Create missing data entities like users and backlogs, on first reference,\\n\"\n                                 f\"4. Renumber / reindex data,\\n\"\n                                 f\"5. Remove any events, which fail after 2 -- 4,\\n\"\n                                 f\"6. Create a backup file and overwrite the original data source one,\\n\"\n                                 f\"7. Display a detailed log of what it did.\\n\"\n                                 f\"\\n\"\n                                 f\"If there are no errors, then this action won't create or overwrite any files.\",\n                                 QMessageBox.StandardButton.Ok,\n                                 QMessageBox.StandardButton.Cancel) \\\n                == QMessageBox.StandardButton.Ok:\n            cast: FileEventSource = self._source_holder.get_source()\n            log, _ = cast.repair()\n            if 'No changes were made' in log[-1]:\n                # Reload the source\n                self._source_holder.close_current_source()\n                self._source_holder.request_new_source()\n            QInputDialog.getMultiLineText(None,\n                                          \"Repair completed\",\n                                          \"Please save this log for future reference. \"\n                                          \"You can find all new items by searching (CTRL+F) for [Repaired] string.\",\n                                          \"\\n\".join(log))\n            return False\n\n    def compress_file_event_source(self, _, callback: Callable) -> bool:\n        if QMessageBox().warning(self.activeWindow(),\n                                 \"Confirmation\",\n                                 f\"Are you sure you want to compress the data source? \"\n                                 f\"This action will\\n\"\n                                 f\"1. Recreate the strategies based on the current data that you see,\\n\"\n                                 f\"2. Update timestamps with the latest modification date/time,\\n\"\n                                 f\"3. Renumber / reindex data,\\n\"\n                                 f\"4. Remove anything that you deleted,\\n\"\n                                 f\"5. Create a backup file and overwrite the original data source one,\\n\"\n                                 f\"6. Display a detailed log of what it did.\\n\"\n                                 f\"\\n\"\n                                 f\"As a result you will still see the same data as you do now, but the underlying \"\n                                 f\"history might be lost. This might affect statistics and any other features relying \"\n                                 f\"on detailed historical data.\\n\\n\"\n                                 f\"We recommend using this feature only if your loading times became uncomfortably \"\n                                 f\"long, or if you deleted something and want it to be gone forever.\",\n                                 QMessageBox.StandardButton.Ok,\n                                 QMessageBox.StandardButton.Cancel) \\\n                == QMessageBox.StandardButton.Ok:\n            cast: FileEventSource = self._source_holder.get_source()\n            log = cast.compress()\n            if 'No changes were made' in log[-1]:\n                # Reload the source\n                self._source_holder.close_current_source()\n                self._source_holder.request_new_source()\n            QInputDialog.getMultiLineText(None,\n                                          \"The file is compressed\",\n                                          None,\n                                          \"\\n\".join(log))\n            return False\n\n    def delete_account(self, _, callback: Callable) -> bool:\n        source = self._source_holder.get_source()\n        if not source.can_connect() or not self.get_heartbeat().is_online():\n            QMessageBox().warning(self.activeWindow(),\n                                  'No connection',\n                                  'To perform this operation you must be logged in and online.',\n                                  QMessageBox.StandardButton.Ok)\n            return False\n        (test, ok) = QInputDialog.getText(self.activeWindow(),\n                                          'Confirmation',\n                                          'Are you sure you want to delete your account? This will erase all\\n'\n                                          'traces of your user on this server. This operation cannot be undone.\\n'\n                                          'Export your data before doing it.\\n\\n'\n                                          'Type \"delete\" below to confirm.',\n                                          text='')\n        if ok:\n            if test.lower() == 'delete':\n                source.execute(DeleteAccountStrategy, [''])\n                # Avoid re-creating this account immediately\n                source.set_config_parameters({'WebsocketEventSource.consent': 'False'})\n                callback('WebsocketEventSource.consent', 'False')\n                return True  # Close Settings dialog\n            else:\n                QMessageBox().information(self.activeWindow(),\n                                          'Canceled',\n                                          'You should\\'ve typed \"delete\", canceling account deletion.',\n                                          QMessageBox.StandardButton.Ok)\n        return False\n\n    def generate_gradient(self, _, callback: Callable) -> bool:\n        preset_names = [preset.name for preset in QGradient.Preset]\n        if 'NumPresets' in preset_names:\n            preset_names.remove('NumPresets')\n        chosen = secrets.choice(preset_names)\n        self._settings.set({'Application.eyecandy_gradient': chosen})\n        callback('Application.eyecandy_gradient', chosen)\n        return False\n\n    def sign_in(self, _, callback: Callable) -> bool:\n        def save(auth: AuthenticationRecord):\n            self._settings.set({\n                'WebsocketEventSource.auth_type': 'google',\n                'WebsocketEventSource.username': auth.email,\n                'WebsocketEventSource.consent': 'False',\n                'WebsocketEventSource.refresh_token!': auth.refresh_token,\n            })\n            callback('WebsocketEventSource.auth_type', 'google')\n            callback('WebsocketEventSource.username', auth.email)\n            callback('WebsocketEventSource.consent', 'False')\n            callback('WebsocketEventSource.refresh_token!', auth.refresh_token)\n            callback('WebsocketEventSource.logout', f'Sign out <{auth.email}>')\n        authenticate(self, save)\n        return False\n\n    def sign_out(self, _, callback: Callable) -> bool:\n        self._settings.set({\n            'WebsocketEventSource.auth_type': 'google',\n            'WebsocketEventSource.username': 'user@local.host',\n            'WebsocketEventSource.consent': 'False',\n            'WebsocketEventSource.refresh_token!': '',\n        })\n        callback('WebsocketEventSource.auth_type', 'google')\n        callback('WebsocketEventSource.username', 'user@local.host')\n        callback('WebsocketEventSource.consent', 'False')\n        callback('WebsocketEventSource.refresh_token!', '')\n        callback('WebsocketEventSource.logout', f'Sign out')\n        return False\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        actions.add('application.settings', \"Settings\", 'F10', None, Application.show_settings_dialog)\n        actions.add('application.quit', \"Quit\", 'Ctrl+Q', None, Application.quit_local)\n        actions.add('application.import', \"Import data...\", 'Ctrl+I', None, Application.show_import_wizard)\n        actions.add('application.export', \"Export data...\", 'Ctrl+E', None, Application.show_export_wizard)\n        actions.add('application.about', \"About\", '', None, Application.show_about)\n        actions.add('application.toolbar', \"Show toolbar\", '', None, Application.toggle_toolbar, True, True)\n        actions.add('application.stats', \"Pomodoro health\", 'F9', None, Application.show_stats)\n        actions.add('application.workSummary', \"Work summary\", 'F3', None, Application.show_work_summary)\n\n        def contact(url: str) -> Callable:\n            return lambda _: open_url(url)\n        actions.add('application.contactGithub', \"GitHub\", '', None, contact('https://github.com/flowkeeper-org/fk-desktop/issues'))\n        actions.add('application.contactDiscord', \"Discord\", '', None, contact('https://discord.gg/SJfrsvgfmf'))\n        actions.add('application.contactReddit', \"Reddit\", '', None, contact('https://www.reddit.com/r/Flowkeeper'))\n        actions.add('application.contactLinkedIn', \"LinkedIn\", '', None, contact('https://www.linkedin.com/company/flowkeeper-org'))\n        actions.add('application.contactTelegram', \"Telegram\", '', None, contact('https://t.me/flowkeeper_org'))\n        actions.add('application.contactEmail', \"Email\", '', None, contact('mailto:contact@flowkeeper.org'))\n\n    def quit_local(self):\n        Application.quit()\n\n    def show_import_wizard(self):\n        ImportWizard(self._source_holder,\n                     self.activeWindow()).show()\n\n    def show_export_wizard(self):\n        ExportWizard(self._source_holder.get_source(),\n                     self.activeWindow()).show()\n\n    def show_about(self):\n        AboutWindow(self.activeWindow()).show()\n\n    def toggle_toolbar(self, state: bool):\n        self._settings.set({ 'Application.show_toolbar': str(state) })\n\n    def get_heartbeat(self) -> Heartbeat:\n        return self._heartbeat\n\n    def check_version(self, event: str, when: datetime.datetime | None = None) -> None:\n        def on_version(latest: Version, changelog: str):\n            if latest is not None:\n                if latest > self._current_version:\n                    self._emit(NewReleaseAvailable, {\n                        'current': self._current_version,\n                        'latest': latest,\n                        'changelog': changelog,\n                    })\n                else:\n                    logger.debug(f'We are on the latest Flowkeeper version already (current is {self._current_version}, latest is {latest})')\n            else:\n                logger.warning(\"Couldn't get the latest release info from GitHub\")\n        logger.debug('Will check GitHub releases for the latest version')\n        get_latest_version(self, on_version)\n\n    def show_stats(self, event: str = None) -> None:\n        StatsWindow(self.activeWindow(),\n                    self.get_header_font(),\n                    self.get_theme_variables(),\n                    self._source_holder.get_source()).show()\n\n    def show_work_summary(self, event: str = None) -> None:\n        WorkSummaryWindow(self.activeWindow(), self._source_holder.get_source()).show()\n\n    def on_new_version(self, event: str, current: Version, latest: Version, changelog: str) -> None:\n        ignored = self._settings.get('Application.ignored_updates').split(',')\n        latest_str = str(latest)\n        if latest_str in ignored:\n            logger.debug(f'An updated version {latest_str} is available, but the user chose to ignore it')\n            return\n        msg = QMessageBox(QMessageBox.Icon.Information,\n                          \"An update is available\",\n                          f\"You currently use Flowkeeper {current}. A newer version {latest_str} is now available at \"\n                          f\"flowkeeper.org. Would you like to download it? \"\n                          f'[More...](https://flowkeeper.org/v{latest_str}/)',\n                          QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n                          self.activeWindow())\n        msg.setDetailedText(changelog)\n        msg.setTextFormat(Qt.TextFormat.MarkdownText)\n        check = QCheckBox(\"Ignore this update\", msg)\n        msg.setCheckBox(check)\n        res = msg.exec()\n        if check.isChecked():\n            ignored.append(latest_str)\n            self._settings.set({'Application.ignored_updates': ','.join(ignored)})\n        if res == QMessageBox.StandardButton.Yes:\n            webbrowser.open(f\"https://flowkeeper.org/#download\")\n\n    def is_dark_theme(self):\n        bg_color_str = self.get_theme_variables()['PRIMARY_BG_COLOR']\n        return QColor(bg_color_str).lightness() < 128\n"
  },
  {
    "path": "src/fk/desktop/config_wizard.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport os\nfrom typing import Type\n\nfrom PySide6.QtCore import Signal\nfrom PySide6.QtGui import QPixmap, QIcon, QHideEvent, Qt\nfrom PySide6.QtWidgets import QWizardPage, QLabel, QVBoxLayout, QWizard, QWidget, QRadioButton, QMenu, \\\n    QHBoxLayout, QSpacerItem, QSizePolicy\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL\nfrom fk.core.workitem import Workitem\nfrom fk.desktop.application import Application\nfrom fk.qt.actions import Actions\nfrom fk.qt.focus_widget import FocusWidget\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.render.abstract_timer_renderer import AbstractTimerRenderer\nfrom fk.qt.render.classic_timer_renderer import ClassicTimerRenderer\nfrom fk.qt.render.minimal_timer_renderer import MinimalTimerRenderer\nfrom fk.qt.timer_widget import TimerWidget\nfrom fk.qt.tray_icon import TrayIcon\n\n\ndef wrap_in_widget(widget: QWidget):\n    container = QWidget(widget.parent())\n    container.setObjectName('FocusBackground')\n    container.setContentsMargins(0, 0, 0, 0)\n    layout = QVBoxLayout()\n    layout.setContentsMargins(0, 0, 0, 0)\n    layout.addWidget(widget)\n    container.setLayout(layout)\n    return container\n\n\nclass PageConfigFocus(QWizardPage):\n    _tick: int\n    _focus1: FocusWidget\n    _widget1: TimerWidget\n    _option_minimal: QRadioButton\n    _focus2: FocusWidget\n    _widget2: TimerWidget\n    _option_classic: QRadioButton\n\n    def __init__(self, application: Application, actions: Actions):\n        super().__init__()\n        self._tick = 10\n        flavor = application.get_settings().get('Application.focus_flavor')\n\n        layout_v = QVBoxLayout()\n\n        label = QLabel(\"This wizard will help you configure Flowkeeper after installation. First choose \"\n                       \"how would you like to see the Focus bar:\")\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        self._option_minimal = QRadioButton(\"Minimalistic\", self)\n        self._option_minimal.setChecked(flavor == 'minimal')\n        layout_v.addWidget(self._option_minimal)\n\n        focus_minimal = FocusWidget(self,\n                                    application,\n                                    None,\n                                    application.get_source_holder(),\n                                    application.get_settings(),\n                                    actions,\n                                    'minimal',\n                                    True)\n        self._widget1 = focus_minimal._timer_widget\n        layout_v.addWidget(wrap_in_widget(focus_minimal))\n        self._focus1 = focus_minimal\n\n        self._option_classic = QRadioButton(\"Classic\", self)\n        self._option_classic.setChecked(flavor == 'classic')\n        layout_v.addWidget(self._option_classic)\n\n        focus_classic = FocusWidget(self,\n                                    application,\n                                    None,\n                                    application.get_source_holder(),\n                                    application.get_settings(),\n                                    actions,\n                                    'classic',\n                                    True)\n        self._widget2 = focus_classic._timer_widget\n        layout_v.addWidget(wrap_in_widget(focus_classic))\n        self._focus2 = focus_classic\n\n        label = QLabel(\"You can change this in Settings > Appearance\")\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        self.setLayout(layout_v)\n\n        self._timer = QtTimer('Configuration wizard step 1')\n        self._timer.schedule(1000, self._handle_tick, None)\n        self._handle_tick()\n\n    def _handle_tick(self, params: dict | None = None, when: datetime.datetime | None = None) -> None:\n        self._widget1.set_values(self._tick, 10, None, None, 'working')\n        self._widget2.set_values(self._tick, 10, None, None, 'working')\n        self._tick -= 1\n        if self._tick < 0:\n            self._tick = 10\n\n    def get_setting(self) -> str:\n        return 'classic' if self._option_classic.isChecked() else 'minimal'\n\n    def unsubscribe(self) -> None:\n        self._timer.cancel()\n        self._focus1.kill()\n        self._focus2.kill()\n\n\nclass FakeTrayIcon(TrayIcon):\n    _tray: QLabel\n    _kind: str\n    _state: str\n\n    def __init__(self,\n                 tray: QLabel,\n                 actions: Actions,\n                 kind: str,\n                 state: str,\n                 cls: Type[AbstractTimerRenderer]):\n        self._tray = tray\n        self._kind = kind\n        self._state = state\n        super(FakeTrayIcon, self).__init__(tray,\n                                           None,\n                                           None,\n                                           actions,\n                                           48,\n                                           cls,\n                                           kind == 'Dark')\n        self.mode_changed(None, state)\n\n    def setIcon(self, icon: QIcon | QPixmap) -> None:\n        if type(icon) is QIcon:\n            icon = icon.pixmap(22, 22)\n        else:\n            pixmap: QPixmap = icon\n            icon = pixmap.scaled(22, 22, mode=Qt.TransformationMode.SmoothTransformation)\n        self._tray.setPixmap(icon)\n\n    def showMessage(self, title: str, msg: str, icon: QIcon = None, **_) -> None:\n        pass\n\n    def setToolTip(self, tip: str) -> None:\n        self._tray.setToolTip(tip)\n\n    def setContextMenu(self, menu: QMenu) -> None:\n        pass\n\n\nclass PageConfigIcons(QWizardPage):\n    _actions: Actions\n    _option_classic_light: QRadioButton\n    _option_thin_light: QRadioButton\n    _option_classic_dark: QRadioButton\n    _option_thin_dark: QRadioButton\n\n    def __init__(self, application: Application, actions: Actions):\n        super().__init__()\n        self._actions = actions\n        flavor = application.get_settings().get('Application.tray_icon_flavor')\n\n        layout_v = QVBoxLayout()\n        label = QLabel(\"Now choose how you prefer your icons:\")\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        self._option_thin_light = QRadioButton(\"Thin, light background\", self)\n        self._option_thin_light.setChecked(flavor == 'thin-light')\n        layout_v.addWidget(self._option_thin_light)\n        widget_tray_light = QWidget(self)\n        widget_tray_light.setObjectName('trayLight')\n        self._create_icons(widget_tray_light, 'Light', MinimalTimerRenderer)\n        layout_v.addWidget(widget_tray_light)\n\n        self._option_thin_dark = QRadioButton(\"Thin, dark background\", self)\n        self._option_thin_dark.setChecked(flavor == 'thin-dark')\n        layout_v.addWidget(self._option_thin_dark)\n        widget_tray_dark = QWidget(self)\n        widget_tray_dark.setObjectName('trayDark')\n        self._create_icons(widget_tray_dark, 'Dark', MinimalTimerRenderer)\n        layout_v.addWidget(widget_tray_dark)\n\n        self._option_classic_light = QRadioButton(\"Classic, light background\", self)\n        self._option_classic_light.setChecked(flavor == 'classic-light')\n        layout_v.addWidget(self._option_classic_light)\n        widget_tray_classic_light = QWidget(self)\n        widget_tray_classic_light.setObjectName('trayLight')\n        self._create_icons(widget_tray_classic_light, 'Light', ClassicTimerRenderer)\n        layout_v.addWidget(widget_tray_classic_light)\n\n        self._option_classic_dark = QRadioButton(\"Classic, dark background\", self)\n        self._option_classic_dark.setChecked(flavor == 'classic-dark')\n        layout_v.addWidget(self._option_classic_dark)\n        widget_tray_classic_dark = QWidget(self)\n        widget_tray_classic_dark.setObjectName('trayDark')\n        self._create_icons(widget_tray_classic_dark, 'Dark', ClassicTimerRenderer)\n        layout_v.addWidget(widget_tray_classic_dark)\n\n        self.setLayout(layout_v)\n        self.setFinalPage(True)\n\n    def _create_icons(self, container: QWidget, kind: str, cls: Type[AbstractTimerRenderer]):\n        layout = QHBoxLayout(container)\n        layout.setContentsMargins(8, 8, 8, 8)\n        icon_size = 22\n        container.setLayout(layout)\n\n        workitem = Workitem('N/A',\n                            '1',\n                            None,\n                            datetime.datetime.now())\n        pomodoro = Pomodoro(1,\n                            True,\n                            'new',\n                            25 * 60 * 1000,\n                            5 * 60 * 1000,\n                            POMODORO_TYPE_NORMAL,\n                            '1',\n                            workitem,\n                            datetime.datetime.now())\n\n        layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Policy.Expanding))\n\n        icon1 = QLabel('', container)\n        icon1.setFixedHeight(icon_size)\n        FakeTrayIcon(icon1, self._actions, kind, 'idle', cls).reset()\n        layout.addWidget(icon1)\n\n        icon2 = QLabel('', container)\n        icon2.setFixedHeight(icon_size)\n        f2 = FakeTrayIcon(icon2, self._actions, kind, 'working', cls)\n        f2.tick(pomodoro, 'Working', 0.33, 1, 'working')\n        layout.addWidget(icon2)\n\n        icon3 = QLabel('', container)\n        icon3.setFixedHeight(icon_size)\n        f3 = FakeTrayIcon(icon3, self._actions, kind, 'resting', cls)\n        f3.tick(pomodoro, 'Resting', 0.66, 1, 'resting')\n        layout.addWidget(icon3)\n\n        icon4 = QLabel('', container)\n        icon4.setFixedHeight(icon_size)\n        FakeTrayIcon(icon4, self._actions, kind, 'ready', cls)\n        layout.addWidget(icon4)\n\n        clock = QLabel(datetime.datetime.now().time().strftime('%H:%M'), container)\n        clock.setObjectName(f'fakeClock{kind}')\n        layout.addWidget(clock)\n\n    def get_setting(self) -> str:\n        if self._option_classic_light.isChecked():\n            return 'classic-light'\n        elif self._option_thin_light.isChecked():\n            return 'thin-light'\n        elif self._option_classic_dark.isChecked():\n            return 'classic-dark'\n        elif self._option_thin_dark.isChecked():\n            return 'thin-dark'\n\n\nclass ConfigWizard(QWizard):\n    _page_focus: PageConfigFocus\n    _page_icons: PageConfigIcons\n    _settings: AbstractSettings\n\n    closed = Signal(None)\n\n    def __init__(self, application: Application, actions: Actions, parent: QWidget | None):\n        super().__init__(parent)\n        self._settings = application.get_settings()\n        self._page_focus = PageConfigFocus(application, actions)\n        self._page_icons = PageConfigIcons(application, actions)\n        self.addPage(self._page_focus)\n        self.addPage(self._page_icons)\n        self.setWindowTitle(\"First-time configuration\")\n\n        # Account for a Qt bug which shrinks dialogs opened on non-primary displays\n        self.setMinimumSize(600, 500)\n        if os.name == 'nt':\n            # AeroStyle is default on Windows 11, but it looks all white (another Qt bug?) The Classic style looks fine.\n            self.setWizardStyle(QWizard.WizardStyle.ClassicStyle)\n\n        self.button(QWizard.WizardButton.FinishButton).clicked.connect(self._on_finish)\n        self.closed.connect(self.unsubscribe)\n\n    def _on_finish(self):\n        self._settings.set({\n            'Application.focus_flavor': self._page_focus.get_setting(),\n            'Application.tray_icon_flavor': self._page_icons.get_setting(),\n        })\n\n    def unsubscribe(self):\n        self._page_focus.unsubscribe()\n\n    def hideEvent(self, event: QHideEvent) -> None:\n        super(ConfigWizard, self).hideEvent(event)\n        self.closed.emit()\n"
  },
  {
    "path": "src/fk/desktop/desktop.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport sys\nimport threading\n\nfrom PySide6 import QtCore, QtWidgets, QtUiTools\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QIcon, QGuiApplication\nfrom PySide6.QtWidgets import QMessageBox, QMainWindow, QMenu\nfrom semantic_version import Version\n\nfrom fk.core import events\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.events import AfterWorkitemComplete, SourceMessagesProcessed, TimerRestComplete, TimerWorkStart\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.timer_data import TimerData\nfrom fk.core.workitem import Workitem\nfrom fk.desktop.application import Application, AfterSourceChanged\nfrom fk.desktop.config_wizard import ConfigWizard\nfrom fk.desktop.tutorial import Tutorial\nfrom fk.qt.abstract_tableview import AfterSelectionChanged\nfrom fk.qt.actions import Actions\nfrom fk.qt.audio_player import AudioPlayer\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.backlog_widget import BacklogWidget\nfrom fk.qt.connection_widget import ConnectionWidget\nfrom fk.qt.focus_widget import FocusWidget\nfrom fk.qt.progress_widget import ProgressWidget\nfrom fk.qt.qt_settings import QtSettings\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.render.classic_timer_renderer import ClassicTimerRenderer\nfrom fk.qt.render.minimal_timer_renderer import MinimalTimerRenderer\nfrom fk.qt.resize_event_filter import ResizeEventFilter\nfrom fk.qt.search_completer import SearchBar\nfrom fk.qt.theme_change_event_filter import ThemeChangeEventFilter\nfrom fk.qt.tray_icon import TrayIcon\nfrom fk.qt.user_tableview import UserTableView\nfrom fk.qt.workitem_tableview import WorkitemTableView\nfrom fk.qt.workitem_widget import WorkitemWidget\n\nlogger = logging.getLogger(__name__)\n\n\ndef get_timer_ui_mode() -> str:\n    # Options: keep (don't do anything), focus (collapse main layout), minimize (window to tray)\n    return settings.get('Application.timer_ui_mode')\n\n\ndef pin_if_needed(always_on_top_setting: str):\n    window_was_visible = window.isVisible()\n    focus_window_was_visible = focus_window.isVisible()\n\n    is_pinned = always_on_top_setting == 'True'\n    # Adding Qt.WindowType.WindowCloseButtonHint explicitly to fix #77\n    window.setWindowFlags(window.windowFlags() | Qt.WindowType.WindowStaysOnTopHint if is_pinned else\n                          window.windowFlags() & ~Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.WindowCloseButtonHint)\n    focus_window.setWindowFlags(focus_window.windowFlags() | Qt.WindowType.WindowStaysOnTopHint if is_pinned else\n                                focus_window.windowFlags() & ~Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.WindowCloseButtonHint)\n    if window_was_visible:\n        window.show()\n    if focus_window_was_visible:\n        focus_window.show()\n\n\ndef to_focus_mode(**kwargs) -> None:\n    logger.debug('Switching to focus mode')\n\n    was_already_hidden = window.isHidden()\n    window.hide()\n    root_layout.removeWidget(focus_widget)\n\n    focus_widget.setParent(focus_window)\n    focus_window.setCentralWidget(focus_widget)\n    focus_window.setFixedWidth(focus_widget.width())\n    focus_window.setFixedHeight(focus_widget.height())\n\n    if was_already_hidden:\n        # This must be due to hiding with --autostart. Make sure focus window has adequate size.\n        logger.debug('Main window was already hidden when we entered focus mode.')\n        focus_window.setFixedWidth(window.width())\n\n    show_title = settings.get('Application.show_window_title') == 'True'\n    focus_window.setWindowFlags(focus_window.windowFlags() & ~Qt.WindowType.FramelessWindowHint if show_title else\n                                focus_window.windowFlags() | Qt.WindowType.FramelessWindowHint)\n    focus_window.show()\n\n\ndef from_focus_mode(**_) -> None:\n    logger.debug('Switching from focus mode')\n    focus_window.hide()\n    focus_widget.setParent(root_layout_widget)\n    root_layout.insertWidget(0, focus_widget)\n    window.show()\n\n\ndef update_tables_visibility() -> None:\n    users_visible = (settings.get('Application.users_visible') == 'True')\n    users_table.setVisible(users_visible)\n    backlogs_visible = (settings.get('Application.backlogs_visible') == 'True')\n    backlogs_widget.setVisible(backlogs_visible)\n    left_table_layout.setVisible(users_visible or backlogs_visible)\n\n\ndef update_mode(timer_ticking: bool) -> None:\n    mode = get_timer_ui_mode()\n    if timer_ticking:\n        if mode == 'focus':\n            actions['window.focusMode'].setChecked(True)  # This will trigger to_focus_mode() automatically\n        elif mode == 'minimize':\n            window.hide()\n    else:\n        if mode == 'focus':\n            actions['window.focusMode'].setChecked(False)  # This will trigger from_focus_mode() automatically\n        elif mode == 'minimize':\n            # It's a bit more complex than just showing the main window, because the user might have\n            # detached the focus widget in the meantime.\n            if focus_widget.parent() == focus_window:\n                actions['window.focusMode'].setChecked(False)  # This will trigger from_focus_mode() automatically\n            elif focus_widget.parent() == root_layout_widget:\n                window.show()\n            else:\n                raise Exception(\"Focus widget is detached, this should never happen. Please open a bug in GitHub.\")\n\n\ndef recreate_tray_icon(flavor: str, show_tray_icon_setting: str) -> None:\n    global tray\n    initialize_timer = False\n    if tray is not None:\n        tray.kill()\n        tray.setVisible(False)\n        initialize_timer = True\n    tray = TrayIcon(window,\n                    pomodoro_timer,\n                    app.get_source_holder(),\n                    actions,\n                    48,\n                    MinimalTimerRenderer if 'thin' in flavor else ClassicTimerRenderer,\n                    'dark' in flavor)\n    if initialize_timer:\n        tray.initialized()\n    tray.setVisible(show_tray_icon_setting == 'True')\n\n\ndef on_settings_changed(event: str, old_values: dict[str, str], new_values: dict[str, str]):\n    logger.debug(f'Settings changed from {old_values} to {new_values}')\n    status.showMessage('Settings changed')\n\n    backlogs_visible = None\n    users_visible = None\n\n    for name in new_values.keys():\n        new_value = new_values[name]\n        if name == 'Application.show_main_menu':\n            main_menu.setVisible(new_value == 'True')\n        elif name == 'Application.show_status_bar':\n            status.setVisible(new_value == 'True')\n        elif name == 'Application.show_left_toolbar':\n            left_toolbar.setVisible(new_value == 'True')\n        elif name == 'Application.show_tray_icon':\n            tray.setVisible(new_value == 'True')\n        elif name == 'Application.shortcuts':\n            actions.update_from_settings(new_value)\n        elif name == 'Application.always_on_top':\n            pin_if_needed(new_value)\n        elif name == 'Application.focus_flavor':\n            focus_widget.set_flavor(new_value)\n        elif name == 'Application.tray_icon_flavor':\n            recreate_tray_icon(new_value,\n                               new_values.get('Application.show_tray_icon',\n                                              settings.get('Application.show_tray_icon')))\n        elif name == 'Application.backlogs_visible':\n            backlogs_visible = new_value == 'True'\n            backlogs_widget.setVisible(backlogs_visible)\n        elif name == 'Application.users_visible':\n            users_visible = new_value == 'True'\n            users_table.setVisible(users_visible)\n\n    if backlogs_visible is not None or users_visible is not None:\n        left_table_layout.setVisible((users_visible is not None and users_visible) or (backlogs_visible is not None and backlogs_visible))\n\n\nclass MainWindow:\n    def __init__(self):\n        super().__init__()\n\n    def toggle_focus_mode(self, state: bool):\n        if state:\n            to_focus_mode()\n        else:\n            from_focus_mode()\n\n    def toggle_pin_window(self, state: bool):\n        is_checked: bool = 'window.pinWindow' in actions and actions['window.pinWindow'].isChecked()\n        settings.set({'Application.always_on_top': str(is_checked)})\n\n    def toggle_main_window(self):\n        if window.isVisible():\n            # If main window is visible, then focus widget must be in it,\n            # then it's enough to just hide the main window\n            window.hide()\n        else:\n            if focus_window.isVisible():\n                # We are in the focus mode -- hide focus window. The main window is already hidden.\n                focus_window.hide()\n            else:\n                # Everything is hidden. We need to detect the mode before showing correct window.\n                # We do it by checking focus widget's parent.\n                if focus_widget.parent() == focus_window:\n                    focus_window.show()\n                elif focus_widget.parent() == root_layout_widget:\n                    window.show()\n                else:\n                    raise Exception(\"Focus widget is detached, this should never happen. Please open a bug in GitHub.\")\n\n    def show_search(self):\n        search.show()\n\n    def show_tutorial(self):\n        global tutorial\n        tutorial = Tutorial(app.get_source_holder(), settings, window, focus_window)\n\n    def on_upgrade(self, from_version: Version):\n        if from_version.major == 0 and from_version.minor < 9:\n            if window.isHidden() and focus_window.isHidden():\n                # Even if it was configured to hide, typically thanks to --autostart\n                window.show()\n            wizard = ConfigWizard(app, actions, window)\n            wizard.closed.connect(self.show_tutorial)\n            wizard.show()\n\n    def toggle_backlogs(self, enabled):\n        settings.set({'Application.backlogs_visible': str(enabled)})\n\n    def toggle_users(self, enabled):\n        settings.set({'Application.users_visible': str(enabled)})\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        actions.add('window.focusMode', \"Focus Mode\", None, (\"tool-show-timer-only\", \"tool-show-all\"), MainWindow.toggle_focus_mode, True)\n        actions.add('window.showMainWindow', \"Show / Hide Main Window\", None, \"tool-show-timer-only\", MainWindow.toggle_main_window)\n        actions.add('window.showSearch', \"Search...\", 'Ctrl+F', '', MainWindow.show_search)\n\n        is_wayland = QGuiApplication.platformName() == 'wayland'\n        if not is_wayland:\n            actions.add('window.pinWindow', \"Pin Flowkeeper\", None, \"tool-pin\", MainWindow.toggle_pin_window, True)\n\n        backlogs_were_visible = (actions.get_settings().get('Application.backlogs_visible') == 'True')\n        actions.add('window.showBacklogs',\n                    \"Show / Hide Backlogs\",\n                    'Ctrl+B',\n                    ('tool-left-close', 'tool-left-open'),\n                    MainWindow.toggle_backlogs,\n                    True,\n                    backlogs_were_visible)\n\n        users_were_visible = (actions.get_settings().get('Application.users_visible') == 'True')\n        actions.add('window.showUsers',\n                    \"Team\",\n                    'Ctrl+T',\n                    'tool-teams',\n                    MainWindow.toggle_users,\n                    True,\n                    actions.get_settings().is_team_supported() and users_were_visible)\n\n\nif __name__ == \"__main__\":\n    # The order is important here. Some Sources use Qt APIs, so we need an Application instance created first.\n    # Then we initialize a Source. This needs to happen before we configure UI, because the Source will replay\n    # Strategies in __init__, and we don't want anyone to be subscribed to their events yet. It will build the\n    # data model. Once the Source is constructed, we can initialize the rest of the UI, including Qt data models.\n    # From that moment we can respond to user actions and events from the backend, which the Source + Strategies\n    # will pass through to Qt data models via Qt-like connect / emit mechanism.\n    try:\n        app = Application(sys.argv)\n        settings = app.get_settings()\n\n        logger.debug(f'UI thread: {threading.get_ident()}')\n        settings.on(events.AfterSettingsChanged, on_settings_changed)\n\n        def _on_workitem_complete(workitem: Workitem, timer: TimerData):\n            if timer.get_running_workitem() == workitem:\n                update_mode(False)\n\n        def _on_source_changed(event: str, source: AbstractEventSource):\n            actions['window.focusMode'].setChecked(False)\n            source.on(SourceMessagesProcessed, lambda **_: update_mode(source.get_data().get_current_user().get_timer().is_ticking()), last=True)\n            source.on(AfterWorkitemComplete, lambda workitem, **_: _on_workitem_complete(workitem, source.get_data().get_current_user().get_timer()), last=True)\n            source.on(TimerRestComplete, lambda **_: update_mode(False))\n            source.on(TimerWorkStart, lambda **_: update_mode(True))\n\n        app.get_source_holder().on(AfterSourceChanged, _on_source_changed)\n\n        pomodoro_timer = PomodoroTimer(QtTimer(\"Pomodoro Tick\"),\n                                       QtTimer(\"Pomodoro Transition\"),\n                                       app.get_settings(),\n                                       app.get_source_holder())\n\n        loader = QtUiTools.QUiLoader(app)\n\n        # Load main window\n        file = QtCore.QFile(\":/core.ui\")\n        file.open(QtCore.QFile.OpenModeFlag.ReadOnly)\n        # noinspection PyTypeChecker\n        window: QtWidgets.QMainWindow = loader.load(file, None)\n        file.close()\n\n        # Collect actions from all widget types\n        actions = Actions(window, settings)\n        Application.define_actions(actions)\n        BacklogTableView.define_actions(actions)\n        UserTableView.define_actions(actions)\n        WorkitemTableView.define_actions(actions)\n        FocusWidget.define_actions(actions)\n        MainWindow.define_actions(actions)\n        actions.all_actions_defined()\n\n        audio = AudioPlayer(window, app.get_source_holder(), settings)\n\n        # File menu\n        menu_file = QtWidgets.QMenu(\"File\", window)\n        menu_file.addAction(actions['application.settings'])\n        menu_file.addAction(actions['application.import'])\n        menu_file.addAction(actions['application.export'])\n        menu_file.addAction(actions['application.stats'])\n        menu_file.addAction(actions['application.workSummary'])\n        menu_file.addSeparator()\n\n        menu_contact = QtWidgets.QMenu(\"Contact us\", window)\n        menu_contact.addAction(actions['application.contactGithub'])\n        menu_contact.addAction(actions['application.contactDiscord'])\n        menu_contact.addAction(actions['application.contactLinkedIn'])\n        menu_contact.addAction(actions['application.contactReddit'])\n        menu_contact.addAction(actions['application.contactTelegram'])\n        menu_contact.addAction(actions['application.contactEmail'])\n        menu_file.addMenu(menu_contact)\n\n        menu_file.addAction(actions['application.about'])\n        menu_file.addSeparator()\n        menu_file.addAction(actions['application.quit'])\n\n        # noinspection PyTypeChecker\n        left_layout: QtWidgets.QVBoxLayout = window.findChild(QtWidgets.QVBoxLayout, \"leftTableLayoutInternal\")\n\n        # noinspection PyTypeChecker\n        left_toolbar_layout: QtWidgets.QVBoxLayout = window.findChild(QtWidgets.QVBoxLayout, \"left_toolbar_layout\")\n        left_toolbar_layout.addWidget(ConnectionWidget(window, app))\n\n        # Backlogs table\n        backlogs_widget: BacklogWidget = BacklogWidget(window, app, app.get_source_holder(), actions)\n        backlogs_widget.get_table().on(AfterSelectionChanged, lambda event, before, after: workitems_widget.upstream_selected(after))\n        backlogs_widget.get_tags().on(AfterSelectionChanged, lambda event, before, after: workitems_widget.upstream_selected(after))\n        backlogs_widget.get_table().on(AfterSelectionChanged, lambda event, before, after: progress_widget.update_progress(after) if after is not None else None)\n        backlogs_widget.get_tags().on(AfterSelectionChanged, lambda event, before, after: progress_widget.update_progress(after) if after is not None else None)\n        left_layout.addWidget(backlogs_widget)\n\n        # Users table\n        users_table: UserTableView = UserTableView(window, app, app.get_source_holder(), actions)\n        left_layout.addWidget(users_table)\n\n        # noinspection PyTypeChecker\n        right_layout: QtWidgets.QVBoxLayout = window.findChild(QtWidgets.QVBoxLayout, \"rightTableLayoutInternal\")\n\n        # Workitems table\n        workitems_widget: WorkitemWidget = WorkitemWidget(window,\n                                                          app,\n                                                          app.get_source_holder(),\n                                                          pomodoro_timer,\n                                                          actions)\n        right_layout.addWidget(workitems_widget)\n\n        progress_widget = ProgressWidget(window, app.get_source_holder())\n        right_layout.addWidget(progress_widget)\n\n        # noinspection PyTypeChecker\n        search_bar: QtWidgets.QHBoxLayout = window.findChild(QtWidgets.QHBoxLayout, \"searchBar\")\n        search = SearchBar(window,\n                           app.get_source_holder(),\n                           actions,\n                           backlogs_widget.get_table(),\n                           workitems_widget.get_table())\n        search_bar.addWidget(search)\n\n        # noinspection PyTypeChecker\n        root_layout_widget: QtWidgets.QWidget = window.findChild(QtWidgets.QWidget, \"rootLayout\")\n\n        focus_window = QMainWindow(window)\n        focus_window.addActions(list(actions.values()))\n\n        # noinspection PyTypeChecker\n        root_layout: QtWidgets.QVBoxLayout = window.findChild(QtWidgets.QVBoxLayout, \"rootLayoutInternal\")\n        focus_widget = FocusWidget(root_layout_widget,\n                                   app,\n                                   pomodoro_timer,\n                                   app.get_source_holder(),\n                                   settings,\n                                   actions,\n                                   settings.get('Application.focus_flavor'))\n        root_layout.insertWidget(0, focus_widget)\n\n        # Focus window should keep the same title as the main one\n        focus_window.setWindowTitle(window.windowTitle())\n        window.windowTitleChanged.connect(focus_window.setWindowTitle)\n\n        # Layouts\n        # noinspection PyTypeChecker\n        main_layout: QtWidgets.QWidget = window.findChild(QtWidgets.QWidget, \"mainLayout\")\n        # noinspection PyTypeChecker\n        left_table_layout: QtWidgets.QWidget = window.findChild(QtWidgets.QWidget, \"leftTableLayout\")\n\n        # noinspection PyTypeChecker\n        action_backlogs = actions['window.showBacklogs']\n        action_teams = actions['window.showUsers']\n\n        # Main menu\n        # noinspection PyTypeChecker\n        main_menu: QtWidgets.QMenuBar = window.findChild(QtWidgets.QMenuBar, \"menuBar\")\n        # Application.define_actions(actions)\n        # BacklogTableView.define_actions(actions)\n        # UserTableView.define_actions(actions)\n        # WorkitemTableView.define_actions(actions)\n        # FocusWidget.define_actions(actions)\n        # MainWindow.define_actions(actions)\n        if main_menu is not None:\n            main_menu.addMenu(menu_file)\n            view_menu = QMenu('View', main_menu)\n            view_menu.addAction(actions['window.focusMode'])\n            if 'window.pinWindow' in actions:\n                view_menu.addAction(actions['window.pinWindow'])\n            view_menu.addAction(actions['window.showBacklogs'])\n            view_menu.addAction(actions['application.toolbar'])\n            view_menu.addSeparator()\n            view_menu.addAction(actions['workitems_table.hideCompleted'])\n            view_menu.addAction(actions['window.showSearch'])\n            main_menu.addMenu(view_menu)\n            backlogs_menu = QMenu('Backlogs', main_menu)\n            backlogs_menu.addAction(actions['backlogs_table.newBacklog'])\n            backlogs_menu.addAction(actions['backlogs_table.newBacklogFromIncomplete'])\n            backlogs_menu.addAction(actions['backlogs_table.renameBacklog'])\n            backlogs_menu.addAction(actions['backlogs_table.deleteBacklog'])\n            main_menu.addMenu(backlogs_menu)\n            workitems_menu = QMenu('Work items', main_menu)\n            workitems_menu.addAction(actions['workitems_table.newItem'])\n            workitems_menu.addAction(actions['workitems_table.renameItem'])\n            workitems_menu.addAction(actions['workitems_table.deleteItem'])\n            workitems_menu.addAction(actions['workitems_table.startItem'])\n            workitems_menu.addAction(actions['workitems_table.completeItem'])\n            workitems_menu.addSeparator()\n            workitems_menu.addAction(actions['workitems_table.addPomodoro'])\n            workitems_menu.addAction(actions['workitems_table.removePomodoro'])\n            workitems_menu.addAction(actions['focus.voidPomodoro'])\n            workitems_menu.addAction(actions['focus.finishTracking'])\n            main_menu.addMenu(workitems_menu)\n            show_main_menu = (settings.get('Application.show_main_menu') == 'True')\n            main_menu.setVisible(show_main_menu)\n\n        # Status bar\n        # noinspection PyTypeChecker\n        status: QtWidgets.QStatusBar = window.findChild(QtWidgets.QStatusBar, \"statusBar\")\n        if status is not None:\n            show_status_bar = (settings.get('Application.show_status_bar') == 'True')\n            status.showMessage('Ready')\n            status.setVisible(show_status_bar)\n\n        # Tray icon\n        tray: TrayIcon | None = None\n        recreate_tray_icon(settings.get('Application.tray_icon_flavor'), settings.get('Application.show_tray_icon'))\n\n        # Some global variables to support \"Next pomodoro\" mode\n        # TODO Empty it if it gets deleted or completed\n        continue_workitem: Workitem | None = None\n\n        # Left toolbar\n        # noinspection PyTypeChecker\n        left_toolbar: QtWidgets.QWidget = window.findChild(QtWidgets.QWidget, \"left_toolbar\")\n        show_left_toolbar = (settings.get('Application.show_left_toolbar') == 'True')\n        left_toolbar.setVisible(show_left_toolbar)\n\n        # noinspection PyTypeChecker\n        tool_backlogs: QtWidgets.QToolButton = window.findChild(QtWidgets.QToolButton, \"toolBacklogs\")\n        tool_backlogs.setDefaultAction(action_backlogs)\n\n        # noinspection PyTypeChecker\n        tool_teams: QtWidgets.QToolButton = window.findChild(QtWidgets.QToolButton, \"toolTeams\")\n        tool_teams.setDefaultAction(action_teams)\n        action_teams.setEnabled(settings.is_team_supported())\n        tool_teams.setVisible(settings.is_team_supported())\n\n        # noinspection PyTypeChecker\n        tool_settings: QtWidgets.QToolButton = window.findChild(QtWidgets.QToolButton, \"toolSettings\")\n        tool_settings.setIcon(QIcon.fromTheme('tool-settings'))\n        tool_settings.clicked.connect(lambda: menu_file.exec(\n            tool_settings.parentWidget().mapToGlobal(tool_settings.geometry().center())\n        ))\n\n        # Restore window config from settings\n        update_tables_visibility()\n\n        resize_event_filter = ResizeEventFilter(window, main_layout, settings)\n        window.installEventFilter(resize_event_filter)\n        window.move(app.primaryScreen().geometry().center() - window.frameGeometry().center())\n\n        theme_change_event_filter = ThemeChangeEventFilter(window, settings)\n        window.installEventFilter(theme_change_event_filter)\n\n        main_window = MainWindow()\n        app.upgraded.connect(main_window.on_upgrade)\n\n        # Bind action domains to widget instances\n        actions.bind('application', app)\n        actions.bind('backlogs_table', backlogs_widget.get_table())\n        actions.bind('users_table', users_table)\n        actions.bind('workitems_table', workitems_widget.get_table())\n        actions.bind('focus', focus_widget)\n        actions.bind('window', main_window)\n\n        pin_if_needed(settings.get('Application.always_on_top'))\n\n        tutorial: Tutorial = None\n\n        if not app.is_hide_on_start():\n            window.show()\n\n        # With Qt 6.7.1 on Windows this needs to happen AFTER the Window is shown.\n        # Otherwise, the font size for the focus' header is picked correctly, but\n        # default font family is used.\n        focus_widget.update_fonts()\n\n        try:\n            app.initialize_source()\n        except Exception as ex:\n            app.on_exception(type(ex), ex, ex.__traceback__)\n\n        if app.is_e2e_mode():\n            # Our end-to-end tests use asyncio.sleep() extensively, so we need Qt event loop to support coroutines.\n            # This is an experimental feature in Qt 6.6.2+.\n            from PySide6 import QtAsyncio\n            QtAsyncio.run()\n        else:\n            if '--reset' in app.arguments():\n                settings.reset_to_defaults()\n            # This would work on any Qt 6.6.x\n            code = app.exec()\n            if tray is not None and tray.isVisible():\n                # To avoid tray icon getting stuck on Windows\n                tray.hide()\n            sys.exit(code)\n\n    except Exception as exc:\n        logger.error(\"FATAL: Exception on startup\", exc_info=exc)\n        if '--version' in sys.argv:\n            # We don't want to display anything blocking here\n            exit(2)\n        res = QMessageBox().critical(None,\n                                     \"Startup error\",\n                                     f\"Something unexpected has happened during Flowkeeper startup. It is most likely \"\n                                     f\"due to some wrong setting, which crashes Flowkeeper. \\n\\nYou can try fixing it \"\n                                     f\"yourself by checking \"\n                                     f\"{settings.location() if settings is not None else 'settings file'}.\\n\\n\"\n                                     f\"Alternatively, if you click 'Restore Defaults' to restore Flowkeeper settings to their \"\n                                     f\"default values. This includes data source and connection settings like your \"\n                                     f\"saved authentication credentials. You will NOT lose your data if you click Reset.\",\n                                     QMessageBox.StandardButton.RestoreDefaults,\n                                     QMessageBox.StandardButton.Close)\n        if res == QMessageBox.StandardButton.RestoreDefaults:\n            QtSettings().reset_to_defaults()\n            QMessageBox().information(None,\n                                      \"Startup error\",\n                                      f\"To finish resetting its configuration, Flowkeeper will now close.\",\n                                      QMessageBox.StandardButton.Ok)\n\n        logger.debug('Exiting')\n        sys.exit(2)\n"
  },
  {
    "path": "src/fk/desktop/desktop_strategies.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nimport re\nfrom typing import Callable\n\nfrom PySide6.QtWidgets import QMessageBox\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.strategy_factory import strategy\nfrom fk.core.tenant import Tenant\n\nlogger = logging.getLogger(__name__)\nEMAIL_REGEX = re.compile(r'[\\w\\-.]+@(?:[\\w-]+\\.)+[\\w-]{2,4}')\n\n\n# Authenticate(\"alice@example.com\", \"google|token123\", \"false\")\n@strategy\nclass AuthenticateStrategy(AbstractStrategy[Tenant]):\n    _username: str\n    _token: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._username = params[0]\n        self._token = params[1]\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        # Send only\n        pass\n\n\n# Replay(\"105\")\n@strategy\nclass ReplayStrategy(AbstractStrategy):\n    _since_seq: int\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._since_seq = int(params[0])\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        # Send only\n        pass\n\n\n# ReplayCompleted()\n@strategy\nclass ReplayCompletedStrategy(AbstractStrategy):\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n\n    def encryptable(self) -> bool:\n        return False\n\n    def requires_sealing(self) -> bool:\n        return True\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        # Send only\n        pass\n\n\n# Error(\"401\", \"User not found\")\n@strategy\nclass ErrorStrategy(AbstractStrategy):\n    _error_code: int\n    _error_message: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._error_code = int(params[0])\n        self._error_message = params[1]\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if self._error_message == 'User consent is required':\n            # TODO: Transfer this message from the server\n            if QMessageBox().warning(\n                None,\n                \"Do you want to create an account?\",\n                \"PLEASE READ IT: Support for Flowkeeper Server is experimental. We host Flowkeeper.org on \"\n                \"best-effort basis and deploy updates frequently, all of which means that in general we cannot \"\n                \"provide reliable 24/7 service. Unplanned downtime should be expected and WILL happen. \\n\\n\"\n                \"RELIABILITY: Although we take regular backups and handle your data as carefully as we can, we \"\n                \"cannot guarantee that your data will be stored forever. We may accidentally lose it or simply \"\n                \"terminate our service without warning. We recommend you to export your data to a local backup \"\n                \"file from time to time. \\n\\n\"\n                \"SECURITY: Your data is encrypted and decrypted on your computer using Fernet algorithm, \"\n                \"which is based on AES cypher. The server deals with encrypted content only, and we don't \"\n                \"have any means of decrypting it, so as long as you keep your encryption key private, your \"\n                \"personal data should be safe.\\n\\n\"\n                \"If you click Yes, we will automatically create an account for the email you provided.\",\n                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No\n            ) == QMessageBox.StandardButton.Yes:\n                logger.debug('Obtained consent for Flowkeeper Server, will re-authenticate')\n                # TODO: Recreate the source. This will trigger re-authentication, this time with\n                #  \"true\" parameter, meaning that the user has already given their consent.\n                self._settings.set({\n                    'WebsocketEventSource.consent': 'True',\n                })\n        elif self._error_message == 'Deleted':\n            self._settings.set({\n                'WebsocketEventSource.auth_type': 'google',\n                'WebsocketEventSource.username': 'user@local.host',\n                'WebsocketEventSource.consent': 'False',\n                'WebsocketEventSource.refresh_token!': '',\n            })\n            QMessageBox().warning(None,\n                                  'Deleted',\n                                  'Your account was deleted and Flowkeeper went offline. '\n                                  'Please select another data source.',\n                                  QMessageBox.StandardButton.Ok)\n        elif (self._error_message.startswith('Unknown user') or\n              self._error_message.startswith('Wrong password for user') or\n              self._error_message.startswith('Invalid Google auth token for user')):\n            QMessageBox().critical(None,\n                                   'Server error',\n                                   self._error_message,\n                                   QMessageBox.StandardButton.Ok)\n        else:\n            raise Exception(self._error_message)\n\n\n# Pong(\"123-456-789-012\", \"\")\n@strategy\nclass PongStrategy(AbstractStrategy):\n    _uid: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._uid = params[0]\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f'Received Pong - {self._uid}')\n        emit(events.PongReceived, {\n            'uid': self._uid\n        }, self._carry)\n\n\n# Ping(\"123-456-789-012\", \"\")\n@strategy\nclass PingStrategy(AbstractStrategy):\n    _uid: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._uid = params[0]\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        # Send only\n        pass\n\n\n# DeleteAccount(\"reason\")\n@strategy\nclass DeleteAccountStrategy(AbstractStrategy):\n    _reason: str\n\n    def __init__(self,\n                 seq: int,\n                 when: datetime.datetime,\n                 user_identity: str,\n                 params: list[str],\n                 settings: AbstractSettings,\n                 carry: any = None):\n        super().__init__(seq, when, user_identity, params, settings, carry)\n        self._reason = params[0]\n\n    def encryptable(self) -> bool:\n        return False\n\n    def execute(self,\n                emit: Callable[[str, dict[str, any], any], None],\n                data: Tenant) -> None:\n        # Send only\n        pass\n"
  },
  {
    "path": "src/fk/desktop/export_wizard.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport os\nimport pathlib\nimport sys\nfrom os import path\n\nfrom PySide6.QtWidgets import QWizardPage, QLabel, QVBoxLayout, QApplication, QWizard, QCheckBox, QLineEdit, \\\n    QHBoxLayout, QPushButton, QProgressBar, QWidget\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.import_export import export\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.tenant import Tenant\nfrom fk.desktop.settings import SettingsDialog\nfrom fk.qt.oauth import open_url\nfrom fk.qt.qt_settings import QtSettings\nfrom fk.core.sandbox import get_sandbox_type\n\n\nclass PageExportIntro(QWizardPage):\n    label: QLabel\n    layout_v: QVBoxLayout\n\n    def __init__(self):\n        super().__init__()\n        #self.setTitle(\"Data export\")\n        self.layout_v = QVBoxLayout()\n        self.label = QLabel(\"This wizard will help you export Flowkeeper data to file.\")\n        self.label.setWordWrap(True)\n        self.layout_v.addWidget(self.label)\n        self.setLayout(self.layout_v)\n\n\nclass PageExportSettings(QWizardPage):\n    label: QLabel\n    label2: QLabel\n    layout_v: QVBoxLayout\n    layout_h: QHBoxLayout\n    export_location: QLineEdit\n    export_location_browse: QPushButton\n    export_compress: QCheckBox\n    export_encrypt: QCheckBox\n\n    def isComplete(self):\n        return len(self.export_location.text().strip()) > 0\n\n    def __init__(self, settings: AbstractSettings):\n        super().__init__()\n        #self.setTitle(\"Export settings\")\n        self.layout_v = QVBoxLayout()\n        self.label = QLabel(\"Select destination file\")\n        self.label.setWordWrap(True)\n        self.layout_v.addWidget(self.label)\n        self.layout_h = QHBoxLayout()\n\n        self.export_location = QLineEdit()\n        self.export_location.textChanged.connect(lambda s: self.completeChanged.emit())\n        # noinspection PyUnresolvedReferences\n        self.export_location.textChanged.connect(lambda s: self.wizard().set_filename(s))\n        self.export_location.setPlaceholderText('Export filename')\n        if get_sandbox_type() is not None:\n            # Force the user to use the XDG portal-aware file chooser\n            self.export_location.setDisabled(True)\n\n        self.layout_h.addWidget(self.export_location)\n        self.export_location_browse = QPushButton(\"Browse...\")\n        self.export_location_browse.clicked.connect(lambda: SettingsDialog.do_browse(self.export_location))\n        self.layout_h.addWidget(self.export_location_browse)\n        self.layout_v.addLayout(self.layout_h)\n        self.export_compress = QCheckBox('Compress data (delete detailed history)')\n        self.export_compress.stateChanged.connect(lambda v: self.wizard().set_compressed(v == 2))\n        self.layout_v.addWidget(self.export_compress)\n        self.export_encrypt = QCheckBox('Export in plain text (decrypted)')\n        if settings.is_e2e_encryption_enabled():\n            self.export_encrypt.setEnabled(True)\n        else:\n            self.export_encrypt.setChecked(True)\n            self.export_encrypt.setDisabled(True)\n        # noinspection PyUnresolvedReferences\n        self.export_encrypt.stateChanged.connect(lambda v: self.wizard().set_encrypted(v != 2))\n        self.layout_v.addWidget(self.export_encrypt)\n        self.setLayout(self.layout_v)\n        self.setCommitPage(True)\n        self.setButtonText(QWizard.WizardButton.CommitButton, 'Start')\n\n\nclass PageExportProgress(QWizardPage):\n    label: QLabel\n    layout_v: QVBoxLayout\n    progress: QProgressBar\n    _source: AbstractEventSource\n    _export_complete: bool\n    _filename: str | None\n\n    def isComplete(self):\n        return self._export_complete\n\n    def __init__(self, source: AbstractEventSource):\n        super().__init__()\n        self._export_complete = False\n        self._source = source\n        self._filename = None\n        #self.setTitle(\"Exporting...\")\n        self.layout_v = QVBoxLayout()\n        self.label = QLabel(\"Data export is in progress. Please do not close this window until it completes.\")\n        self.label.setWordWrap(True)\n        self.layout_v.addWidget(self.label)\n        self.progress = QProgressBar()\n        self.progress.setValue(0)\n        self.layout_v.addWidget(self.progress)\n        self.setLayout(self.layout_v)\n        self.setFinalPage(True)\n\n    def initializePage(self):\n        self.start()\n\n    def finish(self):\n        if self.progress.maximum() == 0:\n            # This is a subtle workaround to avoid \"forever animated\" progress bars on Windows\n            self.progress.setMaximum(1)\n        self.progress.setValue(self.progress.maximum())\n        self._export_complete = True\n        self.label.setText('Done. You can now close this window.')\n        layout_h = QHBoxLayout()\n        open_file = QPushButton(\"Open exported file\")\n        open_file.clicked.connect(lambda: open_url(\n            pathlib.Path(path.abspath(self._filename)).as_uri()))\n        layout_h.addWidget(open_file)\n        layout_h.addStretch()\n        self.layout_v.addLayout(layout_h)\n        self.completeChanged.emit()\n\n    def start(self):\n        # noinspection PyUnresolvedReferences\n        self._filename = self.wizard().option_filename\n        export(self._source,\n               self._filename,\n               Tenant(self._source.get_settings()),\n               self.wizard().option_encrypted,\n               self.wizard().option_compressed,\n               lambda total: self.progress.setMaximum(total),\n               lambda value, total: self.progress.setValue(value),\n               lambda total: self.finish())\n\n\nclass ExportWizard(QWizard):\n    page_intro: PageExportIntro\n    page_settings: PageExportSettings\n    page_progress: PageExportProgress\n    option_filename: str | None\n    option_compressed: bool\n    option_encrypted: bool\n    _source: AbstractEventSource\n\n    def __init__(self, source: AbstractEventSource, parent: QWidget | None):\n        super().__init__(parent)\n        self._source = source\n        self.setWindowTitle(\"Export\")\n        self.page_intro = PageExportIntro()\n        self.page_settings = PageExportSettings(source.get_settings())\n        self.page_progress = PageExportProgress(source)\n        self.addPage(self.page_intro)\n        self.addPage(self.page_settings)\n        self.addPage(self.page_progress)\n        self.option_filename = None\n        self.option_compressed = False\n        self.option_encrypted = source.get_settings().is_e2e_encryption_enabled()\n        # Account for a Qt bug which shrinks dialogs opened on non-primary displays\n        self.setMinimumSize(500, 350)\n        if os.name == 'nt':\n            # AeroStyle is default on Windows 11, but it looks all white (another Qt bug?) The Classic style looks fine.\n            self.setWizardStyle(QWizard.WizardStyle.ClassicStyle)\n\n    def set_filename(self, filename):\n        self.option_filename = filename\n\n    def set_encrypted(self, encrypted):\n        self.option_encrypted = encrypted\n\n    def set_compressed(self, compressed):\n        self.option_compressed = compressed\n\n\nif __name__ == '__main__':\n    app = QApplication([])\n    settings = QtSettings()\n    src = EphemeralEventSource[Tenant](settings, NoCryptograph(settings), Tenant(settings))\n    src.start()\n    wizard = ExportWizard(src, None)\n    wizard.show()\n    sys.exit(app.exec())\n"
  },
  {
    "path": "src/fk/desktop/import_wizard.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport csv\nimport json\nimport logging\nimport os\nimport re\nfrom collections.abc import Callable\n\nfrom PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply\nfrom PySide6.QtWidgets import QWizardPage, QLabel, QVBoxLayout, QWizard, QCheckBox, QLineEdit, \\\n    QHBoxLayout, QPushButton, QProgressBar, QWidget, QRadioButton, QTextEdit, QComboBox\n\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.import_export import import_, import_github_issues, import_simple\nfrom fk.desktop.settings import SettingsDialog\nfrom fk.core.sandbox import get_sandbox_type\n\nlogger = logging.getLogger(__name__)\n\n\nclass PageImportIntro(QWizardPage):\n    from_file: QRadioButton\n    from_csv: QRadioButton\n    from_github: QRadioButton\n    from_gitlab: QRadioButton\n    from_jira: QRadioButton\n    from_trello: QRadioButton\n    from_ms_todo: QRadioButton\n    from_google_tasks: QRadioButton\n    from_todoist: QRadioButton\n    from_ticktick: QRadioButton\n\n    def __init__(self):\n        super().__init__()\n        layout = QVBoxLayout(self)\n\n        label = QLabel(\"This wizard will help you import Flowkeeper data.\", self)\n        label.setWordWrap(True)\n        layout.addWidget(label)\n\n        self.from_file = QRadioButton('Import from Flowkeeper data file or backup', self)\n        layout.addWidget(self.from_file)\n\n        self.from_csv = QRadioButton('Import from CSV', self)\n        layout.addWidget(self.from_csv)\n\n        self.from_github = QRadioButton('Import from GitHub', self)\n        layout.addWidget(self.from_github)\n\n        self.from_gitlab = QRadioButton('Import from GitLab', self)\n        self.from_gitlab.setDisabled(True)\n        layout.addWidget(self.from_gitlab)\n\n        self.from_jira = QRadioButton('Import from JIRA', self)\n        self.from_jira.setDisabled(True)\n        layout.addWidget(self.from_jira)\n\n        self.from_trello = QRadioButton('Import from Trello', self)\n        self.from_trello.setDisabled(True)\n        layout.addWidget(self.from_trello)\n\n        self.from_ms_todo = QRadioButton('Import from Microsoft To Do', self)\n        self.from_ms_todo.setDisabled(True)\n        layout.addWidget(self.from_ms_todo)\n\n        self.from_google_tasks = QRadioButton('Import from Google Tasks', self)\n        self.from_google_tasks.setDisabled(True)\n        layout.addWidget(self.from_google_tasks)\n\n        self.from_todoist = QRadioButton('Import from Todoist', self)\n        self.from_todoist.setDisabled(True)\n        layout.addWidget(self.from_todoist)\n\n        self.from_ticktick = QRadioButton('Import from TickTick', self)\n        self.from_ticktick.setDisabled(True)\n        layout.addWidget(self.from_ticktick)\n\n        self.from_file.setChecked(True)\n        self.setLayout(layout)\n\n    def get_selected_import_type(self) -> str:\n        if self.from_file.isChecked():\n            return 'file'\n        elif self.from_github.isChecked():\n            return 'github'\n        elif self.from_csv.isChecked():\n            return 'csv'\n        else:\n            return 'other'\n\n\nclass PageImportSettings(QWizardPage):\n    get_type: Callable[[], str]\n\n    import_location: QLineEdit | None\n    import_repo: QLineEdit | None\n    import_token: QLineEdit | None\n    import_delimiter: QComboBox | None\n    import_skip_header: QCheckBox | None\n    import_ignore_errors: QCheckBox | None\n    import_type_smart: QRadioButton | None\n    import_type_replay: QRadioButton | None\n\n    tag_creator: QCheckBox | None\n    tag_assignee: QCheckBox | None\n    tag_labels: QCheckBox | None\n    tag_milestone: QCheckBox | None\n    tag_state: QCheckBox | None\n\n    def isComplete(self):\n        import_type = self.get_type()\n        if import_type in ['file', 'csv']:\n            return len(self.import_location.text().strip()) > 0\n        elif import_type == 'github':\n            return len(self.import_repo.text().strip()) > 0\n        else:\n            return False\n\n    def __init__(self, get_type: Callable[[], str]):\n        super().__init__()\n        self.get_type = get_type\n        self._reset()\n\n    def _reset(self):\n        self.import_location = None\n        self.import_repo = None\n        self.import_token = None\n        self.import_delimiter = None\n        self.import_skip_header = None\n        self.import_ignore_errors = None\n        self.import_type_smart = None\n        self.import_type_replay = None\n        self.tag_creator = None\n        self.tag_assignee = None\n        self.tag_labels = None\n        self.tag_milestone = None\n        self.tag_state = None\n        for child in self.children():\n            child.deleteLater()\n\n    def cleanupPage(self):\n        self._reset()\n\n    def initializePage(self):\n        layout_v = QVBoxLayout(self)\n        self.setLayout(layout_v)\n\n        import_type = self.get_type()\n        if import_type == 'file':\n            self._init_for_file(layout_v)\n        elif import_type == 'csv':\n            self._init_for_csv(layout_v)\n        elif import_type == 'github':\n            self._init_for_github(layout_v)\n        else:\n            self._init_for_other(layout_v)\n\n        self.setCommitPage(True)\n        self.setButtonText(QWizard.WizardButton.CommitButton, 'Start')\n\n    def _init_for_file(self, layout_v):\n        label = QLabel(\"Select source file\", self)\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        layout_h = QHBoxLayout()\n        layout_v.addLayout(layout_h)\n\n        self.import_location = QLineEdit(self)\n        self.import_location.setPlaceholderText('Import filename')\n        self.import_location.textChanged.connect(lambda s: self.completeChanged.emit())\n        layout_h.addWidget(self.import_location)\n\n        if get_sandbox_type() is not None:\n            # Force the user to use the XDG portal-aware file chooser\n            self.import_location.setDisabled(True)\n\n        import_location_browse = QPushButton(\"Browse...\", self)\n        import_location_browse.clicked.connect(lambda: SettingsDialog.do_browse(self.import_location))\n        layout_h.addWidget(import_location_browse)\n\n        self.import_ignore_errors = QCheckBox('Ignore errors and continue', self)\n        self.import_ignore_errors.setDisabled(False)\n        layout_v.addWidget(self.import_ignore_errors)\n\n        self.import_type_smart = QRadioButton(\"Smart import - safe option, data is appended or renamed\", self)\n        self.import_type_smart.setChecked(True)\n        layout_v.addWidget(self.import_type_smart)\n\n        self.import_type_replay = QRadioButton(\"Replay imported history - can result in duplicates or deletions\", self)\n        layout_v.addWidget(self.import_type_replay)\n\n    def _init_for_csv(self, layout_v):\n        label = QLabel('Select CSV file. Note that the data should have exactly three columns -- '\n                       'backlog name, task name, and state (\"new\" or \"completed\").', self)\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        layout_h = QHBoxLayout()\n        layout_v.addLayout(layout_h)\n\n        self.import_location = QLineEdit(self)\n        self.import_location.setPlaceholderText('Import filename')\n        self.import_location.textChanged.connect(self.completeChanged)\n        layout_h.addWidget(self.import_location)\n\n        if get_sandbox_type() is not None:\n            # Force the user to use the XDG portal-aware file chooser\n            self.import_location.setDisabled(True)\n\n        import_location_browse = QPushButton(\"Browse...\", self)\n        import_location_browse.clicked.connect(lambda: SettingsDialog.do_browse(self.import_location))\n        layout_h.addWidget(import_location_browse)\n\n        self.import_skip_header = QCheckBox('Skip header (first row)', self)\n        self.import_skip_header.setChecked(False)\n        layout_v.addWidget(self.import_skip_header)\n\n        self.import_delimiter = QComboBox(self)\n        self.import_delimiter.addItems(['Columns are separated by comma (,)',\n                                        'Columns are separated by semicolon (;)',\n                                        'Columns are separated by tab (\\\\t)'])\n        layout_v.addWidget(self.import_delimiter)\n\n    def _init_for_github(self, layout_v):\n        label = QLabel(\"Enter a GitHub owner/repository pair\", self)\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        self.import_repo = QLineEdit(self)\n        self.import_repo.setPlaceholderText('Example: flowkeeper-org/fk-desktop')\n        self.import_repo.textChanged.connect(lambda s: self.completeChanged.emit())\n        layout_v.addWidget(self.import_repo)\n\n        label = QLabel(\"GitHub API token (for private repos only)\", self)\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n        self.import_token = QLineEdit(self)\n        layout_v.addWidget(self.import_token)\n\n        self.tag_state = QCheckBox('Create tags for issue state', self)\n        self.tag_state.setChecked(False)\n        layout_v.addWidget(self.tag_state)\n\n        self.tag_assignee = QCheckBox('Create tags for issue assignee', self)\n        self.tag_assignee.setChecked(False)\n        layout_v.addWidget(self.tag_assignee)\n\n        self.tag_creator = QCheckBox('Create tags for issue creator', self)\n        self.tag_creator.setChecked(False)\n        layout_v.addWidget(self.tag_creator)\n\n        self.tag_labels = QCheckBox('Create tags for issue labels', self)\n        self.tag_labels.setChecked(True)\n        layout_v.addWidget(self.tag_labels)\n\n        self.tag_milestone = QCheckBox('Create tags for issue milestone', self)\n        self.tag_milestone.setChecked(True)\n        layout_v.addWidget(self.tag_milestone)\n\n    def _init_for_other(self, layout_v):\n        label = QLabel(\"Not implemented, sorry\", self)\n        label.setWordWrap(True)\n        layout_v.addWidget(label)\n\n    def get_settings(self) -> dict[str, any]:\n        res = {\n            'import_type': self.get_type(),\n        }\n        if self.import_location is not None:\n            res['location'] = self.import_location.text()\n        if self.import_repo is not None:\n            res['repo'] = self.import_repo.text()\n        if self.import_token is not None:\n            res['token'] = self.import_token.text()\n        if self.import_delimiter is not None:\n            res['delimiter'] = self.import_delimiter.currentText()\n        if self.import_skip_header is not None:\n            res['skip_header'] = self.import_skip_header.isChecked()\n        if self.import_ignore_errors is not None:\n            res['ignore_errors'] = self.import_ignore_errors.isChecked()\n        if self.import_type_smart is not None:\n            res['type_smart'] = self.import_type_smart.isChecked()\n        if self.import_type_replay is not None:\n            res['type_replay'] = self.import_type_replay.isChecked()\n        if self.tag_assignee is not None:\n            res['tag_assignee'] = self.tag_assignee.isChecked()\n        if self.tag_creator is not None:\n            res['tag_creator'] = self.tag_creator.isChecked()\n        if self.tag_labels is not None:\n            res['tag_labels'] = self.tag_labels.isChecked()\n        if self.tag_state is not None:\n            res['tag_state'] = self.tag_state.isChecked()\n        if self.tag_milestone is not None:\n            res['tag_milestone'] = self.tag_milestone.isChecked()\n        return res\n\n\nclass PageImportProgress(QWizardPage):\n    label: QLabel\n    log: QTextEdit\n    progress: QProgressBar\n\n    _import_complete: bool\n    _source_holder: EventSourceHolder\n    _get_settings: Callable[[], dict[str, any]]\n\n    def isComplete(self):\n        return self._import_complete\n\n    def __init__(self,\n                 source_holder: EventSourceHolder,\n                 get_settings: Callable[[], dict[str, any]]):\n        super().__init__()\n        self._import_complete = False\n        self._source_holder = source_holder\n        self._get_settings = get_settings\n\n    def initializePage(self):\n        layout = QVBoxLayout(self)\n\n        self.label = QLabel(\"Data import is in progress. Please do not close this window until it completes.\", self)\n        self.label.setWordWrap(True)\n        layout.addWidget(self.label)\n\n        self.progress = QProgressBar(self)\n        self.progress.setValue(0)\n        layout.addWidget(self.progress)\n\n        self.log = QTextEdit(self)\n        layout.addWidget(self.log)\n\n        self.setLayout(layout)\n        self.setFinalPage(True)\n\n        self.start()\n\n    def finish_for_file(self):\n        # Repair it, if file source\n        repair_result, _ = self._source_holder.get_source().repair()\n        if repair_result is not None:\n            log = \"\\n\".join(repair_result)\n            self.log.setText(f'The result was cleaned up:\\n{log}')\n\n        self._source_holder.close_current_source()\n        self._source_holder.request_new_source()\n\n    def finish(self, callback: Callable[[], None] | None = None):\n        if self.progress.maximum() == 0:\n            # This is a subtle workaround to avoid \"forever animated\" progress bars on Windows\n            self.progress.setMaximum(1)\n        self.progress.setValue(self.progress.maximum())\n        self._import_complete = True\n        self.label.setText('Done. You can now close this window.')\n        if callback:\n            callback()\n        self.completeChanged.emit()\n\n    def _send_request(self,\n                      url: str,\n                      headers: dict[str, str],\n                      callback: Callable[[object], None],\n                      send_another: Callable[[QNetworkReply], tuple[str, dict] | None]):\n        mgr = QNetworkAccessManager(self)\n        req = QNetworkRequest(url)\n        for k in headers.keys():\n            req.setRawHeader(bytes(k, 'iso8859-1'), bytes(headers[k], 'iso8859-1'))\n\n        def _success() -> None:\n            if reply.error() == QNetworkReply.NetworkError.NoError:\n                s = reply.readAll().toStdString()\n                try:\n                    data = json.loads(s)\n                    callback(data)\n                    another = send_another(reply)\n                    if another is not None:\n                        self._send_request(another[0], another[1], callback, send_another)\n                except Exception as err:\n                    msg = f'Cannot import REST API response: {err}'\n                    logger.warning(msg, exc_info=err)\n                    self.log.append(msg)\n                    callback(None)\n                    return\n            else:\n                msg = f'REST API request failed: {reply.errorString()}'\n                logger.warning(msg)\n                self.log.append(msg)\n                callback(None)\n\n        reply: QNetworkReply = mgr.get(req)\n        reply.finished.connect(_success)\n\n    def _import_from_file(self):\n        settings = self._get_settings()\n        import_(self._source_holder.get_source(),\n                settings['location'],\n                settings['ignore_errors'],\n                settings['type_smart'],\n                lambda total: self.progress.setMaximum(total),\n                lambda value, total: self.progress.setValue(value),\n                lambda total: self.finish(self.finish_for_file))\n\n    def _import_from_csv(self):\n        settings = self._get_settings()\n        skip_header = settings['skip_header']\n        delimiter = settings['delimiter']\n        if '(,)' in delimiter:\n            delimiter = ','\n        elif '(;)' in delimiter:\n            delimiter = ';'\n        elif '(\\\\t)' in delimiter:\n            delimiter = '\\t'\n        filename = settings['location']\n        self.log.append(f'Importing from {filename} using \"{delimiter}\" as delimiter.')\n\n        data: dict[str, list[str]] = dict()\n        with open(filename, newline='') as csvfile:\n            reader = csv.reader(csvfile, delimiter=delimiter, quotechar='\"')\n            n = 0\n            for row in reader:\n                n += 1\n                if skip_header and n == 1:\n                    continue\n                if len(row) != 3:\n                    self.log.append(\n                        f'Fatal error: Unable to read row {n} -- it must have exactly three columns, but we parsed {len(row)} instead.')\n                    self.finish()\n                    return\n                if row[2] not in ['new', 'completed']:\n                    self.log.append(\n                        f'Fatal error: Unable to read row {n} -- task state must be either \"new\" or \"completed\", but was \"{row[2]}\".')\n                    self.finish()\n                    return\n                backlog = row[0]\n                if backlog not in data:\n                    data[backlog] = list()\n                data[backlog].append([row[1], row[2]])\n\n        log = import_simple(self._source_holder.get_source(),\n                            data)\n        self.log.append(log)\n        self.finish()\n\n    def _import_from_github(self):\n        settings = self._get_settings()\n        repo = settings['repo']\n        url = f'https://api.github.com/repos/{repo}/issues?per_page=100'\n        token = settings['token']\n        logger.debug(f'Will import from GitHub at {repo}')\n\n        def process_response(data: list[object] | None):\n            if data is None:\n                self.label.setText('ERROR: Cannot get the list of issues from GitHub')\n            else:\n                log = import_github_issues(self._source_holder.get_source(),\n                                           repo,\n                                           data,\n                                           settings['tag_creator'],\n                                           settings['tag_assignee'],\n                                           settings['tag_labels'],\n                                           settings['tag_milestone'],\n                                           settings['tag_state'])\n                self.log.append(log)\n                self.finish()\n\n        headers = {'Accept': 'application/vnd.github+json',\n                   'X-GitHub-Api-Version': '2022-11-28'}\n        if token != '':\n            headers['Authorization'] = f'Bearer {token}'\n\n        def send_another(reply: QNetworkReply) -> tuple[str, dict] | None:\n            links = reply.rawHeader('link').toStdString()\n            match = re.match(r'<(.+?)>; rel=\"next\"', links)\n            if match:\n                next_link = match.group(1)\n                return next_link, headers\n            return None\n\n        # noinspection PyTypeChecker\n        self._send_request(url, headers, process_response, send_another)\n\n    def start(self):\n        import_type = self._get_settings()['import_type']\n        if import_type == 'file':\n            self._import_from_file()\n        elif import_type == 'csv':\n            self._import_from_csv()\n        elif import_type == 'github':\n            self._import_from_github()\n\n\nclass ImportWizard(QWizard):\n    page_intro: PageImportIntro\n    page_settings: PageImportSettings\n    page_progress: PageImportProgress\n    _source_holder: EventSourceHolder\n\n    def __init__(self, source_holder: EventSourceHolder, parent: QWidget | None):\n        super().__init__(parent)\n        self._source_holder = source_holder\n        self.setWindowTitle(\"Import\")\n        self.page_intro = PageImportIntro()\n        self.page_settings = PageImportSettings(self.page_intro.get_selected_import_type)\n        self.page_progress = PageImportProgress(source_holder, self.page_settings.get_settings)\n        self.addPage(self.page_intro)\n        self.addPage(self.page_settings)\n        self.addPage(self.page_progress)\n\n        # Account for a Qt bug which shrinks dialogs opened on non-primary displays\n        self.setMinimumSize(500, 350)\n\n        if os.name == 'nt':\n            # AeroStyle is default on Windows 11, but it looks all white (another Qt bug?) The Classic style looks fine.\n            self.setWizardStyle(QWizard.WizardStyle.ClassicStyle)\n"
  },
  {
    "path": "src/fk/desktop/interruption_dialog.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtGui import QHideEvent\nfrom PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QDialog, QDialogButtonBox, QLineEdit\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.events import AfterPomodoroComplete\n\nlogger = logging.getLogger(__name__)\n\n\nclass InterruptionDialog(QDialog):\n    _source: AbstractEventSource\n    _warning: QLabel\n    _reason: QLineEdit\n    _buttons: QDialogButtonBox\n\n    def __init__(self,\n                 parent: QWidget,\n                 source: AbstractEventSource,\n                 window_title: str,\n                 label_text: str,\n                 placeholder_text: str):\n        super().__init__(parent)\n        self._source = source\n\n        self.setWindowTitle(window_title)\n\n        layout = QVBoxLayout(self)\n\n        label = QLabel(label_text, self)\n        layout.addWidget(label)\n\n        self._reason = QLineEdit(self)\n        self._reason.setPlaceholderText(placeholder_text)\n        layout.addWidget(self._reason)\n\n        self._warning = QLabel('Too late, the pomodoro has just finished.', self)\n        self._warning.setObjectName('warning')\n        self._warning.setVisible(False)\n        layout.addWidget(self._warning)\n\n        self._buttons = QDialogButtonBox(self)\n        self._buttons.setStandardButtons(\n            QDialogButtonBox.StandardButton.Ok |\n            QDialogButtonBox.StandardButton.Cancel\n        )\n        self._buttons.clicked.connect(lambda btn: self._on_action(self._buttons.buttonRole(btn)))\n        layout.addWidget(self._buttons)\n\n        self._source.on(AfterPomodoroComplete, self._on_pomodoro_complete)\n        logger.debug('Subscribed to AfterPomodoroComplete')\n\n    def hideEvent(self, event: QHideEvent) -> None:\n        self._source.unsubscribe(self._on_pomodoro_complete)\n        logger.debug('Unsubscribed from AfterPomodoroComplete')\n        super(InterruptionDialog, self).hideEvent(event)\n\n    def _on_pomodoro_complete(self, **_) -> None:\n        logger.debug('Received AfterPomodoroComplete')\n        self._warning.setVisible(True)\n        self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)\n\n    def _on_action(self, role: QDialogButtonBox.ButtonRole):\n        logger.debug(f'Closing Interruption dialog with role {role}')\n\n        if role == QDialogButtonBox.ButtonRole.AcceptRole:\n            self.accept()\n        else:\n            self.reject()\n\n    def get_reason(self) -> str:\n        return self._reason.text()\n"
  },
  {
    "path": "src/fk/desktop/settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport json\nimport logging\nimport platform\nimport sys\nfrom typing import Callable\n\nfrom PySide6.QtCore import QSize, QTime, Qt\nfrom PySide6.QtGui import QFont, QKeySequence, QIcon, QStandardItemModel, QStandardItem\nfrom PySide6.QtWidgets import QLabel, QApplication, QTabWidget, QWidget, QDialog, QFormLayout, QLineEdit, \\\n    QSpinBox, QCheckBox, QFrame, QHBoxLayout, QPushButton, QComboBox, QDialogButtonBox, QFileDialog, QFontComboBox, \\\n    QMessageBox, QVBoxLayout, QKeySequenceEdit, QTimeEdit, QTableWidget, QTableWidgetItem, QSizePolicy\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.sandbox import get_sandbox_type\nfrom fk.qt.actions import Actions\nfrom fk.qt.qt_settings import QtSettings\n\nlogger = logging.getLogger(__name__)\n\n\ndef _from_total_seconds(total_seconds: int) -> QTime:\n    hours = int(total_seconds / 60 / 60)\n    minutes = int(total_seconds / 60) - hours * 60\n    seconds = total_seconds - hours * 60 * 60 - minutes * 60\n    return QTime(hours, minutes, seconds, 0)\n\n\nclass SettingsDialog(QDialog):\n    _data: AbstractSettings\n    _widgets_get_value: dict[str, Callable[[], str]]\n    _widgets_set_value: dict[str, Callable[[str], None]]\n    _widgets_visibility: dict[QWidget, Callable[[dict[str, str]], bool]]\n    _buttons: QDialogButtonBox\n    _buttons_mapping: dict[str, Callable[[dict[str, str], Callable], bool]] | None\n\n    def __init__(self,\n                 parent: QWidget,\n                 data: AbstractSettings,\n                 buttons_mapping: dict[str, Callable[[dict[str, str], Callable], bool]] | None = None):\n        super().__init__(parent)\n        self._data = data\n        self._buttons_mapping = buttons_mapping\n        self._widgets_get_value = dict()\n        self._widgets_set_value = dict()\n        self._widgets_visibility = dict()\n        self.resize(QSize(500, 450))\n        self.setWindowTitle(\"Settings\")\n\n        self._init_sign_out_button()\n\n        buttons = QDialogButtonBox(self)\n        buttons.setStandardButtons(\n            QDialogButtonBox.StandardButton.Apply |\n            QDialogButtonBox.StandardButton.Close |\n            QDialogButtonBox.StandardButton.Reset |\n            QDialogButtonBox.StandardButton.Save\n        )\n        buttons.clicked.connect(lambda btn: self._on_action(buttons.buttonRole(btn)))\n        self._buttons = buttons\n\n        tabs = QTabWidget(self)\n        tabs.setObjectName('settings_tabs')\n        for tab_name in data.get_categories():\n            tab = self._create_tab(tabs, data.get_settings(tab_name))\n            tabs.addTab(tab, tab_name)\n\n        self._recompute_visibility('', '')\n\n        location = QLabel(self)\n        location.setWordWrap(True)\n        small_font = QFont()\n        small_font.setPointSize(8)\n        location.setFont(small_font)\n        location.setText(f'Settings saved in {data.location()}')\n\n        root_layout = QVBoxLayout(self)\n        root_layout.addWidget(tabs)\n        root_layout.addWidget(location)\n        root_layout.addWidget(buttons)\n        self.setLayout(root_layout)\n        self._set_buttons_state(False)\n\n        # Reinitialize audio outputs -- the sound devices might have changed while Flowkeeper was running\n        self._data.init_audio_outputs()\n\n    def _init_sign_out_button(self):\n        lst = self._data._definitions['Connection']\n        for i, d in enumerate(lst):\n            if d[0] == 'WebsocketEventSource.logout':\n                t = list(d)\n                t[2] = f'Sign out <{self._data.get_username()}>'\n                lst[i] = tuple(t)\n                return\n\n    def _on_action(self, role: QDialogButtonBox.ButtonRole):\n        if role == QDialogButtonBox.ButtonRole.ApplyRole:\n            self._save_settings()\n        elif role == QDialogButtonBox.ButtonRole.RejectRole:\n            self.close()\n        elif role == QDialogButtonBox.ButtonRole.ResetRole:\n            if QMessageBox().warning(self,\n                                     \"Confirmation\",\n                                     f\"Are you sure you want to reset settings to their default values, \"\n                                     f\"including data source connection?\",\n                                     QMessageBox.StandardButton.Ok,\n                                     QMessageBox.StandardButton.Cancel\n                                     ) == QMessageBox.StandardButton.Ok:\n                self._data.reset_to_defaults()\n                self.close()\n        elif role == QDialogButtonBox.ButtonRole.AcceptRole:\n            if self._save_settings():\n                self.close()\n\n    def _computed_values(self) -> dict[str, str]:\n        computed = dict[str, str]()\n        for name in self._widgets_get_value:\n            computed[name] = self._widgets_get_value[name]()\n        return computed\n\n    def _on_value_changed(self, option_id, new_value):\n        changed = False\n        logger.debug(f\"Changed {option_id} to {new_value}\")\n\n        # Enable / disable \"save\" buttons\n        for name in self._widgets_get_value:\n            old_value = self._data.get(name)\n            calculated_value = new_value if name == option_id else self._widgets_get_value[name]()\n            if old_value != calculated_value:\n                changed = True\n                break\n\n        self._set_buttons_state(changed)\n        self._recompute_visibility(option_id, new_value)\n\n    def _recompute_visibility(self, option_id, new_value):\n        # Name / value pair here is a hack to \"override\" settings value for visibility checks\n        # because Qt sends value change events BEFORE setValue() finished\n        computed = self._computed_values()\n        computed[option_id] = new_value\n        for widget in self._widgets_visibility:\n            is_visible = self._widgets_visibility[widget](computed)\n            widget.setVisible(is_visible)\n\n    def _set_buttons_state(self, is_enabled: bool):\n        self._buttons.button(QDialogButtonBox.StandardButton.Apply).setEnabled(is_enabled)\n        self._buttons.button(QDialogButtonBox.StandardButton.Save).setEnabled(is_enabled)\n\n    def _save_settings(self) -> bool:\n        # Returns True if the settings were changed\n        to_set = dict[str, str]()\n        for name in self._widgets_get_value:\n            value = self._widgets_get_value[name]()\n            if self._data.get(name) != value:\n                if self._data.get_type(name) == 'key':\n                    if not SettingsDialog.display_key_warning(self._data.get_display_name(name)):\n                        return False\n                to_set[name] = value\n        self._data.set(to_set)\n        self._set_buttons_state(False)\n        return True\n\n    @staticmethod\n    def do_browse(edit: QLineEdit) -> None:\n        SettingsDialog.do_browse_simple(edit.text(), edit.setText)\n\n    @staticmethod\n    def do_browse_simple(preselected: str, callback: Callable[[str], None]) -> None:\n        dlg = QFileDialog()\n        dlg.setFileMode(QFileDialog.FileMode.AnyFile)\n        dlg.selectFile(preselected)\n        if dlg.exec_():\n            selected: str = dlg.selectedFiles()[0]\n            callback(selected)\n\n    @staticmethod\n    def display_key_warning(name: str) -> bool:\n        warning_text = f\"WARNING: You are about to change the {name}! Read this carefully.\\n\\n\" \\\n                       \\\n                       \"It is only stored on your computer, so if you lose this key, you won't be able to \" \\\n                       \"restore it. Therefore, please SAVE A COPY IN A SAFE PLACE.\\n\\n\" \\\n                       \\\n                       \"DO NOT CHANGE THIS KEY IF YOU ALREADY CREATED SOME DATA! If you do it, \" \\\n                       \"Flowkeeper won't be able to decrypt your old data and so you will lose \" \\\n                       \"access to it. Instead, export your data, and then \" \\\n                       \"import it into a clean data store with the new key.\\n\\n\" \\\n                       \\\n                       \"Finally, you will have to provide the same key in all your Flowkeeper apps, which \" \\\n                       \"connect to this account.\"\n        return QMessageBox().warning(None,\n                                     f\"Change {name}?\",\n                                     warning_text,\n                                     QMessageBox.StandardButton.Yes,\n                                     QMessageBox.StandardButton.Cancel) == QMessageBox.StandardButton.Yes\n\n    def _display_option(self,\n                        parent: QWidget,\n                        option_id: str,\n                        option_type: str,\n                        option_value: str,\n                        option_options: list[any],\n                        option_display: str) -> list[QWidget]:\n        if option_type == 'email' or option_type == 'str':\n            ed1 = QLineEdit(parent)\n            ed1.setText(option_value)\n            ed1.textChanged.connect(lambda v: self._on_value_changed(option_id, v))\n            self._widgets_get_value[option_id] = ed1.text\n            self._widgets_set_value[option_id] = ed1.setText\n            return [ed1]\n        elif option_type == 'secret':\n            ed2 = QLineEdit(parent)\n            ed2.setEchoMode(QLineEdit.EchoMode.Password)\n            ed2.setText(option_value)\n            ed2.textChanged.connect(lambda v: self._on_value_changed(option_id, v))\n            self._widgets_get_value[option_id] = ed2.text\n            self._widgets_set_value[option_id] = ed2.setText\n            return [ed2]\n        elif option_type == 'file':\n            widget = QWidget(parent)\n            layout = QHBoxLayout(widget)\n            layout.setSizeConstraint(QHBoxLayout.SizeConstraint.SetMinimumSize)\n            layout.setContentsMargins(0, 0, 0, 0)\n            ed3 = QLineEdit(parent)\n            ed3.setMinimumWidth(150)\n            ed3.setObjectName(f'{option_id}-edit')\n            ed3.setText(option_value)\n            ed3.textChanged.connect(lambda v: self._on_value_changed(option_id, v))\n            self._widgets_get_value[option_id] = ed3.text\n            self._widgets_set_value[option_id] = ed3.setText\n            if get_sandbox_type() is not None:\n                # Force the user to use the XDG portal-aware file chooser\n                ed3.setDisabled(True)\n            layout.addWidget(ed3)\n            browse_btn = QPushButton(parent)\n            browse_btn.setObjectName(f'{option_id}-button')\n            browse_btn.setText('Browse...')\n            browse_btn.clicked.connect(lambda: SettingsDialog.do_browse(ed3))\n            layout.addWidget(browse_btn)\n            return [widget]\n        elif option_type == 'button':\n            button = QPushButton(parent)\n            button.setText(option_display)\n            if len(option_options) > 0:\n                button.setIcon(QIcon(f':/icons/{option_options[0]}.png'))\n            button.clicked.connect(lambda: self._handle_button_click(option_id))\n            self._widgets_get_value[option_id] = lambda: \"\"\n            self._widgets_set_value[option_id] = lambda txt: button.setText(txt)\n            return [button]\n        elif option_type == 'int':\n            ed4 = QSpinBox(parent)\n            ed4.setMinimum(option_options[0])\n            ed4.setMaximum(option_options[1])\n            ed4.setValue(int(option_value))\n            ed4.valueChanged.connect(lambda v: self._on_value_changed(option_id, str(v)))\n            self._widgets_get_value[option_id] = lambda: str(ed4.value())\n            self._widgets_set_value[option_id] = lambda txt: ed4.setValue(int(txt))\n            return [ed4]\n        elif option_type == 'bool':\n            ed5 = QCheckBox(parent)\n            ed5.setChecked(option_value == 'True')\n            ed5.stateChanged.connect(lambda v: self._on_value_changed(option_id, str(v == 2)))\n            self._widgets_get_value[option_id] = lambda: str(ed5.isChecked())\n            self._widgets_set_value[option_id] = lambda txt: ed5.setChecked(txt == 'True')\n            return [ed5]\n        elif option_type == 'choice':\n            ed6 = QComboBox(parent)\n            data_model6 = QStandardItemModel(ed6)\n            ed6.setModel(data_model6)\n\n            found = 0\n            i = 0\n            for o in option_options:\n                n, v = o.split(':')\n                n = n.replace('$$', ':')\n                v = v.replace('$$', ':')\n                if n == option_value:\n                    found = i\n                item = QStandardItem(v)\n                item.setData(n, 500)\n                data_model6.appendRow(item)\n                i += 1\n            ed6.setCurrentIndex(found)\n\n            def find_item(name: str) -> int:\n                for j in range(data_model6.rowCount()):\n                    if data_model6.index(j, 0).data(500) == name:\n                        return j\n\n            ed6.currentIndexChanged.connect(lambda v: self._on_value_changed(\n                option_id,\n                data_model6.item(v).data(500)\n            ))\n            self._widgets_get_value[option_id] = lambda: ed6.currentData(500)\n            self._widgets_set_value[option_id] = lambda txt: ed6.setCurrentIndex(find_item(txt))\n            return [ed6]\n        elif option_type == 'font' and platform.system() == 'Darwin':\n            system_font = '.AppleSystemUIFont'\n            widget = QWidget(parent)\n            layout = QVBoxLayout(widget)\n            layout.setContentsMargins(0, 6, 0, 0)\n\n            ed7_checkbox = QCheckBox(f'Use macOS system font', parent)\n            layout.addWidget(ed7_checkbox)\n            ed7_font = QFontComboBox(parent)\n            layout.addWidget(ed7_font)\n\n            ed7_checkbox.stateChanged.connect(ed7_font.setDisabled)\n\n            ed7_checkbox.stateChanged.connect(lambda checked: self._on_value_changed(\n                option_id,\n                system_font if checked else ed7_font.currentFont().family()\n            ))\n            ed7_font.currentFontChanged.connect(lambda v: self._on_value_changed(\n                option_id,\n                v.family()\n            ))\n\n            def set_value(txt):\n                is_system_font = txt == system_font\n                if is_system_font:\n                    ed7_font.setCurrentFont('Noto Sans')  # Anything existing\n                else:\n                    ed7_font.setCurrentFont(txt)\n                ed7_checkbox.setChecked(is_system_font)\n            set_value(option_value)\n\n            self._widgets_get_value[option_id] = lambda: \\\n                system_font if ed7_checkbox.isChecked() else ed7_font.currentFont().family()\n            self._widgets_set_value[option_id] = set_value\n\n            return [widget]\n        elif option_type == 'font':\n            ed7 = QFontComboBox(parent)\n            ed7.currentFontChanged.connect(lambda v: self._on_value_changed(\n                option_id,\n                v.family()\n            ))\n            ed7.setCurrentFont(option_value)\n            self._widgets_get_value[option_id] = lambda: ed7.currentFont().family()\n            self._widgets_set_value[option_id] = lambda txt: ed7.currentFont().setFamily(txt)\n            return [ed7]\n        elif option_type == 'shortcuts':\n            widget = QWidget(parent)\n            layout = QVBoxLayout(widget)\n            layout.setContentsMargins(0, 0, 0, 0)\n\n            actions = list(Actions.ALL.keys())\n            shortcuts = dict()\n            # There's no need to do something like \"shortcuts = json.loads(option_value)\"\n            # as those have been already initialized from the settings on startup\n            for a in actions:\n                shortcuts[a] = Actions.ALL[a].shortcut().toString()\n            seq_edit = QKeySequenceEdit(parent)\n            seq_edit.setObjectName(f'{option_id}-edit')\n            seq_edit.setKeySequence(shortcuts[actions[0]])\n            reset_button = QPushButton(widget)\n            reset_button.setObjectName(f'{option_id}-button')\n            reset_button.setText('Clear')\n            reset_button.clicked.connect(lambda: seq_edit.clear())\n\n            def on_shortcut_changed(k: QKeySequence):\n                shortcuts[actions[ed8.currentIndex()]] = k.toString()\n                self._on_value_changed(option_id, json.dumps(shortcuts))\n\n            seq_edit.keySequenceChanged.connect(on_shortcut_changed)\n\n            ed8 = QComboBox(parent)\n            ed8.setObjectName(f'{option_id}-list')\n            ed8.addItems([f'{Actions.ALL[a].text()}' for a in actions])\n            ed8.currentIndexChanged.connect(lambda v: seq_edit.setKeySequence(shortcuts[actions[ed8.currentIndex()]]))\n            self._widgets_get_value[option_id] = lambda: json.dumps(shortcuts)\n            self._widgets_set_value[option_id] = lambda txt: logger.error('Changing shortcuts programmatically is not implemented yet')\n\n            layout.addWidget(ed8)\n\n            hwidget = QWidget(widget)\n            hlayout = QHBoxLayout(hwidget)\n            hlayout.setContentsMargins(0, 0, 0, 0)\n            hlayout.addWidget(seq_edit)\n            hlayout.addWidget(reset_button)\n\n            layout.addWidget(hwidget)\n            return [widget]\n        elif option_type == 'duration':\n            ed9 = QTimeEdit(parent)\n            ed9.setDisplayFormat('HH:mm:ss')\n            ed9.setCurrentSection(QTimeEdit.Section.SecondSection)\n            ed9.setMinimumTime(_from_total_seconds(option_options[0]))\n            ed9.setMaximumTime(_from_total_seconds(option_options[1]))\n            ed9.userTimeChanged.connect(lambda v: self._on_value_changed(\n                option_id,\n                str(int(v.msecsSinceStartOfDay() / 1000))\n            ))\n            ed9.setTime(_from_total_seconds(int(float(option_value))))\n            self._widgets_get_value[option_id] = lambda: str(int(ed9.time().msecsSinceStartOfDay() / 1000))\n            self._widgets_set_value[option_id] = lambda txt: logger.error('Changing durations programmatically is not implemented yet')\n            return [ed9]\n        elif option_type == 'key':\n            widget = QWidget(parent)\n            layout = QHBoxLayout(widget)\n            layout.setContentsMargins(0, 0, 0, 0)\n            ed10 = QLineEdit(parent)\n            ed10.setObjectName(f'{option_id}-edit')\n            ed10.setText(option_value)\n            ed10.setEchoMode(QLineEdit.EchoMode.Password)\n\n            ed10.textChanged.connect(lambda v: self._on_value_changed(option_id, v))\n            self._widgets_get_value[option_id] = ed10.text\n            self._widgets_set_value[option_id] = ed10.setText\n\n            option_id_cache = f'{option_id.replace(\"!\", \"\")}_cache!' if option_id.endswith('!') else f'{option_id}_cache'\n            ed10.textChanged.connect(lambda v: self._on_value_changed(option_id_cache, v))\n            self._widgets_get_value[option_id_cache] = lambda: ''   # Always empty the cache\n            layout.addWidget(ed10)\n\n            key_view = QPushButton(parent)\n            key_view.setObjectName(f'{option_id}-button')\n            key_view.setText('Show')\n            key_view.clicked.connect(lambda: ed10.setEchoMode(QLineEdit.EchoMode.Password if ed10.echoMode() == QLineEdit.EchoMode.Normal else QLineEdit.EchoMode.Normal))\n            key_view.clicked.connect(lambda: key_view.setText(\"Show\" if key_view.text() == \"Hide\" else \"Hide\"))\n            layout.addWidget(key_view)\n            return [widget]\n        elif option_type == 'label':\n            ed11 = QLabel(parent)\n            ed11.setWordWrap(False)\n            sp = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)\n            ed11.setSizePolicy(sp)\n            ed11.setText(option_value)\n            return [ed11]\n        elif option_type == 'keyvalue':\n            ed13 = QTableWidget(parent)\n            ed13.setColumnCount(2)\n            ed13.horizontalHeader().setVisible(True)\n            ed13.horizontalHeader().setStretchLastSection(True)\n            ed13.setHorizontalHeaderItem(0, QTableWidgetItem('Event'))\n            ed13.setHorizontalHeaderItem(1, QTableWidgetItem('Command'))\n            ed13.verticalHeader().setVisible(False)\n            ed13.setRowCount(len(option_options))\n            json_value: dict[str, str] = json.loads(option_value)\n\n            flags = (Qt.ItemFlag.ItemIsSelectable |\n                     Qt.ItemFlag.ItemIsEnabled)\n            for i, option in enumerate(sorted(option_options)):\n                item1 = QTableWidgetItem(option)\n                item1.setFlags(flags)\n                ed13.setItem(i, 0, item1)\n                item2 = QTableWidgetItem(json_value[option] if option in json_value else '')\n                item2.setFlags(flags | Qt.ItemFlag.ItemIsEditable)\n                ed13.setItem(i, 1, item2)\n            ed13.resizeColumnsToContents()\n\n            def get_value() -> str:\n                obj: dict[str, str] = dict()\n                for j in range(ed13.rowCount()):\n                    key = ed13.item(j, 0).text()\n                    value = ed13.item(j, 1).text().strip()\n                    if value != '':\n                        obj[key] = value\n                return json.dumps(obj)\n\n            def set_value(value: str) -> None:\n                obj: dict[str, str] = json.loads(value)\n                for j in range(ed13.rowCount()):\n                    key = ed13.item(j, 0).text()\n                    if key in obj:\n                        ed13.item(j, 1).setText(obj[key])\n\n            ed13.itemChanged.connect(lambda: self._on_value_changed(option_id, get_value()))\n            self._widgets_get_value[option_id] = get_value\n            self._widgets_set_value[option_id] = set_value\n\n            return [ed13]\n        else:\n            return []\n\n    def _handle_button_click(self, option_id: str):\n        if self._buttons_mapping is None or option_id not in self._buttons_mapping:\n            QMessageBox().warning(self,\n                                  \"Not available\",\n                                  \"This button doesn't do anything\",\n                                  QMessageBox.StandardButton.Close)\n            return\n\n        values: dict[str, str] = dict()\n        for name in self._widgets_get_value:\n            values[name] = self._widgets_get_value[name]()\n\n        if self._buttons_mapping[option_id](values, self._value_changed_externally):\n            self.close()\n\n    def _value_changed_externally(self, name: str, value: str):\n        logger.debug(f'Update the setting display of {name} to {value}')\n        self._widgets_set_value[name](value)\n\n    def _create_tab(self, tabs: QTabWidget, settings) -> QWidget:\n        res = QWidget()\n        layout = QFormLayout(res)\n        # layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows)\n        # layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)\n        # layout.setSizeConstraint(QHBoxLayout.SizeConstraint.SetMinimumSize)\n\n        for option_id, option_type, option_display, option_value, option_options, option_visible in settings:\n            widgets = self._display_option(res, option_id, option_type, option_value, option_options, option_display)\n            if option_display == '':\n                label = None\n            else:\n                label = QLabel('' if option_type == 'button' else option_display, res)\n                label.setObjectName(f'label-{option_id}')\n                self._widgets_visibility[label] = option_visible\n            if len(widgets) == 0:\n                # Separator\n                separator = QFrame(res)\n                separator.setFrameShape(QFrame.Shape.HLine)\n                separator.setFrameShadow(QFrame.Shadow.Sunken)\n                separator.setFixedHeight(10)\n                self._widgets_visibility[separator] = option_visible\n                layout.addRow(separator)\n                pass\n            elif len(widgets) == 1:\n                widgets[0].setObjectName(f'{option_id}')\n                self._widgets_visibility[widgets[0]] = option_visible\n                if label is None:\n                    layout.addRow(widgets[0])\n                else:\n                    layout.addRow(label, widgets[0])\n            else:\n                i = 1\n                for widget in widgets:\n                    widget.setObjectName(f'#{option_id}-{i}')\n                    self._widgets_visibility[widget] = option_visible\n                    if i == 1 and label is not None:\n                        layout.addRow(label, widget)\n                    else:\n                        layout.addRow(widget)\n                    i += 1\n\n        return res\n\n\nif __name__ == '__main__':\n    # Simple tests\n    app = QApplication([])\n    window = SettingsDialog(QtSettings())\n    window.show()\n    sys.exit(app.exec())\n"
  },
  {
    "path": "src/fk/desktop/stats_window.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nfrom calendar import monthrange\nfrom typing import Callable\n\nfrom PySide6 import QtUiTools\nfrom PySide6.QtCharts import QChart, QBarSet, QBarCategoryAxis, QValueAxis, QChartView, QStackedBarSeries\nfrom PySide6.QtCore import Qt, QObject, QFile, QMargins\nfrom PySide6.QtGui import QAction, QPainter, QColor, QFont\nfrom PySide6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QLabel, QToolButton\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL\n\n\nclass StatsWindow(QObject):\n    _chart: QChart\n    _source: AbstractEventSource\n    _stats_window: QMainWindow\n    _header_text: QLabel\n    _header_subtext: QLabel\n    _period_actions: dict[str, QAction]\n    _prev_action: QAction\n    _next_action: QAction\n\n    _to: datetime.datetime\n    _period: str\n\n    _series: QStackedBarSeries\n    _axis_x: QBarCategoryAxis\n    _axis_y: QValueAxis\n    _bars: dict[str, QBarSet]\n\n    _color_highlight: QColor\n    _color_primary: QColor\n    _color_secondary: QColor\n\n    def __init__(self,\n                 parent: QWidget,\n                 header_font: QFont,\n                 theme_variables: dict[str, str],\n                 source: AbstractEventSource):\n        super().__init__(parent)\n        self._source = source\n        self._period = 'week'\n        self._reset_to(self._period)\n        self._init_colors(theme_variables)\n\n        file = QFile(\":/stats.ui\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        # noinspection PyTypeChecker\n        self._stats_window: QMainWindow = QtUiTools.QUiLoader().load(file, parent)\n        file.close()\n\n        # noinspection PyTypeChecker\n        self._header_text = self._stats_window.findChild(QLabel, \"statsHeaderText\")\n        self._header_text.setFont(header_font)\n\n        # noinspection PyTypeChecker\n        self._header_subtext = self._stats_window.findChild(QLabel, \"statsHeaderSubtext\")\n        self._period_actions = {\n            'year': self._create_checkable_action('year', 'Ctrl+Y'),\n            'month6': self._create_checkable_action('month6', 'Ctrl+H'),\n            'month': self._create_checkable_action('month', 'Ctrl+M'),\n            'week': self._create_checkable_action('week', 'Ctrl+W'),\n            'day': self._create_checkable_action('day', 'Ctrl+D'),\n        }\n        self._period_actions['week'].setChecked(True)\n        self._prev_action = self._create_simple_action('prev', self._prev)\n        self._prev_action.setShortcut('Left')\n        self._next_action = self._create_simple_action('next', self._next)\n        self._next_action.setShortcut('Right')\n\n        close_action = QAction('Close', self._stats_window)\n        close_action.triggered.connect(self._stats_window.close)\n        close_action.setShortcut('Esc')\n        self._stats_window.addAction(close_action)\n\n        chart = QChart()\n        self._chart = chart\n        axis_x = QBarCategoryAxis(self)\n        f = axis_x.labelsFont()\n        f.setPointSize(round(f.pointSize() * 0.8))\n        axis_x.setLabelsFont(f)\n        axis_x.setGridLineVisible(False)\n        self._axis_x = axis_x\n        axis_y = QValueAxis(self)\n        self._axis_y = axis_y\n        axis_y.setLabelFormat('%d')\n        chart.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom)\n        chart.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft)\n        self._series = QStackedBarSeries(self)    # or QBarSeries\n        chart.addSeries(self._series)\n        self._series.attachAxis(self._axis_x)\n        self._series.attachAxis(self._axis_y)\n        chart.legend().setAlignment(Qt.AlignmentFlag.AlignBottom)\n        chart.setMargins(QMargins(10, 0, 10, 0))\n\n        self._update_chart('week', self._to)\n\n        # noinspection PyTypeChecker\n        layout: QVBoxLayout = self._stats_window.findChild(QVBoxLayout, \"statsGraph\")\n        view = QChartView(chart, self._stats_window)\n        view.setObjectName('statsView')\n        view.setRenderHint(QPainter.RenderHint.Antialiasing)\n\n        chart.setBackgroundVisible(False)\n        chart.setPlotAreaBackgroundVisible(False)\n        layout.addWidget(view)\n\n    def _init_colors(self, theme_variables: dict[str, str]) -> None:\n        self._color_primary = QColor(theme_variables['TABLE_TEXT_COLOR'])\n        self._color_secondary = QColor(theme_variables['SELECTION_BG_COLOR'])\n        self._color_highlight = QColor(theme_variables['FOCUS_TEXT_COLOR'])\n\n    def _style_chart(self) -> None:\n        self._bars['finished'].setColor(QColor('dodgerblue'))\n        self._bars['startable'].setColor(QColor('lightgray'))\n        self._bars['canceled'].setColor(QColor('orangered'))\n\n        self._bars['finished'].setBorderColor(self._color_highlight)\n        self._bars['startable'].setBorderColor(self._color_highlight)\n        self._bars['canceled'].setBorderColor(self._color_highlight)\n\n        self._chart.legend().setLabelColor(self._color_primary)\n        self._axis_x.setLabelsColor(QColor(self._color_primary))\n        self._axis_y.setLabelsColor(QColor(self._color_primary))\n\n        self._axis_y.setGridLineColor(QColor(self._color_secondary))\n\n    def _time_delta_for_period(self, period: str, date: datetime.datetime, left: bool):\n        date = StatsWindow._drop_time(date, period, True)\n        if period == 'week':\n            return datetime.timedelta(days=(-7 if left else 7))\n        elif period == 'year':\n            year = date.year + (-1 if left else 1)\n            cmp_date = datetime.datetime(year,\n                                         date.month,\n                                         min(date.day, monthrange(year, date.month)[1]),\n                                         date.hour,\n                                         tzinfo=date.tzinfo)\n            return cmp_date - date\n        elif period == 'day':\n            return datetime.timedelta(days=(-1 if left else 1))\n        elif period == 'month':\n            year = date.year\n            month = date.month\n            if left:\n                if month == 1:\n                    month = 12\n                    year -= 1\n                else:\n                    month -= 1\n            else:\n                if month == 12:\n                    month = 1\n                    year += 1\n                else:\n                    month += 1\n            cmp_date = datetime.datetime(year,\n                                         month,\n                                         min(date.day, monthrange(year, month)[1]),\n                                         date.hour,\n                                         tzinfo=date.tzinfo)\n            return cmp_date - date\n        elif period == 'month6':\n            delta = datetime.timedelta()\n            for i in range(6):\n                delta += self._time_delta_for_period('month', date + delta, left)\n            return delta\n        else:\n            raise Exception(f'Unexpected period: {period}')\n\n    @staticmethod\n    def _drop_time(date: datetime.datetime, period: str, start: bool):\n        if period == 'week':\n            return datetime.datetime(date.year,\n                                     date.month,\n                                     date.day,\n                                     0 if start else 23,\n                                     0 if start else 59,\n                                     0 if start else 59,\n                                     tzinfo=date.tzinfo)\n        elif period == 'year':\n            return datetime.datetime(date.year,\n                                     date.month,\n                                     monthrange(date.year, date.month)[1],\n                                     0 if start else 23,\n                                     0 if start else 59,\n                                     0 if start else 59,\n                                     tzinfo=date.tzinfo)\n        elif period == 'day':\n            return datetime.datetime(date.year,\n                                     date.month,\n                                     date.day,\n                                     date.hour,\n                                     0 if start else 59,\n                                     tzinfo=date.tzinfo)\n        elif period == 'month':\n            return datetime.datetime(date.year,\n                                     date.month,\n                                     date.day,\n                                     0 if start else 23,\n                                     0 if start else 59,\n                                     0 if start else 59,\n                                     tzinfo=date.tzinfo)\n        elif period == 'month6':\n            return datetime.datetime(date.year,\n                                     date.month,\n                                     monthrange(date.year, date.month)[1],\n                                     tzinfo=date.tzinfo)\n        else:\n            raise Exception(f'Unexpected period: {period}')\n\n    def _substep_delta_for_period(self, period: str, date: datetime.datetime, left: bool):\n        if period == 'week':\n            return self._time_delta_for_period('day', date, left)\n        elif period == 'year':\n            return self._time_delta_for_period('month', date, left)\n        elif period == 'day':\n            return datetime.timedelta(hours=(-6 if left else 6))\n        elif period == 'month':\n            return self._time_delta_for_period('week', date, left)\n        elif period == 'month6':\n            return self._time_delta_for_period('month', date, left)\n        else:\n            raise Exception(f'Unexpected period: {period}')\n\n    def _prev(self):\n        self._to = StatsWindow._drop_time(self._to, self._period, False)\n        self._to += self._substep_delta_for_period(self._period, self._to, True)\n        self._update_chart(self._period, self._to)\n\n    def _next(self):\n        self._to = StatsWindow._drop_time(self._to, self._period, False)\n        self._to += self._substep_delta_for_period(self._period, self._to, False)\n        self._update_chart(self._period, self._to)\n\n    @staticmethod\n    def _format_date(date: datetime.datetime):\n        return date.strftime('%d %b %Y, %H:%M')\n\n    def _update_chart(self, period: str, to: datetime.datetime) -> None:\n        self._period = period\n        _from: datetime.datetime = (to +\n                                    self._time_delta_for_period(period, to, True) +\n                                    datetime.timedelta(minutes=1))\n        header_subtext = f'Average over {StatsWindow._format_date(_from)} to {StatsWindow._format_date(to)}'\n        self._header_subtext.setText(header_subtext)\n\n        d = self.extract_data(period, _from, to)\n\n        completed_count = sum(d[1])\n        total_count = completed_count + sum(d[2]) + sum(d[3])\n        if total_count > 0:\n            completion = round(100 * completed_count / total_count)\n            header_text = f'Completed {completed_count} out of {total_count} ({completion}%)'\n        else:\n            header_text = 'No data'\n        self._header_text.setText(header_text)\n\n        self._axis_y.setRange(0, max(d[4]))\n        self._axis_x.clear()\n        self._axis_x.append(d[0])\n\n        self._bars = {\n            'finished': QBarSet(\"Completed\", self),\n            'canceled': QBarSet(\"Voided\", self),\n            'startable': QBarSet(\"Not started\", self),\n        }\n\n        self._series.clear()\n        self._series.append(self._bars['finished'])\n        self._series.append(self._bars['canceled'])\n        self._series.append(self._bars['startable'])\n\n        [self._bars['finished'].append(i) for i in d[1]]\n        [self._bars['canceled'].append(i) for i in d[2]]\n        [self._bars['startable'].append(i) for i in d[3]]\n\n        self._style_chart()\n\n    def _create_checkable_action(self, name: str, shortcut: str) -> QAction:\n        # noinspection PyTypeChecker\n        button: QToolButton = self._stats_window.findChild(QToolButton, name)\n        action = QAction(button.text(), self)\n        action.setCheckable(True)\n        action.setShortcut(shortcut)\n        button.setDefaultAction(action)\n        action.triggered.connect(lambda: self.select_period(name))\n        return action\n\n    def _create_simple_action(self, name: str, callback: Callable) -> QAction:\n        # noinspection PyTypeChecker\n        button: QToolButton = self._stats_window.findChild(QToolButton, name)\n        action = QAction(button.text(), self)\n        button.setDefaultAction(action)\n        action.triggered.connect(callback)\n        return action\n\n    def _reset_to(self, period):\n        self._to = StatsWindow._drop_time(datetime.datetime.now(datetime.timezone.utc).astimezone(), period, False)\n\n    def select_period(self, period: str) -> None:\n        for name in self._period_actions:\n            action = self._period_actions[name]\n            action.setChecked(name == period)\n        self._reset_to(period)\n        self._update_chart(period, self._to)\n\n    @staticmethod\n    def _rotate(lst: list, n: int) -> list:\n        return lst[n + 1:] + lst[:n + 1]\n\n    def extract_data(self, group: str, period_from: datetime.datetime, period_to: datetime.datetime):\n        if group == 'week':\n            cats = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']\n            rotate_around = period_to.weekday()\n        elif group == 'year':\n            cats = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n            rotate_around = period_to.month - 1\n        elif group == 'day':\n            cats = [str(i) for i in range(24)]\n            rotate_around = period_to.hour\n        elif group == 'month':\n            cats = [str(i + 1) for i in range(31)]\n            rotate_around = period_to.day - 1\n        elif group == 'month6':\n            cats = [str(i + 1) for i in range(53)]\n            rotate_around = period_to.isocalendar()[1] - 1\n        else:\n            raise Exception(f'Grouping by {group} is not implemented')\n\n        list_finished = [0 for _ in cats]\n        list_canceled = list_finished.copy()\n        list_ready = list_finished.copy()\n        list_total = list_finished.copy()\n\n        for p in self._source.pomodoros():\n            if p.get_type() != POMODORO_TYPE_NORMAL:\n                continue\n\n            finished = p.is_finished()\n            canceled = False\n            for interruption in p.values():\n                if interruption.is_void():\n                    canceled = True\n            if finished or canceled:\n                when = p.get_last_modified_date().astimezone()\n            else:\n                when = p.get_create_date().astimezone()\n            if when is None:\n                continue\n\n            if when < period_from or when > period_to:\n                continue\n\n            index = 0\n            if group == 'week':\n                index = when.weekday()\n            elif group == 'year':\n                index = when.month - 1\n            elif group == 'day':\n                index = when.hour\n            elif group == 'month':\n                index = when.day - 1\n            elif group == 'month6':\n                index = when.isocalendar()[1] - 1\n\n            if finished:\n                list_finished[index] += 1\n            elif canceled:\n                list_canceled[index] += 1\n            else:\n                list_ready[index] += 1\n            list_total[index] += 1\n\n        r = [StatsWindow._rotate(cats, rotate_around),\n             StatsWindow._rotate(list_finished, rotate_around),\n             StatsWindow._rotate(list_canceled, rotate_around),\n             StatsWindow._rotate(list_ready, rotate_around),\n             StatsWindow._rotate(list_total, rotate_around)]\n\n        if group == 'month6':\n            # Truncate to half a year\n            r[0] = r[0][26:]\n            r[1] = r[1][26:]\n            r[2] = r[2][26:]\n            r[3] = r[3][26:]\n            r[4] = r[4][26:]\n\n        return r\n\n    def show(self):\n        self._stats_window.show()\n"
  },
  {
    "path": "src/fk/desktop/tutorial.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom typing import Callable\n\nfrom PySide6.QtCore import QPoint\nfrom PySide6.QtWidgets import QWidget, QAbstractItemView\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged, BeforeSourceChanged\nfrom fk.core.events import AfterSettingsChanged, SourceMessagesProcessed, AfterBacklogCreate, \\\n    AfterBacklogRename, AfterWorkitemCreate, AfterWorkitemRename, AfterPomodoroAdd, AfterPomodoroRemove, \\\n    AfterPomodoroWorkStart, AfterPomodoroComplete, AfterWorkitemComplete, AfterPomodoroVoided\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.workitem import Workitem\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.configurable_toolbar import ConfigurableToolBar\nfrom fk.qt.info_overlay import show_tutorial_overlay\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.timer_widget import TimerWidget\nfrom fk.qt.workitem_tableview import WorkitemTableView\n\nlogger = logging.getLogger(__name__)\n\n\ndef _get_row_position(widget: QAbstractItemView, x: float, row: int, col: int, arrow: str) -> QPoint:\n    row_rect = widget.visualRect(widget.model().index(row, col))\n    return widget.mapToGlobal(QPoint(\n        round(row_rect.left() * (1.0 - x) + x * row_rect.right()),\n        row_rect.top() + 5 if arrow == 'down' else row_rect.bottom() + 5\n    ))\n\n\nclass Tutorial:\n    _source_holder: EventSourceHolder\n    _settings: AbstractSettings\n    _main_window: QWidget\n    _focus_window: QWidget\n\n    _steps: dict[str, Callable]\n\n    def __init__(self,\n                 source_holder: EventSourceHolder,\n                 settings: AbstractSettings,\n                 main_window: QWidget,\n                 focus_window: QWidget):\n        super().__init__()\n        self._settings = settings\n        self._source_holder = source_holder\n        self._main_window = main_window\n        self._focus_window = focus_window\n        self._steps = {\n            SourceMessagesProcessed: self._on_messages,\n            AfterBacklogCreate: self._on_backlog_create,\n            AfterBacklogRename: self._on_backlog_rename,\n            AfterWorkitemCreate: self._on_workitem_create,\n            AfterWorkitemRename: self._on_workitem_rename,\n            AfterPomodoroAdd: self._on_pomodoro_add,\n            AfterPomodoroRemove: self._on_pomodoro_remove,\n            AfterPomodoroWorkStart: self._on_pomodoro_work_start,\n            AfterPomodoroComplete: self._on_pomodoro_complete,\n            AfterPomodoroVoided: self._on_pomodoro_complete,\n            AfterWorkitemComplete: self._on_workitem_complete,\n        }\n\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n        if settings.get('Application.show_tutorial') == 'True':\n            self._subscribe()\n            source = source_holder.get_source()\n            if source is not None:\n                self._after_source_changed('Auto', source)\n                if source.get_last_sequence() > 0:\n                    self._on_event(SourceMessagesProcessed)\n\n    def _subscribe(self):\n        logger.debug(f'Subscribing tutorial to source_holder changes')\n        self._source_holder.on(BeforeSourceChanged, self._before_source_changed, True)\n        self._source_holder.on(AfterSourceChanged, self._after_source_changed, True)\n\n    def _unsubscribe(self):\n        logger.debug(f'Unsubscribing the tutorial')\n        self._source_holder.unsubscribe(self._after_source_changed)\n        self._source_holder.unsubscribe(self._before_source_changed)\n        source = self._source_holder.get_source()\n        if source is not None:\n            source.unsubscribe(self._on_event)\n\n    def _on_event(self, event: str, **kwargs):\n        if self._is_to_complete(event):\n            self._steps[event](lambda: self._mark_completed(event),\n                               lambda: self._settings.set({'Application.show_tutorial': 'False'}),\n                               **kwargs)\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.show_tutorial' in new_values:\n            show = new_values['Application.show_tutorial'] == 'True'\n            self._subscribe() if show else self._unsubscribe()\n\n    def _mark_completed(self, step: str) -> None:\n        setting = self._settings.get('Application.completed_tutorial_steps')\n        steps: list[str] = [] if setting == '' else setting.split(',')\n        if step not in steps:\n            steps.append(step)\n        self._settings.set({'Application.completed_tutorial_steps': ','.join(steps)})\n        # Disable tutorial if we completed everything\n        logger.debug(f'Marking tutorial step {step} complete. All completed steps: {steps}')\n        if len(steps) == len(self._steps):\n            logger.debug(f'Disabling the tutorial')\n            self._settings.set({'Application.show_tutorial': 'False'})\n\n    def _is_to_complete(self, step: str) -> bool:\n        return step not in self._settings.get('Application.completed_tutorial_steps').split(',')\n\n    def _before_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        # Here we are dealing with the OLD source, from which we want to unsubscribe\n        if source is not None:\n            logger.debug(f'Unsubscribing tutorial from old source events')\n            for event in self._steps:\n                source.on(event, self._on_event)\n\n    def _after_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        logger.debug(f'Subscribing tutorial to new source events')\n        for e in self._steps:\n            source.on(e, self._on_event)\n\n    def _get_toolbar_button_position(self, action_name: str, arrow: str):\n        toolbar: ConfigurableToolBar\n        if action_name.startswith('backlogs_table'):\n            toolbar = self._main_window.findChild(ConfigurableToolBar, \"backlogs_toolbar\")\n        else:\n            toolbar = self._main_window.findChild(ConfigurableToolBar, \"workitems_toolbar\")\n        rect = toolbar.get_button_geometry(action_name)\n        pt = rect.center()\n        pt.setY(rect.top() if arrow == 'down' else rect.bottom())\n        return toolbar.parentWidget().mapToGlobal(pt)\n\n    # Tutorial \"steps\" implementation are only called if the corresponding step hasn't been completed yet.\n    # The \"complete\" parameter is a callback, which the step can execute to mark it completed.\n\n    def _on_messages(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        show_tutorial_overlay(self._main_window,\n                              '1 / 11: Welcome to Flowkeeper! Let\\'s start by creating your first backlog. You would '\n                              'usually create a new one every morning.\\n\\n'\n                              'Hotkey: Ctrl+N / ⌘N',\n                              self._get_toolbar_button_position('backlogs_table.newBacklog', 'up'),\n                              'info',\n                              complete,\n                              skip,\n                              'up')\n\n    def _on_backlog_create(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        backlogs: BacklogTableView = self._main_window.findChild(BacklogTableView, \"backlogs_table\")\n        show_tutorial_overlay(self._main_window,\n                              '2 / 11: Type some catchy name for your backlog and press Enter.\\n\\n'\n                              'You can rename existing backlogs by double-clicking them or pressing Ctrl+R / ⌘R.',\n                              _get_row_position(backlogs, 0.15, 0, 0, 'down'),\n                              'info',\n                              complete,\n                              skip,\n                              'down')\n\n    def _on_backlog_rename(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        show_tutorial_overlay(self._main_window,\n                              '3 / 11: Now create a work item in the selected backlog. Work items are tasks, '\n                              'which you can execute using Pomodoro Technique.\\n\\n'\n                              'Hotkey: Ins',\n                              self._get_toolbar_button_position('workitems_table.newItem', 'up'),\n                              'info',\n                              complete,\n                              skip,\n                              'up')\n\n    def _on_workitem_create(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        workitems: WorkitemTableView = self._main_window.findChild(WorkitemTableView, \"workitems_table\")\n        show_tutorial_overlay(self._main_window,\n                              '4 / 11: Choose a better name for this work item and press Enter.\\n\\n'\n                              'Just like backlogs, you can rename work items by double-clicking them or '\n                              'by pressing F6.',\n                              _get_row_position(workitems, 0.15, 0, 1, 'down'),\n                              'info',\n                              complete,\n                              skip,\n                              'down')\n\n    def _on_workitem_rename(self, complete: Callable, skip: Callable, workitem: Workitem, **kwargs) -> None:\n        show_tutorial_overlay(self._main_window,\n                              '5 / 11: Before you can start working on it, you need to estimate this task in '\n                              '25-minute pomodoros. Add several pomodoros by clicking this button.\\n\\n'\n                              'Hotkey: Ctrl++ / ⌘+',\n                              self._get_toolbar_button_position('workitems_table.addPomodoro', 'down'),\n                              'info',\n                              complete,\n                              skip,\n                              'down')\n\n    def _on_pomodoro_add(self, complete: Callable, skip: Callable, workitem: Workitem, **kwargs) -> None:\n        if len(workitem) >= 3:\n            workitems: WorkitemTableView = self._main_window.findChild(WorkitemTableView, \"workitems_table\")\n            show_tutorial_overlay(self._main_window,\n                                  '6 / 11: If you overestimated your work item, you can delete excessive pomodoros. '\n                                  'Leave at least two pomodoros to continue the tutorial.\\n\\n'\n                                  'Hotkey: Ctrl+- / ⌘-',\n                                  _get_row_position(workitems, 0.5, 0, 2, 'up'),\n                                  'info',\n                                  complete,\n                                  skip,\n                                  'up')\n\n    def _on_pomodoro_remove(self, complete: Callable, skip: Callable, workitem: Workitem, **kwargs) -> None:\n        if len(workitem) >= 2:\n            show_tutorial_overlay(self._main_window,\n                                  f'7 / 11: Now you are ready to start your first pomodoro by clicking ▶️ button '\n                                  f'in the toolbar.\\n\\n'\n                                  f'Hotkey: Ctrl+S / ⌘S',\n                                  self._get_toolbar_button_position('workitems_table.startItem', 'down'),\n                                  'info',\n                                  complete,\n                                  skip,\n                                  'down')\n\n    def _on_pomodoro_work_start(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        def do(_1, _2):\n            window: QWidget = self._main_window if self._main_window.isVisible() else self._focus_window\n            is_classic: bool = self._settings.get('Application.focus_flavor') == 'classic'\n            timer: TimerWidget = window.findChild(TimerWidget, \"timer\")\n            pt: QPoint = timer.rect().center()\n            pt.setY(timer.rect().bottom() + 10)\n            if is_classic:\n                action = 'clicking X button in the middle of the timer indicator'\n            else:\n                action = 'clicking the timer indicator and selecting \"Void Pomodoro\"'\n            show_tutorial_overlay(window,\n                                  f'8 / 11: Take a minute to explore this view. We call it Focus Mode, and this is '\n                                  f'where Flowkeeper spends most of its time. You can customize this view in the '\n                                  f'Settings.\\n\\n'\n                                  f'You probably don\\'t want to wait for 25 minutes to continue this tutorial, so '\n                                  f'please void this pomodoro by {action}.',\n                                  timer.mapToGlobal(pt),\n                                  'info',\n                                  complete,\n                                  skip,\n                                  'up')\n        # We decouple it through a timer to make sure all resizing is done by the time we display the overlay\n        QtTimer('tutorial-on_pomodoro_work_start').schedule(100, do, None, True)\n\n    def _on_pomodoro_complete(self, complete: Callable, skip: Callable, pomodoro: Pomodoro, **kwargs) -> None:\n        completed_count = 0\n        for p in self._source_holder.get_source().pomodoros():\n            if p.is_finished() or len(p) > 0:\n                completed_count += 1\n        if completed_count == 1:\n            workitems: WorkitemTableView = self._main_window.findChild(WorkitemTableView, \"workitems_table\")\n            show_tutorial_overlay(self._main_window,\n                                  f'9 / 11: {\"We are very sorry that you had to void\" if len(pomodoro) > 0 else \"Congratulations! You successfully completed\"} your first pomodoro. '\n                                  f'{\"Note a little tick meaning an interruption\" if len(pomodoro) > 0 else \"Note how its icon changed\"}.\\n\\n'\n                                  'You might have heard a DING when that happened -- you can configure all Flowkeeper '\n                                  'sounds in the Settings > Audio.\\n\\n'\n                                  'Now try to complete another pomodoro, this time see what different buttons in that '\n                                  'Focus Mode do. Also pay attention to the Flowkeeper icon in system tray.',\n                                  _get_row_position(workitems, 0.4, 0, 2, 'up'),\n                                  'info',\n                                  lambda: None,\n                                  skip,\n                                  'up')\n        elif completed_count > 1:\n            show_tutorial_overlay(self._main_window,\n                                  '10 / 11: Well done! Now let\\'s imagine that you finished this work item. You can '\n                                  'mark it completed by clicking the ✔️ button.\\n\\n'\n                                  f'Hotkey: Ctrl+P / ⌘P',\n                                  self._get_toolbar_button_position('workitems_table.completeItem', 'down'),\n                                  'info',\n                                  complete,\n                                  skip,\n                                  'down')\n\n    def _on_workitem_complete(self, complete: Callable, skip: Callable, **kwargs) -> None:\n        workitems: WorkitemTableView = self._main_window.findChild(WorkitemTableView, \"workitems_table\")\n        show_tutorial_overlay(self._main_window,\n                              '11 / 11: Note that as you marked that work item completed you can\\'t modify it anymore. '\n                              'The only thing you can do is delete it.\\n\\n'\n                              'Hotkey: Del\\n\\n'\n                              'Great job, you finished this tutorial!',\n                              _get_row_position(workitems, 0.15, 0, 1, 'up'),\n                              'info',\n                              complete,\n                              skip,\n                              'up',\n                              True)\n"
  },
  {
    "path": "src/fk/desktop/work_summary_window.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport csv\nimport datetime\nimport json\nimport pathlib\nfrom abc import ABC, abstractmethod\nfrom io import StringIO\nfrom os import path\nfrom xml.etree import ElementTree\nfrom xml.etree.ElementTree import Element\n\nfrom PySide6 import QtUiTools\nfrom PySide6.QtCore import QObject, QFile\nfrom PySide6.QtGui import QAction, QGuiApplication\nfrom PySide6.QtWidgets import QMainWindow, QWidget, QTextEdit, \\\n    QCheckBox, QComboBox, QDialogButtonBox, QMessageBox, QPushButton\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.desktop.settings import SettingsDialog\nfrom fk.qt.oauth import open_url\n\n\ndef _format_date(date: datetime.datetime):\n    return date.strftime('%d %b %Y')\n\n\ndef _format_duration(duration: datetime.timedelta):\n    return str(duration).split('.')[0]\n\n\nclass Formatter(ABC):\n    @abstractmethod\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        pass\n\n    @abstractmethod\n    def week(self, text: str) -> str:\n        pass\n\n    @abstractmethod\n    def day(self, text: str) -> str:\n        pass\n\n    def workitem_plaintext(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        if duration is not None:\n            text += f': {_format_duration(duration)}'\n        if backlogs is not None and len(backlogs) > 0:\n            text += f''', in backlog{\"s\" if len(backlogs) > 1 else \"\"} \"{'\", \"'.join(backlogs)}\"'''\n        return text\n\n    @abstractmethod\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        pass\n\n    @abstractmethod\n    def footer(self) -> str:\n        pass\n\n\nclass MarkdownFormatter(Formatter):\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        return f'# Work summary\\n'\n\n    def week(self, text: str) -> str:\n        return f'\\n## {text}\\n'\n\n    def day(self, text: str) -> str:\n        return f'\\n### {text}\\n\\n'\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        return f' - {self.workitem_plaintext(text, duration, backlogs)}\\n'\n\n    def footer(self) -> str:\n        return ''\n\n\nclass OrgModeFormatter(Formatter):\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        return f'#+title:  Work Summary\\n' \\\n               f'#+author: Flowkeeper\\n' \\\n               f'#+date:   {str(datetime.date.today())}\\n' \\\n               f'\\n' \\\n               f'* Work summary\\n'\n\n    def week(self, text: str) -> str:\n        return f'** {text}\\n'\n\n    def day(self, text: str) -> str:\n        return f'*** {text}\\n'\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        return f'- {self.workitem_plaintext(text, duration, backlogs)}\\n'\n\n    def footer(self) -> str:\n        return ''\n\n\nclass MarkdownTableFormatter(Formatter):\n    _last_week: str  # With those we rely on the correct order of those formatting instructions\n    _last_day: str\n\n    def __init__(self):\n        self._last_week = ''\n        self._last_day = ''\n\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        return f'| Week number | Date | Work item {\"| Time spent \" if include_durations else \"\"}{\"| Backlogs \" if include_backlogs else \"\"}|\\n' \\\n               f'| ----------- | ---- | --------- {\"| ---------- \" if include_durations else \"\"}{\"| -------- \" if include_backlogs else \"\"}|\\n'\n\n    def week(self, text: str) -> str:\n        self._last_week = text\n        return ''\n\n    def day(self, text: str) -> str:\n        self._last_day = text\n        return ''\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        return f'| {self._last_week} | {self._last_day} | {text} {(\"| \" + _format_duration(duration) + \" \") if duration is not None else \"\"}{(\"| \" + \", \".join(backlogs) + \" \") if backlogs is not None else \"\"}|\\n'\n\n    def footer(self) -> str:\n        return ''\n\n\nclass PlaintextFormatter(Formatter):\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        return ''\n\n    def week(self, text: str) -> str:\n        return f'\\n*** {text} ***\\n'\n\n    def day(self, text: str) -> str:\n        return f'\\n{text}\\n\\n'\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        return self.workitem_plaintext(text, duration, backlogs) + '\\n'\n\n    def footer(self) -> str:\n        return ''\n\n\nclass CsvFormatter(Formatter):\n    _last_week: str  # With those we rely on the correct order of those formatting instructions\n    _last_day: str\n\n    def __init__(self):\n        self._last_week = ''\n        self._last_day = ''\n\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        f = StringIO()\n        # TODO: It would be more efficient and elegant, if we used streams throughout this entire file\n        #  instead of string concatenation\n        headers = ['Week number', 'Date', 'Work item']\n        if include_durations:\n            headers.append('Time spent')\n        if include_backlogs:\n            headers.append('Backlogs')\n        csv.writer(f).writerow(headers)\n\n        return f.getvalue()\n\n    def week(self, text: str) -> str:\n        self._last_week = text\n        return ''\n\n    def day(self, text: str) -> str:\n        self._last_day = text\n        return ''\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        f = StringIO()\n        csv.writer(f).writerow([self._last_week,\n                                self._last_day,\n                                text,\n                                _format_duration(duration) if duration is not None else \"\",\n                                ('\"' + '\", \"'.join(backlogs) + '\"') if backlogs is not None and len(backlogs) > 0 else \"\"])\n        return f.getvalue()\n\n    def footer(self) -> str:\n        return ''\n\n\nclass JsonFormatter(Formatter):\n    _json: dict\n    _last_week: str\n    _last_day: str\n\n    def __init__(self):\n        self._json = dict()\n        self._last_week = ''\n        self._last_day = ''\n\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        self._json['weeks'] = dict()\n        return ''\n\n    def week(self, text: str) -> str:\n        self._json['weeks'][text] = {}\n        self._last_week = text\n        return ''\n\n    def day(self, text: str) -> str:\n        self._json['weeks'][self._last_week][text] = []\n        self._last_day = text\n        return ''\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        to_append = {\"title\": text}\n        if duration is not None:\n            to_append['duration'] = _format_duration(duration)\n        if backlogs is not None:\n            to_append['backlogs'] = list(backlogs)\n        self._json['weeks'][self._last_week][self._last_day].append(to_append)\n        return ''\n\n    def footer(self) -> str:\n        return json.dumps(self._json, indent=2)\n\n\nclass XmlFormatter(Formatter):\n    _xml: Element\n    _last_week: Element\n    _last_day: Element\n\n    def __init__(self):\n        self._xml = Element('weeks')\n        self._last_week = ''\n        self._last_day = ''\n\n    def header(self, include_durations: bool, include_backlogs: bool) -> str:\n        return ''\n\n    def week(self, text: str) -> str:\n        el = Element('week', name=text)\n        self._xml.append(el)\n        self._last_week = el\n        return ''\n\n    def day(self, text: str) -> str:\n        el = Element('day', date=text)\n        self._last_week.append(el)\n        self._last_day = el\n        return ''\n\n    def workitem(self, text: str, duration: datetime.timedelta = None, backlogs: set[str] = None) -> str:\n        el = Element('item', title=text)\n        if duration is not None:\n            el.attrib['duration'] = _format_duration(duration)\n        if backlogs is not None and len(backlogs) > 0:\n            bs = Element('backlogs')\n            el.append(bs)\n            for backlog in backlogs:\n                bs.append(Element('backlog', title=backlog))\n        self._last_day.append(el)\n        return ''\n\n    def footer(self) -> str:\n        ElementTree.indent(self._xml)\n        return ElementTree.tostring(self._xml, encoding='utf8').decode('utf8')\n\n\nclass WorkSummaryWindow(QObject):\n    _source: AbstractEventSource\n    _summary_window: QMainWindow\n    _data: dict[datetime.date, dict[str, list[datetime.timedelta, set[str]]]]\n    _results: QTextEdit\n    _view_durations: QCheckBox\n    _view_backlogs: QCheckBox\n    _format: QComboBox\n    _period: QComboBox\n    _buttons: QDialogButtonBox\n\n    def __init__(self, parent: QWidget, source: AbstractEventSource):\n        super().__init__(parent)\n        self._source = source\n\n        file = QFile(\":/summary.ui\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        # noinspection PyTypeChecker\n        self._summary_window: QMainWindow = QtUiTools.QUiLoader().load(file, parent)\n        file.close()\n\n        self._buttons: QDialogButtonBox = self._summary_window.findChild(QDialogButtonBox, \"buttons\")\n        copy_button = QPushButton('Copy summary to clipboard and close')\n        copy_button.setDefault(True)\n        self._buttons.addButton(copy_button, QDialogButtonBox.ButtonRole.ActionRole)\n        self._buttons.clicked.connect(lambda btn: self._on_action(self._buttons.buttonRole(btn)))\n\n        self._results: QTextEdit = self._summary_window.findChild(QTextEdit, \"work_summary_results\")\n\n        self._view_durations: QCheckBox = self._summary_window.findChild(QCheckBox, \"view_time_spent\")\n        self._view_durations.stateChanged.connect(lambda v: self._display_formatted())\n        self._view_durations.stateChanged.connect(self._save_settings)\n\n        self._view_backlogs: QCheckBox = self._summary_window.findChild(QCheckBox, \"view_backlogs\")\n        self._view_backlogs.stateChanged.connect(lambda v: self._display_formatted())\n        self._view_backlogs.stateChanged.connect(self._save_settings)\n\n        self._format: QComboBox = self._summary_window.findChild(QComboBox, \"format\")\n        self._format.addItems(['Markdown',\n                               'Markdown table',\n                               'Emacs Org Mode',\n                               'Formatted',\n                               'Formatted table',\n                               'Plaintext',\n                               'CSV',\n                               'JSON',\n                               'XML'])\n        self._format.currentIndexChanged.connect(lambda v: self._display_formatted())\n        self._format.currentIndexChanged.connect(self._save_settings)\n\n        self._period: QComboBox = self._summary_window.findChild(QComboBox, \"period\")\n        self._period.addItems(['Everything',\n                               'This week',\n                               'Previous week',\n                               'Today',\n                               'Yesterday',\n                               'Last working day (Mon - Fri)'])\n        self._period.currentIndexChanged.connect(lambda v: self._display_formatted())\n        self._period.currentIndexChanged.connect(self._save_settings)\n\n        self._load_settings()\n\n        close_action = QAction(self._summary_window, 'Close')\n        close_action.triggered.connect(self._summary_window.close)\n        close_action.setShortcut('Esc')\n        self._summary_window.addAction(close_action)\n\n        self._data = self._extract_data()\n        self._display_formatted()\n\n    def _save_settings(self):\n        self._source.get_settings().set({\n            \"Application.work_summary_settings\": json.dumps({\n                \"format\": self._format.currentIndex(),\n                \"period\": self._period.currentIndex(),\n                \"durations\": self._view_durations.isChecked(),\n                \"backlogs\": self._view_backlogs.isChecked(),\n            })\n        })\n\n    def _load_settings(self):\n        s = json.loads(self._source.get_settings().get(\"Application.work_summary_settings\"))\n\n        self._format.blockSignals(True)\n        self._format.setCurrentIndex(s.get('format', 0))\n        self._format.blockSignals(False)\n\n        self._period.blockSignals(True)\n        self._period.setCurrentIndex(s.get('period', 0))\n        self._period.blockSignals(False)\n\n        self._view_durations.blockSignals(True)\n        self._view_durations.setChecked(s.get('durations', False))\n        self._view_durations.blockSignals(False)\n\n        self._view_backlogs.blockSignals(True)\n        self._view_backlogs.setChecked(s.get('backlogs', False))\n        self._view_backlogs.blockSignals(False)\n\n    def _extract_data(self) -> dict[datetime.date, dict[str, list[datetime.timedelta, set[str]]]]:\n        data = dict[datetime.date, dict[str, list[datetime.timedelta, set[str]]]]()\n        for w in self._source.workitems():\n            if w.is_sealed():\n                key = w.get_last_modified_date().date()  # That's when it was sealed\n                if key not in data:\n                    data[key] = dict()\n                workitems = data[key]\n                if w.get_name() not in workitems:\n                    workitems[w.get_name()] = [datetime.timedelta(), set()]\n                workitems[w.get_name()][1].add(w.get_parent().get_name())\n            for p in w.values():\n                pp: Pomodoro = p\n                if pp.is_finished():\n                    key = pp.get_last_modified_date().date()  # That's when it was finished\n                    if key not in data:\n                        data[key] = dict()\n                    workitems = data[key]\n                    if w.get_name() not in workitems:\n                        workitems[w.get_name()] = [datetime.timedelta(), set([w.get_parent().get_name()])]\n                    workitems[w.get_name()][0] += datetime.timedelta(seconds=pp.get_work_duration())\n                    workitems[w.get_name()][1].add(w.get_parent().get_name())\n        return data\n\n    def _display_formatted(self) -> None:\n        res = self._format_data(self._view_durations.isChecked(), self._view_backlogs.isChecked())\n        if self._format.currentText() == 'Formatted' or self._format.currentText() == 'Formatted table':\n            self._results.setMarkdown(res)\n        else:\n            self._results.setText(res)\n\n    def _format_data(self, include_durations: bool, include_backlogs: bool) -> str:\n        # Get the period\n        period: str = self._period.currentText()\n\n        # First sort the dates / keys\n        dates = list(self._data.keys())\n        dates.sort(reverse=True)\n\n        # Prepare for period filtering\n        today = datetime.date.today()\n        yesterday = today - datetime.timedelta(days=1)\n        this_week = today.isocalendar()[1]\n        if today.weekday() == 6:\n            last_working_day = today - datetime.timedelta(days=2)\n        elif today.weekday() == 5:\n            last_working_day = today - datetime.timedelta(days=1)\n        else:\n            last_working_day = today\n        if this_week == 1:\n            previous_week = 52\n            year_of_previous_week = today.year - 1\n        else:\n            previous_week = this_week - 1\n            year_of_previous_week = today.year\n\n        # Then group dates by weeks\n        weeks = dict[str, list[datetime.date]]()\n        for date in dates:\n            week_number = date.isocalendar()[1]\n\n            # Period filtering\n            if period == 'Today' and date != today:\n                continue\n            elif period == 'Yesterday' and date != yesterday:\n                continue\n            elif period == 'This week' and (week_number != this_week or date.year != today.year):\n                continue\n            elif period == 'Previous week' and (week_number != previous_week or date.year != year_of_previous_week):\n                continue\n            elif period == 'Last working day (Mon - Fri)' and date != last_working_day:\n                continue\n\n            # Those keys are sortable alphabetically\n            week_key = f'{date.year}, Week {\"0\" if week_number < 10 else \"\"}{week_number}'\n            if week_key not in weeks:\n                weeks[week_key] = list()\n            weeks[week_key].append(date)    # We know they don't repeat\n\n        weeks_sorted = list(weeks.keys())\n        weeks_sorted.sort(reverse=True)\n\n        # Get correct formatter\n        format_name: str = self._format.currentText()\n\n        if format_name == 'Markdown' or format_name == 'Formatted':\n            formatter = MarkdownFormatter()\n        elif format_name == 'Markdown table' or format_name == 'Formatted table':\n            formatter = MarkdownTableFormatter()\n        elif format_name == 'CSV':\n            formatter = CsvFormatter()\n        elif format_name == 'JSON':\n            formatter = JsonFormatter()\n        elif format_name == 'XML':\n            formatter = XmlFormatter()\n        elif format_name == 'Emacs Org Mode':\n            formatter = OrgModeFormatter()\n        else:\n            formatter = PlaintextFormatter()\n\n        # Now iterate through the groups and format\n        res = formatter.header(include_durations, include_backlogs)\n        for week in weeks_sorted:\n            res += formatter.week(week)\n            for date in weeks[week]:\n                res += formatter.day(str(date))\n                workitems = self._data[date]\n                for workitem_name in workitems:\n                    duration = workitems[workitem_name][0]\n                    backlogs = workitems[workitem_name][1]\n                    res += formatter.workitem(workitem_name,\n                                              duration if include_durations else None,\n                                              backlogs if include_backlogs else None)\n        res += formatter.footer()\n        return res\n\n    def show(self):\n        self._summary_window.show()\n\n    def _get_file_extension(self) -> str:\n        format_name: str = self._format.currentText()\n        if format_name == 'Markdown' or \\\n                format_name == 'Formatted' or \\\n                format_name == 'Markdown table' or \\\n                format_name == 'Formatted table':\n            return 'md'\n        elif format_name == 'CSV':\n            return 'csv'\n        elif format_name == 'JSON':\n            return 'json'\n        elif format_name == 'XML':\n            return 'xml'\n        elif format_name == 'Emacs Org Mode':\n            return 'org'\n        else:\n            return 'txt'\n\n    def _export_to_file(self, filename: str):\n        if path.isdir(filename):\n            filename = path.join(filename, f'work-summary.{self._get_file_extension()}')\n        res = self._format_data(self._view_durations.isChecked(), self._view_backlogs.isChecked())\n        with open(filename, \"w\") as file:\n            file.write(res)\n        if QMessageBox().information(\n                self._summary_window,\n                'Success',\n                f'Summary is saved to {filename}.\\n'\n                f'Would you like to open the resulting file?',\n                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Close) == QMessageBox.StandardButton.Yes:\n            open_url(pathlib.Path(path.abspath(filename)).as_uri())\n\n    def _on_action(self, role: QDialogButtonBox.ButtonRole):\n        if role == QDialogButtonBox.ButtonRole.AcceptRole:\n            SettingsDialog.do_browse_simple('', self._export_to_file)\n        elif role == QDialogButtonBox.ButtonRole.ActionRole:\n            res = self._format_data(self._view_durations.isChecked(), self._view_backlogs.isChecked())\n            QGuiApplication.clipboard().setText(res)\n            self._summary_window.close()\n        elif role == QDialogButtonBox.ButtonRole.RejectRole:\n            self._summary_window.close()\n"
  },
  {
    "path": "src/fk/e2e/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/e2e/abstract_e2e_test.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport asyncio\nimport atexit\nimport inspect\nimport logging\nimport os\nimport traceback\nfrom abc import ABC\nfrom datetime import datetime\nfrom typing import Callable\nfrom xml.etree import ElementTree\n\nfrom PySide6.QtCore import QTimer, QPoint, QEvent, Qt\nfrom PySide6.QtGui import QWindow, QMouseEvent, QKeyEvent, QFocusEvent\nfrom PySide6.QtWidgets import QWidget, QAbstractItemView, QMainWindow, QAbstractButton, QCheckBox, QRadioButton\n\nfrom fk.desktop.application import Application\nfrom fk.e2e.screenshot import Screenshot\nfrom fk.qt.actions import Actions\n\nINSTANT_DURATION = 0.5  # seconds\nSTARTUP_DURATION = 3  # seconds\nWINDOW_GALLERY_FILENAME = 'test-results/screenshots-window.html'\nWINDOW_BORDER_GALLERY_FILENAME = 'test-results/screenshots-window-border.html'\nFULLSCREEN_GALLERY_FILENAME = 'test-results/screenshots-full.html'\n\nlogger = logging.getLogger(__name__)\n\n\nclass AbstractE2eTest(ABC):\n    _timer: QTimer\n    _seq: list[Callable]\n    _app: Application\n    _initialized: bool\n    _log_filename: str\n    _log_xml: ElementTree.Element\n    _failures: int\n    _errors: int\n    _skipped: int\n    _tests: int\n    _start: datetime\n    _current_method: str\n    _screenshot: Screenshot\n    _main_window: QMainWindow\n\n    def __init__(self, app: Application):\n        self._app = app\n        self._screenshot = None\n        self._main_window = None\n        self._current_method = 'N/A'\n        app.get_settings().set(self.custom_settings())\n        self._initialized = False\n        self._seq = self._get_test_cases()\n        self._timer = QTimer()\n        self._log_xml = None\n        self._log_filename = None\n        self._timer.timeout.connect(lambda: asyncio.ensure_future(\n            self._run()\n        ))\n\n    def _get_test_cases(self):\n        methods = inspect.getmembers(self.__class__, predicate=inspect.isfunction)\n        res = list()\n        for m in methods:\n            if m[0].startswith('test_'):\n                res.append(m[1])\n        return res\n\n    async def _run(self):\n        self._timer.stop()\n        if not self._initialized:\n            self._initialized = True\n            # noinspection PyTypeChecker\n            window: QWindow = self._app.activeWindow()\n            if window:\n                self._timer.stop()\n                self.setup()\n                atexit.register(self.teardown)\n                self._tests = len(self._seq)\n                self._failures = 0\n                self._errors = 0\n                self._skipped = len(self._seq)\n                self._start = datetime.now()\n                self.init_log()\n                try:\n                    for method in self._seq:\n                        method_start = datetime.now()\n                        try:\n                            self._current_method = method.__name__\n                            self._update_log_for_method('timestamp', method_start.isoformat())\n                            await method(self)\n                        except Exception as e:\n                            self.error(e)\n                        finally:\n                            self._skipped -= 1\n                            method_duration = (datetime.now() - method_start).total_seconds()\n                            if method_duration < 0.0001:\n                                method_duration = 0.0001    # Otherwise we'd get scientific notation in output\n                            self._update_log_for_method('time', str(method_duration))\n                finally:\n                    logger.debug(f'*** E2e tests completed {\"successfully\" if self._errors == 0 and self._skipped == 0 and self._failures == 0 else \"with errors\" } ***')\n                    self.close_log()\n                    logger.debug('Do whatever it takes to exit')\n                    window.close()\n                    logger.debug(' - Closed the window')\n                    self._app.exit(0)\n                    logger.debug(' - Exited Qt')\n                    # sys.exit(0)\n                    # logger.debug(' - Exited Python')\n\n    def _update_log_for_method(self, name: str, value: str):\n        el = self._log_xml.find(f\"testcase[@name='{self._current_method}']\")\n        el.set(name, value)\n\n    def _append_to_system_out_for_method(self, line: str):\n        if self._log_xml is not None:\n            el = self._log_xml.find(f\"testcase[@name='{self._current_method}']/system-out\")\n            if el.text:\n                el.text += '\\n'\n            el.text += line\n\n    def init_log(self) -> None:\n        filename = f\"{__name__.replace('.', '/')}.py\"\n        test_name = f'{__name__}.{self.__class__.__name__}'\n        if not os.path.exists('test-results'):\n            os.mkdir('test-results')\n        self._log_filename = f'test-results/TEST-{test_name}.xml'\n        logger.debug(f'Creating a log file {self._log_filename}')\n        self._log_xml = ElementTree.Element('testsuite')\n        self._log_xml.set('name', test_name)\n        self._log_xml.set('file', filename)\n        self._log_xml.set('timestamp', datetime.now().isoformat())\n        for method in self._seq:\n            testcase = ElementTree.SubElement(self._log_xml, 'testcase', {\n                'classname': __name__,\n                'name': method.__code__.co_name,\n                'file': filename,\n                'line': str(method.__code__.co_firstlineno),\n            })\n            system_out = ElementTree.SubElement(testcase, 'system-out')\n            system_out.text = ''\n\n    def close_log(self) -> None:\n        if self._log_xml is not None and self._log_filename is not None:\n            logger.debug(f'Saving file {self._log_filename}')\n            self._log_xml.set('tests', str(self._tests))\n            self._log_xml.set('time', str((datetime.now() - self._start).total_seconds()))\n            self._log_xml.set('failures', str(self._failures))\n            self._log_xml.set('errors', str(self._errors))\n            self._log_xml.set('skipped', str(self._skipped))\n            tree = ElementTree.ElementTree(self._log_xml)\n            ElementTree.indent(tree, space=\"    \", level=0)\n            tree.write(self._log_filename,\n                       encoding='utf-8',\n                       xml_declaration=True)\n            self._log_filename = None\n            self._log_xml = None\n\n    def info(self, txt):\n        logger.info(f'INFO: {self._current_method}: {txt}')\n        self._append_to_system_out_for_method(txt)\n\n    def error(self, e: Exception):\n        logger.error(f'ERROR: {self._current_method}', exc_info=e)\n        self._append_to_system_out_for_method(f'ERROR: {e}')\n        self._errors += 1\n\n    def _get_row_position(self, widget: QAbstractItemView, row: int, col: int) -> QPoint:\n        return widget.visualRect(widget.model().index(row, col)).center()\n\n    async def mouse_click_row(self, widget: QAbstractItemView, row: int, col: int = 0):\n        logger.debug(f'mouse_click_row({widget.objectName()}, {row}, {col})')\n        await self.mouse_click(widget, self._get_row_position(widget, row, col))\n\n    async def mouse_doubleclick_row(self, widget: QAbstractItemView, row: int, col: int = 0):\n        logger.debug(f'mouse_doubleclick_row({widget.objectName()}, {row}, {col})')\n        await self.mouse_doubleclick(widget, self._get_row_position(widget, row, col))\n\n    async def mouse_click(self, widget: QWidget, pos: QPoint, left_button: bool = True):\n        logger.debug(f'mouse_click({widget.objectName()}, {pos}, {left_button})')\n        widget.focusInEvent(QFocusEvent(\n            QEvent.Type.FocusIn,\n        ))\n        await self.instant_pause()\n        widget.mousePressEvent(QMouseEvent(\n            QEvent.Type.MouseButtonPress,\n            pos,\n            Qt.MouseButton.LeftButton if left_button else Qt.MouseButton.RightButton,\n            Qt.MouseButton.NoButton,\n            Qt.KeyboardModifier.NoModifier,\n        ))\n        await self.instant_pause()\n        widget.mousePressEvent(QMouseEvent(\n            QEvent.Type.MouseButtonRelease,\n            pos,\n            Qt.MouseButton.LeftButton if left_button else Qt.MouseButton.RightButton,\n            Qt.MouseButton.NoButton,\n            Qt.KeyboardModifier.NoModifier,\n        ))\n        await self.instant_pause()\n\n    async def mouse_doubleclick(self, widget: QWidget, pos: QPoint = QPoint(5, 5)):\n        logger.debug(f'mouse_doubleclick({widget.objectName()}, {pos})')\n        widget.mouseDoubleClickEvent(QMouseEvent(\n            QEvent.Type.MouseButtonDblClick,\n            pos,\n            Qt.MouseButton.LeftButton,\n            Qt.MouseButton.NoButton,\n            Qt.KeyboardModifier.NoModifier,\n        ))\n        await self.instant_pause()\n\n    def keypress(self, key: int, ctrl: bool = False, widget: QWidget = None):\n        logger.debug(f'keypress({key}, {ctrl}, {widget})')\n        if widget is None:\n            widget = self.get_focused()\n        self._app.postEvent(widget, QKeyEvent(\n            QEvent.Type.KeyPress,\n            key,\n            Qt.KeyboardModifier.ControlModifier if ctrl else Qt.KeyboardModifier.NoModifier,\n        ))\n\n    def close_modal(self, ok: bool = True):\n        logger.debug(f'close_modal({ok})')\n        for w in self._app.allWindows():\n            if w.isModal():\n                if ok:\n                    self.keypress(Qt.Key.Key_Enter, False, w)\n                else:\n                    self.keypress(Qt.Key.Key_Escape, False, w)\n\n    def type_text(self, text: str):\n        logger.debug(f'type_text({text})')\n        self._app.postEvent(self.get_focused(), QKeyEvent(\n            QEvent.Type.KeyPress,\n            Qt.Key.Key_No,\n            Qt.KeyboardModifier.NoModifier,\n            text,\n        ))\n\n    def get_focused(self) -> QWidget:\n        return self._app.focusWidget()\n\n    def get_application(self) -> Application:\n        return self._app\n\n    def window(self) -> QWidget:\n        win = self._app.activeWindow()\n        if win is None:\n            win = self._main_window\n            if win is None:\n                raise Exception('Cannot find active window')\n        else:\n            self._main_window = win\n        return win\n\n    def click_button(self, text: str = None, name: str = None):\n        logger.debug(f'click_button({text}, {name})')\n        buttons: list[QAbstractButton] = self.get_application().activeWindow().findChildren(QAbstractButton)\n        for b in buttons:\n            if text is not None and b.text() == text or name is not None and b.objectName() == name:\n                b.click()\n\n    def check_checkbox(self, checked: bool = True, text: str = None, name: str = None):\n        logger.debug(f'check_checkbox({checked}, {text}, {name})')\n        checkboxes: list[QCheckBox] = self.get_application().activeWindow().findChildren(QCheckBox)\n        for c in checkboxes:\n            if text is not None and c.text() == text or name is not None and c.objectName() == name:\n                c.setChecked(checked)\n\n    def check_radiobutton(self, checked: bool = True, text: str = None, name: str = None):\n        logger.debug(f'check_radiobutton({checked}, {text}, {name})')\n        radios: list[QRadioButton] = self.get_application().activeWindow().findChildren(QRadioButton)\n        for r in radios:\n            if text is not None and r.text() == text or name is not None and r.objectName() == name:\n                r.setChecked(checked)\n\n    def execute_action(self, name: str) -> None:\n        logger.debug(f'execute_action({name})')\n        Actions.ALL[name].trigger()\n\n    def is_action_enabled(self, name: str) -> bool:\n        return Actions.ALL[name].isVisible() and Actions.ALL[name].isEnabled()\n\n    def custom_settings(self) -> dict[str, str]:\n        return dict()\n\n    def start(self) -> None:\n        self._timer.start(STARTUP_DURATION * 1000)\n\n    def setup(self) -> None:\n        pass\n\n    def teardown(self) -> None:\n        self.close_log()\n\n    async def instant_pause(self) -> None:\n        await asyncio.sleep(INSTANT_DURATION)\n\n    async def longer_pause(self) -> None:\n        await self.instant_pause()\n        await self.instant_pause()\n        await self.instant_pause()\n        await self.instant_pause()\n        await self.instant_pause()\n\n    def on_exception(self, exc_type, exc_value, exc_trace):\n        to_log = \"\".join(traceback.format_exception(exc_type, exc_value, exc_trace))\n        self.info('Exception: ' + to_log)\n\n    def take_screenshot(self, name: str):\n        if self._screenshot is None:    # Lazy init, because we don't always want to take screenshots\n            self._screenshot = Screenshot()\n        self._screenshot.take_window(name, self.window())\n        self._screenshot.take_screen(name)\n\n        # Update window gallery\n        if not os.path.isfile(WINDOW_GALLERY_FILENAME):\n            with open(WINDOW_GALLERY_FILENAME, 'w', encoding='UTF-8') as f:\n                f.write('''\n                    <style>\n                    .screenshot {\n                        box-shadow: 5px 5px 30px 0px rgba(0, 0, 0, 0.5);\n                        margin: 30px;\n                        width: 500px;\n                        height: auto;\n                    }\n                    </style>\n                ''')\n        with open(WINDOW_GALLERY_FILENAME, 'a', encoding='UTF-8') as f:\n            f.write(f'<img class=\"screenshot\" src=\"{name}-window.png\" title=\"{name}\">\\n')\n\n        # Update window w/border gallery\n        if not os.path.isfile(WINDOW_BORDER_GALLERY_FILENAME):\n            with open(WINDOW_BORDER_GALLERY_FILENAME, 'w', encoding='UTF-8') as f:\n                f.write('''\n                    <style>\n                    .screenshot {\n                        margin: 30px;\n                        width: 500px;\n                        height: auto;\n                    }\n                    </style>\n                ''')\n        with open(WINDOW_BORDER_GALLERY_FILENAME, 'a', encoding='UTF-8') as f:\n            f.write(f'<img class=\"screenshot\" src=\"{name}-window-border.png\" title=\"{name}\">\\n')\n\n        # Update screen gallery\n        if not os.path.isfile(FULLSCREEN_GALLERY_FILENAME):\n            with open(FULLSCREEN_GALLERY_FILENAME, 'w', encoding='UTF-8') as f:\n                f.write('''\n                    <style>\n                    .screenshot {\n                        box-shadow: 5px 5px 30px 0px rgba(0, 0, 0, 0.5);\n                        margin: 30px;\n                        width: 1000px;\n                        height: auto;\n                    }\n                    </style>\n                ''')\n        with open(FULLSCREEN_GALLERY_FILENAME, 'a', encoding='UTF-8') as f:\n            f.write(f'<img class=\"screenshot\" src=\"{name}-full.png\" title=\"{name}\">\\n')\n\n    def center_window(self):\n        screen_center = self._app.primaryScreen().geometry().center()\n        fg = self.window().geometry()\n        self.window().move(screen_center - (fg.center() - fg.topLeft()))\n"
  },
  {
    "path": "src/fk/e2e/all-tests.json",
    "content": "{\n  \"ubuntu-20.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"ubuntu-20.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"ubuntu-22.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"ubuntu-22.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"ubuntu-24.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"ubuntu-24.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-20.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-20.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-22.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-22.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-24.04-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"kubuntu-24.04-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"fedora-39-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-39-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-kde-39-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-kde-39-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-rawhide-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-rawhide-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-kde-rawhide-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"fedora-kde-rawhide-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"rhel-9.3-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"rhel-9.3-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"rhel-kde-9.3-wayland\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"rhel-kde-9.3-xorg\": [\"RPM package\", \"All-in-one binary\", \"Portable archive\", \"RedHat build\"],\n  \"debian-12-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-12-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-kde-12-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-kde-12-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-sid-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-sid-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-kde-sid-wayland\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"debian-kde-sid-xorg\": [\"DEB package\", \"All-in-one binary\", \"Portable archive\", \"Debian build\"],\n  \"windows-10-21H2\": [\"Windows installer\", \"Portable exe\", \"Windows build\"],\n  \"windows-10-22H2\": [\"Windows installer\", \"Portable exe\", \"Windows build\"],\n  \"windows-11-23H2\": [\"Windows installer\", \"Portable exe\", \"Windows build\"],\n  \"windows-server-2019\": [\"Windows installer\", \"Portable exe\", \"Windows build\"],\n  \"windows-server-2022\": [\"Windows installer\", \"Portable exe\", \"Windows build\"],\n  \"macOS-11-x86\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-11-m1\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-12-x86\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-12-m1\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-13-x86\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-13-m1\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-14-x86\": [\"DMG installer\", \"MacOS build\"],\n  \"macOS-14-m1\": [\"DMG installer\", \"MacOS build\"]\n}"
  },
  {
    "path": "src/fk/e2e/backlog_e2e.py",
    "content": "import asyncio\nimport os\n\nfrom PySide6.QtCore import Qt\nfrom assertpy import assert_that\n\nfrom fk.core.workitem import Workitem\nfrom fk.desktop.application import Application\nfrom fk.e2e.abstract_e2e_test import AbstractE2eTest\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.search_completer import SearchBar\nfrom fk.qt.workitem_tableview import WorkitemTableView\n\nTEMP_FILENAME = './backlog-e2e.txt'\nPOMODORO_WORK_DURATION = 2  # seconds\nPOMODORO_REST_DURATION = 2  # seconds\n\n\nclass BacklogE2eTest(AbstractE2eTest):\n    def __init__(self, app: Application):\n        super().__init__(app)\n\n    def custom_settings(self) -> dict[str, str]:\n        return {\n            'FileEventSource.filename': TEMP_FILENAME,\n            'Application.show_tutorial': 'False',\n            'Application.check_updates': 'False',\n            'Application.show_window_title': 'True',\n            'Pomodoro.default_work_duration': str(POMODORO_WORK_DURATION),\n            'Pomodoro.default_rest_duration': str(POMODORO_REST_DURATION),\n            'Application.play_alarm_sound': 'False',\n            'Application.play_rest_sound': 'False',\n            'Application.play_tick_sound': 'False',\n            'Logger.filename': 'backlog-e2e.log',\n            'Logger.level': 'DEBUG',\n            'Application.last_version': self.get_application()._current_version,\n        }\n\n    def teardown(self) -> None:\n        super().teardown()\n        os.unlink(TEMP_FILENAME)\n\n    async def _new_backlog(self, name: str) -> None:\n        self.keypress(Qt.Key.Key_N, True)   # self.execute_action('backlogs_table.newBacklog')\n        await self.instant_pause()\n        self.type_text(name)\n        self.keypress(Qt.Key.Key_Enter)\n        await self.instant_pause()\n\n    async def _start_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_S, True)   # self.execute_action('workitems_table.startItem')\n        await self.instant_pause()\n\n    async def _finish_tracking(self) -> None:\n        self.execute_action('focus.finishTracking')\n        await self.instant_pause()\n\n    async def _wait_pomodoro_complete(self) -> None:\n        await asyncio.sleep(POMODORO_WORK_DURATION)\n        await asyncio.sleep(POMODORO_REST_DURATION)\n        await self.instant_pause()\n\n    async def _wait_mid_pomodoro(self) -> None:\n        await asyncio.sleep(POMODORO_WORK_DURATION * 0.75)\n\n    async def _complete_workitem(self) -> None:\n        self.keypress(Qt.Key.Key_P, True)   # self.execute_action('workitems_table.completeItem')\n        await self.instant_pause()\n\n    async def _void_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_V, True)   # self.execute_action('focus.voidPomodoro')\n        await self.instant_pause()\n        self.close_modal()\n        await self.instant_pause()\n\n    async def _add_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_Plus, True)  # self.execute_action('workitems_table.addPomodoro')\n        await self.instant_pause()\n\n    async def _remove_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_Minus, True)  # self.execute_action('workitems_table.removePomodoro')\n        await self.instant_pause()\n\n    async def _new_workitem(self, name: str, pomodoros: int = 0) -> None:\n        self.keypress(Qt.Key.Key_Insert)   # self.execute_action('workitems_table.newItem')\n        await self.instant_pause()\n        self.type_text(name)\n        self.keypress(Qt.Key.Key_Enter)\n        await self.instant_pause()\n        for p in range(pomodoros):\n            await self._add_pomodoro()\n\n    async def _find_workitem(self, name: str) -> None:\n        self.keypress(Qt.Key.Key_F, True)   # self.execute_action('window.showSearch')\n        await self.instant_pause()\n        self.type_text(name)\n        await self.instant_pause()\n        # noinspection PyTypeChecker\n        search: SearchBar = self.window().findChild(SearchBar, \"search\")\n        completer = search.completer()\n        popup = completer.popup()\n        self.keypress(Qt.Key.Key_Down, False, popup)\n        self.keypress(Qt.Key.Key_Enter, False, popup)\n        await self.instant_pause()\n\n    async def _select_backlog(self, name: str) -> int:\n        # noinspection PyTypeChecker\n        backlogs_table: BacklogTableView = self.window().findChild(BacklogTableView, \"backlogs_table\")\n        backlogs_model = backlogs_table.model()\n        for i in range(backlogs_model.rowCount()):\n            if backlogs_model.index(i, 0).data() == name:\n                await self.mouse_click_row(backlogs_table, i)\n                return i\n        return -1\n\n    def assert_actions_enabled(self, names: list[str]) -> None:\n        for name in names:\n            assert_that(self.is_action_enabled(name), f'Action {name} should be enabled').is_true()\n\n    def assert_actions_disabled(self, names: list[str]) -> None:\n        for name in names:\n            assert_that(self.is_action_enabled(name), f'Action {name} should be disabled').is_false()\n\n    async def test_01_create_backlogs(self):\n        ##################\n        # General checks #\n        ##################\n        self.info('General checks')\n        assert_that(self.window().windowTitle()).is_equal_to('Flowkeeper')\n\n        # noinspection PyTypeChecker\n        backlogs_table: BacklogTableView = self.window().findChild(BacklogTableView, \"backlogs_table\")\n        backlogs_model = backlogs_table.model()\n        assert_that(backlogs_model.rowCount()).is_equal_to(0)\n\n        # noinspection PyTypeChecker\n        workitems_table: WorkitemTableView = self.window().findChild(WorkitemTableView, \"workitems_table\")\n        workitems_model = workitems_table.model()\n        assert_that(workitems_model.rowCount()).is_equal_to(0)\n\n        ################################################################\n        # Create a bunch of test backlogs and fill them with workitems #\n        ################################################################\n        self.info('Create a bunch of test backlogs and fill them with workitems')\n        await self._new_backlog('Trip to Italy')\n        await self._new_workitem('Workitem 11')\n        await self._new_workitem('Workitem 12', 1)\n        await self._new_workitem('Workitem 13', 3)\n        assert_that(workitems_model.rowCount()).is_equal_to(3)\n\n        await self._new_backlog('House renovation')\n        await self._new_workitem('Workitem 21')\n        await self._new_workitem('Workitem 22', 1)\n        await self._new_workitem('Workitem 23', 3)\n        assert_that(workitems_model.rowCount()).is_equal_to(3)\n\n        await self._new_backlog('Long-term stuff')\n        await self._new_workitem('Workitem 31')\n        await self._new_workitem('Workitem 32', 1)\n        await self._new_workitem('Workitem 33', 3)\n        assert_that(workitems_model.rowCount()).is_equal_to(3)\n\n        await self._new_backlog('2024-03-12, Tuesday')\n        await self._new_workitem('Workitem 41')\n        await self._new_workitem('Workitem 42', 1)\n        await self._new_workitem('Workitem 43', 3)\n        assert_that(workitems_model.rowCount()).is_equal_to(3)\n\n        await self._new_backlog('2024-03-13, Wednesday')\n        await self._new_workitem('Workitem 51')\n        await self._new_workitem('Workitem 52', 1)\n        await self._new_workitem('Workitem 53', 3)\n        assert_that(workitems_model.rowCount()).is_equal_to(3)\n\n        await self._new_backlog('2024-03-14, Thursday')\n        await self._new_workitem('Generate new screenshots', 2)\n        await self._new_workitem('Reply to Peter', 1)\n        await self._new_workitem('Slides for the demo', 3)\n        await self._new_workitem('Deprecate StartRest strategy', 2)\n        await self._new_workitem('Auto-seal in the web frontend', 2)\n        await self._new_workitem('Order coffee capsules')\n        await self._new_workitem('Call Alex in the afternoon')\n        assert_that(workitems_model.rowCount()).is_equal_to(7)\n\n        assert_that(backlogs_model.rowCount()).is_equal_to(6)\n\n        ####################################\n        # Complete pomodoros and workitems #\n        ####################################\n        self.info('Complete pomodoros and workitems')\n        await self._find_workitem('Generate new screenshots')\n        await self._start_pomodoro()   # 1\n        await self._wait_pomodoro_complete()\n        await self._start_pomodoro()   # 2\n        await self._wait_pomodoro_complete()\n        await self._add_pomodoro()\n        await self._start_pomodoro()   # 3\n        await self._wait_pomodoro_complete()\n        await self._find_workitem('Reply to Peter')\n        await self._start_pomodoro()   # 4\n        await self._wait_pomodoro_complete()\n        await self._finish_tracking()   # Long break\n        await self._add_pomodoro()\n        await self._start_pomodoro()    # 1\n        await self._wait_mid_pomodoro()\n        await self._void_pomodoro()     # 0\n        await self._complete_workitem()\n\n        await self._find_workitem('Slides for the demo')\n        await self._start_pomodoro()    # 1\n        await self._wait_mid_pomodoro()\n        await self._void_pomodoro()     # 0\n        await self._start_pomodoro()    # 1\n        await self._wait_mid_pomodoro()\n        await self._void_pomodoro()     # 0\n        await self._complete_workitem()\n\n        await self._find_workitem('Order coffee capsules')\n        await self._complete_workitem()\n\n        await self._find_workitem('Call Alex in the afternoon')\n        await self._complete_workitem()\n\n    async def test_02_actions_visibility(self):\n        # noinspection PyTypeChecker\n        workitems_table: WorkitemTableView = self.window().findChild(WorkitemTableView, \"workitems_table\")\n\n        ############################################################\n        # Check actions on a new workitem with available pomodoros #\n        ############################################################\n        self.info('Check actions on a new workitem with available pomodoros')\n        await self._find_workitem('Deprecate StartRest strategy')\n        workitem: Workitem = workitems_table.get_current()\n        assert_that(workitem.get_name()).is_equal_to('Deprecate StartRest strategy')\n        assert_that(len(workitem)).is_equal_to(2)\n        for p in workitem.values():\n            assert_that(p.is_startable()).is_true()\n\n        # All actions:\n        # application.settings\n        # application.quit\n        # application.import\n        # application.export\n        # application.about\n        # backlogs_table.newBacklog\n        # backlogs_table.renameBacklog\n        # backlogs_table.deleteBacklog\n        # workitems_table.newItem\n        # workitems_table.renameItem\n        # workitems_table.deleteItem\n        # workitems_table.startItem\n        # workitems_table.completeItem\n        # workitems_table.addPomodoro\n        # workitems_table.removePomodoro\n        # workitems_table.hideCompleted\n        # focus.voidPomodoro\n        # focus.nextPomodoro\n        # focus.completeItem\n        def assert_backlog_actions_enabled(new_from_incomplete: bool):\n            self.assert_actions_enabled([\n                'backlogs_table.newBacklog',\n                'backlogs_table.renameBacklog',\n                'backlogs_table.deleteBacklog',\n                'workitems_table.newItem',\n                'workitems_table.hideCompleted',\n            ])\n            if new_from_incomplete:\n                self.assert_actions_enabled(['backlogs_table.newBacklogFromIncomplete'])\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_enabled([\n            'workitems_table.renameItem',\n            'workitems_table.deleteItem',\n            'workitems_table.startItem',\n            'workitems_table.completeItem',\n            'workitems_table.addPomodoro',\n            'workitems_table.removePomodoro',\n        ])\n        self.assert_actions_disabled([\n            'focus.voidPomodoro',\n        ])\n\n        #########################################################\n        # Check actions on a new workitem without any pomodoros #\n        #########################################################\n        self.info('Check actions on a new workitem without any pomodoros')\n        await self._find_workitem('Workitem 51')\n        workitem = workitems_table.get_current()\n        assert_that(workitem.get_name()).is_equal_to('Workitem 51')\n        assert_that(len(workitem)).is_equal_to(0)\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_enabled([\n            'workitems_table.renameItem',\n            'workitems_table.deleteItem',\n            'workitems_table.completeItem',\n            'workitems_table.addPomodoro',\n            'workitems_table.startItem',\n        ])\n        self.assert_actions_disabled([\n            'workitems_table.removePomodoro',\n            'focus.voidPomodoro',\n        ])\n\n        ###############################################################\n        # Check actions on a new workitem without available pomodoros #\n        ###############################################################\n        self.info('Check actions on a new workitem without available pomodoros')\n        await self._find_workitem('Generate new screenshots')\n        workitem = workitems_table.get_current()\n        assert_that(workitem.get_name()).is_equal_to('Generate new screenshots')\n        assert_that(len(workitem)).is_equal_to(3)\n        for p in workitem.values():\n            assert_that(p.is_startable()).is_false()\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_enabled([\n            'workitems_table.renameItem',\n            'workitems_table.deleteItem',\n            'workitems_table.completeItem',\n            'workitems_table.addPomodoro',\n        ])\n        self.assert_actions_disabled([\n            'workitems_table.removePomodoro',\n            'workitems_table.startItem',\n            'focus.voidPomodoro',\n        ])\n\n        ########################################################################\n        # Check actions on a completed workitem, even with available pomodoros #\n        ########################################################################\n        self.info('Check actions on a completed workitem, even with available pomodoros')\n        await self._find_workitem('Slides for the demo')\n        workitem = workitems_table.get_current()\n        assert_that(workitem.get_name()).is_equal_to('Slides for the demo')\n        assert_that(len(workitem)).is_equal_to(3)\n        can_start = 0\n        for p in workitem.values():\n            if p.is_startable():\n                can_start += 1\n        assert_that(can_start).is_equal_to(1)\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_enabled([\n            'workitems_table.deleteItem',\n        ])\n        self.assert_actions_disabled([\n            'workitems_table.addPomodoro',\n            'workitems_table.completeItem',\n            'workitems_table.renameItem',\n            'workitems_table.removePomodoro',\n            'workitems_table.startItem',\n            'focus.voidPomodoro',\n        ])\n\n        #################################################\n        # Select another backlog to unselect a workitem #\n        #################################################\n        self.info('Select another backlog to unselect a workitem')\n        backlog_index = await self._select_backlog('Trip to Italy')\n        assert_that(backlog_index).is_greater_than(-1)\n        workitem = workitems_table.get_current()\n        assert_that(workitem, 'Selecting another backlog should deselect workitem').is_none()\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_disabled([\n            'workitems_table.deleteItem',\n            'workitems_table.addPomodoro',\n            'workitems_table.completeItem',\n            'workitems_table.renameItem',\n            'workitems_table.removePomodoro',\n            'workitems_table.startItem',\n            'focus.voidPomodoro',\n        ])\n\n        #######################################\n        # Check actions on a started workitem #\n        #######################################\n        self.info('Start a workitem to check actions visibility')\n        await self._find_workitem('Workitem 13')\n        await self._start_pomodoro()\n        await self._wait_mid_pomodoro()\n\n        assert_backlog_actions_enabled(True)\n        self.assert_actions_enabled([\n            'workitems_table.deleteItem',\n            'workitems_table.addPomodoro',\n            'workitems_table.completeItem',\n            'workitems_table.renameItem',\n            'workitems_table.removePomodoro',\n            'focus.voidPomodoro',\n        ])\n        self.assert_actions_disabled([\n            'workitems_table.startItem',\n        ])\n\n        self.info('Void pomodoro to reset the state')\n        await self._void_pomodoro()\n\n    async def test_03_renames(self):\n        # noinspection PyTypeChecker\n        backlogs_table: BacklogTableView = self.window().findChild(BacklogTableView, \"backlogs_table\")\n        # noinspection PyTypeChecker\n        workitems_table: WorkitemTableView = self.window().findChild(WorkitemTableView, \"workitems_table\")\n\n        ##################\n        # Rename backlog #\n        ##################\n        self.info('Rename backlog')\n\n        # First try it via Ctrl+R shortcut\n        backlog_index = await self._select_backlog('Long-term stuff')\n        assert_that(backlog_index).is_greater_than(-1)\n        self.keypress(Qt.Key.Key_R, True, backlogs_table)\n        await self.instant_pause()\n        self.type_text('Long-term items')\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_Enter, False)\n        await self.instant_pause()\n        backlog_index = await self._select_backlog('Long-term items')\n        assert_that(backlog_index, 'Backlog rename via Ctrl+R').is_greater_than(-1)\n\n        # Then via double-clicking\n        await self.mouse_doubleclick_row(backlogs_table, backlog_index)\n        self.type_text('Long-term tasks')\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_Enter, False)\n        await self.instant_pause()\n        backlog_index = await self._select_backlog('Long-term tasks')\n        assert_that(backlog_index, 'Backlog rename via double-click').is_greater_than(-1)\n\n        ###################\n        # Rename workitem #\n        ###################\n        self.info('Rename workitem')\n\n        # First try it via F6 shortcut\n        await self._find_workitem('Workitem 32')\n        self.keypress(Qt.Key.Key_F6, False, workitems_table)\n        await self.instant_pause()\n        self.type_text('Rename 32')\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_Enter, False)\n        await self.instant_pause()\n        assert_that(workitems_table.get_current().get_name(), 'Workitem rename via F6').is_equal_to('Rename 32')\n\n        # Then via double-clicking\n        await self.mouse_doubleclick_row(workitems_table, workitems_table.currentIndex().row(), 1)\n        self.type_text('Update 32')\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_Enter, False)\n        await self.instant_pause()\n        assert_that(workitems_table.get_current().get_name(), 'Workitem rename via double-click').is_equal_to('Update 32')\n\n    # async def test_04_ordering(self):\n    #     # Workitem order (oldest first)\n    #     # Backlog order (oldest last)\n    #     # Backlog order -- moves to the top if a workitem changes\n    #     pass\n    #\n    # async def test_05_filtering(self):\n    #     # Check that the \"hide completed workitems\" does what it should\n    #     pass\n    #\n    # async def test_06_focus(self):\n    #     # Check that the focus widget displays the way it should\n    #     # Also check all three focus modes\n    #     # Check \"Start another pomodoro?\"\n    #     pass\n    #\n    # async def test_07_tray_icon(self):\n    #     # Check that the tray icon displays what it should\n    #     pass\n    #\n    # async def test_08_menus(self):\n    #     # Check that all menus are accessible and contain expected list of actions\n    #     pass\n    #\n    # async def test_09_settings(self):\n    #     # Check that all menus are accessible and contain expected list of actions\n    #     pass\n    #\n    # # Other tests -- audio, websockets, authentication, fonts, reset\n    # # About, Import, Export, Tutorial, updates, connection widget\n    # # Heartbeat, reconnect\n    # # Error handling, auth errors\n    # # FS watcher\n    # # Users\n"
  },
  {
    "path": "src/fk/e2e/screenshot.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport logging\nimport os\nimport subprocess\nfrom typing import Callable\n\nfrom PIL import Image\nfrom PySide6.QtWidgets import QWidget\n\nfrom fk.tests.test_utils import randint\n\nlogger = logging.getLogger(__name__)\n\n\nclass Screenshot:\n    _method: Callable[[str], None]\n    _spectacle_works: bool\n\n    def __init__(self):\n        self._method = None\n        for m in [\n            Screenshot._take_scrot,\n            Screenshot._take_gnome_screenshot,\n            Screenshot._take_xfce4_screenshooter,\n            Screenshot._take_imagemagick1,\n            Screenshot._take_imagemagick2,\n            Screenshot._take_xwd,\n            Screenshot._take_flameshot,\n            Screenshot._take_ksnip,\n            Screenshot._take_spectacle,\n            Screenshot._take_nircmd,\n            Screenshot._take_powershell,\n            Screenshot._take_screencapture,\n        ]:\n            if Screenshot._check_method(m):\n                self._method = m\n                break\n        self._spectacle_works = self._check_method(Screenshot._take_spectacle_active)\n\n    @staticmethod\n    def _check_method(method: Callable[[str], None]) -> bool:\n        file = f'test-results/check-{randint(100000, 999999)}.png'\n        try:\n            method(file)\n            im = Image.open(file)\n            if im.getbbox():\n                logger.info(f'Screenshot method {method.__name__} works')\n                return True\n            else:\n                logger.warning(f'Taking screenshot via {method.__name__} resulted in an empty image')\n        except Exception as e:\n            logger.warning(f'Taking screenshot via {method.__name__} failed')\n        finally:\n            if os.path.exists(file):\n                os.unlink(file)\n        return False\n\n    def take_screen(self, name: str) -> str:\n        if self._method is None:\n            logger.warning(f'Tried to take screenshot {name}, but couldn\\'t find how to do it')\n        else:\n            filename = f'test-results/{name}-full.png'\n            logger.debug(f'Taking full-screen screenshot {filename} using {self._method.__name__}')\n            return self._method(filename)\n\n    def take_window(self, name: str, window: QWidget) -> str:\n        # First take a screenshot using Qt built-in feature -- this always works, but it\n        # can't capture window's border.\n        filename = f'test-results/{name}-window.png'\n        logger.debug(f'Taking screenshot {filename} of window {window.objectName()} using Qt')\n        window.grab().save(filename)\n\n        # Then, if Spectacle is available, take another one with a nice shadow border.\n        # Even on Linux this only works with XOrg, but _check_method() should take care of it.\n        if self._spectacle_works:\n            filename = f'test-results/{name}-window-border.png'\n            logger.debug(f'Taking screenshot {filename} of window {window.objectName()} using Spectacle')\n            self._take_spectacle_active(filename)\n\n    @staticmethod\n    def _take_scrot(filename: str) -> None:\n        subprocess.run([\"scrot\",\n                        \"--silent\",\n                        \"--overwrite\",\n                        filename])\n\n    @staticmethod\n    def _take_imagemagick1(filename: str) -> None:\n        subprocess.run([\"import\",\n                        \"-window\",\n                        \"root\",\n                        filename])\n\n    @staticmethod\n    def _take_imagemagick2(filename: str) -> None:\n        subprocess.run([\"magick\",\n                        \"import\",\n                        \"-window\",\n                        \"root\",\n                        filename])\n\n    @staticmethod\n    def _take_gnome_screenshot(filename: str) -> None:\n        subprocess.run([\"gnome-screenshot\",\n                        \"-f\",\n                        filename])\n\n    @staticmethod\n    def _take_flameshot(filename: str) -> None:\n        with open(filename, \"w\") as file:\n            subprocess.run([\"flameshot\",\n                        \"screen\",\n                        \"-r\"], stdout=file)\n\n    @staticmethod\n    def _take_xwd(filename: str) -> None:\n        subprocess.run([\"xwd\",\n                        \"-root\",\n                        \"-out\",\n                        filename])\n        subprocess.run([\"convert\",\n                        f\"xwd:{filename}\",\n                        filename])\n\n    @staticmethod\n    def _take_xfce4_screenshooter(filename: str) -> None:\n        subprocess.run([\"xfce4-screenshooter\",\n                        \"--fullscreen\",\n                        \"--save\",\n                        filename])\n\n    @staticmethod\n    def _take_ksnip(filename: str) -> None:\n        subprocess.run([\"ksnip\",\n                        \"--fullscreen\",\n                        \"--saveto\",\n                        filename])\n\n    @staticmethod\n    def _take_spectacle(filename: str) -> None:\n        subprocess.run([\"spectacle\",\n                        \"--fullscreen\",\n                        \"--background\",\n                        \"--nonotify\",\n                        \"--output\",\n                        filename])\n\n    @staticmethod\n    def _take_spectacle_active(filename: str) -> None:\n        subprocess.run([\"spectacle\",\n                        \"--activewindow\",\n                        \"--background\",\n                        \"--nonotify\",\n                        \"--output\",\n                        filename])\n\n    @staticmethod\n    def _take_screencapture(filename: str) -> None:\n        subprocess.run([\"screencapture\",\n                        filename])\n\n    @staticmethod\n    def _take_nircmd(filename: str) -> None:\n        subprocess.run([\"nircmdc\",\n                        \"savescreenshot\",\n                        filename])\n\n    @staticmethod\n    def _take_powershell(filename: str, window_id: int | None = None) -> None:\n        ps = f'''\n            [Reflection.Assembly]::LoadWithPartialName(\"System.Drawing\")\n            function screenshot([Drawing.Rectangle]$bounds, $path) {{\n               $bmp = New-Object Drawing.Bitmap $bounds.width, $bounds.height\n               $graphics = [Drawing.Graphics]::FromImage($bmp)\n            \n               $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.size)\n            \n               $bmp.Save($path)\n            \n               $graphics.Dispose()\n               $bmp.Dispose()\n            }}\n            \n            $bounds = [Drawing.Rectangle]::FromLTRB(0, 0, 2880, 1800)\n            screenshot $bounds \"{filename}\"\n        '''\n        subprocess.run([\"powershell\",\n                        \"-Command\",\n                        ps])\n\n\nif __name__ == \"__main__\":\n    s = Screenshot()\n    s.take_screen('test')\n"
  },
  {
    "path": "src/fk/e2e/screenshots_e2e.py",
    "content": "import asyncio\nimport datetime\nimport os\n\nfrom PySide6.QtCore import Qt, QPoint, QSize\nfrom PySide6.QtWidgets import QTabWidget, QComboBox, QLineEdit, QCheckBox, QPushButton, QTableWidget\n\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.interruption import Interruption\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL\nfrom fk.core.pomodoro_strategies import AddInterruptionStrategy\nfrom fk.core.timer_strategies import StartTimerStrategy, StopTimerStrategy\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CompleteWorkitemStrategy\nfrom fk.desktop.application import Application\nfrom fk.e2e.abstract_e2e_test import AbstractE2eTest, WINDOW_GALLERY_FILENAME, FULLSCREEN_GALLERY_FILENAME, \\\n    WINDOW_BORDER_GALLERY_FILENAME\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.search_completer import SearchBar\nfrom fk.qt.workitem_tableview import WorkitemTableView\nfrom fk.tests.test_utils import random\n\nTEMP_FILENAME = './screenshots-e2e.txt'\nPOMODORO_WORK_DURATION = 3  # seconds\nPOMODORO_REST_DURATION = 3  # seconds\n\n\nclass ScreenshotE2eTest(AbstractE2eTest):\n    def __init__(self, app: Application):\n        super().__init__(app)\n\n    def setup(self) -> None:\n        if os.path.isfile(WINDOW_GALLERY_FILENAME):\n            os.unlink(WINDOW_GALLERY_FILENAME)\n        if os.path.isfile(WINDOW_BORDER_GALLERY_FILENAME):\n            os.unlink(WINDOW_BORDER_GALLERY_FILENAME)\n        if os.path.isfile(FULLSCREEN_GALLERY_FILENAME):\n            os.unlink(FULLSCREEN_GALLERY_FILENAME)\n\n    def custom_settings(self) -> dict[str, str]:\n        custom = {\n            'FileEventSource.filename': TEMP_FILENAME,\n            'Application.show_tutorial': 'False',\n            'Application.show_window_title': 'False',\n            'Application.check_updates': 'False',\n            'Pomodoro.long_break_algorithm': 'never',\n            'Pomodoro.default_work_duration': str(POMODORO_WORK_DURATION),\n            'Pomodoro.default_rest_duration': str(POMODORO_REST_DURATION),\n            'Application.play_alarm_sound': 'False',\n            'Application.play_rest_sound': 'False',\n            'Application.play_tick_sound': 'False',\n            'Logger.filename': 'backlog-e2e.log',\n            'Logger.level': 'DEBUG',\n            'Application.window_height': '680',\n            'Application.window_splitter_width': '260',\n            'Application.window_width': '820',\n            'Application.theme': 'mixed',\n            'Application.tray_icon_flavor': 'thin-dark',\n            #'Application.last_version': self.get_application()._current_version,\n            'Application.last_version': '0.0.1',\n            'Integration.callbacks': '{\"FileEventSource.AfterBacklogCreate\": '\n                                     '\"echo \\\\\"Created backlog {backlog.get_uid()}\\\\\"\"}',\n            'Application.show_click_here_hint': 'True',\n        }\n        if os.name == 'nt':\n            custom['Application.font_main_size'] = '10'\n            custom['Application.font_header_family'] = 'Segoe UI Light'\n        return custom\n\n    def teardown(self) -> None:\n        super().teardown()\n        os.unlink(TEMP_FILENAME)\n\n    async def _new_backlog(self, name: str) -> None:\n        self.keypress(Qt.Key.Key_N, True)   # self.execute_action('backlogs_table.newBacklog')\n        await self.instant_pause()\n        self.type_text(name)\n        self.keypress(Qt.Key.Key_Enter)\n        await self.instant_pause()\n\n    async def _start_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_S, True)   # self.execute_action('workitems_table.startItem')\n        await self.instant_pause()\n\n    async def _wait_pomodoro_complete(self) -> None:\n        await asyncio.sleep(POMODORO_WORK_DURATION)\n        await asyncio.sleep(POMODORO_REST_DURATION)\n        await self.instant_pause()\n\n    async def _wait_mid_pomodoro(self) -> None:\n        await asyncio.sleep(POMODORO_WORK_DURATION * 0.75)\n\n    async def _wait_long_pomodoro(self) -> None:\n        await asyncio.sleep(15)\n\n    async def _complete_workitem(self, name: str) -> None:\n        source = self.get_application().get_source_holder().get_source()\n        for w in source.workitems():\n            if w.get_name() == name:\n                source.execute(CompleteWorkitemStrategy, [w.get_uid(), \"finished\"])\n                await self.instant_pause()\n\n    async def _void_pomodoro(self, name: str) -> None:\n        source = self.get_application().get_source_holder().get_source()\n        for w in source.workitems():\n            if w.get_name() == name:\n                source.execute(AddInterruptionStrategy, [w.get_uid(), f'Pomodoro voided'])\n                source.execute(StopTimerStrategy, [])\n                await self.instant_pause()\n\n    async def _stop_tracking(self) -> None:\n        self.keypress(Qt.Key.Key_S, True)\n        await self.instant_pause()\n\n    async def _add_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_Plus, True)  # self.execute_action('workitems_table.addPomodoro')\n        await self.instant_pause()\n\n    async def _remove_pomodoro(self) -> None:\n        self.keypress(Qt.Key.Key_Minus, True)  # self.execute_action('workitems_table.removePomodoro')\n        await self.instant_pause()\n\n    async def _new_workitem(self, name: str, pomodoros: int = 0) -> None:\n        self.keypress(Qt.Key.Key_Insert)   # self.execute_action('workitems_table.newItem')\n        await self.instant_pause()\n        self.type_text(name)\n        self.keypress(Qt.Key.Key_Enter)\n        await self.instant_pause()\n        for p in range(pomodoros):\n            await self._add_pomodoro()\n\n    async def _find_workitem(self, name: str) -> None:\n        self.keypress(Qt.Key.Key_F, True)   # self.execute_action('window.showSearch')\n        await self.instant_pause()\n        self.type_text(name)\n        await self.instant_pause()\n        # noinspection PyTypeChecker\n        search: SearchBar = self.window().findChild(SearchBar, \"search\")\n        completer = search.completer()\n        popup = completer.popup()\n        self.keypress(Qt.Key.Key_Down, False, popup)\n        self.keypress(Qt.Key.Key_Enter, False, popup)\n        await self.instant_pause()\n\n    async def _select_backlog(self, name: str) -> int:\n        main_window = self.window()\n        # noinspection PyTypeChecker\n        backlogs_table: BacklogTableView = main_window.findChild(BacklogTableView, \"backlogs_table\")\n        backlogs_model = backlogs_table.model()\n        for i in range(backlogs_model.rowCount()):\n            if backlogs_model.index(i, 0).data() == name:\n                await self.mouse_click_row(backlogs_table, i)\n                return i\n        return -1\n\n    async def _select_tag(self, name: str) -> bool:\n        main_window = self.window()\n        # noinspection PyTypeChecker\n        tag_widget: QPushButton = main_window.findChild(QPushButton, f\"#{name.lower()}\")\n        if tag_widget is not None:\n            tag_widget.click()\n            return True\n        else:\n            return False\n\n    async def test_01_screenshots(self):\n        await self.instant_pause()\n        await self._wait_mid_pomodoro()\n        await self._wait_mid_pomodoro()\n        await self._wait_mid_pomodoro()\n        self.take_screenshot('26-focus-window-types')\n        # self.click_button(name='__qt__passive_wizardbutton1')\n        self.click_button(name='qt_wizard_commit')\n        await self.instant_pause()\n        self.take_screenshot('27-tray-icon-types')\n        self.click_button(name='qt_wizard_finish')\n        await self.instant_pause()\n\n        self.get_application().get_settings().set({'Application.show_click_here_hint': 'False'})\n        await self.instant_pause()\n\n        main_window = self.window()\n        self.center_window()\n        backlogs_table: BacklogTableView = main_window.findChild(BacklogTableView, \"backlogs_table\")\n        workitems_table: WorkitemTableView = main_window.findChild(WorkitemTableView, \"workitems_table\")\n\n        ################################################################\n        # Create a bunch of test backlogs and fill them with workitems #\n        ################################################################\n        await self._new_backlog('Trip to Italy')\n\n        await self._new_backlog('House renovation')\n        await self._new_backlog('Long-term stuff')\n        await self._new_backlog('2024-03-12, Tuesday')\n        await self._new_backlog('2024-03-13, Wednesday')\n        await self._new_backlog('2024-03-14, Thursday')\n\n        self._generate_pomodoros_for_stats()\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_F9)\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n        self.take_screenshot('13-stats-week')\n        self.keypress(Qt.Key.Key_M, True)\n        await self.instant_pause()\n        self.take_screenshot('14-stats-month')\n        self.keypress(Qt.Key.Key_Y, True)\n        await self.instant_pause()\n        self.take_screenshot('15-stats-year')\n        self.keypress(Qt.Key.Key_Escape)\n        await self.instant_pause()\n\n        self.get_application().get_settings().set({'Pomodoro.long_break_algorithm': 'simple', 'Pomodoro.start_next_automatically': 'True'})\n        await self.instant_pause()\n\n        self.keypress(Qt.Key.Key_F10)\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n        settings_tabs: QTabWidget = self.window().findChild(QTabWidget, \"settings_tabs\")\n        settings_tabs.setCurrentIndex(2)\n        await self.instant_pause()\n        data_file_edit: QLineEdit = self.window().findChild(QLineEdit, \"FileEventSource.filename-edit\")\n        data_file_edit.selectAll()\n        await self.instant_pause()\n        self.take_screenshot('03-settings-connection-offline')\n\n        settings_tabs.setCurrentIndex(1)\n        await self.instant_pause()\n        series_check: QCheckBox = self.window().findChild(QCheckBox, \"Pomodoro.start_next_automatically\")\n        series_check.setChecked(True)\n        await self.instant_pause()\n        self.take_screenshot('04-settings-long-breaks')\n        series_check.setChecked(False)\n        await self.instant_pause()\n\n        settings_tabs.setCurrentIndex(5)\n        await self.instant_pause()\n        sound_alarm_check: QCheckBox = self.window().findChild(QCheckBox, \"Application.play_alarm_sound\")\n        sound_alarm_check.setChecked(True)\n        await self.instant_pause()\n        sound_alarm_check: QCheckBox = self.window().findChild(QCheckBox, \"Application.play_rest_sound\")\n        sound_alarm_check.setChecked(True)\n        await self.instant_pause()\n        sound_file_edit: QLineEdit = self.window().findChild(QLineEdit, \"Application.alarm_sound_file-edit\")\n        sound_file_edit.selectAll()\n        await self.instant_pause()\n        self.take_screenshot('07-settings-audio')\n        sound_alarm_check.setChecked(False)\n        await self.instant_pause()\n\n        settings_tabs.setCurrentIndex(6)\n        self.window().setFixedWidth(800)\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n        integration_callbacks: QTableWidget = self.window().findChild(QTableWidget, \"Integration.callbacks\")\n        integration_callbacks.selectRow(6)\n        await self.instant_pause()\n        self.take_screenshot('21-settings-integration')\n\n        self.keypress(Qt.Key.Key_Escape)\n        await self.instant_pause()\n\n        self.get_application().get_settings().set({'Pomodoro.long_break_algorithm': 'never', 'Pomodoro.start_next_automatically': 'False'})\n        await self.instant_pause()\n\n        await self._new_workitem('Generate new screenshots for #Flowkeeper', 2)\n        await self._new_workitem('Reply to Peter', 1)\n        await self._new_workitem('Slides for #Flowkeeper demo', 3)\n        await self._new_workitem('#Flowkeeper: Deprecate StartRest strategy', 2)\n        await self._new_workitem('#Flowkeeper: Auto-seal in the web frontend', 2)\n        await self._new_workitem('#Followup: Call Alex in the afternoon')\n\n        ####################################\n        # Complete pomodoros and workitems #\n        ####################################\n        await self._find_workitem('Generate new screenshots for #Flowkeeper')\n        await self._start_pomodoro()\n        self.center_window()\n        await self.instant_pause()\n\n        self.take_screenshot('02-pomodoro')\n\n        await self._wait_pomodoro_complete()\n        self.center_window()\n        await self.instant_pause()\n        await self._start_pomodoro()\n        await self._wait_pomodoro_complete()\n        await self._add_pomodoro()\n        await self._start_pomodoro()\n        await self._wait_pomodoro_complete()\n\n        await self._find_workitem('Reply to Peter')\n        await self._start_pomodoro()\n        await self._wait_pomodoro_complete()\n        await self._add_pomodoro()\n        await self._start_pomodoro()\n        await self._wait_mid_pomodoro()\n        await self._void_pomodoro('Reply to Peter')\n        await self._complete_workitem('Reply to Peter')\n        await self.longer_pause()\n\n        # Demo the tracker items -- start another WI in the past\n        await self._find_workitem('#Followup: Call Alex in the afternoon')\n        await self._start_pomodoro()\n        await self._wait_long_pomodoro()\n        source = self.get_application().get_source_holder().get_source()\n        source.execute(StopTimerStrategy, [])\n        await self.longer_pause()\n\n        await self._new_workitem('Order coffee capsules')\n        await self._find_workitem('Order coffee capsules')\n        await self._complete_workitem('Order coffee capsules')\n\n        await self._find_workitem('Slides for #Flowkeeper demo')\n        await self._start_pomodoro()\n        await self._wait_mid_pomodoro()\n        await self._void_pomodoro('Slides for #Flowkeeper demo')\n\n        # Tags\n        await self._select_tag('Flowkeeper')\n        await self.instant_pause()\n        self.take_screenshot('20-tags')\n        await self.instant_pause()\n        await self._find_workitem('Slides for #Flowkeeper demo')\n        await self.instant_pause()\n\n        # Take two \"main\" screenshots right in the middle of this pomodoro\n        settings = self.get_application().get_settings()\n        old_value_work = settings.get('Pomodoro.default_work_duration')\n        old_value_rest = settings.get('Pomodoro.default_rest_duration')\n        old_value_style = settings.get('Application.timer_ui_mode')\n        old_value_theme = settings.get('Application.theme')\n        old_gradient = settings.get('Application.eyecandy_gradient')\n        settings.set({\n            'Pomodoro.default_work_duration': '1500',\n            'Pomodoro.default_rest_duration': '300',\n            'Application.timer_ui_mode': 'keep',\n            'Application.theme': 'light',\n            'Application.eyecandy_gradient': 'OverSun',\n        })\n\n        # Start a Pomodoro in the past\n        source = self.get_application().get_source_holder().get_source()\n        workitem_id = None\n        for w in source.workitems():\n            if w.get_name() == 'Slides for #Flowkeeper demo':\n                workitem_id = w.get_uid()\n        source.execute_prepared_strategy(StartTimerStrategy(\n            1,\n            datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=670),\n            'user@local.host',\n            [workitem_id, '1500', '300'],\n            settings))\n        await self._wait_mid_pomodoro()\n        await self._wait_mid_pomodoro()\n        await self._wait_mid_pomodoro()\n        await self._wait_mid_pomodoro()\n\n        self.take_screenshot('18-main-light')\n\n        settings.set({\n            'Application.theme': 'dark',\n            'Application.eyecandy_gradient': old_gradient,\n        })\n        await self.longer_pause()\n        self.take_screenshot('19-main-dark')\n\n        await self._void_pomodoro('Slides for #Flowkeeper demo')\n        await self._complete_workitem('Slides for #Flowkeeper demo')\n\n        settings.set({\n            'Pomodoro.default_work_duration': old_value_work,\n            'Pomodoro.default_rest_duration': old_value_rest,\n            'Application.timer_ui_mode': old_value_style,\n            'Application.theme': old_value_theme,\n            'Application.eyecandy_gradient': old_gradient,\n        })\n        await self.longer_pause()\n\n        await self._find_workitem('Generate new screenshots for #Flowkeeper')\n\n        backlogs_table._menu.popup(backlogs_table.mapToGlobal(QPoint(100, 300)))\n        await self.instant_pause()\n        self.take_screenshot('01-backlog')\n        backlogs_table._menu.close()\n\n        self.keypress(Qt.Key.Key_F10)\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n\n        shortcuts_dropdown: QComboBox = self.window().findChild(QComboBox, \"Application.shortcuts-list\")\n        shortcuts_dropdown.setCurrentIndex(11)  # \"New item\"\n        await self.instant_pause()\n\n        workitems_table._menu.popup(workitems_table.mapToGlobal(QPoint(400, 20)))\n        await self.instant_pause()\n        self.take_screenshot('06-shortcuts')\n        workitems_table._menu.close()\n\n        self.keypress(Qt.Key.Key_Escape)\n        await self.instant_pause()\n\n        # Import -- all, file, CSV, GitHub\n        for i in range(3):\n            self.keypress(Qt.Key.Key_I, True)\n            await self.instant_pause()\n            self.center_window()\n            await self.instant_pause()\n            if i == 0:\n                self.take_screenshot('11-import')\n            if i == 1:\n                self.check_radiobutton(text='Import from CSV')\n            elif i == 2:\n                self.check_radiobutton(name='Import from GitHub')\n            await self.instant_pause()\n            self.click_button(name='qt_wizard_commit')\n            await self.instant_pause()\n            if i == 0:\n                self.take_screenshot('23-import-file')\n            elif i == 1:\n                self.take_screenshot('24-import-CSV')\n            elif i == 2:\n                self.take_screenshot('25-import-GitHub')\n            self.keypress(Qt.Key.Key_Escape)\n            await self.instant_pause()\n\n        self.keypress(Qt.Key.Key_E, True)\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n        self.keypress(Qt.Key.Key_Enter)\n        await self.instant_pause()\n        self.take_screenshot('12-export')\n        self.keypress(Qt.Key.Key_Escape)\n        await self.instant_pause()\n\n        self.keypress(Qt.Key.Key_F3)\n        await self.longer_pause()\n        self.take_screenshot('16-work-summary')\n        self.keypress(Qt.Key.Key_Escape)\n        await self.instant_pause()\n\n        # Themes\n        self.get_application().get_settings().set({\n            'Application.theme': 'dark',\n            'Application.eyecandy_type': 'default',\n        })\n        await self.longer_pause()\n        self.take_screenshot('08-dark-theme')\n\n        self.get_application().get_settings().set({\n            'Application.theme': 'light',\n        })\n        await self.longer_pause()\n        self.take_screenshot('09-light-theme')\n\n        self.get_application().get_settings().set({\n            'Application.theme': 'dark',\n            'Application.eyecandy_type': 'image',\n            'Application.eyecandy_image': ':/img/bg.jpg',\n            'Application.font_header_family': 'Quicksand Light',\n            'Application.font_header_size': '28',\n            'Application.font_main_family': 'Quicksand',\n            'Application.font_main_size': '10',\n            'Application.show_toolbar': 'True',\n            'Application.show_left_toolbar': 'False',\n        })\n        await self.longer_pause()\n        self.keypress(Qt.Key.Key_B, True)\n        await self.instant_pause()\n        self.window().resize(QSize(400, 400))\n        await self.instant_pause()\n        self.center_window()\n        await self.instant_pause()\n        self.take_screenshot('10-customized')\n\n    def _generate_pomodoros_for_stats(self):\n        source = self.get_application().get_source_holder().get_source()\n        for b in source.backlogs():\n            if b.get_name() == '2024-03-13, Wednesday':\n                now = datetime.datetime.now(datetime.timezone.utc)\n                uid = generate_uid()\n                workitem = Workitem('Huge', uid, b, now)\n                b[uid] = workitem\n                self._emulate_year(workitem, now - datetime.timedelta(days=365))\n\n    def _emulate_year(self, workitem: Workitem, start_date: datetime.datetime):\n        for day in range(365):\n            now = start_date + datetime.timedelta(days=day)\n            now = datetime.datetime(now.year, now.month, now.day, 0, 0, 0)\n            self._emulate_day(workitem, now, day)\n\n    def _emulate_day(self, workitem: Workitem, start_date: datetime.datetime, day: int):\n        weekday = start_date.weekday()\n        if weekday < 5 or random() < 0.05:\n            avg_pomos = 10 + round(day / 100) - weekday\n            num_pomos = round(avg_pomos * (1 + (random() - 0.5) / 5))\n            now = start_date + datetime.timedelta(minutes=round(60 * 7 + random() * 180))\n            for p in range(num_pomos):\n                uid = generate_uid()\n                state_selector = random()\n                num_interruptions = 0\n                if state_selector < 0.1 + (365 - day) / 1200:\n                    state = 'new'\n                    num_interruptions = random() * 3\n                elif state_selector < 0.5 + day / 900:\n                    state = 'finished'\n                else:\n                    state = 'new'\n                workitem[uid] = Pomodoro(p + 1, True, state, 25 * 60, 5 * 60, POMODORO_TYPE_NORMAL, uid, workitem, now)\n                for _ in range(round(num_interruptions)):\n                    int_uid = generate_uid()\n                    workitem[uid][int_uid] = Interruption(\"Pomodoro voided\" if random() < 0.5 else None, None, False, int_uid, workitem[uid], now)\n                now = now + datetime.timedelta(minutes=round(random() * 20))\n"
  },
  {
    "path": "src/fk/qt/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/qt/about_window.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\n\nfrom PySide6 import QtUiTools\nfrom PySide6.QtCore import QFile, QObject\nfrom PySide6.QtGui import QPalette\nfrom PySide6.QtWidgets import QWidget, QLabel, QTextEdit, QDialog\n\nfrom fk.core.abstract_timer import AbstractTimer\nfrom fk.qt.app_version import get_current_version\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.render.minimal_timer_renderer import MinimalTimerRenderer\n\n\nclass AboutWindow(QObject):\n    _about_window: QDialog\n    _timer_display: MinimalTimerRenderer\n    _timer: AbstractTimer\n    _tick: int\n\n    def __init__(self, parent: QWidget | None):\n        super().__init__(parent)\n        self._timer_display = None\n        self._timer = QtTimer('About window', self)\n        self._tick = 299\n\n        file = QFile(\":/about.ui\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        # noinspection PyTypeChecker\n        self._about_window = QtUiTools.QUiLoader().load(file, parent)\n        file.close()\n\n    def show(self):\n        # noinspection PyTypeChecker\n        about_version: QLabel = self._about_window.findChild(QLabel, \"version\")\n        about_version.setText(str(get_current_version()))\n\n        # noinspection PyTypeChecker\n        about_changelog: QTextEdit = self._about_window.findChild(QTextEdit, \"notes\")\n        file = QFile(\":/CHANGELOG.txt\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        about_changelog.setMarkdown(file.readAll().toStdString())\n        file.close()\n\n        # noinspection PyTypeChecker\n        about_credits: QTextEdit = self._about_window.findChild(QTextEdit, \"credits\")\n        file = QFile(\":/CREDITS.txt\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        about_credits.setMarkdown(file.readAll().toStdString())\n        file.close()\n\n        # noinspection PyTypeChecker\n        about_license: QTextEdit = self._about_window.findChild(QTextEdit, \"license\")\n        file = QFile(\":/LICENSE.txt\")\n        file.open(QFile.OpenModeFlag.ReadOnly)\n        about_license.setMarkdown(file.readAll().toStdString())\n        file.close()\n\n        # noinspection PyTypeChecker\n        about_icon: QLabel = self._about_window.findChild(QLabel, \"icon\")\n        about_icon.setFixedWidth(150)\n        about_icon.setFixedHeight(150)\n        bg_color = about_icon.palette().color(QPalette.ColorRole.Base)\n        fg_color = about_icon.palette().color(QPalette.ColorRole.Text)\n        self._timer_display = MinimalTimerRenderer(about_icon,\n                                                   bg_color,\n                                                   fg_color)\n        about_icon.installEventFilter(self._timer_display)\n        self._timer_display.setObjectName('AboutWindowRenderer')\n        self._timer_display.reset()\n        self._timer.schedule(100, self._handle_tick, None)\n        self._handle_tick(None, None)\n\n        self._about_window.rejected.connect(lambda: self._timer.cancel())\n\n        self._about_window.show()\n\n    def _handle_tick(self, params: dict | None, when: datetime.datetime | None = None) -> None:\n        self._timer_display.set_values(self._tick, 300, None, None, 'working')\n        self._timer_display.repaint()\n        self._tick -= 1\n        if self._tick < 0:\n            self._tick = 299\n"
  },
  {
    "path": "src/fk/qt/abstract_drop_model.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport logging\nfrom abc import abstractmethod\n\nfrom PySide6 import QtCore\nfrom PySide6.QtCore import Qt, QMimeData, QModelIndex, QSize\nfrom PySide6.QtGui import QStandardItem, QStandardItemModel, QColor\nfrom PySide6.QtWidgets import QMessageBox\n\nfrom fk.core.abstract_data_container import AbstractDataContainer\nfrom fk.core.abstract_data_item import AbstractDataItem\nfrom fk.core.abstract_serializer import sanitize_user_input\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.event_source_holder import EventSourceHolder\n\nlogger = logging.getLogger(__name__)\n\n\nclass DropPlaceholderItem(QStandardItem):\n    def __init__(self, original: AbstractDataItem, original_display: str, original_index: int):\n        super().__init__()\n        self.setData(original, 500)\n        self.setData('drop', 501)\n        self.setData(original_index, 502)\n        self.setData(original_display, Qt.ItemDataRole.DisplayRole)\n        self.setData(QColor('gray'), Qt.ItemDataRole.ForegroundRole)\n        self.setFlags(Qt.ItemFlag.ItemIsSelectable |\n                      Qt.ItemFlag.ItemIsEnabled |\n                      Qt.ItemFlag.ItemIsDropEnabled)\n\n\nclass AbstractDropModel(QStandardItemModel):\n    _source_holder: EventSourceHolder\n    dragging: QModelIndex | None\n\n    def __init__(self,\n                 columns: int,\n                 parent: QtCore.QObject,\n                 source_holder: EventSourceHolder):\n        super().__init__(0, columns, parent)\n        self._source_holder = source_holder\n        self.dragging = None\n\n    def supportedDropActions(self) -> Qt.DropAction:\n        return Qt.DropAction.LinkAction\n\n    def supportedDragActions(self) -> Qt.DropAction:\n        return Qt.DropAction.LinkAction\n\n    def move_drop_placeholder(self, index: QModelIndex | None):\n        if index is None:\n            # \"Commit\"\n            for i in range(self.rowCount()):\n                if self.item(i).data(501) == 'drop':\n                    original_item: AbstractDataItem = self.item(i).data(500)\n                    value = self.item_for_object(original_item)\n                    for j in range(len(value)):\n                        self.setItem(i, j, value[j])\n                    break\n        elif index.isValid():\n            if index.data(501) == 'drop':\n                return\n            else:\n                for i in range(self.rowCount()):\n                    if self.item(i).data(501) == 'drop':\n                        drop_placeholder_item = self.takeRow(i)\n                        self.insertRow(index.row(), drop_placeholder_item)\n                        return\n                for j in range(self.columnCount()):\n                    this = self.index(index.row(), j)\n                    self.setItem(index.row(), j, DropPlaceholderItem(this.data(500),\n                                                                     this.data(Qt.ItemDataRole.DisplayRole),\n                                                                     index.row()))\n\n    def restore_order(self) -> int:\n        for i in range(self.rowCount()):\n            if self.item(i).data(501) == 'drop':\n                original_item: AbstractDataItem = self.item(i).data(500)\n                original_index = self.item(i).data(502)\n                self.takeRow(i)\n                self.insertRow(original_index, self.item_for_object(original_item))\n                return original_index\n\n    def dropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, where: QModelIndex):\n        logger.debug(f'Dropping {data.formats()} at {row} / {where.row()}')\n        if data.hasFormat(self.get_primary_type()) and where.isValid():\n            # Our own item\n            from_index = self.dragging.row()\n            to_index = where.row()\n            self.move_drop_placeholder(None)\n            if from_index == to_index:\n                return False\n            else:\n                self.reorder(to_index if to_index < from_index else to_index + 1,\n                             data.data(self.get_primary_type()).toStdString())\n                return True\n        elif self.get_secondary_type() is not None and data.hasFormat(self.get_secondary_type()) and where.isValid():\n            # Foreign item\n            return self.adopt_foreign_item(where.data(500),\n                                           data.data(self.get_secondary_type()).toStdString())\n        else:\n            # Something unexpected -- reject and restore to original state\n            self.restore_order()\n            logger.debug('Dropping something unexpected -- restoring original order, just in case')\n            return False\n\n    @abstractmethod\n    def get_primary_type(self) -> str:\n        pass\n\n    def get_secondary_type(self) -> str | None:\n        return None\n\n    @abstractmethod\n    def reorder(self, to_index: int, uid: str):\n        pass\n\n    def adopt_foreign_item(self, target: AbstractDataItem, uid: str) -> bool:\n        return False\n\n    def handle_rename(self, item: QStandardItem, strategy_class: type[AbstractStrategy]) -> None:\n        if item.data(501) == 'title':\n            entity: AbstractDataContainer = item.data(500)\n            old_name = entity.get_name()\n            new_name = sanitize_user_input(item.text())\n            if old_name != new_name:\n                try:\n                    self._source_holder.get_source().execute(strategy_class, [entity.get_uid(), new_name])\n                except Exception as e:\n                    logger.error(f'Failed to rename {old_name} to {new_name}', exc_info=e)\n                    item.setText(old_name)\n                    QMessageBox().warning(\n                        None,\n                        \"Cannot rename\",\n                        str(e),\n                        QMessageBox.StandardButton.Ok\n                    )\n\n    def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, where: QModelIndex):\n        return where.isValid() and where.data(501) == 'drop'\n\n    def mimeTypes(self):\n        return [self.get_primary_type()] if self.get_secondary_type() is None \\\n            else [self.get_primary_type(), self.get_secondary_type()]\n\n    def mimeData(self, indexes):\n        if len(indexes) != 1:\n            raise Exception(f'Unexpected number of rows to move: {len(indexes)}')\n        index = indexes[0]\n        self.dragging = index\n        data = QMimeData()\n        item: AbstractDataItem = index.data(500)\n        data.setData(self.get_primary_type(), bytes(item.get_uid(), 'iso8859-1'))\n        return data\n\n    @abstractmethod\n    def item_for_object(self, obj: AbstractDataItem) -> list[QStandardItem]:\n        pass\n"
  },
  {
    "path": "src/fk/qt/abstract_item_delegate.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nfrom PySide6.QtCore import QObject\nfrom PySide6.QtGui import Qt, QBrush, QColor, QPainter, QPen\nfrom PySide6.QtWidgets import QStyleOptionViewItem, QItemDelegate, QStyle\n\n\ndef get_padding(option: QStyleOptionViewItem) -> int:\n    return (option.rect.height() % option.fontMetrics.height()) / 2\n\n\nclass AbstractItemDelegate(QItemDelegate):\n    _selection_brush: QBrush\n    _crossout_pen: QPen\n    _theme: str\n\n    def __init__(self,\n                 parent: QObject = None,\n                 theme: str = 'mixed',\n                 selection_color: str = '#555',\n                 crossout_color: str = '#777'):\n        QItemDelegate.__init__(self, parent)\n        self._theme = theme\n        self._selection_brush = QBrush(QColor(selection_color), Qt.BrushStyle.SolidPattern)\n        self._crossout_pen = QPen(QColor(crossout_color))\n\n    def paint_background(self, painter: QPainter, option: QStyleOptionViewItem, is_sealed: bool):\n        painter.save()\n\n        if QStyle.StateFlag.State_Selected in option.state:\n            painter.fillRect(option.rect, self._selection_brush)\n\n        if is_sealed:\n            painter.setPen(self._crossout_pen)\n            painter.translate(option.rect.topLeft())\n            lh = option.fontMetrics.height()\n            if lh == 0:\n                lh = 10 # Avoid division by zero in impossible cases\n            lines = int(option.rect.height() / lh)\n            padding = get_padding(option)\n            for i in range(lines):\n                painter.drawLine(0,\n                                 lh * (i + 0.5) + padding,\n                                 option.rect.width(),\n                                 lh * (i + 0.5) + padding)\n\n        painter.restore()\n"
  },
  {
    "path": "src/fk/qt/abstract_tableview.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom abc import abstractmethod\nfrom typing import TypeVar, Generic\n\nfrom PySide6.QtCore import Qt, QModelIndex, QItemSelectionModel, QEvent\nfrom PySide6.QtGui import QPainter, QStandardItemModel, QDragMoveEvent, QDragEnterEvent, QDragLeaveEvent, QColor\nfrom PySide6.QtWidgets import QTableView, QWidget, QAbstractItemView\n\nfrom fk.core.abstract_data_item import AbstractDataItem\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.event_source_holder import EventSourceHolder, BeforeSourceChanged\nfrom fk.core.events import SourceMessagesProcessed, AfterSettingsChanged\nfrom fk.qt.abstract_drop_model import AbstractDropModel\nfrom fk.qt.actions import Actions\n\nlogger = logging.getLogger(__name__)\n\nBeforeSelectionChanged = \"BeforeSelectionChanged\"\nAfterSelectionChanged = \"AfterSelectionChanged\"\n\nTUpstream = TypeVar('TUpstream', bound=AbstractDataItem)\nTDownstream = TypeVar('TDownstream', bound=AbstractDataItem)\n\n\nclass AbstractTableView(QTableView, AbstractEventEmitter, Generic[TUpstream, TDownstream]):\n    _source: AbstractEventSource\n    _is_data_loaded: bool\n    _is_upstream_item_selected: bool\n    _actions: Actions\n    _placeholder_loading: str\n    _placeholder_upstream: str\n    _placeholder_empty: str\n    _editable_column: int\n    _row_height: int\n\n    def __init__(self,\n                 parent: QWidget,\n                 source_holder: EventSourceHolder,\n                 model: QStandardItemModel,\n                 name: str,\n                 actions: Actions,\n                 placeholder_loading: str,\n                 placeholder_upstream: str,\n                 placeholder_empty: str,\n                 editable_column: int):\n        super().__init__(parent,\n                         allowed_events=[\n                             BeforeSelectionChanged,\n                             AfterSelectionChanged,\n                         ],\n                         callback_invoker=source_holder.get_settings().invoke_callback)\n        self._source = None\n        self._actions = actions\n        self._is_data_loaded = False\n        self._is_upstream_item_selected = False\n        self._placeholder_loading = placeholder_loading\n        self._placeholder_upstream = placeholder_upstream\n        self._placeholder_empty = placeholder_empty\n        self._editable_column = editable_column\n        self.setModel(model)\n\n        self.setObjectName(name)\n        self.setTabKeyNavigation(False)\n        self.setSelectionMode(QTableView.SelectionMode.SingleSelection)\n        self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)\n        self.setShowGrid(False)\n        self.horizontalHeader().setVisible(False)\n        self.horizontalHeader().setMinimumSectionSize(10)\n        self.horizontalHeader().setStretchLastSection(False)\n        self.verticalHeader().setVisible(False)\n\n        self.setDragEnabled(True)\n        self.setAcceptDrops(True)\n        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)\n\n        self._update_row_height(int(self._actions.get_settings().get('Application.table_row_height')))\n\n        self.selectionModel().currentRowChanged.connect(self._on_current_changed)\n\n        # This will remove selection, otherwise backlogs would be removed one by one\n        source_holder.on(BeforeSourceChanged, lambda event, source: self.reset())\n        source_holder.get_settings().on(AfterSettingsChanged, self._on_setting_changed)\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.table_row_height' in new_values:\n            self._update_row_height(int(new_values[\"Application.table_row_height\"]))\n\n    def _update_row_height(self, new_height: int):\n        self._row_height = new_height\n        self.verticalHeader().setDefaultSectionSize(self._row_height)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        self._source = source\n        self._is_data_loaded = False\n        self._is_upstream_item_selected = False\n        source.on(SourceMessagesProcessed, self._on_data_loaded)\n\n    def _on_data_loaded(self, event: str, source: AbstractEventSource) -> None:\n        logger.debug(f'Data loaded - {self.objectName()}')\n        self._is_data_loaded = True\n        self.repaint()\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        pass\n\n    def upstream_selected(self, upstream: TUpstream | None) -> None:\n        logger.debug(f'{self.__class__.__name__}.upstream_selected({upstream})')\n        if upstream is None:\n            self._is_upstream_item_selected = False\n        else:\n            self._is_upstream_item_selected = True\n        model: AbstractDropModel = self.model()\n        model.load(upstream)  # Should handle None correctly\n\n    def get_current(self) -> TDownstream | None:\n        index = self.currentIndex()\n        if index is not None:\n            return index.data(500)\n\n    @abstractmethod\n    def update_actions(self, selected: TDownstream | None) -> None:\n        pass\n\n    def _on_current_changed(self, selected: QModelIndex | None, deselected: QModelIndex | None) -> None:\n        after: TDownstream | None = None\n        if selected is not None:\n            after = selected.data(500)\n\n        before: TDownstream | None = None\n        if deselected is not None:\n            before = deselected.data(500)\n\n        params = {\n            'before': before,\n            'after': after,\n        }\n        self._emit(BeforeSelectionChanged, params)\n        self.update_actions(after)\n        self._emit(AfterSelectionChanged, params)\n\n    def paintEvent(self, e):\n        super().paintEvent(e)\n\n        # We may have four situations:\n        # 1. The data source hasn't loaded yet\n        # 2. The user hasn't selected an upstream yet\n        # 3. There are no items in the upstream\n        # 4. There are items to display\n        text: str\n        if not self._is_data_loaded:\n            text = self._placeholder_loading\n        elif not self._is_upstream_item_selected:\n            text = self._placeholder_upstream\n        elif self.model().rowCount() == 0:\n            text = self._placeholder_empty\n        else:\n            return\n\n        painter = QPainter(self.viewport())\n        painter.save()\n        painter.setPen(self.palette().placeholderText().color())\n        painter.drawText(self.viewport().rect(),\n                         Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap,\n                         text)\n        painter.restore()\n        painter.end()\n\n    def select(self, data: TDownstream) -> QModelIndex:\n        model = self.model()\n        for i in range(model.rowCount()):\n            index = model.index(i, self._editable_column)\n            if model.data(index, 500) == data:\n                self.selectionModel().select(index,\n                                             QItemSelectionModel.SelectionFlag.SelectCurrent |\n                                             QItemSelectionModel.SelectionFlag.ClearAndSelect |\n                                             QItemSelectionModel.SelectionFlag.Rows)\n                self.setCurrentIndex(index)\n                self.scrollTo(index)\n                return index\n        raise Exception(f\"Trying to select a table item {data}, which does not exist\")\n\n    def deselect(self) -> None:\n        self.selectionModel().clearSelection()\n\n        # We have to block signals here to avoid triggering AfterSelectionChanged, which would screw up the\n        # backlog / tags mutually exclusive selection logic\n        self.selectionModel().blockSignals(True)\n        self.setCurrentIndex(QModelIndex())\n        self.selectionModel().blockSignals(False)\n\n        self.update_actions(None)\n\n    def dragLeaveEvent(self, event: QDragLeaveEvent):\n        model: AbstractDropModel = self.model()\n        to_select = model.restore_order()\n        if to_select is not None:\n            self.select(model.index(to_select, self._editable_column).data(500))\n        event.accept()\n\n    def dragEnterEvent(self, event: QDragEnterEvent):\n        model: AbstractDropModel = self.model()\n        dragging: QModelIndex = model.dragging\n        if event.mimeData().hasFormat(model.get_primary_type()):\n            # Dragging primary type\n            model.move_drop_placeholder(dragging)\n        event.accept()\n\n    def dragMoveEvent(self, event: QDragMoveEvent):\n        index: QModelIndex = self.indexAt(event.position().toPoint())\n        if event.mimeData().hasFormat(self.model().get_primary_type()):\n            self.deselect()\n            model: AbstractDropModel = self.model()\n            model.move_drop_placeholder(index)\n        event.accept()\n"
  },
  {
    "path": "src/fk/qt/actions.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport json\nfrom typing import Callable, Iterable\n\nfrom PySide6.QtGui import QAction, QIcon\nfrom PySide6.QtWidgets import QWidget\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.events import AfterSettingsChanged\n\n\ndef update_toggle_action_icon(icon1: str, icon2: str, action: QAction) -> None:\n    qi = QIcon()\n    qi.addPixmap(QIcon.fromTheme(icon1).pixmap(48), QIcon.Mode.Normal, QIcon.State.On)\n    qi.addPixmap(QIcon.fromTheme(icon2).pixmap(48), QIcon.Mode.Normal, QIcon.State.Off)\n    action.setIcon(qi)\n\n\nclass Actions:\n    ALL: Actions = None\n    _window: QWidget\n    _domains: dict[str, object]\n    _actions: dict[str, QAction]\n    _shortcuts: dict[str, str]\n    _settings: AbstractSettings\n\n    def __init__(self, window: QWidget, settings: AbstractSettings):\n        self._window = window\n        self._domains = dict()\n        self._actions = dict()\n        self._settings = settings\n        self.update_from_settings(settings.get('Application.shortcuts'))\n        Actions.ALL = self\n\n    def update_from_settings(self, serialized: str):\n        self._shortcuts = json.loads(serialized)\n        for a in self._actions.keys():\n            if a in self._shortcuts:\n                action = self._actions[a]\n                action.setShortcut(self._shortcuts[a])\n                action.setToolTip(f\"{action.text()} ({self._shortcuts[a]})\")\n\n    def add(self,\n            name: str,\n            text: str,\n            shortcut: str | None,\n            icon: str | None | tuple[str, str],\n            member: Callable,\n            is_toggle: bool = False,\n            is_checked: bool = False) -> QAction:\n        res: QAction = QAction(text, self._window)\n        res.setObjectName(name)\n        if shortcut is None or shortcut == '':\n            res.setToolTip(text)\n        else:\n            if name in self._shortcuts:\n                res.setShortcut(self._shortcuts[name])\n                res.setToolTip(f\"{text} ({self._shortcuts[name]})\")\n            else:\n                res.setShortcut(shortcut)\n                res.setToolTip(f\"{text} ({shortcut})\")\n        if icon is not None:\n            if type(icon) is str:\n                res.setIcon(QIcon.fromTheme(icon))\n            else:\n                update_toggle_action_icon(icon[0], icon[1], res)\n                # We also need to reset toggle icons, because converting them to pixmap breaks fromTheme (#138)\n                self._settings.on(\n                    AfterSettingsChanged,\n                    lambda new_values, **_:\n                        update_toggle_action_icon(icon[0], icon[1], res)\n                            if 'Application.theme' in new_values else None)\n        if is_toggle:\n            res.setCheckable(True)\n            res.setChecked(is_checked)\n            res.toggled.connect(lambda checked: self._call(name, member, checked))\n        else:\n            res.triggered.connect(lambda: self._call(name, member))\n        self._window.addAction(res)\n        self._actions[name] = res\n        return res\n\n    def _call(self, name: str, member: Callable, checked: bool = None):\n        [domain, _] = name.split('.')\n        if domain in self._domains:\n            if checked is None:\n                member(self._domains[domain])\n            else:\n                member(self._domains[domain], checked)\n        else:\n            raise Exception(f'Attempt to call unbound action {name}')\n\n    def bind(self, domain: str, obj: object):\n        self._domains[domain] = obj\n\n    def __getitem__(self, name: str) -> QAction:\n        return self._actions[name]\n\n    def __contains__(self, name: str) -> bool:\n        return name in self._actions\n\n    def __iter__(self) -> Iterable[str]:\n        return (x for x in self._actions)\n\n    def __len__(self) -> int:\n        return len(self._actions)\n\n    def values(self) -> Iterable[QAction]:\n        return self._actions.values()\n\n    def keys(self) -> Iterable[str]:\n        return self._actions.keys()\n\n    def get_settings(self):\n        return self._settings\n\n    def all_actions_defined(self) -> None:\n        if self._settings.get('Application.shortcuts') == '{}':\n            shortcuts = dict()\n            for a in self._actions:\n                shortcuts[a] = self._actions[a].shortcut().toString()\n            self._settings.set({'Application.shortcuts': json.dumps(shortcuts)})\n\n    def _on_theme_change(self, icon1: str, icon2: str, action: QAction):\n        pass\n"
  },
  {
    "path": "src/fk/qt/app_version.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport json\nimport logging\nimport re\nfrom typing import Callable\n\nfrom PySide6.QtCore import QFile, QObject\nfrom PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest\nfrom semantic_version import Version\n\nlogger = logging.getLogger(__name__)\nCHANGELOG_REGEX = re.compile(r'### v(.+) \\(.*')\nGITHUB_TAG_REGEX = re.compile(r'v(.+)')\nGITHUB_API_URL = 'https://api.github.com/repos/flowkeeper-org/fk-desktop/releases/latest'\n\n\ndef get_current_version() -> Version:\n    file = QFile(\":/CHANGELOG.txt\")\n    file.open(QFile.OpenModeFlag.ReadOnly)\n    first_line = file.readLine().toStdString()\n    file.close()\n\n    m = CHANGELOG_REGEX.search(first_line)\n    if m is not None:\n        return Version(m.group(1))\n\n    raise Exception('Cannot extract the current Flowkeeper version from CHANGELOG.txt')\n\n\ndef _success(reply: QNetworkReply, callback: Callable[[Version, str], None]) -> None:\n    s = reply.readAll().toStdString()\n    try:\n        j = json.loads(s)\n    except Exception as err:\n        logger.warning(f'Warning -- cannot parse GitHub response (invalid JSON): {s}', exc_info=err)\n        callback(None, None)\n        return\n    if j is not None and 'name' in j:\n        m = GITHUB_TAG_REGEX.search(j['name'])\n        if m is None:\n            raise Exception(f'Cannot extract the latest Flowkeeper version from GitHub tag name')\n        else:\n            callback(Version(m.group(1)), j['body'])\n\n\ndef _error(err: QNetworkReply.NetworkError, callback: Callable[[Version, str], None]) -> None:\n    logger.warning(f'Warning -- cannot get the latest Flowkeeper version info from GitHub: {err}')\n    callback(None, None)\n\n\ndef get_latest_version(parent: QObject, callback: Callable[[Version], None]) -> None:\n    mgr = QNetworkAccessManager(parent)\n    req = QNetworkRequest(GITHUB_API_URL)\n    reply = mgr.get(req)\n    reply.readyRead.connect(lambda: _success(reply, callback))\n    reply.errorOccurred.connect(lambda err: _error(err, callback))\n"
  },
  {
    "path": "src/fk/qt/audio_player.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom PySide6.QtCore import QObject\nfrom PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QMediaDevices, QAudioDevice\nfrom PySide6.QtWidgets import QWidget\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import SourceMessagesProcessed, AfterSettingsChanged, TimerWorkStart\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL, Pomodoro\nfrom fk.core.timer_data import TimerData\nfrom fk.qt.qt_invoker import invoke_in_main_thread\n\nlogger = logging.getLogger(__name__)\n\n\nclass AudioPlayer(QObject):\n    _audio_output: QAudioOutput | None\n    _audio_player: QMediaPlayer | None\n    _settings: AbstractSettings\n    _source: AbstractEventSource | None\n\n    def __init__(self,\n                 parent: QWidget,\n                 source_holder: EventSourceHolder,\n                 settings: AbstractSettings):\n        super().__init__(parent)\n        self._source = None\n        self._settings = settings\n        self._reset()\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        if self._audio_player is not None and self._audio_player.isPlaying():\n            self._audio_player.stop()\n        source.on(SourceMessagesProcessed, lambda **kwargs: self._start_what_is_needed())\n        source.on(\"Timer*Complete\", self._play_audio)\n        source.on(TimerWorkStart, self._start_ticking)\n        self._source = source\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        needs_reset = False\n        for key in new_values.keys():\n            if key in ['Application.play_alarm_sound', 'Application.alarm_sound_file', 'Application.alarm_sound_volume',\n                       'Application.play_rest_sound', 'Application.rest_sound_file', 'Application.rest_sound_volume',\n                       'Application.play_tick_sound', 'Application.tick_sound_file', 'Application.tick_sound_volume',\n                       'Application.audio_output']:\n                needs_reset = True\n        if needs_reset:\n            self._reset()\n            self._start_what_is_needed()\n\n    def _reset(self):\n        found: QAudioDevice = None\n        setting: str = self._settings.get('Application.audio_output')\n        default: QAudioDevice = None\n        for device in QMediaDevices.audioOutputs():\n            if device.id().toStdString() == setting:\n                found = device\n                break\n            if device.isDefault():\n                default = device\n        if found is None and default is not None:\n            found = default\n            logger.info(f\"The previously selected audio device {setting} is not available anymore, \"\n                        f\"switching to default {default.id().toStdString()}\")\n        if found is None:\n            self._audio_output = None\n            self._audio_player = None\n        else:\n            self._audio_output = QAudioOutput(found)\n            self._audio_player = QMediaPlayer(self.parent())\n            self._audio_player.setAudioOutput(self._audio_output)\n\n    def _set_volume(self, setting: str):\n        if self._audio_output is not None:\n            try:\n                from PySide6.QtMultimedia import QtAudio\n                Q = QtAudio\n            except Exception:\n                from PySide6.QtMultimedia import QAudio\n                Q = QAudio\n            volume = float(self._settings.get(setting)) / 100.0\n            # This is what all mixers do\n            volume = Q.convertVolume(volume,\n                                     Q.VolumeScale.LogarithmicVolumeScale,\n                                     Q.VolumeScale.LinearVolumeScale)\n            self._audio_output.setVolume(volume)\n            logger.debug(f'Volume is set to {int(volume * 100)}%')\n\n    def _play_audio(self, event: str, pomodoro: Pomodoro, timer: TimerData) -> None:\n        if self._audio_player is not None:\n            self._audio_player.stop()  # In case it was ticking or playing rest music\n\n            # Alarm bell\n            play_alarm_sound = (self._settings.get('Application.play_alarm_sound') == 'True')\n            play_rest_sound = (self._settings.get('Application.play_rest_sound') == 'True')\n            if play_alarm_sound and (\n                event == 'TimerRestComplete'\n                or not play_rest_sound\n                or (event == 'TimerWorkComplete'\n                    and pomodoro.get_type() == POMODORO_TYPE_NORMAL\n                    and pomodoro.get_rest_duration() == 0)):    # Long break\n                self._reset()\n\n                # We've already checked it, but our audio device could've mysteriously disappeared\n                #  in the meantime, setting self._audio_player to None in _reset(). Bug #81 was\n                #  reported when this happened due to computer waking up from sleep.\n                if self._audio_player is not None:\n                    self._set_volume('Application.alarm_sound_volume')\n                    alarm_file = self._settings.get('Application.alarm_sound_file')\n                    self._audio_player.setSource(alarm_file)\n                    self._audio_player.setLoops(1)\n                    self._audio_player.play()\n\n            # Rest music, for normal pomodoro only\n            if (event == 'TimerWorkComplete'\n                    and pomodoro.get_type() == POMODORO_TYPE_NORMAL\n                    and pomodoro.get_rest_duration() > 0):  # Normal break\n                # We'll be here if the rest started while Flowkeeper was open\n                self._start_rest_sound(pomodoro)\n\n    def _start_ticking(self, event: str = None, **kwargs) -> None:\n        if self._audio_player is not None:\n            play_tick_sound = (self._settings.get('Application.play_tick_sound') == 'True')\n            if play_tick_sound:\n                self._audio_player.stop()     # Just in case\n                tick_file = self._settings.get('Application.tick_sound_file')\n                self._reset()\n\n                # See comment in _play_audio()\n                if self._audio_player is not None:\n                    self._set_volume('Application.tick_sound_volume')\n                    self._audio_player.setSource(tick_file)\n                    self._audio_player.setLoops(QMediaPlayer.Loops.Infinite)\n                    self._audio_player.play()\n\n    def _seek_when_ready(self, elapsed_ms: int = 0):\n        def seek(is_seekable):\n            if connection is not None:\n                self._audio_player.seekableChanged.disconnect(connection)\n                logger.debug('Disconnected from seekableChanged')\n            if is_seekable and self._audio_player.duration() > elapsed_ms:\n                logger.debug(f'Will seek to {elapsed_ms}ms')\n                self._audio_player.setPosition(elapsed_ms)\n\n        if elapsed_ms > 0:\n            connection = self._audio_player.seekableChanged.connect(\n                # We have to use invoke_in_main_thread here, because setPosition() doesn't work\n                # immediately after seekableChanged fired. Seems like it requires one more\n                # event loop iteration, at least in Qt 6.8.2 on Linux.\n                lambda is_seekable: invoke_in_main_thread(seek, is_seekable=is_seekable))\n            logger.debug('Connected to seekableChanged')\n\n    def _start_rest_sound(self, pomodoro: Pomodoro) -> None:\n        if self._audio_player is not None and pomodoro.is_resting():\n            now = datetime.datetime.now(datetime.timezone.utc)\n            elapsed_ms = round(pomodoro.get_elapsed_rest_duration(now) * 1000)\n\n            play_rest_sound = (self._settings.get('Application.play_rest_sound') == 'True')\n            if play_rest_sound:\n                self._audio_player.stop()     # In case it was ticking\n                rest_file = self._settings.get('Application.rest_sound_file')\n                self._reset()\n\n                # See comment in _play_audio()\n                if self._audio_player is not None:\n                    self._set_volume('Application.rest_sound_volume')\n                    self._audio_player.setSource(rest_file)\n                    self._audio_player.setLoops(1)\n                    self._seek_when_ready(elapsed_ms)\n                    self._audio_player.play()     # This will substitute the bell sound\n\n    def _start_what_is_needed(self) -> None:\n        if self._source is not None:\n            timer = self._source.get_data().get_current_user().get_timer()\n            if timer.is_working():\n                self._start_ticking()\n            elif timer.is_resting() and timer.get_running_pomodoro().get_type() == POMODORO_TYPE_NORMAL:\n                # We'll be here if we started Flowkeeper while the timer is resting\n                self._start_rest_sound(timer.get_running_pomodoro())\n\n"
  },
  {
    "path": "src/fk/qt/backlog_model.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport logging\n\nfrom PySide6 import QtGui, QtCore\nfrom PySide6.QtCore import Qt, QMimeData, QModelIndex\nfrom PySide6.QtGui import QStandardItem\n\nfrom fk.core import events\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_timer import AbstractTimer\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import RenameBacklogStrategy, ReorderBacklogStrategy\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import MoveWorkitemStrategy\nfrom fk.qt.abstract_drop_model import AbstractDropModel\nfrom fk.qt.qt_timer import QtTimer\n\nlogger = logging.getLogger(__name__)\nfont_new = QtGui.QFont()\nfont_today = QtGui.QFont()\n# font_today.setBold(True)\n\n\nclass BacklogItem(QStandardItem):\n    _backlog: Backlog\n\n    def __init__(self, backlog: Backlog):\n        super().__init__()\n        self._backlog = backlog\n        self.setData(backlog, 500)\n        self.setData(backlog.get_name(), Qt.ItemDataRole.ToolTipRole)\n        self.setData('title', 501)\n        default_flags = (Qt.ItemFlag.ItemIsSelectable |\n                         Qt.ItemFlag.ItemIsEnabled |\n                         Qt.ItemFlag.ItemIsDragEnabled |\n                         Qt.ItemFlag.ItemIsDropEnabled |    # For workitems\n                         Qt.ItemFlag.ItemIsEditable)\n        self.setFlags(default_flags)\n        self.update_display()\n        self.update_font()\n\n    def update_display(self):\n        self.setData(self._backlog.get_name(), Qt.ItemDataRole.DisplayRole)\n\n    def update_font(self):\n        font = font_today if self._backlog.is_today() else font_new\n        self.setData(font, Qt.ItemDataRole.FontRole)\n\n    def __lt__(self, other: BacklogItem):\n        return self._backlog.get_last_modified_date() < other._backlog.get_last_modified_date()\n\n\nclass BacklogModel(AbstractDropModel):\n    _midnight_timer: AbstractTimer\n\n    def __init__(self,\n                 parent: QtCore.QObject,\n                 source_holder: EventSourceHolder):\n        super().__init__(1, parent, source_holder)\n        self._midnight_timer = QtTimer('Midnight check for BacklogModel')\n        self._schedule_at_midnight()\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        self.itemChanged.connect(lambda item: self.handle_rename(item, RenameBacklogStrategy))\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        self.load(None)\n        source.on(events.AfterBacklogCreate, self._backlog_added)\n        source.on(events.AfterBacklogDelete, self._backlog_removed)\n        source.on(events.AfterBacklogRename, self._backlog_renamed)\n        source.on(events.AfterBacklogReorder, self._backlog_reordered)\n\n    def _backlog_added(self, backlog: Backlog, **kwargs) -> None:\n        self.insertRow(0, BacklogItem(backlog))\n\n    def _backlog_removed(self, backlog: Backlog, **kwargs) -> None:\n        for i in range(self.rowCount()):\n            bl = self.item(i).data(500)\n            if bl == backlog:\n                self.removeRow(i)\n                return\n\n    def _backlog_renamed(self, backlog: Backlog, **kwargs) -> None:\n        for i in range(self.rowCount()):\n            bl = self.item(i).data(500)\n            if bl == backlog:\n                self.item(i).update_display()\n                return\n\n    def _schedule_at_midnight(self):\n        tomorrow = datetime.date.today() + datetime.timedelta(days=1)\n        diff: datetime.timedelta = datetime.datetime(year=tomorrow.year,\n                                                     month=tomorrow.month,\n                                                     day=tomorrow.day) - datetime.datetime.now()\n        wait_for = (int(diff.total_seconds()) + 60) * 1000\n        logger.debug(f'Scheduled _at_midnight in {wait_for}ms')\n        self._midnight_timer.schedule(wait_for, self._at_midnight, None, True)\n\n    def _at_midnight(self, params: dict | None, when: datetime.datetime | None = None) -> None:\n        logger.debug(f'Fired _at_midnight at {datetime.datetime.now()}')\n        for i in range(self.rowCount()):\n            self.item(i).update_font()\n        self._schedule_at_midnight()    # Reschedule\n\n    def _backlog_reordered(self, backlog: Backlog, new_index: int, carry: str, **kwargs) -> None:\n        if carry != 'ui':\n            for old_index in range(self.rowCount()):\n                bl = self.item(old_index).data(500)\n                if bl == backlog:\n                    new_index = self.rowCount() - new_index\n                    if new_index > old_index:\n                        new_index -= 1\n                    row = self.takeRow(old_index)\n                    self.insertRow(new_index, row)\n                    return\n\n    def load(self, user: User | None) -> None:\n        self.clear()\n        if user is not None:\n            for backlog in reversed(user.values()):\n                self.appendRow(BacklogItem(backlog))\n        self.setHorizontalHeaderItem(0, QStandardItem(''))\n\n    def get_primary_type(self) -> str:\n        return 'application/flowkeeper.backlog.id'\n\n    def get_secondary_type(self) -> str:\n        return 'application/flowkeeper.workitem.id'\n\n    def item_for_object(self, backlog: Backlog) -> list[QStandardItem]:\n        return [BacklogItem(backlog)]\n    \n    def reorder(self, to_index: int, uid: str):\n        self._source_holder.get_source().execute(ReorderBacklogStrategy,\n                                                 # We display backlogs in reverse order, so need to subtract here\n                                                 [uid, str(self.rowCount() - to_index)],\n                                                 carry='ui')\n\n    def adopt_foreign_item(self, backlog: Backlog, workitem_uid: str) -> bool:\n        workitem: Workitem = self._source_holder.get_source().find_workitem(workitem_uid)\n        if workitem is None or backlog is None or workitem.get_parent() == backlog:\n            return False\n        logger.debug(f'Moving workitem {workitem.get_name()} to backlog {backlog.get_name()}')\n        self._source_holder.get_source().execute(MoveWorkitemStrategy,\n                                                 [workitem_uid, backlog.get_uid()])\n        return True\n"
  },
  {
    "path": "src/fk/qt/backlog_tableview.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom PySide6.QtCore import Qt, QModelIndex\nfrom PySide6.QtGui import QDragMoveEvent, QDragEnterEvent\nfrom PySide6.QtWidgets import QWidget, QHeaderView, QMenu, QMessageBox, QInputDialog, QTableView, QAbstractItemView\n\nfrom fk.core import events\nfrom fk.core.abstract_data_item import generate_unique_name, generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy, DeleteBacklogStrategy\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import AfterBacklogCreate, SourceMessagesProcessed\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy\nfrom fk.desktop.application import Application\nfrom fk.qt.abstract_tableview import AbstractTableView, AfterSelectionChanged\nfrom fk.qt.actions import Actions\nfrom fk.qt.backlog_model import BacklogModel\n\nlogger = logging.getLogger(__name__)\n\n\nclass BacklogTableView(AbstractTableView[User, Backlog]):\n    _application: Application\n    _menu: QMenu\n\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 source_holder: EventSourceHolder,\n                 actions: Actions):\n        super().__init__(parent,\n                         source_holder,\n                         BacklogModel(parent, source_holder),\n                         'backlogs_table',\n                         actions,\n                         'Loading, please wait...',\n                         'No data or connection error.',\n                         \"You haven't got any backlogs yet. Create the first one by pressing Ctrl+N.\",\n                         0)\n        self._menu = self._init_menu(actions)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        self.on(AfterSelectionChanged, lambda event, before, after: self._application.get_settings().set({\n            'Application.last_selected_backlog': after.get_uid() if after is not None else ''\n        }))\n        self._application = application\n        self.update_actions(None)\n\n    def _lock_ui(self, event, after: int, last_received: datetime.datetime) -> None:\n        self.update_actions(self.get_current())\n\n    def _unlock_ui(self, event, ping: int) -> None:\n        self.update_actions(self.get_current())\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        super()._on_source_changed(event, source)\n        self.selectionModel().clear()\n        self.upstream_selected(None)\n\n        source.on(AfterBacklogCreate, self._on_new_backlog)\n        source.on(SourceMessagesProcessed, self._on_messages)\n\n        # This is done to update the \"New backlog from incomplete\" action, which depends on the child workitems\n        source.on(\"AfterWorkitem*\",\n                  lambda workitem, **kwargs: self._update_actions_if_needed(workitem))\n        source.on('AfterPomodoro*',\n                  lambda **kwargs: self._update_actions_if_needed(\n                      kwargs['workitem'] if 'workitem' in kwargs else kwargs['pomodoro'].get_parent()\n                  ))\n\n        heartbeat = self._application.get_heartbeat()\n        heartbeat.on(events.WentOffline, self._lock_ui)\n        heartbeat.on(events.WentOnline, self._unlock_ui)\n\n    def _init_menu(self, actions: Actions) -> QMenu:\n        menu: QMenu = QMenu()\n        menu.addActions([\n            actions['backlogs_table.newBacklog'],\n            actions['backlogs_table.newBacklogFromIncomplete'],\n            actions['backlogs_table.renameBacklog'],\n            actions['backlogs_table.deleteBacklog'],\n            # Uncomment to troubleshoot\n            # actions['backlogs_table.dumpBacklog'],\n        ])\n        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        self.customContextMenuRequested.connect(lambda p: menu.exec(self.mapToGlobal(p)))\n        return menu\n\n    def upstream_selected(self, user: User) -> None:\n        super().upstream_selected(user)\n        self._actions['backlogs_table.newBacklog'].setEnabled(user is not None)\n        self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)\n\n    def _update_actions_if_needed(self, workitem: Workitem):\n        if workitem is not None:\n            updated: Backlog = workitem.get_parent()\n            current = self.get_current()\n            if updated == current:\n                self.update_actions(current)\n\n    def update_actions(self, selected: Backlog) -> None:\n        logger.debug(f'Backlog table - update_actions({selected})')\n        # It can be None for example if we don't have any backlogs left, or if\n        # we haven't loaded any yet. BacklogModel supports None.\n        is_backlog_selected = selected is not None\n\n        is_incomplete = is_backlog_selected and next(selected.get_incomplete_workitems(), None) is not None\n\n        heartbeat = self._application.get_heartbeat()\n        source = self._application.get_source_holder().get_source()\n        is_online = heartbeat.is_online() or source is None or not source.can_connect()\n        logger.debug(f' - Online: {is_online}')\n        logger.debug(f' - Backlog selected: {is_backlog_selected}')\n        logger.debug(f' - Has incomplete workitems: {is_incomplete}')\n        logger.debug(f' - Heartbeat: {heartbeat}')\n\n        self._actions['backlogs_table.newBacklog'].setEnabled(is_online)\n        self._actions['backlogs_table.newBacklogFromIncomplete'].setEnabled(is_backlog_selected and\n                                                                            is_online and\n                                                                            is_incomplete)\n        self._actions['backlogs_table.renameBacklog'].setEnabled(is_backlog_selected and is_online)\n        self._actions['backlogs_table.deleteBacklog'].setEnabled(is_backlog_selected and is_online)\n        self._actions['backlogs_table.dumpBacklog'].setEnabled(is_backlog_selected)\n        # TODO: Double-clicking the backlog name doesn't use those\n\n    def _on_new_backlog(self, backlog: Backlog, carry: any = None, **kwargs):\n        if carry == 'edit':\n            index: QModelIndex = self.select(backlog)\n            self.edit(index)\n        elif carry == 'select':\n            self.select(backlog)\n\n    def _on_messages(self, event: str, source: AbstractEventSource) -> None:\n        user = source.get_data().get_current_user()\n        self.upstream_selected(user)\n        last_selected_oid = self._application.get_settings().get('Application.last_selected_backlog')\n        if user is not None and last_selected_oid != '' and last_selected_oid in user:\n            self.select(user[last_selected_oid])\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        actions.add('backlogs_table.newBacklog',\n                    \"New Backlog\",\n                    'Ctrl+N',\n                    \"tool-add\",\n                    BacklogTableView.create_backlog)\n        actions.add('backlogs_table.newBacklogFromIncomplete',\n                    \"New Backlog From Incomplete\",\n                    'Ctrl+M',\n                    \"tool-add-prefilled\",\n                    BacklogTableView.create_backlog_from_incomplete)\n        actions.add('backlogs_table.renameBacklog',\n                    \"Rename Backlog\",\n                    'Ctrl+R',\n                    \"tool-rename\",\n                    BacklogTableView.rename_selected_backlog)\n        actions.add('backlogs_table.deleteBacklog',\n                    \"Delete Backlog\",\n                    'F8',\n                    \"tool-delete\",\n                    BacklogTableView.delete_selected_backlog)\n        actions.add('backlogs_table.dumpBacklog',\n                    \"Dump (DEBUG)\",\n                    'Ctrl+D',\n                    None,\n                    BacklogTableView.dump_selected_backlog)\n\n    # Actions\n\n    def create_backlog(self) -> str:\n        prefix: str = datetime.datetime.today().strftime('%Y-%m-%d, %A')   # Locale-formatted\n        new_name = generate_unique_name(prefix, self._source.get_data().get_current_user().names())\n        new_uid = generate_uid()\n        self._source.execute(CreateBacklogStrategy, [new_uid, new_name], carry='edit')\n        return new_uid\n\n    def create_backlog_from_incomplete(self) -> str:\n        selected = self.get_current()\n        # A sanity check, just in case\n        if selected is None:\n            logger.error(f'Trying to create a backlog from incomplete, while there is none selected. Actions '\n                         f'visibility should prevent this from happening.')\n            return\n\n        added_workitems = 0\n        new_backlog_uid = self.create_backlog()\n        for workitem in selected.get_incomplete_workitems():\n            new_workitem_uid = generate_uid()\n            self._source.execute(CreateWorkitemStrategy,\n                                 [new_workitem_uid, new_backlog_uid, workitem.get_name()],\n                                 carry=\"\")  # Note that we don't carry \"edit\" in this case\n            added_workitems += 1\n            incomplete_pomodoros = list(workitem.get_incomplete_pomodoros())\n            pomodoros_to_add = len(incomplete_pomodoros)\n            if pomodoros_to_add > 0 and incomplete_pomodoros[0].get_type() == POMODORO_TYPE_NORMAL:\n                self._source.execute(AddPomodoroStrategy,\n                                     [new_workitem_uid, str(pomodoros_to_add)])\n\n        if added_workitems == 0:\n            logger.warning(f'Created a backlog from incomplete, but without any workitems. Actions '\n                           f'visibility should prevent this from happening.')\n\n        return new_backlog_uid\n\n    def rename_selected_backlog(self) -> None:\n        index: QModelIndex = self.currentIndex()\n        if index is None:\n            raise Exception(\"Trying to rename a backlog, while there's none selected\")\n        self.edit(index)\n\n    def delete_selected_backlog(self) -> None:\n        selected: Backlog = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to delete a backlog, while there's none selected\")\n        if QMessageBox().warning(self,\n                                 \"Confirmation\",\n                                 f\"Are you sure you want to delete backlog '{selected.get_name()}'?\",\n                                 QMessageBox.StandardButton.Ok,\n                                 QMessageBox.StandardButton.Cancel\n                                 ) == QMessageBox.StandardButton.Ok:\n            self._source.execute(DeleteBacklogStrategy, [selected.get_uid()])\n\n    def dump_selected_backlog(self) -> None:\n        selected: Backlog = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to dump a backlog, while there's none selected\")\n        QInputDialog.getMultiLineText(None,\n                                      \"Backlog dump\",\n                                      \"Technical information for debug / development purposes\",\n                                      selected.dump())\n"
  },
  {
    "path": "src/fk/qt/backlog_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtGui import Qt\nfrom PySide6.QtWidgets import QWidget, QVBoxLayout\n\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.events import AfterSettingsChanged\nfrom fk.core.tag import Tag\nfrom fk.desktop.application import Application\nfrom fk.qt.abstract_tableview import AfterSelectionChanged\nfrom fk.qt.actions import Actions\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.configurable_toolbar import ConfigurableToolBar\nfrom fk.qt.tags_widget import TagsWidget\n\n\nclass BacklogWidget(QWidget):\n    _backlogs_table: BacklogTableView\n    _tags: TagsWidget\n    _source_holder: EventSourceHolder\n    _last_selection: Backlog | Tag | None\n\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 source_holder: EventSourceHolder,\n                 actions: Actions):\n        super().__init__(parent)\n        self.setObjectName('backlogs_widget')\n        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)\n        self._last_selection = None\n\n        self._source_holder = source_holder\n        layout = QVBoxLayout(self)\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.setSpacing(0)\n        self.setLayout(layout)\n\n        tb = ConfigurableToolBar(self, actions, \"backlogs_toolbar\")\n        tb.addAction(actions['backlogs_table.newBacklog'])\n        tb.addAction(actions['backlogs_table.newBacklogFromIncomplete'])\n        tb.addAction(actions['backlogs_table.deleteBacklog'])\n        tb.addAction(actions['backlogs_table.renameBacklog'])\n        layout.addWidget(tb)\n\n        self._backlogs_table = BacklogTableView(self, application, source_holder, actions)\n        layout.addWidget(self._backlogs_table)\n\n        self._tags: TagsWidget = TagsWidget(self, application)\n        layout.addWidget(self._tags)\n\n        # Synchronize Backlogs and Tags selections\n        self._backlogs_table.on(AfterSelectionChanged, lambda event, before, after: self._on_selection(after))\n        self._tags.on(AfterSelectionChanged, lambda event, before, after: self._on_selection(after))\n\n        self._tags.update_visibility(application.get_settings().get('Application.feature_tags') == 'True')\n        application.get_settings().on(AfterSettingsChanged, self._on_setting_changed)\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.feature_tags' in new_values:\n            self._tags.update_visibility(new_values['Application.feature_tags'] == 'True')\n\n    def _on_selection(self, backlog_or_tag: Backlog | Tag):\n        if type(backlog_or_tag) is Backlog and type(self._last_selection) is Tag:\n            self._tags.deselect()\n        elif type(backlog_or_tag) is Tag and type(self._last_selection) is Backlog:\n            self._backlogs_table.deselect()\n        self._last_selection = backlog_or_tag\n\n    def get_table(self) -> BacklogTableView:\n        return self._backlogs_table\n\n    def get_tags(self) -> TagsWidget:\n        return self._tags\n"
  },
  {
    "path": "src/fk/qt/configurable_toolbar.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtCore import QEvent, QPoint, QRect\nfrom PySide6.QtGui import Qt, QMouseEvent, QAction\nfrom PySide6.QtWidgets import QWidget, QToolBar, QMenu, QStyleFactory\n\nfrom fk.core.events import AfterSettingsChanged\nfrom fk.qt.actions import Actions\nfrom fk.qt.info_overlay import show_info_overlay\n\n\nclass ConfigurableToolBar(QToolBar):\n    _actions: Actions\n\n    def __init__(self, parent: QWidget, actions: Actions, name: str):\n        super().__init__(parent)\n        self._actions = actions\n        self.setStyle(QStyleFactory.create(\"windows\"))\n        settings = actions.get_settings()\n        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)\n        self.setVisible(settings.get('Application.show_toolbar') == 'True')\n        self.setObjectName(name)\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.show_toolbar' in new_values:\n            self.setVisible(new_values['Application.show_toolbar'] == 'True')\n\n    def _hide(self, pos: QPoint):\n        self._actions.get_settings().set({\n            'Application.show_toolbar': 'False'\n        })\n        show_info_overlay(self,\n                          \"You can re-enable toolbar in Settings > Appearance\",\n                          self.mapToGlobal(pos),\n                          \":/icons/info.png\",\n                          5)\n\n    def mousePressEvent(self, event: QMouseEvent) -> None:\n        if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.RightButton:\n            act = QAction(self)\n            act.setText(\"Hide toolbar\")\n            act.triggered.connect(lambda: self._hide(event.pos()))\n            context_menu = QMenu()\n            context_menu.addAction(act)\n            context_menu.exec(\n                self.parentWidget().mapToGlobal(\n                    event.pos()))\n\n    def get_button_geometry(self, action_name: str) -> QRect | None:\n        for a in self.actions():\n            if a.objectName() == action_name:\n                return self.actionGeometry(a)\n"
  },
  {
    "path": "src/fk/qt/connection_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtCore import QSize\nfrom PySide6.QtGui import QIcon\nfrom PySide6.QtWidgets import QWidget, QToolButton\n\nfrom fk.core import events\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.desktop.application import Application, AfterSourceChanged\n\nlogger = logging.getLogger(__name__)\n\n\nclass ConnectionWidget(QToolButton):\n    _application: Application\n    _source: AbstractEventSource\n\n    def __init__(self, parent: QWidget, application: Application):\n        super().__init__(parent)\n        self._application = application\n        self._source = None\n        self.setObjectName('connectionState')\n        self.setIconSize(QSize(32, 32))\n        application.get_source_holder().on(AfterSourceChanged, self._on_source_changed)\n\n    def _update_connection_state(self, is_connected: bool) -> None:\n        username = self._application.get_settings().get_username()\n        if is_connected:\n            self.setIcon(QIcon.fromTheme('conn-online'))\n            self.setToolTip('Connected')\n            self.topLevelWidget().setWindowTitle(f'Flowkeeper - {username} - Online')\n        else:\n            self.setIcon(QIcon.fromTheme('conn-offline'))\n            self.setToolTip('Disconnected')\n            self.topLevelWidget().setWindowTitle(f'Flowkeeper - {username} - Offline')\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        self._source = source\n        self.clicked.connect(self._source.connect)\n        self.setVisible(source.can_connect())\n        if source.can_connect():\n            logger.debug('ConnectionWidget._on_source_changed: Connectable source')\n            heartbeat = self._application.get_heartbeat()\n            self._update_connection_state(heartbeat.is_online())\n            heartbeat.on(events.WentOnline, lambda event, **kwargs: self._update_connection_state(True))\n            heartbeat.on(events.WentOffline, lambda event, **kwargs: self._update_connection_state(False))\n        else:\n            logger.debug('ConnectionWidget._on_source_changed: Offline source')\n            self.setIcon(QIcon.fromTheme('conn-unknown'))\n            self.setToolTip('N/A')\n            self.topLevelWidget().setWindowTitle('Flowkeeper')\n"
  },
  {
    "path": "src/fk/qt/flow_layout.py",
    "content": "# PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x\n# Copied with minor modifications from here:\n# https://doc-snapshots.qt.io/qtforpython-6.2/examples/example_widgets_layouts_flowlayout.html\n\nfrom PySide6.QtCore import Qt, QMargins, QPoint, QRect, QSize\nfrom PySide6.QtWidgets import QLayout, QSizePolicy, QWidget, QLayoutItem\n\n\nclass FlowLayout(QLayout):\n    def __init__(self, parent=None):\n        super().__init__(parent)\n        self._item_list = []\n\n    def __del__(self):\n        item = self.takeAt(0)\n        while item:\n            item = self.takeAt(0)\n\n    def addItem(self, item):\n        self._item_list.append(item)\n\n    def count(self):\n        return len(self._item_list)\n\n    def itemAt(self, index):\n        if 0 <= index < len(self._item_list):\n            return self._item_list[index]\n        return None\n\n    def takeAt(self, index):\n        if 0 <= index < len(self._item_list):\n            return self._item_list.pop(index)\n        return None\n\n    def removeWidget(self, widget: QWidget):\n        for i in range(len(self._item_list)):\n            if self._item_list[i].widget() == widget:\n                self._item_list.pop(i)\n                widget.setParent(None)  # Otherwise setGeometry won't work\n                self.setGeometry(self.geometry())\n                return\n\n    def widgets(self):\n        return [self._item_list[i].widget() for i in range(len(self._item_list))]\n\n    def expandingDirections(self):\n        return Qt.Orientation(0)\n\n    def hasHeightForWidth(self):\n        return True\n\n    def heightForWidth(self, width):\n        height = self._do_layout(QRect(0, 0, width, 0), True)\n        return height\n\n    def setGeometry(self, rect):\n        super(FlowLayout, self).setGeometry(rect)\n        self._do_layout(rect, False)\n\n    def sizeHint(self):\n        return self.minimumSize()\n\n    def minimumSize(self):\n        size = QSize()\n\n        for item in self._item_list:\n            size = size.expandedTo(item.minimumSize())\n\n        size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top())\n        return size\n\n    def _do_layout(self, rect, test_only):\n        x = rect.x()\n        y = rect.y()\n        line_height = 0\n        spacing = self.spacing()\n\n        for item in self._item_list:\n            style = item.widget().style()\n            layout_spacing_x = style.layoutSpacing(\n                QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal\n            )\n            layout_spacing_y = style.layoutSpacing(\n                QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical\n            )\n            space_x = spacing + layout_spacing_x\n            space_y = spacing + layout_spacing_y\n            next_x = x + item.sizeHint().width() + space_x\n            if next_x - space_x > rect.right() and line_height > 0:\n                x = rect.x()\n                y = y + line_height + space_y\n                next_x = x + item.sizeHint().width() + space_x\n                line_height = 0\n\n            if not test_only:\n                item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))\n\n            x = next_x\n            line_height = max(line_height, item.sizeHint().height())\n\n        return y + line_height - rect.y()\n"
  },
  {
    "path": "src/fk/qt/focus_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtCore import QSize, QPoint, QLine\nfrom PySide6.QtGui import QPainter, QPixmap, Qt, QGradient, QColor, QMouseEvent, QIcon\nfrom PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QMessageBox, QMenu, QSizePolicy, QToolButton, \\\n    QSpacerItem\n\nfrom fk.core.abstract_event_source import AbstractEventSource, start_workitem\nfrom fk.core.abstract_serializer import sanitize_user_input\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_timer_display import AbstractTimerDisplay\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.events import AfterSettingsChanged\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_TRACKER\nfrom fk.core.pomodoro_strategies import AddInterruptionStrategy\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.timer_strategies import StopTimerStrategy\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CompleteWorkitemStrategy\nfrom fk.desktop.application import Application, AfterFontsChanged\nfrom fk.desktop.interruption_dialog import InterruptionDialog\nfrom fk.qt.actions import Actions\nfrom fk.qt.timer_widget import TimerWidget\n\nlogger = logging.getLogger(__name__)\nDISPLAY_HEIGHT = 80\n\n\ndef complete_item(item: Workitem, parent: QWidget, source: AbstractEventSource) -> None:\n    if item is None:\n        raise Exception(\"Trying to complete a workitem, while there's none selected\")\n    if (not item.has_running_pomodoro()\n            or item.is_tracker()\n            or item.get_running_pomodoro().is_long_break()\n            or QMessageBox().warning(\n            parent,\n            \"Confirmation\",\n            f\"Are you sure you want to complete workitem '{item.get_display_name()}'? This will void current pomodoro.\",\n            QMessageBox.StandardButton.Ok,\n            QMessageBox.StandardButton.Cancel\n    ) == QMessageBox.StandardButton.Ok):\n        source.execute(CompleteWorkitemStrategy, [item.get_uid(), \"finished\"])\n\n\nclass FocusWidget(QWidget, AbstractTimerDisplay):\n    _settings: AbstractSettings\n    _header_text: QLabel\n    _header_subtext: QLabel\n    _actions: Actions\n    _application: Application\n    _pixmap: QPixmap | None\n    _border_color: QColor\n    _continue_workitem: Workitem | None\n    _timer_widget: TimerWidget\n    _moving_around: QPoint | None\n    _hint_label: QLabel | None\n    _added: [QWidget]\n    _readonly: bool\n\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 timer: PomodoroTimer,\n                 source_holder: EventSourceHolder,\n                 settings: AbstractSettings,\n                 actions: Actions,\n                 flavor: str = 'minimal',\n                 readonly: bool = False):\n        super().__init__(parent, timer=timer, source_holder=source_holder)\n\n        self._apply_size_policy()\n\n        self._settings = settings\n        self._actions = actions\n        self._application = application\n        self._pixmap = None\n        self._continue_workitem = None\n        self._moving_around = None\n        self._hint_label = None\n        self._border_color = QColor('#000000')\n        self._timer_widget = None\n        self._added = []\n        self._readonly = readonly\n\n        self.setObjectName('focus')\n\n        layout = QHBoxLayout()\n        layout.setObjectName(\"focus_layout\")\n        layout.setContentsMargins(15, 10, 15, 10)\n        layout.setSpacing(0)\n        self.setLayout(layout)\n\n        text_layout = QVBoxLayout()\n        text_layout.setObjectName(\"text_layout\")\n        layout.addLayout(text_layout)\n        text_layout.setContentsMargins(0, 0, 0, 0)\n        text_layout.setSpacing(0)\n        text_layout.addStretch()\n\n        header_text = QLabel(self)\n        header_text.setObjectName('headerText')\n        text_layout.addWidget(header_text)\n        header_text.setText(\"Idle\")\n        header_text.setFont(application.get_header_font())\n        application.on(AfterFontsChanged, self._on_fonts_changed)\n        self._header_text = header_text\n\n        header_subtext = QLabel(self)\n        header_subtext.setObjectName('headerSubtext')\n        text_layout.addWidget(header_subtext)\n        header_subtext.setText(\"Welcome to Flowkeeper!\")\n        self._header_subtext = header_subtext\n\n        text_layout.addStretch()\n\n        self.set_flavor(flavor)\n\n        self.eye_candy()\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n\n    def set_flavor(self, flavor):\n        layout = self.layout()\n        last_values = None\n\n        if self._timer_widget is not None:\n            # Delete all widgets from the layout\n            for w in self._added:\n                if isinstance(w, QSpacerItem):\n                    layout.removeItem(w)\n                else:\n                    layout.removeWidget(w)\n                    w.deleteLater()\n            self._added = []\n            last_values = self._timer_widget.get_last_values()\n\n        center_button = None\n        if flavor == 'classic':\n            # We add both buttons, but one of them will always be hidden\n            center_button = QWidget(self)\n            center_button.setContentsMargins(0, 0, 0, 0)\n            center_button_layout = QHBoxLayout()\n            center_button_layout.setContentsMargins(0, 0, 0, 0)\n            center_button_layout.setSpacing(0)\n            center_button.setLayout(center_button_layout)\n            void_pomodoro_button = self._create_button(\"focus.voidPomodoro\")\n            center_button_layout.addWidget(void_pomodoro_button)\n            self._added.append(void_pomodoro_button)\n            finish_tracking_button = self._create_button(\"focus.finishTracking\")\n            center_button_layout.addWidget(finish_tracking_button)\n            self._added.append(finish_tracking_button)\n\n        self._timer_widget = TimerWidget(self,\n                                         'timer',\n                                         flavor,\n                                         center_button,\n                                         63)\n        if flavor == 'classic':\n            w = self._timer_widget\n            self._added.append(w)\n            layout.addWidget(w)\n\n            w = self._create_button(\"focus.interruption\")\n            self._added.append(w)\n            layout.addWidget(w)\n\n            w = self._create_button(\"focus.nextPomodoro\")\n            self._added.append(w)\n            layout.addWidget(w)\n\n            w = self._create_button(\"focus.completeItem\")\n            self._added.append(w)\n            layout.addWidget(w)\n\n            if \"window.pinWindow\" in self._actions:\n                w = self._create_button(\"window.pinWindow\")\n                self._added.append(w)\n                layout.addWidget(w)\n\n        elif flavor == 'minimal':\n            w = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding)\n            self._added.append(w)\n            layout.addSpacerItem(w)\n\n            if self._settings.get('Application.show_click_here_hint') == 'True':\n                self._hint_label = self._initialize_hint_label()\n                w = self._hint_label\n                self._added.append(w)\n                layout.addWidget(w)\n\n            self._timer_widget.clicked.connect(self._timer_clicked)\n\n            w = self._timer_widget\n            self._added.append(w)\n            layout.addWidget(w)\n\n        self._update_colors()\n        if last_values is not None:\n            self._timer_widget.set_values(**last_values)\n        self.mode_changed(self._mode, self._mode)\n\n    def kill(self):\n        super().kill()\n        self._settings.unsubscribe(self._on_setting_changed)\n        self._application.unsubscribe(self._on_fonts_changed)\n\n    def _initialize_hint_label(self) -> QLabel:\n        hint_label = QLabel(self)\n        hint_label.setObjectName('headerSubSubtext')\n        hint_label.setText(\"Click here →\")\n        return hint_label\n\n    def update_fonts(self):\n        self._header_text.setFont(self._application.get_header_font())\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        actions.add('focus.voidPomodoro', \"Void Pomodoro\", 'Ctrl+V', \"tool-void\", FocusWidget._void_pomodoro)\n        actions.add('focus.interruption', \"Interruption\", 'Ctrl+T', \"tool-interruption\", FocusWidget._interruption)\n        actions.add('focus.finishTracking', \"Stop Tracking Time\", 'Ctrl+S', \"tool-finish-tracking\", FocusWidget._finish_tracking)\n        actions.add('focus.nextPomodoro', \"Next Pomodoro\", None, \"tool-focus-next\", FocusWidget._next_pomodoro)\n        actions.add('focus.completeItem', \"Complete Item\", None, \"tool-focus-complete\", FocusWidget._complete_item)\n\n    def _create_button(self,\n                       name: str,\n                       parent: QWidget = None):\n        action = self._actions[name]\n        btn = QToolButton(self if parent is None else parent)\n        btn.setObjectName(name)\n        btn.setIcon(QIcon(action.icon()))\n        btn.setIconSize(QSize(32, 32))\n        btn.setDefaultAction(action)\n        action.enabledChanged.connect(btn.setVisible)\n        btn.setVisible(action.isEnabled())\n        return btn\n\n    def reset(self, text: str = 'Idle', subtext: str = \"It's time for the next Pomodoro.\") -> None:\n        self._header_text.setText(text)\n        self._header_subtext.setText(subtext)\n        if not self._readonly:\n            self._actions['focus.completeItem'].setDisabled(True)\n            self._actions['focus.voidPomodoro'].setVisible(False)\n            self._actions['focus.voidPomodoro'].setDisabled(True)\n            self._actions['focus.interruption'].setVisible(False)\n            self._actions['focus.interruption'].setDisabled(True)\n            self._actions['focus.finishTracking'].setVisible(False)\n            self._actions['focus.finishTracking'].setDisabled(True)\n        self._timer_widget.reset()\n\n    def eye_candy(self):\n        eyecandy_type = self._settings.get('Application.eyecandy_type')\n        if eyecandy_type == 'image':\n            header_bg = self._settings.get('Application.eyecandy_image')\n            if header_bg:\n                self._pixmap = QPixmap(header_bg)\n            else:\n                self._pixmap = None\n        self.repaint()\n\n    def paintEvent(self, event):\n        super().paintEvent(event)\n        rect = self.rect()\n        painter = QPainter(self)\n        eyecandy_type = self._settings.get('Application.eyecandy_type')\n        if eyecandy_type == 'image':\n            if self._pixmap is not None and self._pixmap.width() > 0:\n                painter.drawPixmap(\n                    QPoint(0, 0),\n                    self._pixmap.scaled(\n                        QSize(self.width(), int(self.width() * self._pixmap.height() / self._pixmap.width())),\n                        mode=Qt.TransformationMode.SmoothTransformation))\n        elif eyecandy_type == 'gradient':\n            gradient = self._settings.get('Application.eyecandy_gradient')\n            try:\n                painter.fillRect(rect, QGradient.Preset[gradient])\n            except Exception as e:\n                logger.error(f'ERROR while updating the gradient to {gradient} -- ignoring it', exc_info=e)\n                painter.fillRect(self.rect(), QColor.setRgb(127, 127, 127))\n        else:   # Default\n            painter.setPen(self._border_color)\n            painter.drawLine(QLine(rect.bottomLeft(), rect.bottomRight()))\n\n    def _update_colors(self):\n        variables = self._application.get_theme_variables()\n        self._border_color = variables['FOCUS_BORDER_COLOR']\n        self._timer_widget.fg_color = QColor(variables['FOCUS_TEXT_COLOR'])\n        self._timer_widget.bg_color = QColor(variables['FOCUS_BG_COLOR'])\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.theme' in new_values:\n            self._update_colors()\n        if 'Application.eyecandy_type' in new_values or \\\n                'Application.eyecandy_gradient' in new_values or \\\n                'Application.eyecandy_image' in new_values:\n            self.eye_candy()\n        if self._hint_label is not None and 'Application.show_click_here_hint' in new_values:\n            self._hint_label.hide()\n\n    def _on_fonts_changed(self, event, header_font, **kwargs):\n        self._header_text.setFont(header_font)\n\n    def _void_pomodoro(self) -> None:\n        for backlog in self._source_holder.get_source().backlogs():\n            workitem, _ = backlog.get_running_workitem()\n            if workitem is not None:\n                uid = workitem.get_uid()\n\n                dlg = InterruptionDialog(\n                    self.parent(),\n                    self._source_holder.get_source(),\n                    'Confirmation',\n                    'Are you sure you want to void current pomodoro?',\n                    'Reason (optional)')\n\n                def ok():\n                    reason = f': {sanitize_user_input(dlg.get_reason())}' if dlg.get_reason() else ''\n                    self._source_holder.get_source().execute(\n                        AddInterruptionStrategy, [\n                            uid,\n                            f'Pomodoro voided{reason}'])\n                    self._source_holder.get_source().execute(\n                        StopTimerStrategy,\n                        [])\n                dlg.accepted.connect(ok)\n                dlg.open()\n\n    def _interruption(self) -> None:\n        for backlog in self._source_holder.get_source().backlogs():\n            workitem, _ = backlog.get_running_workitem()\n            if workitem is not None:\n                uid = workitem.get_uid()\n\n                dlg = InterruptionDialog(\n                    self.parent(),\n                    self._source_holder.get_source(),\n                    'Interruption',\n                    \"It won't pause or void your current pomodoro, only\\n\"\n                    \"record this incident for your future reference:\",\n                    'What happened (optional)')\n\n                def ok():\n                    self._source_holder.get_source().execute(\n                        AddInterruptionStrategy, [\n                            uid,\n                            sanitize_user_input(dlg.get_reason())])\n\n                dlg.accepted.connect(ok)\n                dlg.open()\n\n    def _finish_tracking(self) -> None:\n        # We don't check if there's a running workitem, as the action is only enabled while the timer is ticking\n        self._source_holder.get_source().execute(StopTimerStrategy, [])\n\n    def _next_pomodoro(self) -> None:\n        if self._continue_workitem is None:\n            raise Exception('Cannot start next pomodoro on non-existent work item')\n        start_workitem(self._continue_workitem, self._source_holder.get_source())\n\n    def _complete_item(self) -> None:\n        item = self.timer.get_running_workitem()\n        complete_item(item, self, self._source_holder.get_source())\n\n    def tick(self, pomodoro: Pomodoro, state_text: str, my_value: float, my_max: float, mode: str) -> None:\n        self._header_text.setText(state_text)\n        self._timer_widget.set_values(my_value, my_max, None, None, mode)\n\n    def mode_changed(self, old_mode: str, new_mode: str) -> None:\n        if new_mode == 'undefined' or new_mode == 'idle':\n            self.reset()\n            if not self._readonly:\n                self._actions['focus.nextPomodoro'].setDisabled(True)\n                self._actions['focus.nextPomodoro'].setText('Next Pomodoro')\n        elif new_mode in ('working', 'resting', 'long-resting'):\n            running_item = self.timer.get_running_workitem()\n            if running_item is None:\n                return self.mode_changed(new_mode, 'ready')  # A very rare corner case where we deleted a running\n                # workitem and it auto-sealed precisely between mode changes.\n            if new_mode == 'long-resting':\n                self._header_subtext.setText('It is recommended to take a 20-minute break after each 4 pomodoros')\n            else:\n                self._header_subtext.setText(running_item.get_display_name())\n            if not self._readonly:\n                pomodoro: Pomodoro = self.timer.get_running_pomodoro()\n                if pomodoro.get_type() == POMODORO_TYPE_TRACKER or pomodoro.is_long_break():\n                    self._actions['focus.voidPomodoro'].setVisible(False)\n                    self._actions['focus.voidPomodoro'].setDisabled(True)\n                    self._actions['focus.interruption'].setVisible(False)\n                    self._actions['focus.interruption'].setDisabled(True)\n                    self._actions['focus.finishTracking'].setVisible(True)\n                    self._actions['focus.finishTracking'].setDisabled(False)\n                else:\n                    self._actions['focus.voidPomodoro'].setVisible(True)\n                    self._actions['focus.voidPomodoro'].setDisabled(False)\n                    self._actions['focus.interruption'].setVisible(True)\n                    self._actions['focus.interruption'].setDisabled(False)\n                    self._actions['focus.finishTracking'].setVisible(False)\n                    self._actions['focus.finishTracking'].setDisabled(True)\n                self._actions['focus.nextPomodoro'].setDisabled(True)\n                self._actions['focus.nextPomodoro'].setText(f'Next Pomodoro ({running_item.get_short_display_name()})')\n                self._actions['focus.completeItem'].setDisabled(False)\n        elif new_mode == 'ready':\n            self.reset('Continue?', self._continue_workitem.get_display_name())\n            self._timer_widget.set_values(0, 1, None, None, 'ready')\n            if not self._readonly:\n                self._actions['focus.nextPomodoro'].setDisabled(False)\n                self._actions['focus.nextPomodoro'].setText(f'Next Pomodoro ({self._continue_workitem.get_short_display_name()})')\n\n            # Continue the series automaticallysss\n            work_in_series = self._settings.get('Pomodoro.start_next_automatically') == 'True'\n            after_long_break = self.timer.get_pomodoro_in_series() == 0\n            last_voided = self._last_pomodoro is not None and self._last_pomodoro.is_startable()\n            if work_in_series and not after_long_break and not last_voided:\n                logger.debug('Continuing the series automatically')\n                self._actions['focus.nextPomodoro'].trigger()\n\n    def _apply_size_policy(self):\n        sp = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)\n        sp.setVerticalStretch(0)\n        self.setSizePolicy(sp)\n        self.setMinimumHeight(DISPLAY_HEIGHT)\n        self.setMaximumHeight(DISPLAY_HEIGHT)\n\n    def _timer_clicked(self, pos: QPoint) -> None:\n        self._settings.set({'Application.show_click_here_hint': 'False'})\n        context_menu = QMenu(self)\n        context_menu.addAction(self._actions['focus.nextPomodoro'])\n        timer = self.timer\n        if timer.is_working() or timer.is_resting():\n            pomodoro = timer.get_running_pomodoro()\n            if pomodoro.get_type() == POMODORO_TYPE_TRACKER or pomodoro.is_long_break():\n                context_menu.addAction(self._actions['focus.finishTracking'])\n            else:\n                context_menu.addAction(self._actions['focus.interruption'])\n                context_menu.addAction(self._actions['focus.voidPomodoro'])\n        context_menu.addSeparator()\n        context_menu.addAction(self._actions['window.focusMode'])\n        if 'window.pinWindow' in self._actions:\n            context_menu.addAction(self._actions['window.pinWindow'])\n        context_menu.addSeparator()\n        context_menu.addAction(self._actions['focus.completeItem'])\n        context_menu.exec(self._timer_widget.mapToGlobal(pos))\n\n    def mousePressEvent(self, event: QMouseEvent) -> None:\n        self._moving_around = event.pos()\n\n    def mouseMoveEvent(self, event: QMouseEvent) -> None:\n        if self._moving_around is not None:\n            self.window().move(self.window().pos() + event.pos() - self._moving_around)\n\n    def mouseReleaseEvent(self, event: QMouseEvent) -> None:\n        self._moving_around = None\n\n    def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:\n        if not self._readonly:\n            self._actions['window.focusMode'].toggle()\n\n"
  },
  {
    "path": "src/fk/qt/heartbeat.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom fk.core import events\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.timer import AbstractTimer\nfrom fk.qt.qt_timer import QtTimer\n\nlogger = logging.getLogger(__name__)\n\n\nclass Heartbeat(AbstractEventEmitter):\n    _timer: AbstractTimer\n    _source_holder: EventSourceHolder\n    _state: str\n    _last_sent_uid: str\n    _last_received_uid: str\n    _last_sent_time: datetime.datetime\n    _last_received_time: datetime.datetime\n    _threshold_ms: int\n    _every_ms: int\n    _last_ping_ms: int\n\n    def __init__(self, source_holder: EventSourceHolder, every_ms: int, threshold_ms: int):\n        AbstractEventEmitter.__init__(self,\n                                      [events.WentOnline, events.WentOffline],\n                                      source_holder.get_settings().invoke_callback)\n        self._source_holder = source_holder\n        self._every_ms = every_ms\n        self._threshold_ms = threshold_ms\n        self._timer = QtTimer('Heartbeat')\n        self._reset()\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n\n    def _reset(self) -> None:\n        self._timer.cancel()\n        self._state = 'unknown'\n        self._last_ping_ms = -1\n        self._last_sent_uid = ''\n        self._last_received_uid = ''\n        self._last_sent_time = None\n        self._last_received_time = None\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        self._reset()\n        if source.can_connect():\n            source.on(events.PongReceived, self._on_pong)\n            source.on(events.SourceMessagesRequested, self.start)\n\n    def start(self, event) -> None:\n        self._send_ping(None)\n        self._timer.schedule(self._every_ms,\n                             self._send_ping,\n                             None,\n                             False)\n\n    def _send_ping(self, params: dict | None, when: datetime.datetime | None = None) -> None:\n        now = datetime.datetime.now(datetime.timezone.utc)\n        if self._last_sent_time and not self.is_offline():\n            diff_ms = (now - self._last_sent_time).total_seconds() * 1000\n            if diff_ms > self._threshold_ms and self._last_received_uid != self._last_sent_uid:\n                self._state = 'offline'\n                self._emit(events.WentOffline, {\n                    'after': diff_ms,\n                    'last_received': self._last_received_time,\n                })\n        self._last_sent_uid = self._source_holder.get_source().send_ping()\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f' -> Ping {self._last_sent_uid}')\n        self._last_sent_time = now\n\n    def _on_pong(self, event, uid, carry) -> None:\n        now = datetime.datetime.now(datetime.timezone.utc)\n        if self._last_sent_uid == uid:\n            diff_ms = (now - self._last_sent_time).total_seconds() * 1000\n            self._last_ping_ms = diff_ms\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f' <- Pong {uid} with {diff_ms}ms delay')\n            if not self.is_online():\n                if diff_ms <= self._threshold_ms:\n                    self._state = 'online'\n                    self._emit(events.WentOnline, {\n                        'ping': diff_ms,\n                    })\n        else:\n            logger.warning(f'Received unexpected pong {uid}')\n        self._last_received_uid = uid\n        self._last_received_time = now\n\n    def stop(self) -> None:\n        self._timer.cancel()\n\n    def is_online(self) -> bool:\n        return self._state == 'online'\n\n    def is_offline(self) -> bool:\n        return self._state == 'offline'\n\n    # This may be -1 if there's been no pong yet\n    def get_last_ping(self) -> int:\n        return self._last_ping_ms\n"
  },
  {
    "path": "src/fk/qt/info_overlay.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom typing import Callable, Tuple\n\nfrom PySide6.QtCore import Qt, QTimer, QPoint, QObject, QEvent\nfrom PySide6.QtGui import QPixmap, QMouseEvent, QFont, QMoveEvent\nfrom PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QSizePolicy, QWidget, QPushButton\n\n\nclass InfoOverlay(QFrame):\n    _timer: QTimer\n    _on_close: Callable[[None], None]\n    _text: str\n\n    def __init__(self,\n                 parent: QWidget,\n                 text: str,\n                 absolute_position: QPoint,\n                 icon: str = None,\n                 duration: int = 3,\n                 font_scale: float = 0.8,\n                 width: int | None = None,\n                 on_close: Callable[[], None] = None,\n                 on_prev: Callable[[], None] = None,\n                 on_skip: Callable[[], None] = None,\n                 arrow: str = None,\n                 is_last: bool = False):\n        super().__init__(parent)\n        self._on_close = on_close\n        self._text = text\n\n        self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)\n        if arrow is None:\n            self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)\n\n        if parent:\n            parent.installEventFilter(self)\n\n        main_layout = QVBoxLayout()\n        main_layout.setSpacing(0)\n        main_layout.setContentsMargins(0, 0, 0, 0)\n        self.setLayout(main_layout)\n        if arrow == 'up':\n            triangle = QLabel(self)\n            triangle.setPixmap(QPixmap(':/icons/triangle-up.svg'))\n            triangle.setAlignment(Qt.AlignmentFlag.AlignCenter)\n            main_layout.addWidget(triangle)\n            self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)\n\n        self._timer = QTimer(self)\n        if duration > 0:\n            self._timer.setInterval(duration * 1000)\n            self._timer.timeout.connect(self.close)\n            self._timer.start()\n\n        widget = InfoOverlayContent(self,\n                                    text,\n                                    icon,\n                                    font_scale,\n                                    on_prev,\n                                    on_skip,\n                                    is_last)\n        main_layout.addWidget(widget)\n\n        if arrow == 'down':\n            triangle = QLabel(self)\n            triangle.setPixmap(QPixmap(':/icons/triangle-down.svg'))\n            triangle.setAlignment(Qt.AlignmentFlag.AlignCenter)\n            main_layout.addWidget(triangle)\n            self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)\n\n        if width is not None:\n            self.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding))\n            self.setFixedWidth(width)\n\n        self.adjustSize()\n\n        absolute_position.setX(absolute_position.x() - round(self.width() / 2))\n        if arrow is None:\n            absolute_position.setY(absolute_position.y() - round(self.height() / 2))\n        if arrow == 'down':\n            absolute_position.setY(absolute_position.y() - self.height())\n\n        self.move(absolute_position)\n\n    def eventFilter(self, watched: QObject, event: QEvent) -> bool:\n        if event.type() == QEvent.Type.Move:\n            move_event: QMoveEvent = event\n            self.move(self.pos() + move_event.pos() - move_event.oldPos())\n        return False\n\n    def get_text(self):\n        return self._text\n\n    def mousePressEvent(self, event: QMouseEvent) -> None:\n        self.close()\n\n    def close(self):\n        if self.parent():\n            self.parent().removeEventFilter(self)\n        global INFO_OVERLAY_INSTANCE\n        if self._timer is not None:\n            self._timer.stop()\n        super().close()\n        if self._on_close is not None:\n            self._on_close()\n        INFO_OVERLAY_INSTANCE = None\n\n\nclass InfoOverlayContent(QWidget):\n    def __init__(self,\n                 parent: InfoOverlay,\n                 text: str,\n                 icon: str = None,\n                 font_scale: float = 0.8,\n                 on_prev: Callable[[], None] = None,\n                 on_skip: Callable[[], None] = None,\n                 is_last: bool = False):\n        super().__init__()\n\n        main_layout = QVBoxLayout()\n        main_layout.setContentsMargins(12, 12, 12, 12)\n        main_layout.setSpacing(6)\n        self.setLayout(main_layout)\n        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)\n\n        top_layout = QHBoxLayout()\n        top_layout.setSpacing(6)\n        main_layout.addLayout(top_layout)\n\n        if icon is not None:\n            icon_label = QLabel(self)\n            icon_label.setPixmap(QPixmap(icon))\n            icon_label.setAlignment(Qt.AlignmentFlag.AlignTop)\n            top_layout.addWidget(icon_label)\n\n        main_label = QLabel(self)\n        main_label.setObjectName('overlay_text')\n        main_label.setText(text)\n        main_label.setWordWrap(True)\n        font = main_label.font()\n        font.setPointSize(font.pointSize() * font_scale)\n        main_label.setFont(font)\n        top_layout.addWidget(main_label)\n        top_layout.addStretch()\n\n        if on_skip is not None:\n            bottom_layout = QHBoxLayout()\n            bottom_layout.setContentsMargins(0, 8, 0, 0)\n            main_layout.addLayout(bottom_layout)\n            skip_button = QPushButton(' Finish ' if is_last else ' Skip tutorial ', self)\n            skip_button.setObjectName('skip_button')\n            skip_button.clicked.connect(on_skip)\n            skip_button.clicked.connect(lambda: parent.close())\n            bottom_layout.addStretch()\n            bottom_layout.addWidget(skip_button)\n\n        if on_prev is not None:\n            bottom_layout = QHBoxLayout()\n            bottom_layout.setContentsMargins(8, 0, 8, 8)\n            bottom_layout.setSpacing(2)\n            main_layout.addLayout(bottom_layout)\n            prev_button = QLabel(self)\n            prev_button.setObjectName('prev_button')\n            prev_button.setText('&lt; <a href=\"#\">Back</a>')\n            prev_button.linkActivated.connect(on_prev)\n            prev_button.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)\n            smaller_font = QFont(main_label.font())\n            smaller_font.setPointSize(font.pointSize() * 0.8)\n            prev_button.setFont(smaller_font)\n            bottom_layout.addWidget(prev_button)\n            bottom_layout.addStretch()\n\n\n# Without it Qt will destroy the overlay before you can see it\nINFO_OVERLAY_INSTANCE: InfoOverlay | None = None\nTUTORIAL_STEP: int = 0\n\n\ndef show_info_overlay(parent,\n                      text: str,\n                      absolute_position: QPoint,\n                      icon: str = None,\n                      duration: int = 3,\n                      on_close: Callable[[None], None] = None):\n    global INFO_OVERLAY_INSTANCE\n    INFO_OVERLAY_INSTANCE = InfoOverlay(parent,\n                                        text,\n                                        absolute_position,\n                                        icon,\n                                        duration,\n                                        0.8,\n                                        None,\n                                        on_close,\n                                        None,\n                                        None,\n                                        None)\n    INFO_OVERLAY_INSTANCE.show()\n\n\ndef show_tutorial(parent,\n                  get_step: Callable[[int], Tuple[str, QPoint, str]],\n                  width: int | None = None,\n                  first: bool = True,\n                  arrow: str = 'down'):\n    global TUTORIAL_STEP\n    if first:\n        TUTORIAL_STEP = 0\n    TUTORIAL_STEP += 1\n    res = get_step(TUTORIAL_STEP)\n\n    def on_prev():\n        global TUTORIAL_STEP\n        TUTORIAL_STEP -= 2  # That's because the onMousePress event also fires at the same time\n        show_tutorial(parent, get_step, width, False, arrow)\n\n    if res is not None:\n        text, pos, icon = res\n        if text is not None and pos is not None:\n            global INFO_OVERLAY_INSTANCE\n            INFO_OVERLAY_INSTANCE = InfoOverlay(parent,\n                                                text,\n                                                pos,\n                                                f\":/icons/tutorial-{icon}.png\",\n                                                0,\n                                                1,\n                                                width,\n                                                lambda: show_tutorial(parent, get_step, width, False, arrow),\n                                                on_prev if TUTORIAL_STEP > 1 else None,\n                                                None,\n                                                arrow)\n            INFO_OVERLAY_INSTANCE.show()\n\n\ndef show_tutorial_overlay(parent,\n                          text: str,\n                          pos: QPoint,\n                          icon: str,\n                          on_close: Callable[[], None] = None,\n                          on_skip: Callable[[], None] = None,\n                          arrow: str = 'down',\n                          is_last: bool = False):\n    if text is not None and pos is not None:\n        global INFO_OVERLAY_INSTANCE\n        if INFO_OVERLAY_INSTANCE is not None:\n            if INFO_OVERLAY_INSTANCE.isVisible() and INFO_OVERLAY_INSTANCE.get_text() == text:\n                # Don't create duplicates\n                return\n            INFO_OVERLAY_INSTANCE.close()\n        INFO_OVERLAY_INSTANCE = InfoOverlay(parent,\n                                            text,\n                                            pos,\n                                            f\":/icons/tutorial-{icon}.png\",\n                                            0,\n                                            1,\n                                            None,\n                                            on_close,\n                                            None,\n                                            on_skip,\n                                            arrow,\n                                            is_last)\n        INFO_OVERLAY_INSTANCE.show()\n"
  },
  {
    "path": "src/fk/qt/oauth.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport base64\nimport json\nimport logging\nimport webbrowser\nfrom typing import Callable\n\nfrom PySide6.QtCore import QUrl, QObject\nfrom PySide6.QtNetwork import QNetworkAccessManager\nfrom PySide6.QtNetworkAuth import QAbstractOAuth, QOAuth2AuthorizationCodeFlow, QOAuthHttpServerReplyHandler\n\nlogger = logging.getLogger(__name__)\n\nclient_id = '248052959881-pqd62jj04427c7amt7g72crmu591rip8.apps.googleusercontent.com'\nlocal_port = 64166\nauth_url = 'https://accounts.google.com/o/oauth2/auth'\ntoken_url = 'https://app.flowkeeper.org/token'\n\nMGR: QNetworkAccessManager = None\nHANDLER: QOAuthHttpServerReplyHandler = None\n\n\nclass AuthenticationRecord:\n    type: str\n    email: str\n    refresh_token: str\n    access_token: str\n    id_token: str\n\n    def __str__(self):\n        return (f'AuthenticationRecord({self.type}):\\n'\n                f' - Email: {self.email}\\n'\n                f' - Refresh token: {self.refresh_token}\\n'\n                f' - Access token: {self.access_token}\\n'\n                f' - ID token: {self.id_token}')\n\n\ndef _fix_parameters(stage, parameters):\n    if stage == QAbstractOAuth.Stage.RequestingAccessToken:\n        parameters['client_id'] = [client_id]\n        # The client secret is handled on the server side\n        # parameters['client_secret'] = [client_secret]\n        parameters['code'] = [QUrl.fromPercentEncoding(parameters['code'][0])]\n        parameters['redirect_uri'] = [f'http://127.0.0.1:{local_port}/']\n    elif stage == QAbstractOAuth.Stage.RequestingAuthorization:\n        parameters['access_type'] = ['offline']\n    return parameters\n\n\ndef authenticate(parent: QObject, callback: Callable[[AuthenticationRecord], None]) -> None:\n    logger.debug(f'Authenticating for a refresh token')\n    return _perform_flow(parent, callback, None)\n\n\ndef get_id_token(parent: QObject, callback: Callable[[AuthenticationRecord], None], refresh_token: str) -> None:\n    logger.debug(f'Getting ID token for refresh token {refresh_token}')\n    _perform_flow(parent, callback, refresh_token)\n\n\ndef open_url(url: QUrl | str) -> None:\n    if isinstance(url, QUrl):\n        webbrowser.open(url.toString(), 2)\n    else:\n        webbrowser.open(url, 2)\n\n\ndef _perform_flow(parent: QObject, callback: Callable[[AuthenticationRecord], None], refresh_token: str | None):\n    global MGR, HANDLER\n    if MGR is None:\n        MGR = QNetworkAccessManager(parent)\n    if HANDLER is None:\n        HANDLER = QOAuthHttpServerReplyHandler(local_port, parent)\n    flow = QOAuth2AuthorizationCodeFlow(client_id, auth_url, token_url, MGR, parent)\n    flow.setScope('email')\n    if refresh_token is not None:\n        flow.setRefreshToken(refresh_token)\n    # We are adding the client secret on the server side\n    # flow.setClientIdentifierSharedKey(client_secret)\n    flow.authorizeWithBrowser.connect(open_url)\n    flow.setReplyHandler(HANDLER)\n    flow.setModifyParametersFunction(_fix_parameters)\n    flow.granted.connect(lambda: _granted(flow, callback))\n    flow.error.connect(lambda err: _error(err, flow, callback))\n    if refresh_token is not None:\n        logger.debug('Refreshing access token')\n        flow.refreshAccessToken()\n    else:\n        logger.debug('Requesting access grant')\n        flow.grant()\n\n\ndef _extract_email(id_token: str) -> str:\n    b = bytes(id_token.split('.')[1], 'iso8859-1')\n    t = json.loads(base64.decodebytes(b + b'===='))\n    logger.debug(f'Extracted JWT info: {json.dumps(t)}')\n    return t['email']\n\n\ndef _error(err, flow: QOAuth2AuthorizationCodeFlow, callback: Callable[[AuthenticationRecord], None]):\n    logger.error('Error in OAuth2 Authorization Flow', exc_info=err)\n\n\ndef _granted(flow: QOAuth2AuthorizationCodeFlow, callback: Callable[[AuthenticationRecord], None]):\n    logger.debug('Access granted')\n    id_token = flow.extraTokens().get('id_token', None)\n    email = _extract_email(id_token)\n    auth = AuthenticationRecord()\n    auth.email = email\n    auth.type = 'google'\n    auth.access_token = flow.token()\n    auth.id_token = id_token\n    auth.refresh_token = flow.refreshToken()\n    logger.debug(f'OAuth access granted / refreshed: {auth}')\n    callback(auth)\n"
  },
  {
    "path": "src/fk/qt/pomodoro_delegate.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nfrom PySide6.QtCore import QSize, QObject, QRectF, QModelIndex\nfrom PySide6.QtGui import Qt, QBrush, QPainter, QStaticText\nfrom PySide6.QtSvg import QSvgRenderer\nfrom PySide6.QtWidgets import QStyleOptionViewItem\n\nfrom fk.core.workitem import Workitem\nfrom fk.qt.abstract_item_delegate import AbstractItemDelegate, get_padding\n\nPOMODORO_VOIDED = \"voided\"\n\nPOMODORO_NEW_PLANNED = \"new-planned\"\nPOMODORO_FINISHED_PLANNED = \"finished-planned\"\nPOMODORO_RUNNING_PLANNED = \"running-planned\"\n\nPOMODORO_NEW_UNPLANNED = \"new-unplanned\"\nPOMODORO_FINISHED_UNPLANNED = \"finished-unplanned\"\nPOMODORO_RUNNING_UNPLANNED = \"running-unplanned\"\n\n\nclass PomodoroDelegate(AbstractItemDelegate):\n    _svg_renderer: dict[str, QSvgRenderer]\n    _selection_brush: QBrush\n    _theme: str\n    _cross_out: bool\n    _display_tags: bool\n\n    def _get_renderer(self, name):\n        return QSvgRenderer(\n            f':/icons/{self._theme}/24x24/pomodoro-{name}.svg',\n            aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio)\n\n    def __init__(self,\n                 parent: QObject = None,\n                 theme: str = 'mixed',\n                 selection_color: str = '#555',\n                 crossout_color: str = '#777',\n                 display_tags: bool = False):\n        AbstractItemDelegate.__init__(self, parent, theme, selection_color, crossout_color)\n        self._display_tags = display_tags\n        self._svg_renderer = {\n            POMODORO_VOIDED: self._get_renderer(POMODORO_VOIDED),\n            POMODORO_NEW_PLANNED: self._get_renderer(POMODORO_NEW_PLANNED),\n            POMODORO_FINISHED_PLANNED: self._get_renderer(POMODORO_FINISHED_PLANNED),\n            POMODORO_RUNNING_PLANNED: self._get_renderer(POMODORO_RUNNING_PLANNED),\n            POMODORO_NEW_UNPLANNED: self._get_renderer(POMODORO_NEW_UNPLANNED),\n            POMODORO_FINISHED_UNPLANNED: self._get_renderer(POMODORO_FINISHED_UNPLANNED),\n            POMODORO_RUNNING_UNPLANNED: self._get_renderer(POMODORO_RUNNING_UNPLANNED),\n        }\n\n    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:\n        if index.data(501) == 'pomodoro':  # We can also get a drop placeholder here, which we don't want to paint\n            painter.save()\n            space: QRectF = option.rect\n\n            workitem: Workitem = index.data(500)\n            self.paint_background(painter, option, workitem.is_sealed() if self._display_tags else False)\n\n            s: QSize = index.data(Qt.ItemDataRole.SizeHintRole)\n            height = s.height()\n            # This would've worked just fine if our SVGs had no margins\n            # height = option.fontMetrics.height()\n\n            left = space.left()\n            text_padding = get_padding(option)\n            one_line_height = option.fontMetrics.height() + 2 * text_padding\n            padding = (one_line_height - height) / 2\n\n            if workitem.is_tracker():\n                st = QStaticText(index.data())\n                st.setTextWidth(space.width() - 4)\n                painter.drawStaticText(left + 4,\n                                       space.top() + text_padding,\n                                       st)\n            else:\n                for p in workitem.values():\n                    width = height\n                    rect = QRectF(\n                        left,\n                        space.top() + padding,  # space.center().y() - (size / 2) + 1,\n                        width,\n                        height)\n\n                    if p.is_running():\n                        renderer = POMODORO_RUNNING_PLANNED if p.is_planned() else POMODORO_RUNNING_UNPLANNED\n                    elif p.is_finished():\n                        renderer = POMODORO_FINISHED_PLANNED if p.is_planned() else POMODORO_FINISHED_UNPLANNED\n                    else:\n                        renderer = POMODORO_NEW_PLANNED if p.is_planned() else POMODORO_NEW_UNPLANNED\n\n                    self._svg_renderer[renderer].render(painter, rect)\n                    left += width\n\n                    for _ in range(len(p)):\n                        width = height / 4\n                        rect = QRectF(\n                            left,\n                            space.top() + padding,  # space.center().y() - (size / 2) + 1,\n                            width,\n                            height)\n\n                        self._svg_renderer[POMODORO_VOIDED].render(painter, rect)\n                        left += width\n\n            painter.restore()\n"
  },
  {
    "path": "src/fk/qt/progress_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL\nfrom fk.core.tag import Tag\nfrom fk.core.timer_data import TimerData\n\n\nclass ProgressWidget(QWidget):\n    _label: QLabel\n\n    def __init__(self,\n                 parent: QWidget,\n                 source_holder: EventSourceHolder):\n        super().__init__(parent)\n        self.setObjectName('progress')\n        layout = QHBoxLayout(self)\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.setSpacing(0)\n        self._label = QLabel(self)\n        self._label.setObjectName(\"footerLabel\")\n        layout.addWidget(self._label)\n        self.setVisible(False)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        self.update_progress(None)\n        source.on(\"AfterWorkitem*\", lambda workitem, **kwargs: self.update_progress(workitem.get_parent()))\n        source.on('AfterPomodoro*',\n                  lambda **kwargs: self.update_progress(\n                      kwargs['workitem'].get_parent() if 'workitem' in kwargs else kwargs['pomodoro'].get_parent().get_parent()\n                  ))\n\n    def update_progress(self, backlog_or_tag: Backlog | Tag | None) -> None:\n        total: int = 0\n        done: int = 0\n        in_series: int = -1\n        if backlog_or_tag:\n            workitems = backlog_or_tag.values() if type(backlog_or_tag) is Backlog else backlog_or_tag.get_workitems()\n            timer: TimerData = backlog_or_tag.get_parent().get_timer() if type(backlog_or_tag) is Backlog else backlog_or_tag.get_parent().get_parent().get_timer()\n            in_series = timer.get_pomodoro_in_series()\n            for wi in workitems:\n                for p in wi.values():\n                    if p.get_type() == POMODORO_TYPE_NORMAL:\n                        total += 1\n                        if p.is_finished():\n                            done += 1\n\n        self.setVisible(total > 0)\n        self._label.setVisible(total > 0)\n        percent = f' ({round(100 * done / total)}%)' if total > 0 else ''\n        series = f', {in_series} in this set' if in_series > 0 else ''\n        self._label.setText(f'Done {done} of {total} pomodoros{percent}{series}')\n"
  },
  {
    "path": "src/fk/qt/qt_filesystem_watcher.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom typing import Callable\n\nfrom PySide6 import QtCore\n\nfrom fk.core.abstract_filesystem_watcher import AbstractFilesystemWatcher\n\n\nclass QtFilesystemWatcher(AbstractFilesystemWatcher):\n    _connections: dict[str, list[Callable]]\n    _watcher: QtCore.QFileSystemWatcher\n\n    def __init__(self):\n        self._connections = dict()\n        self._watcher = QtCore.QFileSystemWatcher()\n        self._watcher.fileChanged.connect(lambda f: self._on_file_change(f))\n\n    def watch(self, filename: str, callback: Callable[[str], None]):\n        self._watcher.addPath(filename)\n        if filename not in self._connections:\n            self._connections[filename] = list()\n        self._connections[filename].append(callback)\n\n    def unwatch(self, filename: str) -> None:\n        if filename in self._connections:\n            self._watcher.removePath(filename)\n            del self._connections[filename]\n\n    def unwatch_all(self) -> None:\n        self._watcher.removePaths(self._watcher.files())\n        self._connections.clear()\n\n    def _on_file_change(self, filename: str) -> None:\n        for callback in self._connections[filename]:\n            callback(filename)\n"
  },
  {
    "path": "src/fk/qt/qt_invoker.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtCore import QEvent, QObject, QCoreApplication\n\n\nclass InvokeEvent(QEvent):\n    EVENT_TYPE = QEvent.Type(QEvent.registerEventType())\n\n    def __init__(self, fn, **kwargs):\n        QEvent.__init__(self, InvokeEvent.EVENT_TYPE)\n        self.fn = fn\n        self.kwargs = kwargs\n\n\nclass Invoker(QObject):\n    def event(self, e):\n        e.fn(**e.kwargs)\n        return True\n\n\n_invoker = Invoker()\n\n\ndef invoke_in_main_thread(fn, **kwargs):\n    QCoreApplication.postEvent(_invoker,\n                               InvokeEvent(fn, **kwargs))\n"
  },
  {
    "path": "src/fk/qt/qt_settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport json\nimport logging\nimport re\nimport sys\n\nimport keyring\nfrom PySide6 import QtCore\nfrom PySide6.QtCore import QStandardPaths\nfrom PySide6.QtGui import QFont, Qt, QGuiApplication, QGradient\nfrom PySide6.QtMultimedia import QMediaDevices\nfrom PySide6.QtWidgets import QMessageBox, QApplication\n\nfrom fk.core import events\nfrom fk.core.abstract_settings import AbstractSettings, _is_gnome\nfrom fk.core.sandbox import get_sandbox_type\nfrom fk.qt.qt_invoker import invoke_in_main_thread\n\nSECRET_NAME = 'all-secrets'\nlogger = logging.getLogger(__name__)\n\n\ndef _check_keyring() -> bool:\n    return not isinstance(keyring.get_keyring(), keyring.backends.fail.Keyring)\n\n\nclass QtSettings(AbstractSettings):\n    _settings: QtCore.QSettings\n    _app_name: str\n    _keyring_enabled: bool\n    _is_wayland: bool\n\n    def __init__(self, app_name: str = 'flowkeeper-desktop'):\n        self._app_name = app_name\n        self._is_wayland = QGuiApplication.platformName() == 'wayland'\n        super().__init__(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation),\n                         QStandardPaths.writableLocation(QStandardPaths.StandardLocation.CacheLocation),\n                         invoke_in_main_thread,\n                         self._is_wayland)\n        self._settings = QtCore.QSettings(\"flowkeeper\", app_name)\n\n        keyring_feature_enabled = self.get('Application.feature_keyring') == 'True'\n        self._keyring_enabled = keyring_feature_enabled and _check_keyring()\n\n        if keyring_feature_enabled and not self._keyring_enabled:\n            # Display the warning only if keyring check failed\n            self._display_warning_if_needed()\n\n        if not self._keyring_enabled:\n            self._disable_connected_sources()  # Disable and hide forbidden source types\n            self._disable_secrets()  # Disable and hide forbidden encryption settings\n\n        connect_feature_enabled = self.get('Application.feature_connect') == 'True'\n        if not connect_feature_enabled:\n            self._disable_connected_sources()\n\n        self.init_audio_outputs()\n        self.init_gradients()\n        self.init_fonts()\n        self.init_appearance()\n        self.init_network_access()\n\n    def _display_warning_if_needed(self) -> None:\n        if self.get('Application.ignore_keyring_errors') == 'False':\n            if QMessageBox().warning(\n                    None,\n                    \"No keyring\",\n                    \"Flowkeeper couldn't detect a compatible keyring for storing credentials. You can try to install one \"\n                    \"(for example, on Kubuntu 20.04, this can be fixed by installing gnome-keyring), or ignore this \"\n                    \"warning. If you choose to ignore it, the following features will be disabled:\\n\\n\"\n                    \"1. Data sync with flowkeeper.org,\\n\"\n                    \"2. Data sync with custom Flowkeeper Server,\\n\"\n                    \"3. End-to-end data encryption.\",\n                    QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Abort\n            ) == QMessageBox.StandardButton.Ignore:\n                logger.debug('Compatible keyring is not found and the user chose to ignore it. '\n                             'Encryption and websockets will be disabled.')\n                self.set({'Application.ignore_keyring_errors': 'True'})\n            else:\n                logger.error('Compatible keyring is not found and the user chose not to ignore it. Exiting.')\n                sys.exit(1)\n\n    def _disable_connected_sources(self) -> None:\n        if self.is_remote_source():\n            self.set({'Source.type': 'local'})\n\n        original = self.get_configuration('Source.type')\n        for option in list(original):\n            key = option.split(':')[0]\n            if key in ['flowkeeper.org', 'flowkeeper.pro', 'websocket']:\n                original.remove(option)\n\n    def _disable_secrets(self) -> None:\n        if self.get('Source.encryption_enabled') == 'True':\n            self.set({'Source.encryption_enabled': 'False'})\n\n        # TODO: Reimplement this via some bool variable on the AbstractSettings class, e.g. \"is_encryption_disabled\"\n        #  and updating the corresponding visibility checks. This would be a more elegant solution.\n        self.hide('Source.encryption_enabled')\n        self.hide('Source.encryption_key!')\n        self.hide('Source.encryption_separator')\n\n    def set(self, values: dict[str, str], force_fire=False) -> None:\n        old_values: dict[str, str] = dict()\n        for name in values.keys():\n            old_value = self.get(name)\n            if old_value != values[name] or force_fire:\n                old_values[name] = old_value\n        if len(old_values) > 0:\n            params = {\n                'old_values': old_values,\n                'new_values': values,\n            }\n            self._emit(events.BeforeSettingsChanged, params)\n            # We have to set settings via invoke_in_main_thread(), otherwise it won't be queued\n            # correctly in respect to BeforeSettingsChanged and AfterSettingsChanged. Without\n            # invoke_in_main_thread it might happen that the setting will be de-facto set first,\n            # and only then a pair of BeforeSettingsChanged / AfterSettingsChanged emitted.\n            def set_settings():\n                encrypted = dict()\n                for name in old_values.keys():  # This is not a typo, we've just filtered this list\n                    # to only contain settings which actually changed.\n                    if name.endswith('!'):\n                        # We want to set all secrets at once (see explanation below in get())\n                        encrypted[name] = values[name]\n                    else:\n                        self._settings.setValue(name, values[name])\n                if len(encrypted) > 0:\n                    if self._keyring_enabled:\n                        existing = self.load_secret()\n                        for e in encrypted:\n                            existing[e] = encrypted[e]\n                        keyring.set_password(self._app_name, SECRET_NAME, json.dumps(existing))\n                    else:\n                        logger.warning(f'Setting encrypted preferences {encrypted.keys()}, while the keyring is disabled')\n                self._emit(events.AfterSettingsChanged, params)\n            invoke_in_main_thread(set_settings)\n\n    def load_secret(self) -> dict[str, str]:\n        json_str = keyring.get_password(self._app_name, SECRET_NAME)\n        return json.loads(json_str) if json_str else dict()\n\n    def get(self, name: str) -> str:\n        if name.endswith('!'):\n            if self._keyring_enabled:\n                # MacOS keeps asking to unlock login keychain *for each* password. I couldn't find how to avoid\n                # this, and decided to squeeze *all* passwords into a single JSON secret instead.\n                j = self.load_secret()\n                if name in j and j[name] is not None:\n                    return j[name]\n                else:\n                    return ''\n            else:\n                return self._defaults[name]\n        else:\n            return str(self._settings.value(name, self._defaults[name]))\n\n    def is_set(self, name: str) -> bool:\n        if name.endswith('!'):\n            if self._keyring_enabled:\n                j = self.load_secret()\n                return name in j and j[name] is not None\n            else:\n                return False\n        else:\n            return self._settings.contains(name)\n\n    def location(self) -> str:\n        return self._settings.fileName()\n\n    def clear(self) -> None:\n        self._settings.clear()\n        try:\n            keyring.delete_password(self._app_name, SECRET_NAME)\n        except Exception as e:\n            # Ignore, this is a common issue with keyring module.\n            pass\n\n    def is_keyring_enabled(self) -> bool:\n        return self._keyring_enabled\n\n    def get_auto_theme(self) -> str:\n        scheme = QApplication.styleHints().colorScheme()\n        if scheme == Qt.ColorScheme.Dark:\n            return 'dark'\n        else:\n            return 'mixed'\n\n    def init_audio_outputs(self):\n        choice = []\n        for d in self._definitions['Audio']:\n            if d[0] == 'Application.audio_output':\n                choice = d[4]\n                choice.clear()\n                for output in QMediaDevices.audioOutputs():\n                    name = output.id().toStdString().replace(':', '$$')\n                    choice.append(f'{name}:{output.description().replace(\":\", \"$$\")}')\n                    if output.isDefault():\n                        self.update_default('Application.audio_output', name)\n                break\n        if len(choice) == 0:\n            choice.append('#none:No audio outputs detected')\n            self.update_default('Application.audio_output', '#none')\n\n    def init_gradients(self):\n        regex = re.compile('([A-Z][a-z]+)([A-Z].+)?')\n        for d in self._definitions['Appearance']:\n            if d[0] == 'Application.eyecandy_gradient':\n                choice = d[4]\n                choice.clear()\n                for preset in QGradient.Preset:\n                    if preset.name == 'NumPresets':\n                        continue\n                    m = regex.search(preset.name)\n                    if m is not None:\n                        if m.group(2):\n                            display_name = f'{m.group(1)} {m.group(2)}'\n                        else:\n                            display_name = f'{m.group(1)}'\n                        choice.append(f'{preset.name}:{display_name}')\n                choice.sort()\n                break\n\n    def init_fonts(self):\n        default_font = QFont()\n        self.update_default('Application.font_main_family', default_font.family())\n        self.update_default('Application.font_main_size', str(default_font.pointSize()))\n        self.update_default('Application.font_header_family', default_font.family())\n        self.update_default('Application.font_header_size', str(int(8.0 / 3 * default_font.pointSize())))\n\n    def init_appearance(self):\n        if _is_gnome():\n            self.set({\n                'Application.show_window_title': 'True',\n                'Application.quit_on_close': 'True',\n            })\n        if self._is_wayland:\n            self.set({\n                'Application.show_window_title': 'True',\n                'Application.always_on_top': 'False',\n                'Application.show_window_title': 'True',\n            })\n\n    def init_network_access(self):\n        if get_sandbox_type() is not None:\n            self.set({\n                'Application.singleton': 'False',\n                'Application.check_updates': 'False',\n            })\n"
  },
  {
    "path": "src/fk/qt/qt_timer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nimport threading\nfrom typing import Callable\n\nfrom PySide6.QtCore import QEvent, QCoreApplication, QTimer, QObject\n\nfrom fk.core.timer import AbstractTimer\n\nlogger = logging.getLogger(__name__)\n\n\nclass QExtendedTimer(QTimer):\n    def __init__(self, parent: QObject | None = None):\n        super().__init__(parent)\n\n    def customEvent(self, event: QEvent) -> None:\n        logger.debug(f'QExtendedTimer - customEvent, {threading.get_ident()}, {self.objectName()}')\n        self.start(int(event.ms))\n\n    def schedule_start(self, ms: float) -> None:\n        logger.debug(f'QExtendedTimer - scheduling, {threading.get_ident()}, {self.objectName()}')\n        # We need to be careful -- this function might be called\n        # from a non-GUI thread. We should decouple it via Slots.\n        e = QEvent(QEvent.Type.User)\n        e.ms = ms\n        QCoreApplication.postEvent(self, e)\n\n\nclass QtTimer(AbstractTimer):\n    _timer: QExtendedTimer\n    _callback: Callable[[dict, datetime.datetime], None]\n    _params: dict | None\n    _once: bool\n    _name: str\n\n    def __init__(self, name: str, parent: QObject | None = None):\n        self._name = name\n        logger.debug(f'Creating timer {name}')\n        self._timer = QExtendedTimer(parent)\n        self._timer.setObjectName(name)\n        self._timer.timeout.connect(lambda: self._call())\n\n    def _call(self) -> None:\n        if self._once:\n            self._timer.stop()\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f'QtTimer - callback, {threading.get_ident()}, {self._name}')\n        self._callback(self._params, datetime.datetime.now(datetime.timezone.utc))\n\n    def schedule(self,\n                 ms: float,\n                 callback: Callable[[dict, datetime.datetime], None],\n                 params: dict | None,\n                 once: bool = False) -> None:\n        # We need to be careful -- this function might be called\n        # from a non-GUI thread. We should decouple it via Slots.\n        self._callback = callback\n        self._params = params\n        self._once = once\n        # self._timer.schedule_start(ms)\n        self._timer.start(int(ms))\n\n    def cancel(self):\n        self._timer.stop()\n"
  },
  {
    "path": "src/fk/qt/render/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/qt/render/abstract_timer_renderer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport math\nfrom abc import abstractmethod\n\nfrom PySide6 import QtCore, QtGui, QtWidgets\nfrom PySide6.QtCore import QObject, QRect, QPointF\nfrom PySide6.QtGui import QPainter, QColor\nfrom PySide6.QtWidgets import QWidget\n\nlogger = logging.getLogger(__name__)\n\n\ndef rotate_point(x: float, y: float, cx: float, cy: float, phi: float) -> QPointF:\n    sin = math.sin(phi)\n    cos = math.cos(phi)\n    return QPointF(cos * (x - cx) - sin * (y - cy) + cx,\n                   sin * (x - cx) + cos * (y - cy) + cy)\n\n\nclass AbstractTimerRenderer(QObject):\n    _widget: QWidget | None\n    _my_value: float | None\n    _my_max: float | None\n    _team_value: float | None\n    _team_max: float | None\n    _mode: str\n    _bg_color: QColor\n    _fg_color: QColor\n    _thin: bool\n    _small: bool\n\n    def __init__(self,\n                 parent: QWidget | None,\n                 bg_color: QColor = None,\n                 fg_color: QColor = None,\n                 thin: bool = False,\n                 small: bool = False):\n        super(AbstractTimerRenderer, self).__init__(parent)\n        self._widget = parent\n        if bg_color is None or fg_color is None:\n            raise Exception('Renderer needs to know the colors')\n        self._bg_color = bg_color\n        self._fg_color = fg_color\n        self._thin = thin\n        self._small = small\n        self.reset()\n\n    def set_colors(self, bg_color: QColor, fg_color: QColor):\n        self._bg_color = bg_color\n        self._fg_color = fg_color\n        self.repaint()\n\n    def reset(self) -> None:\n        self._my_value = 0\n        self._my_max = 0\n        self._team_value = None\n        self._team_max = None\n        self._mode = 'idle'\n\n    def set_values(self,\n                   my_value: float,\n                   my_max: float,\n                   team_value: float | None,\n                   team_max: float | None,\n                   mode: str) -> None:\n        self._my_value = my_value\n        self._my_max = my_max\n        self._team_value = team_value\n        self._team_max = team_max\n        self._mode = mode\n\n    @abstractmethod\n    def paint(self, painter: QPainter, rect: QRect) -> None:\n        pass\n\n    @abstractmethod\n    def has_idle_display(self) -> bool:\n        pass\n\n    @abstractmethod\n    def has_next_display(self) -> bool:\n        pass\n\n    def eventFilter(self, widget: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:\n        if self._widget is None:\n            logger.error(f\"Cannot filter events like {event} on a {self.__class__.__name__}\")\n        if widget == self._widget and event.type() == QtCore.QEvent.Type.Paint:\n            painter = QtGui.QPainter(widget)\n            rect = widget.contentsRect()\n            self.paint(painter, rect)\n            return True\n        return False\n\n    def repaint(self, painter: QPainter = None, rect: QRect = None) -> None:\n        if self._widget is None:\n            self.paint(painter, rect)\n        else:\n            self._widget.repaint()\n\n    def get_mode(self):\n        return self._mode\n"
  },
  {
    "path": "src/fk/qt/render/classic_timer_renderer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport math\n\nfrom PySide6 import QtCore, QtGui, QtWidgets\nfrom PySide6.QtCore import QPointF\nfrom PySide6.QtGui import QColor, QPainterPath, QPen\n\nfrom fk.qt.render.abstract_timer_renderer import AbstractTimerRenderer\n\n\nclass ClassicTimerRenderer(AbstractTimerRenderer):\n    def __init__(self,\n                 parent: QtWidgets.QWidget | None,\n                 bg_color: QColor = None,\n                 fg_color: QColor = None,\n                 thin: bool = False,\n                 small: bool = False):\n        super(ClassicTimerRenderer, self).__init__(parent, bg_color, fg_color, thin, small)\n\n    def clip(self, painter: QtGui.QPainter, rect: QtCore.QRectF | None, entire: QtCore.QRectF, shift: float = 0) -> None:\n        # I also tried painter.setClipRegion(QRegion), but it won't apply antialiasing, looking ugly\n        full = QPainterPath()\n        full.addRect(entire)\n        if rect is not None:\n            hole = QPainterPath()\n            hole.addEllipse(rect.center(), rect.width() / 2 + shift, rect.height() / 2 + shift)\n            full = full.subtracted(hole)\n        painter.setClipPath(full)\n\n    def clear_pie_outline(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None:\n        pen_border = QPen(self._fg_color)\n        pen_border.setWidthF(3)\n        painter.setPen(pen_border)\n        painter.drawEllipse(rect)\n\n    def draw_sector(self,\n                    painter: QtGui.QPainter,\n                    my_rect: QtCore.QRectF,\n                    value: float,\n                    max_value: float,\n                    invert_colors: bool = False):\n        hue_from = 120 if invert_colors else 0\n        hue_to = 0 if invert_colors else 120\n        pen_width = 2\n\n        if max_value == 0:\n            max_value = 1 # This should never happen\n        my_hue = int((hue_to - hue_from) * value / max_value + hue_from)\n        my_color_pen = QtGui.QPen(self._fg_color)\n        my_color_pen.setWidth(pen_width)\n        my_color_brush = QtGui.QColor().fromHsl(my_hue, 255, 128)\n        painter.setPen(my_color_pen)\n        painter.setBrush(my_color_brush)\n        painter.drawPie(my_rect,\n                        int(5760 * (1.0 - value / max_value) + 1440),\n                        int(5760 * value / max_value))\n\n    def draw_points(self,\n                    painter: QtGui.QPainter,\n                    my_rect: QtCore.QRectF):\n        pen_width = 2\n        pen = QtGui.QPen(self._fg_color)\n        pen.setWidth(pen_width)\n        painter.setPen(pen)\n        center = my_rect.center()\n        radius = my_rect.width() / 2.0\n        for hour in range(0, 12):\n            sin = math.sin(hour * math.pi / 6)\n            cos = math.cos(hour * math.pi / 6)\n            painter.drawPoint(QPointF(center.x() + radius * sin, center.y() + radius * cos))\n\n    def has_idle_display(self) -> bool:\n        return False\n\n    def has_next_display(self) -> bool:\n        return False\n\n    def paint(self, painter: QtGui.QPainter, rect: QtCore.QRectF) -> None:\n        if self.get_mode() not in ('working', 'resting', 'long-resting', 'tracking'):\n            painter.end()\n            return\n\n        margin = 0.05\n        thickness = 0.3\n        has_two_sectors = self._team_value is not None\n\n        rw = rect.width()\n        rh = rect.height()\n        painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)\n\n        my_width = rw * (1 - 2 * margin) * thickness\n        my_height = rh * (1 - 2 * margin) * thickness\n        if has_two_sectors:\n            my_width /= 2\n            my_height /= 2\n\n        # My\n        my_rect = QtCore.QRectF(\n            rect.left() + rw * margin,\n            rect.top() + rh * margin,\n            rect.width() - 2 * rw * margin,\n            rect.height() - 2 * rh * margin,\n        )\n\n        if has_two_sectors:\n            # Team or tracking\n            team_width = rw * (1 - 2 * margin) * thickness / 2\n            team_height = rh * (1 - 2 * margin) * thickness / 2\n        else:\n            team_width = 0\n            team_height = 0\n\n        hole_rect = None\n        if thickness < 0.5 and not self._small:\n            # Hole\n            hole_rect = QtCore.QRectF(\n                my_rect.left() + my_width + team_width,\n                my_rect.top() + my_height + team_height,\n                my_rect.width() - 2 * (my_width + team_width),\n                my_rect.height() - 2 * (my_height + team_height),\n            )\n            self.clip(painter, hole_rect, rect, 1)\n\n        if has_two_sectors:\n            # Team or tracking\n            team_rect = QtCore.QRectF(\n                my_rect.left() + my_width,\n                my_rect.top() + my_height,\n                my_rect.width() - 2 * my_width,\n                my_rect.height() - 2 * my_height,\n            )\n            if self._team_value is not None and self._team_max > 0:\n                self.draw_sector(painter, team_rect, self._team_value, self._team_max)\n            self.clip(painter, team_rect, rect, 1)\n\n        if self.get_mode() == 'tracking' or self.get_mode() == 'long-resting':\n            minutes = (self._my_value / 60) % 60.0\n            self.draw_sector(painter, my_rect, minutes, 60, True)\n        elif self._my_max > 0:\n            self.draw_sector(painter, my_rect, self._my_value, self._my_max)\n\n        self.draw_points(painter, my_rect)\n\n        if hole_rect is not None:\n            # Draw the hole outline\n            self.clip(painter, hole_rect, rect, 0)\n            self.clear_pie_outline(painter, hole_rect)\n\n        painter.end()\n"
  },
  {
    "path": "src/fk/qt/render/minimal_timer_renderer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport math\n\nfrom PySide6 import QtCore, QtGui, QtWidgets\nfrom PySide6.QtCore import QPointF, QLineF\nfrom PySide6.QtGui import QColor, QPen, Qt, QBrush\n\nfrom fk.qt.render.abstract_timer_renderer import AbstractTimerRenderer, rotate_point\n\n\nclass MinimalTimerRenderer(AbstractTimerRenderer):\n    def __init__(self,\n                 parent: QtWidgets.QWidget | None,\n                 bg_color: QColor = None,\n                 fg_color: QColor = None,\n                 thin: bool = False,\n                 small: bool = False):\n        super(MinimalTimerRenderer, self).__init__(parent, bg_color, fg_color, thin, small)\n\n    def _dial_pen(self, th: float) -> QPen:\n        if self._thin:\n            color = self._fg_color\n        else:\n            if self._bg_color.value() < 128:\n                # Dark background\n                if self.get_mode() == 'working' or self.get_mode() == 'tracking':\n                    color = '#e06666'\n                elif self.get_mode() == 'resting' or self.get_mode() == 'long-resting':\n                    color = '#6d9eeb'\n                elif self.get_mode() == 'ready':\n                    color = '#00e000'\n                else:\n                    color = '#ffffff'\n            else:\n                if self.get_mode() == 'working' or self.get_mode() == 'tracking':\n                    color = '#cc0000'\n                elif self.get_mode() == 'resting' or self.get_mode() == 'long-resting':\n                    color = '#3c78d8'\n                elif self.get_mode() == 'ready':\n                    color = '#006400'\n                else:\n                    color = '#000000'\n        outline = QPen(QColor(color), th)\n        outline.setCapStyle(Qt.PenCapStyle.RoundCap)\n        return outline\n\n    def _next_brush(self) -> QBrush:\n        if self._thin:\n            color = self._fg_color\n        else:\n            if self._bg_color.value() < 128:\n                # Dark background\n                color = '#00e000'\n            else:\n                color = '#006400'\n        return QBrush(QColor(color))\n\n    def _hand_pen(self, th: float) -> QPen:\n        hand = QPen(QColor(self._fg_color), th)\n        hand.setCapStyle(Qt.PenCapStyle.RoundCap)\n        return hand\n\n    def has_idle_display(self) -> bool:\n        return True\n\n    def has_next_display(self) -> bool:\n        return True\n\n    def paint(self, painter: QtGui.QPainter, rect: QtCore.QRect) -> None:\n        size = rect.width()\n        th = size * 0.1\n        shift = th / 2\n        radius = (size - 2 * shift - th) / 2\n        hand_length = radius - 2\n        center = rect.center()\n        center.setY(center.y() + shift)\n        cx = center.x()\n        cy = center.y()\n\n        painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)\n\n        # Dial and \"buttons\"\n        painter.setPen(self._dial_pen(th))\n        painter.drawEllipse(center, radius, radius)\n\n        painter.setPen(self._dial_pen(th / 2))\n        painter.drawLine(QLineF(cx * 0.8, th / 4, cx * 1.2, th / 4))\n        painter.drawLine(QLineF(cx, th / 4 + 1, cx, shift * 2))\n\n        painter.drawLine(QLineF(rotate_point(cx * 0.8, th / 4, cx, cy, math.pi / 4),\n                                rotate_point(cx * 1.2, th / 4, cx, cy, math.pi / 4)))\n        painter.drawLine(QLineF(rotate_point(cx, th / 4 + 1, cx, cy, math.pi / 4),\n                                rotate_point(cx, shift * 2, cx, cy, math.pi / 4)))\n\n        if self._mode == 'ready':\n            # Draw the \"next arrow\"\n            painter.setPen(Qt.PenStyle.NoPen)\n            painter.setBrush(self._next_brush())\n            aspect = 0.666\n            painter.drawPolygon([\n                QPointF(cx * 0.8, cy - hand_length * aspect),\n                QPointF(cx + hand_length * aspect, cy),\n                QPointF(cx * 0.8, cy + hand_length * aspect),\n            ])\n        elif self._mode == 'tracking' or self._mode == 'long-resting':\n            # Draw a pair of hands\n            v = int(self._my_value)\n            # 1. Second hand\n            seconds = (self._my_value % 60) / 60.0\n            sin = math.sin(2 * math.pi * seconds)\n            cos = math.cos(2 * math.pi * seconds)\n            painter.setPen(self._hand_pen(th / 6))\n            pt = QPointF(cx + hand_length * sin,\n                         cy - hand_length * cos)\n            painter.drawLine(center, pt)\n            # 2. Minute hand\n            minutes = ((self._my_value / 60) % 60.0) / 60.0\n            sin = math.sin(2 * math.pi * minutes)\n            cos = math.cos(2 * math.pi * minutes)\n            painter.setPen(self._hand_pen(th / 4))\n            pt = QPointF(cx + hand_length * 0.75 * sin,\n                         cy - hand_length * 0.75 * cos)\n            painter.drawLine(center, pt)\n            # 3. Hour hand\n            hour = ((self._my_value / 60 / 60) % 12) / 12.0\n            sin = math.sin(2 * math.pi * hour)\n            cos = math.cos(2 * math.pi * hour)\n            painter.setPen(self._hand_pen(th / 2))\n            pt = QPointF(cx + hand_length * 0.5 * sin,\n                         cy - hand_length * 0.5 * cos)\n            painter.drawLine(center, pt)\n        elif self._mode not in ('working', 'resting'):\n            # Draw the \"hanging hand\"\n            painter.drawLine(center, QPointF(cx, cy - hand_length))\n        else:\n            # Draw the hand\n            sin = math.sin(\n                2 * math.pi * self._my_value / self._my_max) \\\n                if self._my_max != 0 and self._my_max is not None \\\n                else 0\n            cos = math.cos(\n                2 * math.pi * self._my_value / self._my_max) \\\n                if self._my_max != 0 and self._my_max is not None \\\n                else 0\n            painter.setPen(self._hand_pen(th / 2))\n            hx = cx + hand_length * sin\n            hy = cy - hand_length * cos\n            pt = QPointF(hx, hy)\n            painter.drawLine(center, pt)\n\n        painter.end()\n"
  },
  {
    "path": "src/fk/qt/resize_event_filter.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtCore import QObject, QEvent, QSize\nfrom PySide6.QtGui import QResizeEvent\nfrom PySide6.QtWidgets import QWidget, QMainWindow, QSplitter\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.qt.qt_timer import QtTimer\n\n\nclass ResizeEventFilter(QMainWindow):\n    _window: QMainWindow\n    _timer: QtTimer\n    _is_resizing: bool\n    _settings: AbstractSettings\n    _main_layout: QWidget\n    _splitter: QSplitter\n\n    def __init__(self,\n                 window: QMainWindow,\n                 main_layout: QWidget,\n                 settings: AbstractSettings):\n        super().__init__()\n        self._window = window\n        self._settings = settings\n        self._main_layout = main_layout\n        self._timer = QtTimer(\"Window resizing\")\n        self._is_resizing = False\n\n        # Splitter\n        # noinspection PyTypeChecker\n        self._splitter = window.findChild(QSplitter, \"splitter\")\n        self._splitter.splitterMoved.connect(self.save_splitter_size)\n\n        self.restore_size()\n\n    def resize_completed(self):\n        self._is_resizing = False\n        if not self._main_layout.isVisible():  # Avoid saving window size in Timer mode\n            return\n        # We'll check against the old value to avoid resize loops and spurious setting change events\n        new_width = self._window.size().width()\n        new_height = self._window.size().height()\n        old_width = int(self._settings.get('Application.window_width'))\n        old_height = int(self._settings.get('Application.window_height'))\n        if old_width != new_width or old_height != new_height:\n            self._settings.set({\n                'Application.window_width': str(new_width),\n                'Application.window_height': str(new_height),\n            })\n\n    def eventFilter(self, widget: QObject, event: QEvent) -> bool:\n        if event.type() == QEvent.Type.Resize and isinstance(event, QResizeEvent):\n            if widget == self._window:\n                if self._is_resizing:   # Don't fire those events too frequently\n                    return False\n                self._timer.schedule(1000,\n                                     lambda _1, _2: self.resize_completed(),\n                                     None,\n                                     True)\n                self._is_resizing = True\n        return False\n\n    def restore_size(self) -> None:\n        w = int(self._settings.get('Application.window_width'))\n        h = int(self._settings.get('Application.window_height'))\n        splitter_width = int(self._settings.get('Application.window_splitter_width'))\n        self._splitter.setSizes([splitter_width, w - splitter_width])\n        self._window.resize(QSize(w, h))\n\n    def save_splitter_size(self, new_width: int, index: int) -> None:\n        old_width = int(self._settings.get('Application.window_splitter_width'))\n        if old_width != new_width:\n            self._settings.set({'Application.window_splitter_width': str(new_width)})\n"
  },
  {
    "path": "src/fk/qt/search_completer.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6 import QtWidgets, QtGui, QtCore\nfrom PySide6.QtCore import QModelIndex\nfrom PySide6.QtGui import QStandardItemModel, QStandardItem\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.qt.abstract_tableview import AbstractTableView\nfrom fk.qt.actions import Actions\n\n\nclass SearchBar(QtWidgets.QLineEdit):\n    _source_holder: EventSourceHolder\n    _backlogs_table: AbstractTableView[User, Backlog]\n    _workitems_table: AbstractTableView[Backlog, Workitem]\n    _hide_completed: bool\n    _actions: Actions\n\n    def __init__(self,\n                 parent: QtWidgets.QWidget,\n                 source_holder: EventSourceHolder,\n                 actions: Actions,\n                 backlogs_table: AbstractTableView[User, Backlog],\n                 workitems_table: AbstractTableView[Backlog, Workitem]):\n        super().__init__(parent)\n        self.setObjectName(\"search\")\n        self._source_holder = source_holder\n        self._backlogs_table = backlogs_table\n        self._workitems_table = workitems_table\n        self._hide_completed = False\n        self.hide()\n        self.setPlaceholderText('Search')\n        self.installEventFilter(self)\n        self._actions = actions\n        actions['workitems_table.hideCompleted'].toggled.connect(self.hide_completed)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        if self.isVisible():\n            self.hide()\n\n    def _select(self, index: QModelIndex):\n        workitem: Workitem = index.data(500)\n        backlog: Backlog = workitem.get_parent()\n        self._backlogs_table.select(backlog)\n        # Queue the second selection step, as AfterSelectionChanged\n        # will go through Qt postEvent\n        self._source_holder.get_source().get_settings().invoke_callback(\n            lambda w: self._workitems_table.select(w),\n            w=workitem)\n        self.hide()\n\n    def show(self) -> None:\n        completer = QtWidgets.QCompleter()\n        completer.setObjectName('search_completer')\n        completer.activated[QModelIndex].connect(lambda index: self._select(index))\n        completer.setFilterMode(QtGui.Qt.MatchFlag.MatchContains)\n        completer.setCaseSensitivity(QtGui.Qt.CaseSensitivity.CaseInsensitive)\n\n        model = QStandardItemModel()\n        for wi in self._source_holder.get_source().workitems():\n            if self._hide_completed and wi.is_sealed():\n                continue\n            item = QStandardItem()\n            item.setText(wi.get_name())\n            item.setData(wi, 500)\n            model.appendRow(item)\n        completer.setModel(model)\n\n        self.setCompleter(completer)\n        self.setFocus()\n        if not self.isVisible():\n            self.setText(\"\")\n        super().show()\n\n    def eventFilter(self, widget: QtCore.QObject, event: QtCore.QEvent) -> bool:\n        if widget == self and event.type() == QtCore.QEvent.Type.KeyPress and isinstance(event, QtGui.QKeyEvent):\n            if event.matches(QtGui.QKeySequence.StandardKey.Cancel):\n                self.hide()\n        return False\n\n    def hide_completed(self, hide: bool) -> None:\n        self._hide_completed = hide\n"
  },
  {
    "path": "src/fk/qt/tags_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtWidgets import QWidget, QFrame, QPushButton\n\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.events import TagCreated, TagDeleted, SourceMessagesProcessed\nfrom fk.core.tag import Tag\nfrom fk.desktop.application import Application, AfterSourceChanged\nfrom fk.qt.abstract_tableview import BeforeSelectionChanged, AfterSelectionChanged\nfrom fk.qt.flow_layout import FlowLayout\n\nlogger = logging.getLogger(__name__)\n\n\nclass TagsWidget(QFrame, AbstractEventEmitter):\n    _application: Application\n    _source: AbstractEventSource\n    _should_be_visible: bool\n\n    def __init__(self, parent: QWidget, application: Application):\n        super().__init__(parent,\n                         allowed_events=[\n                             BeforeSelectionChanged,\n                             AfterSelectionChanged,\n                         ],\n                         callback_invoker=application.get_settings().invoke_callback)\n        self._application = application\n        self._source = None\n        self._should_be_visible = True\n\n        self.setObjectName('tags_table')\n        self.setLayout(FlowLayout(self))\n\n        application.get_source_holder().on(AfterSourceChanged, self._on_source_changed)\n\n    def _add_tag(self, tag: Tag, event: str = None, carry: any = None) -> None:\n        name = f'#{tag.get_uid()}'\n        widget = QPushButton(name, self)\n        widget.setObjectName(name)\n        widget.setProperty('class', 'tag_label')\n        widget.setCheckable(True)\n        widget.toggled.connect(lambda is_checked: self._on_tag_toggled(widget, is_checked, tag))\n        self.layout().addWidget(widget)\n        self.update_visibility()\n\n    def deselect(self) -> None:\n        for w in self.layout().widgets():\n            if type(w) is QPushButton and w.isChecked():\n                w.blockSignals(True)\n                w.setChecked(False)\n                w.blockSignals(False)\n\n    def _on_tag_toggled(self, widget: QPushButton, is_checked: bool, tag: Tag) -> None:\n        if is_checked:\n            # We selected a tag -- see if we need to deselect anything else\n            before = None\n            previously_checked: QPushButton = None\n            for w in self.layout().widgets():\n                if type(w) is QPushButton and w != widget and w.isChecked():\n                    before = self._find_tag(w.objectName()[1:])\n                    previously_checked = w\n                    break\n\n            params = {\n                'before': before,\n                'after': self._find_tag(tag.get_uid()),\n            }\n            self._emit(BeforeSelectionChanged, params)\n            if previously_checked is not None:\n                previously_checked.blockSignals(True)\n                previously_checked.setChecked(False)\n                previously_checked.blockSignals(False)\n            self._emit(AfterSelectionChanged, params)\n        else:\n            # We deselected a tag\n            params = {\n                'before': self._find_tag(tag.get_uid()),\n                'after': None,\n            }\n            self._emit(BeforeSelectionChanged, params)\n            self._emit(AfterSelectionChanged, params)\n\n    def _delete_tag(self, tag: Tag, event: str, carry: any = None) -> None:\n        for widget in self.layout().widgets():\n            if widget.objectName()[1:] == tag.get_uid():\n                if widget.isChecked():\n                    # The tag was selected -- deselect it and fire events properly\n                    params = {\n                        'before': self._find_tag(tag.get_uid()),\n                        'after': None,\n                    }\n                    self._emit(BeforeSelectionChanged, params)\n                    widget.setChecked(False)\n                    self._emit(AfterSelectionChanged, params)\n                self.layout().removeWidget(widget)\n                widget.deleteLater()\n                break\n        self.update_visibility()\n\n    def _find_tag(self, uid: str) -> Tag:\n        return self._source.find_tag(uid)\n\n    def update_visibility(self, from_settings: bool = None) -> None:\n        tag_exists = False\n        for w in self.layout().widgets():\n            if type(w) is QPushButton:\n                tag_exists = True\n\n        if from_settings is not None:\n            self._should_be_visible = from_settings\n\n        self.setVisible(tag_exists and self._should_be_visible)\n\n    def _init_tags(self, source: AbstractEventSource, event: str = None) -> None:\n        for widget in self.layout().widgets():\n            self.layout().removeWidget(widget)\n            widget.deleteLater()\n        for tag in source.get_data().get_current_user().get_tags().values():\n            self._add_tag(tag)\n        self.update_visibility()\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        self._source = source\n        source.on(TagCreated, self._add_tag)\n        source.on(TagDeleted, self._delete_tag)\n        source.on(SourceMessagesProcessed, self._init_tags)\n"
  },
  {
    "path": "src/fk/qt/theme_change_event_filter.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtCore import QObject, QEvent\nfrom PySide6.QtGui import Qt\nfrom PySide6.QtWidgets import QMainWindow, QApplication\n\nfrom fk.core.abstract_settings import AbstractSettings\n\nlogger = logging.getLogger(__name__)\n\n\nclass ThemeChangeEventFilter(QMainWindow):\n    _window: QMainWindow\n    _settings: AbstractSettings\n\n    # We need to use it, because Windows sometimes triggers dozens of ThemeChange events at once, and\n    #  we don't want to translate all of them into our settings change events, which might be too slow.\n    _last_value: Qt.ColorScheme\n\n    def __init__(self,\n                 window: QMainWindow,\n                 settings: AbstractSettings):\n        super().__init__()\n        self._window = window\n        self._settings = settings\n        self._last_value = QApplication.styleHints().colorScheme()\n\n    def eventFilter(self, widget: QObject, event: QEvent) -> bool:\n        if event.type() == QEvent.Type.ThemeChange and widget == self._window:\n            if self._settings.get('Application.theme') == 'auto':\n                new_theme = QApplication.styleHints().colorScheme()\n                logger.debug(f'Theme changed from {self._last_value} to {new_theme}')\n                if new_theme != self._last_value:\n                    self._settings.set({\n                        'Application.theme': 'auto'\n                    }, force_fire=True)\n                    self._last_value = new_theme\n        return False\n"
  },
  {
    "path": "src/fk/qt/threaded_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nfrom typing import TypeVar, Callable, Iterable\n\nfrom PySide6.QtCore import QThreadPool, Slot\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog import Backlog\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.tag import Tag\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.qt.qt_invoker import invoke_in_main_thread\n\nTRoot = TypeVar('TRoot')\n\n\nclass ThreadedEventSource(AbstractEventSource[TRoot]):\n    _thread_pool: QThreadPool\n    _wrapped: AbstractEventSource[TRoot]\n    _app: 'Application'\n\n    def __init__(self, wrapped: AbstractEventSource[TRoot], app: 'Application'):\n        super().__init__(wrapped._serializer, wrapped._settings, wrapped._cryptograph)\n        self._app = app\n        self._thread_pool = QThreadPool()\n        self._wrapped = wrapped\n\n    def start(self, mute_events=True) -> None:\n        @Slot()\n        def job():\n            try:\n                self._wrapped.start(mute_events)\n            except Exception as e:\n                def fail(ex):\n                    if type(ex) == IsADirectoryError and type(self._wrapped) == FileEventSource:\n                        # Fixing #70 -- a rare case when the user selected a directory instead of a filename\n                        self._app.bad_file_for_file_source()\n                    else:\n                        self._app.on_exception(type(ex), ex, ex.__traceback__)\n                invoke_in_main_thread(fail, ex=e)\n        self._thread_pool.start(job)\n\n    def get_data(self) -> TRoot:\n        return self._wrapped.get_data()\n\n    def get_name(self) -> str:\n        return self._wrapped.get_name()\n\n    def _append(self, strategies: list[AbstractStrategy]) -> None:\n        return self._wrapped._append(strategies)\n\n    def clone(self, new_root: TRoot) -> ThreadedEventSource[TRoot]:\n        return self._wrapped.clone(new_root)\n\n    def on(self, event_pattern: str, callback: Callable, last: bool = False) -> None:\n        self._wrapped.on(event_pattern, callback, last)\n\n    def unsubscribe(self, callback: Callable) -> None:\n        self._wrapped.unsubscribe(callback)\n\n    def unsubscribe_one(self, callback: Callable, event_pattern: str) -> None:\n        self._wrapped.unsubscribe_one(callback, event_pattern)\n\n    def cancel(self, event_pattern: str) -> None:\n        self._wrapped.cancel(event_pattern)\n\n    def unmute(self) -> None:\n        self._wrapped.unmute()\n\n    def mute(self) -> None:\n        self._wrapped.mute()\n\n    def get_config_parameter(self, name: str) -> str:\n        return self._wrapped.get_config_parameter(name)\n\n    def set_config_parameters(self, values: dict[str, str]) -> None:\n        return self._wrapped.set_config_parameters(values)\n\n    def execute(self,\n                strategy_class:\n                type[AbstractStrategy],\n                params: list[str],\n                persist: bool = True,\n                when: datetime.datetime = None,\n                auto: bool = False,\n                carry: any = None) -> None:\n        self._wrapped.execute(strategy_class, params, persist, when, auto, carry)\n\n    def execute_prepared_strategy(self,\n                                  strategy: AbstractStrategy[TRoot],\n                                  auto: bool = False,\n                                  persist: bool = False) -> None:\n        self._wrapped.execute_prepared_strategy(strategy, auto, persist)\n\n    def users(self) -> Iterable[User]:\n        return self._wrapped.users()\n\n    def backlogs(self) -> Iterable[Backlog]:\n        return self._wrapped.backlogs()\n\n    def tags(self) -> Iterable[Tag]:\n        return self._wrapped.tags()\n\n    def workitems(self) -> Iterable[Workitem]:\n        return self._wrapped.workitems()\n\n    def pomodoros(self) -> Iterable[Pomodoro]:\n        return self._wrapped.pomodoros()\n\n    def find_workitem(self, uid: str) -> Workitem | None:\n        return self._wrapped.find_workitem(uid)\n\n    def find_tag(self, uid: str) -> Tag | None:\n        return self._wrapped.find_tag(uid)\n\n    def find_backlog(self, uid: str) -> Backlog | None:\n        return self._wrapped.find_backlog(uid)\n\n    def find_user(self, identity: str) -> User | None:\n        return self._wrapped.find_user(identity)\n\n    def disconnect(self):\n        self._wrapped.disconnect()\n\n    def send_ping(self) -> str | None:\n        return self._wrapped.send_ping()\n\n    def can_connect(self):\n        return self._wrapped.can_connect()\n\n    def repair(self) -> tuple[list[str], str | None]:\n        return self._wrapped.repair()\n\n    def compress(self):\n        return self._wrapped.compress()\n\n    def get_last_sequence(self):\n        return self._wrapped.get_last_sequence()\n\n    def get_init_strategy(self, emit: Callable[[str, dict[str, any], any], None]) -> AbstractStrategy[AbstractEventSource[TRoot]]:\n        return self._wrapped.get_init_strategy(emit)\n"
  },
  {
    "path": "src/fk/qt/timer_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtCore import QSize, Property, QEvent, Signal, QPoint\nfrom PySide6.QtGui import QFont, QColor, QPalette, QMouseEvent\nfrom PySide6.QtWidgets import QWidget, QSizePolicy, QHBoxLayout, QToolButton\n\nfrom fk.qt.render.minimal_timer_renderer import MinimalTimerRenderer\nfrom fk.qt.render.classic_timer_renderer import ClassicTimerRenderer\n\nlogger = logging.getLogger(__name__)\n\n\nclass TimerWidget(QWidget):\n    _timer_display: MinimalTimerRenderer\n    _fg_color: QColor\n    _bg_color: QColor\n    _last_values: dict | None\n\n    clicked = Signal(QPoint)\n\n    def __init__(self,\n                 parent: QWidget,\n                 name: str,\n                 flavor: str,\n                 center_button: QToolButton = None,\n                 display_size: int = 63,\n                 is_dark: bool = True):\n        super().__init__(parent)\n        self._last_values = None\n\n        self.setObjectName(name)\n\n        self._bg_color = self.palette().color(QPalette.ColorRole.Base)\n        self._fg_color = self.palette().color(QPalette.ColorRole.Text)\n        self._timer_display = None\n\n        sp3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)\n        sp3.setHorizontalStretch(0)\n        sp3.setVerticalStretch(0)\n        self.setSizePolicy(sp3)\n        self.setMinimumHeight(display_size)\n        self.setMinimumWidth(display_size)\n        self.setMaximumHeight(display_size)\n        self.setMaximumWidth(display_size)\n        self.setBaseSize(QSize(0, 0))\n\n        inner_timer_layout = QHBoxLayout(self)\n        inner_timer_layout.setObjectName(f\"inner_{name}_layout\")\n        inner_timer_layout.setContentsMargins(0, 0, 0, 0)\n        inner_timer_layout.setSpacing(0)\n\n        if center_button is not None:\n            inner_timer_layout.addWidget(center_button)\n\n        self._init_renderer(flavor)\n\n    def _init_renderer(self, flavor):\n        if flavor == 'classic':\n            cls = ClassicTimerRenderer\n        elif flavor == 'minimal':\n            cls = MinimalTimerRenderer\n\n        if self._timer_display is not None:\n            self.removeEventFilter(self._timer_display)\n        self._timer_display = cls(self,\n                                  self._bg_color,\n                                  self._fg_color,\n                                  True)\n        self.installEventFilter(self._timer_display)\n        self._timer_display.setObjectName('TimerWidgetRenderer')\n\n    def _init_timer_display(self):\n        if self._timer_display is not None:\n            self._timer_display.set_colors(self._bg_color, self._fg_color)\n\n    @Property('QColor')\n    def fg_color(self):\n        return self._fg_color\n\n    @Property('QColor')\n    def bg_color(self):\n        return self._bg_color\n\n    @fg_color.setter\n    def fg_color(self, new_fg_color):\n        if self._fg_color != new_fg_color:\n            self._fg_color = new_fg_color\n            self._init_timer_display()\n\n    @bg_color.setter\n    def bg_color(self, new_bg_color):\n        if self._bg_color != new_bg_color:\n            self._bg_color = new_bg_color\n            self._init_timer_display()\n\n    def reset(self):\n        self._timer_display.reset()\n        self._timer_display.repaint()\n\n    def set_values(self,\n                   my_value: float,\n                   my_max: float,\n                   team_value: float | None,\n                   team_max: float | None,\n                   mode: str) -> None:\n        self._timer_display.set_values(my_value, my_max, team_value, team_max, mode)\n        self._timer_display.repaint()\n        self._last_values = {\n            'my_value': my_value,\n            'my_max': my_max,\n            'team_value': team_value,\n            'team_max': team_max,\n            'mode': mode,\n        }\n\n    def get_last_values(self) -> dict():\n        return self._last_values\n\n    def mousePressEvent(self, event: QMouseEvent) -> None:\n        if event.type() == QEvent.Type.MouseButtonPress:\n            self.clicked.emit(event.pos())\n"
  },
  {
    "path": "src/fk/qt/tray_icon.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom typing import Type\n\nfrom PySide6.QtCore import QRect\nfrom PySide6.QtGui import QIcon, Qt, QPixmap, QPainter, QColor\nfrom PySide6.QtWidgets import QWidget, QMainWindow, QSystemTrayIcon, QMenu\n\nfrom fk.core.abstract_event_source import start_workitem\nfrom fk.core.abstract_timer_display import AbstractTimerDisplay\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.workitem import Workitem\nfrom fk.qt.actions import Actions\nfrom fk.qt.render.abstract_timer_renderer import AbstractTimerRenderer\n\n\nclass TrayIcon(QSystemTrayIcon, AbstractTimerDisplay):\n    _about_window: QMainWindow\n    _default_icon: QIcon\n    _next_icon: QIcon\n    _actions: Actions\n    _timer_renderer: AbstractTimerRenderer | None\n    _continue_workitem: Workitem | None\n    _size: int\n\n    def __init__(self,\n                 parent: QWidget,\n                 timer: PomodoroTimer,\n                 source_holder: EventSourceHolder,\n                 actions: Actions,\n                 size: int,\n                 cls: Type[AbstractTimerRenderer],\n                 is_dark: bool):\n        super().__init__(parent, timer=timer, source_holder=source_holder)\n        self._size = size\n        self._default_icon = QIcon(\":/flowkeeper.png\")\n        if is_dark:\n            # We don't use QIcon.fromTheme() here because we can't predict the tray background color\n            self._next_icon = QIcon(':/icons/dark/24x24/tool-next.svg')\n        else:\n            self._next_icon = QIcon(':/icons/light/24x24/tool-next.svg')\n        self._actions = actions\n        self._continue_workitem = None\n        self.setObjectName('tray')\n        self._timer_renderer = cls(None,\n                                   QColor('#000000' if is_dark else '#ffffff'),\n                                   QColor('#ffffff' if is_dark else '#000000'),\n                                   False,\n                                   True)\n        self._timer_renderer.setObjectName('TrayIconRenderer')\n\n        self.activated.connect(lambda reason:\n                               self._tray_clicked() if reason == QSystemTrayIcon.ActivationReason.Trigger else None)\n\n        self._initialize_menu()\n        self.reset()\n\n    def _initialize_menu(self):\n        menu = QMenu()\n        if 'focus.voidPomodoro' in self._actions:\n            menu.addAction(self._actions['focus.voidPomodoro'])\n        if 'focus.finishTracking' in self._actions:\n            menu.addAction(self._actions['focus.finishTracking'])\n        menu.addSeparator()\n        if 'window.showMainWindow' in self._actions:\n            menu.addAction(self._actions['window.showMainWindow'])\n        if 'application.settings' in self._actions:\n            menu.addAction(self._actions['application.settings'])\n        # if 'window.quickConfig' in self._actions:\n        #     menu.addAction(self._actions['window.quickConfig'])\n        if 'application.quit' in self._actions:\n            menu.addAction(self._actions['application.quit'])\n        self.setContextMenu(menu)\n\n    def kill(self):\n        super().kill()\n        pass    # Unsubscribe any externals here\n\n    def reset(self):\n        self.setToolTip(\"It's time for the next Pomodoro.\")\n        if self._timer_renderer.has_idle_display():\n            self._timer_renderer.set_values(0, 1, None, None, 'idle')\n            self.paint()\n        else:\n            self.setIcon(self._default_icon)\n\n    def _tray_clicked(self) -> None:\n        if self._continue_workitem is not None and self._continue_workitem.is_startable() and self.timer.is_idling():\n            if self._continue_workitem is None:\n                raise Exception('Cannot start next pomodoro on non-existent work item')\n            start_workitem(self._continue_workitem, self._source_holder.get_source())\n        else:\n            if 'window.showMainWindow' in self._actions:\n                self._actions['window.showMainWindow'].trigger()\n\n    def paint(self) -> None:\n        tray_width = 48 if self._size is None else self._size\n        tray_height = 48 if self._size is None else self._size\n        pixmap = QPixmap(tray_width, tray_height)\n        pixmap.fill(Qt.GlobalColor.transparent)\n        painter = QPainter(pixmap)\n        self._timer_renderer.repaint(painter, QRect(0, 0, tray_width, tray_height))\n        self.setIcon(pixmap)\n\n    def tick(self, pomodoro: Pomodoro, state_text: str, my_value: float, my_max: float, mode: str) -> None:\n        self.setToolTip(f\"{state_text} ({pomodoro.get_parent().get_name()})\")\n        self._timer_renderer.set_values(my_value, my_max, None, None, mode)\n        self.paint()\n\n    def mode_changed(self, old_mode: str, new_mode: str) -> None:\n        if new_mode == 'undefined' or new_mode == 'idle':\n            self.reset()\n            if old_mode == 'working' or old_mode == 'resting' or old_mode == 'long-resting':\n                self.showMessage(\"Ready\", \"It's time for the next Pomodoro.\", self._default_icon)\n        elif new_mode == 'resting' and old_mode == 'working':\n            self.showMessage(\"Work is done\", \"Have some rest\", self._default_icon)\n        elif new_mode == 'long-resting' and old_mode == 'working':\n            self.showMessage(\"A series is done\", \"Enjoy a long rest\", self._default_icon)\n        elif new_mode == 'ready':\n            if self._continue_workitem is not None:\n                self.setToolTip(f'Continue? ({self._continue_workitem.get_name()})')\n            self.showMessage(\"Ready\", \"Continue?\", self._next_icon)\n            self._timer_renderer.set_values(0, 1, None, None, 'ready')\n            if self._timer_renderer.has_next_display():\n                self.paint()\n            else:\n                self.setIcon(self._next_icon)\n"
  },
  {
    "path": "src/fk/qt/user_model.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\n\nfrom PySide6 import QtGui, QtCore\nfrom PySide6.QtCore import Qt\n\nfrom fk.core import events\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\n\nclass UserModel(QtGui.QStandardItemModel):\n    _font_normal: QtGui.QFont\n    _font_busy: QtGui.QFont\n\n    def __init__(self, parent: QtCore.QObject, source_holder: EventSourceHolder):\n        super().__init__(0, 1, parent)\n        self._font_normal = QtGui.QFont()\n        self._font_busy = QtGui.QFont()\n        # self._font_busy.setBold(True)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        source.on(events.AfterUserCreate, self._user_added)\n        source.on(events.AfterUserDelete, self._user_removed)\n        source.on(events.AfterUserRename, self._user_renamed)\n        source.on(events.SourceMessagesProcessed, self._on_messages)\n\n    def _on_messages(self, event: str, source: AbstractEventSource) -> None:\n        self.load(source.get_data())\n\n    def _user_added(self, event: str, user: User) -> None:\n        item = QtGui.QStandardItem('')\n        self.appendRow(item)\n        self.set_row(self.rowCount() - 1, user)\n\n    def _user_removed(self, event: str, user: User) -> None:\n        for i in range(self.rowCount()):\n            u = self.item(i).data(500)\n            if u == user:\n                self.removeRow(i)\n                return\n\n    def _user_renamed(self, event: str, user: User, old_name: str, new_name: str) -> None:\n        for i in range(self.rowCount()):\n            u = self.item(i).data(500)\n            if u == user:\n                self.set_row(i, u)\n                return\n\n    def set_row(self, i: int, user: User) -> None:\n        state, remaining = user.get_state(datetime.datetime.now(datetime.timezone.utc))\n        font = self._font_busy if state == 'Focus' else self._font_normal\n\n        col1 = QtGui.QStandardItem()\n        if state == 'Idle':\n            txt = f'{user.get_name()}'\n        elif state == 'Tracking':\n            txt = f'{user.get_name()}: {state}'\n        else:\n            txt = f'{user.get_name()}: {state}, {remaining} left'\n        col1.setData(txt, Qt.ItemDataRole.DisplayRole)\n        col1.setData(font, Qt.ItemDataRole.FontRole)\n        col1.setData(user, 500)\n        col1.setData('title', 501)\n        col1.setData(txt, Qt.ItemDataRole.ToolTipRole)\n        col1.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)\n        self.setItem(i, 0, col1)\n\n    def load(self, app: Tenant) -> None:\n        self.clear()\n        if app is not None:\n            i = 0\n            for user in app.values():\n                if user.is_system_user():\n                    continue\n                item = QtGui.QStandardItem('')\n                self.appendRow(item)\n                self.set_row(i, user)\n                i += 1\n        self.setHorizontalHeaderItem(0, QtGui.QStandardItem(''))\n"
  },
  {
    "path": "src/fk/qt/user_tableview.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QWidget, QHeaderView\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.events import SourceMessagesProcessed\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.desktop.application import AfterSourceChanged, Application\nfrom fk.qt.abstract_tableview import AbstractTableView\nfrom fk.qt.actions import Actions\nfrom fk.qt.user_model import UserModel\n\n\nclass UserTableView(AbstractTableView[Tenant, User]):\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 source_holder: EventSourceHolder,\n                 actions: Actions):\n        super().__init__(parent,\n                         source_holder,\n                         UserModel(parent, source_holder),\n                         'users_table',\n                         actions,\n                         'Loading, please wait...',\n                         'Select a tenant.\\nYou should never see this message. Please report a bug in GitHub.',\n                         'There are no users.\\nYou should never see this message. Please report a bug in GitHub.',\n                         0)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        self.update_actions(None)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        super()._on_source_changed(event, source)\n        self.selectionModel().clear()\n        self.upstream_selected(None)\n        self._source.on(SourceMessagesProcessed, self._on_messages)\n\n    def update_actions(self, selected: User) -> None:\n        pass\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        pass\n\n    def upstream_selected(self, upstream: Tenant) -> None:\n        super().upstream_selected(upstream)\n        self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)\n\n    def _on_messages(self, event: str, source: AbstractEventSource) -> None:\n        self.upstream_selected(source.get_data())\n"
  },
  {
    "path": "src/fk/qt/websocket_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport enum\nimport logging\nfrom typing import TypeVar\n\nfrom PySide6 import QtWebSockets, QtCore\nfrom PySide6.QtNetwork import QAbstractSocket\nfrom PySide6.QtWidgets import QApplication\n\nfrom fk.core import events\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.abstract_timer import AbstractTimer\nfrom fk.core.simple_serializer import SimpleSerializer\nfrom fk.core.tenant import ADMIN_USER\nfrom fk.desktop.desktop_strategies import AuthenticateStrategy, ReplayStrategy, PongStrategy, \\\n    PingStrategy, ReplayCompletedStrategy\nfrom fk.qt.oauth import get_id_token, AuthenticationRecord\nfrom fk.qt.qt_timer import QtTimer\n\nlogger = logging.getLogger(__name__)\nTRoot = TypeVar('TRoot')\n\n\nclass WebsocketEventSource(AbstractEventSource[TRoot]):\n    _data: TRoot\n    _ws: QtWebSockets.QWebSocket\n    _mute_requested: bool\n    _connection_attempt: int\n    _reconnect_timer: AbstractTimer\n    _received_error: bool\n    _application: QApplication\n\n    def __init__(self,\n                 settings: AbstractSettings,\n                 cryptograph: AbstractCryptograph,\n                 application: QApplication,\n                 root: TRoot):\n        super().__init__(SimpleSerializer(settings, cryptograph),\n                         settings,\n                         cryptograph)\n        self._data = root\n        self._application = application\n        self._mute_requested = True\n        self._connection_attempt = 0\n        self._received_error = False\n        self._reconnect_timer = QtTimer(\"WS Reconnect\")\n        self._ws = QtWebSockets.QWebSocket()\n        self._ws.connected.connect(lambda: self.replay())\n        self._ws.disconnected.connect(lambda: self._connection_lost())\n        self._ws.textMessageReceived.connect(lambda msg: self._on_message(msg))\n\n        # Log errors\n        self._ws.sslErrors.connect(lambda e: self._on_error('SSL error', e))\n        self._ws.errorOccurred.connect(lambda e: self._on_error('error occurred', e))\n        self._ws.handshakeInterruptedOnError.connect(lambda e: self._on_error('handshake interrupted on error', e))\n        self._ws.peerVerifyError.connect(lambda e: self._on_error('peer verify error', e))\n\n    def _on_error(self, s: str, e: enum) -> None:\n        if type(e) != QAbstractSocket.SocketError:\n            raise Exception(f'WebSocket {s}: {e}')\n\n    def _connection_lost(self) -> None:\n        next_reconnect = min(max(500, int(pow(1.5, self._connection_attempt))), 30000)\n        if self._received_error:\n            logger.warning(f'WebSocket disconnected due to an error reported by the server. Will not try to reconnect.')\n        else:\n            logger.warning(f'WebSocket disconnected for unknown reason. Will attempt to reconnect in {next_reconnect}ms')\n            self._reconnect_timer.schedule(next_reconnect,\n                                           lambda _1, _2: self.connect(),\n                                           None,\n                                           True)\n\n    def connect(self) -> None:\n        self._connection_attempt += 1\n        source_type = self.get_config_parameter('Source.type')\n        if source_type == 'websocket':\n            url = self.get_config_parameter('WebsocketEventSource.url')\n        elif source_type == 'flowkeeper.org':\n            url = 'wss://app.flowkeeper.org/ws'\n        elif source_type == 'flowkeeper.pro':\n            url = 'wss://app.flowkeeper.pro/ws'\n        else:\n            raise Exception(f\"Unexpected source type for WebSocket event source: {source_type}\")\n        logger.debug(f'Connecting to {url}, attempt {self._connection_attempt}')\n        self._ws.open(QtCore.QUrl(url))\n\n    def start(self, mute_events=True) -> None:\n        self._last_seq = 0\n        self._mute_requested = mute_events\n        self.connect()\n\n    def _on_message(self, message: str) -> None:\n        self._received_error = False\n        lines = message.split('\\n')\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f'Received {len(lines)} messages')\n        i = 0\n        to_unmute = False\n        to_emit = False\n        last_executed = None\n        for line in lines:\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f\" - {line}\")\n            try:\n                s = self._serializer.deserialize(line)\n                if s is None:\n                    continue\n                elif type(s) is ReplayCompletedStrategy:\n                    if self._mute_requested:\n                        to_unmute = True\n                    to_emit = True\n                    break\n                elif type(s) is PongStrategy:\n                    # A special case where we want to ignore the sequence\n                    self.execute_prepared_strategy(s)\n                    last_executed = s\n                elif s.get_sequence() is not None and s.get_sequence() > self._last_seq:\n                    if not self._ignore_invalid_sequences and s.get_sequence() != self._last_seq + 1:\n                        self._sequence_error(self._last_seq, s.get_sequence())\n                    self._last_seq = s.get_sequence()\n                    self.execute_prepared_strategy(s)\n                    last_executed = s\n                i += 1\n                if i % 1000 == 0:    # Yield to Qt from time to time\n                    QApplication.processEvents()\n            except Exception as ex:\n                if self._ignore_errors and not self._received_error:\n                    logger.warning(f'Error processing {line} (ignored)', exc_info=ex)\n                else:\n                    raise ex\n\n        self._auto_seal_at_the_end(last_executed)\n        if to_unmute:\n            self.unmute()\n        if to_emit:\n            self._emit(events.SourceMessagesProcessed, {'source': self})\n\n    def _authenticate_with_google_and_replay(self) -> None:\n        refresh_token = self.get_config_parameter('WebsocketEventSource.refresh_token!')\n        get_id_token(self._application, self._replay_after_auth, refresh_token)\n\n    def _replay_after_auth(self, auth: AuthenticationRecord) -> None:\n        logger.debug(f'Authenticated against identity provider. Authenticating against Flowkeeper server now.')\n        now = datetime.datetime.now(datetime.timezone.utc)\n        consent_given = 'true' if self.get_config_parameter('WebsocketEventSource.consent') == 'True' else 'false'\n        auth_strategy = AuthenticateStrategy(1,\n                                             now,\n                                             ADMIN_USER,\n                                             [auth.email, f'{auth.type}|{auth.id_token}', consent_given],\n                                             self._settings)\n        st = self._serializer.serialize(auth_strategy)\n        logger.debug(f'Sending auth strategy: {st}')\n        self._ws.sendTextMessage(st)\n\n        logger.debug(f'Requesting replay starting from #{self._last_seq}')\n        replay = ReplayStrategy(2,\n                                now,\n                                ADMIN_USER,\n                                [str(self._last_seq)],\n                                self._settings)\n        rt = self._serializer.serialize(replay)\n        logger.debug(f'Sending replay strategy: {rt}')\n        self._ws.sendTextMessage(rt)\n\n        self._emit(events.SourceMessagesRequested, dict())\n        if self._mute_requested:\n            self.mute()\n\n    def replay(self) -> None:\n        self._connection_attempt = 0    # This will allow us to reconnect quickly\n        self._received_error = False\n\n        auth_type = self.get_config_parameter('WebsocketEventSource.auth_type')\n        logger.debug(f'Connected. Authenticating with {auth_type}')\n\n        if auth_type == 'basic':\n            auth = AuthenticationRecord()\n            auth.email = self.get_config_parameter('WebsocketEventSource.username')\n            auth.type = auth_type\n            auth.id_token = self.get_config_parameter('WebsocketEventSource.password!')\n            self._replay_after_auth(auth)\n        elif auth_type == 'google':\n            self._authenticate_with_google_and_replay()\n        else:\n            raise Exception(f'Unsupported authentication type: {auth_type}')\n\n    def _append(self, strategies: list[AbstractStrategy]) -> None:\n        for s in strategies:\n            to_send = self._serializer.serialize(s)\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f'Sending strategy {to_send}')\n            self._ws.sendTextMessage(to_send)\n\n    def get_name(self) -> str:\n        return \"Websocket\"\n\n    def get_data(self) -> TRoot:\n        return self._data\n\n    def clone(self, new_root: TRoot) -> WebsocketEventSource[TRoot]:\n        return WebsocketEventSource[TRoot](self._settings,\n                                           self._cryptograph,\n                                           self._application,\n                                           new_root)\n\n    def disconnect(self):\n        self._ws.disconnected.disconnect()\n        self._ws.close()\n\n    def send_ping(self) -> str | None:\n        now = datetime.datetime.now(datetime.timezone.utc)\n        uid = generate_uid()\n        ping = PingStrategy(1,\n                            now,\n                            ADMIN_USER,\n                            [uid],\n                            self._settings)\n        ps = self._serializer.serialize(ping)\n        if logger.isEnabledFor(logging.DEBUG):\n            logger.debug(f'Sending ping strategy: {ps}')\n        self._ws.sendTextMessage(ps)\n        return uid\n\n    def can_connect(self):\n        return True\n\n    def repair(self) -> tuple[list[str], str | None]:\n        return list(), None\n"
  },
  {
    "path": "src/fk/qt/workitem_model.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\n\nfrom PySide6 import QtGui, QtWidgets\nfrom PySide6.QtCore import Qt, QSize\nfrom PySide6.QtGui import QFontMetrics, QStandardItem\nfrom PySide6.QtWidgets import QApplication\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import AfterWorkitemRename, AfterWorkitemComplete, AfterWorkitemStart, AfterWorkitemCreate, \\\n    AfterWorkitemDelete, AfterSettingsChanged, AfterWorkitemReorder, AfterWorkitemMove\nfrom fk.core.pomodoro import POMODORO_TYPE_TRACKER, Pomodoro\nfrom fk.core.tag import Tag\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import RenameWorkitemStrategy, ReorderWorkitemStrategy\nfrom fk.qt.abstract_drop_model import AbstractDropModel\n\nlogger = logging.getLogger(__name__)\n\n\nclass WorkitemPlanned(QStandardItem):\n    _workitem: Workitem\n\n    def __init__(self, workitem: Workitem, font: QtGui.QFont):\n        super().__init__()\n        self._workitem = workitem\n        self.setData(workitem, 500)\n        self.setData('planned', 501)\n        flags = (Qt.ItemFlag.ItemIsSelectable |\n                 Qt.ItemFlag.ItemIsEnabled)\n        self.setFlags(flags)\n        self.update_planned()\n        self.update_font(font)\n\n    def update_planned(self):\n        self.setData('' if self._workitem.is_planned() else '*', Qt.ItemDataRole.DisplayRole)\n        self.setData('Planned work item' if self._workitem.is_planned() else 'Unplanned work item', Qt.ItemDataRole.ToolTipRole)\n\n    def update_font(self, font: QtGui.QFont):\n        self.setData(font, Qt.ItemDataRole.FontRole)\n\n\nclass WorkitemTitle(QStandardItem):\n    _workitem: Workitem\n\n    def __init__(self, workitem: Workitem, font: QtGui.QFont):\n        super().__init__()\n        self._workitem = workitem\n        self.setData(workitem, 500)\n        self.setData('title', 501)\n        self.update_display()\n        self.update_font(font)\n        self.update_flags()\n\n    def update_display(self):\n        self.setData(self._workitem.get_name(), Qt.ItemDataRole.DisplayRole)\n        self.setData(self._workitem.get_name(), Qt.ItemDataRole.ToolTipRole)\n\n    def update_flags(self):\n        flags = (Qt.ItemFlag.ItemIsSelectable |\n                 Qt.ItemFlag.ItemIsEnabled |\n                 Qt.ItemFlag.ItemIsDragEnabled)\n        if not self._workitem.is_sealed():\n            flags |= Qt.ItemFlag.ItemIsEditable\n        self.setFlags(flags)\n\n    def update_font(self, font: QtGui.QFont):\n        self.setData(font, Qt.ItemDataRole.FontRole)\n\n\ndef hhmm(when: datetime.datetime) -> str:\n    return when.astimezone().strftime('%H:%M')\n\n\nclass WorkitemPomodoro(QStandardItem):\n    _workitem: Workitem\n    _row_height: int\n\n    def __init__(self, workitem: Workitem, row_height: int):\n        super().__init__()\n        self._workitem = workitem\n        self._row_height = row_height\n        self.setData(workitem, 500)\n        self.setData('pomodoro', 501)\n        flags = (Qt.ItemFlag.ItemIsSelectable |\n                 Qt.ItemFlag.ItemIsEnabled)\n        self.setFlags(flags)\n        self.update_display()\n\n    def _list_interruptions(self, pomodoro: Pomodoro, res: list[str]) -> None:\n        for i in pomodoro.values():\n            reason = f' ({i.get_reason()})' if i.get_reason() else ''\n            action = 'Voided' if i.is_void() else 'Interrupted'\n            res.append(f' - {action} at {hhmm(i.get_create_date())}{reason}')\n\n    def _format_tooltip(self) -> str:\n        res = list()\n\n        for p in self._workitem.values():\n            if p.get_type() == POMODORO_TYPE_TRACKER:\n                # The fact that we detect it as a tracker means that we started it\n                elapsed = round((p.get_last_modified_date() - p.get_work_start_date()).total_seconds())\n                res.append(f'Tracked {datetime.timedelta(seconds=elapsed)} '\n                           f'from {hhmm(p.get_work_start_date())} to {hhmm(p.get_last_modified_date())}')\n                self._list_interruptions(p, res)\n            else:\n                res.append(f'{p.get_name()} - {\"planned\" if p.is_planned() else \"unplanned\"}, {p.get_state()}:')\n                res.append(f' - Created at {hhmm(p.get_create_date())}')\n                if p.is_working():\n                    res.append(f' - Working since {hhmm(p.get_work_start_date())}')\n                if p.is_resting() or p.is_finished():\n                    res.append(f' - Started work at {hhmm(p.get_work_start_date())}')\n                if p.is_resting():\n                    res.append(f' - Resting since {hhmm(p.get_rest_start_date())}')\n                self._list_interruptions(p, res)\n                if p.is_finished():\n                    work_duration = round(p.get_elapsed_work_duration())\n                    if work_duration > 0:\n                        res.append(f' - Worked for {datetime.timedelta(seconds=work_duration)}')\n                    rest_duration = round(p.get_elapsed_rest_duration())\n                    if rest_duration > 0:\n                        rest_type = ''\n                        if p.get_rest_duration() == 0:\n                            rest_type = ' (long break)'\n                        res.append(f' - Rested for {datetime.timedelta(seconds=rest_duration)}{rest_type}')\n                    res.append(f' - Completed at {hhmm(p.get_last_modified_date())}')\n\n        if self._workitem.is_sealed():\n            res.append(f'Marked completed at {hhmm(self._workitem.get_last_modified_date())}')\n\n        return '\\n'.join(res)\n\n    def update_display(self):\n        self.setData(','.join([str(p) for p in self._workitem.values()]), Qt.ItemDataRole.DisplayRole)\n\n        if self._workitem.is_tracker():\n            elapsed = str(self._workitem.get_total_elapsed_time())\n            if self._workitem.has_running_pomodoro():\n                elapsed += '+'\n            self.setData(elapsed, Qt.ItemDataRole.DisplayRole)\n            sz = QFontMetrics(QApplication.font()).horizontalAdvance(elapsed) + 8\n        else:\n            # Calculate its size, given that voided pomodoros are just narrow ticks\n            sz = 0\n            for p in self._workitem.values():\n                sz += self._row_height\n                sz += len(p) * self._row_height / 4     # Voided pomodoro ticks\n\n        self.setData(QSize(sz, self._row_height), Qt.ItemDataRole.SizeHintRole)\n        self.setData(self._format_tooltip(), Qt.ItemDataRole.ToolTipRole)\n\n\nclass WorkitemModel(AbstractDropModel):\n    _font_new: QtGui.QFont\n    _font_running: QtGui.QFont\n    _font_sealed: QtGui.QFont\n    _backlog_or_tag: Backlog | Tag | None\n    _row_height: int\n    _hide_completed: bool\n\n    def __init__(self, parent: QtWidgets.QWidget, source_holder: EventSourceHolder):\n        super().__init__(1, parent, source_holder)\n        self._font_new = QtGui.QFont()\n        self._font_running = QtGui.QFont()\n        # self._font_running.setWeight(QtGui.QFont.Weight.Bold)\n        self._font_sealed = QtGui.QFont()\n        self._font_sealed.setStrikeOut(True)\n        self._backlog_or_tag = None\n        settings = source_holder.get_settings()\n        self._hide_completed = (settings.get('Application.hide_completed') == 'True')\n        self._update_row_height(int(settings.get('Application.table_row_height')))\n        self.itemChanged.connect(lambda item: self.handle_rename(item, RenameWorkitemStrategy))\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        settings.on(AfterSettingsChanged, self._on_setting_changed)\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.table_row_height' in new_values:\n            self._update_row_height(int(new_values[\"Application.table_row_height\"]))\n\n    def _update_row_height(self, new_height: int):\n        self._row_height = new_height\n        # TODO: Updating existing rows doesn't work.\n        #  The right way to do it is by using QStandardItem subclass, like we do for BacklogModel\n        # for i in range(self.rowCount()):\n        #     item: QStandardItem = self.item(i, 2)\n        #     workitem: Workitem = item.data(500)\n        #     item.setData(QSize(len(workitem) * rh, rh), Qt.ItemDataRole.SizeHintRole)\n        #     self.setItem(i, 2, item)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        self.load(None)\n        source.on(AfterWorkitemCreate, self._workitem_created)\n        source.on(AfterWorkitemDelete, self._workitem_deleted)\n        source.on(AfterWorkitemRename, self._workitem_renamed)\n        source.on(AfterWorkitemReorder, self._workitem_reordered)\n        source.on(AfterWorkitemMove, self._workitem_moved)\n        source.on(AfterWorkitemComplete, self._workitem_changed)\n        source.on(AfterWorkitemStart, self._workitem_changed)\n        source.on('AfterPomodoro*',\n                  lambda **kwargs: self._workitem_changed(\n                      kwargs['workitem'] if 'workitem' in kwargs else kwargs['pomodoro'].get_parent()\n                  ))\n\n    def _workitem_belongs_here(self, workitem: Workitem) -> bool:\n        return (type(self._backlog_or_tag) is Backlog and workitem.get_parent() == self._backlog_or_tag\n                or\n                type(self._backlog_or_tag) is Tag and self._backlog_or_tag.get_uid() in workitem.get_tags())\n\n    def _add_workitem(self, workitem: Workitem) -> None:\n        self.appendRow(self.item_for_object(workitem))\n\n    def _find_workitem(self, workitem: Workitem) -> int:\n        for i in range(self.rowCount()):\n            wi = self.item(i).data(500)  # 500 ~ Qt.UserRole + 1\n            if wi == workitem:\n                return i\n        return -1\n\n    def _remove_if_found(self, workitem: Workitem) -> None:\n        i = self._find_workitem(workitem)\n        if i >= 0:\n            self.removeRow(i)\n\n    def _workitem_created(self, workitem: Workitem, **kwargs) -> None:\n        if self._workitem_belongs_here(workitem):\n            self._add_workitem(workitem)\n\n    def _workitem_deleted(self, workitem: Workitem, **kwargs) -> None:\n        if self._workitem_belongs_here(workitem):\n            self._remove_if_found(workitem)\n\n    def _workitem_renamed(self, workitem: Workitem, old_name: str, new_name: str, **kwargs) -> None:\n        if type(self._backlog_or_tag) is Tag:\n            if self._backlog_or_tag.get_uid() in workitem.get_tags():\n                # This workitem should be in this list\n                if self._find_workitem(workitem) < 0:\n                    self._add_workitem(workitem)\n            else:\n                # This workitem should not be in this list\n                self._remove_if_found(workitem)\n        self._workitem_changed(workitem)\n\n    def _workitem_reordered(self, workitem: Workitem, new_index: int, carry: str, **kwargs) -> None:\n        if (carry != 'ui' and\n                type(self._backlog_or_tag) is Backlog and\n                self._workitem_belongs_here(workitem)):\n            old_index = self._find_workitem(workitem)\n            if old_index >= 0:\n                if new_index > old_index:\n                    new_index -= 1\n                row = self.takeRow(old_index)\n                self.insertRow(new_index, row)\n\n    def _workitem_moved(self, workitem: Workitem, old_backlog: Backlog, new_backlog: Backlog, **kwargs) -> None:\n        if old_backlog == self._backlog_or_tag or self._backlog_or_tag.get_uid() in workitem.get_tags():\n            # Moved from here\n            self._remove_if_found(workitem)\n        elif self._workitem_belongs_here(workitem):   # We can only drop workitems on backlogs, not tags\n            # Moved in here\n            self._add_workitem(workitem)\n\n    def _workitem_changed(self, workitem: Workitem, **kwargs) -> None:\n        for i in range(self.rowCount()):\n            item0: WorkitemPlanned = self.item(i, 0)\n            wi = item0.data(500)\n            if wi == workitem:\n                if self._hide_completed and workitem.is_sealed():\n                    self.removeRow(i)\n                else:\n                    font = self._get_font(workitem)\n                    item0.update_font(font)\n                    item0.update_planned()\n\n                    item1: WorkitemTitle = self.item(i, 1)\n                    item1.update_font(font)\n                    item1.update_display()\n                    item1.update_flags()\n\n                    item2: WorkitemPomodoro = self.item(i, 2)\n                    item2.update_display()\n                return\n\n    def get_row_height(self):\n        return self._row_height\n\n    def load(self, backlog_or_tag: Backlog | Tag) -> None:\n        logger.debug(f'WorkitemModel.load({backlog_or_tag})')\n        self.clear()\n        self._backlog_or_tag = backlog_or_tag\n        if backlog_or_tag is not None:\n            if type(backlog_or_tag) is Backlog:\n                workitems = backlog_or_tag.values()\n            else:\n                workitems = sorted(backlog_or_tag.get_workitems(),\n                                   key=lambda a: a.get_last_modified_date())\n            for workitem in workitems:\n                if self._hide_completed and workitem.is_sealed():\n                    continue\n                self.appendRow(self.item_for_object(workitem))\n        self.setHorizontalHeaderItem(0, QStandardItem(''))\n        self.setHorizontalHeaderItem(1, QStandardItem(''))\n        self.setHorizontalHeaderItem(2, QStandardItem(''))\n\n    def hide_completed(self, hide: bool) -> None:\n        self._hide_completed = hide\n        self.load(self._backlog_or_tag)\n\n    def get_backlog_or_tag(self) -> Backlog | Tag | None:\n        return self._backlog_or_tag\n\n    def get_primary_type(self) -> str:\n        return 'application/flowkeeper.workitem.id'\n\n    def _get_font(self, workitem: Workitem) -> QtGui.QFont:\n        if workitem.is_running():\n            return self._font_running\n        elif workitem.is_sealed():\n            return self._font_sealed\n        return self._font_new\n\n    def item_for_object(self, workitem: Workitem) -> list[QStandardItem]:\n        font = self._get_font(workitem)\n        return [\n            WorkitemPlanned(workitem, font),\n            WorkitemTitle(workitem, font),\n            WorkitemPomodoro(workitem, self._row_height)\n        ]\n\n    def reorder(self, to_index: int, uid: str):\n        # Convert to_index into the \"item index\".\n        # We are sure it's a Backlog, since reordering is disabled for tags.\n        to_add = 0\n        visible_index = 0\n        if self._hide_completed:\n            for item in self._backlog_or_tag.values():\n                if item.is_sealed():\n                    to_add += 1\n                else:\n                    visible_index += 1\n                    if visible_index >= to_index:\n                        break\n        logger.debug(f'When reordering {uid} having to add {to_add} items before our target index {to_index}')\n        self._source_holder.get_source().execute(ReorderWorkitemStrategy,\n                                                 [uid, str(to_index + to_add)],\n                                                 carry='ui')\n\n    def repaint_workitem(self, workitem: Workitem):\n        for i in range(self.rowCount()):\n            wi = self.item(i).data(500)  # 500 ~ Qt.UserRole + 1\n            if wi == workitem:\n                item: WorkitemPomodoro = self.item(i, 2)\n                item.update_display()\n"
  },
  {
    "path": "src/fk/qt/workitem_state_delegate.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nfrom PySide6.QtCore import QModelIndex, QObject\nfrom PySide6.QtGui import Qt, QPainter\nfrom PySide6.QtSvg import QSvgRenderer\nfrom PySide6.QtWidgets import QStyleOptionViewItem\n\nfrom fk.qt.abstract_item_delegate import AbstractItemDelegate, get_padding\n\n\nclass WorkitemStateDelegate(AbstractItemDelegate):\n    _svg_renderer: QSvgRenderer\n\n    def __init__(self,\n                 parent: QObject = None,\n                 theme: str = 'mixed',\n                 selection_color: str = '#555',\n                 crossout_color: str = '#777'):\n        AbstractItemDelegate.__init__(self, parent, theme, selection_color, crossout_color)\n        self._svg_renderer = QSvgRenderer(\n            f':/icons/{self._theme}/24x24/workitem-unplanned.svg',\n            aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio)\n\n    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:\n        if index.data(501) == 'planned':  # We can also get a drop placeholder here, which we don't want to paint\n            workitem = index.data(500)\n            painter.save()\n\n            self.paint_background(painter, option, workitem.is_sealed())\n\n            if not workitem.is_planned():\n                padding = get_padding(option)\n                rect = option.rect.adjusted(2, padding, -2, -padding)\n                rect.setHeight(option.fontMetrics.height())\n                self._svg_renderer.render(painter, rect)\n\n            painter.restore()\n"
  },
  {
    "path": "src/fk/qt/workitem_tableview.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtCore import Qt, QModelIndex\nfrom PySide6.QtWidgets import QWidget, QHeaderView, QMenu, QMessageBox\n\nfrom fk.core.abstract_data_item import generate_unique_name, generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource, start_workitem\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder, AfterSourceChanged\nfrom fk.core.events import AfterWorkitemCreate, AfterSettingsChanged\nfrom fk.core.pomodoro import POMODORO_TYPE_NORMAL, Pomodoro, POMODORO_TYPE_TRACKER\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, RemovePomodoroStrategy\nfrom fk.core.tag import Tag\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.timer_data import TimerData\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import DeleteWorkitemStrategy, CreateWorkitemStrategy\nfrom fk.desktop.application import Application\nfrom fk.qt.abstract_tableview import AbstractTableView\nfrom fk.qt.actions import Actions\nfrom fk.qt.focus_widget import complete_item\nfrom fk.qt.pomodoro_delegate import PomodoroDelegate\nfrom fk.qt.workitem_model import WorkitemModel\nfrom fk.qt.workitem_state_delegate import WorkitemStateDelegate\nfrom fk.qt.workitem_text_delegate import WorkitemTextDelegate\n\nlogger = logging.getLogger(__name__)\n\n\nclass WorkitemTableView(AbstractTableView[Backlog | Tag, Workitem]):\n    _application: Application\n    _menu: QMenu\n\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 source_holder: EventSourceHolder,\n                 timer: PomodoroTimer | None,\n                 actions: Actions):\n        super().__init__(parent,\n                         source_holder,\n                         WorkitemModel(parent, source_holder),\n                         'workitems_table',\n                         actions,\n                         'Loading, please wait...',\n                         '← Select a backlog or tag.',\n                         'The selected backlog is empty.\\nCreate the first workitem by pressing Ins key.',\n                         1)\n        self._application = application\n        self._configure_delegate()\n        self._menu = self._init_menu(actions)\n        source_holder.on(AfterSourceChanged, self._on_source_changed)\n        self.update_actions(None)\n        application.get_settings().on(AfterSettingsChanged, self._on_setting_changed)\n        if timer is not None:\n            timer.on(PomodoroTimer.TimerTick, self._on_tick)\n        else:\n            logger.debug('WorkitemTableView will not update automatically on timer ticks')\n\n    def _on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.theme' in new_values or 'Application.feature_tags' in new_values:\n            self._configure_delegate()\n            self._resize()\n\n    def _is_tags_enabled(self) -> bool:\n        return self._application.get_settings().get('Application.feature_tags') == 'True'\n\n    def _configure_delegate(self):\n        # Workitem state -- image or no delegate\n        if self._is_tags_enabled():\n            self.setItemDelegateForColumn(\n                0,\n                WorkitemStateDelegate(\n                    self,\n                    self._application.get_icon_theme(),\n                    self._application.get_theme_variables()['SELECTION_BG_COLOR'],\n                    self._application.get_theme_variables()['TABLE_CROSSOUT_COLOR']))\n        else:\n            self.setItemDelegateForColumn(0, None)\n\n        # Workitem text -- HTML or no delegate\n        if self._is_tags_enabled():\n            self.setItemDelegateForColumn(\n                1,\n                WorkitemTextDelegate(\n                    self,\n                    self._application.get_icon_theme(),\n                    self._application.get_theme_variables()['TABLE_TEXT_COLOR'],\n                    self._application.get_theme_variables()['SELECTION_BG_COLOR'],\n                    self._application.get_theme_variables()['TABLE_CROSSOUT_COLOR']))\n        else:\n            self.setItemDelegateForColumn(1, None)\n\n        # Pomodoros display\n        self.setItemDelegateForColumn(\n            2,\n            PomodoroDelegate(\n                self,\n                self._application.get_icon_theme(),\n                self._application.get_theme_variables()['SELECTION_BG_COLOR'],\n                self._application.get_theme_variables()['TABLE_CROSSOUT_COLOR'],\n                self._is_tags_enabled()))\n\n    def _update_actions_if_needed(self, workitem: Workitem):\n        current = self.get_current()\n        if workitem == current:\n            self.update_actions(current)\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource) -> None:\n        super()._on_source_changed(event, source)\n        source.on(AfterWorkitemCreate, self._on_new_workitem)\n        source.on(\"AfterWorkitem*\",\n                  lambda workitem, **kwargs: self._update_actions_if_needed(workitem))\n        source.on('AfterPomodoro*',\n                  lambda **kwargs: self._update_actions_if_needed(\n                      kwargs['workitem'] if 'workitem' in kwargs else kwargs['pomodoro'].get_parent()\n                  ))\n        source.on('Timer(Work|Rest)(Start|Complete)', lambda **_: self.update_actions(self.get_current()))\n        self.selectionModel().clear()\n        self.upstream_selected(None)\n\n    def _init_menu(self, actions: Actions) -> QMenu:\n        menu: QMenu = QMenu()\n        menu.addActions([\n            actions['workitems_table.newItem'],\n            actions['workitems_table.renameItem'],\n            actions['workitems_table.deleteItem'],\n            actions['workitems_table.startItem'],\n            actions['workitems_table.addPomodoro'],\n            actions['workitems_table.removePomodoro'],\n            actions['workitems_table.hideCompleted'],\n            actions['workitems_table.completeItem'],\n        ])\n        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)\n        self.customContextMenuRequested.connect(lambda p: menu.exec(self.mapToGlobal(p)))\n        return menu\n\n    @staticmethod\n    def define_actions(actions: Actions):\n        actions.add('workitems_table.newItem', \"New Item\", 'Ins', \"tool-add\", WorkitemTableView.create_workitem)\n        actions.add('workitems_table.renameItem', \"Rename Item\", 'F6', \"tool-rename\", WorkitemTableView.rename_selected_workitem)\n        actions.add('workitems_table.deleteItem', \"Delete Item\", 'Del', \"tool-delete\", WorkitemTableView.delete_selected_workitem)\n        actions.add('workitems_table.startItem', \"Start Item\", 'Ctrl+S', \"tool-start-item\", WorkitemTableView.start_selected_workitem)\n        actions.add('workitems_table.completeItem', \"Complete Item\", 'Ctrl+P', \"tool-complete-item\", WorkitemTableView.complete_selected_workitem)\n        actions.add('workitems_table.addPomodoro', \"Add Pomodoro\", 'Ctrl++', \"tool-add-pomodoro\", WorkitemTableView.add_pomodoro)\n        actions.add('workitems_table.removePomodoro', \"Remove Pomodoro\", 'Ctrl+-', \"tool-remove-pomodoro\", WorkitemTableView.remove_pomodoro)\n        actions.add('workitems_table.hideCompleted',\n                    \"Hide Completed Items\",\n                    '',\n                    (\"tool-filter-on\", \"tool-filter-off\"),\n                    WorkitemTableView._toggle_hide_completed_workitems,\n                    True,\n                    actions.get_settings().get('Application.hide_completed') == 'True')\n\n    def upstream_selected(self, backlog_or_tag: Backlog | Tag | None) -> None:\n        super().upstream_selected(backlog_or_tag)\n        is_backlog = type(backlog_or_tag) is Backlog\n        self._actions['workitems_table.newItem'].setEnabled(is_backlog)\n        self._resize()\n\n    def update_actions(self, selected: Workitem | None) -> None:\n        # It can be None for example if we don't have any backlogs left, or if we haven't loaded any yet.\n        is_workitem_selected = selected is not None\n        is_workitem_editable = is_workitem_selected and not selected.is_sealed()\n        is_tracker = is_workitem_selected and selected.is_tracker()\n        self._actions['workitems_table.deleteItem'].setEnabled(is_workitem_selected)\n        self._actions['workitems_table.renameItem'].setEnabled(is_workitem_editable)\n        self._actions['workitems_table.startItem'].setEnabled(is_workitem_editable\n                                                              and (selected.is_startable() or len(selected) == 0 or selected.is_tracker())\n                                                              and self._source.get_data().get_current_user().get_timer().is_idling())\n        self._actions['workitems_table.completeItem'].setEnabled(is_workitem_editable)\n        self._actions['workitems_table.addPomodoro'].setEnabled(is_workitem_editable and not is_tracker)\n        self._actions['workitems_table.removePomodoro'].setEnabled(is_workitem_editable\n                                                                   and selected.is_startable()\n                                                                   and not is_tracker)\n\n    # Actions\n\n    def create_workitem(self) -> None:\n        model = self.model()\n        backlog_or_tag: Backlog | Tag = model.get_backlog_or_tag()\n        if backlog_or_tag is None:\n            raise Exception(\"Trying to create a workitem while there's no backlog nor tag selected\")\n        if type(backlog_or_tag) is Tag:\n            raise Exception(\"Trying to create a workitem directly in a tag -- shouldn't be possible\")\n        backlog: Backlog = backlog_or_tag\n        new_name = generate_unique_name(\"Do something\", backlog.names())\n        self._source.execute(CreateWorkitemStrategy,\n                             [generate_uid(), backlog.get_uid(), new_name],\n                             carry=\"edit\")\n\n        # A simpler, more efficient, but a bit uglier single-step alternative\n        # (new_name, ok) = QInputDialog.getText(self,\n        #                                       \"New item\",\n        #                                       \"Provide a name for the new item\",\n        #                                       text=\"Do something\")\n        # if ok:\n        #     self._source.execute(CreateWorkitemStrategy, [generate_uid(), backlog.get_uid(), new_name])\n\n    def _on_new_workitem(self, workitem: Workitem, **kwargs):\n        if 'carry' in kwargs and kwargs['carry'] == 'edit':\n            index: QModelIndex = self.select(workitem)\n            self.edit(index)\n\n    def rename_selected_workitem(self) -> None:\n        index: QModelIndex = self.currentIndex()\n        if index is None:\n            raise Exception(\"Trying to rename a workitem, while there's none selected\")\n        self.edit(index)\n\n    def delete_selected_workitem(self) -> None:\n        selected: Workitem = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to delete a workitem, while there's none selected\")\n        if QMessageBox().warning(self,\n                                 \"Confirmation\",\n                                 f\"Are you sure you want to delete workitem '{selected.get_name()}'?\",\n                                 QMessageBox.StandardButton.Ok,\n                                 QMessageBox.StandardButton.Cancel\n                                 ) == QMessageBox.StandardButton.Ok:\n            self._source.execute(DeleteWorkitemStrategy, [selected.get_uid()])\n\n    def start_selected_workitem(self) -> None:\n        selected: Workitem = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to start a workitem, while there's none selected\")\n        start_workitem(selected, self._source)\n\n    def complete_selected_workitem(self) -> None:\n        selected: Workitem = self.get_current()\n        complete_item(selected, self, self._source)\n\n    def add_pomodoro(self) -> None:\n        selected: Workitem = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to add pomodoro to a workitem, while there's none selected\")\n        self._source.execute(AddPomodoroStrategy, [\n            selected.get_uid(),\n            \"1\",\n            POMODORO_TYPE_NORMAL\n        ])\n\n    def remove_pomodoro(self) -> None:\n        selected: Workitem = self.get_current()\n        if selected is None:\n            raise Exception(\"Trying to remove pomodoro from a workitem, while there's none selected\")\n        self._source.execute(RemovePomodoroStrategy, [\n            selected.get_uid(),\n            \"1\"\n        ])\n\n    def _toggle_hide_completed_workitems(self, checked: bool) -> None:\n        self.model().hide_completed(checked)\n        self._resize()\n        self._source.set_config_parameters({'Application.hide_completed': str(checked)})\n\n    def _resize(self) -> None:\n        self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)\n        self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)\n        self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)\n\n        # Resizing to contents results in visible blinking on Kubuntu 20.04, so cannot be enabled by default.\n        self.verticalHeader().setSectionResizeMode(\n            QHeaderView.ResizeMode.ResizeToContents if self._is_tags_enabled() else QHeaderView.ResizeMode.Fixed)\n\n    def _on_tick(self, timer: TimerData, counter: int, event: str) -> None:\n        if counter % 10 == 0:\n            pomodoro: Pomodoro = timer.get_running_pomodoro()\n            # We only care about repainting workitems in tracking mode\n            if pomodoro is not None and pomodoro.get_type() == POMODORO_TYPE_TRACKER:\n                workitem: Workitem = pomodoro.get_parent()\n                backlog: Backlog = workitem.get_parent()\n                if backlog == self.model().get_backlog_or_tag():\n                    self.model().repaint_workitem(workitem)\n"
  },
  {
    "path": "src/fk/qt/workitem_text_delegate.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport re\nfrom html import escape\n\nfrom PySide6.QtCore import QSize, QObject, QModelIndex\nfrom PySide6.QtGui import QStaticText, QPainter\nfrom PySide6.QtWidgets import QStyleOptionViewItem\n\nfrom fk.core.workitem import Workitem\nfrom fk.qt.abstract_item_delegate import AbstractItemDelegate, get_padding\n\nTAG_REGEX = re.compile('#(\\\\w+)')\n\n\nclass WorkitemTextDelegate(AbstractItemDelegate):\n    _text_color: str\n\n    def __init__(self,\n                 parent: QObject = None,\n                 theme: str = 'mixed',\n                 text_color: str = '#000',\n                 selection_color: str = '#555',\n                 crossout_color: str = '#777'):\n        AbstractItemDelegate.__init__(self, parent, theme, selection_color, crossout_color)\n        self._text_color = text_color\n\n    def _format_html(self, workitem: Workitem, is_placeholder: bool) -> str:\n        text = workitem.get_name()\n        text = TAG_REGEX.sub('<b>\\\\1</b>', escape(text, False))\n        return (f'<span '\n                f'style=\"color: {\"gray\" if is_placeholder else self._text_color}; '\n                f'text-decoration: {\"line-through\" if workitem.is_sealed() else \"none\"}; '\n                # f'font-weight: {\"bold\" if workitem.is_running() else \"normal\"};'\n                f'\">{text}</span>')\n\n    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:\n        is_placeholder = index.data(501) == 'drop'\n        painter.save()\n\n        workitem: Workitem = index.data(500)\n        self.paint_background(painter, option, workitem.is_sealed())\n\n        st = QStaticText(self._format_html(workitem, is_placeholder))\n        st.setTextWidth(option.rect.width())\n\n        painter.drawStaticText(option.rect.left(),\n                               option.rect.top() + get_padding(option),\n                               st)\n\n        painter.restore()\n\n    def sizeHint(self, option, index) -> QSize:\n        size = super().sizeHint(option, index)\n        size.setHeight(size.height() + 8)\n        return size\n"
  },
  {
    "path": "src/fk/qt/workitem_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtGui import Qt\nfrom PySide6.QtWidgets import QWidget, QVBoxLayout\n\nfrom fk.core.backlog import Backlog\nfrom fk.core.event_source_holder import EventSourceHolder\nfrom fk.core.events import AfterSettingsChanged\nfrom fk.core.tag import Tag\nfrom fk.core.timer import PomodoroTimer\nfrom fk.desktop.application import Application\nfrom fk.qt.actions import Actions\nfrom fk.qt.configurable_toolbar import ConfigurableToolBar\nfrom fk.qt.workitem_tableview import WorkitemTableView\n\nlogger = logging.getLogger(__name__)\n\n\nclass WorkitemWidget(QWidget):\n    _workitems_table: WorkitemTableView\n    _source_holder: EventSourceHolder\n\n    def __init__(self,\n                 parent: QWidget,\n                 application: Application,\n                 source_holder: EventSourceHolder,\n                 timer: PomodoroTimer,\n                 actions: Actions):\n        super().__init__(parent)\n        self.setObjectName('workitems_widget')\n        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground)\n\n        self._source_holder = source_holder\n        layout = QVBoxLayout(self)\n        layout.setContentsMargins(0, 0, 0, 0)\n        layout.setSpacing(0)\n        self.setLayout(layout)\n\n        tb = ConfigurableToolBar(self, actions, \"workitems_toolbar\")\n        tb.addAction(actions['workitems_table.newItem'])\n        tb.addAction(actions['workitems_table.deleteItem'])\n        tb.addAction(actions['workitems_table.renameItem'])\n        tb.addAction(actions['workitems_table.startItem'])\n        tb.addAction(actions['workitems_table.addPomodoro'])\n        tb.addAction(actions['workitems_table.removePomodoro'])\n        tb.addAction(actions['workitems_table.hideCompleted'])\n        tb.addAction(actions['workitems_table.completeItem'])\n        layout.addWidget(tb)\n\n        self._workitems_table = WorkitemTableView(self,\n                                                  application,\n                                                  source_holder,\n                                                  timer,\n                                                  actions)\n        layout.addWidget(self._workitems_table)\n\n        application.get_settings().on(AfterSettingsChanged, self.on_setting_changed)\n\n    def get_table(self) -> WorkitemTableView:\n        return self._workitems_table\n\n    def upstream_selected(self, backlog_or_tag: Backlog | Tag | None) -> None:\n        self._workitems_table.upstream_selected(backlog_or_tag)\n\n    def on_setting_changed(self, event: str, old_values: dict[str, str], new_values: dict[str, str]):\n        if 'Application.show_toolbar' in new_values:\n            show = new_values['Application.show_toolbar'] == 'True'\n            logger.debug(f'Show workitem toolbar: {show}')\n"
  },
  {
    "path": "src/fk/tests/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/tests/abstract_test_case.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport abc\nfrom typing import Callable\nfrom unittest import TestCase\n\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\n\n\nclass AbstractTestCase(TestCase, abc.ABC):\n    def assert_events(self,\n                      emitter: AbstractEventEmitter,\n                      action: Callable,\n                      expected_events: list[str],\n                      expected_params: dict[str, dict[str, any]] = None):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if expected_params is not None:\n                params = expected_params.get(event)\n                if params is not None:\n                    for name in params:\n                        self.assertIn(name, kwargs)\n                        self.assertEqual(kwargs[name], params[name])\n\n        emitter.on('*', on_event)\n        action()\n        self.assertEqual(len(fired), len(expected_events))\n        for i in range(len(expected_events)):\n            self.assertEqual(fired[i], expected_events[i])\n"
  },
  {
    "path": "src/fk/tests/data_generator.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport sys\nfrom typing import Iterable\n\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog_strategies import CreateBacklogStrategy, RenameBacklogStrategy, DeleteBacklogStrategy\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, AddInterruptionStrategy, RemovePomodoroStrategy\nfrom fk.core.simple_serializer import SimpleSerializer\nfrom fk.core.tenant import ADMIN_USER\nfrom fk.core.timer_strategies import StopTimerStrategy, StartTimerStrategy\nfrom fk.core.user_strategies import CreateUserStrategy\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, CompleteWorkitemStrategy, DeleteWorkitemStrategy, \\\n    RenameWorkitemStrategy\nfrom fk.tests.test_utils import one_of, shuffle, randint, rand_normal, random\n\nPROJECTS = ['#Alpha', '#Beta', '#Gamma', '#Delta', '#Omega']\n\nVERBS = ['Create', 'Generate', 'Fix', 'Explore', 'Request',\n         'Send', 'Document', 'Think about', 'Plan', 'Draw',\n         'Deprecate', 'Explain', 'Check', 'Verify', 'Find']\n\nNOUNS = ['screenshot', 'bug', 'code', 'function', 'website',\n         'documentation', 'script', 'tool', 'email', 'new feature',\n         'automation', 'scheme', 'design', 'architecture', 'idea']\n\n\ndef lorem_ipsum() -> str:\n    return f'{one_of(VERBS)} {one_of(NOUNS)} for {one_of(PROJECTS)}'\n\n\ndef lorem_ipsum_backlog() -> str:\n    return f'Template for {one_of(PROJECTS)}'\n\n\ndef emulate(days: int, user: str) -> Iterable[AbstractStrategy]:\n    seq = 1\n    day = days + 1\n    while day > 0:\n        day -= 1\n        now = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=day)\n        if now.weekday() >= 5:\n            continue\n\n        now = datetime.datetime(now.year, now.month, now.day,\n                                rand_normal(8, 10), randint(0, 59),\n                                tzinfo=datetime.timezone.utc)\n\n        if seq == 1:\n            yield CreateUserStrategy(seq,\n                                     now,\n                                     ADMIN_USER,\n                                     [user, user],\n                                     settings)\n\n        seq += 1\n        now += datetime.timedelta(seconds=rand_normal(1, 60))\n        backlog_uid = generate_uid()\n        backlog_name = now.strftime('%Y-%m-%d, %A')\n        yield CreateBacklogStrategy(seq,\n                                    now,\n                                    user,\n                                    [backlog_uid, backlog_name],\n                                    settings)\n\n        pomodoros = list[tuple[str, str]]()\n        incomplete_workitems = set[str]()\n\n        for w in range(rand_normal(1, 10)):\n            seq += 1\n            now += datetime.timedelta(seconds=rand_normal(1, 60))\n            workitem_uid = generate_uid()\n            yield CreateWorkitemStrategy(seq,\n                                         now,\n                                         user,\n                                         [workitem_uid, backlog_uid, lorem_ipsum()],\n                                         settings)\n            incomplete_workitems.add(workitem_uid)\n\n            if randint(0, 10) > 6:\n                # This *will be* a tracker pomodoro. AddPomodoro will be called later.\n                for _ in range(rand_normal(0, 8)):\n                    pomodoros.append((workitem_uid, 'tracker'))\n            else:\n                # Normal pomodoro\n                for _ in range(rand_normal(0, 4)):\n                    seq += 1\n                    now += datetime.timedelta(seconds=rand_normal(1, 10))\n                    pomodoros.append((workitem_uid, 'normal'))\n                    yield AddPomodoroStrategy(seq,\n                                              now,\n                                              user,\n                                              [workitem_uid, '1', 'normal'],\n                                              settings)\n\n        completed_in_series = 0\n        shuffle(pomodoros)\n\n        while len(pomodoros) > 0:\n            workitem_uid, pomodoro_type = pomodoros.pop()\n\n            choice = randint(1, 10)\n            if choice < 2:\n                continue    # Ignore it\n            elif choice < 3 and pomodoro_type == 'normal':\n                # Remove it\n                seq += 1\n                now += datetime.timedelta(seconds=rand_normal(1, 60))\n                yield RemovePomodoroStrategy(seq,\n                                             now,\n                                             user,\n                                             [workitem_uid, '1'],\n                                             settings)\n                continue\n\n            if random() < 0.2:\n                # Rename workitem\n                seq += 1\n                now += datetime.timedelta(seconds=rand_normal(1, 60))\n                yield RenameWorkitemStrategy(seq,\n                                             now,\n                                             user,\n                                             [workitem_uid, lorem_ipsum()],\n                                             settings)\n\n            if random() < 0.01:\n                # Rename backlog\n                seq += 1\n                now += datetime.timedelta(seconds=rand_normal(1, 60))\n                yield RenameBacklogStrategy(seq,\n                                            now,\n                                            user,\n                                            [backlog_uid, lorem_ipsum_backlog()],\n                                            settings)\n\n            if random() < 0.01:\n                # Delete backlog\n                seq += 1\n                now += datetime.timedelta(seconds=rand_normal(1, 60))\n                yield DeleteBacklogStrategy(seq,\n                                            now,\n                                            user,\n                                            [backlog_uid],\n                                            settings)\n                incomplete_workitems.clear()\n                break\n\n            # Else, start it and...\n            seq += 1\n            now += datetime.timedelta(seconds=rand_normal(1, 120))\n\n            if pomodoro_type == 'tracker':\n                # That's what the GUI does for trackers -- it creates a pomodoro right before starting it\n                yield AddPomodoroStrategy(seq,\n                                          now,\n                                          user,\n                                          [workitem_uid, '1', 'tracker'],\n                                          settings)\n                seq += 1\n                now += datetime.timedelta(seconds=0.01)\n                yield StartTimerStrategy(seq,\n                                         now,\n                                         user,\n                                         [workitem_uid],\n                                         settings)\n\n                seq += 1\n                now += datetime.timedelta(seconds=rand_normal(1, 1200))\n                if random() < 0.05:\n                    yield CompleteWorkitemStrategy(seq,\n                                                   now,\n                                                   user,\n                                                   [workitem_uid, 'finished'],\n                                                   settings)\n                    incomplete_workitems.remove(workitem_uid)\n                    pomodoros = list(filter(lambda p: p[0] != workitem_uid, pomodoros))\n                elif random() < 0.05:\n                    yield DeleteWorkitemStrategy(seq,\n                                                now,\n                                                user,\n                                                [workitem_uid],\n                                                settings)\n                    incomplete_workitems.remove(workitem_uid)\n                    pomodoros = list(filter(lambda p: p[0] != workitem_uid, pomodoros))\n                else:\n                    yield StopTimerStrategy(seq,\n                                            now,\n                                            user,\n                                            [],\n                                            settings)\n            else:\n                # For normal pomodoros only\n                yield StartTimerStrategy(seq,\n                                         now,\n                                         user,\n                                         [workitem_uid, '1500', '300' if completed_in_series < 3 else '0'],\n                                         settings)\n\n                choice = randint(1, 10)\n                if choice < 3:  # Void it\n                    seq += 1\n                    now += datetime.timedelta(seconds=rand_normal(1, 1800))\n                    yield AddInterruptionStrategy(seq,\n                                                  now,\n                                                  user,\n                                                  [\n                                                      workitem_uid,\n                                                      'Voided for a good reason' if random() < 0.5 else 'Pomodoro voided',\n                                                      ''],\n                                                  settings)\n                    seq += 1\n                    yield StopTimerStrategy(seq,\n                                            now,\n                                            user,\n                                            [],\n                                            settings)\n                else:\n                    pomodoro_duration = 1500\n                    if completed_in_series < 3:\n                        pomodoro_duration += 300\n                    else:\n                        # Take a long break every 4 pomodoro\n                        pomodoro_duration += randint(1, 3600)\n                    if choice < 5:  # Add an interruption\n                        seq += 1\n                        after = rand_normal(1, pomodoro_duration)\n                        now += datetime.timedelta(seconds=after)\n                        yield AddInterruptionStrategy(seq,\n                                                      now,\n                                                      user,\n                                                      [workitem_uid,\n                                                       'An interruption' if random() < 0.5 else '',\n                                                       str(after / 2) if random() < 0.5 else ''],\n                                                      settings)\n\n                    if random() < 0.05:\n                        now += datetime.timedelta(seconds=randint(1, pomodoro_duration - 1))\n                        seq += 1\n                        yield CompleteWorkitemStrategy(seq,\n                                                       now,\n                                                       user,\n                                                       [workitem_uid, 'finished'],\n                                                       settings)\n                        incomplete_workitems.remove(workitem_uid)\n                        pomodoros = list(filter(lambda p: p[0] != workitem_uid, pomodoros))\n                    elif random() < 0.05:\n                        now += datetime.timedelta(seconds=randint(1, pomodoro_duration - 1))\n                        seq += 1\n                        yield DeleteWorkitemStrategy(seq,\n                                                     now,\n                                                     user,\n                                                     [workitem_uid],\n                                                     settings)\n                        incomplete_workitems.remove(workitem_uid)\n                        pomodoros = list(filter(lambda p: p[0] != workitem_uid, pomodoros))\n                    else:\n                        # Complete it -- just increment the timer, let it \"finish\"\n                        now += datetime.timedelta(seconds=pomodoro_duration)\n                        completed_in_series += 1\n                        if completed_in_series == 4:\n                            # We've just took a long break -- stop the timer and reset the series\n                            seq += 1\n                            yield StopTimerStrategy(seq,\n                                                    now,\n                                                    user,\n                                                    [],\n                                                    settings)\n                            completed_in_series = 0\n\n                        if choice > 8:\n                            seq += 1\n                            now += datetime.timedelta(seconds=rand_normal(1, 10))\n                            yield AddPomodoroStrategy(seq,\n                                                      now,\n                                                      user,\n                                                      [workitem_uid, '1', 'normal'],\n                                                      settings)\n                            pomodoros.append((workitem_uid, 'normal'))\n\n        for w in incomplete_workitems:\n            choice = randint(1, 10)\n            if choice < 4:  # Ignore it\n                continue\n\n            # Else complete it\n            seq += 1\n            now += datetime.timedelta(seconds=rand_normal(1, 120))\n            yield CompleteWorkitemStrategy(seq,\n                                           now,\n                                           user,\n                                           [w, 'finished'],\n                                           settings)\n\n\nif __name__ == '__main__':\n    if len(sys.argv) != 2:\n        print('Usage: PYTHONPATH=src python -m fk.tests.data_generator <DAYS>')\n        print('Where DAYS is the number of days to emulate. The results are output to STDOUT.')\n        exit(1)\n\n    settings = MockSettings()\n    serializer = SimpleSerializer(settings, NoCryptograph(settings))\n    for strategy in emulate(int(sys.argv[1]), 'user@local.host'):\n        print(serializer.serialize(strategy))\n"
  },
  {
    "path": "src/fk/tests/fixtures/random-dump.txt",
    "content": "- Class: User\n  UID: user@local.host\n  Owner: N/A\n  Parent UID: 0\n  Create date: 2025-05-05 14:38:00+00:00\n  Last modified: 2025-06-02 23:19:32.070000+00:00\n  Name: user@local.host\n  Children:\n  - Class: Backlog\n    UID: b59e1a2e-225d-4944-8684-43329f82e395\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-05 14:38:27+00:00\n    Last modified: 2025-05-05 18:37:06.100000+00:00\n    Name: Template for #Alpha\n    Children:\n    - Class: Workitem\n      UID: 614642f8-837b-4729-b51c-e786c50f103b\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:38:47+00:00\n      Last modified: 2025-05-05 18:35:15.100000+00:00\n      Name: Find website for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 17:46:53.040000+00:00\n        Last modified: 2025-05-05 17:55:28.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 17:46:53.050000+00:00\n        Rest started: None\n        Completed: 2025-05-05 17:55:28.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 17:56:24.050000+00:00\n        Last modified: 2025-05-05 18:05:33.060000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 17:56:24.060000+00:00\n        Rest started: None\n        Completed: 2025-05-05 18:05:33.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 18:07:10.060000+00:00\n        Last modified: 2025-05-05 18:10:58.070000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 18:07:10.070000+00:00\n        Rest started: None\n        Completed: 2025-05-05 18:10:58.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 18:11:39.070000+00:00\n        Last modified: 2025-05-05 18:15:13.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 18:11:39.080000+00:00\n        Rest started: None\n        Completed: 2025-05-05 18:15:13.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 18:17:09.080000+00:00\n        Last modified: 2025-05-05 18:25:33.090000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 18:17:09.090000+00:00\n        Rest started: None\n        Completed: 2025-05-05 18:25:33.090000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 18:26:29.090000+00:00\n        Last modified: 2025-05-05 18:35:15.100000+00:00\n        Name: Pomodoro 6\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 18:26:29.100000+00:00\n        Rest started: None\n        Completed: 2025-05-05 18:35:15.100000+00:00\n      Intervals: ['From 2025-05-05 17:46:53.050000+00:00 to 2025-05-05 17:55:28.050000+00:00 [0 / 0]', 'From 2025-05-05 17:56:24.060000+00:00 to 2025-05-05 18:05:33.060000+00:00 [0 / 0]', 'From 2025-05-05 18:07:10.070000+00:00 to 2025-05-05 18:10:58.070000+00:00 [0 / 0]', 'From 2025-05-05 18:11:39.080000+00:00 to 2025-05-05 18:15:13.080000+00:00 [0 / 0]', 'From 2025-05-05 18:17:09.090000+00:00 to 2025-05-05 18:25:33.090000+00:00 [0 / 0]', 'From 2025-05-05 18:26:29.100000+00:00 to 2025-05-05 18:35:15.100000+00:00 [0 / 0]']\n      State: running\n      Work started: 2025-05-05 17:46:53.050000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:39:17+00:00\n      Last modified: 2025-05-05 17:45:56.040000+00:00\n      Name: Request architecture for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:39:22+00:00\n        Last modified: 2025-05-05 17:45:56.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-05 17:34:27.040000+00:00\n          Last modified: 2025-05-05 17:34:27.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-05 17:45:56.040000+00:00\n          Last modified: 2025-05-05 17:45:56.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-05 17:35:39.040000+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:39:26+00:00\n        Last modified: 2025-05-05 14:39:26+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-05 17:23:59.040000+00:00 to 2025-05-05 17:34:27.040000+00:00 [1500.0 / 300.0]', 'From 2025-05-05 17:35:39.040000+00:00 to 2025-05-05 17:45:56.040000+00:00 [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-05 17:23:59.040000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 1fb907fd-59cc-406d-8b08-ca7788c83cca\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:39:49+00:00\n      Last modified: 2025-05-05 18:36:11.100000+00:00\n      Name: Check documentation for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:39:55+00:00\n        Last modified: 2025-05-05 17:08:40.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-05 16:52:51.040000+00:00\n          Last modified: 2025-05-05 16:52:51.040000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-05 16:38:40.040000+00:00\n        Rest started: 2025-05-05 17:03:40.040000+00:00\n        Completed: 2025-05-05 17:08:40.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:40:00+00:00\n        Last modified: 2025-05-05 14:40:00+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-05 16:38:40.040000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-05 16:38:40.040000+00:00\n      Work ended: 2025-05-05 18:36:11.100000+00:00\n    - Class: Workitem\n      UID: d6f21703-52fc-45cc-b635-df6b3a024b16\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:40:40+00:00\n      Last modified: 2025-05-05 16:38:10.040000+00:00\n      Name: Send automation for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 16:26:04.030000+00:00\n        Last modified: 2025-05-05 16:38:10.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 16:26:04.040000+00:00\n        Rest started: None\n        Completed: 2025-05-05 16:38:10.040000+00:00\n      Intervals: ['From 2025-05-05 16:26:04.040000+00:00 to 2025-05-05 16:38:10.040000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-05 16:26:04.040000+00:00\n      Work ended: 2025-05-05 16:38:10.040000+00:00\n    - Class: Workitem\n      UID: 66f859dd-3162-4cf5-a21e-74452bb150b6\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:41:03+00:00\n      Last modified: 2025-05-05 16:25:03.030000+00:00\n      Name: Deprecate script for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:41:09+00:00\n        Last modified: 2025-05-05 16:25:03.030000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-05 16:25:03.030000+00:00\n          Last modified: 2025-05-05 16:25:03.030000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-05 16:05:32.030000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-05 16:05:32.030000+00:00 to 2025-05-05 16:25:03.030000+00:00 [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-05 16:05:32.030000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 977b7a9d-c678-4984-b67e-05dec7c8ac88\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:41:58+00:00\n      Last modified: 2025-05-05 16:03:27.030000+00:00\n      Name: Request screenshot for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 15:35:31+00:00\n        Last modified: 2025-05-05 15:45:20.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 15:35:31.010000+00:00\n        Rest started: None\n        Completed: 2025-05-05 15:45:20.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 15:46:19.010000+00:00\n        Last modified: 2025-05-05 15:56:48.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 15:46:19.020000+00:00\n        Rest started: None\n        Completed: 2025-05-05 15:56:48.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 15:57:48.020000+00:00\n        Last modified: 2025-05-05 16:03:27.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-05 15:57:48.030000+00:00\n        Rest started: None\n        Completed: 2025-05-05 16:03:27.030000+00:00\n      Intervals: ['From 2025-05-05 15:35:31.010000+00:00 to 2025-05-05 15:45:20.010000+00:00 [0 / 0]', 'From 2025-05-05 15:46:19.020000+00:00 to 2025-05-05 15:56:48.020000+00:00 [0 / 0]', 'From 2025-05-05 15:57:48.030000+00:00 to 2025-05-05 16:03:27.030000+00:00 [0 / 0]']\n      State: running\n      Work started: 2025-05-05 15:35:31.010000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 771a44c3-24fa-4b3a-b77b-1c83dece9c10\n      Owner: user@local.host\n      Parent UID: b59e1a2e-225d-4944-8684-43329f82e395\n      Create date: 2025-05-05 14:43:02+00:00\n      Last modified: 2025-05-05 18:37:06.100000+00:00\n      Name: Find script for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:43:09+00:00\n        Last modified: 2025-05-05 15:14:29+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-05 14:44:29+00:00\n        Rest started: 2025-05-05 15:09:29+00:00\n        Completed: 2025-05-05 15:14:29+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-05 14:43:13+00:00\n        Last modified: 2025-05-05 14:43:13+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-05 14:44:29+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-05 14:44:29+00:00\n      Work ended: 2025-05-05 18:37:06.100000+00:00\n  - Class: Backlog\n    UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-09 12:46:34+00:00\n    Last modified: 2025-05-09 22:06:58.040000+00:00\n    Name: 2025-05-09, Friday\n    Children:\n    - Class: Workitem\n      UID: 2390a9aa-0697-4c60-b9d1-747488c6b0b7\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:47:05+00:00\n      Last modified: 2025-05-09 22:06:12.040000+00:00\n      Name: Generate design for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:47:10+00:00\n        Last modified: 2025-05-09 22:01:55.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 22:01:55.040000+00:00\n          Last modified: 2025-05-09 22:01:55.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 21:49:49.040000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 21:49:49.040000+00:00 to 2025-05-09 22:01:55.040000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-09 21:49:49.040000+00:00\n      Work ended: 2025-05-09 22:06:12.040000+00:00\n    - Class: Workitem\n      UID: 6a754aa5-a75f-4afc-b189-846f4c922d50\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:47:38+00:00\n      Last modified: 2025-05-09 21:48:35.040000+00:00\n      Name: Plan architecture for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:47:43+00:00\n        Last modified: 2025-05-09 21:48:35.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 21:14:28.040000+00:00\n          Last modified: 2025-05-09 21:14:28.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 21:34:29.040000+00:00\n          Last modified: 2025-05-09 21:34:29.040000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 21:48:35.040000+00:00\n          Last modified: 2025-05-09 21:48:35.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 21:35:12.040000+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:47:51+00:00\n        Last modified: 2025-05-09 12:47:51+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:47:57+00:00\n        Last modified: 2025-05-09 12:47:57+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 20:57:20.040000+00:00 to 2025-05-09 21:14:28.040000+00:00 [1500.0 / 300.0]', 'From 2025-05-09 21:15:18.040000+00:00 to 2025-05-09 21:34:29.040000+00:00 [1500.0 / 300.0]', 'From 2025-05-09 21:35:12.040000+00:00 to 2025-05-09 21:48:35.040000+00:00 [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-09 20:57:20.040000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:48:35+00:00\n      Last modified: 2025-05-09 22:03:19.040000+00:00\n      Name: Explain tool for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:48:43+00:00\n        Last modified: 2025-05-09 20:13:18.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 19:43:18.040000+00:00\n        Rest started: 2025-05-09 20:08:18.040000+00:00\n        Completed: 2025-05-09 20:13:18.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:48:50+00:00\n        Last modified: 2025-05-09 20:44:07.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 20:14:07.040000+00:00\n        Rest started: 2025-05-09 20:39:07.040000+00:00\n        Completed: 2025-05-09 20:44:07.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 20:13:22.040000+00:00\n        Last modified: 2025-05-09 20:56:06.040000+00:00\n        Name: Pomodoro 3\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 20:56:06.040000+00:00\n          Last modified: 2025-05-09 20:56:06.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 20:45:13.040000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 19:43:18.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 20:14:07.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 20:45:13.040000+00:00 to 2025-05-09 20:56:06.040000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-09 19:43:18.040000+00:00\n      Work ended: 2025-05-09 22:03:19.040000+00:00\n    - Class: Workitem\n      UID: 29f31815-f2a0-48fb-ab66-b12306655c2b\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:49:27+00:00\n      Last modified: 2025-05-09 22:02:29.040000+00:00\n      Name: Send automation for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:49:34+00:00\n        Last modified: 2025-05-09 19:01:42.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 18:31:42.040000+00:00\n        Rest started: 2025-05-09 18:56:42.040000+00:00\n        Completed: 2025-05-09 19:01:42.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:49:39+00:00\n        Last modified: 2025-05-09 19:42:19.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-09 19:02:18.040000+00:00\n        Rest started: 2025-05-09 19:27:18.040000+00:00\n        Completed: 2025-05-09 19:42:19.040000+00:00\n      Intervals: ['From 2025-05-09 18:31:42.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 19:02:18.040000+00:00 to 2025-05-09 19:42:19.040000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-09 18:31:42.040000+00:00\n      Work ended: 2025-05-09 22:02:29.040000+00:00\n    - Class: Workitem\n      UID: f8ee9e53-bb37-4920-bb44-2da47f46a1b5\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:50:08+00:00\n      Last modified: 2025-05-09 18:30:47.040000+00:00\n      Name: Send architecture for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:50:15+00:00\n        Last modified: 2025-05-09 17:56:44.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 17:47:55.040000+00:00\n          Last modified: 2025-05-09 17:47:55.040000+00:00\n          Reason: <None>\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 17:26:44.040000+00:00\n        Rest started: 2025-05-09 17:51:44.040000+00:00\n        Completed: 2025-05-09 17:56:44.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:50:21+00:00\n        Last modified: 2025-05-09 18:30:47.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 18:30:47.040000+00:00\n          Last modified: 2025-05-09 18:30:47.040000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 18:18:42.040000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 17:26:44.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 18:18:42.040000+00:00 to 2025-05-09 18:30:47.040000+00:00 [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-09 17:26:44.040000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: c27fdf19-ad1f-4334-a47d-156d7890608e\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:51:12+00:00\n      Last modified: 2025-05-09 22:06:58.040000+00:00\n      Name: Think about scheme for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:51:21+00:00\n        Last modified: 2025-05-09 17:25:47.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 16:54:26.040000+00:00\n          Last modified: 2025-05-09 16:54:26.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 16:55:47.040000+00:00\n        Rest started: 2025-05-09 17:20:47.040000+00:00\n        Completed: 2025-05-09 17:25:47.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:51:27+00:00\n        Last modified: 2025-05-09 12:51:27+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 16:34:01.040000+00:00 to 2025-05-09 16:54:26.040000+00:00 [1500.0 / 300.0]', 'From 2025-05-09 16:55:47.040000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-09 16:34:01.040000+00:00\n      Work ended: 2025-05-09 22:06:58.040000+00:00\n    - Class: Workitem\n      UID: c02c6190-b9f5-4a60-a6ce-16bff70c4b20\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:51:47+00:00\n      Last modified: 2025-05-09 22:05:31.040000+00:00\n      Name: Verify website for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:51:53+00:00\n        Last modified: 2025-05-09 15:11:30.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 14:41:30.040000+00:00\n        Rest started: 2025-05-09 15:06:30.040000+00:00\n        Completed: 2025-05-09 15:11:30.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:52:01+00:00\n        Last modified: 2025-05-09 16:00:35.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-09 15:12:40.040000+00:00\n        Rest started: 2025-05-09 15:37:40.040000+00:00\n        Completed: 2025-05-09 16:00:35.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:52:04+00:00\n        Last modified: 2025-05-09 16:33:02.040000+00:00\n        Name: Pomodoro 3\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 16:18:16.040000+00:00\n          Last modified: 2025-05-09 16:18:16.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-09 16:33:02.040000+00:00\n          Last modified: 2025-05-09 16:33:02.040000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 16:19:47.040000+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 15:11:35.040000+00:00\n        Last modified: 2025-05-09 15:11:35.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 16:00:42.040000+00:00\n        Last modified: 2025-05-09 16:00:42.040000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 14:41:30.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 15:12:40.040000+00:00 to 2025-05-09 16:00:35.040000+00:00 [1500.0 / 0.0]', 'From 2025-05-09 16:01:31.040000+00:00 to 2025-05-09 16:18:16.040000+00:00 [1500.0 / 300.0]', 'From 2025-05-09 16:19:47.040000+00:00 to 2025-05-09 16:33:02.040000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-09 14:41:30.040000+00:00\n      Work ended: 2025-05-09 22:05:31.040000+00:00\n    - Class: Workitem\n      UID: 048735dc-2e14-4468-b92b-887442533005\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:52:42+00:00\n      Last modified: 2025-05-09 22:04:28.040000+00:00\n      Name: Think about automation for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 13:57:13+00:00\n        Last modified: 2025-05-09 14:06:34.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-09 13:57:13.010000+00:00\n        Rest started: None\n        Completed: 2025-05-09 14:06:34.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 14:08:06.010000+00:00\n        Last modified: 2025-05-09 14:20:34.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-09 14:08:06.020000+00:00\n        Rest started: None\n        Completed: 2025-05-09 14:20:34.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 14:21:19.020000+00:00\n        Last modified: 2025-05-09 14:29:48.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-09 14:21:19.030000+00:00\n        Rest started: None\n        Completed: 2025-05-09 14:29:48.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 14:30:56.030000+00:00\n        Last modified: 2025-05-09 14:40:38.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-09 14:30:56.040000+00:00\n        Rest started: None\n        Completed: 2025-05-09 14:40:38.040000+00:00\n      Intervals: ['From 2025-05-09 13:57:13.010000+00:00 to 2025-05-09 14:06:34.010000+00:00 [0 / 0]', 'From 2025-05-09 14:08:06.020000+00:00 to 2025-05-09 14:20:34.020000+00:00 [0 / 0]', 'From 2025-05-09 14:21:19.030000+00:00 to 2025-05-09 14:29:48.030000+00:00 [0 / 0]', 'From 2025-05-09 14:30:56.040000+00:00 to 2025-05-09 14:40:38.040000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-09 13:57:13.010000+00:00\n      Work ended: 2025-05-09 22:04:28.040000+00:00\n    - Class: Workitem\n      UID: dc092249-26c9-4b09-ac27-da99eebdeacf\n      Owner: user@local.host\n      Parent UID: 307e3d0b-6522-41f0-b9bf-f06e67efe56b\n      Create date: 2025-05-09 12:52:59+00:00\n      Last modified: 2025-05-09 13:56:03+00:00\n      Name: Fix documentation for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:53:07+00:00\n        Last modified: 2025-05-09 13:24:29+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 12:54:29+00:00\n        Rest started: 2025-05-09 13:19:29+00:00\n        Completed: 2025-05-09 13:24:29+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 12:53:11+00:00\n        Last modified: 2025-05-09 13:56:03+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-09 13:26:03+00:00\n        Rest started: 2025-05-09 13:51:03+00:00\n        Completed: 2025-05-09 13:56:03+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-09 13:24:35+00:00\n        Last modified: 2025-05-09 13:24:35+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-09 12:54:29+00:00 to None [1500.0 / 300.0]', 'From 2025-05-09 13:26:03+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-09 12:54:29+00:00\n      Work ended: None\n  - Class: Backlog\n    UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-13 13:24:23+00:00\n    Last modified: 2025-05-13 20:45:14.080000+00:00\n    Name: 2025-05-13, Tuesday\n    Children:\n    - Class: Workitem\n      UID: 3bba42a2-c057-436a-953f-849f590c4462\n      Owner: user@local.host\n      Parent UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n      Create date: 2025-05-13 13:24:46+00:00\n      Last modified: 2025-05-13 20:43:53.080000+00:00\n      Name: Fix idea for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:24:56+00:00\n        Last modified: 2025-05-13 19:33:01.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 19:03:01.080000+00:00\n        Rest started: 2025-05-13 19:28:01.080000+00:00\n        Completed: 2025-05-13 19:33:01.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:25:05+00:00\n        Last modified: 2025-05-13 20:42:13.080000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-13 19:33:54.080000+00:00\n        Rest started: 2025-05-13 19:58:54.080000+00:00\n        Completed: 2025-05-13 20:42:13.080000+00:00\n      Intervals: ['From 2025-05-13 19:03:01.080000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-13 19:33:54.080000+00:00 to 2025-05-13 20:42:13.080000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-13 19:03:01.080000+00:00\n      Work ended: 2025-05-13 20:43:53.080000+00:00\n    - Class: Workitem\n      UID: d32cf12d-f3e3-4284-bb81-ad2503490de7\n      Owner: user@local.host\n      Parent UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n      Create date: 2025-05-13 13:25:29+00:00\n      Last modified: 2025-05-13 19:02:11.080000+00:00\n      Name: Fix screenshot for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:25:36+00:00\n        Last modified: 2025-05-13 18:58:23.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 18:28:23.080000+00:00\n        Rest started: 2025-05-13 18:53:23.080000+00:00\n        Completed: 2025-05-13 18:58:23.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:25:43+00:00\n        Last modified: 2025-05-13 19:02:11.080000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-13 19:02:11.080000+00:00\n          Last modified: 2025-05-13 19:02:11.080000+00:00\n          Reason: The item was marked completed before Pomodoro rang\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 18:59:03.080000+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:25:48+00:00\n        Last modified: 2025-05-13 13:25:48+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 18:58:29.080000+00:00\n        Last modified: 2025-05-13 18:58:29.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-13 18:28:23.080000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-13 18:59:03.080000+00:00 to 2025-05-13 19:02:11.080000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-13 18:28:23.080000+00:00\n      Work ended: 2025-05-13 19:02:11.080000+00:00\n    - Class: Workitem\n      UID: 19884dba-d35d-431b-a474-010406185049\n      Owner: user@local.host\n      Parent UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n      Create date: 2025-05-13 13:26:14+00:00\n      Last modified: 2025-05-13 18:18:39.080000+00:00\n      Name: Create website for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:26:19+00:00\n        Last modified: 2025-05-13 15:53:53.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 15:23:53.080000+00:00\n        Rest started: 2025-05-13 15:48:53.080000+00:00\n        Completed: 2025-05-13 15:53:53.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:26:26+00:00\n        Last modified: 2025-05-13 16:25:16.080000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 15:55:16.080000+00:00\n        Rest started: 2025-05-13 16:20:16.080000+00:00\n        Completed: 2025-05-13 16:25:16.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:26:33+00:00\n        Last modified: 2025-05-13 17:47:37.080000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-13 16:26:45.080000+00:00\n        Rest started: 2025-05-13 16:51:45.080000+00:00\n        Completed: 2025-05-13 17:47:37.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 17:47:42.080000+00:00\n        Last modified: 2025-05-13 18:18:39.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-13 17:57:23.080000+00:00\n          Last modified: 2025-05-13 17:57:23.080000+00:00\n          Reason: <None>\n          Void: False\n          Duration: 0:04:22\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 17:48:39.080000+00:00\n        Rest started: 2025-05-13 18:13:39.080000+00:00\n        Completed: 2025-05-13 18:18:39.080000+00:00\n      Intervals: ['From 2025-05-13 15:23:53.080000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-13 15:55:16.080000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-13 16:26:45.080000+00:00 to 2025-05-13 17:47:37.080000+00:00 [1500.0 / 0.0]', 'From 2025-05-13 17:48:39.080000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-13 15:23:53.080000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 485e0875-108c-4055-8f89-2d24892fc52b\n      Owner: user@local.host\n      Parent UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n      Create date: 2025-05-13 13:27:05+00:00\n      Last modified: 2025-05-13 20:45:14.080000+00:00\n      Name: Request email for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 14:37:46.040000+00:00\n        Last modified: 2025-05-13 14:48:06.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-13 14:37:46.050000+00:00\n        Rest started: None\n        Completed: 2025-05-13 14:48:06.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 14:48:42.050000+00:00\n        Last modified: 2025-05-13 14:56:54.060000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-13 14:48:42.060000+00:00\n        Rest started: None\n        Completed: 2025-05-13 14:56:54.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 14:58:14.060000+00:00\n        Last modified: 2025-05-13 15:10:51.070000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-13 14:58:14.070000+00:00\n        Rest started: None\n        Completed: 2025-05-13 15:10:51.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 15:12:12.070000+00:00\n        Last modified: 2025-05-13 15:23:05.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-13 15:12:12.080000+00:00\n        Rest started: None\n        Completed: 2025-05-13 15:23:05.080000+00:00\n      Intervals: ['From 2025-05-13 14:37:46.050000+00:00 to 2025-05-13 14:48:06.050000+00:00 [0 / 0]', 'From 2025-05-13 14:48:42.060000+00:00 to 2025-05-13 14:56:54.060000+00:00 [0 / 0]', 'From 2025-05-13 14:58:14.070000+00:00 to 2025-05-13 15:10:51.070000+00:00 [0 / 0]', 'From 2025-05-13 15:12:12.080000+00:00 to 2025-05-13 15:23:05.080000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-13 14:37:46.050000+00:00\n      Work ended: 2025-05-13 20:45:14.080000+00:00\n    - Class: Workitem\n      UID: 576cb27a-7d48-4f6d-81da-e3e537de16a2\n      Owner: user@local.host\n      Parent UID: cd7802ad-b68b-4722-81b4-baefe9888c39\n      Create date: 2025-05-13 13:27:26+00:00\n      Last modified: 2025-05-13 20:43:05.080000+00:00\n      Name: Verify design for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-13 13:27:35+00:00\n        Last modified: 2025-05-13 14:36:52.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-13 14:06:52.040000+00:00\n        Rest started: 2025-05-13 14:31:52.040000+00:00\n        Completed: 2025-05-13 14:36:52.040000+00:00\n      Intervals: ['From 2025-05-13 14:06:52.040000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-13 14:06:52.040000+00:00\n      Work ended: 2025-05-13 20:43:05.080000+00:00\n  - Class: Backlog\n    UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-14 13:14:40+00:00\n    Last modified: 2025-05-14 17:38:36.030000+00:00\n    Name: 2025-05-14, Wednesday\n    Children:\n    - Class: Workitem\n      UID: d0dcc6dc-9aa4-4d44-b299-f2203b12cbcc\n      Owner: user@local.host\n      Parent UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n      Create date: 2025-05-14 13:15:41+00:00\n      Last modified: 2025-05-14 17:10:47.010000+00:00\n      Name: Find screenshot for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 17:02:46+00:00\n        Last modified: 2025-05-14 17:10:47.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-14 17:02:46.010000+00:00\n        Rest started: None\n        Completed: 2025-05-14 17:10:47.010000+00:00\n      Intervals: ['From 2025-05-14 17:02:46.010000+00:00 to 2025-05-14 17:10:47.010000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-14 17:02:46.010000+00:00\n      Work ended: 2025-05-14 17:10:47.010000+00:00\n    - Class: Workitem\n      UID: 48f05ec8-7d8b-47b9-9efb-f46699da55ec\n      Owner: user@local.host\n      Parent UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n      Create date: 2025-05-14 13:16:16+00:00\n      Last modified: 2025-05-14 17:35:33.030000+00:00\n      Name: Plan idea for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 13:16:20+00:00\n        Last modified: 2025-05-14 17:01:56+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-14 15:46:02+00:00\n          Last modified: 2025-05-14 15:46:02+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-14 15:16:57+00:00\n        Rest started: 2025-05-14 15:41:57+00:00\n        Completed: 2025-05-14 17:01:56+00:00\n      Intervals: ['From 2025-05-14 15:16:57+00:00 to 2025-05-14 17:01:56+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-14 15:16:57+00:00\n      Work ended: 2025-05-14 17:35:33.030000+00:00\n    - Class: Workitem\n      UID: ea664213-15af-4e12-988f-f140fc06208d\n      Owner: user@local.host\n      Parent UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n      Create date: 2025-05-14 13:17:01+00:00\n      Last modified: 2025-05-14 17:37:48.030000+00:00\n      Name: Create scheme for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 13:17:08+00:00\n        Last modified: 2025-05-14 14:29:59+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-14 13:59:59+00:00\n        Rest started: 2025-05-14 14:24:59+00:00\n        Completed: 2025-05-14 14:29:59+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 13:17:14+00:00\n        Last modified: 2025-05-14 15:15:44+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-14 14:45:17+00:00\n          Last modified: 2025-05-14 14:45:17+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-14 14:45:44+00:00\n        Rest started: 2025-05-14 15:10:44+00:00\n        Completed: 2025-05-14 15:15:44+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 13:17:19+00:00\n        Last modified: 2025-05-14 13:17:19+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-14 13:59:59+00:00 to None [1500.0 / 300.0]', 'From 2025-05-14 14:31:15+00:00 to 2025-05-14 14:45:17+00:00 [1500.0 / 300.0]', 'From 2025-05-14 14:45:44+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-14 13:59:59+00:00\n      Work ended: 2025-05-14 17:37:48.030000+00:00\n    - Class: Workitem\n      UID: 32555d52-bced-45f0-93a5-864c36628de3\n      Owner: user@local.host\n      Parent UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n      Create date: 2025-05-14 13:17:50+00:00\n      Last modified: 2025-05-14 17:36:36.030000+00:00\n      Name: Draw tool for #Alpha\n      Children:\n      - <Empty>\n      Intervals: []\n      State: finished\n      Work started: None\n      Work ended: 2025-05-14 17:36:36.030000+00:00\n    - Class: Workitem\n      UID: bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\n      Owner: user@local.host\n      Parent UID: 764eb6a7-cd3a-495a-8477-5d8cc41475ea\n      Create date: 2025-05-14 13:18:18+00:00\n      Last modified: 2025-05-14 17:38:36.030000+00:00\n      Name: Draw automation for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-14 13:18:24+00:00\n        Last modified: 2025-05-14 13:50:03+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-14 13:28:44+00:00\n          Last modified: 2025-05-14 13:28:44+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-14 13:20:03+00:00\n        Rest started: 2025-05-14 13:45:03+00:00\n        Completed: 2025-05-14 13:50:03+00:00\n      Intervals: ['From 2025-05-14 13:20:03+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-14 13:20:03+00:00\n      Work ended: 2025-05-14 17:38:36.030000+00:00\n  - Class: Backlog\n    UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-15 15:10:39+00:00\n    Last modified: 2025-05-16 00:31:34.130000+00:00\n    Name: 2025-05-15, Thursday\n    Children:\n    - Class: Workitem\n      UID: dce14bc7-9d5e-496e-a190-9330d771fb31\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:11:23+00:00\n      Last modified: 2025-05-16 00:27:07.130000+00:00\n      Name: Create email for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:11:27+00:00\n        Last modified: 2025-05-15 21:04:04.130000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 20:34:04.130000+00:00\n        Rest started: 2025-05-15 20:59:04.130000+00:00\n        Completed: 2025-05-15 21:04:04.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:11:33+00:00\n        Last modified: 2025-05-15 21:34:51.130000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-15 21:18:54.130000+00:00\n          Last modified: 2025-05-15 21:18:54.130000+00:00\n          Reason: <None>\n          Void: False\n          Duration: 0:07:01.500000\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 21:04:51.130000+00:00\n        Rest started: 2025-05-15 21:29:51.130000+00:00\n        Completed: 2025-05-15 21:34:51.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 21:04:11.130000+00:00\n        Last modified: 2025-05-15 22:19:54.130000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 21:49:54.130000+00:00\n        Rest started: 2025-05-15 22:14:54.130000+00:00\n        Completed: 2025-05-15 22:19:54.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 22:20:01.130000+00:00\n        Last modified: 2025-05-15 23:25:06.130000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-15 22:21:07.130000+00:00\n        Rest started: 2025-05-15 22:46:07.130000+00:00\n        Completed: 2025-05-15 23:25:06.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 23:25:11.130000+00:00\n        Last modified: 2025-05-15 23:56:17.130000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 23:26:17.130000+00:00\n        Rest started: 2025-05-15 23:51:17.130000+00:00\n        Completed: 2025-05-15 23:56:17.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 23:56:24.130000+00:00\n        Last modified: 2025-05-16 00:27:07.130000+00:00\n        Name: Pomodoro 6\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 23:57:07.130000+00:00\n        Rest started: 2025-05-16 00:22:07.130000+00:00\n        Completed: 2025-05-16 00:27:07.130000+00:00\n      Intervals: ['From 2025-05-15 20:34:04.130000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-15 21:04:51.130000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-15 21:49:54.130000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-15 22:21:07.130000+00:00 to 2025-05-15 23:25:06.130000+00:00 [1500.0 / 0.0]', 'From 2025-05-15 23:26:17.130000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-15 23:57:07.130000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-15 20:34:04.130000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 5884d6b4-eb07-4432-b65a-b5ff0c272d22\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:12:06+00:00\n      Last modified: 2025-05-15 20:33:18.130000+00:00\n      Name: Document automation for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:12:12+00:00\n        Last modified: 2025-05-15 20:33:18.130000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-15 19:33:03.130000+00:00\n        Rest started: 2025-05-15 19:58:03.130000+00:00\n        Completed: 2025-05-15 20:33:18.130000+00:00\n      Intervals: ['From 2025-05-15 19:33:03.130000+00:00 to 2025-05-15 20:33:18.130000+00:00 [1500.0 / 0.0]']\n      State: running\n      Work started: 2025-05-15 19:33:03.130000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 797a0f6d-ebf5-4986-bfca-c6ee0cb84f5b\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:12:35+00:00\n      Last modified: 2025-05-15 19:31:54.130000+00:00\n      Name: Think about new feature for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:12:39+00:00\n        Last modified: 2025-05-15 19:31:54.130000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 19:01:54.130000+00:00\n        Rest started: 2025-05-15 19:26:54.130000+00:00\n        Completed: 2025-05-15 19:31:54.130000+00:00\n      Intervals: ['From 2025-05-15 19:01:54.130000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-15 19:01:54.130000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 9b109e03-dd12-42fa-b431-e0bac1fcf9b8\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:13:07+00:00\n      Last modified: 2025-05-16 00:30:25.130000+00:00\n      Name: Plan architecture for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 18:12:07.090000+00:00\n        Last modified: 2025-05-15 18:23:50.100000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 18:12:07.100000+00:00\n        Rest started: None\n        Completed: 2025-05-15 18:23:50.100000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 18:25:35.100000+00:00\n        Last modified: 2025-05-15 18:32:54.110000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 18:25:35.110000+00:00\n        Rest started: None\n        Completed: 2025-05-15 18:32:54.110000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 18:33:49.110000+00:00\n        Last modified: 2025-05-15 18:49:12.120000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 18:33:49.120000+00:00\n        Rest started: None\n        Completed: 2025-05-15 18:49:12.120000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 18:49:58.120000+00:00\n        Last modified: 2025-05-15 19:00:39.130000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 18:49:58.130000+00:00\n        Rest started: None\n        Completed: 2025-05-15 19:00:39.130000+00:00\n      Intervals: ['From 2025-05-15 18:12:07.100000+00:00 to 2025-05-15 18:23:50.100000+00:00 [0 / 0]', 'From 2025-05-15 18:25:35.110000+00:00 to 2025-05-15 18:32:54.110000+00:00 [0 / 0]', 'From 2025-05-15 18:33:49.120000+00:00 to 2025-05-15 18:49:12.120000+00:00 [0 / 0]', 'From 2025-05-15 18:49:58.130000+00:00 to 2025-05-15 19:00:39.130000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-15 18:12:07.100000+00:00\n      Work ended: 2025-05-16 00:30:25.130000+00:00\n    - Class: Workitem\n      UID: 077c106a-6c53-4d99-80b2-54c2b5f24208\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:13:37+00:00\n      Last modified: 2025-05-16 00:28:56.130000+00:00\n      Name: Fix scheme for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:28:33.050000+00:00\n        Last modified: 2025-05-15 17:35:35.060000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:28:33.060000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:35:35.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:37:12.060000+00:00\n        Last modified: 2025-05-15 17:48:10.070000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:37:12.070000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:48:10.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:48:28.070000+00:00\n        Last modified: 2025-05-15 17:58:01.080000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:48:28.080000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:58:01.080000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:58:47.080000+00:00\n        Last modified: 2025-05-15 18:11:05.090000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:58:47.090000+00:00\n        Rest started: None\n        Completed: 2025-05-15 18:11:05.090000+00:00\n      Intervals: ['From 2025-05-15 17:28:33.060000+00:00 to 2025-05-15 17:35:35.060000+00:00 [0 / 0]', 'From 2025-05-15 17:37:12.070000+00:00 to 2025-05-15 17:48:10.070000+00:00 [0 / 0]', 'From 2025-05-15 17:48:28.080000+00:00 to 2025-05-15 17:58:01.080000+00:00 [0 / 0]', 'From 2025-05-15 17:58:47.090000+00:00 to 2025-05-15 18:11:05.090000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-15 17:28:33.060000+00:00\n      Work ended: 2025-05-16 00:28:56.130000+00:00\n    - Class: Workitem\n      UID: c5bcb337-9185-4e9e-8936-0961ccbb7662\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:14:06+00:00\n      Last modified: 2025-05-16 00:31:34.130000+00:00\n      Name: Generate function for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 16:27:18+00:00\n        Last modified: 2025-05-15 16:40:51.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 16:27:18.010000+00:00\n        Rest started: None\n        Completed: 2025-05-15 16:40:51.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 16:42:19.010000+00:00\n        Last modified: 2025-05-15 16:52:34.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 16:42:19.020000+00:00\n        Rest started: None\n        Completed: 2025-05-15 16:52:34.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 16:54:02.020000+00:00\n        Last modified: 2025-05-15 17:05:35.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 16:54:02.030000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:05:35.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:06:41.030000+00:00\n        Last modified: 2025-05-15 17:18:05.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:06:41.040000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:18:05.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 17:18:49.040000+00:00\n        Last modified: 2025-05-15 17:27:33.050000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-15 17:18:49.050000+00:00\n        Rest started: None\n        Completed: 2025-05-15 17:27:33.050000+00:00\n      Intervals: ['From 2025-05-15 16:27:18.010000+00:00 to 2025-05-15 16:40:51.010000+00:00 [0 / 0]', 'From 2025-05-15 16:42:19.020000+00:00 to 2025-05-15 16:52:34.020000+00:00 [0 / 0]', 'From 2025-05-15 16:54:02.030000+00:00 to 2025-05-15 17:05:35.030000+00:00 [0 / 0]', 'From 2025-05-15 17:06:41.040000+00:00 to 2025-05-15 17:18:05.040000+00:00 [0 / 0]', 'From 2025-05-15 17:18:49.050000+00:00 to 2025-05-15 17:27:33.050000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-15 16:27:18.010000+00:00\n      Work ended: 2025-05-16 00:31:34.130000+00:00\n    - Class: Workitem\n      UID: 852dc914-b8b1-4395-8584-a8f66e3fbcda\n      Owner: user@local.host\n      Parent UID: e796bdf4-129d-4b8f-8aa0-572a922fe0a3\n      Create date: 2025-05-15 15:14:39+00:00\n      Last modified: 2025-05-16 00:27:55.130000+00:00\n      Name: Plan documentation for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:14:47+00:00\n        Last modified: 2025-05-15 15:45:14+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 15:15:14+00:00\n        Rest started: 2025-05-15 15:40:14+00:00\n        Completed: 2025-05-15 15:45:14+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:14:52+00:00\n        Last modified: 2025-05-15 16:26:20+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-15 15:55:28+00:00\n          Last modified: 2025-05-15 15:55:28+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-15 15:56:20+00:00\n        Rest started: 2025-05-15 16:21:20+00:00\n        Completed: 2025-05-15 16:26:20+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-15 15:45:21+00:00\n        Last modified: 2025-05-15 15:45:21+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-15 15:15:14+00:00 to None [1500.0 / 300.0]', 'From 2025-05-15 15:46:49+00:00 to 2025-05-15 15:55:28+00:00 [1500.0 / 300.0]', 'From 2025-05-15 15:56:20+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-15 15:15:14+00:00\n      Work ended: 2025-05-16 00:27:55.130000+00:00\n  - Class: Backlog\n    UID: 093030f6-8c59-4e12-b672-a5f3f12d4905\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-16 14:05:28+00:00\n    Last modified: 2025-05-16 21:15:02.140000+00:00\n    Name: 2025-05-16, Friday\n    Children:\n    - Class: Workitem\n      UID: e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\n      Owner: user@local.host\n      Parent UID: 093030f6-8c59-4e12-b672-a5f3f12d4905\n      Create date: 2025-05-16 14:06:31+00:00\n      Last modified: 2025-05-16 21:13:02.140000+00:00\n      Name: Check function for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 14:06:34+00:00\n        Last modified: 2025-05-16 19:36:06.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-16 19:24:41.080000+00:00\n          Last modified: 2025-05-16 19:24:41.080000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-16 19:06:06.080000+00:00\n        Rest started: 2025-05-16 19:31:06.080000+00:00\n        Completed: 2025-05-16 19:36:06.080000+00:00\n      Intervals: ['From 2025-05-16 19:06:06.080000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-16 19:06:06.080000+00:00\n      Work ended: 2025-05-16 21:13:02.140000+00:00\n    - Class: Workitem\n      UID: 2f2a6481-8ab3-43c5-ad98-e6ffccd68738\n      Owner: user@local.host\n      Parent UID: 093030f6-8c59-4e12-b672-a5f3f12d4905\n      Create date: 2025-05-16 14:07:15+00:00\n      Last modified: 2025-05-16 21:13:53.140000+00:00\n      Name: Explain automation for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:27:12.040000+00:00\n        Last modified: 2025-05-16 18:35:12.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:27:12.050000+00:00\n        Rest started: None\n        Completed: 2025-05-16 18:35:12.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:37:11.050000+00:00\n        Last modified: 2025-05-16 18:47:31.060000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:37:11.060000+00:00\n        Rest started: None\n        Completed: 2025-05-16 18:47:31.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:49:07.060000+00:00\n        Last modified: 2025-05-16 18:55:50.070000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:49:07.070000+00:00\n        Rest started: None\n        Completed: 2025-05-16 18:55:50.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:57:01.070000+00:00\n        Last modified: 2025-05-16 19:05:03.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:57:01.080000+00:00\n        Rest started: None\n        Completed: 2025-05-16 19:05:03.080000+00:00\n      Intervals: ['From 2025-05-16 18:27:12.050000+00:00 to 2025-05-16 18:35:12.050000+00:00 [0 / 0]', 'From 2025-05-16 18:37:11.060000+00:00 to 2025-05-16 18:47:31.060000+00:00 [0 / 0]', 'From 2025-05-16 18:49:07.070000+00:00 to 2025-05-16 18:55:50.070000+00:00 [0 / 0]', 'From 2025-05-16 18:57:01.080000+00:00 to 2025-05-16 19:05:03.080000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-16 18:27:12.050000+00:00\n      Work ended: 2025-05-16 21:13:53.140000+00:00\n    - Class: Workitem\n      UID: 3c05819c-fe73-44cc-a2db-5c98da0feda1\n      Owner: user@local.host\n      Parent UID: 093030f6-8c59-4e12-b672-a5f3f12d4905\n      Create date: 2025-05-16 14:07:53+00:00\n      Last modified: 2025-05-16 21:11:53.140000+00:00\n      Name: Request script for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 17:32:50+00:00\n        Last modified: 2025-05-16 17:46:13.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 17:32:50.010000+00:00\n        Rest started: None\n        Completed: 2025-05-16 17:46:13.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 17:47:07.010000+00:00\n        Last modified: 2025-05-16 17:59:20.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 17:47:07.020000+00:00\n        Rest started: None\n        Completed: 2025-05-16 17:59:20.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:00:09.020000+00:00\n        Last modified: 2025-05-16 18:12:29.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:00:09.030000+00:00\n        Rest started: None\n        Completed: 2025-05-16 18:12:29.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 18:13:38.030000+00:00\n        Last modified: 2025-05-16 18:26:06.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-16 18:13:38.040000+00:00\n        Rest started: None\n        Completed: 2025-05-16 18:26:06.040000+00:00\n      Intervals: ['From 2025-05-16 17:32:50.010000+00:00 to 2025-05-16 17:46:13.010000+00:00 [0 / 0]', 'From 2025-05-16 17:47:07.020000+00:00 to 2025-05-16 17:59:20.020000+00:00 [0 / 0]', 'From 2025-05-16 18:00:09.030000+00:00 to 2025-05-16 18:12:29.030000+00:00 [0 / 0]', 'From 2025-05-16 18:13:38.040000+00:00 to 2025-05-16 18:26:06.040000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-16 17:32:50.010000+00:00\n      Work ended: 2025-05-16 21:11:53.140000+00:00\n    - Class: Workitem\n      UID: ced870aa-d13b-48db-86ea-223331745b00\n      Owner: user@local.host\n      Parent UID: 093030f6-8c59-4e12-b672-a5f3f12d4905\n      Create date: 2025-05-16 14:08:26+00:00\n      Last modified: 2025-05-16 21:15:02.140000+00:00\n      Name: Draw website for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 14:08:31+00:00\n        Last modified: 2025-05-16 14:39:30+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-16 14:09:30+00:00\n        Rest started: 2025-05-16 14:34:30+00:00\n        Completed: 2025-05-16 14:39:30+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 14:08:37+00:00\n        Last modified: 2025-05-16 15:10:39+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-16 14:40:39+00:00\n        Rest started: 2025-05-16 15:05:39+00:00\n        Completed: 2025-05-16 15:10:39+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 14:39:35+00:00\n        Last modified: 2025-05-16 15:41:51+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-16 15:11:51+00:00\n        Rest started: 2025-05-16 15:36:51+00:00\n        Completed: 2025-05-16 15:41:51+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 15:10:47+00:00\n        Last modified: 2025-05-16 17:14:13+00:00\n        Name: Pomodoro 4\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-16 16:05:49+00:00\n          Last modified: 2025-05-16 16:05:49+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-16 16:07:19+00:00\n        Rest started: 2025-05-16 16:32:19+00:00\n        Completed: 2025-05-16 17:14:13+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 15:41:55+00:00\n        Last modified: 2025-05-16 17:31:35+00:00\n        Name: Pomodoro 5\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-16 17:31:35+00:00\n          Last modified: 2025-05-16 17:31:35+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-16 17:15:42+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-16 17:14:19+00:00\n        Last modified: 2025-05-16 17:14:19+00:00\n        Name: Pomodoro 6\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-16 14:09:30+00:00 to None [1500.0 / 300.0]', 'From 2025-05-16 14:40:39+00:00 to None [1500.0 / 300.0]', 'From 2025-05-16 15:11:51+00:00 to None [1500.0 / 300.0]', 'From 2025-05-16 15:43:18+00:00 to 2025-05-16 16:05:49+00:00 [1500.0 / 0.0]', 'From 2025-05-16 16:07:19+00:00 to 2025-05-16 17:14:13+00:00 [1500.0 / 0.0]', 'From 2025-05-16 17:15:42+00:00 to 2025-05-16 17:31:35+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-16 14:09:30+00:00\n      Work ended: 2025-05-16 21:15:02.140000+00:00\n  - Class: Backlog\n    UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-19 14:54:45+00:00\n    Last modified: 2025-05-20 00:29:14.050000+00:00\n    Name: Template for #Gamma\n    Children:\n    - Class: Workitem\n      UID: 98d16ca2-926b-4e7a-a756-2e8e6c859c0b\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:55:18+00:00\n      Last modified: 2025-05-20 00:27:12.050000+00:00\n      Name: Find function for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:55:24+00:00\n        Last modified: 2025-05-20 00:23:22.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-19 23:43:23.050000+00:00\n          Last modified: 2025-05-19 23:43:23.050000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-19 23:44:44.050000+00:00\n        Rest started: 2025-05-20 00:09:44.050000+00:00\n        Completed: 2025-05-20 00:23:22.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:55:27+00:00\n        Last modified: 2025-05-19 14:55:27+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-19 23:25:52.050000+00:00 to 2025-05-19 23:43:23.050000+00:00 [1500.0 / 0.0]', 'From 2025-05-19 23:44:44.050000+00:00 to 2025-05-20 00:23:22.050000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-19 23:25:52.050000+00:00\n      Work ended: 2025-05-20 00:27:12.050000+00:00\n    - Class: Workitem\n      UID: 8a62a3e9-f297-4049-b74a-7be505e77f87\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:55:53+00:00\n      Last modified: 2025-05-20 00:24:47.050000+00:00\n      Name: Generate scheme for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:56:00+00:00\n        Last modified: 2025-05-19 22:52:16.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 22:22:16.050000+00:00\n        Rest started: 2025-05-19 22:47:16.050000+00:00\n        Completed: 2025-05-19 22:52:16.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:56:06+00:00\n        Last modified: 2025-05-19 23:24:01.050000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 22:54:01.050000+00:00\n        Rest started: 2025-05-19 23:19:01.050000+00:00\n        Completed: 2025-05-19 23:24:01.050000+00:00\n      Intervals: ['From 2025-05-19 22:22:16.050000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-19 22:54:01.050000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-19 22:22:16.050000+00:00\n      Work ended: 2025-05-20 00:24:47.050000+00:00\n    - Class: Workitem\n      UID: e05ea36a-7013-44b4-959c-24df01e50b06\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:56:46+00:00\n      Last modified: 2025-05-19 22:02:38.050000+00:00\n      Name: Fix email for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:56:52+00:00\n        Last modified: 2025-05-19 21:31:57.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-19 20:53:42.050000+00:00\n        Rest started: 2025-05-19 21:18:42.050000+00:00\n        Completed: 2025-05-19 21:31:57.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:56:58+00:00\n        Last modified: 2025-05-19 22:02:38.050000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-19 21:51:12.050000+00:00\n          Last modified: 2025-05-19 21:51:12.050000+00:00\n          Reason: <None>\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 21:32:38.050000+00:00\n        Rest started: 2025-05-19 21:57:38.050000+00:00\n        Completed: 2025-05-19 22:02:38.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 21:32:05.050000+00:00\n        Last modified: 2025-05-19 21:32:05.050000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-19 20:53:42.050000+00:00 to 2025-05-19 21:31:57.050000+00:00 [1500.0 / 0.0]', 'From 2025-05-19 21:32:38.050000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-19 20:53:42.050000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 0bf9c9da-a350-4f97-8b75-f80c62e72d56\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:57:33+00:00\n      Last modified: 2025-05-20 00:28:05.050000+00:00\n      Name: Explore code for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 20:31:37.030000+00:00\n        Last modified: 2025-05-19 20:41:12.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-19 20:31:37.040000+00:00\n        Rest started: None\n        Completed: 2025-05-19 20:41:12.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 20:42:07.040000+00:00\n        Last modified: 2025-05-19 20:51:58.050000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-19 20:42:07.050000+00:00\n        Rest started: None\n        Completed: 2025-05-19 20:51:58.050000+00:00\n      Intervals: ['From 2025-05-19 20:31:37.040000+00:00 to 2025-05-19 20:41:12.040000+00:00 [0 / 0]', 'From 2025-05-19 20:42:07.050000+00:00 to 2025-05-19 20:51:58.050000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-19 20:31:37.040000+00:00\n      Work ended: 2025-05-20 00:28:05.050000+00:00\n    - Class: Workitem\n      UID: 8483a10b-26e7-43a7-8b0a-a641442b680d\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:58:06+00:00\n      Last modified: 2025-05-20 00:29:14.050000+00:00\n      Name: Plan website for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:58:09+00:00\n        Last modified: 2025-05-19 19:44:47.030000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 19:14:47.030000+00:00\n        Rest started: 2025-05-19 19:39:47.030000+00:00\n        Completed: 2025-05-19 19:44:47.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:58:13+00:00\n        Last modified: 2025-05-19 20:16:01.030000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-19 20:00:39.030000+00:00\n          Last modified: 2025-05-19 20:00:39.030000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: 0:07:19\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 19:46:01.030000+00:00\n        Rest started: 2025-05-19 20:11:01.030000+00:00\n        Completed: 2025-05-19 20:16:01.030000+00:00\n      Intervals: ['From 2025-05-19 19:14:47.030000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-19 19:46:01.030000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-19 19:14:47.030000+00:00\n      Work ended: 2025-05-20 00:29:14.050000+00:00\n    - Class: Workitem\n      UID: 798ef11b-b7e0-4b42-96e9-9f56c312f747\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:58:40+00:00\n      Last modified: 2025-05-19 19:13:37.030000+00:00\n      Name: Think about bug for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 18:38:27+00:00\n        Last modified: 2025-05-19 18:50:09.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-19 18:38:27.010000+00:00\n        Rest started: None\n        Completed: 2025-05-19 18:50:09.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 18:50:53.010000+00:00\n        Last modified: 2025-05-19 19:01:42.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-19 18:50:53.020000+00:00\n        Rest started: None\n        Completed: 2025-05-19 19:01:42.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 19:02:53.020000+00:00\n        Last modified: 2025-05-19 19:13:37.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-19 19:02:53.030000+00:00\n        Rest started: None\n        Completed: 2025-05-19 19:13:37.030000+00:00\n      Intervals: ['From 2025-05-19 18:38:27.010000+00:00 to 2025-05-19 18:50:09.010000+00:00 [0 / 0]', 'From 2025-05-19 18:50:53.020000+00:00 to 2025-05-19 19:01:42.020000+00:00 [0 / 0]', 'From 2025-05-19 19:02:53.030000+00:00 to 2025-05-19 19:13:37.030000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-19 18:38:27.010000+00:00\n      Work ended: 2025-05-19 19:13:37.030000+00:00\n    - Class: Workitem\n      UID: bd3aa90b-1b11-4e7a-94ac-1281856ad351\n      Owner: user@local.host\n      Parent UID: 42d95228-a140-4b38-b0d9-7c1d90268a93\n      Create date: 2025-05-19 14:59:01+00:00\n      Last modified: 2025-05-20 00:26:02.050000+00:00\n      Name: Plan new feature for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:59:05+00:00\n        Last modified: 2025-05-19 15:30:07+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 15:00:07+00:00\n        Rest started: 2025-05-19 15:25:07+00:00\n        Completed: 2025-05-19 15:30:07+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:59:09+00:00\n        Last modified: 2025-05-19 16:01:17+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 15:31:17+00:00\n        Rest started: 2025-05-19 15:56:17+00:00\n        Completed: 2025-05-19 16:01:17+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 14:59:15+00:00\n        Last modified: 2025-05-19 16:32:22+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 16:02:22+00:00\n        Rest started: 2025-05-19 16:27:22+00:00\n        Completed: 2025-05-19 16:32:22+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 15:30:13+00:00\n        Last modified: 2025-05-19 18:06:19+00:00\n        Name: Pomodoro 4\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-19 17:05:44+00:00\n          Last modified: 2025-05-19 17:05:44+00:00\n          Reason: <None>\n          Void: False\n          Duration: 0:16:00.500000\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-19 16:33:43+00:00\n        Rest started: 2025-05-19 16:58:43+00:00\n        Completed: 2025-05-19 18:06:19+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-19 16:32:27+00:00\n        Last modified: 2025-05-19 18:37:07+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-19 18:07:07+00:00\n        Rest started: 2025-05-19 18:32:07+00:00\n        Completed: 2025-05-19 18:37:07+00:00\n      Intervals: ['From 2025-05-19 15:00:07+00:00 to None [1500.0 / 300.0]', 'From 2025-05-19 15:31:17+00:00 to None [1500.0 / 300.0]', 'From 2025-05-19 16:02:22+00:00 to None [1500.0 / 300.0]', 'From 2025-05-19 16:33:43+00:00 to 2025-05-19 18:06:19+00:00 [1500.0 / 0.0]', 'From 2025-05-19 18:07:07+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-19 15:00:07+00:00\n      Work ended: 2025-05-20 00:26:02.050000+00:00\n  - Class: Backlog\n    UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-21 18:18:25+00:00\n    Last modified: 2025-05-21 23:37:13.020000+00:00\n    Name: 2025-05-21, Wednesday\n    Children:\n    - Class: Workitem\n      UID: c459bfb2-bfd9-44be-ada8-5b8765bdabbf\n      Owner: user@local.host\n      Parent UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n      Create date: 2025-05-21 18:19:02+00:00\n      Last modified: 2025-05-21 23:33:27.020000+00:00\n      Name: Fix design for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:19:09+00:00\n        Last modified: 2025-05-21 22:04:27.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 21:34:27.020000+00:00\n        Rest started: 2025-05-21 21:59:27.020000+00:00\n        Completed: 2025-05-21 22:04:27.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:19:17+00:00\n        Last modified: 2025-05-21 22:35:39.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 22:05:39.020000+00:00\n        Rest started: 2025-05-21 22:30:39.020000+00:00\n        Completed: 2025-05-21 22:35:39.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 22:04:34.020000+00:00\n        Last modified: 2025-05-21 23:32:42.020000+00:00\n        Name: Pomodoro 3\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-21 22:50:11.020000+00:00\n          Last modified: 2025-05-21 22:50:11.020000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-21 22:51:12.020000+00:00\n        Rest started: 2025-05-21 23:16:12.020000+00:00\n        Completed: 2025-05-21 23:32:42.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 22:35:44.020000+00:00\n        Last modified: 2025-05-21 22:35:44.020000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-21 21:34:27.020000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-21 22:05:39.020000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-21 22:36:33.020000+00:00 to 2025-05-21 22:50:11.020000+00:00 [1500.0 / 0.0]', 'From 2025-05-21 22:51:12.020000+00:00 to 2025-05-21 23:32:42.020000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-21 21:34:27.020000+00:00\n      Work ended: 2025-05-21 23:33:27.020000+00:00\n    - Class: Workitem\n      UID: 6962eacc-5c88-40da-809b-69003418cf03\n      Owner: user@local.host\n      Parent UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n      Create date: 2025-05-21 18:19:45+00:00\n      Last modified: 2025-05-21 21:32:59.020000+00:00\n      Name: Request email for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:19:50+00:00\n        Last modified: 2025-05-21 21:01:53.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-21 20:33:46.020000+00:00\n        Rest started: 2025-05-21 20:58:46.020000+00:00\n        Completed: 2025-05-21 21:01:53.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:19:56+00:00\n        Last modified: 2025-05-21 21:32:59.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 21:02:59.020000+00:00\n        Rest started: 2025-05-21 21:27:59.020000+00:00\n        Completed: 2025-05-21 21:32:59.020000+00:00\n      Intervals: ['From 2025-05-21 20:33:46.020000+00:00 to 2025-05-21 21:01:53.020000+00:00 [1500.0 / 0.0]', 'From 2025-05-21 21:02:59.020000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-21 20:33:46.020000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\n      Owner: user@local.host\n      Parent UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n      Create date: 2025-05-21 18:20:24+00:00\n      Last modified: 2025-05-21 23:34:43.020000+00:00\n      Name: Find script for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:20:29+00:00\n        Last modified: 2025-05-21 20:01:18.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 19:31:18.020000+00:00\n        Rest started: 2025-05-21 19:56:18.020000+00:00\n        Completed: 2025-05-21 20:01:18.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:20:33+00:00\n        Last modified: 2025-05-21 20:32:17.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 20:02:17.020000+00:00\n        Rest started: 2025-05-21 20:27:17.020000+00:00\n        Completed: 2025-05-21 20:32:17.020000+00:00\n      Intervals: ['From 2025-05-21 19:31:18.020000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-21 20:02:17.020000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-21 19:31:18.020000+00:00\n      Work ended: 2025-05-21 23:34:43.020000+00:00\n    - Class: Workitem\n      UID: 6bd534eb-5f7d-465b-b154-e719e5812cb6\n      Owner: user@local.host\n      Parent UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n      Create date: 2025-05-21 18:21:00+00:00\n      Last modified: 2025-05-21 23:37:13.020000+00:00\n      Name: Verify bug for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:21:08+00:00\n        Last modified: 2025-05-21 19:30:12.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-21 18:59:04.020000+00:00\n          Last modified: 2025-05-21 18:59:04.020000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-21 19:00:12.020000+00:00\n        Rest started: 2025-05-21 19:25:12.020000+00:00\n        Completed: 2025-05-21 19:30:12.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:21:13+00:00\n        Last modified: 2025-05-21 18:21:13+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-21 18:39:13.020000+00:00 to 2025-05-21 18:59:04.020000+00:00 [1500.0 / 300.0]', 'From 2025-05-21 19:00:12.020000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-21 18:39:13.020000+00:00\n      Work ended: 2025-05-21 23:37:13.020000+00:00\n    - Class: Workitem\n      UID: a70c47e0-3368-4348-a658-3f4c0b5f6a9f\n      Owner: user@local.host\n      Parent UID: ce93ebfd-69b2-48b0-9501-09a78d903ef8\n      Create date: 2025-05-21 18:21:42+00:00\n      Last modified: 2025-05-21 23:36:08.020000+00:00\n      Name: Explain documentation for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:22:41+00:00\n        Last modified: 2025-05-21 18:28:49.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-21 18:22:41.010000+00:00\n        Rest started: None\n        Completed: 2025-05-21 18:28:49.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-21 18:29:37.010000+00:00\n        Last modified: 2025-05-21 18:38:25.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-21 18:29:37.020000+00:00\n        Rest started: None\n        Completed: 2025-05-21 18:38:25.020000+00:00\n      Intervals: ['From 2025-05-21 18:22:41.010000+00:00 to 2025-05-21 18:28:49.010000+00:00 [0 / 0]', 'From 2025-05-21 18:29:37.020000+00:00 to 2025-05-21 18:38:25.020000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-21 18:22:41.010000+00:00\n      Work ended: 2025-05-21 23:36:08.020000+00:00\n  - Class: Backlog\n    UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-22 14:53:29+00:00\n    Last modified: 2025-05-22 19:28:17.030000+00:00\n    Name: 2025-05-22, Thursday\n    Children:\n    - Class: Workitem\n      UID: af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:53:48+00:00\n      Last modified: 2025-05-22 19:27:17.030000+00:00\n      Name: Request architecture for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:53:56+00:00\n        Last modified: 2025-05-22 18:19:41.030000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-22 17:20:26.030000+00:00\n        Rest started: 2025-05-22 17:45:26.030000+00:00\n        Completed: 2025-05-22 18:19:41.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 18:19:46.030000+00:00\n        Last modified: 2025-05-22 18:50:32.030000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-22 18:20:32.030000+00:00\n        Rest started: 2025-05-22 18:45:32.030000+00:00\n        Completed: 2025-05-22 18:50:32.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 18:50:38.030000+00:00\n        Last modified: 2025-05-22 19:21:35.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-22 18:51:35.030000+00:00\n        Rest started: 2025-05-22 19:16:35.030000+00:00\n        Completed: 2025-05-22 19:21:35.030000+00:00\n      Intervals: ['From 2025-05-22 17:20:26.030000+00:00 to 2025-05-22 18:19:41.030000+00:00 [1500.0 / 0.0]', 'From 2025-05-22 18:20:32.030000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-22 18:51:35.030000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-22 17:20:26.030000+00:00\n      Work ended: 2025-05-22 19:27:17.030000+00:00\n    - Class: Workitem\n      UID: a05461de-8c4f-47e1-ac8d-b529ca98554d\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:54:17+00:00\n      Last modified: 2025-05-22 19:28:17.030000+00:00\n      Name: Generate website for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:54:23+00:00\n        Last modified: 2025-05-22 14:54:23+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: []\n      State: finished\n      Work started: None\n      Work ended: 2025-05-22 19:28:17.030000+00:00\n    - Class: Workitem\n      UID: 08083657-8bea-461b-ac89-b3f07f2bca52\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:55:15+00:00\n      Last modified: 2025-05-22 19:23:28.030000+00:00\n      Name: Deprecate architecture for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:55:23+00:00\n        Last modified: 2025-05-22 17:05:51.030000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-22 16:47:58.030000+00:00\n          Last modified: 2025-05-22 16:47:58.030000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-22 16:35:51.030000+00:00\n        Rest started: 2025-05-22 17:00:51.030000+00:00\n        Completed: 2025-05-22 17:05:51.030000+00:00\n      Intervals: ['From 2025-05-22 16:35:51.030000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-22 16:35:51.030000+00:00\n      Work ended: 2025-05-22 19:23:28.030000+00:00\n    - Class: Workitem\n      UID: 5e435829-de78-443a-90a2-03f8990a0b06\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:55:57+00:00\n      Last modified: 2025-05-22 19:26:10.030000+00:00\n      Name: Request design for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 16:01:55+00:00\n        Last modified: 2025-05-22 16:12:23.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-22 16:01:55.010000+00:00\n        Rest started: None\n        Completed: 2025-05-22 16:12:23.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 16:13:49.010000+00:00\n        Last modified: 2025-05-22 16:22:17.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-22 16:13:49.020000+00:00\n        Rest started: None\n        Completed: 2025-05-22 16:22:17.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 16:23:24.020000+00:00\n        Last modified: 2025-05-22 16:34:46.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-22 16:23:24.030000+00:00\n        Rest started: None\n        Completed: 2025-05-22 16:34:46.030000+00:00\n      Intervals: ['From 2025-05-22 16:01:55.010000+00:00 to 2025-05-22 16:12:23.010000+00:00 [0 / 0]', 'From 2025-05-22 16:13:49.020000+00:00 to 2025-05-22 16:22:17.020000+00:00 [0 / 0]', 'From 2025-05-22 16:23:24.030000+00:00 to 2025-05-22 16:34:46.030000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-22 16:01:55.010000+00:00\n      Work ended: 2025-05-22 19:26:10.030000+00:00\n    - Class: Workitem\n      UID: 602ecec1-c6e4-4f56-8460-8ac4d7208c1a\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:56:23+00:00\n      Last modified: 2025-05-22 19:24:52.030000+00:00\n      Name: Find script for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:56:29+00:00\n        Last modified: 2025-05-22 16:00:29+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-22 15:30:29+00:00\n        Rest started: 2025-05-22 15:55:29+00:00\n        Completed: 2025-05-22 16:00:29+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:56:34+00:00\n        Last modified: 2025-05-22 14:56:34+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-22 15:30:29+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-22 15:30:29+00:00\n      Work ended: 2025-05-22 19:24:52.030000+00:00\n    - Class: Workitem\n      UID: b6019c40-a044-46bc-a8fb-73c23f43ffd2\n      Owner: user@local.host\n      Parent UID: 23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\n      Create date: 2025-05-22 14:56:57+00:00\n      Last modified: 2025-05-22 19:22:29.030000+00:00\n      Name: Check screenshot for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:57:02+00:00\n        Last modified: 2025-05-22 15:28:01+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-22 14:58:01+00:00\n        Rest started: 2025-05-22 15:23:01+00:00\n        Completed: 2025-05-22 15:28:01+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-22 14:57:08+00:00\n        Last modified: 2025-05-22 14:57:08+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-22 14:58:01+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-22 14:58:01+00:00\n      Work ended: 2025-05-22 19:22:29.030000+00:00\n  - Class: Backlog\n    UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-23 14:10:36+00:00\n    Last modified: 2025-05-23 21:30:27.210000+00:00\n    Name: 2025-05-23, Friday\n    Children:\n    - Class: Workitem\n      UID: 795afa16-549d-4d50-bc9e-634cc5ff7b4e\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:11:03+00:00\n      Last modified: 2025-05-23 21:29:27.210000+00:00\n      Name: Explain tool for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 20:45:34.160000+00:00\n        Last modified: 2025-05-23 20:54:28.170000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 20:45:34.170000+00:00\n        Rest started: None\n        Completed: 2025-05-23 20:54:28.170000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 20:55:30.170000+00:00\n        Last modified: 2025-05-23 21:03:52.180000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 20:55:30.180000+00:00\n        Rest started: None\n        Completed: 2025-05-23 21:03:52.180000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 21:04:59.180000+00:00\n        Last modified: 2025-05-23 21:14:10.190000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 21:04:59.190000+00:00\n        Rest started: None\n        Completed: 2025-05-23 21:14:10.190000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 21:15:07.190000+00:00\n        Last modified: 2025-05-23 21:21:21.200000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 21:15:07.200000+00:00\n        Rest started: None\n        Completed: 2025-05-23 21:21:21.200000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 21:22:09.200000+00:00\n        Last modified: 2025-05-23 21:27:28.210000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 21:22:09.210000+00:00\n        Rest started: None\n        Completed: 2025-05-23 21:27:28.210000+00:00\n      Intervals: ['From 2025-05-23 20:45:34.170000+00:00 to 2025-05-23 20:54:28.170000+00:00 [0 / 0]', 'From 2025-05-23 20:55:30.180000+00:00 to 2025-05-23 21:03:52.180000+00:00 [0 / 0]', 'From 2025-05-23 21:04:59.190000+00:00 to 2025-05-23 21:14:10.190000+00:00 [0 / 0]', 'From 2025-05-23 21:15:07.200000+00:00 to 2025-05-23 21:21:21.200000+00:00 [0 / 0]', 'From 2025-05-23 21:22:09.210000+00:00 to 2025-05-23 21:27:28.210000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-23 20:45:34.170000+00:00\n      Work ended: 2025-05-23 21:29:27.210000+00:00\n    - Class: Workitem\n      UID: 92065277-d0e2-41f8-ae84-947145b3ed31\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:11:44+00:00\n      Last modified: 2025-05-23 20:43:56.160000+00:00\n      Name: Fix email for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 19:44:59.110000+00:00\n        Last modified: 2025-05-23 19:54:31.120000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 19:44:59.120000+00:00\n        Rest started: None\n        Completed: 2025-05-23 19:54:31.120000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 19:55:38.120000+00:00\n        Last modified: 2025-05-23 20:08:27.130000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 19:55:38.130000+00:00\n        Rest started: None\n        Completed: 2025-05-23 20:08:27.130000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 20:09:31.130000+00:00\n        Last modified: 2025-05-23 20:21:58.140000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 20:09:31.140000+00:00\n        Rest started: None\n        Completed: 2025-05-23 20:21:58.140000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 20:23:26.140000+00:00\n        Last modified: 2025-05-23 20:33:27.150000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 20:23:26.150000+00:00\n        Rest started: None\n        Completed: 2025-05-23 20:33:27.150000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 20:34:46.150000+00:00\n        Last modified: 2025-05-23 20:43:56.160000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 20:34:46.160000+00:00\n        Rest started: None\n        Completed: 2025-05-23 20:43:56.160000+00:00\n      Intervals: ['From 2025-05-23 19:44:59.120000+00:00 to 2025-05-23 19:54:31.120000+00:00 [0 / 0]', 'From 2025-05-23 19:55:38.130000+00:00 to 2025-05-23 20:08:27.130000+00:00 [0 / 0]', 'From 2025-05-23 20:09:31.140000+00:00 to 2025-05-23 20:21:58.140000+00:00 [0 / 0]', 'From 2025-05-23 20:23:26.150000+00:00 to 2025-05-23 20:33:27.150000+00:00 [0 / 0]', 'From 2025-05-23 20:34:46.160000+00:00 to 2025-05-23 20:43:56.160000+00:00 [0 / 0]']\n      State: running\n      Work started: 2025-05-23 19:44:59.120000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 51864f25-d8a2-4170-a717-e5f46a2cf40a\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:12:26+00:00\n      Last modified: 2025-05-23 19:43:30.110000+00:00\n      Name: Draw design for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 19:17:48.080000+00:00\n        Last modified: 2025-05-23 19:22:39.090000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 19:17:48.090000+00:00\n        Rest started: None\n        Completed: 2025-05-23 19:22:39.090000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 19:23:35.090000+00:00\n        Last modified: 2025-05-23 19:31:39.100000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 19:23:35.100000+00:00\n        Rest started: None\n        Completed: 2025-05-23 19:31:39.100000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 19:32:47.100000+00:00\n        Last modified: 2025-05-23 19:43:30.110000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-23 19:32:47.110000+00:00\n        Rest started: None\n        Completed: 2025-05-23 19:43:30.110000+00:00\n      Intervals: ['From 2025-05-23 19:17:48.090000+00:00 to 2025-05-23 19:22:39.090000+00:00 [0 / 0]', 'From 2025-05-23 19:23:35.100000+00:00 to 2025-05-23 19:31:39.100000+00:00 [0 / 0]', 'From 2025-05-23 19:32:47.110000+00:00 to 2025-05-23 19:43:30.110000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-23 19:17:48.090000+00:00\n      Work ended: 2025-05-23 19:43:30.110000+00:00\n    - Class: Workitem\n      UID: 410ace38-77ff-4f0d-a826-b1020f4010b9\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:13:02+00:00\n      Last modified: 2025-05-23 19:17:07.080000+00:00\n      Name: Fix automation for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 14:13:10+00:00\n        Last modified: 2025-05-23 19:17:07.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-23 18:47:07.080000+00:00\n        Rest started: 2025-05-23 19:12:07.080000+00:00\n        Completed: 2025-05-23 19:17:07.080000+00:00\n      Intervals: ['From 2025-05-23 18:47:07.080000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-23 18:47:07.080000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: ef97a600-593c-464c-8999-a39a42c55b45\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:13:43+00:00\n      Last modified: 2025-05-23 21:28:24.210000+00:00\n      Name: Send automation for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 14:13:49+00:00\n        Last modified: 2025-05-23 18:43:59.080000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-23 18:43:59.080000+00:00\n          Last modified: 2025-05-23 18:43:59.080000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-23 18:27:22.080000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-23 18:27:22.080000+00:00 to 2025-05-23 18:43:59.080000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-23 18:27:22.080000+00:00\n      Work ended: 2025-05-23 21:28:24.210000+00:00\n    - Class: Workitem\n      UID: fbcb1480-4acc-426d-b7cf-1cd34202e793\n      Owner: user@local.host\n      Parent UID: 72afcb2d-bdc1-41c4-be77-e9694f966fb7\n      Create date: 2025-05-23 14:15:05+00:00\n      Last modified: 2025-05-23 21:30:27.210000+00:00\n      Name: Plan website for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 14:15:12+00:00\n        Last modified: 2025-05-23 14:46:32+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-23 14:16:32+00:00\n        Rest started: 2025-05-23 14:41:32+00:00\n        Completed: 2025-05-23 14:46:32+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 14:15:18+00:00\n        Last modified: 2025-05-23 15:17:47+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-23 14:47:47+00:00\n        Rest started: 2025-05-23 15:12:47+00:00\n        Completed: 2025-05-23 15:17:47+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 14:15:28+00:00\n        Last modified: 2025-05-23 15:49:24+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-23 15:19:24+00:00\n        Rest started: 2025-05-23 15:44:24+00:00\n        Completed: 2025-05-23 15:49:24+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-23 15:49:29+00:00\n        Last modified: 2025-05-23 16:50:43+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-23 15:50:52+00:00\n        Rest started: 2025-05-23 16:15:52+00:00\n        Completed: 2025-05-23 16:50:43+00:00\n      Intervals: ['From 2025-05-23 14:16:32+00:00 to None [1500.0 / 300.0]', 'From 2025-05-23 14:47:47+00:00 to None [1500.0 / 300.0]', 'From 2025-05-23 15:19:24+00:00 to None [1500.0 / 300.0]', 'From 2025-05-23 15:50:52+00:00 to 2025-05-23 16:50:43+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-23 14:16:32+00:00\n      Work ended: 2025-05-23 21:30:27.210000+00:00\n  - Class: Backlog\n    UID: c15fa70b-1040-4961-8b36-69218f730029\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-26 11:48:39+00:00\n    Last modified: 2025-05-26 18:04:31+00:00\n    Name: 2025-05-26, Monday\n    Children:\n    - Class: Workitem\n      UID: 19feb1bd-fae8-49ce-b50c-de10f5dd6c54\n      Owner: user@local.host\n      Parent UID: c15fa70b-1040-4961-8b36-69218f730029\n      Create date: 2025-05-26 11:49:21+00:00\n      Last modified: 2025-05-26 18:03:42+00:00\n      Name: Find idea for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:49:28+00:00\n        Last modified: 2025-05-26 16:27:16+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 15:57:16+00:00\n        Rest started: 2025-05-26 16:22:16+00:00\n        Completed: 2025-05-26 16:27:16+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:49:35+00:00\n        Last modified: 2025-05-26 16:58:15+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 16:28:15+00:00\n        Rest started: 2025-05-26 16:53:15+00:00\n        Completed: 2025-05-26 16:58:15+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 16:58:20+00:00\n        Last modified: 2025-05-26 18:01:00+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-26 16:59:27+00:00\n        Rest started: 2025-05-26 17:24:27+00:00\n        Completed: 2025-05-26 18:01:00+00:00\n      Intervals: ['From 2025-05-26 15:57:16+00:00 to None [1500.0 / 300.0]', 'From 2025-05-26 16:28:15+00:00 to None [1500.0 / 300.0]', 'From 2025-05-26 16:59:27+00:00 to 2025-05-26 18:01:00+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-26 15:57:16+00:00\n      Work ended: 2025-05-26 18:03:42+00:00\n    - Class: Workitem\n      UID: 609d3d76-1a58-439a-b4a9-42ac2e4683a0\n      Owner: user@local.host\n      Parent UID: c15fa70b-1040-4961-8b36-69218f730029\n      Create date: 2025-05-26 11:50:10+00:00\n      Last modified: 2025-05-26 15:55:34+00:00\n      Name: Verify website for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:50:17+00:00\n        Last modified: 2025-05-26 15:55:17+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-26 15:24:10+00:00\n          Last modified: 2025-05-26 15:24:10+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 15:25:17+00:00\n        Rest started: 2025-05-26 15:50:17+00:00\n        Completed: 2025-05-26 15:55:17+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:50:23+00:00\n        Last modified: 2025-05-26 11:50:23+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-26 15:06:25+00:00 to 2025-05-26 15:24:10+00:00 [1500.0 / 300.0]', 'From 2025-05-26 15:25:17+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-26 15:06:25+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 6a24dc5e-311a-45e8-8746-f7aa43e4822b\n      Owner: user@local.host\n      Parent UID: c15fa70b-1040-4961-8b36-69218f730029\n      Create date: 2025-05-26 11:50:57+00:00\n      Last modified: 2025-05-26 18:02:36+00:00\n      Name: Draw code for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:51:01+00:00\n        Last modified: 2025-05-26 14:24:32+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 13:54:32+00:00\n        Rest started: 2025-05-26 14:19:32+00:00\n        Completed: 2025-05-26 14:24:32+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:51:05+00:00\n        Last modified: 2025-05-26 15:05:28+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-26 14:24:52+00:00\n        Rest started: 2025-05-26 14:49:52+00:00\n        Completed: 2025-05-26 15:05:28+00:00\n      Intervals: ['From 2025-05-26 13:54:32+00:00 to None [1500.0 / 300.0]', 'From 2025-05-26 14:24:52+00:00 to 2025-05-26 15:05:28+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-26 13:54:32+00:00\n      Work ended: 2025-05-26 18:02:36+00:00\n    - Class: Workitem\n      UID: 952a107c-409b-4b87-9b56-b7152689eae6\n      Owner: user@local.host\n      Parent UID: c15fa70b-1040-4961-8b36-69218f730029\n      Create date: 2025-05-26 11:51:51+00:00\n      Last modified: 2025-05-26 18:01:25+00:00\n      Name: Explain automation for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:51:54+00:00\n        Last modified: 2025-05-26 13:23:46+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-26 13:05:51+00:00\n          Last modified: 2025-05-26 13:05:51+00:00\n          Reason: <None>\n          Void: False\n          Duration: 0:06:02.500000\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 12:53:46+00:00\n        Rest started: 2025-05-26 13:18:46+00:00\n        Completed: 2025-05-26 13:23:46+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:52:01+00:00\n        Last modified: 2025-05-26 13:53:43+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-26 13:53:43+00:00\n          Last modified: 2025-05-26 13:53:43+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 13:36:11+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-26 12:53:46+00:00 to None [1500.0 / 300.0]', 'From 2025-05-26 13:36:11+00:00 to 2025-05-26 13:53:43+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-26 12:53:46+00:00\n      Work ended: 2025-05-26 18:01:25+00:00\n    - Class: Workitem\n      UID: e42d4736-6450-411e-8a58-7b8b0f8898c6\n      Owner: user@local.host\n      Parent UID: c15fa70b-1040-4961-8b36-69218f730029\n      Create date: 2025-05-26 11:52:26+00:00\n      Last modified: 2025-05-26 18:04:31+00:00\n      Name: Create bug for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:52:29+00:00\n        Last modified: 2025-05-26 12:39:12+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-26 12:08:32+00:00\n          Last modified: 2025-05-26 12:08:32+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-26 12:22:45+00:00\n          Last modified: 2025-05-26 12:22:45+00:00\n          Reason: <None>\n          Void: False\n          Duration: 0:06:46.500000\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-26 12:09:12+00:00\n        Rest started: 2025-05-26 12:34:12+00:00\n        Completed: 2025-05-26 12:39:12+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-26 11:52:32+00:00\n        Last modified: 2025-05-26 11:52:32+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-26 11:54:00+00:00 to 2025-05-26 12:08:32+00:00 [1500.0 / 300.0]', 'From 2025-05-26 12:09:12+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-26 11:54:00+00:00\n      Work ended: 2025-05-26 18:04:31+00:00\n  - Class: Backlog\n    UID: a9b28c10-81c4-42b4-9734-7d544b7ccba5\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-27 13:42:28+00:00\n    Last modified: 2025-05-27 16:12:12+00:00\n    Name: 2025-05-27, Tuesday\n    Children:\n    - Class: Workitem\n      UID: 23cc4726-7afd-4754-8e63-2996738929bf\n      Owner: user@local.host\n      Parent UID: a9b28c10-81c4-42b4-9734-7d544b7ccba5\n      Create date: 2025-05-27 13:43:10+00:00\n      Last modified: 2025-05-27 16:08:43+00:00\n      Name: Document new feature for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:43:15+00:00\n        Last modified: 2025-05-27 16:07:41+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-27 16:07:41+00:00\n          Last modified: 2025-05-27 16:07:41+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-27 15:52:08+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:43:21+00:00\n        Last modified: 2025-05-27 13:43:21+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:43:27+00:00\n        Last modified: 2025-05-27 13:43:27+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-27 15:52:08+00:00 to 2025-05-27 16:07:41+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-27 15:52:08+00:00\n      Work ended: 2025-05-27 16:08:43+00:00\n    - Class: Workitem\n      UID: 7e4068f5-6315-4e91-ac67-76a689d45397\n      Owner: user@local.host\n      Parent UID: a9b28c10-81c4-42b4-9734-7d544b7ccba5\n      Create date: 2025-05-27 13:44:06+00:00\n      Last modified: 2025-05-27 16:10:47+00:00\n      Name: Draw bug for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:44:14+00:00\n        Last modified: 2025-05-27 15:51:34+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-27 15:21:34+00:00\n        Rest started: 2025-05-27 15:46:34+00:00\n        Completed: 2025-05-27 15:51:34+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:44:19+00:00\n        Last modified: 2025-05-27 13:44:19+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-27 15:21:34+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-27 15:21:34+00:00\n      Work ended: 2025-05-27 16:10:47+00:00\n    - Class: Workitem\n      UID: 8a374fea-1788-47a4-ad12-58178e38b3cb\n      Owner: user@local.host\n      Parent UID: a9b28c10-81c4-42b4-9734-7d544b7ccba5\n      Create date: 2025-05-27 13:44:51+00:00\n      Last modified: 2025-05-27 16:09:53+00:00\n      Name: Draw automation for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:44:56+00:00\n        Last modified: 2025-05-27 15:19:44+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-27 14:49:44+00:00\n        Rest started: 2025-05-27 15:14:44+00:00\n        Completed: 2025-05-27 15:19:44+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:45:02+00:00\n        Last modified: 2025-05-27 13:45:02+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-27 14:49:44+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-27 14:49:44+00:00\n      Work ended: 2025-05-27 16:09:53+00:00\n    - Class: Workitem\n      UID: 75e9667c-480c-4cf3-93da-b09cca095d50\n      Owner: user@local.host\n      Parent UID: a9b28c10-81c4-42b4-9734-7d544b7ccba5\n      Create date: 2025-05-27 13:45:42+00:00\n      Last modified: 2025-05-27 16:12:12+00:00\n      Name: Think about tool for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:45:48+00:00\n        Last modified: 2025-05-27 14:32:02+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-27 14:00:50+00:00\n          Last modified: 2025-05-27 14:00:50+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-27 14:02:02+00:00\n        Rest started: 2025-05-27 14:27:02+00:00\n        Completed: 2025-05-27 14:32:02+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:45:55+00:00\n        Last modified: 2025-05-27 14:48:57+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-27 14:48:57+00:00\n          Last modified: 2025-05-27 14:48:57+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-27 14:32:56+00:00\n        Rest started: None\n        Completed: None\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-27 13:46:04+00:00\n        Last modified: 2025-05-27 13:46:04+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-27 13:47:20+00:00 to 2025-05-27 14:00:50+00:00 [1500.0 / 300.0]', 'From 2025-05-27 14:02:02+00:00 to None [1500.0 / 300.0]', 'From 2025-05-27 14:32:56+00:00 to 2025-05-27 14:48:57+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-27 13:47:20+00:00\n      Work ended: 2025-05-27 16:12:12+00:00\n  - Class: Backlog\n    UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-28 13:42:28+00:00\n    Last modified: 2025-05-28 21:16:58.110000+00:00\n    Name: 2025-05-28, Wednesday\n    Children:\n    - Class: Workitem\n      UID: 60ad180f-fd02-41eb-8117-af4e7e688f14\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:42:56+00:00\n      Last modified: 2025-05-28 20:56:56.110000+00:00\n      Name: Find website for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:43:04+00:00\n        Last modified: 2025-05-28 20:56:56.110000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-28 20:42:38.110000+00:00\n          Last modified: 2025-05-28 20:42:38.110000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: 0:07:51\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 20:26:56.110000+00:00\n        Rest started: 2025-05-28 20:51:56.110000+00:00\n        Completed: 2025-05-28 20:56:56.110000+00:00\n      Intervals: ['From 2025-05-28 20:26:56.110000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-28 20:26:56.110000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: 9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:43:28+00:00\n      Last modified: 2025-05-28 21:13:46.110000+00:00\n      Name: Explore new feature for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:43:33+00:00\n        Last modified: 2025-05-28 20:06:52.110000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 19:36:52.110000+00:00\n        Rest started: 2025-05-28 20:01:52.110000+00:00\n        Completed: 2025-05-28 20:06:52.110000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:43:38+00:00\n        Last modified: 2025-05-28 20:25:34.110000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-28 20:25:34.110000+00:00\n          Last modified: 2025-05-28 20:25:34.110000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 20:08:18.110000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-28 19:36:52.110000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-28 20:08:18.110000+00:00 to 2025-05-28 20:25:34.110000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-05-28 19:36:52.110000+00:00\n      Work ended: 2025-05-28 21:13:46.110000+00:00\n    - Class: Workitem\n      UID: 4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:44:10+00:00\n      Last modified: 2025-05-28 21:16:58.110000+00:00\n      Name: Request automation for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:44:14+00:00\n        Last modified: 2025-05-28 16:19:55.110000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-28 16:01:42.110000+00:00\n          Last modified: 2025-05-28 16:01:42.110000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: 0:05:53.500000\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 15:49:55.110000+00:00\n        Rest started: 2025-05-28 16:14:55.110000+00:00\n        Completed: 2025-05-28 16:19:55.110000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:44:18+00:00\n        Last modified: 2025-05-28 17:02:38.110000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 16:32:38.110000+00:00\n        Rest started: 2025-05-28 16:57:38.110000+00:00\n        Completed: 2025-05-28 17:02:38.110000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 17:02:44.110000+00:00\n        Last modified: 2025-05-28 17:34:37.110000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-28 17:04:37.110000+00:00\n        Rest started: 2025-05-28 17:29:37.110000+00:00\n        Completed: 2025-05-28 17:34:37.110000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 17:34:43.110000+00:00\n        Last modified: 2025-05-28 19:35:47.110000+00:00\n        Name: Pomodoro 4\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-28 18:18:20.110000+00:00\n          Last modified: 2025-05-28 18:18:20.110000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: 0:21:05\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-28 17:36:10.110000+00:00\n        Rest started: 2025-05-28 18:01:10.110000+00:00\n        Completed: 2025-05-28 19:35:47.110000+00:00\n      Intervals: ['From 2025-05-28 15:49:55.110000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-28 16:32:38.110000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-28 17:04:37.110000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-28 17:36:10.110000+00:00 to 2025-05-28 19:35:47.110000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-28 15:49:55.110000+00:00\n      Work ended: 2025-05-28 21:16:58.110000+00:00\n    - Class: Workitem\n      UID: 4b2ae63a-91af-4a75-8def-3964502be86d\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:44:39+00:00\n      Last modified: 2025-05-28 21:15:58.110000+00:00\n      Name: Document new feature for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 15:18:42.080000+00:00\n        Last modified: 2025-05-28 15:26:45.090000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 15:18:42.090000+00:00\n        Rest started: None\n        Completed: 2025-05-28 15:26:45.090000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 15:27:27.090000+00:00\n        Last modified: 2025-05-28 15:35:40.100000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 15:27:27.100000+00:00\n        Rest started: None\n        Completed: 2025-05-28 15:35:40.100000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 15:37:02.100000+00:00\n        Last modified: 2025-05-28 15:48:56.110000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 15:37:02.110000+00:00\n        Rest started: None\n        Completed: 2025-05-28 15:48:56.110000+00:00\n      Intervals: ['From 2025-05-28 15:18:42.090000+00:00 to 2025-05-28 15:26:45.090000+00:00 [0 / 0]', 'From 2025-05-28 15:27:27.100000+00:00 to 2025-05-28 15:35:40.100000+00:00 [0 / 0]', 'From 2025-05-28 15:37:02.110000+00:00 to 2025-05-28 15:48:56.110000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-28 15:18:42.090000+00:00\n      Work ended: 2025-05-28 21:15:58.110000+00:00\n    - Class: Workitem\n      UID: 3fe527ec-d751-45a1-b870-c6d65b34c793\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:45:08+00:00\n      Last modified: 2025-05-28 15:17:09.080000+00:00\n      Name: Check tool for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 14:33:50.040000+00:00\n        Last modified: 2025-05-28 14:42:07.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 14:33:50.050000+00:00\n        Rest started: None\n        Completed: 2025-05-28 14:42:07.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 14:43:26.050000+00:00\n        Last modified: 2025-05-28 14:55:31.060000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 14:43:26.060000+00:00\n        Rest started: None\n        Completed: 2025-05-28 14:55:31.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 14:56:10.060000+00:00\n        Last modified: 2025-05-28 15:06:58.070000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 14:56:10.070000+00:00\n        Rest started: None\n        Completed: 2025-05-28 15:06:58.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 15:08:38.070000+00:00\n        Last modified: 2025-05-28 15:17:09.080000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 15:08:38.080000+00:00\n        Rest started: None\n        Completed: 2025-05-28 15:17:09.080000+00:00\n      Intervals: ['From 2025-05-28 14:33:50.050000+00:00 to 2025-05-28 14:42:07.050000+00:00 [0 / 0]', 'From 2025-05-28 14:43:26.060000+00:00 to 2025-05-28 14:55:31.060000+00:00 [0 / 0]', 'From 2025-05-28 14:56:10.070000+00:00 to 2025-05-28 15:06:58.070000+00:00 [0 / 0]', 'From 2025-05-28 15:08:38.080000+00:00 to 2025-05-28 15:17:09.080000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-28 14:33:50.050000+00:00\n      Work ended: 2025-05-28 15:17:09.080000+00:00\n    - Class: Workitem\n      UID: fab4497a-599f-407e-910a-b5c544d3d1dc\n      Owner: user@local.host\n      Parent UID: 4feceee6-b7ce-45f8-a0ad-8ba17002751f\n      Create date: 2025-05-28 13:45:46+00:00\n      Last modified: 2025-05-28 21:15:11.110000+00:00\n      Name: Find email for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:47:06+00:00\n        Last modified: 2025-05-28 13:55:19.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 13:47:06.010000+00:00\n        Rest started: None\n        Completed: 2025-05-28 13:55:19.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 13:57:03.010000+00:00\n        Last modified: 2025-05-28 14:06:41.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 13:57:03.020000+00:00\n        Rest started: None\n        Completed: 2025-05-28 14:06:41.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 14:07:47.020000+00:00\n        Last modified: 2025-05-28 14:21:46.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 14:07:47.030000+00:00\n        Rest started: None\n        Completed: 2025-05-28 14:21:46.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-28 14:22:39.030000+00:00\n        Last modified: 2025-05-28 14:33:00.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-05-28 14:22:39.040000+00:00\n        Rest started: None\n        Completed: 2025-05-28 14:33:00.040000+00:00\n      Intervals: ['From 2025-05-28 13:47:06.010000+00:00 to 2025-05-28 13:55:19.010000+00:00 [0 / 0]', 'From 2025-05-28 13:57:03.020000+00:00 to 2025-05-28 14:06:41.020000+00:00 [0 / 0]', 'From 2025-05-28 14:07:47.030000+00:00 to 2025-05-28 14:21:46.030000+00:00 [0 / 0]', 'From 2025-05-28 14:22:39.040000+00:00 to 2025-05-28 14:33:00.040000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-05-28 13:47:06.010000+00:00\n      Work ended: 2025-05-28 21:15:11.110000+00:00\n  - Class: Backlog\n    UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-05-29 13:05:39+00:00\n    Last modified: 2025-05-29 23:16:16.020000+00:00\n    Name: 2025-05-29, Thursday\n    Children:\n    - Class: Workitem\n      UID: 2a1c3ec5-6c08-4801-8cba-964df98ff2fd\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:06:01+00:00\n      Last modified: 2025-05-29 23:15:35.020000+00:00\n      Name: Think about tool for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:06:09+00:00\n        Last modified: 2025-05-29 13:06:09+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: []\n      State: finished\n      Work started: None\n      Work ended: 2025-05-29 23:15:35.020000+00:00\n    - Class: Workitem\n      UID: bf199fc8-0b8d-48e7-9cea-7d94bee7d773\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:06:45+00:00\n      Last modified: 2025-05-29 23:13:41.020000+00:00\n      Name: Generate documentation for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:06:52+00:00\n        Last modified: 2025-05-29 21:14:36.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-29 20:54:34.020000+00:00\n          Last modified: 2025-05-29 20:54:34.020000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: 0:04:59\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 20:44:36.020000+00:00\n        Rest started: 2025-05-29 21:09:36.020000+00:00\n        Completed: 2025-05-29 21:14:36.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:07:00+00:00\n        Last modified: 2025-05-29 22:42:02.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-29 21:25:19.020000+00:00\n        Rest started: 2025-05-29 21:50:19.020000+00:00\n        Completed: 2025-05-29 22:42:02.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 22:42:10.020000+00:00\n        Last modified: 2025-05-29 23:13:22.020000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 22:43:22.020000+00:00\n        Rest started: 2025-05-29 23:08:22.020000+00:00\n        Completed: 2025-05-29 23:13:22.020000+00:00\n      Intervals: ['From 2025-05-29 20:44:36.020000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 21:25:19.020000+00:00 to 2025-05-29 22:42:02.020000+00:00 [1500.0 / 0.0]', 'From 2025-05-29 22:43:22.020000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-29 20:44:36.020000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: aa133fd6-57d6-4805-b6cd-35f89475b5e9\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:07:39+00:00\n      Last modified: 2025-05-29 20:26:32.020000+00:00\n      Name: Draw tool for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:07:44+00:00\n        Last modified: 2025-05-29 19:55:40.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 19:25:40.020000+00:00\n        Rest started: 2025-05-29 19:50:40.020000+00:00\n        Completed: 2025-05-29 19:55:40.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 19:55:47.020000+00:00\n        Last modified: 2025-05-29 20:26:32.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-29 20:13:51.020000+00:00\n          Last modified: 2025-05-29 20:13:51.020000+00:00\n          Reason: An interruption\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 19:56:32.020000+00:00\n        Rest started: 2025-05-29 20:21:32.020000+00:00\n        Completed: 2025-05-29 20:26:32.020000+00:00\n      Intervals: ['From 2025-05-29 19:25:40.020000+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 19:56:32.020000+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-29 19:25:40.020000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: ab997673-6f52-4f52-9d27-22a565e2d09d\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:08:07+00:00\n      Last modified: 2025-05-29 23:14:31.020000+00:00\n      Name: Draw email for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:08:11+00:00\n        Last modified: 2025-05-29 19:24:30.020000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-29 18:04:28.020000+00:00\n        Rest started: 2025-05-29 18:29:28.020000+00:00\n        Completed: 2025-05-29 19:24:30.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 19:24:36.020000+00:00\n        Last modified: 2025-05-29 19:24:36.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-29 18:04:28.020000+00:00 to 2025-05-29 19:24:30.020000+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-29 18:04:28.020000+00:00\n      Work ended: 2025-05-29 23:14:31.020000+00:00\n    - Class: Workitem\n      UID: 7501a585-d3b2-47ed-9aef-bcbc489f7492\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:09:29+00:00\n      Last modified: 2025-05-29 23:16:16.020000+00:00\n      Name: Find screenshot for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:09:36+00:00\n        Last modified: 2025-05-29 17:45:08+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-29 17:45:08+00:00\n          Last modified: 2025-05-29 17:45:08+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-29 17:26:57+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-29 17:26:57+00:00 to 2025-05-29 17:45:08+00:00 [1500.0 / 0.0]']\n      State: finished\n      Work started: 2025-05-29 17:26:57+00:00\n      Work ended: 2025-05-29 23:16:16.020000+00:00\n    - Class: Workitem\n      UID: a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:10:05+00:00\n      Last modified: 2025-05-29 17:25:38+00:00\n      Name: Plan architecture for #Delta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:10:13+00:00\n        Last modified: 2025-05-29 15:52:08+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-05-29 15:06:26+00:00\n        Rest started: 2025-05-29 15:31:26+00:00\n        Completed: 2025-05-29 15:52:08+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:10:18+00:00\n        Last modified: 2025-05-29 16:23:00+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 15:53:00+00:00\n        Rest started: 2025-05-29 16:18:00+00:00\n        Completed: 2025-05-29 16:23:00+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:10:26+00:00\n        Last modified: 2025-05-29 16:54:19+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 16:24:19+00:00\n        Rest started: 2025-05-29 16:49:19+00:00\n        Completed: 2025-05-29 16:54:19+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 15:52:12+00:00\n        Last modified: 2025-05-29 17:25:38+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 16:55:38+00:00\n        Rest started: 2025-05-29 17:20:38+00:00\n        Completed: 2025-05-29 17:25:38+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 16:23:05+00:00\n        Last modified: 2025-05-29 16:23:05+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-05-29 15:06:26+00:00 to 2025-05-29 15:52:08+00:00 [1500.0 / 0.0]', 'From 2025-05-29 15:53:00+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 16:24:19+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 16:55:38+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-29 15:06:26+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: fd280450-0aee-45f2-8c54-1f54725a10f7\n      Owner: user@local.host\n      Parent UID: 7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\n      Create date: 2025-05-29 13:11:05+00:00\n      Last modified: 2025-05-29 15:04:44+00:00\n      Name: Check tool for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:11:10+00:00\n        Last modified: 2025-05-29 13:43:23+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-05-29 13:32:07+00:00\n          Last modified: 2025-05-29 13:32:07+00:00\n          Reason: <None>\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 13:13:23+00:00\n        Rest started: 2025-05-29 13:38:23+00:00\n        Completed: 2025-05-29 13:43:23+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:11:17+00:00\n        Last modified: 2025-05-29 14:33:25+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 14:03:25+00:00\n        Rest started: 2025-05-29 14:28:25+00:00\n        Completed: 2025-05-29 14:33:25+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-05-29 13:11:24+00:00\n        Last modified: 2025-05-29 15:04:44+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-05-29 14:34:44+00:00\n        Rest started: 2025-05-29 14:59:44+00:00\n        Completed: 2025-05-29 15:04:44+00:00\n      Intervals: ['From 2025-05-29 13:13:23+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 14:03:25+00:00 to None [1500.0 / 300.0]', 'From 2025-05-29 14:34:44+00:00 to None [1500.0 / 300.0]']\n      State: running\n      Work started: 2025-05-29 13:13:23+00:00\n      Work ended: None\n  - Class: Backlog\n    UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n    Owner: user@local.host\n    Parent UID: user@local.host\n    Create date: 2025-06-02 14:42:34+00:00\n    Last modified: 2025-06-02 23:19:32.070000+00:00\n    Name: 2025-06-02, Monday\n    Children:\n    - Class: Workitem\n      UID: 3b3449c6-3050-44da-a8fb-de7043c164c2\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:42:59+00:00\n      Last modified: 2025-06-02 23:19:32.070000+00:00\n      Name: Check function for #Alpha\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:43:05+00:00\n        Last modified: 2025-06-02 23:13:04.070000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-06-02 23:13:04.070000+00:00\n          Last modified: 2025-06-02 23:13:04.070000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 23:02:17.070000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-06-02 23:02:17.070000+00:00 to 2025-06-02 23:13:04.070000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-06-02 23:02:17.070000+00:00\n      Work ended: 2025-06-02 23:19:32.070000+00:00\n    - Class: Workitem\n      UID: 69573c4b-32d1-40d3-9888-6175500a5366\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:43:49+00:00\n      Last modified: 2025-06-02 23:15:44.070000+00:00\n      Name: Document new feature for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:43:55+00:00\n        Last modified: 2025-06-02 22:53:47.070000+00:00\n        Name: Pomodoro 1\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-06-02 22:31:24.070000+00:00\n          Last modified: 2025-06-02 22:31:24.070000+00:00\n          Reason: <None>\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 22:23:47.070000+00:00\n        Rest started: 2025-06-02 22:48:47.070000+00:00\n        Completed: 2025-06-02 22:53:47.070000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:44:02+00:00\n        Last modified: 2025-06-02 14:44:02+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-06-02 22:23:47.070000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-06-02 22:23:47.070000+00:00\n      Work ended: 2025-06-02 23:15:44.070000+00:00\n    - Class: Workitem\n      UID: b453fd1d-478f-4b51-89f9-e017eac484d4\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:44:38+00:00\n      Last modified: 2025-06-02 23:17:47.070000+00:00\n      Name: Generate email for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 22:02:16.040000+00:00\n        Last modified: 2025-06-02 22:09:31.050000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 22:02:16.050000+00:00\n        Rest started: None\n        Completed: 2025-06-02 22:09:31.050000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 22:10:16.050000+00:00\n        Last modified: 2025-06-02 22:15:51.060000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 22:10:16.060000+00:00\n        Rest started: None\n        Completed: 2025-06-02 22:15:51.060000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 22:17:05.060000+00:00\n        Last modified: 2025-06-02 22:23:09.070000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 22:17:05.070000+00:00\n        Rest started: None\n        Completed: 2025-06-02 22:23:09.070000+00:00\n      Intervals: ['From 2025-06-02 22:02:16.050000+00:00 to 2025-06-02 22:09:31.050000+00:00 [0 / 0]', 'From 2025-06-02 22:10:16.060000+00:00 to 2025-06-02 22:15:51.060000+00:00 [0 / 0]', 'From 2025-06-02 22:17:05.070000+00:00 to 2025-06-02 22:23:09.070000+00:00 [0 / 0]']\n      State: finished\n      Work started: 2025-06-02 22:02:16.050000+00:00\n      Work ended: 2025-06-02 23:17:47.070000+00:00\n    - Class: Workitem\n      UID: 7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:45:05+00:00\n      Last modified: 2025-06-02 23:14:43.070000+00:00\n      Name: Draw scheme for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:45:10+00:00\n        Last modified: 2025-06-02 19:38:22.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 19:08:22.040000+00:00\n        Rest started: 2025-06-02 19:33:22.040000+00:00\n        Completed: 2025-06-02 19:38:22.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:45:17+00:00\n        Last modified: 2025-06-02 20:09:14.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 19:39:14.040000+00:00\n        Rest started: 2025-06-02 20:04:14.040000+00:00\n        Completed: 2025-06-02 20:09:14.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:45:23+00:00\n        Last modified: 2025-06-02 21:10:30.040000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-06-02 20:10:14.040000+00:00\n        Rest started: 2025-06-02 20:35:14.040000+00:00\n        Completed: 2025-06-02 21:10:30.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:45:28+00:00\n        Last modified: 2025-06-02 22:00:54.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-06-02 21:29:40.040000+00:00\n          Last modified: 2025-06-02 21:29:40.040000+00:00\n          Reason: Pomodoro voided\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 21:30:54.040000+00:00\n        Rest started: 2025-06-02 21:55:54.040000+00:00\n        Completed: 2025-06-02 22:00:54.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 20:09:20.040000+00:00\n        Last modified: 2025-06-02 20:09:20.040000+00:00\n        Name: Pomodoro 5\n        Children:\n        - <Empty>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: None\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-06-02 19:08:22.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-06-02 19:39:14.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-06-02 20:10:14.040000+00:00 to 2025-06-02 21:10:30.040000+00:00 [1500.0 / 0.0]', 'From 2025-06-02 21:11:49.040000+00:00 to 2025-06-02 21:29:40.040000+00:00 [1500.0 / 300.0]', 'From 2025-06-02 21:30:54.040000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-06-02 19:08:22.040000+00:00\n      Work ended: 2025-06-02 23:14:43.070000+00:00\n    - Class: Workitem\n      UID: 4d52a015-557f-4ed6-8a19-260d0113cc33\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:46:11+00:00\n      Last modified: 2025-06-02 23:13:49.070000+00:00\n      Name: Document tool for #Gamma\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:46:20+00:00\n        Last modified: 2025-06-02 16:45:55.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 16:15:55.040000+00:00\n        Rest started: 2025-06-02 16:40:55.040000+00:00\n        Completed: 2025-06-02 16:45:55.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:46:23+00:00\n        Last modified: 2025-06-02 17:17:10.040000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 16:47:10.040000+00:00\n        Rest started: 2025-06-02 17:12:10.040000+00:00\n        Completed: 2025-06-02 17:17:10.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 16:46:00.040000+00:00\n        Last modified: 2025-06-02 18:15:35.040000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 0.0\n        Work started: 2025-06-02 17:18:34.040000+00:00\n        Rest started: 2025-06-02 17:43:34.040000+00:00\n        Completed: 2025-06-02 18:15:35.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 17:17:16.040000+00:00\n        Last modified: 2025-06-02 18:46:51.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 18:16:51.040000+00:00\n        Rest started: 2025-06-02 18:41:51.040000+00:00\n        Completed: 2025-06-02 18:46:51.040000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 18:15:41.040000+00:00\n        Last modified: 2025-06-02 19:07:19.040000+00:00\n        Name: Pomodoro 5\n        Children:\n        - Class: Interruption\n          UID: <MASKED>\n          Owner: user@local.host\n          Parent UID: <MASKED>\n          Create date: 2025-06-02 19:07:19.040000+00:00\n          Last modified: 2025-06-02 19:07:19.040000+00:00\n          Reason: Voided for a good reason\n          Void: False\n          Duration: <None>\n        Type: normal\n        State: new\n        Is planned: False\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 18:47:25.040000+00:00\n        Rest started: None\n        Completed: None\n      Intervals: ['From 2025-06-02 16:15:55.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-06-02 16:47:10.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-06-02 17:18:34.040000+00:00 to 2025-06-02 18:15:35.040000+00:00 [1500.0 / 0.0]', 'From 2025-06-02 18:16:51.040000+00:00 to None [1500.0 / 300.0]', 'From 2025-06-02 18:47:25.040000+00:00 to 2025-06-02 19:07:19.040000+00:00 [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-06-02 16:15:55.040000+00:00\n      Work ended: 2025-06-02 23:13:49.070000+00:00\n    - Class: Workitem\n      UID: 33e28c58-866f-4853-8859-8f0c709d507f\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:46:50+00:00\n      Last modified: 2025-06-02 23:16:49.070000+00:00\n      Name: Send scheme for #Omega\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:46:58+00:00\n        Last modified: 2025-06-02 16:14:27.040000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: normal\n        State: finished\n        Is planned: True\n        Work duration: 1500.0\n        Rest duration: 300.0\n        Work started: 2025-06-02 15:44:27.040000+00:00\n        Rest started: 2025-06-02 16:09:27.040000+00:00\n        Completed: 2025-06-02 16:14:27.040000+00:00\n      Intervals: ['From 2025-06-02 15:44:27.040000+00:00 to None [1500.0 / 300.0]']\n      State: finished\n      Work started: 2025-06-02 15:44:27.040000+00:00\n      Work ended: 2025-06-02 23:16:49.070000+00:00\n    - Class: Workitem\n      UID: d728166f-0a32-4475-9cdf-30fa316dc2fd\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:47:30+00:00\n      Last modified: 2025-06-02 15:43:28.040000+00:00\n      Name: Draw architecture for #Beta\n      Children:\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 14:48:52+00:00\n        Last modified: 2025-06-02 14:58:30.010000+00:00\n        Name: Pomodoro 1\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: True\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 14:48:52.010000+00:00\n        Rest started: None\n        Completed: 2025-06-02 14:58:30.010000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 15:00:21.010000+00:00\n        Last modified: 2025-06-02 15:12:30.020000+00:00\n        Name: Pomodoro 2\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 15:00:21.020000+00:00\n        Rest started: None\n        Completed: 2025-06-02 15:12:30.020000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 15:13:40.020000+00:00\n        Last modified: 2025-06-02 15:26:32.030000+00:00\n        Name: Pomodoro 3\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 15:13:40.030000+00:00\n        Rest started: None\n        Completed: 2025-06-02 15:26:32.030000+00:00\n      - Class: Pomodoro\n        UID: <MASKED>\n        Owner: user@local.host\n        Parent UID: <MASKED>\n        Create date: 2025-06-02 15:28:28.030000+00:00\n        Last modified: 2025-06-02 15:43:28.040000+00:00\n        Name: Pomodoro 4\n        Children:\n        - <Empty>\n        Type: tracker\n        State: finished\n        Is planned: False\n        Work duration: 0\n        Rest duration: 0\n        Work started: 2025-06-02 15:28:28.040000+00:00\n        Rest started: None\n        Completed: 2025-06-02 15:43:28.040000+00:00\n      Intervals: ['From 2025-06-02 14:48:52.010000+00:00 to 2025-06-02 14:58:30.010000+00:00 [0 / 0]', 'From 2025-06-02 15:00:21.020000+00:00 to 2025-06-02 15:12:30.020000+00:00 [0 / 0]', 'From 2025-06-02 15:13:40.030000+00:00 to 2025-06-02 15:26:32.030000+00:00 [0 / 0]', 'From 2025-06-02 15:28:28.040000+00:00 to 2025-06-02 15:43:28.040000+00:00 [0 / 0]']\n      State: running\n      Work started: 2025-06-02 14:48:52.010000+00:00\n      Work ended: None\n    - Class: Workitem\n      UID: ff0418c7-e0fb-4bcc-9070-cd8de8dfde85\n      Owner: user@local.host\n      Parent UID: 0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\n      Create date: 2025-06-02 14:48:00+00:00\n      Last modified: 2025-06-02 23:18:15.070000+00:00\n      Name: Plan new feature for #Gamma\n      Children:\n      - <Empty>\n      Intervals: []\n      State: finished\n      Work started: None\n      Work ended: 2025-06-02 23:18:15.070000+00:00\n  System user: False"
  },
  {
    "path": "src/fk/tests/fixtures/random.txt",
    "content": "1, 2025-05-05 14:38:00+00:00, admin@local.host: CreateUser(\"user@local.host\", \"user@local.host\")\n2, 2025-05-05 14:38:27+00:00, user@local.host: CreateBacklog(\"b59e1a2e-225d-4944-8684-43329f82e395\", \"2025-05-05, Monday\")\n3, 2025-05-05 14:38:47+00:00, user@local.host: CreateWorkitem(\"614642f8-837b-4729-b51c-e786c50f103b\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Check script for #Alpha\")\n4, 2025-05-05 14:39:17+00:00, user@local.host: CreateWorkitem(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Request architecture for #Gamma\")\n5, 2025-05-05 14:39:22+00:00, user@local.host: AddPomodoro(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"1\", \"normal\")\n6, 2025-05-05 14:39:26+00:00, user@local.host: AddPomodoro(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"1\", \"normal\")\n7, 2025-05-05 14:39:49+00:00, user@local.host: CreateWorkitem(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Check documentation for #Alpha\")\n8, 2025-05-05 14:39:55+00:00, user@local.host: AddPomodoro(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"1\", \"normal\")\n9, 2025-05-05 14:40:00+00:00, user@local.host: AddPomodoro(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"1\", \"normal\")\n10, 2025-05-05 14:40:40+00:00, user@local.host: CreateWorkitem(\"d6f21703-52fc-45cc-b635-df6b3a024b16\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Send automation for #Gamma\")\n11, 2025-05-05 14:41:03+00:00, user@local.host: CreateWorkitem(\"66f859dd-3162-4cf5-a21e-74452bb150b6\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Plan bug for #Omega\")\n12, 2025-05-05 14:41:09+00:00, user@local.host: AddPomodoro(\"66f859dd-3162-4cf5-a21e-74452bb150b6\", \"1\", \"normal\")\n13, 2025-05-05 14:41:58+00:00, user@local.host: CreateWorkitem(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Find new feature for #Delta\")\n14, 2025-05-05 14:42:29+00:00, user@local.host: CreateWorkitem(\"a55d7d23-16d3-42c7-b325-97ffb3cba535\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Draw documentation for #Alpha\")\n15, 2025-05-05 14:42:33+00:00, user@local.host: AddPomodoro(\"a55d7d23-16d3-42c7-b325-97ffb3cba535\", \"1\", \"normal\")\n16, 2025-05-05 14:42:40+00:00, user@local.host: AddPomodoro(\"a55d7d23-16d3-42c7-b325-97ffb3cba535\", \"1\", \"normal\")\n17, 2025-05-05 14:43:02+00:00, user@local.host: CreateWorkitem(\"771a44c3-24fa-4b3a-b77b-1c83dece9c10\", \"b59e1a2e-225d-4944-8684-43329f82e395\", \"Find script for #Beta\")\n18, 2025-05-05 14:43:09+00:00, user@local.host: AddPomodoro(\"771a44c3-24fa-4b3a-b77b-1c83dece9c10\", \"1\", \"normal\")\n19, 2025-05-05 14:43:13+00:00, user@local.host: AddPomodoro(\"771a44c3-24fa-4b3a-b77b-1c83dece9c10\", \"1\", \"normal\")\n20, 2025-05-05 14:44:29+00:00, user@local.host: StartTimer(\"771a44c3-24fa-4b3a-b77b-1c83dece9c10\", \"1500\", \"300\")\n21, 2025-05-05 15:15:10+00:00, user@local.host: StartTimer(\"a55d7d23-16d3-42c7-b325-97ffb3cba535\", \"1500\", \"300\")\n22, 2025-05-05 15:33:56+00:00, user@local.host: DeleteWorkitem(\"a55d7d23-16d3-42c7-b325-97ffb3cba535\", \"\")\n23, 2025-05-05 15:34:26+00:00, user@local.host: RenameWorkitem(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"Request screenshot for #Delta\")\n24, 2025-05-05 15:35:31+00:00, user@local.host: AddPomodoro(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"1\", \"tracker\")\n25, 2025-05-05 15:35:31.010000+00:00, user@local.host: StartTimer(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"\")\n26, 2025-05-05 15:45:20.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n27, 2025-05-05 15:46:19.010000+00:00, user@local.host: AddPomodoro(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"1\", \"tracker\")\n28, 2025-05-05 15:46:19.020000+00:00, user@local.host: StartTimer(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"\")\n29, 2025-05-05 15:56:48.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n30, 2025-05-05 15:57:48.020000+00:00, user@local.host: AddPomodoro(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"1\", \"tracker\")\n31, 2025-05-05 15:57:48.030000+00:00, user@local.host: StartTimer(\"977b7a9d-c678-4984-b67e-05dec7c8ac88\", \"\")\n32, 2025-05-05 16:03:27.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n33, 2025-05-05 16:04:01.030000+00:00, user@local.host: RenameWorkitem(\"66f859dd-3162-4cf5-a21e-74452bb150b6\", \"Deprecate script for #Omega\")\n34, 2025-05-05 16:05:32.030000+00:00, user@local.host: StartTimer(\"66f859dd-3162-4cf5-a21e-74452bb150b6\", \"1500\", \"300\")\n35, 2025-05-05 16:25:03.030000+00:00, user@local.host: AddInterruption(\"66f859dd-3162-4cf5-a21e-74452bb150b6\", \"Voided for a good reason\", \"\")\n36, 2025-05-05 16:25:03.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n37, 2025-05-05 16:26:04.030000+00:00, user@local.host: AddPomodoro(\"d6f21703-52fc-45cc-b635-df6b3a024b16\", \"1\", \"tracker\")\n38, 2025-05-05 16:26:04.040000+00:00, user@local.host: StartTimer(\"d6f21703-52fc-45cc-b635-df6b3a024b16\", \"\")\n39, 2025-05-05 16:38:10.040000+00:00, user@local.host: CompleteWorkitem(\"d6f21703-52fc-45cc-b635-df6b3a024b16\", \"finished\")\n40, 2025-05-05 16:38:40.040000+00:00, user@local.host: StartTimer(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"1500\", \"300\")\n41, 2025-05-05 16:52:51.040000+00:00, user@local.host: AddInterruption(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"An interruption\", \"\")\n42, 2025-05-05 17:23:59.040000+00:00, user@local.host: StartTimer(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"1500\", \"300\")\n43, 2025-05-05 17:34:27.040000+00:00, user@local.host: AddInterruption(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"Voided for a good reason\", \"\")\n44, 2025-05-05 17:34:27.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n45, 2025-05-05 17:35:39.040000+00:00, user@local.host: StartTimer(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"1500\", \"300\")\n46, 2025-05-05 17:45:56.040000+00:00, user@local.host: AddInterruption(\"1d5057fd-aad8-4bd7-85c3-1f3f7be6bb98\", \"Voided for a good reason\", \"\")\n47, 2025-05-05 17:45:56.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n48, 2025-05-05 17:46:53.040000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n49, 2025-05-05 17:46:53.050000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n50, 2025-05-05 17:55:28.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n51, 2025-05-05 17:56:24.050000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n52, 2025-05-05 17:56:24.060000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n53, 2025-05-05 18:05:33.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n54, 2025-05-05 18:06:02.060000+00:00, user@local.host: RenameBacklog(\"b59e1a2e-225d-4944-8684-43329f82e395\", \"Template for #Alpha\")\n55, 2025-05-05 18:07:10.060000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n56, 2025-05-05 18:07:10.070000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n57, 2025-05-05 18:10:58.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n58, 2025-05-05 18:11:39.070000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n59, 2025-05-05 18:11:39.080000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n60, 2025-05-05 18:15:13.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n61, 2025-05-05 18:16:03.080000+00:00, user@local.host: RenameWorkitem(\"614642f8-837b-4729-b51c-e786c50f103b\", \"Find website for #Omega\")\n62, 2025-05-05 18:17:09.080000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n63, 2025-05-05 18:17:09.090000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n64, 2025-05-05 18:25:33.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n65, 2025-05-05 18:26:29.090000+00:00, user@local.host: AddPomodoro(\"614642f8-837b-4729-b51c-e786c50f103b\", \"1\", \"tracker\")\n66, 2025-05-05 18:26:29.100000+00:00, user@local.host: StartTimer(\"614642f8-837b-4729-b51c-e786c50f103b\", \"\")\n67, 2025-05-05 18:35:15.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n68, 2025-05-05 18:36:11.100000+00:00, user@local.host: CompleteWorkitem(\"1fb907fd-59cc-406d-8b08-ca7788c83cca\", \"finished\")\n69, 2025-05-05 18:37:06.100000+00:00, user@local.host: CompleteWorkitem(\"771a44c3-24fa-4b3a-b77b-1c83dece9c10\", \"finished\")\n70, 2025-05-06 14:47:32+00:00, user@local.host: CreateBacklog(\"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"2025-05-06, Tuesday\")\n71, 2025-05-06 14:48:08+00:00, user@local.host: CreateWorkitem(\"5deb5cf3-0e89-41f2-8e09-b30795cc010f\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Plan code for #Beta\")\n72, 2025-05-06 14:48:13+00:00, user@local.host: AddPomodoro(\"5deb5cf3-0e89-41f2-8e09-b30795cc010f\", \"1\", \"normal\")\n73, 2025-05-06 14:48:18+00:00, user@local.host: AddPomodoro(\"5deb5cf3-0e89-41f2-8e09-b30795cc010f\", \"1\", \"normal\")\n74, 2025-05-06 14:48:22+00:00, user@local.host: AddPomodoro(\"5deb5cf3-0e89-41f2-8e09-b30795cc010f\", \"1\", \"normal\")\n75, 2025-05-06 14:48:59+00:00, user@local.host: CreateWorkitem(\"492ffcd6-3357-45c9-a4d5-350d6826e0b7\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Generate new feature for #Delta\")\n76, 2025-05-06 14:49:23+00:00, user@local.host: CreateWorkitem(\"28806be0-41f9-4570-8d78-81a052ba9fab\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Request screenshot for #Beta\")\n77, 2025-05-06 14:49:30+00:00, user@local.host: AddPomodoro(\"28806be0-41f9-4570-8d78-81a052ba9fab\", \"1\", \"normal\")\n78, 2025-05-06 14:49:34+00:00, user@local.host: AddPomodoro(\"28806be0-41f9-4570-8d78-81a052ba9fab\", \"1\", \"normal\")\n79, 2025-05-06 14:49:41+00:00, user@local.host: AddPomodoro(\"28806be0-41f9-4570-8d78-81a052ba9fab\", \"1\", \"normal\")\n80, 2025-05-06 14:50:05+00:00, user@local.host: CreateWorkitem(\"81752e9f-42e3-4b97-9397-f5658ba28b43\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Deprecate website for #Omega\")\n81, 2025-05-06 14:50:47+00:00, user@local.host: CreateWorkitem(\"5ab1c108-8cc2-405c-9337-baaa2428ed6c\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Think about code for #Beta\")\n82, 2025-05-06 14:50:54+00:00, user@local.host: AddPomodoro(\"5ab1c108-8cc2-405c-9337-baaa2428ed6c\", \"1\", \"normal\")\n83, 2025-05-06 14:50:58+00:00, user@local.host: AddPomodoro(\"5ab1c108-8cc2-405c-9337-baaa2428ed6c\", \"1\", \"normal\")\n84, 2025-05-06 14:51:16+00:00, user@local.host: CreateWorkitem(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"Find code for #Gamma\")\n85, 2025-05-06 14:51:22+00:00, user@local.host: AddPomodoro(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1\", \"normal\")\n86, 2025-05-06 14:51:27+00:00, user@local.host: AddPomodoro(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1\", \"normal\")\n87, 2025-05-06 14:52:13+00:00, user@local.host: StartTimer(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1500\", \"300\")\n88, 2025-05-06 15:22:15+00:00, user@local.host: AddPomodoro(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1\", \"normal\")\n89, 2025-05-06 15:23:13+00:00, user@local.host: StartTimer(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1500\", \"300\")\n90, 2025-05-06 15:53:18+00:00, user@local.host: AddPomodoro(\"548f46ec-949c-46c9-8477-2c67e3a943dd\", \"1\", \"normal\")\n91, 2025-05-06 15:53:46+00:00, user@local.host: DeleteBacklog(\"6660c04a-6b51-4578-b5b2-6f36449dc101\", \"\")\n92, 2025-05-07 13:52:29+00:00, user@local.host: CreateBacklog(\"f26055e5-49ec-4124-b023-efa8f41a7220\", \"2025-05-07, Wednesday\")\n93, 2025-05-07 13:52:53+00:00, user@local.host: CreateWorkitem(\"deefe1d0-52ef-4657-91a2-f9ed4c412491\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Find design for #Alpha\")\n94, 2025-05-07 13:53:24+00:00, user@local.host: CreateWorkitem(\"e137ce00-f676-435c-af94-30f1ee108ffb\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Find design for #Gamma\")\n95, 2025-05-07 13:53:27+00:00, user@local.host: AddPomodoro(\"e137ce00-f676-435c-af94-30f1ee108ffb\", \"1\", \"normal\")\n96, 2025-05-07 13:53:33+00:00, user@local.host: AddPomodoro(\"e137ce00-f676-435c-af94-30f1ee108ffb\", \"1\", \"normal\")\n97, 2025-05-07 13:54:01+00:00, user@local.host: CreateWorkitem(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Send email for #Delta\")\n98, 2025-05-07 13:54:06+00:00, user@local.host: AddPomodoro(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"1\", \"normal\")\n99, 2025-05-07 13:54:14+00:00, user@local.host: AddPomodoro(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"1\", \"normal\")\n100, 2025-05-07 13:54:50+00:00, user@local.host: CreateWorkitem(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Request documentation for #Gamma\")\n101, 2025-05-07 13:55:10+00:00, user@local.host: CreateWorkitem(\"ddb17495-8a36-4914-bda4-1f0d2b4fe958\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Explain idea for #Beta\")\n102, 2025-05-07 13:55:38+00:00, user@local.host: CreateWorkitem(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Draw screenshot for #Alpha\")\n103, 2025-05-07 13:55:44+00:00, user@local.host: AddPomodoro(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1\", \"normal\")\n104, 2025-05-07 13:55:49+00:00, user@local.host: AddPomodoro(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1\", \"normal\")\n105, 2025-05-07 13:55:56+00:00, user@local.host: AddPomodoro(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1\", \"normal\")\n106, 2025-05-07 13:57:16+00:00, user@local.host: StartTimer(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1500\", \"300\")\n107, 2025-05-07 14:27:35+00:00, user@local.host: RenameBacklog(\"f26055e5-49ec-4124-b023-efa8f41a7220\", \"Template for #Alpha\")\n108, 2025-05-07 14:28:21+00:00, user@local.host: StartTimer(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1500\", \"300\")\n109, 2025-05-07 14:35:15+00:00, user@local.host: AddInterruption(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"Pomodoro voided\", \"\")\n110, 2025-05-07 14:35:15+00:00, user@local.host: StopTimer(\"\", \"\")\n111, 2025-05-07 14:35:47+00:00, user@local.host: RemovePomodoro(\"09d22e2a-c71e-452a-bd24-de16f91e13ae\", \"1\")\n112, 2025-05-07 14:36:19+00:00, user@local.host: AddPomodoro(\"ddb17495-8a36-4914-bda4-1f0d2b4fe958\", \"1\", \"tracker\")\n113, 2025-05-07 14:36:19.010000+00:00, user@local.host: StartTimer(\"ddb17495-8a36-4914-bda4-1f0d2b4fe958\", \"\")\n114, 2025-05-07 14:51:19.010000+00:00, user@local.host: DeleteWorkitem(\"ddb17495-8a36-4914-bda4-1f0d2b4fe958\", \"\")\n115, 2025-05-07 14:52:02.010000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n116, 2025-05-07 14:52:02.020000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n117, 2025-05-07 14:58:29.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n118, 2025-05-07 14:59:35.020000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n119, 2025-05-07 14:59:35.030000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n120, 2025-05-07 15:11:29.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n121, 2025-05-07 15:12:09.030000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n122, 2025-05-07 15:12:09.040000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n123, 2025-05-07 15:22:11.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n124, 2025-05-07 15:22:54.040000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n125, 2025-05-07 15:22:54.050000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n126, 2025-05-07 15:34:14.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n127, 2025-05-07 15:35:06.050000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n128, 2025-05-07 15:35:06.060000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n129, 2025-05-07 15:40:17.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n130, 2025-05-07 15:41:35.060000+00:00, user@local.host: AddPomodoro(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"1\", \"tracker\")\n131, 2025-05-07 15:41:35.070000+00:00, user@local.host: StartTimer(\"b53802b1-d174-4c04-bd6a-b4f1a006dfe2\", \"\")\n132, 2025-05-07 15:53:33.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n133, 2025-05-07 15:54:01.070000+00:00, user@local.host: RenameWorkitem(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"Plan scheme for #Delta\")\n134, 2025-05-07 15:55:22.070000+00:00, user@local.host: StartTimer(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"1500\", \"300\")\n135, 2025-05-07 16:25:30.070000+00:00, user@local.host: AddPomodoro(\"e59b217f-4925-4e67-9b90-43cc40ed1bc9\", \"1\", \"normal\")\n136, 2025-05-07 16:26:03.070000+00:00, user@local.host: DeleteBacklog(\"f26055e5-49ec-4124-b023-efa8f41a7220\", \"\")\n137, 2025-05-08 13:12:26+00:00, user@local.host: CreateBacklog(\"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"2025-05-08, Thursday\")\n138, 2025-05-08 13:13:05+00:00, user@local.host: CreateWorkitem(\"4171a81f-f3a6-4065-83f2-9c9bb8b3ef82\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Request automation for #Gamma\")\n139, 2025-05-08 13:13:35+00:00, user@local.host: CreateWorkitem(\"92e0bdb7-6d1c-4b4c-8eeb-0af30262a6df\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Verify screenshot for #Gamma\")\n140, 2025-05-08 13:14:05+00:00, user@local.host: CreateWorkitem(\"b0e8c1a8-6e04-4d75-9b17-861830a8e51d\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Create new feature for #Delta\")\n141, 2025-05-08 13:14:12+00:00, user@local.host: AddPomodoro(\"b0e8c1a8-6e04-4d75-9b17-861830a8e51d\", \"1\", \"normal\")\n142, 2025-05-08 13:14:35+00:00, user@local.host: CreateWorkitem(\"4d2b9059-53b6-4086-b697-2964422ccd31\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Draw screenshot for #Alpha\")\n143, 2025-05-08 13:14:57+00:00, user@local.host: CreateWorkitem(\"7244a411-e729-4f3c-aa07-af70d6733570\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Explain architecture for #Beta\")\n144, 2025-05-08 13:15:15+00:00, user@local.host: CreateWorkitem(\"0c6891b4-b194-45ec-8e24-8e5b2e1c9aba\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Verify design for #Beta\")\n145, 2025-05-08 13:16:00+00:00, user@local.host: CreateWorkitem(\"c6b6cb02-4d04-4f93-b063-3c2f6c895681\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Find tool for #Omega\")\n146, 2025-05-08 13:16:06+00:00, user@local.host: AddPomodoro(\"c6b6cb02-4d04-4f93-b063-3c2f6c895681\", \"1\", \"normal\")\n147, 2025-05-08 13:16:45+00:00, user@local.host: CreateWorkitem(\"95d1c099-09e2-4582-ba85-8ebb91526f76\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Explore code for #Beta\")\n148, 2025-05-08 13:16:50+00:00, user@local.host: AddPomodoro(\"95d1c099-09e2-4582-ba85-8ebb91526f76\", \"1\", \"normal\")\n149, 2025-05-08 13:16:55+00:00, user@local.host: AddPomodoro(\"95d1c099-09e2-4582-ba85-8ebb91526f76\", \"1\", \"normal\")\n150, 2025-05-08 13:17:42+00:00, user@local.host: CreateWorkitem(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Fix documentation for #Delta\")\n151, 2025-05-08 13:17:49+00:00, user@local.host: AddPomodoro(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"1\", \"normal\")\n152, 2025-05-08 13:17:52+00:00, user@local.host: AddPomodoro(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"1\", \"normal\")\n153, 2025-05-08 13:18:26+00:00, user@local.host: CreateWorkitem(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"Explore documentation for #Delta\")\n154, 2025-05-08 13:19:06+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n155, 2025-05-08 13:19:06.010000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n156, 2025-05-08 13:27:14.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n157, 2025-05-08 13:27:56.010000+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n158, 2025-05-08 13:27:56.020000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n159, 2025-05-08 13:34:23.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n160, 2025-05-08 13:35:28.020000+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n161, 2025-05-08 13:35:28.030000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n162, 2025-05-08 13:38:26.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n163, 2025-05-08 13:38:48.030000+00:00, user@local.host: RenameWorkitem(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"Deprecate idea for #Beta\")\n164, 2025-05-08 13:40:07.030000+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n165, 2025-05-08 13:40:07.040000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n166, 2025-05-08 13:48:29.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n167, 2025-05-08 13:49:03.040000+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n168, 2025-05-08 13:49:03.050000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n169, 2025-05-08 13:57:51.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n170, 2025-05-08 13:59:09.050000+00:00, user@local.host: AddPomodoro(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"1\", \"tracker\")\n171, 2025-05-08 13:59:09.060000+00:00, user@local.host: StartTimer(\"67423f4c-75ad-42e9-9e59-53ac1365cfb7\", \"\")\n172, 2025-05-08 14:10:11.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n173, 2025-05-08 14:10:34.060000+00:00, user@local.host: RemovePomodoro(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"1\")\n174, 2025-05-08 14:11:40.060000+00:00, user@local.host: StartTimer(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"1500\", \"300\")\n175, 2025-05-08 14:26:31.060000+00:00, user@local.host: AddInterruption(\"d610e345-a1ae-4585-bad1-d6b232d182de\", \"An interruption\", \"\")\n176, 2025-05-08 14:56:58.060000+00:00, user@local.host: RenameWorkitem(\"95d1c099-09e2-4582-ba85-8ebb91526f76\", \"Create architecture for #Alpha\")\n177, 2025-05-08 14:57:37.060000+00:00, user@local.host: DeleteBacklog(\"45d4e781-f0d9-49e8-a71c-afa9a7668afc\", \"\")\n178, 2025-05-09 12:46:34+00:00, user@local.host: CreateBacklog(\"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"2025-05-09, Friday\")\n179, 2025-05-09 12:47:05+00:00, user@local.host: CreateWorkitem(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Document function for #Delta\")\n180, 2025-05-09 12:47:10+00:00, user@local.host: AddPomodoro(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"1\", \"normal\")\n181, 2025-05-09 12:47:38+00:00, user@local.host: CreateWorkitem(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Plan architecture for #Alpha\")\n182, 2025-05-09 12:47:43+00:00, user@local.host: AddPomodoro(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1\", \"normal\")\n183, 2025-05-09 12:47:51+00:00, user@local.host: AddPomodoro(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1\", \"normal\")\n184, 2025-05-09 12:47:57+00:00, user@local.host: AddPomodoro(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1\", \"normal\")\n185, 2025-05-09 12:48:35+00:00, user@local.host: CreateWorkitem(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Explain tool for #Omega\")\n186, 2025-05-09 12:48:43+00:00, user@local.host: AddPomodoro(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1\", \"normal\")\n187, 2025-05-09 12:48:50+00:00, user@local.host: AddPomodoro(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1\", \"normal\")\n188, 2025-05-09 12:49:27+00:00, user@local.host: CreateWorkitem(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Send automation for #Gamma\")\n189, 2025-05-09 12:49:34+00:00, user@local.host: AddPomodoro(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"1\", \"normal\")\n190, 2025-05-09 12:49:39+00:00, user@local.host: AddPomodoro(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"1\", \"normal\")\n191, 2025-05-09 12:50:08+00:00, user@local.host: CreateWorkitem(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Send architecture for #Gamma\")\n192, 2025-05-09 12:50:15+00:00, user@local.host: AddPomodoro(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"1\", \"normal\")\n193, 2025-05-09 12:50:21+00:00, user@local.host: AddPomodoro(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"1\", \"normal\")\n194, 2025-05-09 12:51:12+00:00, user@local.host: CreateWorkitem(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Think about scheme for #Delta\")\n195, 2025-05-09 12:51:21+00:00, user@local.host: AddPomodoro(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"1\", \"normal\")\n196, 2025-05-09 12:51:27+00:00, user@local.host: AddPomodoro(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"1\", \"normal\")\n197, 2025-05-09 12:51:47+00:00, user@local.host: CreateWorkitem(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Verify website for #Beta\")\n198, 2025-05-09 12:51:53+00:00, user@local.host: AddPomodoro(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1\", \"normal\")\n199, 2025-05-09 12:52:01+00:00, user@local.host: AddPomodoro(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1\", \"normal\")\n200, 2025-05-09 12:52:04+00:00, user@local.host: AddPomodoro(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1\", \"normal\")\n201, 2025-05-09 12:52:42+00:00, user@local.host: CreateWorkitem(\"048735dc-2e14-4468-b92b-887442533005\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Think about automation for #Delta\")\n202, 2025-05-09 12:52:59+00:00, user@local.host: CreateWorkitem(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"307e3d0b-6522-41f0-b9bf-f06e67efe56b\", \"Send function for #Alpha\")\n203, 2025-05-09 12:53:07+00:00, user@local.host: AddPomodoro(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"1\", \"normal\")\n204, 2025-05-09 12:53:11+00:00, user@local.host: AddPomodoro(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"1\", \"normal\")\n205, 2025-05-09 12:53:40+00:00, user@local.host: RenameWorkitem(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"Fix documentation for #Beta\")\n206, 2025-05-09 12:54:29+00:00, user@local.host: StartTimer(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"1500\", \"300\")\n207, 2025-05-09 13:24:35+00:00, user@local.host: AddPomodoro(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"1\", \"normal\")\n208, 2025-05-09 13:26:03+00:00, user@local.host: StartTimer(\"dc092249-26c9-4b09-ac27-da99eebdeacf\", \"1500\", \"300\")\n209, 2025-05-09 13:57:13+00:00, user@local.host: AddPomodoro(\"048735dc-2e14-4468-b92b-887442533005\", \"1\", \"tracker\")\n210, 2025-05-09 13:57:13.010000+00:00, user@local.host: StartTimer(\"048735dc-2e14-4468-b92b-887442533005\", \"\")\n211, 2025-05-09 14:06:34.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n212, 2025-05-09 14:08:06.010000+00:00, user@local.host: AddPomodoro(\"048735dc-2e14-4468-b92b-887442533005\", \"1\", \"tracker\")\n213, 2025-05-09 14:08:06.020000+00:00, user@local.host: StartTimer(\"048735dc-2e14-4468-b92b-887442533005\", \"\")\n214, 2025-05-09 14:20:34.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n215, 2025-05-09 14:21:19.020000+00:00, user@local.host: AddPomodoro(\"048735dc-2e14-4468-b92b-887442533005\", \"1\", \"tracker\")\n216, 2025-05-09 14:21:19.030000+00:00, user@local.host: StartTimer(\"048735dc-2e14-4468-b92b-887442533005\", \"\")\n217, 2025-05-09 14:29:48.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n218, 2025-05-09 14:30:56.030000+00:00, user@local.host: AddPomodoro(\"048735dc-2e14-4468-b92b-887442533005\", \"1\", \"tracker\")\n219, 2025-05-09 14:30:56.040000+00:00, user@local.host: StartTimer(\"048735dc-2e14-4468-b92b-887442533005\", \"\")\n220, 2025-05-09 14:40:38.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n221, 2025-05-09 14:41:30.040000+00:00, user@local.host: StartTimer(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1500\", \"300\")\n222, 2025-05-09 15:11:35.040000+00:00, user@local.host: AddPomodoro(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1\", \"normal\")\n223, 2025-05-09 15:12:40.040000+00:00, user@local.host: StartTimer(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1500\", \"0\")\n224, 2025-05-09 16:00:35.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n225, 2025-05-09 16:00:42.040000+00:00, user@local.host: AddPomodoro(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1\", \"normal\")\n226, 2025-05-09 16:01:31.040000+00:00, user@local.host: StartTimer(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1500\", \"300\")\n227, 2025-05-09 16:18:16.040000+00:00, user@local.host: AddInterruption(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"Voided for a good reason\", \"\")\n228, 2025-05-09 16:18:16.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n229, 2025-05-09 16:19:47.040000+00:00, user@local.host: StartTimer(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"1500\", \"300\")\n230, 2025-05-09 16:33:02.040000+00:00, user@local.host: AddInterruption(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"Pomodoro voided\", \"\")\n231, 2025-05-09 16:33:02.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n232, 2025-05-09 16:34:01.040000+00:00, user@local.host: StartTimer(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"1500\", \"300\")\n233, 2025-05-09 16:54:26.040000+00:00, user@local.host: AddInterruption(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"Voided for a good reason\", \"\")\n234, 2025-05-09 16:54:26.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n235, 2025-05-09 16:55:47.040000+00:00, user@local.host: StartTimer(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"1500\", \"300\")\n236, 2025-05-09 17:26:44.040000+00:00, user@local.host: StartTimer(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"1500\", \"300\")\n237, 2025-05-09 17:47:55.040000+00:00, user@local.host: AddInterruption(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"\", \"\")\n238, 2025-05-09 18:18:42.040000+00:00, user@local.host: StartTimer(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"1500\", \"300\")\n239, 2025-05-09 18:30:47.040000+00:00, user@local.host: AddInterruption(\"f8ee9e53-bb37-4920-bb44-2da47f46a1b5\", \"Pomodoro voided\", \"\")\n240, 2025-05-09 18:30:47.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n241, 2025-05-09 18:31:42.040000+00:00, user@local.host: StartTimer(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"1500\", \"300\")\n242, 2025-05-09 19:02:18.040000+00:00, user@local.host: StartTimer(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"1500\", \"0\")\n243, 2025-05-09 19:42:19.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n244, 2025-05-09 19:43:18.040000+00:00, user@local.host: StartTimer(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1500\", \"300\")\n245, 2025-05-09 20:13:22.040000+00:00, user@local.host: AddPomodoro(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1\", \"normal\")\n246, 2025-05-09 20:14:07.040000+00:00, user@local.host: StartTimer(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1500\", \"300\")\n247, 2025-05-09 20:45:13.040000+00:00, user@local.host: StartTimer(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"1500\", \"300\")\n248, 2025-05-09 20:56:06.040000+00:00, user@local.host: AddInterruption(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"Voided for a good reason\", \"\")\n249, 2025-05-09 20:56:06.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n250, 2025-05-09 20:57:20.040000+00:00, user@local.host: StartTimer(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1500\", \"300\")\n251, 2025-05-09 21:14:28.040000+00:00, user@local.host: AddInterruption(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"Voided for a good reason\", \"\")\n252, 2025-05-09 21:14:28.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n253, 2025-05-09 21:15:18.040000+00:00, user@local.host: StartTimer(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1500\", \"300\")\n254, 2025-05-09 21:34:29.040000+00:00, user@local.host: AddInterruption(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"Pomodoro voided\", \"\")\n255, 2025-05-09 21:34:29.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n256, 2025-05-09 21:35:12.040000+00:00, user@local.host: StartTimer(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"1500\", \"300\")\n257, 2025-05-09 21:48:35.040000+00:00, user@local.host: AddInterruption(\"6a754aa5-a75f-4afc-b189-846f4c922d50\", \"Voided for a good reason\", \"\")\n258, 2025-05-09 21:48:35.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n259, 2025-05-09 21:49:04.040000+00:00, user@local.host: RenameWorkitem(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"Generate design for #Alpha\")\n260, 2025-05-09 21:49:49.040000+00:00, user@local.host: StartTimer(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"1500\", \"300\")\n261, 2025-05-09 22:01:55.040000+00:00, user@local.host: AddInterruption(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"Voided for a good reason\", \"\")\n262, 2025-05-09 22:01:55.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n263, 2025-05-09 22:02:29.040000+00:00, user@local.host: CompleteWorkitem(\"29f31815-f2a0-48fb-ab66-b12306655c2b\", \"finished\")\n264, 2025-05-09 22:03:19.040000+00:00, user@local.host: CompleteWorkitem(\"ddbfe8a5-0e12-443d-93c4-d6ff3a492d41\", \"finished\")\n265, 2025-05-09 22:04:28.040000+00:00, user@local.host: CompleteWorkitem(\"048735dc-2e14-4468-b92b-887442533005\", \"finished\")\n266, 2025-05-09 22:05:31.040000+00:00, user@local.host: CompleteWorkitem(\"c02c6190-b9f5-4a60-a6ce-16bff70c4b20\", \"finished\")\n267, 2025-05-09 22:06:12.040000+00:00, user@local.host: CompleteWorkitem(\"2390a9aa-0697-4c60-b9d1-747488c6b0b7\", \"finished\")\n268, 2025-05-09 22:06:58.040000+00:00, user@local.host: CompleteWorkitem(\"c27fdf19-ad1f-4334-a47d-156d7890608e\", \"finished\")\n269, 2025-05-12 13:42:35+00:00, user@local.host: CreateBacklog(\"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"2025-05-12, Monday\")\n270, 2025-05-12 13:42:58+00:00, user@local.host: CreateWorkitem(\"a3b0e409-0ae4-47ba-a475-aefb70a8dadf\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Draw scheme for #Beta\")\n271, 2025-05-12 13:43:27+00:00, user@local.host: CreateWorkitem(\"78c2cd51-7539-4d91-9154-9d6472196bac\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Request new feature for #Omega\")\n272, 2025-05-12 13:43:54+00:00, user@local.host: CreateWorkitem(\"e6dbff1b-6bcc-43fa-9ae7-ea78cd5549ee\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Plan documentation for #Gamma\")\n273, 2025-05-12 13:43:59+00:00, user@local.host: AddPomodoro(\"e6dbff1b-6bcc-43fa-9ae7-ea78cd5549ee\", \"1\", \"normal\")\n274, 2025-05-12 13:44:48+00:00, user@local.host: CreateWorkitem(\"daaacd69-046f-4a38-b3d6-73e976514943\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Find automation for #Alpha\")\n275, 2025-05-12 13:45:17+00:00, user@local.host: CreateWorkitem(\"cff479fe-43dc-4fd5-abf6-9b97dc7f4de0\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Draw documentation for #Omega\")\n276, 2025-05-12 13:45:23+00:00, user@local.host: AddPomodoro(\"cff479fe-43dc-4fd5-abf6-9b97dc7f4de0\", \"1\", \"normal\")\n277, 2025-05-12 13:45:29+00:00, user@local.host: AddPomodoro(\"cff479fe-43dc-4fd5-abf6-9b97dc7f4de0\", \"1\", \"normal\")\n278, 2025-05-12 13:45:36+00:00, user@local.host: AddPomodoro(\"cff479fe-43dc-4fd5-abf6-9b97dc7f4de0\", \"1\", \"normal\")\n279, 2025-05-12 13:46:04+00:00, user@local.host: CreateWorkitem(\"87eea635-6c87-43ef-8532-dbcf9fd9a2b2\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Send screenshot for #Beta\")\n280, 2025-05-12 13:46:10+00:00, user@local.host: AddPomodoro(\"87eea635-6c87-43ef-8532-dbcf9fd9a2b2\", \"1\", \"normal\")\n281, 2025-05-12 13:46:18+00:00, user@local.host: AddPomodoro(\"87eea635-6c87-43ef-8532-dbcf9fd9a2b2\", \"1\", \"normal\")\n282, 2025-05-12 13:47:06+00:00, user@local.host: CreateWorkitem(\"b07c4e3b-0555-40d1-84ff-13d47aafe8f5\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Generate idea for #Beta\")\n283, 2025-05-12 13:47:10+00:00, user@local.host: AddPomodoro(\"b07c4e3b-0555-40d1-84ff-13d47aafe8f5\", \"1\", \"normal\")\n284, 2025-05-12 13:47:16+00:00, user@local.host: AddPomodoro(\"b07c4e3b-0555-40d1-84ff-13d47aafe8f5\", \"1\", \"normal\")\n285, 2025-05-12 13:47:22+00:00, user@local.host: AddPomodoro(\"b07c4e3b-0555-40d1-84ff-13d47aafe8f5\", \"1\", \"normal\")\n286, 2025-05-12 13:47:53+00:00, user@local.host: CreateWorkitem(\"a79fe749-a46d-49c7-adeb-50f17611c495\", \"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"Check idea for #Gamma\")\n287, 2025-05-12 13:48:00+00:00, user@local.host: AddPomodoro(\"a79fe749-a46d-49c7-adeb-50f17611c495\", \"1\", \"normal\")\n288, 2025-05-12 13:48:07+00:00, user@local.host: AddPomodoro(\"a79fe749-a46d-49c7-adeb-50f17611c495\", \"1\", \"normal\")\n289, 2025-05-12 13:48:39+00:00, user@local.host: StartTimer(\"a79fe749-a46d-49c7-adeb-50f17611c495\", \"1500\", \"300\")\n290, 2025-05-12 14:03:10+00:00, user@local.host: AddInterruption(\"a79fe749-a46d-49c7-adeb-50f17611c495\", \"\", \"\")\n291, 2025-05-12 14:33:39+00:00, user@local.host: DeleteBacklog(\"551eea70-0e53-4f0c-ae68-b7dd64e57574\", \"\")\n292, 2025-05-13 13:24:23+00:00, user@local.host: CreateBacklog(\"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"2025-05-13, Tuesday\")\n293, 2025-05-13 13:24:46+00:00, user@local.host: CreateWorkitem(\"3bba42a2-c057-436a-953f-849f590c4462\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Fix idea for #Gamma\")\n294, 2025-05-13 13:24:56+00:00, user@local.host: AddPomodoro(\"3bba42a2-c057-436a-953f-849f590c4462\", \"1\", \"normal\")\n295, 2025-05-13 13:25:05+00:00, user@local.host: AddPomodoro(\"3bba42a2-c057-436a-953f-849f590c4462\", \"1\", \"normal\")\n296, 2025-05-13 13:25:29+00:00, user@local.host: CreateWorkitem(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Fix screenshot for #Omega\")\n297, 2025-05-13 13:25:36+00:00, user@local.host: AddPomodoro(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1\", \"normal\")\n298, 2025-05-13 13:25:43+00:00, user@local.host: AddPomodoro(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1\", \"normal\")\n299, 2025-05-13 13:25:48+00:00, user@local.host: AddPomodoro(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1\", \"normal\")\n300, 2025-05-13 13:26:14+00:00, user@local.host: CreateWorkitem(\"19884dba-d35d-431b-a474-010406185049\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Draw idea for #Beta\")\n301, 2025-05-13 13:26:19+00:00, user@local.host: AddPomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\", \"normal\")\n302, 2025-05-13 13:26:26+00:00, user@local.host: AddPomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\", \"normal\")\n303, 2025-05-13 13:26:33+00:00, user@local.host: AddPomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\", \"normal\")\n304, 2025-05-13 13:27:05+00:00, user@local.host: CreateWorkitem(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Request email for #Omega\")\n305, 2025-05-13 13:27:26+00:00, user@local.host: CreateWorkitem(\"576cb27a-7d48-4f6d-81da-e3e537de16a2\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Send email for #Beta\")\n306, 2025-05-13 13:27:35+00:00, user@local.host: AddPomodoro(\"576cb27a-7d48-4f6d-81da-e3e537de16a2\", \"1\", \"normal\")\n307, 2025-05-13 13:28:04+00:00, user@local.host: CreateWorkitem(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"cd7802ad-b68b-4722-81b4-baefe9888c39\", \"Think about new feature for #Delta\")\n308, 2025-05-13 13:29:28+00:00, user@local.host: AddPomodoro(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"1\", \"tracker\")\n309, 2025-05-13 13:29:28.010000+00:00, user@local.host: StartTimer(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"\")\n310, 2025-05-13 13:39:20.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n311, 2025-05-13 13:40:25.010000+00:00, user@local.host: AddPomodoro(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"1\", \"tracker\")\n312, 2025-05-13 13:40:25.020000+00:00, user@local.host: StartTimer(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"\")\n313, 2025-05-13 13:46:07.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n314, 2025-05-13 13:47:10.020000+00:00, user@local.host: AddPomodoro(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"1\", \"tracker\")\n315, 2025-05-13 13:47:10.030000+00:00, user@local.host: StartTimer(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"\")\n316, 2025-05-13 13:53:55.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n317, 2025-05-13 13:54:46.030000+00:00, user@local.host: AddPomodoro(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"1\", \"tracker\")\n318, 2025-05-13 13:54:46.040000+00:00, user@local.host: StartTimer(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"\")\n319, 2025-05-13 14:05:25.040000+00:00, user@local.host: DeleteWorkitem(\"7b4880cb-8849-4fa1-b423-98b65ec28a0c\", \"\")\n320, 2025-05-13 14:06:02.040000+00:00, user@local.host: RenameWorkitem(\"576cb27a-7d48-4f6d-81da-e3e537de16a2\", \"Verify design for #Gamma\")\n321, 2025-05-13 14:06:52.040000+00:00, user@local.host: StartTimer(\"576cb27a-7d48-4f6d-81da-e3e537de16a2\", \"1500\", \"300\")\n322, 2025-05-13 14:37:46.040000+00:00, user@local.host: AddPomodoro(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"1\", \"tracker\")\n323, 2025-05-13 14:37:46.050000+00:00, user@local.host: StartTimer(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"\")\n324, 2025-05-13 14:48:06.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n325, 2025-05-13 14:48:42.050000+00:00, user@local.host: AddPomodoro(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"1\", \"tracker\")\n326, 2025-05-13 14:48:42.060000+00:00, user@local.host: StartTimer(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"\")\n327, 2025-05-13 14:56:54.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n328, 2025-05-13 14:58:14.060000+00:00, user@local.host: AddPomodoro(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"1\", \"tracker\")\n329, 2025-05-13 14:58:14.070000+00:00, user@local.host: StartTimer(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"\")\n330, 2025-05-13 15:10:51.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n331, 2025-05-13 15:12:12.070000+00:00, user@local.host: AddPomodoro(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"1\", \"tracker\")\n332, 2025-05-13 15:12:12.080000+00:00, user@local.host: StartTimer(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"\")\n333, 2025-05-13 15:23:05.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n334, 2025-05-13 15:23:53.080000+00:00, user@local.host: StartTimer(\"19884dba-d35d-431b-a474-010406185049\", \"1500\", \"300\")\n335, 2025-05-13 15:54:25.080000+00:00, user@local.host: RenameWorkitem(\"19884dba-d35d-431b-a474-010406185049\", \"Create website for #Alpha\")\n336, 2025-05-13 15:55:16.080000+00:00, user@local.host: StartTimer(\"19884dba-d35d-431b-a474-010406185049\", \"1500\", \"300\")\n337, 2025-05-13 16:25:20.080000+00:00, user@local.host: AddPomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\", \"normal\")\n338, 2025-05-13 16:25:52.080000+00:00, user@local.host: RemovePomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\")\n339, 2025-05-13 16:26:45.080000+00:00, user@local.host: StartTimer(\"19884dba-d35d-431b-a474-010406185049\", \"1500\", \"0\")\n340, 2025-05-13 17:47:37.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n341, 2025-05-13 17:47:42.080000+00:00, user@local.host: AddPomodoro(\"19884dba-d35d-431b-a474-010406185049\", \"1\", \"normal\")\n342, 2025-05-13 17:48:39.080000+00:00, user@local.host: StartTimer(\"19884dba-d35d-431b-a474-010406185049\", \"1500\", \"300\")\n343, 2025-05-13 17:57:23.080000+00:00, user@local.host: AddInterruption(\"19884dba-d35d-431b-a474-010406185049\", \"\", \"262.0\")\n344, 2025-05-13 18:28:23.080000+00:00, user@local.host: StartTimer(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1500\", \"300\")\n345, 2025-05-13 18:58:29.080000+00:00, user@local.host: AddPomodoro(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1\", \"normal\")\n346, 2025-05-13 18:59:03.080000+00:00, user@local.host: StartTimer(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"1500\", \"300\")\n347, 2025-05-13 19:02:11.080000+00:00, user@local.host: CompleteWorkitem(\"d32cf12d-f3e3-4284-bb81-ad2503490de7\", \"finished\")\n348, 2025-05-13 19:03:01.080000+00:00, user@local.host: StartTimer(\"3bba42a2-c057-436a-953f-849f590c4462\", \"1500\", \"300\")\n349, 2025-05-13 19:33:54.080000+00:00, user@local.host: StartTimer(\"3bba42a2-c057-436a-953f-849f590c4462\", \"1500\", \"0\")\n350, 2025-05-13 20:42:13.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n351, 2025-05-13 20:43:05.080000+00:00, user@local.host: CompleteWorkitem(\"576cb27a-7d48-4f6d-81da-e3e537de16a2\", \"finished\")\n352, 2025-05-13 20:43:53.080000+00:00, user@local.host: CompleteWorkitem(\"3bba42a2-c057-436a-953f-849f590c4462\", \"finished\")\n353, 2025-05-13 20:45:14.080000+00:00, user@local.host: CompleteWorkitem(\"485e0875-108c-4055-8f89-2d24892fc52b\", \"finished\")\n354, 2025-05-14 13:14:40+00:00, user@local.host: CreateBacklog(\"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"2025-05-14, Wednesday\")\n355, 2025-05-14 13:15:01+00:00, user@local.host: CreateWorkitem(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Draw design for #Delta\")\n356, 2025-05-14 13:15:41+00:00, user@local.host: CreateWorkitem(\"d0dcc6dc-9aa4-4d44-b299-f2203b12cbcc\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Find screenshot for #Gamma\")\n357, 2025-05-14 13:16:16+00:00, user@local.host: CreateWorkitem(\"48f05ec8-7d8b-47b9-9efb-f46699da55ec\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Plan idea for #Delta\")\n358, 2025-05-14 13:16:20+00:00, user@local.host: AddPomodoro(\"48f05ec8-7d8b-47b9-9efb-f46699da55ec\", \"1\", \"normal\")\n359, 2025-05-14 13:17:01+00:00, user@local.host: CreateWorkitem(\"ea664213-15af-4e12-988f-f140fc06208d\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Create scheme for #Gamma\")\n360, 2025-05-14 13:17:08+00:00, user@local.host: AddPomodoro(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1\", \"normal\")\n361, 2025-05-14 13:17:14+00:00, user@local.host: AddPomodoro(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1\", \"normal\")\n362, 2025-05-14 13:17:19+00:00, user@local.host: AddPomodoro(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1\", \"normal\")\n363, 2025-05-14 13:17:50+00:00, user@local.host: CreateWorkitem(\"32555d52-bced-45f0-93a5-864c36628de3\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Draw tool for #Alpha\")\n364, 2025-05-14 13:17:55+00:00, user@local.host: AddPomodoro(\"32555d52-bced-45f0-93a5-864c36628de3\", \"1\", \"normal\")\n365, 2025-05-14 13:18:18+00:00, user@local.host: CreateWorkitem(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"764eb6a7-cd3a-495a-8477-5d8cc41475ea\", \"Draw automation for #Delta\")\n366, 2025-05-14 13:18:24+00:00, user@local.host: AddPomodoro(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"1\", \"normal\")\n367, 2025-05-14 13:18:28+00:00, user@local.host: AddPomodoro(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"1\", \"normal\")\n368, 2025-05-14 13:18:50+00:00, user@local.host: RemovePomodoro(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"1\")\n369, 2025-05-14 13:20:03+00:00, user@local.host: StartTimer(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"1500\", \"300\")\n370, 2025-05-14 13:28:44+00:00, user@local.host: AddInterruption(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"An interruption\", \"\")\n371, 2025-05-14 13:59:11+00:00, user@local.host: RemovePomodoro(\"32555d52-bced-45f0-93a5-864c36628de3\", \"1\")\n372, 2025-05-14 13:59:59+00:00, user@local.host: StartTimer(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1500\", \"300\")\n373, 2025-05-14 14:31:15+00:00, user@local.host: StartTimer(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1500\", \"300\")\n374, 2025-05-14 14:45:17+00:00, user@local.host: AddInterruption(\"ea664213-15af-4e12-988f-f140fc06208d\", \"Pomodoro voided\", \"\")\n375, 2025-05-14 14:45:17+00:00, user@local.host: StopTimer(\"\", \"\")\n376, 2025-05-14 14:45:44+00:00, user@local.host: StartTimer(\"ea664213-15af-4e12-988f-f140fc06208d\", \"1500\", \"300\")\n377, 2025-05-14 15:16:57+00:00, user@local.host: StartTimer(\"48f05ec8-7d8b-47b9-9efb-f46699da55ec\", \"1500\", \"0\")\n378, 2025-05-14 15:46:02+00:00, user@local.host: AddInterruption(\"48f05ec8-7d8b-47b9-9efb-f46699da55ec\", \"An interruption\", \"\")\n379, 2025-05-14 17:01:56+00:00, user@local.host: StopTimer(\"\", \"\")\n380, 2025-05-14 17:02:46+00:00, user@local.host: AddPomodoro(\"d0dcc6dc-9aa4-4d44-b299-f2203b12cbcc\", \"1\", \"tracker\")\n381, 2025-05-14 17:02:46.010000+00:00, user@local.host: StartTimer(\"d0dcc6dc-9aa4-4d44-b299-f2203b12cbcc\", \"\")\n382, 2025-05-14 17:10:47.010000+00:00, user@local.host: CompleteWorkitem(\"d0dcc6dc-9aa4-4d44-b299-f2203b12cbcc\", \"finished\")\n383, 2025-05-14 17:11:08.010000+00:00, user@local.host: RenameWorkitem(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"Verify idea for #Beta\")\n384, 2025-05-14 17:11:50.010000+00:00, user@local.host: AddPomodoro(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"1\", \"tracker\")\n385, 2025-05-14 17:11:50.020000+00:00, user@local.host: StartTimer(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"\")\n386, 2025-05-14 17:24:30.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n387, 2025-05-14 17:25:13.020000+00:00, user@local.host: RenameWorkitem(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"Request website for #Beta\")\n388, 2025-05-14 17:26:24.020000+00:00, user@local.host: AddPomodoro(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"1\", \"tracker\")\n389, 2025-05-14 17:26:24.030000+00:00, user@local.host: StartTimer(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"\")\n390, 2025-05-14 17:34:02.030000+00:00, user@local.host: DeleteWorkitem(\"8bc4951f-0b79-4f1b-80e4-674f3ffb48ae\", \"\")\n391, 2025-05-14 17:35:33.030000+00:00, user@local.host: CompleteWorkitem(\"48f05ec8-7d8b-47b9-9efb-f46699da55ec\", \"finished\")\n392, 2025-05-14 17:36:36.030000+00:00, user@local.host: CompleteWorkitem(\"32555d52-bced-45f0-93a5-864c36628de3\", \"finished\")\n393, 2025-05-14 17:37:48.030000+00:00, user@local.host: CompleteWorkitem(\"ea664213-15af-4e12-988f-f140fc06208d\", \"finished\")\n394, 2025-05-14 17:38:36.030000+00:00, user@local.host: CompleteWorkitem(\"bdcfd9a1-c28e-44fd-a0b0-41c6b06cb1cf\", \"finished\")\n395, 2025-05-15 15:10:39+00:00, user@local.host: CreateBacklog(\"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"2025-05-15, Thursday\")\n396, 2025-05-15 15:11:23+00:00, user@local.host: CreateWorkitem(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Think about function for #Beta\")\n397, 2025-05-15 15:11:27+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n398, 2025-05-15 15:11:33+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n399, 2025-05-15 15:12:06+00:00, user@local.host: CreateWorkitem(\"5884d6b4-eb07-4432-b65a-b5ff0c272d22\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Document automation for #Alpha\")\n400, 2025-05-15 15:12:12+00:00, user@local.host: AddPomodoro(\"5884d6b4-eb07-4432-b65a-b5ff0c272d22\", \"1\", \"normal\")\n401, 2025-05-15 15:12:35+00:00, user@local.host: CreateWorkitem(\"797a0f6d-ebf5-4986-bfca-c6ee0cb84f5b\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Think about new feature for #Gamma\")\n402, 2025-05-15 15:12:39+00:00, user@local.host: AddPomodoro(\"797a0f6d-ebf5-4986-bfca-c6ee0cb84f5b\", \"1\", \"normal\")\n403, 2025-05-15 15:13:07+00:00, user@local.host: CreateWorkitem(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Explore email for #Alpha\")\n404, 2025-05-15 15:13:37+00:00, user@local.host: CreateWorkitem(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Fix tool for #Omega\")\n405, 2025-05-15 15:14:06+00:00, user@local.host: CreateWorkitem(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Create function for #Alpha\")\n406, 2025-05-15 15:14:39+00:00, user@local.host: CreateWorkitem(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"e796bdf4-129d-4b8f-8aa0-572a922fe0a3\", \"Plan documentation for #Beta\")\n407, 2025-05-15 15:14:47+00:00, user@local.host: AddPomodoro(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1\", \"normal\")\n408, 2025-05-15 15:14:52+00:00, user@local.host: AddPomodoro(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1\", \"normal\")\n409, 2025-05-15 15:15:14+00:00, user@local.host: StartTimer(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1500\", \"300\")\n410, 2025-05-15 15:45:21+00:00, user@local.host: AddPomodoro(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1\", \"normal\")\n411, 2025-05-15 15:46:49+00:00, user@local.host: StartTimer(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1500\", \"300\")\n412, 2025-05-15 15:55:28+00:00, user@local.host: AddInterruption(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"Pomodoro voided\", \"\")\n413, 2025-05-15 15:55:28+00:00, user@local.host: StopTimer(\"\", \"\")\n414, 2025-05-15 15:56:20+00:00, user@local.host: StartTimer(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"1500\", \"300\")\n415, 2025-05-15 16:27:18+00:00, user@local.host: AddPomodoro(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"1\", \"tracker\")\n416, 2025-05-15 16:27:18.010000+00:00, user@local.host: StartTimer(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"\")\n417, 2025-05-15 16:40:51.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n418, 2025-05-15 16:42:19.010000+00:00, user@local.host: AddPomodoro(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"1\", \"tracker\")\n419, 2025-05-15 16:42:19.020000+00:00, user@local.host: StartTimer(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"\")\n420, 2025-05-15 16:52:34.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n421, 2025-05-15 16:53:10.020000+00:00, user@local.host: RenameWorkitem(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"Generate function for #Beta\")\n422, 2025-05-15 16:54:02.020000+00:00, user@local.host: AddPomodoro(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"1\", \"tracker\")\n423, 2025-05-15 16:54:02.030000+00:00, user@local.host: StartTimer(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"\")\n424, 2025-05-15 17:05:35.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n425, 2025-05-15 17:06:41.030000+00:00, user@local.host: AddPomodoro(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"1\", \"tracker\")\n426, 2025-05-15 17:06:41.040000+00:00, user@local.host: StartTimer(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"\")\n427, 2025-05-15 17:18:05.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n428, 2025-05-15 17:18:49.040000+00:00, user@local.host: AddPomodoro(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"1\", \"tracker\")\n429, 2025-05-15 17:18:49.050000+00:00, user@local.host: StartTimer(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"\")\n430, 2025-05-15 17:27:33.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n431, 2025-05-15 17:28:33.050000+00:00, user@local.host: AddPomodoro(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"1\", \"tracker\")\n432, 2025-05-15 17:28:33.060000+00:00, user@local.host: StartTimer(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"\")\n433, 2025-05-15 17:35:35.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n434, 2025-05-15 17:36:04.060000+00:00, user@local.host: RenameWorkitem(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"Fix scheme for #Delta\")\n435, 2025-05-15 17:37:12.060000+00:00, user@local.host: AddPomodoro(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"1\", \"tracker\")\n436, 2025-05-15 17:37:12.070000+00:00, user@local.host: StartTimer(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"\")\n437, 2025-05-15 17:48:10.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n438, 2025-05-15 17:48:28.070000+00:00, user@local.host: AddPomodoro(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"1\", \"tracker\")\n439, 2025-05-15 17:48:28.080000+00:00, user@local.host: StartTimer(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"\")\n440, 2025-05-15 17:58:01.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n441, 2025-05-15 17:58:47.080000+00:00, user@local.host: AddPomodoro(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"1\", \"tracker\")\n442, 2025-05-15 17:58:47.090000+00:00, user@local.host: StartTimer(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"\")\n443, 2025-05-15 18:11:05.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n444, 2025-05-15 18:12:07.090000+00:00, user@local.host: AddPomodoro(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"1\", \"tracker\")\n445, 2025-05-15 18:12:07.100000+00:00, user@local.host: StartTimer(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"\")\n446, 2025-05-15 18:23:50.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n447, 2025-05-15 18:24:23.100000+00:00, user@local.host: RenameWorkitem(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"Plan architecture for #Delta\")\n448, 2025-05-15 18:25:35.100000+00:00, user@local.host: AddPomodoro(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"1\", \"tracker\")\n449, 2025-05-15 18:25:35.110000+00:00, user@local.host: StartTimer(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"\")\n450, 2025-05-15 18:32:54.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n451, 2025-05-15 18:33:49.110000+00:00, user@local.host: AddPomodoro(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"1\", \"tracker\")\n452, 2025-05-15 18:33:49.120000+00:00, user@local.host: StartTimer(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"\")\n453, 2025-05-15 18:49:12.120000+00:00, user@local.host: StopTimer(\"\", \"\")\n454, 2025-05-15 18:49:58.120000+00:00, user@local.host: AddPomodoro(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"1\", \"tracker\")\n455, 2025-05-15 18:49:58.130000+00:00, user@local.host: StartTimer(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"\")\n456, 2025-05-15 19:00:39.130000+00:00, user@local.host: StopTimer(\"\", \"\")\n457, 2025-05-15 19:01:54.130000+00:00, user@local.host: StartTimer(\"797a0f6d-ebf5-4986-bfca-c6ee0cb84f5b\", \"1500\", \"300\")\n458, 2025-05-15 19:33:03.130000+00:00, user@local.host: StartTimer(\"5884d6b4-eb07-4432-b65a-b5ff0c272d22\", \"1500\", \"0\")\n459, 2025-05-15 20:33:18.130000+00:00, user@local.host: StopTimer(\"\", \"\")\n460, 2025-05-15 20:34:04.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"300\")\n461, 2025-05-15 21:04:11.130000+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n462, 2025-05-15 21:04:51.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"300\")\n463, 2025-05-15 21:18:54.130000+00:00, user@local.host: AddInterruption(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"\", \"421.5\")\n464, 2025-05-15 21:49:18.130000+00:00, user@local.host: RenameWorkitem(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"Create email for #Beta\")\n465, 2025-05-15 21:49:54.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"300\")\n466, 2025-05-15 22:20:01.130000+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n467, 2025-05-15 22:21:07.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"0\")\n468, 2025-05-15 23:25:06.130000+00:00, user@local.host: StopTimer(\"\", \"\")\n469, 2025-05-15 23:25:11.130000+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n470, 2025-05-15 23:26:17.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"300\")\n471, 2025-05-15 23:56:24.130000+00:00, user@local.host: AddPomodoro(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1\", \"normal\")\n472, 2025-05-15 23:57:07.130000+00:00, user@local.host: StartTimer(\"dce14bc7-9d5e-496e-a190-9330d771fb31\", \"1500\", \"300\")\n473, 2025-05-16 00:27:55.130000+00:00, user@local.host: CompleteWorkitem(\"852dc914-b8b1-4395-8584-a8f66e3fbcda\", \"finished\")\n474, 2025-05-16 00:28:56.130000+00:00, user@local.host: CompleteWorkitem(\"077c106a-6c53-4d99-80b2-54c2b5f24208\", \"finished\")\n475, 2025-05-16 00:30:25.130000+00:00, user@local.host: CompleteWorkitem(\"9b109e03-dd12-42fa-b431-e0bac1fcf9b8\", \"finished\")\n476, 2025-05-16 00:31:34.130000+00:00, user@local.host: CompleteWorkitem(\"c5bcb337-9185-4e9e-8936-0961ccbb7662\", \"finished\")\n477, 2025-05-16 14:05:28+00:00, user@local.host: CreateBacklog(\"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"2025-05-16, Friday\")\n478, 2025-05-16 14:06:05+00:00, user@local.host: CreateWorkitem(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"Find idea for #Omega\")\n479, 2025-05-16 14:06:31+00:00, user@local.host: CreateWorkitem(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"Check function for #Alpha\")\n480, 2025-05-16 14:06:34+00:00, user@local.host: AddPomodoro(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"1\", \"normal\")\n481, 2025-05-16 14:06:38+00:00, user@local.host: AddPomodoro(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"1\", \"normal\")\n482, 2025-05-16 14:07:15+00:00, user@local.host: CreateWorkitem(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"Send email for #Alpha\")\n483, 2025-05-16 14:07:53+00:00, user@local.host: CreateWorkitem(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"Create function for #Alpha\")\n484, 2025-05-16 14:08:26+00:00, user@local.host: CreateWorkitem(\"ced870aa-d13b-48db-86ea-223331745b00\", \"093030f6-8c59-4e12-b672-a5f3f12d4905\", \"Fix new feature for #Delta\")\n485, 2025-05-16 14:08:31+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n486, 2025-05-16 14:08:37+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n487, 2025-05-16 14:09:30+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"300\")\n488, 2025-05-16 14:39:35+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n489, 2025-05-16 14:40:39+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"300\")\n490, 2025-05-16 15:10:47+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n491, 2025-05-16 15:11:51+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"300\")\n492, 2025-05-16 15:41:55+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n493, 2025-05-16 15:43:18+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"0\")\n494, 2025-05-16 16:05:49+00:00, user@local.host: AddInterruption(\"ced870aa-d13b-48db-86ea-223331745b00\", \"Pomodoro voided\", \"\")\n495, 2025-05-16 16:05:49+00:00, user@local.host: StopTimer(\"\", \"\")\n496, 2025-05-16 16:06:22+00:00, user@local.host: RenameWorkitem(\"ced870aa-d13b-48db-86ea-223331745b00\", \"Draw website for #Beta\")\n497, 2025-05-16 16:07:19+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"0\")\n498, 2025-05-16 17:14:13+00:00, user@local.host: StopTimer(\"\", \"\")\n499, 2025-05-16 17:14:19+00:00, user@local.host: AddPomodoro(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1\", \"normal\")\n500, 2025-05-16 17:15:42+00:00, user@local.host: StartTimer(\"ced870aa-d13b-48db-86ea-223331745b00\", \"1500\", \"300\")\n501, 2025-05-16 17:31:35+00:00, user@local.host: AddInterruption(\"ced870aa-d13b-48db-86ea-223331745b00\", \"Pomodoro voided\", \"\")\n502, 2025-05-16 17:31:35+00:00, user@local.host: StopTimer(\"\", \"\")\n503, 2025-05-16 17:32:06+00:00, user@local.host: RenameWorkitem(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"Request script for #Delta\")\n504, 2025-05-16 17:32:50+00:00, user@local.host: AddPomodoro(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"1\", \"tracker\")\n505, 2025-05-16 17:32:50.010000+00:00, user@local.host: StartTimer(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"\")\n506, 2025-05-16 17:46:13.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n507, 2025-05-16 17:47:07.010000+00:00, user@local.host: AddPomodoro(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"1\", \"tracker\")\n508, 2025-05-16 17:47:07.020000+00:00, user@local.host: StartTimer(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"\")\n509, 2025-05-16 17:59:20.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n510, 2025-05-16 18:00:09.020000+00:00, user@local.host: AddPomodoro(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"1\", \"tracker\")\n511, 2025-05-16 18:00:09.030000+00:00, user@local.host: StartTimer(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"\")\n512, 2025-05-16 18:12:29.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n513, 2025-05-16 18:13:38.030000+00:00, user@local.host: AddPomodoro(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"1\", \"tracker\")\n514, 2025-05-16 18:13:38.040000+00:00, user@local.host: StartTimer(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"\")\n515, 2025-05-16 18:26:06.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n516, 2025-05-16 18:27:12.040000+00:00, user@local.host: AddPomodoro(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"1\", \"tracker\")\n517, 2025-05-16 18:27:12.050000+00:00, user@local.host: StartTimer(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"\")\n518, 2025-05-16 18:35:12.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n519, 2025-05-16 18:36:05.050000+00:00, user@local.host: RenameWorkitem(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"Explain automation for #Alpha\")\n520, 2025-05-16 18:37:11.050000+00:00, user@local.host: AddPomodoro(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"1\", \"tracker\")\n521, 2025-05-16 18:37:11.060000+00:00, user@local.host: StartTimer(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"\")\n522, 2025-05-16 18:47:31.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n523, 2025-05-16 18:49:07.060000+00:00, user@local.host: AddPomodoro(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"1\", \"tracker\")\n524, 2025-05-16 18:49:07.070000+00:00, user@local.host: StartTimer(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"\")\n525, 2025-05-16 18:55:50.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n526, 2025-05-16 18:57:01.070000+00:00, user@local.host: AddPomodoro(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"1\", \"tracker\")\n527, 2025-05-16 18:57:01.080000+00:00, user@local.host: StartTimer(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"\")\n528, 2025-05-16 19:05:03.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n529, 2025-05-16 19:06:06.080000+00:00, user@local.host: StartTimer(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"1500\", \"300\")\n530, 2025-05-16 19:24:41.080000+00:00, user@local.host: AddInterruption(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"An interruption\", \"\")\n531, 2025-05-16 19:55:12.080000+00:00, user@local.host: RemovePomodoro(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"1\")\n532, 2025-05-16 19:55:37.080000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n533, 2025-05-16 19:55:37.090000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n534, 2025-05-16 20:05:36.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n535, 2025-05-16 20:06:11.090000+00:00, user@local.host: RenameWorkitem(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"Verify idea for #Omega\")\n536, 2025-05-16 20:07:43.090000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n537, 2025-05-16 20:07:43.100000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n538, 2025-05-16 20:23:04.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n539, 2025-05-16 20:24:14.100000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n540, 2025-05-16 20:24:14.110000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n541, 2025-05-16 20:35:57.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n542, 2025-05-16 20:36:59.110000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n543, 2025-05-16 20:36:59.120000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n544, 2025-05-16 20:46:21.120000+00:00, user@local.host: StopTimer(\"\", \"\")\n545, 2025-05-16 20:47:25.120000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n546, 2025-05-16 20:47:25.130000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n547, 2025-05-16 20:57:29.130000+00:00, user@local.host: StopTimer(\"\", \"\")\n548, 2025-05-16 20:58:19.130000+00:00, user@local.host: AddPomodoro(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"1\", \"tracker\")\n549, 2025-05-16 20:58:19.140000+00:00, user@local.host: StartTimer(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n550, 2025-05-16 21:11:00.140000+00:00, user@local.host: DeleteWorkitem(\"9e360288-d824-46a1-bc9b-d9abe78ebf8a\", \"\")\n551, 2025-05-16 21:11:53.140000+00:00, user@local.host: CompleteWorkitem(\"3c05819c-fe73-44cc-a2db-5c98da0feda1\", \"finished\")\n552, 2025-05-16 21:13:02.140000+00:00, user@local.host: CompleteWorkitem(\"e35946c9-0cd7-4e83-bd3d-21d6f3a865c6\", \"finished\")\n553, 2025-05-16 21:13:53.140000+00:00, user@local.host: CompleteWorkitem(\"2f2a6481-8ab3-43c5-ad98-e6ffccd68738\", \"finished\")\n554, 2025-05-16 21:15:02.140000+00:00, user@local.host: CompleteWorkitem(\"ced870aa-d13b-48db-86ea-223331745b00\", \"finished\")\n555, 2025-05-19 14:54:45+00:00, user@local.host: CreateBacklog(\"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"2025-05-19, Monday\")\n556, 2025-05-19 14:55:18+00:00, user@local.host: CreateWorkitem(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Find function for #Gamma\")\n557, 2025-05-19 14:55:24+00:00, user@local.host: AddPomodoro(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1\", \"normal\")\n558, 2025-05-19 14:55:27+00:00, user@local.host: AddPomodoro(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1\", \"normal\")\n559, 2025-05-19 14:55:53+00:00, user@local.host: CreateWorkitem(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Generate scheme for #Alpha\")\n560, 2025-05-19 14:56:00+00:00, user@local.host: AddPomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\", \"normal\")\n561, 2025-05-19 14:56:06+00:00, user@local.host: AddPomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\", \"normal\")\n562, 2025-05-19 14:56:12+00:00, user@local.host: AddPomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\", \"normal\")\n563, 2025-05-19 14:56:46+00:00, user@local.host: CreateWorkitem(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Fix email for #Delta\")\n564, 2025-05-19 14:56:52+00:00, user@local.host: AddPomodoro(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"1\", \"normal\")\n565, 2025-05-19 14:56:58+00:00, user@local.host: AddPomodoro(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"1\", \"normal\")\n566, 2025-05-19 14:57:33+00:00, user@local.host: CreateWorkitem(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Explore code for #Gamma\")\n567, 2025-05-19 14:58:06+00:00, user@local.host: CreateWorkitem(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Request function for #Gamma\")\n568, 2025-05-19 14:58:09+00:00, user@local.host: AddPomodoro(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"1\", \"normal\")\n569, 2025-05-19 14:58:13+00:00, user@local.host: AddPomodoro(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"1\", \"normal\")\n570, 2025-05-19 14:58:40+00:00, user@local.host: CreateWorkitem(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Think about bug for #Beta\")\n571, 2025-05-19 14:59:01+00:00, user@local.host: CreateWorkitem(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Plan new feature for #Alpha\")\n572, 2025-05-19 14:59:05+00:00, user@local.host: AddPomodoro(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1\", \"normal\")\n573, 2025-05-19 14:59:09+00:00, user@local.host: AddPomodoro(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1\", \"normal\")\n574, 2025-05-19 14:59:15+00:00, user@local.host: AddPomodoro(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1\", \"normal\")\n575, 2025-05-19 15:00:07+00:00, user@local.host: StartTimer(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1500\", \"300\")\n576, 2025-05-19 15:30:13+00:00, user@local.host: AddPomodoro(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1\", \"normal\")\n577, 2025-05-19 15:31:17+00:00, user@local.host: StartTimer(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1500\", \"300\")\n578, 2025-05-19 16:02:22+00:00, user@local.host: StartTimer(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1500\", \"300\")\n579, 2025-05-19 16:32:27+00:00, user@local.host: AddPomodoro(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1\", \"normal\")\n580, 2025-05-19 16:33:43+00:00, user@local.host: StartTimer(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1500\", \"0\")\n581, 2025-05-19 17:05:44+00:00, user@local.host: AddInterruption(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"\", \"960.5\")\n582, 2025-05-19 18:06:19+00:00, user@local.host: StopTimer(\"\", \"\")\n583, 2025-05-19 18:07:07+00:00, user@local.host: StartTimer(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"1500\", \"300\")\n584, 2025-05-19 18:38:27+00:00, user@local.host: AddPomodoro(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"1\", \"tracker\")\n585, 2025-05-19 18:38:27.010000+00:00, user@local.host: StartTimer(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"\")\n586, 2025-05-19 18:50:09.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n587, 2025-05-19 18:50:53.010000+00:00, user@local.host: AddPomodoro(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"1\", \"tracker\")\n588, 2025-05-19 18:50:53.020000+00:00, user@local.host: StartTimer(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"\")\n589, 2025-05-19 19:01:42.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n590, 2025-05-19 19:02:53.020000+00:00, user@local.host: AddPomodoro(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"1\", \"tracker\")\n591, 2025-05-19 19:02:53.030000+00:00, user@local.host: StartTimer(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"\")\n592, 2025-05-19 19:13:37.030000+00:00, user@local.host: CompleteWorkitem(\"798ef11b-b7e0-4b42-96e9-9f56c312f747\", \"finished\")\n593, 2025-05-19 19:14:09.030000+00:00, user@local.host: RenameWorkitem(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"Plan website for #Omega\")\n594, 2025-05-19 19:14:47.030000+00:00, user@local.host: StartTimer(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"1500\", \"300\")\n595, 2025-05-19 19:46:01.030000+00:00, user@local.host: StartTimer(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"1500\", \"300\")\n596, 2025-05-19 20:00:39.030000+00:00, user@local.host: AddInterruption(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"An interruption\", \"439.0\")\n597, 2025-05-19 20:31:37.030000+00:00, user@local.host: AddPomodoro(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"1\", \"tracker\")\n598, 2025-05-19 20:31:37.040000+00:00, user@local.host: StartTimer(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"\")\n599, 2025-05-19 20:41:12.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n600, 2025-05-19 20:42:07.040000+00:00, user@local.host: AddPomodoro(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"1\", \"tracker\")\n601, 2025-05-19 20:42:07.050000+00:00, user@local.host: StartTimer(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"\")\n602, 2025-05-19 20:51:58.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n603, 2025-05-19 20:53:42.050000+00:00, user@local.host: StartTimer(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"1500\", \"0\")\n604, 2025-05-19 21:31:57.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n605, 2025-05-19 21:32:05.050000+00:00, user@local.host: AddPomodoro(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"1\", \"normal\")\n606, 2025-05-19 21:32:38.050000+00:00, user@local.host: StartTimer(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"1500\", \"300\")\n607, 2025-05-19 21:51:12.050000+00:00, user@local.host: AddInterruption(\"e05ea36a-7013-44b4-959c-24df01e50b06\", \"\", \"\")\n608, 2025-05-19 22:22:16.050000+00:00, user@local.host: StartTimer(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1500\", \"300\")\n609, 2025-05-19 22:52:54.050000+00:00, user@local.host: RenameBacklog(\"42d95228-a140-4b38-b0d9-7c1d90268a93\", \"Template for #Gamma\")\n610, 2025-05-19 22:54:01.050000+00:00, user@local.host: StartTimer(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1500\", \"300\")\n611, 2025-05-19 23:24:09.050000+00:00, user@local.host: AddPomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\", \"normal\")\n612, 2025-05-19 23:24:42.050000+00:00, user@local.host: RemovePomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\")\n613, 2025-05-19 23:25:07.050000+00:00, user@local.host: RemovePomodoro(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"1\")\n614, 2025-05-19 23:25:52.050000+00:00, user@local.host: StartTimer(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1500\", \"0\")\n615, 2025-05-19 23:43:23.050000+00:00, user@local.host: AddInterruption(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"Voided for a good reason\", \"\")\n616, 2025-05-19 23:43:23.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n617, 2025-05-19 23:44:44.050000+00:00, user@local.host: StartTimer(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1500\", \"0\")\n618, 2025-05-20 00:23:22.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n619, 2025-05-20 00:23:28.050000+00:00, user@local.host: AddPomodoro(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1\", \"normal\")\n620, 2025-05-20 00:23:59.050000+00:00, user@local.host: RemovePomodoro(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"1\")\n621, 2025-05-20 00:24:47.050000+00:00, user@local.host: CompleteWorkitem(\"8a62a3e9-f297-4049-b74a-7be505e77f87\", \"finished\")\n622, 2025-05-20 00:26:02.050000+00:00, user@local.host: CompleteWorkitem(\"bd3aa90b-1b11-4e7a-94ac-1281856ad351\", \"finished\")\n623, 2025-05-20 00:27:12.050000+00:00, user@local.host: CompleteWorkitem(\"98d16ca2-926b-4e7a-a756-2e8e6c859c0b\", \"finished\")\n624, 2025-05-20 00:28:05.050000+00:00, user@local.host: CompleteWorkitem(\"0bf9c9da-a350-4f97-8b75-f80c62e72d56\", \"finished\")\n625, 2025-05-20 00:29:14.050000+00:00, user@local.host: CompleteWorkitem(\"8483a10b-26e7-43a7-8b0a-a641442b680d\", \"finished\")\n626, 2025-05-20 10:29:23+00:00, user@local.host: CreateBacklog(\"41edb839-2529-4b98-b846-e4765937d532\", \"2025-05-20, Tuesday\")\n627, 2025-05-20 10:30:04+00:00, user@local.host: CreateWorkitem(\"17a51ac3-de2a-4eef-a5cf-d84a61e16a1d\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Plan architecture for #Gamma\")\n628, 2025-05-20 10:30:09+00:00, user@local.host: AddPomodoro(\"17a51ac3-de2a-4eef-a5cf-d84a61e16a1d\", \"1\", \"normal\")\n629, 2025-05-20 10:30:44+00:00, user@local.host: CreateWorkitem(\"7e390905-2a94-4d13-9958-6073b6c91b2f\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Plan script for #Delta\")\n630, 2025-05-20 10:30:52+00:00, user@local.host: AddPomodoro(\"7e390905-2a94-4d13-9958-6073b6c91b2f\", \"1\", \"normal\")\n631, 2025-05-20 10:31:15+00:00, user@local.host: CreateWorkitem(\"61316698-c953-4486-90c8-4432b9c579b7\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Explain screenshot for #Alpha\")\n632, 2025-05-20 10:31:38+00:00, user@local.host: CreateWorkitem(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Draw script for #Omega\")\n633, 2025-05-20 10:32:02+00:00, user@local.host: CreateWorkitem(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Plan function for #Delta\")\n634, 2025-05-20 10:32:09+00:00, user@local.host: AddPomodoro(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"1\", \"normal\")\n635, 2025-05-20 10:32:17+00:00, user@local.host: AddPomodoro(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"1\", \"normal\")\n636, 2025-05-20 10:32:55+00:00, user@local.host: CreateWorkitem(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"41edb839-2529-4b98-b846-e4765937d532\", \"Deprecate function for #Beta\")\n637, 2025-05-20 10:33:37+00:00, user@local.host: AddPomodoro(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"1\", \"tracker\")\n638, 2025-05-20 10:33:37.010000+00:00, user@local.host: StartTimer(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"\")\n639, 2025-05-20 10:44:07.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n640, 2025-05-20 10:45:02.010000+00:00, user@local.host: AddPomodoro(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"1\", \"tracker\")\n641, 2025-05-20 10:45:02.020000+00:00, user@local.host: StartTimer(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"\")\n642, 2025-05-20 10:55:43.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n643, 2025-05-20 10:56:03.020000+00:00, user@local.host: RenameWorkitem(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"Plan script for #Beta\")\n644, 2025-05-20 10:56:58.020000+00:00, user@local.host: AddPomodoro(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"1\", \"tracker\")\n645, 2025-05-20 10:56:58.030000+00:00, user@local.host: StartTimer(\"53b995b1-4b9e-41b0-b7d0-56ffd8533c1f\", \"\")\n646, 2025-05-20 11:03:44.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n647, 2025-05-20 11:04:40.030000+00:00, user@local.host: StartTimer(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"1500\", \"300\")\n648, 2025-05-20 11:09:07.030000+00:00, user@local.host: AddInterruption(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"\", \"\")\n649, 2025-05-20 11:40:10.030000+00:00, user@local.host: StartTimer(\"51de5463-c0e4-4bd3-969a-19b8e737edb2\", \"1500\", \"300\")\n650, 2025-05-20 12:11:01.030000+00:00, user@local.host: AddPomodoro(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"1\", \"tracker\")\n651, 2025-05-20 12:11:01.040000+00:00, user@local.host: StartTimer(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"\")\n652, 2025-05-20 12:19:30.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n653, 2025-05-20 12:19:52.040000+00:00, user@local.host: AddPomodoro(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"1\", \"tracker\")\n654, 2025-05-20 12:19:52.050000+00:00, user@local.host: StartTimer(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"\")\n655, 2025-05-20 12:30:09.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n656, 2025-05-20 12:30:41.050000+00:00, user@local.host: RenameWorkitem(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"Verify email for #Gamma\")\n657, 2025-05-20 12:31:17.050000+00:00, user@local.host: AddPomodoro(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"1\", \"tracker\")\n658, 2025-05-20 12:31:17.060000+00:00, user@local.host: StartTimer(\"f0b7509e-8334-4e2a-8380-c69966055d38\", \"\")\n659, 2025-05-20 12:44:14.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n660, 2025-05-20 12:45:08.060000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n661, 2025-05-20 12:45:08.070000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n662, 2025-05-20 12:57:29.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n663, 2025-05-20 12:58:15.070000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n664, 2025-05-20 12:58:15.080000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n665, 2025-05-20 13:11:45.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n666, 2025-05-20 13:12:19.080000+00:00, user@local.host: RenameWorkitem(\"61316698-c953-4486-90c8-4432b9c579b7\", \"Verify script for #Alpha\")\n667, 2025-05-20 13:13:15.080000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n668, 2025-05-20 13:13:15.090000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n669, 2025-05-20 13:26:12.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n670, 2025-05-20 13:27:23.090000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n671, 2025-05-20 13:27:23.100000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n672, 2025-05-20 13:34:07.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n673, 2025-05-20 13:35:21.100000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n674, 2025-05-20 13:35:21.110000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n675, 2025-05-20 13:47:19.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n676, 2025-05-20 13:48:16.110000+00:00, user@local.host: AddPomodoro(\"61316698-c953-4486-90c8-4432b9c579b7\", \"1\", \"tracker\")\n677, 2025-05-20 13:48:16.120000+00:00, user@local.host: StartTimer(\"61316698-c953-4486-90c8-4432b9c579b7\", \"\")\n678, 2025-05-20 14:01:41.120000+00:00, user@local.host: StopTimer(\"\", \"\")\n679, 2025-05-20 14:02:09.120000+00:00, user@local.host: RenameWorkitem(\"7e390905-2a94-4d13-9958-6073b6c91b2f\", \"Check bug for #Omega\")\n680, 2025-05-20 14:02:40.120000+00:00, user@local.host: DeleteBacklog(\"41edb839-2529-4b98-b846-e4765937d532\", \"\")\n681, 2025-05-21 18:18:25+00:00, user@local.host: CreateBacklog(\"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"2025-05-21, Wednesday\")\n682, 2025-05-21 18:19:02+00:00, user@local.host: CreateWorkitem(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"Fix design for #Gamma\")\n683, 2025-05-21 18:19:09+00:00, user@local.host: AddPomodoro(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1\", \"normal\")\n684, 2025-05-21 18:19:17+00:00, user@local.host: AddPomodoro(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1\", \"normal\")\n685, 2025-05-21 18:19:45+00:00, user@local.host: CreateWorkitem(\"6962eacc-5c88-40da-809b-69003418cf03\", \"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"Fix scheme for #Beta\")\n686, 2025-05-21 18:19:50+00:00, user@local.host: AddPomodoro(\"6962eacc-5c88-40da-809b-69003418cf03\", \"1\", \"normal\")\n687, 2025-05-21 18:19:56+00:00, user@local.host: AddPomodoro(\"6962eacc-5c88-40da-809b-69003418cf03\", \"1\", \"normal\")\n688, 2025-05-21 18:20:24+00:00, user@local.host: CreateWorkitem(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"Find script for #Alpha\")\n689, 2025-05-21 18:20:29+00:00, user@local.host: AddPomodoro(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"1\", \"normal\")\n690, 2025-05-21 18:20:33+00:00, user@local.host: AddPomodoro(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"1\", \"normal\")\n691, 2025-05-21 18:21:00+00:00, user@local.host: CreateWorkitem(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"Verify bug for #Gamma\")\n692, 2025-05-21 18:21:08+00:00, user@local.host: AddPomodoro(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"1\", \"normal\")\n693, 2025-05-21 18:21:13+00:00, user@local.host: AddPomodoro(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"1\", \"normal\")\n694, 2025-05-21 18:21:42+00:00, user@local.host: CreateWorkitem(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"ce93ebfd-69b2-48b0-9501-09a78d903ef8\", \"Explain documentation for #Omega\")\n695, 2025-05-21 18:22:41+00:00, user@local.host: AddPomodoro(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"1\", \"tracker\")\n696, 2025-05-21 18:22:41.010000+00:00, user@local.host: StartTimer(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"\")\n697, 2025-05-21 18:28:49.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n698, 2025-05-21 18:29:37.010000+00:00, user@local.host: AddPomodoro(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"1\", \"tracker\")\n699, 2025-05-21 18:29:37.020000+00:00, user@local.host: StartTimer(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"\")\n700, 2025-05-21 18:38:25.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n701, 2025-05-21 18:39:13.020000+00:00, user@local.host: StartTimer(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"1500\", \"300\")\n702, 2025-05-21 18:59:04.020000+00:00, user@local.host: AddInterruption(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"Pomodoro voided\", \"\")\n703, 2025-05-21 18:59:04.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n704, 2025-05-21 19:00:12.020000+00:00, user@local.host: StartTimer(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"1500\", \"300\")\n705, 2025-05-21 19:31:18.020000+00:00, user@local.host: StartTimer(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"1500\", \"300\")\n706, 2025-05-21 20:02:17.020000+00:00, user@local.host: StartTimer(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"1500\", \"300\")\n707, 2025-05-21 20:32:47.020000+00:00, user@local.host: RenameWorkitem(\"6962eacc-5c88-40da-809b-69003418cf03\", \"Request email for #Omega\")\n708, 2025-05-21 20:33:46.020000+00:00, user@local.host: StartTimer(\"6962eacc-5c88-40da-809b-69003418cf03\", \"1500\", \"0\")\n709, 2025-05-21 21:01:53.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n710, 2025-05-21 21:02:59.020000+00:00, user@local.host: StartTimer(\"6962eacc-5c88-40da-809b-69003418cf03\", \"1500\", \"300\")\n711, 2025-05-21 21:34:27.020000+00:00, user@local.host: StartTimer(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1500\", \"300\")\n712, 2025-05-21 22:04:34.020000+00:00, user@local.host: AddPomodoro(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1\", \"normal\")\n713, 2025-05-21 22:05:39.020000+00:00, user@local.host: StartTimer(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1500\", \"300\")\n714, 2025-05-21 22:35:44.020000+00:00, user@local.host: AddPomodoro(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1\", \"normal\")\n715, 2025-05-21 22:36:33.020000+00:00, user@local.host: StartTimer(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1500\", \"0\")\n716, 2025-05-21 22:50:11.020000+00:00, user@local.host: AddInterruption(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"Pomodoro voided\", \"\")\n717, 2025-05-21 22:50:11.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n718, 2025-05-21 22:51:12.020000+00:00, user@local.host: StartTimer(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"1500\", \"0\")\n719, 2025-05-21 23:32:42.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n720, 2025-05-21 23:33:27.020000+00:00, user@local.host: CompleteWorkitem(\"c459bfb2-bfd9-44be-ada8-5b8765bdabbf\", \"finished\")\n721, 2025-05-21 23:34:43.020000+00:00, user@local.host: CompleteWorkitem(\"5f5a1b1b-5489-42fe-9f5c-7cf4fd6e949d\", \"finished\")\n722, 2025-05-21 23:36:08.020000+00:00, user@local.host: CompleteWorkitem(\"a70c47e0-3368-4348-a658-3f4c0b5f6a9f\", \"finished\")\n723, 2025-05-21 23:37:13.020000+00:00, user@local.host: CompleteWorkitem(\"6bd534eb-5f7d-465b-b154-e719e5812cb6\", \"finished\")\n724, 2025-05-22 14:53:29+00:00, user@local.host: CreateBacklog(\"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"2025-05-22, Thursday\")\n725, 2025-05-22 14:53:48+00:00, user@local.host: CreateWorkitem(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Plan website for #Delta\")\n726, 2025-05-22 14:53:56+00:00, user@local.host: AddPomodoro(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1\", \"normal\")\n727, 2025-05-22 14:54:17+00:00, user@local.host: CreateWorkitem(\"a05461de-8c4f-47e1-ac8d-b529ca98554d\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Generate website for #Alpha\")\n728, 2025-05-22 14:54:23+00:00, user@local.host: AddPomodoro(\"a05461de-8c4f-47e1-ac8d-b529ca98554d\", \"1\", \"normal\")\n729, 2025-05-22 14:55:15+00:00, user@local.host: CreateWorkitem(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Deprecate architecture for #Delta\")\n730, 2025-05-22 14:55:23+00:00, user@local.host: AddPomodoro(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"1\", \"normal\")\n731, 2025-05-22 14:55:29+00:00, user@local.host: AddPomodoro(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"1\", \"normal\")\n732, 2025-05-22 14:55:57+00:00, user@local.host: CreateWorkitem(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Request design for #Omega\")\n733, 2025-05-22 14:56:23+00:00, user@local.host: CreateWorkitem(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Request new feature for #Delta\")\n734, 2025-05-22 14:56:29+00:00, user@local.host: AddPomodoro(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"1\", \"normal\")\n735, 2025-05-22 14:56:34+00:00, user@local.host: AddPomodoro(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"1\", \"normal\")\n736, 2025-05-22 14:56:57+00:00, user@local.host: CreateWorkitem(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"23b8dae7-3fdd-47a1-bad1-f0d54d400e6c\", \"Check screenshot for #Alpha\")\n737, 2025-05-22 14:57:02+00:00, user@local.host: AddPomodoro(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"1\", \"normal\")\n738, 2025-05-22 14:57:08+00:00, user@local.host: AddPomodoro(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"1\", \"normal\")\n739, 2025-05-22 14:58:01+00:00, user@local.host: StartTimer(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"1500\", \"300\")\n740, 2025-05-22 15:28:06+00:00, user@local.host: AddPomodoro(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"1\", \"normal\")\n741, 2025-05-22 15:28:47+00:00, user@local.host: RemovePomodoro(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"1\")\n742, 2025-05-22 15:29:14+00:00, user@local.host: RenameWorkitem(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"Find script for #Delta\")\n743, 2025-05-22 15:30:29+00:00, user@local.host: StartTimer(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"1500\", \"300\")\n744, 2025-05-22 16:01:55+00:00, user@local.host: AddPomodoro(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"1\", \"tracker\")\n745, 2025-05-22 16:01:55.010000+00:00, user@local.host: StartTimer(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"\")\n746, 2025-05-22 16:12:23.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n747, 2025-05-22 16:13:49.010000+00:00, user@local.host: AddPomodoro(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"1\", \"tracker\")\n748, 2025-05-22 16:13:49.020000+00:00, user@local.host: StartTimer(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"\")\n749, 2025-05-22 16:22:17.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n750, 2025-05-22 16:23:24.020000+00:00, user@local.host: AddPomodoro(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"1\", \"tracker\")\n751, 2025-05-22 16:23:24.030000+00:00, user@local.host: StartTimer(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"\")\n752, 2025-05-22 16:34:46.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n753, 2025-05-22 16:35:51.030000+00:00, user@local.host: StartTimer(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"1500\", \"300\")\n754, 2025-05-22 16:47:58.030000+00:00, user@local.host: AddInterruption(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"An interruption\", \"\")\n755, 2025-05-22 17:18:36.030000+00:00, user@local.host: RemovePomodoro(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"1\")\n756, 2025-05-22 17:19:16.030000+00:00, user@local.host: RenameWorkitem(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"Request architecture for #Omega\")\n757, 2025-05-22 17:20:26.030000+00:00, user@local.host: StartTimer(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1500\", \"0\")\n758, 2025-05-22 18:19:41.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n759, 2025-05-22 18:19:46.030000+00:00, user@local.host: AddPomodoro(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1\", \"normal\")\n760, 2025-05-22 18:20:32.030000+00:00, user@local.host: StartTimer(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1500\", \"300\")\n761, 2025-05-22 18:50:38.030000+00:00, user@local.host: AddPomodoro(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1\", \"normal\")\n762, 2025-05-22 18:51:35.030000+00:00, user@local.host: StartTimer(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"1500\", \"300\")\n763, 2025-05-22 19:22:29.030000+00:00, user@local.host: CompleteWorkitem(\"b6019c40-a044-46bc-a8fb-73c23f43ffd2\", \"finished\")\n764, 2025-05-22 19:23:28.030000+00:00, user@local.host: CompleteWorkitem(\"08083657-8bea-461b-ac89-b3f07f2bca52\", \"finished\")\n765, 2025-05-22 19:24:52.030000+00:00, user@local.host: CompleteWorkitem(\"602ecec1-c6e4-4f56-8460-8ac4d7208c1a\", \"finished\")\n766, 2025-05-22 19:26:10.030000+00:00, user@local.host: CompleteWorkitem(\"5e435829-de78-443a-90a2-03f8990a0b06\", \"finished\")\n767, 2025-05-22 19:27:17.030000+00:00, user@local.host: CompleteWorkitem(\"af17b9d2-0ed8-4f7c-944f-2fbe0a15f02b\", \"finished\")\n768, 2025-05-22 19:28:17.030000+00:00, user@local.host: CompleteWorkitem(\"a05461de-8c4f-47e1-ac8d-b529ca98554d\", \"finished\")\n769, 2025-05-23 14:10:36+00:00, user@local.host: CreateBacklog(\"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"2025-05-23, Friday\")\n770, 2025-05-23 14:11:03+00:00, user@local.host: CreateWorkitem(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Generate scheme for #Alpha\")\n771, 2025-05-23 14:11:44+00:00, user@local.host: CreateWorkitem(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Explain bug for #Beta\")\n772, 2025-05-23 14:12:26+00:00, user@local.host: CreateWorkitem(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Draw design for #Delta\")\n773, 2025-05-23 14:13:02+00:00, user@local.host: CreateWorkitem(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Plan code for #Beta\")\n774, 2025-05-23 14:13:10+00:00, user@local.host: AddPomodoro(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"1\", \"normal\")\n775, 2025-05-23 14:13:18+00:00, user@local.host: AddPomodoro(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"1\", \"normal\")\n776, 2025-05-23 14:13:43+00:00, user@local.host: CreateWorkitem(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Send automation for #Gamma\")\n777, 2025-05-23 14:13:49+00:00, user@local.host: AddPomodoro(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1\", \"normal\")\n778, 2025-05-23 14:13:55+00:00, user@local.host: AddPomodoro(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1\", \"normal\")\n779, 2025-05-23 14:14:01+00:00, user@local.host: AddPomodoro(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1\", \"normal\")\n780, 2025-05-23 14:14:20+00:00, user@local.host: CreateWorkitem(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Check new feature for #Alpha\")\n781, 2025-05-23 14:14:44+00:00, user@local.host: CreateWorkitem(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Explore automation for #Omega\")\n782, 2025-05-23 14:15:05+00:00, user@local.host: CreateWorkitem(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"72afcb2d-bdc1-41c4-be77-e9694f966fb7\", \"Explore architecture for #Alpha\")\n783, 2025-05-23 14:15:12+00:00, user@local.host: AddPomodoro(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1\", \"normal\")\n784, 2025-05-23 14:15:18+00:00, user@local.host: AddPomodoro(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1\", \"normal\")\n785, 2025-05-23 14:15:28+00:00, user@local.host: AddPomodoro(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1\", \"normal\")\n786, 2025-05-23 14:16:32+00:00, user@local.host: StartTimer(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1500\", \"300\")\n787, 2025-05-23 14:47:47+00:00, user@local.host: StartTimer(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1500\", \"300\")\n788, 2025-05-23 15:18:21+00:00, user@local.host: RenameWorkitem(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"Plan website for #Gamma\")\n789, 2025-05-23 15:19:24+00:00, user@local.host: StartTimer(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1500\", \"300\")\n790, 2025-05-23 15:49:29+00:00, user@local.host: AddPomodoro(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1\", \"normal\")\n791, 2025-05-23 15:50:52+00:00, user@local.host: StartTimer(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"1500\", \"0\")\n792, 2025-05-23 16:50:43+00:00, user@local.host: StopTimer(\"\", \"\")\n793, 2025-05-23 16:51:25+00:00, user@local.host: RenameWorkitem(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"Verify code for #Beta\")\n794, 2025-05-23 16:52:44+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n795, 2025-05-23 16:52:44.010000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n796, 2025-05-23 17:06:03.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n797, 2025-05-23 17:07:02.010000+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n798, 2025-05-23 17:07:02.020000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n799, 2025-05-23 17:16:11.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n800, 2025-05-23 17:17:26.020000+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n801, 2025-05-23 17:17:26.030000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n802, 2025-05-23 17:23:30.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n803, 2025-05-23 17:24:48.030000+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n804, 2025-05-23 17:24:48.040000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n805, 2025-05-23 17:36:12.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n806, 2025-05-23 17:37:04.040000+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n807, 2025-05-23 17:37:04.050000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n808, 2025-05-23 17:49:57.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n809, 2025-05-23 17:50:31.050000+00:00, user@local.host: RenameWorkitem(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"Verify email for #Alpha\")\n810, 2025-05-23 17:51:20.050000+00:00, user@local.host: AddPomodoro(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"1\", \"tracker\")\n811, 2025-05-23 17:51:20.060000+00:00, user@local.host: StartTimer(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n812, 2025-05-23 18:02:10.060000+00:00, user@local.host: DeleteWorkitem(\"d71c0c7e-299c-4b95-ad29-ca561a3e4a69\", \"\")\n813, 2025-05-23 18:03:34.060000+00:00, user@local.host: AddPomodoro(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"1\", \"tracker\")\n814, 2025-05-23 18:03:34.070000+00:00, user@local.host: StartTimer(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"\")\n815, 2025-05-23 18:15:33.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n816, 2025-05-23 18:16:40.070000+00:00, user@local.host: AddPomodoro(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"1\", \"tracker\")\n817, 2025-05-23 18:16:40.080000+00:00, user@local.host: StartTimer(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"\")\n818, 2025-05-23 18:25:30.080000+00:00, user@local.host: DeleteWorkitem(\"d7e70a61-e24e-4c6b-874f-f3c1cdf1d360\", \"\")\n819, 2025-05-23 18:26:07.080000+00:00, user@local.host: RemovePomodoro(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1\")\n820, 2025-05-23 18:27:22.080000+00:00, user@local.host: StartTimer(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1500\", \"300\")\n821, 2025-05-23 18:43:59.080000+00:00, user@local.host: AddInterruption(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"Pomodoro voided\", \"\")\n822, 2025-05-23 18:43:59.080000+00:00, user@local.host: StopTimer(\"\", \"\")\n823, 2025-05-23 18:44:32.080000+00:00, user@local.host: RemovePomodoro(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"1\")\n824, 2025-05-23 18:44:55.080000+00:00, user@local.host: RemovePomodoro(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"1\")\n825, 2025-05-23 18:45:29.080000+00:00, user@local.host: RenameWorkitem(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"Fix automation for #Beta\")\n826, 2025-05-23 18:47:07.080000+00:00, user@local.host: StartTimer(\"410ace38-77ff-4f0d-a826-b1020f4010b9\", \"1500\", \"300\")\n827, 2025-05-23 19:17:48.080000+00:00, user@local.host: AddPomodoro(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"1\", \"tracker\")\n828, 2025-05-23 19:17:48.090000+00:00, user@local.host: StartTimer(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"\")\n829, 2025-05-23 19:22:39.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n830, 2025-05-23 19:23:35.090000+00:00, user@local.host: AddPomodoro(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"1\", \"tracker\")\n831, 2025-05-23 19:23:35.100000+00:00, user@local.host: StartTimer(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"\")\n832, 2025-05-23 19:31:39.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n833, 2025-05-23 19:32:47.100000+00:00, user@local.host: AddPomodoro(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"1\", \"tracker\")\n834, 2025-05-23 19:32:47.110000+00:00, user@local.host: StartTimer(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"\")\n835, 2025-05-23 19:43:30.110000+00:00, user@local.host: CompleteWorkitem(\"51864f25-d8a2-4170-a717-e5f46a2cf40a\", \"finished\")\n836, 2025-05-23 19:44:59.110000+00:00, user@local.host: AddPomodoro(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"1\", \"tracker\")\n837, 2025-05-23 19:44:59.120000+00:00, user@local.host: StartTimer(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"\")\n838, 2025-05-23 19:54:31.120000+00:00, user@local.host: StopTimer(\"\", \"\")\n839, 2025-05-23 19:55:38.120000+00:00, user@local.host: AddPomodoro(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"1\", \"tracker\")\n840, 2025-05-23 19:55:38.130000+00:00, user@local.host: StartTimer(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"\")\n841, 2025-05-23 20:08:27.130000+00:00, user@local.host: StopTimer(\"\", \"\")\n842, 2025-05-23 20:09:31.130000+00:00, user@local.host: AddPomodoro(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"1\", \"tracker\")\n843, 2025-05-23 20:09:31.140000+00:00, user@local.host: StartTimer(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"\")\n844, 2025-05-23 20:21:58.140000+00:00, user@local.host: StopTimer(\"\", \"\")\n845, 2025-05-23 20:22:30.140000+00:00, user@local.host: RenameWorkitem(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"Fix email for #Alpha\")\n846, 2025-05-23 20:23:26.140000+00:00, user@local.host: AddPomodoro(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"1\", \"tracker\")\n847, 2025-05-23 20:23:26.150000+00:00, user@local.host: StartTimer(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"\")\n848, 2025-05-23 20:33:27.150000+00:00, user@local.host: StopTimer(\"\", \"\")\n849, 2025-05-23 20:34:46.150000+00:00, user@local.host: AddPomodoro(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"1\", \"tracker\")\n850, 2025-05-23 20:34:46.160000+00:00, user@local.host: StartTimer(\"92065277-d0e2-41f8-ae84-947145b3ed31\", \"\")\n851, 2025-05-23 20:43:56.160000+00:00, user@local.host: StopTimer(\"\", \"\")\n852, 2025-05-23 20:44:25.160000+00:00, user@local.host: RenameWorkitem(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"Explain tool for #Beta\")\n853, 2025-05-23 20:45:34.160000+00:00, user@local.host: AddPomodoro(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"1\", \"tracker\")\n854, 2025-05-23 20:45:34.170000+00:00, user@local.host: StartTimer(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"\")\n855, 2025-05-23 20:54:28.170000+00:00, user@local.host: StopTimer(\"\", \"\")\n856, 2025-05-23 20:55:30.170000+00:00, user@local.host: AddPomodoro(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"1\", \"tracker\")\n857, 2025-05-23 20:55:30.180000+00:00, user@local.host: StartTimer(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"\")\n858, 2025-05-23 21:03:52.180000+00:00, user@local.host: StopTimer(\"\", \"\")\n859, 2025-05-23 21:04:59.180000+00:00, user@local.host: AddPomodoro(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"1\", \"tracker\")\n860, 2025-05-23 21:04:59.190000+00:00, user@local.host: StartTimer(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"\")\n861, 2025-05-23 21:14:10.190000+00:00, user@local.host: StopTimer(\"\", \"\")\n862, 2025-05-23 21:15:07.190000+00:00, user@local.host: AddPomodoro(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"1\", \"tracker\")\n863, 2025-05-23 21:15:07.200000+00:00, user@local.host: StartTimer(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"\")\n864, 2025-05-23 21:21:21.200000+00:00, user@local.host: StopTimer(\"\", \"\")\n865, 2025-05-23 21:22:09.200000+00:00, user@local.host: AddPomodoro(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"1\", \"tracker\")\n866, 2025-05-23 21:22:09.210000+00:00, user@local.host: StartTimer(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"\")\n867, 2025-05-23 21:27:28.210000+00:00, user@local.host: StopTimer(\"\", \"\")\n868, 2025-05-23 21:28:24.210000+00:00, user@local.host: CompleteWorkitem(\"ef97a600-593c-464c-8999-a39a42c55b45\", \"finished\")\n869, 2025-05-23 21:29:27.210000+00:00, user@local.host: CompleteWorkitem(\"795afa16-549d-4d50-bc9e-634cc5ff7b4e\", \"finished\")\n870, 2025-05-23 21:30:27.210000+00:00, user@local.host: CompleteWorkitem(\"fbcb1480-4acc-426d-b7cf-1cd34202e793\", \"finished\")\n871, 2025-05-26 11:48:39+00:00, user@local.host: CreateBacklog(\"c15fa70b-1040-4961-8b36-69218f730029\", \"2025-05-26, Monday\")\n872, 2025-05-26 11:49:21+00:00, user@local.host: CreateWorkitem(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"c15fa70b-1040-4961-8b36-69218f730029\", \"Document tool for #Gamma\")\n873, 2025-05-26 11:49:28+00:00, user@local.host: AddPomodoro(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1\", \"normal\")\n874, 2025-05-26 11:49:35+00:00, user@local.host: AddPomodoro(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1\", \"normal\")\n875, 2025-05-26 11:50:10+00:00, user@local.host: CreateWorkitem(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"c15fa70b-1040-4961-8b36-69218f730029\", \"Verify website for #Delta\")\n876, 2025-05-26 11:50:17+00:00, user@local.host: AddPomodoro(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1\", \"normal\")\n877, 2025-05-26 11:50:23+00:00, user@local.host: AddPomodoro(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1\", \"normal\")\n878, 2025-05-26 11:50:30+00:00, user@local.host: AddPomodoro(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1\", \"normal\")\n879, 2025-05-26 11:50:57+00:00, user@local.host: CreateWorkitem(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"c15fa70b-1040-4961-8b36-69218f730029\", \"Draw code for #Omega\")\n880, 2025-05-26 11:51:01+00:00, user@local.host: AddPomodoro(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"1\", \"normal\")\n881, 2025-05-26 11:51:05+00:00, user@local.host: AddPomodoro(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"1\", \"normal\")\n882, 2025-05-26 11:51:51+00:00, user@local.host: CreateWorkitem(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"c15fa70b-1040-4961-8b36-69218f730029\", \"Explain automation for #Omega\")\n883, 2025-05-26 11:51:54+00:00, user@local.host: AddPomodoro(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"1\", \"normal\")\n884, 2025-05-26 11:52:01+00:00, user@local.host: AddPomodoro(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"1\", \"normal\")\n885, 2025-05-26 11:52:26+00:00, user@local.host: CreateWorkitem(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"c15fa70b-1040-4961-8b36-69218f730029\", \"Think about idea for #Alpha\")\n886, 2025-05-26 11:52:29+00:00, user@local.host: AddPomodoro(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"1\", \"normal\")\n887, 2025-05-26 11:52:32+00:00, user@local.host: AddPomodoro(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"1\", \"normal\")\n888, 2025-05-26 11:54:00+00:00, user@local.host: StartTimer(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"1500\", \"300\")\n889, 2025-05-26 12:08:32+00:00, user@local.host: AddInterruption(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"Pomodoro voided\", \"\")\n890, 2025-05-26 12:08:32+00:00, user@local.host: StopTimer(\"\", \"\")\n891, 2025-05-26 12:08:53+00:00, user@local.host: RenameWorkitem(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"Create bug for #Omega\")\n892, 2025-05-26 12:09:12+00:00, user@local.host: StartTimer(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"1500\", \"300\")\n893, 2025-05-26 12:22:45+00:00, user@local.host: AddInterruption(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"\", \"406.5\")\n894, 2025-05-26 12:53:46+00:00, user@local.host: StartTimer(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"1500\", \"300\")\n895, 2025-05-26 13:05:51+00:00, user@local.host: AddInterruption(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"\", \"362.5\")\n896, 2025-05-26 13:36:11+00:00, user@local.host: StartTimer(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"1500\", \"300\")\n897, 2025-05-26 13:53:43+00:00, user@local.host: AddInterruption(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"Voided for a good reason\", \"\")\n898, 2025-05-26 13:53:43+00:00, user@local.host: StopTimer(\"\", \"\")\n899, 2025-05-26 13:54:32+00:00, user@local.host: StartTimer(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"1500\", \"300\")\n900, 2025-05-26 14:24:52+00:00, user@local.host: StartTimer(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"1500\", \"0\")\n901, 2025-05-26 15:05:28+00:00, user@local.host: StopTimer(\"\", \"\")\n902, 2025-05-26 15:06:25+00:00, user@local.host: StartTimer(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1500\", \"300\")\n903, 2025-05-26 15:24:10+00:00, user@local.host: AddInterruption(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"Voided for a good reason\", \"\")\n904, 2025-05-26 15:24:10+00:00, user@local.host: StopTimer(\"\", \"\")\n905, 2025-05-26 15:25:17+00:00, user@local.host: StartTimer(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1500\", \"300\")\n906, 2025-05-26 15:55:34+00:00, user@local.host: RemovePomodoro(\"609d3d76-1a58-439a-b4a9-42ac2e4683a0\", \"1\")\n907, 2025-05-26 15:56:07+00:00, user@local.host: RenameWorkitem(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"Find idea for #Beta\")\n908, 2025-05-26 15:57:16+00:00, user@local.host: StartTimer(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1500\", \"300\")\n909, 2025-05-26 16:28:15+00:00, user@local.host: StartTimer(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1500\", \"300\")\n910, 2025-05-26 16:58:20+00:00, user@local.host: AddPomodoro(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1\", \"normal\")\n911, 2025-05-26 16:59:27+00:00, user@local.host: StartTimer(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"1500\", \"0\")\n912, 2025-05-26 18:01:00+00:00, user@local.host: StopTimer(\"\", \"\")\n913, 2025-05-26 18:01:25+00:00, user@local.host: CompleteWorkitem(\"952a107c-409b-4b87-9b56-b7152689eae6\", \"finished\")\n914, 2025-05-26 18:02:36+00:00, user@local.host: CompleteWorkitem(\"6a24dc5e-311a-45e8-8746-f7aa43e4822b\", \"finished\")\n915, 2025-05-26 18:03:42+00:00, user@local.host: CompleteWorkitem(\"19feb1bd-fae8-49ce-b50c-de10f5dd6c54\", \"finished\")\n916, 2025-05-26 18:04:31+00:00, user@local.host: CompleteWorkitem(\"e42d4736-6450-411e-8a58-7b8b0f8898c6\", \"finished\")\n917, 2025-05-27 13:42:28+00:00, user@local.host: CreateBacklog(\"a9b28c10-81c4-42b4-9734-7d544b7ccba5\", \"2025-05-27, Tuesday\")\n918, 2025-05-27 13:43:10+00:00, user@local.host: CreateWorkitem(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"a9b28c10-81c4-42b4-9734-7d544b7ccba5\", \"Document new feature for #Alpha\")\n919, 2025-05-27 13:43:15+00:00, user@local.host: AddPomodoro(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"1\", \"normal\")\n920, 2025-05-27 13:43:21+00:00, user@local.host: AddPomodoro(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"1\", \"normal\")\n921, 2025-05-27 13:43:27+00:00, user@local.host: AddPomodoro(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"1\", \"normal\")\n922, 2025-05-27 13:44:06+00:00, user@local.host: CreateWorkitem(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"a9b28c10-81c4-42b4-9734-7d544b7ccba5\", \"Draw email for #Omega\")\n923, 2025-05-27 13:44:14+00:00, user@local.host: AddPomodoro(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"1\", \"normal\")\n924, 2025-05-27 13:44:19+00:00, user@local.host: AddPomodoro(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"1\", \"normal\")\n925, 2025-05-27 13:44:51+00:00, user@local.host: CreateWorkitem(\"8a374fea-1788-47a4-ad12-58178e38b3cb\", \"a9b28c10-81c4-42b4-9734-7d544b7ccba5\", \"Draw automation for #Delta\")\n926, 2025-05-27 13:44:56+00:00, user@local.host: AddPomodoro(\"8a374fea-1788-47a4-ad12-58178e38b3cb\", \"1\", \"normal\")\n927, 2025-05-27 13:45:02+00:00, user@local.host: AddPomodoro(\"8a374fea-1788-47a4-ad12-58178e38b3cb\", \"1\", \"normal\")\n928, 2025-05-27 13:45:42+00:00, user@local.host: CreateWorkitem(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"a9b28c10-81c4-42b4-9734-7d544b7ccba5\", \"Explain script for #Omega\")\n929, 2025-05-27 13:45:48+00:00, user@local.host: AddPomodoro(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1\", \"normal\")\n930, 2025-05-27 13:45:55+00:00, user@local.host: AddPomodoro(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1\", \"normal\")\n931, 2025-05-27 13:46:04+00:00, user@local.host: AddPomodoro(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1\", \"normal\")\n932, 2025-05-27 13:46:28+00:00, user@local.host: RenameWorkitem(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"Create bug for #Beta\")\n933, 2025-05-27 13:47:20+00:00, user@local.host: StartTimer(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1500\", \"300\")\n934, 2025-05-27 14:00:50+00:00, user@local.host: AddInterruption(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"Pomodoro voided\", \"\")\n935, 2025-05-27 14:00:50+00:00, user@local.host: StopTimer(\"\", \"\")\n936, 2025-05-27 14:01:12+00:00, user@local.host: RenameWorkitem(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"Think about tool for #Delta\")\n937, 2025-05-27 14:02:02+00:00, user@local.host: StartTimer(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1500\", \"300\")\n938, 2025-05-27 14:32:56+00:00, user@local.host: StartTimer(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"1500\", \"300\")\n939, 2025-05-27 14:48:57+00:00, user@local.host: AddInterruption(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"Pomodoro voided\", \"\")\n940, 2025-05-27 14:48:57+00:00, user@local.host: StopTimer(\"\", \"\")\n941, 2025-05-27 14:49:44+00:00, user@local.host: StartTimer(\"8a374fea-1788-47a4-ad12-58178e38b3cb\", \"1500\", \"300\")\n942, 2025-05-27 15:20:30+00:00, user@local.host: RenameWorkitem(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"Draw bug for #Delta\")\n943, 2025-05-27 15:21:34+00:00, user@local.host: StartTimer(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"1500\", \"300\")\n944, 2025-05-27 15:52:08+00:00, user@local.host: StartTimer(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"1500\", \"0\")\n945, 2025-05-27 16:07:41+00:00, user@local.host: AddInterruption(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"Voided for a good reason\", \"\")\n946, 2025-05-27 16:07:41+00:00, user@local.host: StopTimer(\"\", \"\")\n947, 2025-05-27 16:08:43+00:00, user@local.host: CompleteWorkitem(\"23cc4726-7afd-4754-8e63-2996738929bf\", \"finished\")\n948, 2025-05-27 16:09:53+00:00, user@local.host: CompleteWorkitem(\"8a374fea-1788-47a4-ad12-58178e38b3cb\", \"finished\")\n949, 2025-05-27 16:10:47+00:00, user@local.host: CompleteWorkitem(\"7e4068f5-6315-4e91-ac67-76a689d45397\", \"finished\")\n950, 2025-05-27 16:12:12+00:00, user@local.host: CompleteWorkitem(\"75e9667c-480c-4cf3-93da-b09cca095d50\", \"finished\")\n951, 2025-05-28 13:42:28+00:00, user@local.host: CreateBacklog(\"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"2025-05-28, Wednesday\")\n952, 2025-05-28 13:42:56+00:00, user@local.host: CreateWorkitem(\"60ad180f-fd02-41eb-8117-af4e7e688f14\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Find website for #Alpha\")\n953, 2025-05-28 13:43:04+00:00, user@local.host: AddPomodoro(\"60ad180f-fd02-41eb-8117-af4e7e688f14\", \"1\", \"normal\")\n954, 2025-05-28 13:43:28+00:00, user@local.host: CreateWorkitem(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Explore new feature for #Alpha\")\n955, 2025-05-28 13:43:33+00:00, user@local.host: AddPomodoro(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"1\", \"normal\")\n956, 2025-05-28 13:43:38+00:00, user@local.host: AddPomodoro(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"1\", \"normal\")\n957, 2025-05-28 13:44:10+00:00, user@local.host: CreateWorkitem(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Fix screenshot for #Omega\")\n958, 2025-05-28 13:44:14+00:00, user@local.host: AddPomodoro(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1\", \"normal\")\n959, 2025-05-28 13:44:18+00:00, user@local.host: AddPomodoro(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1\", \"normal\")\n960, 2025-05-28 13:44:39+00:00, user@local.host: CreateWorkitem(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Create bug for #Omega\")\n961, 2025-05-28 13:45:08+00:00, user@local.host: CreateWorkitem(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Fix new feature for #Gamma\")\n962, 2025-05-28 13:45:46+00:00, user@local.host: CreateWorkitem(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"4feceee6-b7ce-45f8-a0ad-8ba17002751f\", \"Create bug for #Delta\")\n963, 2025-05-28 13:47:06+00:00, user@local.host: AddPomodoro(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"1\", \"tracker\")\n964, 2025-05-28 13:47:06.010000+00:00, user@local.host: StartTimer(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"\")\n965, 2025-05-28 13:55:19.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n966, 2025-05-28 13:55:53.010000+00:00, user@local.host: RenameWorkitem(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"Find email for #Alpha\")\n967, 2025-05-28 13:57:03.010000+00:00, user@local.host: AddPomodoro(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"1\", \"tracker\")\n968, 2025-05-28 13:57:03.020000+00:00, user@local.host: StartTimer(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"\")\n969, 2025-05-28 14:06:41.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n970, 2025-05-28 14:07:47.020000+00:00, user@local.host: AddPomodoro(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"1\", \"tracker\")\n971, 2025-05-28 14:07:47.030000+00:00, user@local.host: StartTimer(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"\")\n972, 2025-05-28 14:21:46.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n973, 2025-05-28 14:22:39.030000+00:00, user@local.host: AddPomodoro(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"1\", \"tracker\")\n974, 2025-05-28 14:22:39.040000+00:00, user@local.host: StartTimer(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"\")\n975, 2025-05-28 14:33:00.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n976, 2025-05-28 14:33:50.040000+00:00, user@local.host: AddPomodoro(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"1\", \"tracker\")\n977, 2025-05-28 14:33:50.050000+00:00, user@local.host: StartTimer(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"\")\n978, 2025-05-28 14:42:07.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n979, 2025-05-28 14:42:18.050000+00:00, user@local.host: RenameWorkitem(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"Generate bug for #Delta\")\n980, 2025-05-28 14:43:26.050000+00:00, user@local.host: AddPomodoro(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"1\", \"tracker\")\n981, 2025-05-28 14:43:26.060000+00:00, user@local.host: StartTimer(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"\")\n982, 2025-05-28 14:55:31.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n983, 2025-05-28 14:56:10.060000+00:00, user@local.host: AddPomodoro(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"1\", \"tracker\")\n984, 2025-05-28 14:56:10.070000+00:00, user@local.host: StartTimer(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"\")\n985, 2025-05-28 15:06:58.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n986, 2025-05-28 15:07:28.070000+00:00, user@local.host: RenameWorkitem(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"Check tool for #Gamma\")\n987, 2025-05-28 15:08:38.070000+00:00, user@local.host: AddPomodoro(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"1\", \"tracker\")\n988, 2025-05-28 15:08:38.080000+00:00, user@local.host: StartTimer(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"\")\n989, 2025-05-28 15:17:09.080000+00:00, user@local.host: CompleteWorkitem(\"3fe527ec-d751-45a1-b870-c6d65b34c793\", \"finished\")\n990, 2025-05-28 15:17:42.080000+00:00, user@local.host: RenameWorkitem(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"Document new feature for #Omega\")\n991, 2025-05-28 15:18:42.080000+00:00, user@local.host: AddPomodoro(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"1\", \"tracker\")\n992, 2025-05-28 15:18:42.090000+00:00, user@local.host: StartTimer(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"\")\n993, 2025-05-28 15:26:45.090000+00:00, user@local.host: StopTimer(\"\", \"\")\n994, 2025-05-28 15:27:27.090000+00:00, user@local.host: AddPomodoro(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"1\", \"tracker\")\n995, 2025-05-28 15:27:27.100000+00:00, user@local.host: StartTimer(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"\")\n996, 2025-05-28 15:35:40.100000+00:00, user@local.host: StopTimer(\"\", \"\")\n997, 2025-05-28 15:37:02.100000+00:00, user@local.host: AddPomodoro(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"1\", \"tracker\")\n998, 2025-05-28 15:37:02.110000+00:00, user@local.host: StartTimer(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"\")\n999, 2025-05-28 15:48:56.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n1000, 2025-05-28 15:49:55.110000+00:00, user@local.host: StartTimer(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1500\", \"300\")\n1001, 2025-05-28 16:01:42.110000+00:00, user@local.host: AddInterruption(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"An interruption\", \"353.5\")\n1002, 2025-05-28 16:32:38.110000+00:00, user@local.host: StartTimer(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1500\", \"300\")\n1003, 2025-05-28 17:02:44.110000+00:00, user@local.host: AddPomodoro(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1\", \"normal\")\n1004, 2025-05-28 17:03:11.110000+00:00, user@local.host: RenameWorkitem(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"Verify documentation for #Omega\")\n1005, 2025-05-28 17:04:37.110000+00:00, user@local.host: StartTimer(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1500\", \"300\")\n1006, 2025-05-28 17:34:43.110000+00:00, user@local.host: AddPomodoro(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1\", \"normal\")\n1007, 2025-05-28 17:35:08.110000+00:00, user@local.host: RenameWorkitem(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"Request automation for #Beta\")\n1008, 2025-05-28 17:36:10.110000+00:00, user@local.host: StartTimer(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"1500\", \"0\")\n1009, 2025-05-28 18:18:20.110000+00:00, user@local.host: AddInterruption(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"An interruption\", \"1265.0\")\n1010, 2025-05-28 19:35:47.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n1011, 2025-05-28 19:36:52.110000+00:00, user@local.host: StartTimer(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"1500\", \"300\")\n1012, 2025-05-28 20:08:18.110000+00:00, user@local.host: StartTimer(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"1500\", \"300\")\n1013, 2025-05-28 20:25:34.110000+00:00, user@local.host: AddInterruption(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"Voided for a good reason\", \"\")\n1014, 2025-05-28 20:25:34.110000+00:00, user@local.host: StopTimer(\"\", \"\")\n1015, 2025-05-28 20:26:56.110000+00:00, user@local.host: StartTimer(\"60ad180f-fd02-41eb-8117-af4e7e688f14\", \"1500\", \"300\")\n1016, 2025-05-28 20:42:38.110000+00:00, user@local.host: AddInterruption(\"60ad180f-fd02-41eb-8117-af4e7e688f14\", \"An interruption\", \"471.0\")\n1017, 2025-05-28 21:13:46.110000+00:00, user@local.host: CompleteWorkitem(\"9d339fc0-75c1-4a3c-82e5-5b98bd2128f1\", \"finished\")\n1018, 2025-05-28 21:15:11.110000+00:00, user@local.host: CompleteWorkitem(\"fab4497a-599f-407e-910a-b5c544d3d1dc\", \"finished\")\n1019, 2025-05-28 21:15:58.110000+00:00, user@local.host: CompleteWorkitem(\"4b2ae63a-91af-4a75-8def-3964502be86d\", \"finished\")\n1020, 2025-05-28 21:16:58.110000+00:00, user@local.host: CompleteWorkitem(\"4ae7387c-371b-4efc-93dc-8f0cbd0e9caf\", \"finished\")\n1021, 2025-05-29 13:05:39+00:00, user@local.host: CreateBacklog(\"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"2025-05-29, Thursday\")\n1022, 2025-05-29 13:06:01+00:00, user@local.host: CreateWorkitem(\"2a1c3ec5-6c08-4801-8cba-964df98ff2fd\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Think about tool for #Omega\")\n1023, 2025-05-29 13:06:09+00:00, user@local.host: AddPomodoro(\"2a1c3ec5-6c08-4801-8cba-964df98ff2fd\", \"1\", \"normal\")\n1024, 2025-05-29 13:06:45+00:00, user@local.host: CreateWorkitem(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Think about design for #Delta\")\n1025, 2025-05-29 13:06:52+00:00, user@local.host: AddPomodoro(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1\", \"normal\")\n1026, 2025-05-29 13:07:00+00:00, user@local.host: AddPomodoro(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1\", \"normal\")\n1027, 2025-05-29 13:07:39+00:00, user@local.host: CreateWorkitem(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Draw tool for #Beta\")\n1028, 2025-05-29 13:07:44+00:00, user@local.host: AddPomodoro(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"1\", \"normal\")\n1029, 2025-05-29 13:08:07+00:00, user@local.host: CreateWorkitem(\"ab997673-6f52-4f52-9d27-22a565e2d09d\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Draw email for #Alpha\")\n1030, 2025-05-29 13:08:11+00:00, user@local.host: AddPomodoro(\"ab997673-6f52-4f52-9d27-22a565e2d09d\", \"1\", \"normal\")\n1031, 2025-05-29 13:08:48+00:00, user@local.host: CreateWorkitem(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Generate architecture for #Delta\")\n1032, 2025-05-29 13:09:29+00:00, user@local.host: CreateWorkitem(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Create code for #Omega\")\n1033, 2025-05-29 13:09:36+00:00, user@local.host: AddPomodoro(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"1\", \"normal\")\n1034, 2025-05-29 13:10:05+00:00, user@local.host: CreateWorkitem(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Plan architecture for #Delta\")\n1035, 2025-05-29 13:10:13+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1036, 2025-05-29 13:10:18+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1037, 2025-05-29 13:10:26+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1038, 2025-05-29 13:10:34+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1039, 2025-05-29 13:11:05+00:00, user@local.host: CreateWorkitem(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"7b1cfa64-9965-4eb5-83db-4ccffdbbbb88\", \"Document automation for #Delta\")\n1040, 2025-05-29 13:11:10+00:00, user@local.host: AddPomodoro(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1\", \"normal\")\n1041, 2025-05-29 13:11:17+00:00, user@local.host: AddPomodoro(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1\", \"normal\")\n1042, 2025-05-29 13:11:24+00:00, user@local.host: AddPomodoro(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1\", \"normal\")\n1043, 2025-05-29 13:11:58+00:00, user@local.host: RenameWorkitem(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"Fix screenshot for #Omega\")\n1044, 2025-05-29 13:13:23+00:00, user@local.host: StartTimer(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1500\", \"300\")\n1045, 2025-05-29 13:32:07+00:00, user@local.host: AddInterruption(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"\", \"\")\n1046, 2025-05-29 14:02:31+00:00, user@local.host: RenameWorkitem(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"Check tool for #Alpha\")\n1047, 2025-05-29 14:03:25+00:00, user@local.host: StartTimer(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1500\", \"300\")\n1048, 2025-05-29 14:34:44+00:00, user@local.host: StartTimer(\"fd280450-0aee-45f2-8c54-1f54725a10f7\", \"1500\", \"300\")\n1049, 2025-05-29 15:05:18+00:00, user@local.host: RemovePomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\")\n1050, 2025-05-29 15:06:26+00:00, user@local.host: StartTimer(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1500\", \"0\")\n1051, 2025-05-29 15:52:08+00:00, user@local.host: StopTimer(\"\", \"\")\n1052, 2025-05-29 15:52:12+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1053, 2025-05-29 15:53:00+00:00, user@local.host: StartTimer(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1500\", \"300\")\n1054, 2025-05-29 16:23:05+00:00, user@local.host: AddPomodoro(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1\", \"normal\")\n1055, 2025-05-29 16:24:19+00:00, user@local.host: StartTimer(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1500\", \"300\")\n1056, 2025-05-29 16:55:38+00:00, user@local.host: StartTimer(\"a9daccaa-1dd8-4996-9e9b-b5a3788d6dbb\", \"1500\", \"300\")\n1057, 2025-05-29 17:26:13+00:00, user@local.host: RenameWorkitem(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"Find screenshot for #Omega\")\n1058, 2025-05-29 17:26:57+00:00, user@local.host: StartTimer(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"1500\", \"0\")\n1059, 2025-05-29 17:45:08+00:00, user@local.host: AddInterruption(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"Pomodoro voided\", \"\")\n1060, 2025-05-29 17:45:08+00:00, user@local.host: StopTimer(\"\", \"\")\n1061, 2025-05-29 17:45:58+00:00, user@local.host: AddPomodoro(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"1\", \"tracker\")\n1062, 2025-05-29 17:45:58.010000+00:00, user@local.host: StartTimer(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"\")\n1063, 2025-05-29 17:56:47.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n1064, 2025-05-29 17:57:51.010000+00:00, user@local.host: AddPomodoro(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"1\", \"tracker\")\n1065, 2025-05-29 17:57:51.020000+00:00, user@local.host: StartTimer(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"\")\n1066, 2025-05-29 18:03:40.020000+00:00, user@local.host: DeleteWorkitem(\"8262e649-ccde-4a06-9be9-6be086cc4cf4\", \"\")\n1067, 2025-05-29 18:04:28.020000+00:00, user@local.host: StartTimer(\"ab997673-6f52-4f52-9d27-22a565e2d09d\", \"1500\", \"0\")\n1068, 2025-05-29 19:24:30.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n1069, 2025-05-29 19:24:36.020000+00:00, user@local.host: AddPomodoro(\"ab997673-6f52-4f52-9d27-22a565e2d09d\", \"1\", \"normal\")\n1070, 2025-05-29 19:25:40.020000+00:00, user@local.host: StartTimer(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"1500\", \"300\")\n1071, 2025-05-29 19:55:47.020000+00:00, user@local.host: AddPomodoro(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"1\", \"normal\")\n1072, 2025-05-29 19:56:32.020000+00:00, user@local.host: StartTimer(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"1500\", \"300\")\n1073, 2025-05-29 20:13:51.020000+00:00, user@local.host: AddInterruption(\"aa133fd6-57d6-4805-b6cd-35f89475b5e9\", \"An interruption\", \"\")\n1074, 2025-05-29 20:44:36.020000+00:00, user@local.host: StartTimer(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1500\", \"300\")\n1075, 2025-05-29 20:54:34.020000+00:00, user@local.host: AddInterruption(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"An interruption\", \"299.0\")\n1076, 2025-05-29 21:25:19.020000+00:00, user@local.host: StartTimer(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1500\", \"0\")\n1077, 2025-05-29 22:42:02.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n1078, 2025-05-29 22:42:10.020000+00:00, user@local.host: AddPomodoro(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1\", \"normal\")\n1079, 2025-05-29 22:42:38.020000+00:00, user@local.host: RenameWorkitem(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"Generate documentation for #Delta\")\n1080, 2025-05-29 22:43:22.020000+00:00, user@local.host: StartTimer(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1500\", \"300\")\n1081, 2025-05-29 23:13:29.020000+00:00, user@local.host: AddPomodoro(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1\", \"normal\")\n1082, 2025-05-29 23:13:41.020000+00:00, user@local.host: RemovePomodoro(\"bf199fc8-0b8d-48e7-9cea-7d94bee7d773\", \"1\")\n1083, 2025-05-29 23:14:31.020000+00:00, user@local.host: CompleteWorkitem(\"ab997673-6f52-4f52-9d27-22a565e2d09d\", \"finished\")\n1084, 2025-05-29 23:15:35.020000+00:00, user@local.host: CompleteWorkitem(\"2a1c3ec5-6c08-4801-8cba-964df98ff2fd\", \"finished\")\n1085, 2025-05-29 23:16:16.020000+00:00, user@local.host: CompleteWorkitem(\"7501a585-d3b2-47ed-9aef-bcbc489f7492\", \"finished\")\n1086, 2025-05-30 13:17:27+00:00, user@local.host: CreateBacklog(\"9b91c2ad-9f08-4e82-a5cd-08435a161478\", \"2025-05-30, Friday\")\n1087, 2025-05-30 13:17:49+00:00, user@local.host: CreateWorkitem(\"54b2ae36-ec7d-40c0-b90f-bc4820fcfc75\", \"9b91c2ad-9f08-4e82-a5cd-08435a161478\", \"Deprecate screenshot for #Omega\")\n1088, 2025-05-30 13:18:05+00:00, user@local.host: CreateWorkitem(\"06f7fc11-5b68-478f-91b8-830166e73525\", \"9b91c2ad-9f08-4e82-a5cd-08435a161478\", \"Explore documentation for #Beta\")\n1089, 2025-05-30 13:18:25+00:00, user@local.host: CreateWorkitem(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"9b91c2ad-9f08-4e82-a5cd-08435a161478\", \"Fix new feature for #Delta\")\n1090, 2025-05-30 13:19:46+00:00, user@local.host: AddPomodoro(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"1\", \"tracker\")\n1091, 2025-05-30 13:19:46.010000+00:00, user@local.host: StartTimer(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"\")\n1092, 2025-05-30 13:32:40.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n1093, 2025-05-30 13:33:49.010000+00:00, user@local.host: AddPomodoro(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"1\", \"tracker\")\n1094, 2025-05-30 13:33:49.020000+00:00, user@local.host: StartTimer(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"\")\n1095, 2025-05-30 13:41:13.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n1096, 2025-05-30 13:42:35.020000+00:00, user@local.host: AddPomodoro(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"1\", \"tracker\")\n1097, 2025-05-30 13:42:35.030000+00:00, user@local.host: StartTimer(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"\")\n1098, 2025-05-30 13:49:48.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n1099, 2025-05-30 13:50:30.030000+00:00, user@local.host: RenameWorkitem(\"a35e579a-828a-49d0-bb04-ea95ca878627\", \"Send documentation for #Omega\")\n1100, 2025-05-30 13:51:05.030000+00:00, user@local.host: DeleteBacklog(\"9b91c2ad-9f08-4e82-a5cd-08435a161478\", \"\")\n1101, 2025-06-02 14:42:34+00:00, user@local.host: CreateBacklog(\"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"2025-06-02, Monday\")\n1102, 2025-06-02 14:42:59+00:00, user@local.host: CreateWorkitem(\"3b3449c6-3050-44da-a8fb-de7043c164c2\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Check function for #Alpha\")\n1103, 2025-06-02 14:43:05+00:00, user@local.host: AddPomodoro(\"3b3449c6-3050-44da-a8fb-de7043c164c2\", \"1\", \"normal\")\n1104, 2025-06-02 14:43:49+00:00, user@local.host: CreateWorkitem(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Document new feature for #Beta\")\n1105, 2025-06-02 14:43:55+00:00, user@local.host: AddPomodoro(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"1\", \"normal\")\n1106, 2025-06-02 14:44:02+00:00, user@local.host: AddPomodoro(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"1\", \"normal\")\n1107, 2025-06-02 14:44:38+00:00, user@local.host: CreateWorkitem(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Generate email for #Gamma\")\n1108, 2025-06-02 14:45:05+00:00, user@local.host: CreateWorkitem(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Draw scheme for #Beta\")\n1109, 2025-06-02 14:45:10+00:00, user@local.host: AddPomodoro(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1\", \"normal\")\n1110, 2025-06-02 14:45:17+00:00, user@local.host: AddPomodoro(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1\", \"normal\")\n1111, 2025-06-02 14:45:23+00:00, user@local.host: AddPomodoro(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1\", \"normal\")\n1112, 2025-06-02 14:45:28+00:00, user@local.host: AddPomodoro(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1\", \"normal\")\n1113, 2025-06-02 14:46:11+00:00, user@local.host: CreateWorkitem(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Document tool for #Gamma\")\n1114, 2025-06-02 14:46:20+00:00, user@local.host: AddPomodoro(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1\", \"normal\")\n1115, 2025-06-02 14:46:23+00:00, user@local.host: AddPomodoro(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1\", \"normal\")\n1116, 2025-06-02 14:46:50+00:00, user@local.host: CreateWorkitem(\"33e28c58-866f-4853-8859-8f0c709d507f\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Send scheme for #Omega\")\n1117, 2025-06-02 14:46:58+00:00, user@local.host: AddPomodoro(\"33e28c58-866f-4853-8859-8f0c709d507f\", \"1\", \"normal\")\n1118, 2025-06-02 14:47:30+00:00, user@local.host: CreateWorkitem(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Think about code for #Gamma\")\n1119, 2025-06-02 14:48:00+00:00, user@local.host: CreateWorkitem(\"ff0418c7-e0fb-4bcc-9070-cd8de8dfde85\", \"0406fd1d-71c8-4aea-ba07-1f1f94e2cc9f\", \"Plan new feature for #Gamma\")\n1120, 2025-06-02 14:48:52+00:00, user@local.host: AddPomodoro(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"1\", \"tracker\")\n1121, 2025-06-02 14:48:52.010000+00:00, user@local.host: StartTimer(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"\")\n1122, 2025-06-02 14:58:30.010000+00:00, user@local.host: StopTimer(\"\", \"\")\n1123, 2025-06-02 14:59:06.010000+00:00, user@local.host: RenameWorkitem(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"Find design for #Delta\")\n1124, 2025-06-02 15:00:21.010000+00:00, user@local.host: AddPomodoro(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"1\", \"tracker\")\n1125, 2025-06-02 15:00:21.020000+00:00, user@local.host: StartTimer(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"\")\n1126, 2025-06-02 15:12:30.020000+00:00, user@local.host: StopTimer(\"\", \"\")\n1127, 2025-06-02 15:13:40.020000+00:00, user@local.host: AddPomodoro(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"1\", \"tracker\")\n1128, 2025-06-02 15:13:40.030000+00:00, user@local.host: StartTimer(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"\")\n1129, 2025-06-02 15:26:32.030000+00:00, user@local.host: StopTimer(\"\", \"\")\n1130, 2025-06-02 15:27:10.030000+00:00, user@local.host: RenameWorkitem(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"Draw architecture for #Beta\")\n1131, 2025-06-02 15:28:28.030000+00:00, user@local.host: AddPomodoro(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"1\", \"tracker\")\n1132, 2025-06-02 15:28:28.040000+00:00, user@local.host: StartTimer(\"d728166f-0a32-4475-9cdf-30fa316dc2fd\", \"\")\n1133, 2025-06-02 15:43:28.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n1134, 2025-06-02 15:44:27.040000+00:00, user@local.host: StartTimer(\"33e28c58-866f-4853-8859-8f0c709d507f\", \"1500\", \"300\")\n1135, 2025-06-02 16:15:55.040000+00:00, user@local.host: StartTimer(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1500\", \"300\")\n1136, 2025-06-02 16:46:00.040000+00:00, user@local.host: AddPomodoro(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1\", \"normal\")\n1137, 2025-06-02 16:47:10.040000+00:00, user@local.host: StartTimer(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1500\", \"300\")\n1138, 2025-06-02 17:17:16.040000+00:00, user@local.host: AddPomodoro(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1\", \"normal\")\n1139, 2025-06-02 17:18:34.040000+00:00, user@local.host: StartTimer(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1500\", \"0\")\n1140, 2025-06-02 18:15:35.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n1141, 2025-06-02 18:15:41.040000+00:00, user@local.host: AddPomodoro(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1\", \"normal\")\n1142, 2025-06-02 18:16:51.040000+00:00, user@local.host: StartTimer(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1500\", \"300\")\n1143, 2025-06-02 18:47:25.040000+00:00, user@local.host: StartTimer(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"1500\", \"300\")\n1144, 2025-06-02 19:07:19.040000+00:00, user@local.host: AddInterruption(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"Voided for a good reason\", \"\")\n1145, 2025-06-02 19:07:19.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n1146, 2025-06-02 19:08:22.040000+00:00, user@local.host: StartTimer(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1500\", \"300\")\n1147, 2025-06-02 19:39:14.040000+00:00, user@local.host: StartTimer(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1500\", \"300\")\n1148, 2025-06-02 20:09:20.040000+00:00, user@local.host: AddPomodoro(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1\", \"normal\")\n1149, 2025-06-02 20:10:14.040000+00:00, user@local.host: StartTimer(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1500\", \"0\")\n1150, 2025-06-02 21:10:30.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n1151, 2025-06-02 21:11:49.040000+00:00, user@local.host: StartTimer(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1500\", \"300\")\n1152, 2025-06-02 21:29:40.040000+00:00, user@local.host: AddInterruption(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"Pomodoro voided\", \"\")\n1153, 2025-06-02 21:29:40.040000+00:00, user@local.host: StopTimer(\"\", \"\")\n1154, 2025-06-02 21:30:54.040000+00:00, user@local.host: StartTimer(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"1500\", \"300\")\n1155, 2025-06-02 22:02:16.040000+00:00, user@local.host: AddPomodoro(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"1\", \"tracker\")\n1156, 2025-06-02 22:02:16.050000+00:00, user@local.host: StartTimer(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"\")\n1157, 2025-06-02 22:09:31.050000+00:00, user@local.host: StopTimer(\"\", \"\")\n1158, 2025-06-02 22:10:16.050000+00:00, user@local.host: AddPomodoro(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"1\", \"tracker\")\n1159, 2025-06-02 22:10:16.060000+00:00, user@local.host: StartTimer(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"\")\n1160, 2025-06-02 22:15:51.060000+00:00, user@local.host: StopTimer(\"\", \"\")\n1161, 2025-06-02 22:17:05.060000+00:00, user@local.host: AddPomodoro(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"1\", \"tracker\")\n1162, 2025-06-02 22:17:05.070000+00:00, user@local.host: StartTimer(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"\")\n1163, 2025-06-02 22:23:09.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n1164, 2025-06-02 22:23:47.070000+00:00, user@local.host: StartTimer(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"1500\", \"300\")\n1165, 2025-06-02 22:31:24.070000+00:00, user@local.host: AddInterruption(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"\", \"\")\n1166, 2025-06-02 23:02:17.070000+00:00, user@local.host: StartTimer(\"3b3449c6-3050-44da-a8fb-de7043c164c2\", \"1500\", \"300\")\n1167, 2025-06-02 23:13:04.070000+00:00, user@local.host: AddInterruption(\"3b3449c6-3050-44da-a8fb-de7043c164c2\", \"Pomodoro voided\", \"\")\n1168, 2025-06-02 23:13:04.070000+00:00, user@local.host: StopTimer(\"\", \"\")\n1169, 2025-06-02 23:13:49.070000+00:00, user@local.host: CompleteWorkitem(\"4d52a015-557f-4ed6-8a19-260d0113cc33\", \"finished\")\n1170, 2025-06-02 23:14:43.070000+00:00, user@local.host: CompleteWorkitem(\"7b8f6c62-2a30-479a-bf44-4b69dfee4bbd\", \"finished\")\n1171, 2025-06-02 23:15:44.070000+00:00, user@local.host: CompleteWorkitem(\"69573c4b-32d1-40d3-9888-6175500a5366\", \"finished\")\n1172, 2025-06-02 23:16:49.070000+00:00, user@local.host: CompleteWorkitem(\"33e28c58-866f-4853-8859-8f0c709d507f\", \"finished\")\n1173, 2025-06-02 23:17:47.070000+00:00, user@local.host: CompleteWorkitem(\"b453fd1d-478f-4b51-89f9-e017eac484d4\", \"finished\")\n1174, 2025-06-02 23:18:15.070000+00:00, user@local.host: CompleteWorkitem(\"ff0418c7-e0fb-4bcc-9070-cd8de8dfde85\", \"finished\")\n1175, 2025-06-02 23:19:32.070000+00:00, user@local.host: CompleteWorkitem(\"3b3449c6-3050-44da-a8fb-de7043c164c2\", \"finished\")\n"
  },
  {
    "path": "src/fk/tests/fixtures/test-tags.txt",
    "content": "1, 2023-10-18 15:13:24.607620+00:00, admin@local.host: CreateUser(\"alice@flowkeeper.org\", \"Alice Cooper\")\n2, 2023-10-21 16:31:55.992372+00:00, alice@flowkeeper.org: CreateBacklog(\"123-456-789\", \"Sample backlog\")\n3, 2023-10-21 16:32:55.992372+00:00, alice@flowkeeper.org: CreateWorkitem(\"w11\", \"123-456-789\", \"#one #1 # ###десять #_TAG #one| #1\")\n4, 2023-10-21 16:33:55.992372+00:00, alice@flowkeeper.org: CreateWorkitem(\"w12\", \"123-456-789\", \"#ONE #One #one #two\")\n"
  },
  {
    "path": "src/fk/tests/test_backlogs.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.backlog_strategies import RenameBacklogStrategy, DeleteBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy\nfrom fk.tests.test_utils import (predefined_datetime, noop_emit, test_settings,\n                                 TEST_USERNAMES, predefined_uid, check_timestamp, test_data)\n\n\ndef _create_sample_backlog(existing: Tenant | None = None) -> Tenant:\n    data = test_data() if existing is None else existing\n    s = CreateBacklogStrategy(\n        1,\n        predefined_datetime(0),\n        TEST_USERNAMES[0],\n        [predefined_uid(0), 'Basic Test'],\n        test_settings(0))\n    s.execute(noop_emit, data)\n    return data\n\n\nclass TestBacklogs(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = NoCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    def test_initialize(self):\n        self.assertIn('user@local.host', self.data)\n        user = self.data['user@local.host']\n        self.assertEqual(len(user), 0)\n\n    def _assert_backlog(self, backlog1: Backlog, user: User):\n        self.assertEqual(backlog1.get_name(), 'First backlog')\n        self.assertEqual(backlog1.get_uid(), '123-456-789-1')\n        self.assertEqual(backlog1.get_parent(), user)\n        self.assertEqual(backlog1.get_owner(), user)\n        self.assertTrue(backlog1.is_today())\n        self.assertEqual(backlog1.get_running_workitem(), (None, None))\n        self.assertEqual(len(backlog1.values()), 0)\n\n    def test_create_backlogs(self):\n        user = self.data['user@local.host']\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-2', 'Second backlog'])\n        self.assertIn('123-456-789-1', user)\n        self.assertIn('123-456-789-2', user)\n        backlog1: Backlog = user['123-456-789-1']\n        self._assert_backlog(backlog1, user)\n        backlog2 = user['123-456-789-2']\n        self.assertEqual(backlog2.get_name(), 'Second backlog')\n\n    def test_execute_prepared(self):\n        user = self.data['user@local.host']\n        s = CreateBacklogStrategy(2,\n                                  datetime.datetime.now(datetime.timezone.utc),\n                                  user.get_identity(),\n                                  ['123-456-789-1', 'First backlog'],\n                                  self.settings)\n        self.source.execute_prepared_strategy(s)\n        self.assertIn('123-456-789-1', user)\n        backlog1: Backlog = user['123-456-789-1']\n        self._assert_backlog(backlog1, user)\n\n    def test_create_duplicate_backlog_failure(self):\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog 1'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog 2']))\n\n    def test_rename_nonexistent_backlog_failure(self):\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-2', 'Second backlog'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RenameBacklogStrategy, ['123-456-789-3', 'Renamed backlog']))\n\n    def test_rename_backlog(self):\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(RenameBacklogStrategy, ['123-456-789-1', 'Renamed backlog'])\n        user = self.data['user@local.host']\n        self.assertEqual(user['123-456-789-1'].get_name(), 'Renamed backlog')\n\n    def test_delete_nonexistent_backlog_failure(self):\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-2', 'Second backlog'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(DeleteBacklogStrategy, ['123-456-789-3']))\n\n    def test_delete_backlog(self):\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-2', 'Second backlog'])\n        self.source.execute(DeleteBacklogStrategy, ['123-456-789-1'])\n        user = self.data['user@local.host']\n        self.assertNotIn('123-456-789-1', user)\n        self.assertIn('123-456-789-2', user)\n\n    def test_today(self):\n        user = self.data['user@local.host']\n        s = CreateBacklogStrategy(2,\n                                  datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=24),\n                                  user.get_identity(),\n                                  ['123-456-789-1', 'First backlog'],\n                                  self.settings)\n        self.source.execute_prepared_strategy(s)\n        backlog = user['123-456-789-1']\n        self.assertFalse(backlog.is_today())\n\n    def test_events_create_backlog(self):\n        # Subscribe to all events and check that only required ones fire\n        # Ephemeral event source is synchronous, so it's alright that we don't add any delays here\n        state = 0\n        fired = list()\n        def on_event(event, **kwargs):\n            self.assertNotIn(state, [0, 2])\n            self.assertIn(event, ['BeforeMessageProcessed', 'BeforeBacklogCreate', 'AfterBacklogCreate', 'AfterMessageProcessed'])\n            fired.append(event)\n            if event == 'BeforeMessageProcessed' or event == 'AfterMessageProcessed':\n                self.assertIn('strategy', kwargs)\n                self.assertIn('auto', kwargs)\n                self.assertIn('persist', kwargs)\n                self.assertTrue(type(kwargs['strategy']) is CreateBacklogStrategy)\n            elif event == 'BeforeBacklogCreate':\n                self.assertIn('backlog_owner', kwargs)\n                self.assertIn('backlog_uid', kwargs)\n                self.assertIn('backlog_name', kwargs)\n                self.assertTrue(type(kwargs['backlog_owner']) is User)\n                self.assertEqual(kwargs['backlog_uid'], '123-456-789-1')\n                self.assertEqual(kwargs['backlog_name'], 'First backlog')\n            elif event == 'AfterBacklogCreate':\n                self.assertIn('backlog', kwargs)\n                self.assertTrue(type(kwargs['backlog']) is Backlog)\n        self.source.on('*', on_event)\n        state = 1\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        state = 2\n        self.assertEqual(len(fired), 4)\n\n    def test_events_delete_backlog(self):\n        # Here we shall also test the recursive deletion\n        fired = list()\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'BeforeBacklogDelete' or event == 'AfterBacklogDelete':\n                self.assertIn('backlog', kwargs)\n                self.assertTrue(type(kwargs['backlog']) is Backlog)\n                self.assertEqual(kwargs['backlog'].get_name(), 'First backlog')\n            elif event == 'BeforeWorkitemDelete' or event == 'AfterWorkitemDelete':\n                self.assertIn('workitem', kwargs)\n                self.assertTrue(type(kwargs['workitem']) is Workitem)\n                self.assertIn(kwargs['workitem'].get_name(), ['First item', 'Second item'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'First backlog'])\n        self.source.execute(CreateWorkitemStrategy, ['w1', '123-456-789-1', 'First item'])\n        self.source.execute(AddPomodoroStrategy, ['w1', '2'])\n        self.source.execute(CreateWorkitemStrategy, ['w2', '123-456-789-1', 'Second item'])\n        self.source.execute(CreateBacklogStrategy, ['123-456-789-2', 'Second backlog'])\n        self.source.execute(CreateWorkitemStrategy, ['w3', '123-456-789-2', 'Third item'])\n        self.source.on('*', on_event)  # We only care about delete here\n        self.source.execute(DeleteBacklogStrategy, ['123-456-789-1'])\n        self.assertEqual(len(fired), 12)  # Note that although we had a cascade delete, only one strategy got executed\n        # The events must arrive in the right order, too\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeBacklogDelete')\n        self.assertEqual(fired[2], 'BeforeMessageProcessed')  # auto=True\n        self.assertEqual(fired[3], 'BeforeWorkitemDelete')\n        self.assertEqual(fired[4], 'AfterWorkitemDelete')\n        self.assertEqual(fired[5], 'AfterMessageProcessed')  # auto=True\n        self.assertEqual(fired[6], 'BeforeMessageProcessed')  # auto=True\n        self.assertEqual(fired[7], 'BeforeWorkitemDelete')\n        self.assertEqual(fired[8], 'AfterWorkitemDelete')\n        self.assertEqual(fired[9], 'AfterMessageProcessed')  # auto=True\n        self.assertEqual(fired[10], 'AfterBacklogDelete')\n        self.assertEqual(fired[11], 'AfterMessageProcessed')        \n        # Automatic voiding of pomodoros will be tested when we cover the lifecycles\n\n    def test_events_rename_backlog(self):\n        fired = list()\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'BeforeBacklogRename' or event == 'AfterBacklogRename':\n                self.assertIn('backlog', kwargs)\n                self.assertIn('old_name', kwargs)\n                self.assertIn('new_name', kwargs)\n                self.assertEqual(kwargs['old_name'], 'Before')\n                self.assertEqual(kwargs['new_name'], 'After')\n                self.assertTrue(type(kwargs['backlog']) is Backlog)                    \n        self.source.execute(CreateBacklogStrategy, ['123-456-789-1', 'Before'])\n        self.source.on('*', on_event)\n        self.source.execute(RenameBacklogStrategy, ['123-456-789-1', 'After'])\n        self.assertEqual(len(fired), 4)\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeBacklogRename')\n        self.assertEqual(fired[2], 'AfterBacklogRename')\n        self.assertEqual(fired[3], 'AfterMessageProcessed')\n\n    def test_create_backlog_strategy_basic(self):\n        data = _create_sample_backlog()\n        self.assertEqual(4, len(data))   # It also includes admin user\n        user = data[TEST_USERNAMES[0]]\n        self.assertEqual(1, len(user))\n        self.assertIn(predefined_uid(0), user)\n        backlog = user[predefined_uid(0)]\n        self.assertEqual('Basic Test', backlog.get_name())\n        self.assertEqual(predefined_uid(0), backlog.get_uid())\n        self.assertEqual(user, backlog.get_parent())\n        self.assertEqual(user, backlog.get_owner())\n        self.assertTrue(check_timestamp(backlog.get_create_date(), 0))\n        self.assertIsNone(backlog.get_running_workitem()[0])\n        self.assertIsNone(backlog.get_running_workitem()[1])\n        self.assertTrue(check_timestamp(backlog.get_last_modified_date(), 0))\n\n    def test_create_backlog_strategy_already_exists(self):\n        data = _create_sample_backlog()\n        with self.assertRaises(Exception):\n            _create_sample_backlog(data)\n\n    def test_create_backlog_strategy_same_name(self):\n        data = _create_sample_backlog()\n        s = CreateBacklogStrategy(\n            2,\n            predefined_datetime(1),\n            TEST_USERNAMES[0],\n            [predefined_uid(1), 'Basic Test'],\n            test_settings(0))\n        s.execute(noop_emit, data)\n        user = data[TEST_USERNAMES[0]]\n        self.assertEqual(user[predefined_uid(0)].get_name(), 'Basic Test')\n        self.assertEqual(user[predefined_uid(1)].get_name(), 'Basic Test')\n        self.assertNotEqual(user[predefined_uid(0)], user[predefined_uid(1)])\n\n    def test_create_backlog_independent_users_same_uid(self):\n        data = _create_sample_backlog()\n        s = CreateBacklogStrategy(\n            2,\n            predefined_datetime(1),\n            TEST_USERNAMES[1],\n            [predefined_uid(0), 'Second Backlog'],\n            test_settings(1))\n        s.execute(noop_emit, data)\n        user1 = data[TEST_USERNAMES[0]]\n        user2 = data[TEST_USERNAMES[1]]\n        user3 = data[TEST_USERNAMES[2]]\n        self.assertEqual(len(user1), 1)\n        self.assertEqual(user1[predefined_uid(0)].get_name(), 'Basic Test')\n        self.assertEqual(len(user2), 1)\n        self.assertEqual(user2[predefined_uid(0)].get_name(), 'Second Backlog')\n        self.assertEqual(len(user3), 0)\n"
  },
  {
    "path": "src/fk/tests/test_events.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom typing import Callable\n\nfrom fk.core.abstract_event_emitter import AbstractEventEmitter\nfrom fk.core.mock_settings import invoke_direct\nfrom fk.tests.abstract_test_case import AbstractTestCase\n\nBeforeAction = 'BeforeAction'\nAfterAction = 'AfterAction'\n\n\nclass TestEmitter(AbstractEventEmitter):\n    value: str | None\n\n    def __init__(self,\n                 invoker: Callable = invoke_direct):\n        AbstractEventEmitter.__init__(self, [\n            BeforeAction,\n            AfterAction,\n        ], invoker)\n        self.value = None\n\n    def action(self, value: str, carry: str = None):\n        self._emit(BeforeAction, {'value': value}, carry)\n        self.value = value\n        self._emit(AfterAction, {'value': value}, carry)\n\n    def wrong_emit(self):\n        self._emit('WrongEvent', {'value': 'one'}, None)\n\n\nclass TestEvents(AbstractTestCase):\n    def setUp(self):\n        logging.getLogger().setLevel(logging.DEBUG)\n\n    def test_events_basic(self):\n        emitter = TestEmitter()\n        self.assert_events(emitter,\n                           lambda: emitter.action('foo'),\n                           ['BeforeAction', 'AfterAction'],\n                           {\n                               'BeforeAction': {'value': 'foo'},\n                               'AfterAction': {'value': 'foo'}\n                           })\n\n    # - Callback invoker is used every time\n    def test_callback_invoker(self):\n        fired = list()\n\n        def invoke(fn, **kwargs):\n            fired.append(kwargs['event'])\n            fn(**kwargs)\n\n        def on_event(event, **kwargs):\n            pass\n\n        emitter = TestEmitter(invoke)\n        emitter.on('*', on_event)\n        emitter.action('foo')\n        self.assertEqual(len(fired), 2)\n        self.assertEqual(fired[0], 'BeforeAction')\n        self.assertEqual(fired[1], 'AfterAction')\n\n    def test_cancel_one(self):\n        fired = False\n        def on_event(event, **kwargs):\n            self.assertEqual(event, 'AfterAction')\n            nonlocal fired\n            fired = True\n        emitter = TestEmitter()\n        emitter.on('*', on_event, True)\n        emitter.cancel('BeforeAction')\n        emitter.action('foo')\n        self.assertTrue(fired)\n\n    def test_unsubscribe(self):\n        def on_event(event, **kwargs):\n            self.assertTrue(False)\n        emitter = TestEmitter()\n        emitter.on('BeforeAction', on_event, True)\n        emitter.on('AfterAction', on_event, True)\n        emitter.unsubscribe(on_event)\n        emitter.action('foo')\n\n    def test_unsubscribe_one(self):\n        fired = False\n        def on_event(event, **kwargs):\n            self.assertEqual(event, 'BeforeAction')\n            nonlocal fired\n            fired = True\n        emitter = TestEmitter()\n        emitter.on('BeforeAction', on_event, True)\n        emitter.on('AfterAction', on_event, True)\n        emitter.unsubscribe_one(on_event, 'AfterAction')\n        emitter.action('foo')\n        self.assertTrue(fired)\n\n    def test_firing_order(self):\n        fired1 = False\n        fired2 = False\n        def on_event1(event, **kwargs):\n            nonlocal fired1\n            nonlocal fired2\n            self.assertFalse(fired1)\n            self.assertFalse(fired2)\n            fired1 = True\n        def on_event2(event, **kwargs):\n            nonlocal fired1\n            nonlocal fired2\n            self.assertTrue(fired1)\n            self.assertFalse(fired2)\n            fired2 = True\n\n        emitter = TestEmitter()\n\n        emitter.on('BeforeAction', on_event1, False)\n        emitter.on('BeforeAction', on_event2, True)\n        emitter.action('foo')\n        self.assertTrue(fired1 and fired2)\n        fired1 = False\n        fired2 = False\n        emitter.cancel('BeforeAction')\n\n        emitter.on('BeforeAction', on_event1, False)\n        emitter.on('BeforeAction', on_event2, False)\n        emitter.action('foo')\n        self.assertTrue(fired1 and fired2)\n        fired1 = False\n        fired2 = False\n        emitter.cancel('BeforeAction')\n\n        emitter.on('BeforeAction', on_event2, True)\n        emitter.on('BeforeAction', on_event1, False)\n        emitter.action('foo')\n        self.assertTrue(fired1 and fired2)\n        fired1 = False\n        fired2 = False\n        emitter.cancel('BeforeAction')\n\n    # Tests:\n    # + Two priorities (\"last\" callbacks)\n    # + Subscribe with wildcards\n    # + Subscribe to a single event\n    # - Carry parameter\n    # - No duplicate subscriptions\n    # + Canceling subscriptions\n    # + Unsubscribing from emitter\n    # - Emitting events with parameters\n    # - Error when trying to emit something new\n    # - Mute / unmute events\n    # - \"Event\" parameter\n"
  },
  {
    "path": "src/fk/tests/test_file_event_source.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport os\nfrom collections.abc import Callable\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_serializer import AbstractSerializer, T\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy\n\nTEMP_FILENAME = 'src/fk/tests/fixtures/flowkeeper-data-TEMP.txt'\nRAND_FILENAME = 'src/fk/tests/fixtures/random.txt'\n\n\nclass FilteringSerializer(AbstractSerializer):\n    _another: AbstractSerializer\n    _strategy_filter: Callable[[AbstractStrategy], bool]\n\n    def __init__(self, another: AbstractSerializer, strategy_filter: Callable[[AbstractStrategy], bool] = None):\n        super().__init__(None, None)\n        self._another = another\n        self._strategy_filter = strategy_filter\n\n    def serialize(self, s: AbstractStrategy) -> T:\n        return self._another.serialize(s)\n\n    def deserialize(self, t: str) -> AbstractStrategy | None:\n        s = self._another.deserialize(t)\n        if self._strategy_filter is None or self._strategy_filter(s):\n            return s\n\n\ndef _create_filtered_source(strategy_filter: Callable[[AbstractStrategy], bool] = None) -> FileEventSource:\n    _settings = MockSettings(filename=RAND_FILENAME)\n    _settings.set({\n        'Source.ignore_errors': 'True',\n        'Source.ignore_invalid_sequence': 'True',\n    })  # Otherwise we won't be able to start it\n    _cryptograph = FernetCryptograph(_settings)\n    _source = FileEventSource[Tenant](_settings, _cryptograph, Tenant(_settings))\n    # This is a hack to replace SimpleSerializer with a filtering wrapper, but it's ok for a unit test\n    _source._serializer = FilteringSerializer(_source._serializer, strategy_filter)\n    _source.start()\n    return _source\n\n\ndef _test_repair(strategy_filter: Callable[[AbstractStrategy], bool],\n                 before: Callable[[FileEventSource], None],\n                 after: Callable[[FileEventSource], None]):\n    backup_filename = None\n    root = logging.getLogger()\n    try:\n        root.setLevel(logging.FATAL)\n        src = _create_filtered_source(strategy_filter)\n        before(src)\n        log, backup_filename = src.repair()\n        src = _create_filtered_source()\n        after(src)\n    finally:\n        root.setLevel(logging.DEBUG)\n        if backup_filename is not None:\n            os.rename(backup_filename, RAND_FILENAME)\n\n\nclass TestFileEventSource(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: FileEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings(filename=TEMP_FILENAME)\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = FileEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        os.unlink(TEMP_FILENAME)\n\n    def test_initialize(self):\n        self.assertIn('user@local.host', self.data)\n        user = self.data['user@local.host']\n        self.assertEqual(len(user), 0)\n\n    def test_repair_strip_create_backlog(self):\n        original = _create_filtered_source()\n\n        def check_after_repair(src: FileEventSource):\n            self.assertEqual(len(list(original.backlogs())), len(list(src.backlogs())))\n            user: User = src.get_data().get_current_user()\n            for b in original.backlogs():\n                self.assertIsNotNone(user.get(b.get_uid()))\n                self.assertEqual(len(user.get(b.get_uid()).values()), len(b.values()))\n\n        _test_repair(lambda s: not isinstance(s, CreateBacklogStrategy),\n                     lambda src: self.assertEqual(0, len(list(src.backlogs()))),\n                     check_after_repair)\n\n    def test_repair_strip_create_workitem(self):\n        original = _create_filtered_source()\n\n        def check_after_repair(src: FileEventSource):\n            user: User = src.get_data().get_current_user()\n            orphans_list = list(filter(lambda b: b.get_name() == '[Repaired] Orphan workitems', src.backlogs()))\n            self.assertEqual(1, len(orphans_list))\n            orphans = orphans_list[0]\n\n            # First, check that none of the backlogs have workitems anymore\n            for b in src.backlogs():\n                if b != orphans:\n                    self.assertEqual(0, len(b.values()))\n\n            # Now make sure all WIs are in the orphans instead\n            for w in original.workitems():\n                self.assertIn(w.get_uid(), orphans)\n                self.assertEqual(len(w), len(orphans[w.get_uid()])) # All pomodoros are still there\n\n        _test_repair(lambda s: not isinstance(s, CreateWorkitemStrategy),\n                     lambda src: self.assertEqual(len(list(original.backlogs())), len(list(src.backlogs()))),\n                     check_after_repair)\n\n    def test_repair_no_op(self):\n        original = _create_filtered_source()\n        _test_repair(lambda s: True,\n                     lambda src: self.assertEqual(original.get_data().get_current_user().dump(), src.get_data().get_current_user().dump()),\n                     lambda src: self.assertEqual(original.get_data().get_current_user().dump(), src.get_data().get_current_user().dump()))\n\n    # Tests:\n    # - Filesystem watcher\n    # - Cryptograph -- create a dedicated unit test for it\n    # - Creation from existing strategies (no file read)\n    # - Events - SourceMessagesRequested, SourceMessagesProcessed, ...\n    # - Events in case of errors\n    # - Sequence errors, configurable\n    # - Skipping strategies out of order\n    # - Mute / don't mute\n    # - Create file automatically\n    # - Dealing with syntax errors\n    # - Ignoring comments and empty lines\n    # - Starting with non-1 sequences\n    # - Writing strategies to file (close / open)\n    # - Repair (various cases)\n    # - - Removing duplicates\n    # - - Renumber strategies\n    # - - Create non-existent users on first reference\n    # - - Create non-existent backlogs on first reference\n    # - - Create non-existent workitems on first reference\n    # - - Removing any invalid strategies\n    # - - Creating a backup file\n    # - - Don't do anything for \"good\" files\n    # - - Check that it can repair something that won't import\n    # - Compress\n    # - - Creates a backup file\n    # - - Compare dumps of random.txt\n    # - - No changes if there are no savings\n    # - Method calls like disconnect, send_ping, etc.\n    # - Auto-seals last strategy"
  },
  {
    "path": "src/fk/tests/test_import_export.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nimport os\nfrom pathlib import Path\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.event_source_factory import EventSourceFactory\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.import_export import import_, export, import_github_issues, import_simple\nfrom fk.core.interruption import Interruption\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, AddInterruptionStrategy\nfrom fk.core.tags import Tags\nfrom fk.core.tenant import Tenant\nfrom fk.core.timer_data import TimerData\nfrom fk.core.timer_strategies import StartTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy\nfrom fk.tests.test_utils import epyc\n\nTEMP_DIR = 'src/fk/tests/fixtures/'\nTEMP_FILE = 'flowkeeper-data-TEMP.txt'\nTEMP_FILENAME = f'{TEMP_DIR}{TEMP_FILE}'\nEXPORTED_FILENAME = f'{TEMP_DIR}{TEMP_FILE}-exported'\nRAND_FILENAME = 'src/fk/tests/fixtures/random.txt'\nRAND_DUMP_FILENAME = 'src/fk/tests/fixtures/random-dump.txt'\n\n\ndef _skip_first(dump: str, skip_rows: int) -> str:\n    return '\\n'.join(dump.split('\\n')[skip_rows:])\n\n\nclass TestImportExport(TestCase):\n    settings_temp: AbstractSettings\n    cryptograph_temp: AbstractCryptograph\n    source_temp: FileEventSource\n    data_temp: dict[str, User]\n\n    settings_rand: AbstractSettings\n    cryptograph_rand: AbstractCryptograph\n    source_rand: FileEventSource\n    data_rand: dict[str, User]\n\n    def _init_source_temp(self):\n        self.source_temp = FileEventSource[Tenant](self.settings_temp, self.cryptograph_temp, Tenant(self.settings_temp))\n        self.source_temp.start()\n        self.data_temp = self.source_temp.get_data()\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings_temp = MockSettings(filename=TEMP_FILENAME)\n        self.cryptograph_temp = FernetCryptograph(self.settings_temp)\n        self._init_source_temp()\n\n        self.settings_rand = MockSettings(filename=RAND_FILENAME)\n        self.cryptograph_rand = FernetCryptograph(self.settings_rand)\n        self.source_rand = FileEventSource[Tenant](self.settings_rand, self.cryptograph_rand, Tenant(self.settings_rand))\n        self.source_rand.start()\n        self.data_rand = self.source_rand.get_data()\n\n        # Needed by smart import\n        self._register_source_producers()\n\n    def tearDown(self) -> None:\n        for p in Path(TEMP_DIR).glob(f'{TEMP_FILE}*'):\n            p.unlink()\n\n    def _register_source_producers(self):\n        def ephemeral_source_producer(settings: AbstractSettings, cryptograph: AbstractCryptograph, root: Tenant):\n            # This is not 100% accurate, as the original wraps it into a ThreadedEventSource, but should suffice\n            # for the purpose of this unit test\n            return EphemeralEventSource[Tenant](settings, cryptograph, root)\n\n        EventSourceFactory.get_event_source_factory().register_producer('ephemeral', ephemeral_source_producer)\n\n    def test_initialize(self):\n        self.assertIn('user@local.host', self.data_temp)\n        user_temp = self.data_temp['user@local.host']\n        self.assertEqual(len(user_temp), 0)\n\n        self.assertIn('user@local.host', self.data_rand)\n        user_rand = self.data_rand['user@local.host']\n        self.assertEqual(15, len(user_rand))\n\n        dump = user_rand.dump()\n        with open(RAND_DUMP_FILENAME, encoding='UTF-8') as f:\n            self.assertEqual(f.read(), dump)\n\n    def _execute_import(self, ignore_errors: bool, merge: bool, repair: bool = True, filename: str = None) -> (int, int):\n        total_start = 0\n\n        def set_total_start(total):\n            nonlocal total_start\n            total_start = total\n\n        total_end = 0\n\n        def finish(total):\n            nonlocal total_end\n            total_end = total\n            if repair:\n                self.source_temp.repair()\n                self._init_source_temp()\n\n        def nothing(*args):\n            pass\n\n        import_(self.source_temp,\n                RAND_FILENAME if filename is None else filename,\n                ignore_errors,\n                merge,\n                set_total_start,\n                nothing,\n                finish)\n\n        return total_start, total_end\n\n    def _execute_export(self, compress: bool, filename: str) -> (int, int):\n        total_start = 0\n\n        def set_total_start(total):\n            nonlocal total_start\n            total_start = total\n\n        total_end = 0\n\n        def finish(total):\n            nonlocal total_end\n            total_end = total\n\n        def nothing(*args):\n            pass\n\n        export(self.source_rand,\n               filename,\n               Tenant(self.settings_rand),\n               False,\n               compress,\n               set_total_start,\n               nothing,\n               finish)\n\n        return total_start, total_end\n\n    def test_import_classic_ok(self):\n        total_start, total_end = self._execute_import(False, False)\n        self.assertEqual(total_start, total_end)\n        self.assertEqual(1175, total_end)    # That's how many strategies are in random.txt\n\n        # We skip the first 7 lines, as the existing user is kept\n        dump_imported = _skip_first(self.data_temp['user@local.host'].dump(), 7)\n        dump_original = _skip_first(self.data_rand['user@local.host'].dump(), 7)\n        self.assertEqual(dump_imported, dump_original)\n\n    def test_import_classic_twice_error(self):\n        self._execute_import(False, False)\n        self.assertRaises(Exception, self._execute_import, [False, False])\n\n    def test_import_classic_twice_ignore_errors(self):\n        root = logging.getLogger()\n        try:\n            root.setLevel(logging.FATAL)\n\n            self._execute_import(False, False)\n            self._execute_import(True, False)\n\n            # We expect to have all the same backlogs and workitems, but random number of pomodoros in them\n            user_temp = self.data_temp['user@local.host']\n            user_rand = self.data_rand['user@local.host']\n            self.assertEqual(len(user_temp), len(user_rand))\n            for b in user_temp:\n                backlog_temp = user_temp[b]\n                backlog_rand = user_rand[b]\n                self.assertEqual(backlog_temp.get_name(), backlog_rand.get_name())\n                for w in backlog_temp:\n                    workitem_temp = backlog_temp[w]\n                    workitem_rand = backlog_rand[w]\n                    self.assertEqual(workitem_temp.get_name(), workitem_rand.get_name())\n        finally:\n            root.setLevel(logging.DEBUG)\n\n    def _compare_imported_and_original_dumps(self):\n        # We skip the first 7 lines, as the existing user is kept\n        dump_imported = _skip_first(self.data_temp['user@local.host'].dump(mask_last_modified=True), 7)\n        dump_original = _skip_first(self.data_rand['user@local.host'].dump(mask_last_modified=True), 7)\n        self.assertEqual(dump_imported, dump_original)\n\n    def test_import_smart_ok(self):\n        total_start, total_end = self._execute_import(False, True)\n        self.assertEqual(total_end, 827)\n        self._compare_imported_and_original_dumps()\n\n    def test_import_smart_twice_ok(self):\n        self._execute_import(False, False)\n        self._execute_import(False, True)\n        self._compare_imported_and_original_dumps()\n\n    def test_import_classic_in_halves_correct_order(self):\n        fn1 = f'{RAND_FILENAME}-1'\n        fn2 = f'{RAND_FILENAME}-2'\n        try:\n            # 1. Split the file in two halves\n            with open(RAND_FILENAME, encoding='UTF-8') as r:\n                i = 0\n                with open(fn1, 'w', encoding='UTF-8') as w1, open(fn2, 'w', encoding='UTF-8') as w2:\n                    for line in r:\n                        if i == 0:\n                            w2.write(line)\n                        if i < 300:\n                            w1.write(line)\n                        else:\n                            w2.write(line)\n                        i += 1\n\n            # 2. Import them\n            self._execute_import(False, False, False, fn1)\n            self._execute_import(False, False, False, fn2)\n            self._compare_imported_and_original_dumps()\n        except Exception as e:\n            raise e\n        finally:\n            os.unlink(fn1)\n            os.unlink(fn2)\n\n    def test_export_simple_ok(self):\n        total_start, total_end = self._execute_export(False, EXPORTED_FILENAME)\n        self.assertEqual(1176, total_start)\n        self.assertEqual(total_end, total_start)\n\n        self._execute_import(False, False, filename=EXPORTED_FILENAME)\n\n        # We skip the first 7 lines, as the existing user is kept\n        dump_imported = _skip_first(self.data_temp['user@local.host'].dump(), 7)\n        dump_original = _skip_first(self.data_rand['user@local.host'].dump(), 7)\n        self.assertEqual(dump_imported, dump_original)\n\n    def test_export_compressed_ok(self):\n        total_start, total_end = self._execute_export(True, EXPORTED_FILENAME)\n        self.assertEqual(1176, total_start)\n        self.assertEqual(total_end, total_start)\n\n        self._execute_import(False, False, filename=EXPORTED_FILENAME)\n\n        # We skip the first 7 lines, as the existing user is kept\n        dump_imported = _skip_first(self.data_temp['user@local.host'].dump(mask_last_modified=True), 7)\n        dump_original = _skip_first(self.data_rand['user@local.host'].dump(mask_last_modified=True), 7)\n        self.assertEqual(dump_imported, dump_original)\n\n    def test_import_github_ok(self):\n        issues = [{\n            'number': 101,\n            'title': 'Title 101',\n            'user': {\n                'login': 'user101',\n            },\n            'assignee': {\n                'login': 'assignee101',\n            },\n            'labels': [\n                {'name': 'label101'},\n            ],\n            'milestone': {\n                'title': 'milestone101',\n            },\n            'state': 'new',\n        }, {\n            'number': 102,\n            'title': 'Title 102',\n            'user': {\n                'login': 'user101',\n            },\n            'assignee': {\n                'login': 'assignee101',\n            },\n            'labels': [\n                {'name': 'label101'},\n            ],\n            'milestone': {\n                'title': 'milestone101',\n            },\n            'state': 'new',\n        }]\n        import_github_issues(self.source_temp,\n                             'github',\n                             issues,\n                             True,\n                             True,\n                             True,\n                             True,\n                             True)\n        user: User = self.data_temp.get_current_user()\n\n        tags: Tags = user.get_tags()\n        self.assertIn('user101', tags)\n        self.assertIn('assignee101', tags)\n        self.assertIn('label101', tags)\n        self.assertIn('milestone101', tags)\n        self.assertIn('new', tags)\n\n        backlog: Backlog = user.values()[0]\n        self.assertEqual(backlog.get_name(), 'github')\n\n        names = backlog.names()\n        self.assertEqual(len(names), 2)\n        for n in names:\n            self.assertTrue(n.startswith('101 - Title 101') or n.startswith('102 - Title 102'))\n\n    def test_import_simple_ok(self):\n        tasks = {\n            'b1': [['Title 101', 'new'],\n                   ['Title 102', 'completed']],\n            'b2': [['Title 201', 'new']],\n        }\n        import_simple(self.source_temp, tasks)\n        user: User = self.data_temp.get_current_user()\n\n        self.assertEqual(len(user), 2)\n        for backlog in user.values():\n            self.assertTrue(backlog.get_name() == 'b1' or backlog.get_name() == 'b2')\n            wi_names = backlog.names()\n            if backlog.get_name() == 'b1':\n                self.assertEqual(len(wi_names), 2)\n                self.assertIn('Title 101', wi_names)\n                self.assertIn('Title 102', wi_names)\n                for workitem in backlog.values():\n                    if workitem.get_name() == 'Title 101':\n                        self.assertFalse(workitem.is_sealed())\n                    else:\n                        self.assertTrue(workitem.is_sealed())\n            else:\n                self.assertEqual(len(wi_names), 1)\n                self.assertIn('Title 201', wi_names)\n                workitem: Workitem = backlog.values()[0]\n                self.assertFalse(workitem.is_sealed())\n\n    def test_backlog_to_json(self):\n        when = epyc()\n\n        # Check user\n        user = self.data_temp['user@local.host']\n        d = user.to_dict()\n        self.assertEqual(len(d), 5)\n        self.assertEqual(d['is_system_user'], False)\n        self.assertEqual(d['name'], 'Local User')\n        self.assertEqual(d['uid'], user.get_uid())\n        self.assertEqual(d['create_date'], user.get_create_date())\n        self.assertEqual(d['last_modified_date'], user.get_last_modified_date())\n\n        # Check backlog\n        self.source_temp.execute(CreateBacklogStrategy,\n                                 ['b1', 'First backlog'], True, when)\n        backlog: Backlog = user['b1']\n        d = backlog.to_dict()\n        self.assertEqual(len(d), 5)\n        self.assertEqual(d['date_work_started'], None)\n        self.assertEqual(d['name'], 'First backlog')\n        self.assertEqual(d['uid'], backlog.get_uid())\n        self.assertEqual(d['create_date'], when)\n        self.assertEqual(d['last_modified_date'], when)\n\n        # Check workitem\n        self.source_temp.execute(CreateWorkitemStrategy,\n                                 ['w1', backlog.get_uid(), 'Item #one'], True, when)\n        workitem: Workitem = backlog['w1']\n        d = workitem.to_dict()\n        self.assertEqual(len(d), 8)\n        self.assertEqual(d['date_work_started'], None)\n        self.assertEqual(d['date_work_ended'], None)\n        self.assertEqual(d['state'], 'new')\n        self.assertEqual(d['name'], 'Item #one')\n        self.assertEqual(d['uid'], workitem.get_uid())\n        self.assertEqual(d['create_date'], when)\n        self.assertEqual(d['last_modified_date'], when)\n\n        # Check pomodoro\n        self.source_temp.execute(AddPomodoroStrategy,\n                                 ['w1', '1', 'normal'], True, when)\n        pomodoro: Pomodoro = workitem.values()[0]\n        d = pomodoro.to_dict()\n        self.assertEqual(len(d), 12)\n        self.assertEqual(d['is_planned'], True)\n        self.assertEqual(d['state'], 'new')\n        self.assertEqual(d['type'], 'normal')\n        self.assertEqual(d['work_duration'], 1500)\n        self.assertEqual(d['rest_duration'], 300)\n        self.assertEqual(d['date_work_started'], None)\n        self.assertEqual(d['date_rest_started'], None)\n        self.assertEqual(d['date_completed'], None)\n        self.assertEqual(d['name'], 'Pomodoro 1')\n        self.assertEqual(d['uid'], pomodoro.get_uid())\n        self.assertEqual(d['create_date'], when)\n        self.assertEqual(d['last_modified_date'], when)\n\n        # Check interruption\n        self.source_temp.execute(StartTimerStrategy,\n                                 ['w1', '1500', '300'], True, when)\n        self.source_temp.execute(AddInterruptionStrategy,\n                                 ['w1', 'good reason', '15'], True, when)\n        interruption: Interruption = pomodoro.values()[0]\n        d = interruption.to_dict()\n        self.assertEqual(len(d), 6)\n        self.assertEqual(d['reason'], 'good reason')\n        self.assertEqual(d['duration'], datetime.timedelta(seconds=15))\n        self.assertEqual(d['void'], False)\n        self.assertEqual(d['uid'], interruption.get_uid())\n        self.assertEqual(d['create_date'], when)\n        self.assertEqual(d['last_modified_date'], when)\n\n        # Check timer\n        timer: TimerData = user.get_timer()\n        d = timer.to_dict()\n        self.assertEqual(len(d), 9)\n        self.assertEqual(d['state'], 'work')\n        self.assertEqual(d['pomodoro'], pomodoro.get_uid())\n        self.assertEqual(d['planned_duration'], 1500)\n        self.assertEqual(d['remaining_duration'], 1500)\n        self.assertEqual(d['last_state_change'], when)\n        self.assertEqual(d['next_state_change'], when + datetime.timedelta(seconds=1500))\n        self.assertEqual(d['uid'], timer.get_uid())\n        self.assertEqual(d['create_date'], user.get_create_date())\n        self.assertEqual(d['last_modified_date'], timer.get_last_modified_date())\n"
  },
  {
    "path": "src/fk/tests/test_pomodoros.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL, POMODORO_TYPE_TRACKER\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy, RemovePomodoroStrategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.timer_data import TimerData\nfrom fk.core.timer_strategies import StartTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.user_strategies import AutoSealInternalStrategy\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, RenameWorkitemStrategy, CompleteWorkitemStrategy\nfrom fk.tests.test_utils import epyc\n\n\nclass TestPomodoros(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    def _assert_workitem(self, workitem1: Workitem, user: User, backlog: Backlog):\n        self.assertEqual(workitem1.get_name(), 'First workitem')\n        self.assertEqual(workitem1.get_uid(), 'w11')\n        self.assertEqual(workitem1.get_parent(), backlog)\n        self.assertEqual(workitem1.get_owner(), user)\n        self.assertFalse(workitem1.is_running())\n        self.assertFalse(workitem1.is_sealed())\n        self.assertFalse(workitem1.is_startable())\n        self.assertFalse(workitem1.has_running_pomodoro())\n        self.assertTrue(workitem1.is_planned())\n        self.assertEqual(len(workitem1.values()), 0)\n\n    def _standard_backlog(self) -> (User, Backlog):\n        self.source.execute(CreateBacklogStrategy, ['b1', 'First backlog'])\n        user = self.data['user@local.host']\n        backlog = user['b1']\n        return user, backlog\n\n    def _standard_workitem(self) -> (User, Backlog, Workitem):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        workitem = backlog['w11']\n        return user, backlog, workitem\n\n    def _standard_pomodoro(self, n: int, type: str = POMODORO_TYPE_NORMAL) -> (User, Backlog, Workitem, Pomodoro):\n        user, backlog, workitem = self._standard_workitem()\n        self.source.execute(AddPomodoroStrategy, ['w11', str(n), type])\n        return user, backlog, workitem, workitem.values()\n\n    # Tests:\n    # + Sealing pomodoros\n    # - pomodoro.seal()\n    # + Planned time in current state, for different states\n    # + Remaining --//--\n    # + Total planned time, end of work, end of rest\n    # - Start work\n    #   - Sealed workitem, non-existing workitem\n    #   - Another pomodoro is running\n    #   - Non-standard durations overwrite original values\n    # - Start rest\n    #   - Call explicitly\n    #   - Called by Timer\n    #   - Sealed workitem, non-existing workitem\n    # + Add pomodoro, remove\n    #   + Successful case\n    #   + Sealed workitem, non-existing workitem\n    #   + Invalid number of pomodoros\n    #   + Check number of pomodoros added / removed\n    #   + Check that add / remove acts as a stack\n    # - Finish work, void current\n    #   - Sealed workitem, non-existing workitem\n    #   - Workitem is not running\n    #   - Only affects the running pomodoro\n    # - Interruptions (TODO)\n    # - Last modified dates on any Pomodoro changes propagate to the root\n    # - Iterate through all pomodoros from event source level\n    # - Events\n    # - Update remaining duration\n    # - To string\n\n    def test_add_pomodoro(self):\n        user, backlog, workitem = self._standard_workitem()\n        now = datetime.datetime.now(datetime.timezone.utc)\n        then = now - datetime.timedelta(minutes=3)\n        self.source.execute(AddPomodoroStrategy, ['w11', '1'], when=then)\n        self.source.execute(AddPomodoroStrategy, ['w11', '2'], when=then)\n        self.assertEqual(3, len(workitem))\n        self.assertFalse(workitem.has_running_pomodoro())\n        incomplete = list[Pomodoro](workitem.get_incomplete_pomodoros())\n        self.assertEqual(3, len(incomplete))\n        for pomodoro in incomplete:\n            self.assertEqual(True, pomodoro.is_startable())\n            self.assertEqual(False, pomodoro.is_running())\n            self.assertEqual(False, pomodoro.is_working())\n            self.assertEqual(False, pomodoro.is_finished())\n            self.assertEqual(False, pomodoro.is_resting())\n            self.assertEqual(workitem, pomodoro.get_parent())\n            self.assertEqual(workitem.get_owner(), pomodoro.get_owner())\n            self.assertEqual(300, pomodoro.get_rest_duration())\n            self.assertEqual(1500, pomodoro.get_work_duration())\n            self.assertEqual(pomodoro.get_create_date(), pomodoro.get_last_modified_date())\n            self.assertEqual(then, pomodoro.get_create_date())\n            self.assertIsNone(pomodoro.get_rest_start_date())\n            self.assertIsNone(pomodoro.get_work_start_date())\n            self.assertEqual('new', pomodoro.get_state())\n            self.assertIsNone(pomodoro.planned_end_of_rest())\n            self.assertIsNone(pomodoro.planned_end_of_work())\n            self.assertEqual(0, pomodoro.remaining_time_in_current_state(now))\n            self.assertEqual('N/A', pomodoro.remaining_minutes_in_current_state_str(now))\n            self.assertIsNotNone(pomodoro.get_uid())\n        self.assertNotEqual(incomplete[0].get_uid(), incomplete[1].get_uid())\n\n    def test_add_pomodoro_errors(self):\n        user, backlog, workitem = self._standard_workitem()\n        self.source.execute(AddPomodoroStrategy, ['w11', '1'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(AddPomodoroStrategy,\n                                                      ['w11', '0']))\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(AddPomodoroStrategy,\n                                                      ['w11', '-1']))\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(AddPomodoroStrategy,\n                                                      ['notfound', '1']))\n        self.source.execute(AddPomodoroStrategy, ['w11', '20'])\n\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(AddPomodoroStrategy,\n                                                      ['w11', '1']))\n\n    def test_remove_pomodoro_errors(self):\n        user, backlog, workitem = self._standard_workitem()\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['w11', '1']))\n        self.source.execute(AddPomodoroStrategy, ['w11', '3'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['w11', '0']))\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['w11', '-1']))\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['w11', '4']))\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['notfound', '1']))\n\n        self.source.execute(RemovePomodoroStrategy, ['w11', '1'])\n        self.assertEqual(2, len(workitem))\n        self.source.execute(RemovePomodoroStrategy, ['w11', '2'])\n        self.assertEqual(0, len(workitem))\n\n        self.source.execute(AddPomodoroStrategy, ['w11', '1'])\n\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RemovePomodoroStrategy,\n                                                      ['w11', '1']))\n\n    def test_add_remove_stack(self):\n        user, backlog, workitem = self._standard_workitem()\n        self.source.execute(AddPomodoroStrategy, ['w11', '3'])\n        [p1, p2, p3] = list[Pomodoro](workitem.values())\n\n        self.source.execute(RemovePomodoroStrategy, ['w11', '1'])\n        pomodoros = list(workitem.values())\n        self.assertEqual(2, len(pomodoros))\n        self.assertEqual(pomodoros[0], p1)\n        self.assertEqual(pomodoros[1], p2)\n\n        self.source.execute(AddPomodoroStrategy, ['w11', '1'])\n        pomodoros = list(workitem.values())\n        self.assertEqual(3, len(pomodoros))\n        self.assertEqual(pomodoros[0], p1)\n        self.assertEqual(pomodoros[1], p2)\n        p3 = pomodoros[2]\n\n        self.source.execute(RemovePomodoroStrategy, ['w11', '2'])\n        pomodoros = list(workitem.values())\n        self.assertEqual(1, len(pomodoros))\n        self.assertEqual(pomodoros[0], p1)\n\n    def _assert_pomodoro_and_timer(self,\n                                   pomodoro: Pomodoro,\n                                   state: str,\n                                   when: datetime.datetime,\n                                   elapsed: int,\n                                   elapsed_str: str = '',\n                                   remaining_str: str = '',\n                                   remaining_in_state_str: str = ''):\n        self.assertIn(state, ['idle', 'work', 'rest', 'finished'])\n        timer: TimerData = pomodoro.get_timer()\n        timer.update_remaining_duration(when)\n        if state == 'idle':\n            self.assertTrue(pomodoro.is_startable())\n            self.assertFalse(pomodoro.is_running())\n            self.assertFalse(pomodoro.is_resting())\n            self.assertFalse(pomodoro.is_working())\n            self.assertFalse(pomodoro.is_finished())\n            self.assertIsNone(pomodoro.get_work_start_date())\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                self.assertIsNone(pomodoro.get_rest_start_date())\n                self.assertIsNone(pomodoro.planned_end_of_rest())\n                self.assertEqual(pomodoro.remaining_time_in_current_state(when), 0)\n                self.assertIsNone(pomodoro.planned_end_of_work())\n                self.assertEqual(pomodoro.remaining_minutes_in_current_state_str(when), \"N/A\")\n            self.assertTrue(timer.is_idling())\n            self.assertFalse(timer.is_ticking())\n            self.assertFalse(timer.is_working())\n            self.assertFalse(timer.is_resting())\n            self.assertEqual(timer.get_planned_duration(), 0)\n            self.assertEqual(timer.get_remaining_duration(), 0)\n            self.assertEqual(timer.format_elapsed_work_duration(when), \"N/A\")\n            self.assertEqual(timer.format_remaining_duration(), \"00:00\")\n        elif state == 'work':\n            self.assertFalse(pomodoro.is_startable())\n            self.assertTrue(pomodoro.is_running())\n            self.assertFalse(pomodoro.is_resting())\n            self.assertTrue(pomodoro.is_working())\n            self.assertFalse(pomodoro.is_finished())\n            self.assertIsNotNone(pomodoro.get_work_start_date())\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                self.assertIsNone(pomodoro.get_rest_start_date())\n                self.assertEqual(pomodoro.planned_end_of_work(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1500))\n                self.assertEqual(pomodoro.planned_end_of_rest(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1800))\n                self.assertEqual(pomodoro.remaining_time_in_current_state(when), 1500 - elapsed)\n                self.assertEqual(timer.get_planned_duration(), 1500)\n                self.assertEqual(timer.get_remaining_duration(), 1500 - elapsed)\n                self.assertEqual(timer.format_remaining_duration(), remaining_str)\n                self.assertEqual(pomodoro.remaining_minutes_in_current_state_str(when), remaining_in_state_str)\n            self.assertEqual(timer.format_elapsed_work_duration(when), elapsed_str)\n            self.assertFalse(timer.is_idling())\n            self.assertTrue(timer.is_ticking())\n            self.assertTrue(timer.is_working())\n            self.assertFalse(timer.is_resting())\n        elif state == 'rest':\n            self.assertFalse(pomodoro.is_startable())\n            self.assertTrue(pomodoro.is_running())\n            self.assertTrue(pomodoro.is_resting())\n            self.assertFalse(pomodoro.is_working())\n            self.assertFalse(pomodoro.is_finished())\n            self.assertIsNotNone(pomodoro.get_work_start_date())\n            self.assertEqual(pomodoro.get_type(), POMODORO_TYPE_NORMAL)\n            self.assertIsNotNone(pomodoro.get_rest_start_date())\n            self.assertEqual(pomodoro.planned_end_of_work(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1500))\n            self.assertEqual(pomodoro.planned_end_of_rest(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1800))\n            self.assertEqual(pomodoro.remaining_time_in_current_state(when), 1800 - elapsed)\n            self.assertFalse(timer.is_idling())\n            self.assertTrue(timer.is_ticking())\n            self.assertFalse(timer.is_working())\n            self.assertTrue(timer.is_resting())\n            self.assertEqual(timer.get_planned_duration(), 300)\n            self.assertEqual(timer.get_remaining_duration(), 1800 - elapsed)\n            self.assertEqual(timer.format_elapsed_work_duration(when), elapsed_str)\n            self.assertEqual(timer.format_remaining_duration(), remaining_str)\n            self.assertEqual(pomodoro.remaining_minutes_in_current_state_str(when), remaining_in_state_str)\n        elif state == 'finished':\n            self.assertFalse(pomodoro.is_startable())\n            self.assertFalse(pomodoro.is_running())\n            self.assertFalse(pomodoro.is_resting())\n            self.assertFalse(pomodoro.is_working())\n            self.assertTrue(pomodoro.is_finished())\n            self.assertIsNotNone(pomodoro.get_work_start_date())\n            if pomodoro.get_type() == POMODORO_TYPE_NORMAL:\n                self.assertIsNotNone(pomodoro.get_rest_start_date())\n                self.assertEqual(pomodoro.planned_end_of_rest(), pomodoro.get_last_modified_date())\n                self.assertEqual(pomodoro.planned_end_of_work(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1500))\n                self.assertEqual(pomodoro.planned_end_of_rest(), pomodoro.get_work_start_date() + datetime.timedelta(seconds=1800))\n                self.assertEqual(pomodoro.remaining_time_in_current_state(when), 0)\n                self.assertEqual(timer.format_remaining_duration(), \"00:00\")\n                self.assertEqual(pomodoro.remaining_minutes_in_current_state_str(when), \"N/A\")\n            self.assertTrue(timer.is_idling())\n            self.assertFalse(timer.is_ticking())\n            self.assertFalse(timer.is_working())\n            self.assertFalse(timer.is_resting())\n            self.assertEqual(timer.get_planned_duration(), 0)\n            self.assertEqual(timer.get_remaining_duration(), 0)\n            self.assertEqual(timer.format_elapsed_work_duration(when), \"N/A\")\n\n    def test_planned_unplanned_pomodoro(self):\n        _, _, workitem, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        self.source.execute(AddPomodoroStrategy, ['w11', '1', POMODORO_TYPE_NORMAL], True, when)\n        [p1, p2] = workitem.values()\n        self.assertTrue(p1.is_planned())\n        self.assertFalse(p2.is_planned())\n\n    def test_start_work_normal_ok(self):\n        user, _, workitem, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self._assert_pomodoro_and_timer(pomodoro, 'idle', when, 0)\n        self.assertEqual(user.get_state(when), ('Idle', 0))\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'work', when, 0, \"0:00:00\", \"25:00\", \"25 minutes\")\n        self.assertEqual(user.get_state(when), ('Focus', \"25 minutes\"))\n\n    def test_start_work_tracker_ok(self):\n        user, backlog, workitem, [pomodoro] = self._standard_pomodoro(1, POMODORO_TYPE_TRACKER)\n        when = epyc()\n        self._assert_pomodoro_and_timer(pomodoro, 'idle', when, 0)\n        self.source.execute(StartTimerStrategy, ['w11'], True, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'work', when, 0, \"0:00:00\")\n        self.assertEqual(user.get_state(when), ('Tracking', 0))\n        self.assertEqual(backlog.get_running_workitem(), (workitem, pomodoro))\n\n    def test_start_work_tracker_raises(self):\n        _, _, workitem, [pomodoro] = self._standard_pomodoro(1, POMODORO_TYPE_TRACKER)\n        self.assertRaises(Exception, lambda: pomodoro.get_rest_start_date())\n        self.assertRaises(Exception, lambda: pomodoro.get_rest_duration())\n        self.assertRaises(Exception, lambda: pomodoro.planned_end_of_rest())\n        self.assertRaises(Exception, lambda: pomodoro.planned_end_of_work())\n        self.assertRaises(Exception, lambda: pomodoro.get_rest_duration())\n        self.assertRaises(Exception, lambda: pomodoro.update_rest_duration(1))\n        self.assertRaises(Exception, lambda: pomodoro.remaining_time_in_current_state(epyc()))\n        self.assertRaises(Exception, lambda: pomodoro.remaining_minutes_in_current_state_str(epyc()))\n\n    def test_auto_seal_shortly_from_work(self):\n        user, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=1550)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'rest', when, 1550, \"0:25:50\", \"04:10\", \"4 minutes\")\n        self.assertEqual(user.get_state(when), ('Rest', \"4 minutes\"))\n\n    def test_auto_seal_shortly_from_rest(self):\n        _, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=1550)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        when += datetime.timedelta(seconds=300)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'finished', when, 1850)\n\n    def test_auto_seal_long_after_from_work(self):\n        user, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=1850)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'finished', when, 1850)\n        self.assertEqual(user.get_state(when), ('Idle', 0))\n\n    def test_auto_seal_too_early(self):\n        _, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=600)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'work', when, 600, \"0:10:00\", \"15:00\", \"15 minutes\")\n\n    def test_auto_seal_twice(self):\n        _, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=1550)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'rest', when, 1550, \"0:25:50\", \"04:10\", \"4 minutes\")\n        when += datetime.timedelta(seconds=10)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'rest', when, 1560, \"0:26:00\", \"04:00\", \"4 minutes\")\n\n    def test_auto_seal_unneeded(self):\n        _, _, _, [pomodoro] = self._standard_pomodoro(1)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11', '1500', '300'], True, when)\n        when += datetime.timedelta(seconds=1550)\n        self.source.execute(RenameWorkitemStrategy, ['w11', 'New name'], True, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'work', when, 1500, \"0:25:50\", \"00:00\", \"N/A\")\n\n    def test_auto_seal_strategies(self):\n        # For all strategies check if the pomodoro was auto-sealed\n        pass\n\n    def test_auto_seal_tracker(self):\n        # No auto-seal for trackers. All other tests use normal pomodoros.\n        _, _, _, [pomodoro] = self._standard_pomodoro(1, POMODORO_TYPE_TRACKER)\n        when = epyc()\n        self.source.execute(StartTimerStrategy, ['w11'], True, when)\n        when += datetime.timedelta(seconds=1550)\n        self.source.execute(AutoSealInternalStrategy, [], False, when)\n        self._assert_pomodoro_and_timer(pomodoro, 'work', when, 1550,  \"0:25:50\")\n\n    def test_auto_seal_long_break(self):\n        # No auto-seal for long breaks. All other tests have short breaks.\n        pass\n\n"
  },
  {
    "path": "src/fk/tests/test_settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\n\nclass TestSettings(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    def test_defaults(self):\n        val1 = self.settings.get('Pomodoro.default_work_duration')\n        self.assertEqual(val1, str(25 * 60))\n        val2 = self.settings.get('Application.timer_ui_mode')\n        self.assertEqual(val2, 'focus')\n\n    def test_invalid_setting(self):\n        self.assertRaises(Exception,\n                          lambda: self.settings.get('Invalid.name'))\n\n    def test_categories(self):\n        categories = self.settings.get_categories()\n        self.assertEqual(len(categories), 7)\n        self.assertIn('General', categories)\n        self.settings.set({\n            'Pomodoro.default_work_duration': '10',\n        })\n        general = self.settings.get_settings('General')\n        found = False\n        for s in general:\n            if s[0] == 'Pomodoro.default_work_duration':\n                found = True\n                self.assertEqual(s[1], 'duration')\n                self.assertEqual(s[3], '10')\n        self.assertTrue(found)\n\n    def test_get_set(self):\n        self.settings.set({\n            'Pomodoro.default_work_duration': '11',\n        })\n        self.assertEqual(self.settings.get('Pomodoro.default_work_duration'), '11')\n\n    def test_clear(self):\n        # What's the difference between this and reset_to_defaults()?\n        self.settings.set({\n            'Pomodoro.default_work_duration': '12',\n        })\n        self.settings.clear()\n        self.assertEqual(self.settings.get('Pomodoro.default_work_duration'), str(25 * 60))\n\n    def test_reset(self):\n        self.settings.set({\n            'Pomodoro.default_work_duration': '13',\n        })\n        self.settings.reset_to_defaults()\n        self.assertEqual(self.settings.get('Pomodoro.default_work_duration'), str(25 * 60))\n\n    def test_location(self):\n        self.assertEqual(self.settings.location(), 'N/A')\n\n    def test_shortcuts(self):\n        self.settings.set({\n            'Source.type': 'local',\n            'Pomodoro.default_work_duration': '14',\n            'Pomodoro.default_rest_duration': '15',\n            'Source.fullname': 'John Doe',\n        })\n        self.assertEqual(self.settings.get_username(), 'user@local.host')\n        self.assertEqual(self.settings.get_work_duration(), 14)\n        self.assertEqual(self.settings.get_rest_duration(), 15)\n        self.assertEqual(self.settings.get_fullname(), 'John Doe')\n        self.assertFalse(self.settings.is_team_supported(), False)\n        self.settings.set({\n            'Source.type': 'flowkeeper.org',\n            'WebsocketEventSource.username': 'alice@example.org',\n            'Application.enable_teams': 'True',\n        })\n        self.assertEqual(self.settings.get_username(), 'alice@example.org')\n        self.assertTrue(self.settings.is_team_supported())\n\n    def test_visibility(self):\n        self.settings.reset_to_defaults()\n        visible = self.settings.get_displayed_settings()\n        # Always\n        self.assertIn('Source.type', visible)\n        self.assertIn('Application.eyecandy_type', visible)\n        self.assertIn('Pomodoro.default_work_duration', visible)\n        self.assertIn('Application.play_tick_sound', visible)\n        # Never\n        self.assertNotIn('Application.window_width', visible)\n        self.assertNotIn('Application.show_status_bar', visible)\n        self.assertNotIn('WebsocketEventSource.refresh_token!', visible)\n        self.assertNotIn('Source.fullname', visible)\n        self.assertNotIn('Application.hide_completed', visible)\n        # For file event source\n        self.assertIn('FileEventSource.filename', visible)\n        self.assertNotIn('WebsocketEventSource.auth_type', visible)\n        self.assertNotIn('WebsocketEventSource.url', visible)\n        # For Flowkeeper.org event source\n        self.settings.set({\n            'Source.type': 'flowkeeper.org',\n        })\n        visible = self.settings.get_displayed_settings()\n        self.assertNotIn('FileEventSource.filename', visible)\n        self.assertIn('WebsocketEventSource.auth_type', visible)\n        self.assertNotIn('WebsocketEventSource.username', visible)\n        self.assertNotIn('WebsocketEventSource.url', visible)\n        # For custom WS event source\n        self.settings.set({\n            'Source.type': 'websocket',\n            'WebsocketEventSource.auth_type': 'basic',\n        })\n        visible = self.settings.get_displayed_settings()\n        self.assertIn('WebsocketEventSource.username', visible)\n        self.assertIn('WebsocketEventSource.url', visible)\n"
  },
  {
    "path": "src/fk/tests/test_tags.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.tag import Tag\nfrom fk.core.tags import Tags\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.user_strategies import CreateUserStrategy\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, DeleteWorkitemStrategy, RenameWorkitemStrategy\n\n\nclass TestTags(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    # TODO: Move it to superclass\n    def _standard_backlog(self) -> (User, Backlog):\n        user = self.data['user@local.host']\n        if 'b1' not in user:\n            self.source.execute(CreateBacklogStrategy, ['b1', 'First backlog'])\n            return user, user['b1']\n\n    def _add_workitem(self, name: str, uid: str = 'w11') -> Workitem:\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, [uid, 'b1', name])\n        return self.data['user@local.host']['b1'][uid]\n\n    def _delete_workitem(self, uid: str) -> None:\n        self.source.execute(DeleteWorkitemStrategy, [uid])\n\n    def _rename_workitem(self, uid: str, new_name: str) -> None:\n        self.source.execute(RenameWorkitemStrategy, [uid, new_name])\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    # - Invalid tag names\n    def test_create_workitem_without_tags(self):\n        user = self.data['user@local.host']\n        self._add_workitem('There are no tags', 'w11')\n        self._add_workitem('Trying ## x', 'w12')\n        self._add_workitem('Trying #. x', 'w13')\n        self._add_workitem('Trying # x', 'w14')\n        self._add_workitem('# trying', 'w15')\n        self._add_workitem('##. another', 'w16')\n        self.assertEqual(len(user.get_tags()), 0)\n\n    # - Valid tags (beginning of string, end, only tag, no value, etc.)\n    def test_create_workitem_with_tags(self):\n        user = self.data['user@local.host']\n        self._add_workitem('There is #one tag', 'w11')\n        self._add_workitem('There are #two tags #three', 'w12')\n        self._add_workitem('There are #four and #four identical tags', 'w13')\n        self._add_workitem('#five', 'w14')\n        self._add_workitem('#6', 'w15')\n        self._add_workitem('And #seven', 'w16')\n        self._add_workitem('###eight', 'w17')\n        self._add_workitem('# nine', 'w18')\n        self._add_workitem('And another #one', 'w19')\n        self._add_workitem('#десять', 'w20')\n        self._add_workitem('#11eleven11', 'w21')\n        self._add_workitem('#twelve_12', 'w22')\n        self._add_workitem('#_', 'w23')\n        self._add_workitem('#fourteen-14', 'w24')\n        self._add_workitem('#fifteen_15', 'w25')\n        self._add_workitem('#SIXTEEN', 'w26')\n        self._add_workitem('#Sixteen', 'w27')\n        tags = user.get_tags()\n        self.assertEqual(len(tags), 15)\n\n        self.assertIn('one', tags)\n        self.assertIn('two', tags)\n        self.assertIn('three', tags)\n        self.assertIn('four', tags)\n        self.assertIn('five', tags)\n        self.assertIn('6', tags)\n        self.assertIn('seven', tags)\n        self.assertIn('eight', tags)\n        self.assertIn('десять', tags)\n        self.assertIn('11eleven11', tags)\n        self.assertIn('twelve_12', tags)\n        self.assertIn('_', tags)\n        self.assertIn('fourteen', tags)\n        self.assertIn('fifteen_15', tags)\n        self.assertIn('sixteen', tags)\n\n    # - DeleteWorkitem\n    def test_delete_workitem(self):\n        user = self.data['user@local.host']\n        self._add_workitem('There is #one tag', 'w11')\n        self._add_workitem('There is #another #one', 'w12')\n        self._add_workitem('And #another one', 'w13')\n        tags = user.get_tags()\n        self.assertEqual(len(tags), 2)\n        self.assertIn('another', tags)\n        self.assertIn('one', tags)\n\n        self._delete_workitem('w11')\n        self.assertEqual(len(tags), 2)\n\n        self._delete_workitem('w12')\n        self.assertEqual(len(tags), 1)\n        self.assertIn('another', tags)\n\n        self._delete_workitem('w13')\n        self.assertEqual(len(tags), 0)\n\n    # - RenameWorkitem -- add, delete, no change\n    def test_rename_workitem(self):\n        user = self.data['user@local.host']\n        self._add_workitem('There is #one tag', 'w11')\n        self._add_workitem('There is #another #one', 'w12')\n        self._add_workitem('And #another one', 'w13')\n        tags = user.get_tags()\n        self.assertEqual(len(tags), 2)\n\n        self._rename_workitem('w11', '#Third tag')\n        self.assertEqual(len(tags), 3)\n        self.assertIn('another', tags)\n        self.assertIn('one', tags)\n        self.assertIn('third', tags)\n\n        self._rename_workitem('w11', 'No tag')\n        self.assertEqual(len(tags), 2)\n        self.assertIn('another', tags)\n        self.assertIn('one', tags)\n\n        self._rename_workitem('w12', 'Also no tags here')\n        self.assertEqual(len(tags), 1)\n        self.assertIn('another', tags)\n\n        self._rename_workitem('w13', 'Removed all tags')\n        self.assertEqual(len(tags), 0)\n\n        self._rename_workitem('w11', 'Added #two #tags')\n        self.assertEqual(len(tags), 2)\n        self.assertIn('two', tags)\n        self.assertIn('tags', tags)\n\n        self._rename_workitem('w11', 'Added #last #tags')\n        self.assertEqual(len(tags), 2)\n        self.assertIn('last', tags)\n        self.assertIn('tags', tags)\n\n        self._rename_workitem('w11', 'Added #last #tags')\n        self.assertEqual(len(tags), 2)\n        self.assertIn('last', tags)\n        self.assertIn('tags', tags)\n\n    # - Tag accessors in event source\n    def test_event_source(self):\n        user = self.data['user@local.host']\n        self._add_workitem('#one #two #three')\n\n        found_one = False\n        found_two = False\n        found_three = False\n        for tag in self.source.tags():\n            self.assertIn(tag.get_uid(), ['one', 'two', 'three'])\n            if tag.get_uid() == 'one':\n                found_one = True\n            elif tag.get_uid() == 'two':\n                found_two = True\n            elif tag.get_uid() == 'three':\n                found_three = True\n        self.assertTrue(found_one)\n        self.assertTrue(found_two)\n        self.assertTrue(found_three)\n\n        self.assertIsNotNone(self.source.find_tag('one'))\n        self.assertIsNone(self.source.find_tag('four'))\n        self.assertEqual(self.source.find_tag('one').get_uid(), 'one')\n\n    # - Reverse workitem accessors\n    def test_workitem_accessors(self):\n        user = self.data['user@local.host']\n        w11 = self._add_workitem('There is #one tag', 'w11')\n        w12 = self._add_workitem('There is #another #one', 'w12')\n        w13 = self._add_workitem('And #another one', 'w13')\n        self._rename_workitem('w11', 'Now it is #another tag')\n        self._rename_workitem('w13', '#one here')\n\n        tags = user.get_tags()\n        self.assertEqual(len(tags), 2)\n        tag_one = tags['one'].get_workitems()\n        tag_another = tags['another'].get_workitems()\n\n        self.assertEqual(len(tag_one), 2)\n        self.assertEqual(len(tag_another), 2)\n        self.assertIn(w12, tag_one)\n        self.assertIn(w13, tag_one)\n        self.assertIn(w11, tag_another)\n        self.assertIn(w12, tag_another)\n\n    # - Find tags in a workitem\n    def test_find_tags(self):\n        user = self.data['user@local.host']\n        w11 = self._add_workitem('#one #1 # ###десять #_TAG #one| #1', 'w11')\n        tags = w11.get_tags()\n        self.assertEqual(len(tags), 4)\n        self.assertIn('one', tags)\n        self.assertIn('1', tags)\n        self.assertIn('десять', tags)\n        self.assertIn('_tag', tags)\n\n        w12 = self._add_workitem('', 'w12')\n        tags = w12.get_tags()\n        self.assertEqual(len(tags), 0)\n\n    # - Get tags for different users\n    def test_different_users(self):\n        user = self.data['user@local.host']\n        w11_local = self._add_workitem('#local_tag', 'w11')\n\n        self.source.execute_prepared_strategy(CreateUserStrategy(\n            11,\n            datetime.datetime.now(datetime.timezone.utc),\n            'admin@local.host',\n            ['alice', 'Alice'],\n            self.settings,\n            None), False, True)\n\n        self.source.execute_prepared_strategy(CreateUserStrategy(\n            12,\n            datetime.datetime.now(datetime.timezone.utc),\n            'admin@local.host',\n            ['bob', 'Bob'],\n            self.settings,\n            None), False, True)\n\n        self.source.execute_prepared_strategy(CreateBacklogStrategy(\n            13,\n            datetime.datetime.now(datetime.timezone.utc),\n            'alice',\n            ['b1', 'First backlog'],\n            self.settings,\n            None), False, True)\n\n        self.source.execute_prepared_strategy(CreateBacklogStrategy(\n            14,\n            datetime.datetime.now(datetime.timezone.utc),\n            'bob',\n            ['b1', 'First backlog'],\n            self.settings,\n            None), False, True)\n\n        self.source.execute_prepared_strategy(CreateWorkitemStrategy(\n            15,\n            datetime.datetime.now(datetime.timezone.utc),\n            'alice',\n            ['w11', 'b1', '#alice_tag'],\n            self.settings,\n            None), False, True)\n\n        self.source.execute_prepared_strategy(CreateWorkitemStrategy(\n            16,\n            datetime.datetime.now(datetime.timezone.utc),\n            'bob',\n            ['w11', 'b1', '#bob_tag'],\n            self.settings,\n            None), False, True)\n\n\n        local_tags = self.data['user@local.host'].get_tags()\n        self.assertEqual(len(local_tags), 1)\n        self.assertIn('local_tag', local_tags)\n\n        alice_tags = self.data['alice'].get_tags()\n        self.assertEqual(len(alice_tags), 1)\n        self.assertIn('alice_tag', alice_tags)\n\n        bob_tags = self.data['bob'].get_tags()\n        self.assertEqual(len(bob_tags), 1)\n        self.assertIn('bob_tag', bob_tags)\n\n\n    # - Check if FileEventSource loads tags correctly\n    def test_file_event_source(self):\n        settings = MockSettings(filename='src/fk/tests/fixtures/test-tags.txt')\n        source = FileEventSource[Tenant](settings,\n                                         FernetCryptograph(settings),\n                                         Tenant(settings))\n        source.start()\n        data = source.get_data()\n        self.assertIn('alice@flowkeeper.org', data)\n\n        tags = data['alice@flowkeeper.org'].get_tags()\n        self.assertEqual(len(tags), 5)\n        self.assertIn('one', tags)\n        self.assertIn('1', tags)\n        self.assertIn('десять', tags)\n        self.assertIn('_tag', tags)\n        self.assertIn('two', tags)\n\n        source.execute_prepared_strategy(CreateWorkitemStrategy(\n            5,\n            datetime.datetime.now(datetime.timezone.utc),\n            'alice@flowkeeper.org',\n            ['w13', '123-456-789', 'Tags #three and #four'],\n            self.settings,\n            None), False, False)\n        self.assertEqual(len(tags), 7)\n        self.assertIn('three', tags)\n        self.assertIn('four', tags)\n\n    # - Tags class\n    def test_tags_class(self):\n        w = self._add_workitem('There is #one tag', 'w11')\n        user = self.data['user@local.host']\n\n        tags = user.get_tags()\n        self.assertEqual(type(tags), Tags)\n        self.assertEqual(tags.get_parent(), user)\n\n        tag = tags['one']\n        self.assertEqual(type(tag), Tag)\n        self.assertEqual(tag.get_parent(), tags)\n\n    # - Events: TagDeleted\n    def test_tag_deleted_event(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'TagDeleted':\n                self.assertIn('tag', kwargs)\n                tag = kwargs['tag']\n                self.assertEqual(tag.get_uid(), 'deleted')\n                self.assertEqual(tag.get_parent().get_parent(), self.data['user@local.host'])\n            elif event == 'TagContentChanged':\n                self.assertIn('tag', kwargs)\n                tag = kwargs['tag']\n                self.assertEqual(tag.get_uid(), 'deleted')\n                self.assertEqual(len(tag.get_workitems()), 0)\n\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Tags #one, #two and #deleted'])\n\n        self.source.on('*', on_event)\n        self.source.execute(RenameWorkitemStrategy, ['w11', 'Tags #one and #two only'])\n\n        self.assertEqual(len(fired), 6)\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeWorkitemRename')\n        self.assertEqual(fired[2], 'TagContentChanged')\n        self.assertEqual(fired[3], 'TagDeleted')\n        self.assertEqual(fired[4], 'AfterWorkitemRename')\n        self.assertEqual(fired[5], 'AfterMessageProcessed')\n\n    # - Events: TagContentChanged\n    def test_tag_content_changed_event(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'TagContentChanged':\n                self.assertIn('tag', kwargs)\n                tag = kwargs['tag']\n                self.assertEqual(tag.get_uid(), 'new')\n                self.assertEqual(len(tag.get_workitems()), 2)\n\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', '#New workitem'])\n\n        self.source.on('*', on_event)\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Another #new workitem'])\n\n        self.assertEqual(len(fired), 5)\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeWorkitemCreate')\n        self.assertEqual(fired[2], 'TagContentChanged')\n        self.assertEqual(fired[3], 'AfterWorkitemCreate')\n        self.assertEqual(fired[4], 'AfterMessageProcessed')\n"
  },
  {
    "path": "src/fk/tests/test_users.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.tenant import ADMIN_USER\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\nfrom fk.core.user_strategies import CreateUserStrategy, RenameUserStrategy, DeleteUserStrategy\nfrom fk.tests.test_utils import (epyc)\n\n\nclass TestUsers(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    def _assert_user(self, user: User):\n        self.assertEqual(user.get_name(), 'Alice Cooper')\n        self.assertEqual(user.get_uid(), 'u1')\n        self.assertEqual(user.get_parent(), self.data)\n        self.assertEqual(user.get_owner(), None)\n        self.assertEqual(len(user.values()), 0)\n        self.assertEqual(len(user.get_tags()), 0)\n\n    def _create_standard_user(self):\n        self.source.execute_prepared_strategy(\n            CreateUserStrategy(2, epyc(), ADMIN_USER, ['u1', 'Alice Cooper'], self.settings, None))\n\n    def test_create_user(self):\n        self._create_standard_user()\n        self.assertIn('u1', self.data)\n        self._assert_user(self.data['u1'])\n\n    def test_create_user_unauthorized_failure(self):\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(CreateUserStrategy, ['u1', 'Alice Cooper']))\n\n    def test_create_duplicate_user_failure(self):\n        self._create_standard_user()\n        self.assertRaises(Exception, self._create_standard_user)\n\n    def test_rename_nonexistent_user_failure(self):\n        self._create_standard_user()\n        self.assertRaises(Exception,\n                          lambda: self.source.execute_prepared_strategy(\n                              RenameUserStrategy(3, epyc(), ADMIN_USER, ['u2', 'Bob'], self.settings, None)))\n\n    def test_rename_user(self):\n        self._create_standard_user()\n        self.source.execute_prepared_strategy(\n            RenameUserStrategy(3, epyc(), ADMIN_USER, ['u1', 'Bob'], self.settings, None))\n        self.assertEqual(self.data['u1'].get_name(), 'Bob')\n\n    def test_delete_nonexistent_user_failure(self):\n        self._create_standard_user()\n        self.assertRaises(Exception,\n                          lambda: self.source.execute_prepared_strategy(\n                              DeleteUserStrategy(3, epyc(), ADMIN_USER, ['u2'], self.settings, None)))\n\n    def test_delete_user(self):\n        self._create_standard_user()\n        self.source.execute_prepared_strategy(\n            DeleteUserStrategy(3, epyc(), ADMIN_USER, ['u1'], self.settings, None))\n        self.assertNotIn('u1', self.data)\n\n    def test_events_create_user(self):\n        fired = list()\n        def on_event(event, **kwargs):\n            self.assertIn(event, ['BeforeMessageProcessed', 'BeforeUserCreate', 'AfterUserCreate', 'AfterMessageProcessed'])\n            fired.append(event)\n            if event == 'BeforeMessageProcessed' or event == 'AfterMessageProcessed':\n                self.assertIn('strategy', kwargs)\n                self.assertIn('auto', kwargs)\n                self.assertIn('persist', kwargs)\n                self.assertTrue(type(kwargs['strategy']) is CreateUserStrategy)\n            elif event == 'BeforeUserCreate':\n                self.assertIn('user_identity', kwargs)\n                self.assertIn('user_name', kwargs)\n                self.assertEqual(kwargs['user_identity'], 'u1')\n                self.assertEqual(kwargs['user_name'], 'Alice Cooper')\n            elif event == 'AfterUserCreate':\n                self.assertIn('user', kwargs)\n                self.assertTrue(type(kwargs['user']) is User)\n        self.source.on('*', on_event)\n        self._create_standard_user()\n        self.assertEqual(len(fired), 4)\n\n    def test_events_delete_user(self):\n        # Here we shall also test the recursive deletion\n        fired = list()\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'BeforeUserDelete' or event == 'AfterUserDelete':\n                self.assertIn('user', kwargs)\n                self.assertTrue(type(kwargs['user']) is User)\n                self.assertEqual(kwargs['user'].get_name(), 'Local User')\n            elif event == 'BeforeBacklogDelete' or event == 'AfterBacklogDelete':\n                self.assertIn('backlog', kwargs)\n                self.assertTrue(type(kwargs['backlog']) is Backlog)\n                self.assertEqual(kwargs['backlog'].get_name(), 'Backlog')\n        self.source.execute(CreateBacklogStrategy, ['b1', 'Backlog'])\n        self.source.on('*', on_event)  # We only care about delete here\n        self.source.execute_prepared_strategy(\n            DeleteUserStrategy(2, epyc(), ADMIN_USER, ['user@local.host'], self.settings, None))\n        self.assertEqual(len(fired), 8)\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeUserDelete')\n        self.assertEqual(fired[2], 'BeforeMessageProcessed')  # auto=True\n        self.assertEqual(fired[3], 'BeforeBacklogDelete')\n        self.assertEqual(fired[4], 'AfterBacklogDelete')\n        self.assertEqual(fired[5], 'AfterMessageProcessed')  # auto=True\n        self.assertEqual(fired[6], 'AfterUserDelete')\n        self.assertEqual(fired[7], 'AfterMessageProcessed')\n\n    def test_events_rename_user(self):\n        fired = list()\n        def on_event(event, **kwargs):\n            fired.append(event)\n            if event == 'BeforeUserRename' or event == 'AfterUserRename':\n                self.assertIn('user', kwargs)\n                self.assertIn('old_name', kwargs)\n                self.assertIn('new_name', kwargs)\n                self.assertEqual(kwargs['old_name'], 'Alice Cooper')\n                self.assertEqual(kwargs['new_name'], 'Bob')\n                self.assertTrue(type(kwargs['user']) is User)\n        self._create_standard_user()\n        self.source.on('*', on_event)\n        self.source.execute_prepared_strategy(\n            RenameUserStrategy(3, epyc(), ADMIN_USER, ['u1', 'Bob'], self.settings, None))\n        self.assertEqual(len(fired), 4)\n        self.assertEqual(fired[0], 'BeforeMessageProcessed')\n        self.assertEqual(fired[1], 'BeforeUserRename')\n        self.assertEqual(fired[2], 'AfterUserRename')\n        self.assertEqual(fired[3], 'AfterMessageProcessed')\n"
  },
  {
    "path": "src/fk/tests/test_utils.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom __future__ import annotations\n\nimport datetime\nimport math\nimport secrets\nimport sys\nfrom typing import TypeVar\n\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\nPREDEFINED_TIMESTAMP = [1632408308, 1666626369, 1700938030]\nPREDEFINED_UID = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005']\nTEST_USERNAMES = ['alice@flowkeeper.org', 'bob@flowkeeper.org', 'charlie@flowkeeper.org']\n_T = TypeVar('_T')\n\n\ndef check_timestamp(t: datetime.datetime, n: int) -> bool:\n    return int(t.timestamp()) == PREDEFINED_TIMESTAMP[n]\n\n\ndef predefined_datetime(n: int) -> datetime.datetime:\n    return datetime.datetime.fromtimestamp(PREDEFINED_TIMESTAMP[n], tz=datetime.timezone.utc)\n\n\ndef predefined_uid(n: int) -> str:\n    return PREDEFINED_UID[n]\n\n\ndef noop_emit(event: str, params: dict[str, any], carry: any) -> None:\n    pass\n\n\ndef test_user(n: int) -> User:\n    return User(\n        None,\n        TEST_USERNAMES[n],\n        f'Test User #{n}',\n        predefined_datetime(0),\n        False)\n\n\ndef test_users() -> dict[str, User]:\n    return {\n        TEST_USERNAMES[0]: test_user(0),\n        TEST_USERNAMES[1]: test_user(1),\n        TEST_USERNAMES[2]: test_user(2),\n    }\n\n\ndef test_settings(n: int) -> AbstractSettings:\n    return MockSettings(username=TEST_USERNAMES[n])\n\n\ndef test_data() -> Tenant:\n    tenant = Tenant(test_settings(0))\n    users = test_users()\n    for u in users:\n        tenant[u] = users[u]\n    return tenant\n\n\ndef epyc() -> datetime.datetime:\n    return datetime.datetime(2025, 1, 1, 15, 0, 0, tzinfo=datetime.timezone.utc)\n\n\n##########################################################################################\n# Random stuff\n##########################################################################################\ndef one_of(seq: list[_T]) -> _T:\n    return secrets.choice(seq)\n\n\ndef randint(a: int, b: int) -> int:\n    return a + secrets.randbelow(b + 1)\n\n\ndef random() -> float:\n    # This is slow, but works correctly\n    sz = sys.maxsize\n    if sz == 0:\n        sz = 8  # This should never happen\n    return secrets.randbits(int(8 * math.log(sys.maxsize, 256))) / sys.maxsize\n\n\n# Good enough normally distributed random number\ndef rand_normal(a: int, b: int) -> int:\n    return round(sum([randint(a, b) for x in range(5)]) / 5)\n\n\ndef shuffle(seq: list[_T]) -> list[_T]:\n    res = list()\n    lst = list(seq)\n    while len(lst) > 0:\n        v = one_of(lst)\n        res.append(v)\n        lst.remove(v)\n    return res\n\n"
  },
  {
    "path": "src/fk/tests/test_workitems.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport datetime\nimport logging\nfrom unittest import TestCase\n\nfrom fk.core.abstract_cryptograph import AbstractCryptograph\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.backlog import Backlog\nfrom fk.core.backlog_strategies import CreateBacklogStrategy\nfrom fk.core.ephemeral_event_source import EphemeralEventSource\nfrom fk.core.fernet_cryptograph import FernetCryptograph\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.pomodoro import Pomodoro\nfrom fk.core.pomodoro_strategies import AddPomodoroStrategy\nfrom fk.core.tenant import Tenant\nfrom fk.core.timer_strategies import StartTimerStrategy\nfrom fk.core.user import User\nfrom fk.core.workitem import Workitem\nfrom fk.core.workitem_strategies import CreateWorkitemStrategy, RenameWorkitemStrategy, DeleteWorkitemStrategy, \\\n    CompleteWorkitemStrategy, MoveWorkitemStrategy\n\n\nclass TestWorkitems(TestCase):\n    settings: AbstractSettings\n    cryptograph: AbstractCryptograph\n    source: EphemeralEventSource\n    data: dict[str, User]\n\n    def setUp(self) -> None:\n        logging.getLogger().setLevel(logging.DEBUG)\n        self.settings = MockSettings()\n        self.cryptograph = FernetCryptograph(self.settings)\n        self.source = EphemeralEventSource[Tenant](self.settings, self.cryptograph, Tenant(self.settings))\n        self.source.start()\n        self.data = self.source.get_data()\n\n    def tearDown(self) -> None:\n        self.source.dump()\n\n    def _assert_workitem(self, workitem1: Workitem, user: User, backlog: Backlog):\n        self.assertEqual(workitem1.get_name(), 'First workitem')\n        self.assertEqual(workitem1.get_uid(), 'w11')\n        self.assertEqual(workitem1.get_parent(), backlog)\n        self.assertEqual(workitem1.get_owner(), user)\n        self.assertFalse(workitem1.is_running())\n        self.assertFalse(workitem1.is_sealed())\n        self.assertFalse(workitem1.is_startable())\n        self.assertFalse(workitem1.has_running_pomodoro())\n        self.assertTrue(workitem1.is_planned())\n        self.assertEqual(len(workitem1.values()), 0)\n        \n    def _standard_backlog(self) -> (User, Backlog): \n        self.source.execute(CreateBacklogStrategy, ['b1', 'First backlog'])\n        user = self.data['user@local.host']\n        backlog = user['b1']\n        return user, backlog\n\n    def test_create_workitems(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second workitem'])\n        self.assertIn('w11', backlog)\n        self.assertIn('w12', backlog)\n        workitem1: Workitem = backlog['w11']\n        self._assert_workitem(workitem1, user, backlog)\n        workitem2 = backlog['w12']\n        self.assertEqual(workitem2.get_name(), 'Second workitem')\n\n    def test_create_workitems_with_tags(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', '#Second workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w13', 'b1', '#Third #workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w14', 'b1', 'Fourth #workitem and some more #workitem text'])\n        self.source.execute(CreateWorkitemStrategy, ['w15', 'b1', 'Fifth #workitem.'])\n        self.source.execute(CreateWorkitemStrategy, ['w16', 'b1', 'Six #workitem and #workitems'])\n        self.assertIn('w11', backlog)\n        self.assertIn('w12', backlog)\n        self.assertIn('w13', backlog)\n        self.assertIn('w14', backlog)\n        self.assertIn('w15', backlog)\n        self.assertIn('w16', backlog)\n        workitem1: Workitem = backlog['w11']\n        self._assert_workitem(workitem1, user, backlog)\n        workitem2 = backlog['w12']\n        self.assertEqual(workitem2.get_name(), '#Second workitem')\n        workitem3 = backlog['w13']\n        self.assertEqual(workitem3.get_name(), '#Third #workitem')\n        workitem4 = backlog['w14']\n        self.assertEqual(workitem4.get_name(), 'Fourth #workitem and some more #workitem text')\n        workitem5 = backlog['w15']\n        self.assertEqual(workitem5.get_name(), 'Fifth #workitem.')\n        workitem6 = backlog['w16']\n        self.assertEqual(workitem6.get_name(), 'Six #workitem and #workitems')\n        tags = user.get_tags()\n        self.assertEqual(len(tags), 4)\n        self.assertIn('workitem', tags)\n        self.assertIn('second', tags)\n        self.assertIn('third', tags)\n        self.assertIn('workitems', tags)\n        workitems = tags['workitem'].get_workitems()\n        self.assertEqual(len(workitems), 4)\n        self.assertIn(workitem3, workitems)\n        self.assertIn(workitem4, workitems)\n        self.assertIn(workitem5, workitems)\n        self.assertIn(workitem6, workitems)\n\n    def test_execute_prepared(self):\n        user, backlog = self._standard_backlog()\n        s = CreateWorkitemStrategy(2,\n                                  datetime.datetime.now(datetime.timezone.utc),\n                                  user.get_identity(),\n                                  ['w11', 'b1', 'First workitem'],\n                                  self.settings)\n        self.source.execute_prepared_strategy(s)\n        self.assertIn('w11', backlog)\n        workitem1: Workitem = backlog['w11']\n        self._assert_workitem(workitem1, user, backlog)\n\n    def test_create_duplicate_workitem_failure(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem 1'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem 2']))\n\n    def test_rename_nonexistent_workitem_failure(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second workitem'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RenameWorkitemStrategy, ['w13', 'Renamed workitem']))\n\n    def test_rename_workitem(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(RenameWorkitemStrategy, ['w11', 'Renamed workitem'])\n        self.assertEqual(backlog['w11'].get_name(), 'Renamed workitem')\n\n    def test_delete_nonexistent_workitem_failure(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second workitem'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(DeleteWorkitemStrategy, ['w13']))\n\n    def test_delete_workitem(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second workitem'])\n        self.assertIn('w11', backlog)\n        self.source.execute(DeleteWorkitemStrategy, ['w11'])\n        self.assertNotIn('w11', backlog)\n        self.assertIn('w12', backlog)\n\n    def test_complete_workitem_basic(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        workitem = backlog['w11']\n        incomplete = list(backlog.get_incomplete_workitems())\n        self.assertEqual(len(incomplete), 1)\n        self.assertEqual(incomplete[0], workitem)\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertIn('w11', backlog)\n        self.assertFalse(workitem.is_startable())\n        self.assertTrue(workitem.is_sealed())\n        self.assertFalse(workitem.is_running())\n        self.assertFalse(workitem.has_running_pomodoro())\n        incomplete = list(backlog.get_incomplete_workitems())\n        self.assertEqual(len(incomplete), 0)\n\n    def test_complete_workitem_with_two_pomodoros(self):\n        user, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(AddPomodoroStrategy, ['w11', '2'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertFalse(backlog['w11'].is_startable())\n\n    def test_complete_workitem_invalid_state(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(CompleteWorkitemStrategy, ['w11', 'invalid']))\n\n    def test_complete_workitem_twice(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished']))\n\n    def test_rename_completed_workitem(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Before'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(RenameWorkitemStrategy, ['w11', 'After']))\n\n    def test_add_pomodoro_to_completed_workitem(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Before'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(AddPomodoroStrategy, ['w11', '1']))\n\n    def test_delete_completed_workitem(self):\n        _, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Before'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.source.execute(DeleteWorkitemStrategy, ['w11'])\n        self.assertNotIn('w11', backlog)\n\n    def test_start_completed_workitem(self):\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Before'])\n        self.source.execute(AddPomodoroStrategy, ['w11', '1'])\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertRaises(Exception,\n                          lambda: self.source.execute(StartTimerStrategy, ['w11', '1', '1']))\n\n    # Next -- Test all workitem-specific stuff (check coverage)\n    # - Lifecycle, including automatic voiding and completion of pomodoros (check all situations)\n    # - State -- isStartable based on pomodoros\n    # - Isolation between backlogs\n    # - That we can find them via the Source\n    # - Check update timestamps\n    # - Add (2), (3) and (4) to backlogs, too\n\n    def test_events_create_workitem(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            if event not in ('BeforeMessageProcessed', 'AfterMessageProcessed'):\n                fired.append(event)\n            if event == 'BeforeWorkitemCreate':\n                self.assertIn('workitem_uid', kwargs)\n                self.assertIn('backlog_uid', kwargs)\n                self.assertIn('workitem_name', kwargs)\n                self.assertEqual(kwargs['workitem_uid'], 'w11')\n                self.assertEqual(kwargs['backlog_uid'], 'b1')\n                self.assertEqual(kwargs['workitem_name'], 'First workitem')\n            elif event == 'AfterWorkitemCreate':\n                self.assertIn('workitem', kwargs)\n                self.assertTrue(type(kwargs['workitem']) is Workitem)\n\n        self._standard_backlog()\n        self.source.on('*', on_event)\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.assertEqual(len(fired), 2)\n        self.assertEqual(fired[0], 'BeforeWorkitemCreate')\n        self.assertEqual(fired[1], 'AfterWorkitemCreate')\n\n    def test_events_delete_workitem(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            if event not in ('BeforeMessageProcessed', 'AfterMessageProcessed'):\n                fired.append(event)\n            if event == 'BeforeWorkitemDelete' or event == 'AfterWorkitemDelete':\n                self.assertIn('workitem', kwargs)\n                self.assertTrue(type(kwargs['workitem']) is Workitem)\n                self.assertEqual(kwargs['workitem'].get_name(), 'First item')\n            elif event == 'BeforePomodoroVoided' or event == 'AfterPomodoroVoided':\n                self.assertIn('pomodoro', kwargs)\n                self.assertIn('reason', kwargs)\n                self.assertTrue(type(kwargs['pomodoro']) is Pomodoro)\n                self.assertEqual(kwargs['pomodoro'].get_parent().get_name(), 'First item')\n                self.assertTrue(kwargs['reason'].startswith('Voided automatically'))\n\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First item'])\n        self.source.execute(AddPomodoroStrategy, ['w11', '2'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second item'])\n        self.source.execute(AddPomodoroStrategy, ['w11', '2'])\n        self.source.execute(StartTimerStrategy, ['w11', '1', '1'])\n        self.source.on('*', on_event)  # We only care about delete here\n        self.source.execute(DeleteWorkitemStrategy, ['w11'])\n        self.assertEqual(len(fired), 8)\n        self.assertEqual(fired[0], 'BeforePomodoroRestStart')\n        self.assertEqual(fired[1], 'TimerWorkComplete')\n        self.assertEqual(fired[2], 'AfterPomodoroRestStart')\n        self.assertEqual(fired[3], 'BeforeWorkitemDelete')\n        self.assertEqual(fired[4], 'BeforePomodoroVoided')\n        self.assertEqual(fired[5], 'TimerRestComplete')\n        self.assertEqual(fired[6], 'AfterPomodoroVoided')\n        self.assertEqual(fired[7], 'AfterWorkitemDelete')\n\n    def test_events_complete_workitem(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            if event not in ('BeforeMessageProcessed', 'AfterMessageProcessed'):\n                fired.append(event)\n            if event == 'BeforeWorkitemComplete' or event == 'AfterWorkitemComplete':\n                self.assertIn('workitem', kwargs)\n                self.assertTrue(type(kwargs['workitem']) is Workitem)\n                self.assertEqual(kwargs['workitem'].get_name(), 'First item')\n            elif event == 'BeforePomodoroVoided' or event == 'AfterPomodoroVoided':\n                self.assertIn('pomodoro', kwargs)\n                self.assertIn('reason', kwargs)\n                self.assertTrue(type(kwargs['pomodoro']) is Pomodoro)\n                self.assertEqual(kwargs['pomodoro'].get_parent().get_name(), 'First item')\n                self.assertTrue(kwargs['reason'].startswith('Voided automatically'))\n\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First item'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second item'])\n        self.source.execute(AddPomodoroStrategy, ['w11', '2'])\n        self.source.execute(StartTimerStrategy, ['w11', '1', '1'])\n        self.source.on('*', on_event)  # We only care about delete here\n        self.source.execute(CompleteWorkitemStrategy, ['w11', 'finished'])\n        self.assertEqual(len(fired), 10)\n        self.assertEqual(fired[0], 'BeforePomodoroRestStart')\n        self.assertEqual(fired[1], 'TimerWorkComplete')\n        self.assertEqual(fired[2], 'AfterPomodoroRestStart')\n        self.assertEqual(fired[3], 'BeforeWorkitemComplete')\n        self.assertEqual(fired[4], 'BeforePomodoroInterrupted')\n        self.assertEqual(fired[5], 'AfterPomodoroInterrupted')\n        self.assertEqual(fired[6], 'BeforePomodoroVoided')\n        self.assertEqual(fired[7], 'TimerRestComplete')\n        self.assertEqual(fired[8], 'AfterPomodoroVoided')\n        self.assertEqual(fired[9], 'AfterWorkitemComplete')\n\n    def test_events_rename_workitem(self):\n        fired = list()\n\n        def on_event(event, **kwargs):\n            if event not in ('BeforeMessageProcessed', 'AfterMessageProcessed'):\n                fired.append(event)\n            if event == 'BeforeWorkitemRename' or event == 'AfterWorkitemRename':\n                self.assertIn('workitem', kwargs)\n                self.assertIn('old_name', kwargs)\n                self.assertIn('new_name', kwargs)\n                self.assertEqual(kwargs['old_name'], 'Before')\n                self.assertEqual(kwargs['new_name'], 'After')\n                self.assertTrue(type(kwargs['workitem']) is Workitem)\n\n        self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'Before'])\n        self.source.on('*', on_event)\n        self.source.execute(RenameWorkitemStrategy, ['w11', 'After'])\n        self.assertEqual(len(fired), 2)\n        self.assertEqual(fired[0], 'BeforeWorkitemRename')\n        self.assertEqual(fired[1], 'AfterWorkitemRename')\n\n    # Reordering tests:\n    # - Positive test -- move up and down\n    # - Negative index and index > len()\n    # - No move -- up and down\n    # - Events\n\n    def _create_workitems_for_reorder_tests(self):\n        _, backlog = self._standard_backlog()\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w12', 'b1', 'Second workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w13', 'b1', 'Third workitem'])\n        self.source.execute(CreateWorkitemStrategy, ['w14', 'b1', 'Fourth workitem'])\n        return backlog\n\n    def _assert_workitem_order(self, backlog: Backlog, order: str):\n        pass\n\n    def test_reorder_workitem_up_normal(self):\n        backlog = self._create_workitems_for_reorder_tests()\n        self._assert_workitem_order(backlog, 'w11,w12,w13,w14')\n\n    def test_move_workitems_ok(self):\n        self.source.execute(CreateBacklogStrategy, ['b1', 'Backlog 1'])\n        self.source.execute(CreateBacklogStrategy, ['b2', 'Backlog 2'])\n        self.source.execute(CreateWorkitemStrategy, ['w11', 'b1', 'First workitem'])\n        self.source.execute(MoveWorkitemStrategy, ['w11', 'b2'])\n        user: User = self.data.get_current_user()\n        self.assertEqual(len(user['b1']), 0)\n        self.assertEqual(len(user['b2']), 1)\n        self.assertIn('w11', user['b2'])\n"
  },
  {
    "path": "src/fk/tools/__init__.py",
    "content": ""
  },
  {
    "path": "src/fk/tools/cli.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport json\nimport logging\nimport re\nfrom argparse import ArgumentParser, Namespace\nfrom typing import Type, Callable\n\nfrom fk.core.abstract_data_item import generate_uid\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_strategy import AbstractStrategy\nfrom fk.core.backlog_strategies import CreateBacklogStrategy, DeleteBacklogStrategy\nfrom fk.core.file_event_source import FileEventSource\nfrom fk.core.mock_settings import MockSettings\nfrom fk.core.no_cryptograph import NoCryptograph\nfrom fk.core.tenant import Tenant\nfrom fk.core.user import User\n\nlogger = logging.getLogger(__name__)\n\n\ndef strategy(cls: Type[AbstractStrategy],\n             params: list[str],\n             display: Callable[[Tenant], None]) -> Callable[[AbstractEventSource[Tenant]], None]:\n    def callback(source: AbstractEventSource[Tenant]):\n        source.execute(cls, params)\n        display(source.get_data())\n    return callback\n\ndef dump(obj: object) -> None:\n    print(json.dumps(obj, indent=2, sort_keys=True, default=str))\n\ndef list_backlogs(source: AbstractEventSource[Tenant], uid: str | None, name_pattern: str | None) -> None:\n    user: User = source.get_data().get_current_user()\n    if uid is not None:\n        dump(user[uid].to_dict())\n    else:\n        if name_pattern is not None:\n            regex = re.compile(name_pattern)\n            found = list()\n            for b in user.values():\n                if regex.match(b.get_name()):\n                    found.append(b.to_dict())\n            dump(found)\n        else:\n            dump([b.to_dict() for b in user.values()])\n\ndef execute(callback: Callable[[AbstractEventSource[Tenant]], None]) -> None:\n    settings = MockSettings(filename=filename)\n    source = FileEventSource[Tenant](settings,\n                                     NoCryptograph(settings),\n                                     Tenant(settings))\n    source.start()  # FileEventSource uses synchronous IO\n    callback(source)\n\ndef default(args) -> None:\n    parser.print_help()\n\ndef backlog(args) -> None:\n    global filename\n    filename = args.file\n    if args.add:\n        uid = generate_uid()\n        execute(strategy(CreateBacklogStrategy,\n                         [uid, args.add],\n                         lambda tenant: dump(tenant.get_current_user()[uid].to_dict())))\n    elif args.delete:\n        execute(strategy(DeleteBacklogStrategy,\n                         [args.delete],\n                         lambda tenant: print('{}')))\n    elif args.list:\n        execute(lambda source: list_backlogs(source, None, None))\n    elif args.get:\n        execute(lambda source: list_backlogs(source, args.get, None))\n    elif args.find:\n        execute(lambda source: list_backlogs(source, None, args.find))\n    else:\n        backlog_parser.print_help()\n\n\nif __name__ == '__main__':\n    parser = ArgumentParser(description=\"Flowkeeper command-line client\")\n    parser.set_defaults(func=default)\n\n    subparsers = parser.add_subparsers(title='Available commands')\n\n    backlog_parser = subparsers.add_parser('backlog', help='backlog help')\n    backlog_parser.add_argument(\"--add\", help=\"Add backlog\")\n    backlog_parser.add_argument(\"--delete\", help=\"Delete backlog by UID\")\n    backlog_parser.add_argument(\"--get\", help=\"Describe backlog by UID\")\n    backlog_parser.add_argument(\"--list\", help=\"List all backlogs\", action='store_true')\n    backlog_parser.add_argument(\"--find\", help=\"Find backlog by applying this regex to its name\")\n    backlog_parser.add_argument(\"--file\", required=True, help=\"Data file\")\n    backlog_parser.set_defaults(func=backlog)\n\n    parser.add_argument(\"--debug\", action='store_true', help=\"Debug output for troubleshooting Flowkeeper\")\n\n    args: Namespace = parser.parse_args()\n\n    filename = None\n    if args.debug:\n        logging.getLogger().setLevel(logging.DEBUG)\n\n    args.func(args)\n"
  },
  {
    "path": "src/fk/tools/minimal_actions.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\n\nfrom PySide6.QtWidgets import QMenuBar\n\nfrom fk.desktop.application import Application\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.qt.focus_widget import FocusWidget\nfrom fk.qt.user_tableview import UserTableView\nfrom fk.qt.workitem_tableview import WorkitemTableView\nfrom fk.tools.minimal_common import MinimalCommon\n\nlogger = logging.getLogger(__name__)\n\nmc = MinimalCommon()\n\nApplication.define_actions(mc.get_actions())\nBacklogTableView.define_actions(mc.get_actions())\nUserTableView.define_actions(mc.get_actions())\nWorkitemTableView.define_actions(mc.get_actions())\nFocusWidget.define_actions(mc.get_actions())\n\nmc.get_actions().bind('application', mc.get_app())\n\nmenu = QMenuBar(mc.get_window())\nmenu.addActions(list(mc.get_actions().values()))\nmc.get_window().setCentralWidget(menu)\n\nlogger.debug('All actions:')\nfor action in mc.get_actions().values():\n    logger.debug(action.objectName())\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_audio.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QPushButton\n\nfrom fk.qt.audio_player import AudioPlayer\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon()\n\naudio = AudioPlayer(mc.get_window(), mc.get_app().get_source_holder(), mc.get_settings())\n\nbutton = QPushButton(mc.get_window())\nbutton.setText('Audio')\nmc.get_window().setCentralWidget(button)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_auth.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QPushButton\n\nfrom fk.qt.oauth import authenticate\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon(initialize_source=False)\n\nbutton = QPushButton(mc.get_window())\nbutton.setText('Login...')\nbutton.clicked.connect(lambda: authenticate(mc.get_app(), print))\nmc.get_window().setCentralWidget(button)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_backlogs.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.qt.backlog_tableview import BacklogTableView\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon(lambda root: backlogs_table.upstream_selected(root.get_current_user()))\n\nBacklogTableView.define_actions(mc.get_actions())\nbacklogs_table: BacklogTableView = BacklogTableView(mc.get_window(), mc.get_app(), mc.get_app().get_source_holder(), mc.get_actions())\nmc.get_actions().bind('backlogs_table', backlogs_table)\n\nmc.get_window().setCentralWidget(backlogs_table)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_common.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nimport logging\nimport sys\nfrom typing import Callable\n\nfrom PySide6.QtWidgets import QMainWindow\n\nfrom fk.core.abstract_event_source import AbstractEventSource\nfrom fk.core.abstract_settings import AbstractSettings\nfrom fk.core.event_source_holder import AfterSourceChanged\nfrom fk.core.events import SourceMessagesProcessed\nfrom fk.desktop.application import Application\nfrom fk.qt.actions import Actions\n\nlogger = logging.getLogger(__name__)\n\n\nclass MinimalCommon:\n    _app: Application\n    _window: QMainWindow\n    _actions: Actions\n    _source: AbstractEventSource\n    _settings: AbstractSettings\n    _callback: Callable\n    _initialize_source: bool\n\n    def main_loop(self):\n        self._app.setQuitOnLastWindowClosed(True)\n        self._window.show()\n\n        try:\n            logger.debug(f'MinimalCommon: Entering main_loop: {self._source} / {self._initialize_source}')\n            if self._initialize_source:\n                logger.debug('MinimalCommon: Request source initialization')\n                self._app.initialize_source()\n        except Exception as ex:\n            self._app.on_exception(type(ex), ex, ex.__traceback__)\n\n        sys.exit(self._app.exec())\n\n    def _on_messages(self, event: str, source: AbstractEventSource) -> None:\n        logger.debug(f'MinimalCommon: Will call {self._callback}')\n        self._callback(source.get_data())\n\n    def _on_source_changed(self, event: str, source: AbstractEventSource):\n        logger.debug(f'MinimalCommon: _on_source_changed({source})')\n        self._source = source\n        if self._callback is not None:\n            source.on(SourceMessagesProcessed, self._on_messages)\n\n    def __init__(self, callback: Callable = None, initialize_source: bool = True):\n        self._source = None\n        self._callback = callback\n        self._initialize_source = initialize_source\n        self._app = Application(sys.argv)\n        self._app.setQuitOnLastWindowClosed(True)\n        self._settings = self._app.get_settings()\n        self._window = QMainWindow()\n        self._actions = Actions(self._window, self._settings)\n        Application.define_actions(self._actions)\n        self._actions.bind('application', self._app)\n        self._app.get_source_holder().on(AfterSourceChanged, self._on_source_changed)\n\n    def get_actions(self):\n        return self._actions\n\n    def get_app(self):\n        return self._app\n\n    def get_settings(self):\n        return self._settings\n\n    def get_window(self):\n        return self._window\n\n    def get_source(self):\n        return self._source\n"
  },
  {
    "path": "src/fk/tools/minimal_focus.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.core.timer import PomodoroTimer\nfrom fk.qt.focus_widget import FocusWidget\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon()\n\npomodoro_timer = PomodoroTimer(QtTimer(\"Pomodoro Tick\"), QtTimer(\"Pomodoro Transition\"), mc.get_settings(), mc.get_app().get_source_holder())\nFocusWidget.define_actions(mc.get_actions())\nfocus = FocusWidget(mc.get_window(),\n                    mc.get_app(),\n                    pomodoro_timer,\n                    mc.get_app().get_source_holder(),\n                    mc.get_settings(),\n                    mc.get_actions(),\n                    mc.get_settings().get('Application.focus_flavor'))\nmc.get_actions().bind('focus', focus)\nmc.get_window().setCentralWidget(focus)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_settings.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.desktop.settings import SettingsDialog\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon()\n\ndialog = SettingsDialog(mc.get_settings())\nmc.get_window().setCentralWidget(dialog)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_timer_widget.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nfrom fk.core.timer import PomodoroTimer\nfrom fk.qt.focus_widget import FocusWidget\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.timer_widget import TimerWidget\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon()\n\npomodoro_timer = PomodoroTimer(QtTimer(\"Pomodoro Tick\"), QtTimer(\"Pomodoro Transition\"), mc.get_settings(), mc.get_app().get_source_holder())\nFocusWidget.define_actions(mc.get_actions())\n\naction = mc.get_actions()['focus.voidPomodoro']\n\ntimer = TimerWidget(mc.get_window(),\n                    'timer',\n                    #mc.get_settings().get('Application.focus_flavor'),\n                    'minimal',\n                    None,\n                    500)\ntimer.set_values(1 * 60 * 60 + 5 * 60 + 20,\n                 None,\n                 None,\n                 None,\n                 'tracking')\nmc.get_window().setCentralWidget(timer)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_tray.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QPushButton\n\nfrom fk.core.pomodoro import Pomodoro, POMODORO_TYPE_NORMAL\nfrom fk.core.timer import PomodoroTimer\nfrom fk.core.workitem import Workitem\nfrom fk.qt.qt_timer import QtTimer\nfrom fk.qt.render.minimal_timer_renderer import MinimalTimerRenderer\nfrom fk.qt.tray_icon import TrayIcon\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon()\n\napp = mc.get_app()\nwindow = mc.get_window()\nactions = mc.get_actions()\n\npomodoro_timer = PomodoroTimer(QtTimer(\"Pomodoro Tick\"), QtTimer(\"Pomodoro Transition\"), mc.get_settings(), app.get_source_holder())\ntray = TrayIcon(window, pomodoro_timer, app.get_source_holder(), actions, 48, MinimalTimerRenderer, True)   # TODO: Detect automatically\n\ntray.setVisible(True)\ntray.mode_changed('idle', 'working')\nwi = Workitem('Test', '123', None, None)\n\nvalue = 0\npomodoro_timer._state = 'work'\n\n\ndef tick():\n    global value\n    global pomodoro_timer\n    tray.tick(Pomodoro(1, False, pomodoro_timer._state, 5000, 5000, POMODORO_TYPE_NORMAL, \"123\", wi, None),\n              'State',\n              value,\n              10,\n              'working')\n    value += 1\n    if value > 10:\n        if pomodoro_timer._state == 'work':\n            pomodoro_timer._state = 'rest'\n        else:\n            pomodoro_timer._state = 'work'\n        value = 0\n\n\nbutton = QPushButton(window)\nbutton.setText('See tray icon')\nbutton.clicked.connect(lambda: tick())\nwindow.setCentralWidget(button)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_tutorial.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtCore import QPoint\nfrom PySide6.QtWidgets import QPushButton, QWidget\n\nfrom fk.qt.info_overlay import show_tutorial\nfrom fk.tools.minimal_common import MinimalCommon\n\n\ndef get_tutorial_step(step: int, widget: QWidget) -> (str, QPoint, str):\n    if step == 1:\n        return 'Welcome to Flowkeeper!', widget.mapToGlobal(widget.rect().topLeft()), 'info'\n    elif step == 2:\n        return 'Tutorial step 2 with a somewhat longer description', widget.mapToGlobal(widget.rect().bottomRight()), 'arrow'\n    elif step == 3:\n        return 'Thank you!', widget.mapToGlobal(widget.rect().center()), 'info'\n\n\nmc = MinimalCommon(initialize_source=False)\nbutton = QPushButton(mc.get_window())\nbutton.setFixedWidth(300)\nbutton.setText('Tutorial')\nbutton.clicked.connect(lambda: show_tutorial(mc.get_window(), lambda step: get_tutorial_step(step, button), None, True))\nmc.get_window().setCentralWidget(button)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_update.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom PySide6.QtWidgets import QTextEdit\nfrom semantic_version import Version\n\nfrom fk.qt.app_version import get_current_version, get_latest_version\nfrom fk.tools.minimal_common import MinimalCommon\n\nmc = MinimalCommon(initialize_source=False)\n\ntxt = QTextEdit(mc.get_window())\n\n\ndef update(latest: Version, changelog: str):\n    txt.setMarkdown(f'Current version: {get_current_version()}\\n\\nLatest version: {latest}\\n\\nChangelog: \\n\\n{changelog}')\n\n\nget_latest_version(mc.get_app(), update)\nmc.get_window().setCentralWidget(txt)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_users.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.core.tenant import Tenant\nfrom fk.qt.user_tableview import UserTableView\nfrom fk.tools.minimal_common import MinimalCommon\n\n\ndef on_data(root: Tenant):\n    users_table.upstream_selected(root)\n\n\nmc = MinimalCommon(on_data)\n\napp = mc.get_app()\nwindow = mc.get_window()\nactions = mc.get_actions()\n\nUserTableView.define_actions(actions)\nusers_table: UserTableView = UserTableView(window, app, app.get_source_holder(), actions)\nactions.bind('users_table', users_table)\nwindow.setCentralWidget(users_table)\n\nmc.main_loop()\n"
  },
  {
    "path": "src/fk/tools/minimal_workitems.py",
    "content": "#  Flowkeeper - Pomodoro timer for power users and teams\n#  Copyright (c) 2023 Constantine Kulak\n#\n#  This program is free software: you can redistribute it and/or modify\n#  it under the terms of the GNU General Public License as published by\n#  the Free Software Foundation; either version 3 of the License, or\n#  (at your option) any later version.\n#\n#  This program is distributed in the hope that it will be useful,\n#  but WITHOUT ANY WARRANTY; without even the implied warranty of\n#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n#  GNU General Public License for more details.\n#\n#  You should have received a copy of the GNU General Public License\n#  along with this program.  If not, see <https://www.gnu.org/licenses/>.\nfrom fk.core.tenant import Tenant\nfrom fk.qt.workitem_tableview import WorkitemTableView\nfrom fk.tools.minimal_common import MinimalCommon\n\n\ndef select_first_backlog(data: Tenant):\n    backlogs = list(data.get_current_user().values())\n    workitems_table.upstream_selected(backlogs[0])\n\n\nmc = MinimalCommon(select_first_backlog)\n\nmc.get_window().resize(600, 400)\nWorkitemTableView.define_actions(mc.get_actions())\nworkitems_table: WorkitemTableView = WorkitemTableView(mc.get_window(),\n                                                       mc.get_app(),\n                                                       mc.get_app().get_source_holder(),\n                                                       None,\n                                                       mc.get_actions())\nmc.get_actions().bind('workitems_table', workitems_table)\nmc.get_window().setCentralWidget(workitems_table)\n\nmc.main_loop()\n"
  },
  {
    "path": "ws-tests.md",
    "content": "Lost connection between server and client\n    Client knows about it\n    Client doesn't know\n\nServer is down\n    Client connects w/cache\n    Client connects w/o cache\n\nClient is down\n\nReconnect\n\nConcurrent modification\n    Same backlog\n    Same workitem\n    Different backlogs\n\nWrong decryption key\n\nSomeone sends a message before the replay is completed\n\nReplay contains many strategies\n    One message\n    Multiple messages\n\nCache is deleted\n\nRedo log is deleted\n\nRelogin with another account\n\nWith redo log the UI is not locked when the client goes offline\n\nSend strategies \"in the past\", then reconnect -- do they appear correctly?\n\n"
  }
]