diff -Nru yokadi-1.1.1/bin/fromsrc.py yokadi-1.2.0/bin/fromsrc.py --- yokadi-1.1.1/bin/fromsrc.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/bin/fromsrc.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Add parent dir to sys.path so that one can use Yokadi from an uninstalled - source tree. - -@author: Aurélien Gâteau (mail@agateau.com) -@license:GPL v3 or later -""" - -import os -import sys - -parentPath = os.path.join(os.path.dirname(__file__), os.pardir) -sys.path.insert(0, parentPath) diff -Nru yokadi-1.1.1/bin/yokadi yokadi-1.2.0/bin/yokadi --- yokadi-1.1.1/bin/yokadi 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/bin/yokadi 2019-02-10 11:35:44.000000000 +0000 @@ -7,12 +7,5 @@ @author: Sébastien Renard (sebastien.renard@digitalfox.org) @license:GPL v3 or later """ - -import os - -fromsrc_py = os.path.join(os.path.dirname(__file__), "fromsrc.py") -if os.path.exists(fromsrc_py): - import fromsrc - from yokadi.ycli import main main.main() diff -Nru yokadi-1.1.1/bin/yokadid yokadi-1.2.0/bin/yokadid --- yokadi-1.1.1/bin/yokadid 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/bin/yokadid 2019-02-10 11:35:44.000000000 +0000 @@ -7,12 +7,5 @@ @author: Sébastien Renard (sebastien.renard@digitalfox.org) @license:GPL v3 or later """ - -import os - -fromsrc_py = os.path.join(os.path.dirname(__file__), "fromsrc.py") -if os.path.exists(fromsrc_py): - import fromsrc - from yokadi import yokadid yokadid.main() diff -Nru yokadi-1.1.1/debian/changelog yokadi-1.2.0/debian/changelog --- yokadi-1.1.1/debian/changelog 2018-09-09 15:42:20.000000000 +0000 +++ yokadi-1.2.0/debian/changelog 2019-10-17 11:22:59.000000000 +0000 @@ -1,3 +1,22 @@ +yokadi (1.2.0-2) unstable; urgency=low + + * Upload to unstable. + * debian/control: + + Updated Standards-Version to 4.4.1 + * Bumped dh to 12. + + -- Kartik Mistry Thu, 17 Oct 2019 16:52:59 +0530 + +yokadi (1.2.0-1) experimental; urgency=low + + * New upstream release. + * Fixed debian/watch. + * debian/control: + + Updated Standards-Version to 4.3.0 + * Added debian/gitlab-ci.yml pipeline. + + -- Kartik Mistry Fri, 01 Mar 2019 12:47:22 +0530 + yokadi (1.1.1-2) unstable; urgency=low * debian/control: diff -Nru yokadi-1.1.1/debian/compat yokadi-1.2.0/debian/compat --- yokadi-1.1.1/debian/compat 2018-01-26 14:04:38.000000000 +0000 +++ yokadi-1.2.0/debian/compat 2019-10-17 11:22:59.000000000 +0000 @@ -1 +1 @@ -11 +12 diff -Nru yokadi-1.1.1/debian/control yokadi-1.2.0/debian/control --- yokadi-1.1.1/debian/control 2018-09-09 12:42:44.000000000 +0000 +++ yokadi-1.2.0/debian/control 2019-10-17 11:22:59.000000000 +0000 @@ -2,9 +2,9 @@ Section: utils Priority: optional Maintainer: Kartik Mistry -Build-Depends: debhelper (>= 11), dh-python +Build-Depends: debhelper (>= 12), dh-python Build-Depends-Indep: python3 (>= 3.4), python3-setuptools -Standards-Version: 4.2.1 +Standards-Version: 4.4.1 Homepage: https://yokadi.github.io/ Vcs-Git: https://salsa.debian.org/debian/yokadi.git Vcs-Browser: https://salsa.debian.org/debian/yokadi diff -Nru yokadi-1.1.1/debian/gitlab-ci.yml yokadi-1.2.0/debian/gitlab-ci.yml --- yokadi-1.1.1/debian/gitlab-ci.yml 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/debian/gitlab-ci.yml 2019-02-26 08:27:18.000000000 +0000 @@ -0,0 +1,6 @@ +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml + +variables: + RELEASE: 'unstable' diff -Nru yokadi-1.1.1/debian/watch yokadi-1.2.0/debian/watch --- yokadi-1.1.1/debian/watch 2018-01-26 14:13:56.000000000 +0000 +++ yokadi-1.2.0/debian/watch 2019-02-26 08:25:41.000000000 +0000 @@ -1,2 +1,2 @@ version=3 -https://yokadi.github.com/download.html download/yokadi-(.*)\.tar\.bz2 +https://yokadi.github.io/download.html download/yokadi-(.*)\.tar\.gz diff -Nru yokadi-1.1.1/doc/dev/debug.md yokadi-1.2.0/doc/dev/debug.md --- yokadi-1.1.1/doc/dev/debug.md 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/doc/dev/debug.md 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,6 @@ +# Debugging + +## Show SQL commands + +If you set the `YOKADI_SQL_DEBUG` environment variable to a value different +from "0", all SQL commands will be printed to stdout. diff -Nru yokadi-1.1.1/doc/dev/release.md yokadi-1.2.0/doc/dev/release.md --- yokadi-1.1.1/doc/dev/release.md 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/doc/dev/release.md 2019-02-10 11:35:44.000000000 +0000 @@ -2,71 +2,42 @@ ## Introduction -A series is major.minor (ex: 0.12). There is a branch for each series. - -A version is major.minor.patch (ex 0.12.1). There is a tag for each version. - This doc assumes there is a checkout of yokadi.github.com next to the checkout of yokadi. ## In yokadi checkout export version= - export series= - -### For a new series - -Update `NEWS` file (add changes, check release date) - -Ensure `yokadi/__init__.py` file contains $version - -Create branch: - - git checkout -b $series - git push -u origin $series - -The version in master should always be bigger than the version in release -branches, so update version in master: - - git checkout master - vi version - git commit version -m "Bump version number" - git push - git checkout - -### For a new release in an existing series +Check dev is clean - git checkout + git checkout dev + git pull + git status Update `NEWS` file (add changes, check release date) -Bump version number - - echo $version > version - git commit NEWS version -m "Getting ready for $version" - -### Common +Ensure `yokadi/__init__.py` file contains $version Build archives ./scripts/mkdist.sh ../yokadi.github.com/download -Tag - - git tag -a $version -m "Releasing $version" - Push changes git push - git push --tags -Merge changes in master (so that future forward merges are simpler). Be careful -to keep version to its master value. +When CI has checked the branch, merge changes in master git checkout master - git merge --no-ff $series + git pull + git merge dev git push - git checkout - + +Tag the release + + git tag -a $version -m "Releasing $version" + git push --tags ## In yokadi.github.com checkout diff -Nru yokadi-1.1.1/extra-requirements.txt yokadi-1.2.0/extra-requirements.txt --- yokadi-1.1.1/extra-requirements.txt 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/extra-requirements.txt 2019-02-10 11:35:44.000000000 +0000 @@ -1,3 +1,2 @@ icalendar==3.6.1 -pycrypto==2.6.1 setproctitle==1.1.8 diff -Nru yokadi-1.1.1/man/yokadi.1 yokadi-1.2.0/man/yokadi.1 --- yokadi-1.1.1/man/yokadi.1 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/man/yokadi.1 2019-02-10 11:35:44.000000000 +0000 @@ -19,8 +19,8 @@ options starting with two dashes (`-'). A summary of options is included below. .TP -.B \-d, FILE \-\-db=FILE -TODO database. +.B \-\-datadir= +Database directory. .TP .B \-c, \-\-create-only Just create an empty database. diff -Nru yokadi-1.1.1/man/yokadid.1 yokadi-1.2.0/man/yokadid.1 --- yokadi-1.1.1/man/yokadid.1 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/man/yokadid.1 2019-02-10 11:35:44.000000000 +0000 @@ -22,8 +22,8 @@ options starting with two dashes (`-'). A summary of options is included below. .TP -.B \-d, FILE \-\-db=FILE -TODO database. +.B \-\-datadir= +Database directory. .TP .B \-k, \-\-kill Kill Yokadi Daemon (you can specify database with \-db if you run multiple diff -Nru yokadi-1.1.1/MANIFEST.in yokadi-1.2.0/MANIFEST.in --- yokadi-1.1.1/MANIFEST.in 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/MANIFEST.in 2019-02-10 11:35:44.000000000 +0000 @@ -1,4 +1,3 @@ -include bin/fromsrc.py include doc/*.md include doc/dev/*.md include man/*.1 diff -Nru yokadi-1.1.1/NEWS yokadi-1.2.0/NEWS --- yokadi-1.1.1/NEWS 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/NEWS 2019-02-10 11:35:44.000000000 +0000 @@ -1,3 +1,23 @@ +v1.2.0 unreleased +- New features: + - The new `p_merge` command lets you merge a project into another. + - It is now possible to turn a task into a note with `t_to_note` and a note into a task with `n_to_task`. +- Bug fixes: + - The `k_remove` command no longer ignores unused keywords. + - HTML output has been fixed to no longer output strings wrapped in `b""`. + - `t_list` filtering has been fixed so that `t_list --urgency 0` filters out tasks with a negative urgency, as expected. +- Improvements: + - HTML output has been refreshed: + - It looks more modern now. + - Some fields have been removed (doneDate, creationDate). + - The title, keywords and description fields have been merged. + - An ID field has been added (handy to run a command on a task listed in the output). + - Columns now use human-friendly titles. +- Misc: + - The `--db` option is now deprecated and replaced by the `--datadir` option. `--db` will be removed in the next version. + - Similarly, the `YOKADI_DB` environment variable is now deprecated and will be removed in the next version. + - Yokadi no longer supports cryptography: encrypted databases will be decrypted at update. + v1.1.1 2016/11/11 - Improvements: diff -Nru yokadi-1.1.1/PKG-INFO yokadi-1.2.0/PKG-INFO --- yokadi-1.1.1/PKG-INFO 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/PKG-INFO 2019-02-10 11:35:45.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 1.0 Name: yokadi -Version: 1.1.1 +Version: 1.2.0 Summary: Command line oriented todo list system Home-page: http://yokadi.github.io/ Author: The Yokadi Team diff -Nru yokadi-1.1.1/README.md yokadi-1.2.0/README.md --- yokadi-1.1.1/README.md 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/README.md 2019-02-10 11:35:44.000000000 +0000 @@ -25,7 +25,7 @@ pip install -r extra-requirements.txt -These modules are needed for the Yokadi Daemon and for the cryptography features. +These modules are needed for the Yokadi Daemon. # Quickstart @@ -179,53 +179,6 @@ Type `help t_recurs` to see all possible syntaxes. -## Encrypt your tasks - -Whenever you want to protect your todo list data, Yokadi provides a simple -mechanism to encrypt a task title or description. This is useful when you store -passwords like tasks or notes. - -Let's encrypt a task and a note title with the -c option: - - yokadi> t_add -c my_project this is a very secret task, don't tell anyone ! - passphrase> - Added task '<... encrypted data...>' (id=1) - -Yokadi asks you for a passphrase. Don't forget it! It is a global passphrase -for this Yokadi database. Each time you will want to encrypt something, you -will have to use this passphrase. For convenience, Yokadi will keep this -passphrase in memory during your Yokadi session. If you are quite paranoiac -and feel bad with that, don't panic, you can set the `PASSPHRASE_CACHE` -option to 0 to disable passphrase cache: - - yokadi> c_set PASSPHRASE_CACHE 0 - Info: Parameter updated - -If you list encrypted stuff but haven't given your passphrase in the current -session, Yokadi won't bother you with asking for passphrase, but won't display -data in a clear way: - - yokadi> t_list - my_project - ID|Title |U |S|Age |Due date - -------------------------------------------------------------------- - 1 |<... encrypted data...>|0 |N|5m | - yokadi> - -To reveal secret data, you have to use the --decrypt option and type your -passphrase when prompted to: - - yokadi> t_list --decrypt - passphrase> - my_project - ID|Title |U |S|Age |Due date - -------------------------------------------------------------------------------------------- - 1 |this is a very secret task, don't tell anyone !|0 |N|6m | - yokadi> - -Note: when you encrypt a task or note title, the description will be also -encrypted. - ## Tasks range and magic __ keyword `t_apply` is a very powerful function but sometimes you have to use it on @@ -285,7 +238,7 @@ ## Database location By default, Yokadi creates a database in `$HOME/.local/share/yokadi/yokadi.db`, -but you can specify an alternative location with the `--db` option. +but you can specify an alternative directory with the `--datadir` option. A convenient way to start Yokadi is by creating an alias in your `.bashrc` file like this: @@ -295,11 +248,6 @@ The single letter `y` will start Yokadi with your favorite database from wherever you are. -If you do not want to use the default database location, you can define -the `YOKADI_DB` environment variable to point to your database: - - export YOKADI_DB=$HOME/work/yokadi.db - ## History location By default, Yokadi will store input history in `$HOME/.cache/yokadi/history`. diff -Nru yokadi-1.1.1/scripts/mkdist.sh yokadi-1.2.0/scripts/mkdist.sh --- yokadi-1.1.1/scripts/mkdist.sh 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/scripts/mkdist.sh 2019-02-10 11:35:44.000000000 +0000 @@ -24,24 +24,20 @@ log "Copying source" cp -a --no-target-directory "$SRC_DIR" "$WORK_DIR" -log "Check we are not master" -cd "$WORK_DIR" -BRANCH=$(git branch | awk '$1 == "*" { print $2 }') -[ "$BRANCH" != "master" ] || die "Source dir should point to a release branch checkout, not master!" - log "Cleaning" +cd "$WORK_DIR" git reset --hard HEAD git clean -q -dxf log "Building archives" -./setup.py -q sdist --formats=bztar,zip +./setup.py -q sdist --formats=gztar,zip log "Installing archive" cd dist/ -YOKADI_TARBZ2=$(ls ./*.tar.bz2) -tar xf "$YOKADI_TARBZ2" +YOKADI_TARGZ=$(ls ./*.tar.gz) +tar xf "$YOKADI_TARGZ" -ARCHIVE_DIR="$PWD/${YOKADI_TARBZ2%.tar.bz2}" +ARCHIVE_DIR="$PWD/${YOKADI_TARGZ%.tar.gz}" virtualenv --python python3 "$WORK_DIR/venv" ( @@ -62,6 +58,6 @@ log "Moving archives out of work dir" cd "$WORK_DIR/dist" -mv ./*.tar.bz2 ./*.zip "$DST_DIR" +mv ./*.tar.gz ./*.zip "$DST_DIR" rm -rf "$WORK_DIR" log "Done" diff -Nru yokadi-1.1.1/setup.py yokadi-1.2.0/setup.py --- yokadi-1.1.1/setup.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/setup.py 2019-02-10 11:35:44.000000000 +0000 @@ -11,7 +11,7 @@ import sys import os from fnmatch import fnmatch -from os.path import abspath, isdir, dirname, join +from os.path import isdir, dirname, join sys.path.insert(0, dirname(__file__)) import yokadi @@ -27,6 +27,7 @@ if fnmatch(name, pattern): yield join(sourceDir, name) + # Additional files data_files = [] data_files.append(["share/yokadi", @@ -46,8 +47,7 @@ for size in os.listdir("icon"): if not isdir(join("icon", size)): continue - data_files.append(["share/icons/hicolor/%s/apps" % size, - ["icon/%s/yokadi.png" % size]]) + data_files.append(["share/icons/hicolor/%s/apps" % size, ["icon/%s/yokadi.png" % size]]) data_files.append(["share/applications", ["icon/yokadi.desktop"]]) @@ -59,26 +59,27 @@ scripts.append("w32_postinst.py") # Go for setup -setup(name="yokadi", - version=yokadi.__version__, - description="Command line oriented todo list system", - author="The Yokadi Team", - author_email="ml-yokadi@sequanux.org", - url="http://yokadi.github.io/", - packages=[ +setup( + name="yokadi", + version=yokadi.__version__, + description="Command line oriented todo list system", + author="The Yokadi Team", + author_email="ml-yokadi@sequanux.org", + url="http://yokadi.github.io/", + packages=[ "yokadi", "yokadi.core", "yokadi.tests", "yokadi.update", "yokadi.ycli", "yokadi.yical", - ], - # distutils does not support install_requires, but pip needs it to be - # able to automatically install dependencies - install_requires=[ + ], + # distutils does not support install_requires, but pip needs it to be + # able to automatically install dependencies + install_requires=[ "sqlalchemy", "python-dateutil", - ], - scripts=scripts, - data_files=data_files - ) + ], + scripts=scripts, + data_files=data_files +) diff -Nru yokadi-1.1.1/.travis.yml yokadi-1.2.0/.travis.yml --- yokadi-1.1.1/.travis.yml 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/.travis.yml 2019-02-10 11:35:44.000000000 +0000 @@ -7,8 +7,11 @@ install: - pip install -r requirements.txt - pip install -r extra-requirements.txt - - pip install coverage coveralls + - pip install coverage coveralls flake8 script: + # Make sure what's already flake8-happy remains flake8-happy + # Exclude w32_postinst.py because it uses install-specific builtin functions + - flake8 --exclude build,w32_postinst.py - coverage run --source=yokadi --omit="yokadi/tests/*" yokadi/tests/tests.py after_success: coveralls diff -Nru yokadi-1.1.1/w32_postinst.py yokadi-1.2.0/w32_postinst.py --- yokadi-1.1.1/w32_postinst.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/w32_postinst.py 2019-02-10 11:35:44.000000000 +0000 @@ -15,35 +15,34 @@ # pylint: disable-msg=E0602 # Description string -desc="Command line oriented todo list system" +desc = "Command line oriented todo list system" # Shortcut name -lnk="yokadi.lnk" +lnk = "yokadi.lnk" # Only do things at install stage, not uninstall if sys.argv[1] == "-install": # Get python.exe path py_path = abspath(join(sys.prefix, "python.exe")) - + # Yokadi wrapper path - yokadi_dir=abspath(join(sys.prefix, "scripts")) - yokadi_path=join(yokadi_dir, "yokadi") - - #TODO: create a sexy yokadi .ico file to be put in share dir - - - # Find desktop + yokadi_dir = abspath(join(sys.prefix, "scripts")) + yokadi_path = join(yokadi_dir, "yokadi") + + # TODO: create a sexy yokadi .ico file to be put in share dir + + # Find desktop try: desktop_path = get_special_folder_path("CSIDL_COMMON_DESKTOPDIRECTORY") except OSError: desktop_path = get_special_folder_path("CSIDL_DESKTOPDIRECTORY") - + # Desktop shortcut creation - create_shortcut(py_path, # program to launch + create_shortcut(py_path, # program to launch desc, join(desktop_path, lnk), # shortcut file - yokadi_path, # Argument (pythohn script) - yokadi_dir, # Current work dir - "" # Ico file (nothing for now) + yokadi_path, # Argument (pythohn script) + yokadi_dir, # Current work dir + "" # Ico file (nothing for now) ) # Tel install process that we create a file so it can removed it during uninstallation @@ -63,14 +62,14 @@ pass directory_created(programs_path) - create_shortcut(py_path, # program to launch - desc, + create_shortcut(py_path, # program to launch + desc, join(programs_path, lnk), # Shortcut file - yokadi_path, # Argument (python script) - yokadi_dir, # Cuurent work dir - "" # Icone + yokadi_path, # Argument (python script) + yokadi_dir, # Cuurent work dir + "" # Icone ) file_created(join(programs_path, lnk)) # End of script - sys.exit() \ No newline at end of file + sys.exit() diff -Nru yokadi-1.1.1/yokadi/core/basepaths.py yokadi-1.2.0/yokadi/core/basepaths.py --- yokadi-1.1.1/yokadi/core/basepaths.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/basepaths.py 2019-02-10 11:35:44.000000000 +0000 @@ -19,6 +19,8 @@ _WINDOWS = os.name == "nt" +DB_NAME = "yokadi.db" + class MigrationException(Exception): pass @@ -55,14 +57,14 @@ def getDataDir(): + xdgDataDir = os.environ.get("XDG_DATA_HOME") + if xdgDataDir: + return os.path.join(xdgDataDir, "yokadi") + if _WINDOWS: - value = os.path.join(_getAppDataDir(), "yokadi", "data") - else: - dataBaseDir = os.environ.get("XDG_DATA_HOME") - if not dataBaseDir: - dataBaseDir = os.path.expandvars("$HOME/.local/share") - value = os.path.join(dataBaseDir, "yokadi") - return value + return os.path.join(_getAppDataDir(), "yokadi", "data") + + return os.path.expandvars("$HOME/.local/share/yokadi") def getHistoryPath(): @@ -72,11 +74,11 @@ return os.path.join(getCacheDir(), "history") -def getDbPath(): +def getDbPath(dataDir): path = os.getenv("YOKADI_DB") if path: return path - return os.path.join(getDataDir(), "yokadi.db") + return os.path.join(dataDir, "yokadi.db") def _getOldHistoryPath(): @@ -100,14 +102,14 @@ print("Moved %s to %s" % (oldHistoryPath, newHistoryPath)) -def migrateOldDb(): +def migrateOldDb(newDbPath): oldDbPath = os.path.normcase(os.path.expandvars("$HOME/.yokadi.db")) if not os.path.exists(oldDbPath): return - newDbPath = getDbPath() if os.path.exists(newDbPath): - raise MigrationException("Tried to move %s to %s, but %s already exists. You must remove one of the two files." % (oldDbPath, newDbPath, newDbPath)) + raise MigrationException("Tried to move %s to %s, but %s already exists." + " You must remove one of the two files." % (oldDbPath, newDbPath, newDbPath)) fileutils.createParentDirs(newDbPath) shutil.move(oldDbPath, newDbPath) print("Moved %s to %s" % (oldDbPath, newDbPath)) diff -Nru yokadi-1.1.1/yokadi/core/bugutils.py yokadi-1.2.0/yokadi/core/bugutils.py --- yokadi-1.1.1/yokadi/core/bugutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/bugutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -20,7 +20,7 @@ (5, "Minor usability: Impairs usability in secondary scenarios"), (6, "Major usability: Impairs usability in key scenarios"), (7, "Crash: Bug causes crash or data loss. Asserts in the Debug release"), - ] +] LIKELIHOOD_LIST = [ (1, "Will affect almost no one"), @@ -28,7 +28,7 @@ (3, "Will affect average number of users"), (4, "Will affect most users"), (5, "Will affect all users"), - ] +] def computeUrgency(keywordDict): diff -Nru yokadi-1.1.1/yokadi/core/cryptutils.py yokadi-1.2.0/yokadi/core/cryptutils.py --- yokadi-1.1.1/yokadi/core/cryptutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/cryptutils.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,153 +0,0 @@ -# coding:utf-8 -""" -Cryptographic functions for encrypting and decrypting text. -Temporary file are used by only contains encrypted data. - -@author: Sébastien Renard -@license: GPL v3 -""" - -import base64 -from random import Random - -from yokadi.ycli import tui -from yokadi.core import db -from yokadi.core.yokadiexception import YokadiException - -from sqlalchemy.orm.exc import NoResultFound - -# Prefix used to recognise encrypted message -CRYPTO_PREFIX = "---YOKADI-ENCRYPTED-MESSAGE---" -# AES Key length -KEY_LENGTH = 32 - -try: - from Crypto.Cipher import AES as Cypher - CRYPT = True -except ImportError: - tui.warning("Python Cryptographic Toolkit module not found. You will not be able to use cryptographic function") - tui.warning("like encrypting or decrypting task title or description") - tui.warning("You can find pycrypto here http://www.pycrypto.org") - CRYPT = False - -# TODO: add unit test - - -class YokadiCryptoManager(object): - """Manager object for Yokadi cryptographic operation""" - def __init__(self): - # Cache encryption passphrase - self.passphrase = None - # Force decryption (and ask passphrase) instead of decrypting only when passphrase was - # previously provided - self.force_decrypt = False - try: - self.crypto_check = db.getConfigKey("CRYPTO_CHECK", environ=False) - except NoResultFound: - # Ok, set it to None. It will be setup after user defined passphrase - self.crypto_check = None - - def encrypt(self, data): - """Encrypt user data. - @return: encrypted data""" - if not CRYPT: - tui.warning("Crypto functions not available") - return data - self.askPassphrase() - return self._encrypt(data) - - def _encrypt(self, data): - """Low level encryption interface. For internal usage only""" - # Complete data with blanck - data_length = int(1 + (len(data) / KEY_LENGTH)) * KEY_LENGTH - data = adjustString(data, data_length) - cypher = Cypher.new(self.passphrase) - return CRYPTO_PREFIX + base64.b64encode(cypher.encrypt(data)).decode(encoding='utf8') - - def decrypt(self, data): - """Decrypt user data. - @return: decrypted data""" - if not self.isEncrypted(data): - # Just return data as is if it's not encrypted - return data - - if not CRYPT: - tui.warning("Crypto functions not available") - return data - - if not self.force_decrypt: - # No flag to force decryption, just return fixed string to indicate - # data is encrypted - return "<... encrypted data...>" - - # Ask passphrase if needed and decrypt data - self.askPassphrase() - if self.passphrase: - data = self._decrypt(data) - else: - data = "<...Failed to decrypt data...>" - return data - - def _decrypt(self, data): - """Low level decryption interface. For internal use only""" - data = data[len(CRYPTO_PREFIX):] # Remove crypto prefix - data = base64.b64decode(data) - cypher = Cypher.new(self.passphrase) - return cypher.decrypt(data).rstrip().decode(encoding='utf-8') - - def askPassphrase(self): - """Ask user for passphrase if needed""" - cache = bool(int(db.getConfigKey("PASSPHRASE_CACHE", environ=False))) - if self.passphrase and cache: - return - self.passphrase = tui.editLine("", prompt="passphrase> ", echo=False) - self.passphrase = adjustString(self.passphrase, KEY_LENGTH) - if not self.isPassphraseValid() and cache: - self.passphrase = None - self.force_decrypt = False # As passphrase is invalid, don't force decrypt for next time - raise YokadiException("Passphrase differ from previous one." - "If you really want to change passphrase, " - "you should blank the CRYPTO_CHECK parameter " - "with c_set CRYPTO_CHECK '' " - "Note that you won't be able to retrieve previous tasks you " - "encrypted with your lost passphrase") - else: - # Now that passphrase is valid, we will always decrypt encrypted data - self.force_decrypt = True - - def isEncrypted(self, data): - """Check if data is encrypted - @return: True is the data seems encrypted, else False""" - return data is not None and data.startswith(CRYPTO_PREFIX) - - def isPassphraseValid(self): - """Check if user passphrase is valid. - ie. : if it can decrypt the check crypto word""" - if not self.passphrase: - # If no passphrase has been defined, it is definitively not valid ! - return False - if self.crypto_check: - try: - int(self._decrypt(self.crypto_check)) - return True - except ValueError: - return False - else: - # First time that user enter a passphrase. Store the crypto check - # for next time usage - # We use a long string composed of int that we encrypt - check_word = str(Random().getrandbits(KEY_LENGTH * KEY_LENGTH)) - check_word = adjustString(check_word, 10 * KEY_LENGTH) - self.crypto_check = self._encrypt(check_word) - - # Save it to database config - db.getSession().add(db.Config(name="CRYPTO_CHECK", value=self.crypto_check, system=True, - desc="Cryptographic check data of passphrase")) - return True - - -def adjustString(string, length): - """Adjust string to meet cipher requirement length""" - string = string[:length] # Shrink if key is too large - string = string.ljust(length, " ") # Complete if too short - return string diff -Nru yokadi-1.1.1/yokadi/core/db.py yokadi-1.2.0/yokadi/core/db.py --- yokadi-1.1.1/yokadi/core/db.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/db.py 2019-02-10 11:35:44.000000000 +0000 @@ -9,16 +9,16 @@ import json import os import sys -from pickle import loads, dumps from datetime import datetime from uuid import uuid1 from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import sessionmaker, relationship +from sqlalchemy.orm import scoped_session, sessionmaker, relationship from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy import Column, Integer, Boolean, Unicode, DateTime, Enum, ForeignKey, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy import Column, Integer, Boolean, Unicode, DateTime, Enum, ForeignKey, UniqueConstraint from sqlalchemy.types import TypeDecorator, VARCHAR from yokadi.core.recurrencerule import RecurrenceRule @@ -27,7 +27,7 @@ # Yokadi database version needed for this code # If database config key DB_VERSION differs from this one a database migration # is required -DB_VERSION = 10 +DB_VERSION = 12 DB_VERSION_KEY = "DB_VERSION" @@ -60,6 +60,24 @@ def __repr__(self): return self.name + def merge(self, session, other): + """Merge other into us + + This function calls session.commit() itself: we have to commit after + moving the tasks but *before* deleting `other` otherwise when we delete + `other` SQLAlchemy deletes its former tasks as well because it thinks + they are still attached to `other`""" + if self is other: + raise YokadiException("Cannot merge a project into itself") + + for task in other.tasks: + task.projectId = self.id + + session.commit() + + session.delete(other) + session.commit() + class Keyword(Base): __tablename__ = "keyword" @@ -79,6 +97,10 @@ keywordId = Column("keyword_id", Integer, ForeignKey("keyword.id"), nullable=False) value = Column(Integer, default=None) + __table_args__ = ( + UniqueConstraint("task_id", "keyword_id", name="task_keyword_uc"), + ) + def __repr__(self): return "".format(self.task, self.keyword, self.value) @@ -182,6 +204,32 @@ self.recurrence = rule self.dueDate = rule.getNext() + @staticmethod + def getNoteKeyword(session): + return session.query(Keyword).filter_by(name=NOTE_KEYWORD).one() + + def toNote(self, session): + session.add(TaskKeyword(task=self, keyword=Task.getNoteKeyword(session), value=None)) + try: + session.flush() + except IntegrityError: + # Already a note + session.rollback() + return + + def toTask(self, session): + noteKeyword = Task.getNoteKeyword(session) + try: + taskKeyword = session.query(TaskKeyword).filter_by(task=self, keyword=noteKeyword).one() + except NoResultFound: + # Already a task + return + session.delete(taskKeyword) + + def isNote(self, session): + noteKeyword = Task.getNoteKeyword(session) + return any((x.keyword == noteKeyword for x in self.taskKeywords)) + def __repr__(self): return "".format(self.id, self.title) @@ -243,12 +291,14 @@ _database = None + def getSession(): global _database if not _database: raise YokadiException("Cannot get session. Not connected to database") return _database.session + def connectDatabase(dbFileName, createIfNeeded=True, memoryDatabase=False): global _database _database = Database(dbFileName, createIfNeeded, memoryDatabase) @@ -277,9 +327,9 @@ if memoryDatabase: connectionString = "sqlite:///:memory:" - self.engine = create_engine(connectionString) - Session = sessionmaker(bind=self.engine) - self.session = Session() + echo = os.environ.get("YOKADI_SQL_DEBUG", "0") != "0" + self.engine = create_engine(connectionString, echo=echo) + self.session = scoped_session(sessionmaker(bind=self.engine)) if not os.path.exists(dbFileName) or memoryDatabase: if not createIfNeeded: @@ -288,8 +338,10 @@ print("Creating %s" % dbFileName) self.createTables() # Set database version according to current yokadi release - if not updateMode: # Update script add it from dump - self.session.add(Config(name=DB_VERSION_KEY, value=str(DB_VERSION), system=True, desc="Database schema release number")) + # Don't do it in updateMode: the update script adds the version from the dump + if not updateMode: + self.session.add(Config(name=DB_VERSION_KEY, value=str(DB_VERSION), system=True, + desc="Database schema release number")) self.session.commit() if not updateMode: @@ -328,15 +380,16 @@ def setDefaultConfig(): """Set default config parameter in database if they (still) do not exist""" defaultConfig = { - "ALARM_DELAY_CMD" : ('''kdialog --passivepopup "task {TITLE} ({ID}) is due for {DATE}" 180 --title "Yokadi: {PROJECT}"''', False, - "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), - "ALARM_DUE_CMD" : ('''kdialog --passivepopup "task {TITLE} ({ID}) should be done now" 1800 --title "Yokadi: {PROJECT}"''', False, - "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), - "ALARM_DELAY" : ("8", False, "Delay (in hours) before due date to launch the alarm (see ALARM_CMD)"), - "ALARM_SUSPEND" : ("1", False, "Delay (in hours) before an alarm trigger again"), - "PURGE_DELAY" : ("90", False, "Default delay (in days) for the t_purge command"), - "PASSPHRASE_CACHE": ("1", False, "Keep passphrase in memory till Yokadi is started (0 is false else true"), - } + "ALARM_DELAY_CMD": + ('''kdialog --passivepopup "task {TITLE} ({ID}) is due for {DATE}" 180 --title "Yokadi: {PROJECT}"''', + False, "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), + "ALARM_DUE_CMD": + ('''kdialog --passivepopup "task {TITLE} ({ID}) should be done now" 1800 --title "Yokadi: {PROJECT}"''', + False, "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), + "ALARM_DELAY": ("8", False, "Delay (in hours) before due date to launch the alarm (see ALARM_CMD)"), + "ALARM_SUSPEND": ("1", False, "Delay (in hours) before an alarm trigger again"), + "PURGE_DELAY": ("90", False, "Default delay (in days) for the t_purge command"), + } session = getSession() for name, value in defaultConfig.items(): diff -Nru yokadi-1.1.1/yokadi/core/dbutils.py yokadi-1.2.0/yokadi/core/dbutils.py --- yokadi-1.1.1/yokadi/core/dbutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/dbutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -9,7 +9,7 @@ from datetime import datetime, timedelta import os -from sqlalchemy import and_, or_ +from sqlalchemy import and_ from sqlalchemy.orm import aliased from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound @@ -27,8 +27,7 @@ @param interactive: Ask user before creating project (this is the default) @type interactive: Bool @returns : Task instance on success, None if cancelled.""" - session = db.getSession( - ) + session = db.getSession() if keywordDict is None: keywordDict = {} @@ -42,7 +41,8 @@ return None # Create task - task = Task(creationDate=datetime.now().replace(second=0, microsecond=0), project=project, title=title, description="", status="new") + task = Task(creationDate=datetime.now().replace(second=0, microsecond=0), project=project, title=title, + description="", status="new") session.add(task) task.setKeywordDict(keywordDict) session.merge(task) @@ -53,17 +53,21 @@ def getTaskFromId(tid): """Returns a task given its id, or raise a YokadiException if it does not exist. - @param tid: taskId string + @param tid: Task id or uuid @return: Task instance or None if existingTask is False""" session = db.getSession() - # We do not use line.isdigit() because it returns True if line is '¹'! - try: - taskId = int(tid) - except ValueError: - raise YokadiException("task id should be a number") + if isinstance(tid, str) and '-' in tid: + filters = dict(uuid=tid) + else: + try: + # We do not use line.isdigit() because it returns True if line is '¹'! + taskId = int(tid) + except ValueError: + raise YokadiException("task id should be a number") + filters = dict(id=taskId) try: - task = session.query(Task).filter_by(id=taskId).one() + task = session.query(Task).filter_by(**filters).one() except NoResultFound: raise YokadiException("Task %s does not exist. Use t_list to see all tasks" % taskId) return task @@ -165,7 +169,7 @@ try: return db.getSession().query(TaskLock).filter(TaskLock.task == self.task).one() except NoResultFound: - return None + return None def acquire(self, pid=None, now=None): """Acquire a lock for that task and remove any previous stale lock""" @@ -235,7 +239,8 @@ @return: a new query""" if self.negative: session = db.getSession() - excludedTaskIds = session.query(Task.id).join(TaskKeyword).join(Keyword).filter(Keyword.name.like(self.name)) + excludedTaskIds = session.query(Task.id).join(TaskKeyword).join(Keyword) \ + .filter(Keyword.name.like(self.name)) return query.filter(~Task.id.in_(excludedTaskIds)) else: keywordAlias = aliased(Keyword) diff -Nru yokadi-1.1.1/yokadi/core/recurrencerule.py yokadi-1.2.0/yokadi/core/recurrencerule.py --- yokadi-1.1.1/yokadi/core/recurrencerule.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/recurrencerule.py 2019-02-10 11:35:44.000000000 +0000 @@ -99,11 +99,11 @@ bymonthday = int(tokens[1]) byhour, byminute = getHourAndMinute(tokens[2]) except ValueError: - POSITION = {"first": 1, "second": 2, "third": 3, "fourth": 4, "last":-1} + POSITION = {"first": 1, "second": 2, "third": 3, "fourth": 4, "last": -1} if tokens[1].lower() in POSITION and len(tokens) == 4: byweekday = RecurrenceRule.createWeekDay( - weekday=getWeekDayNumberFromDay(tokens[2].lower()), - pos=POSITION[tokens[1]]) + weekday=getWeekDayNumberFromDay(tokens[2].lower()), + pos=POSITION[tokens[1]]) byhour, byminute = getHourAndMinute(tokens[3]) bymonthday = None # Default to current day number - need to be blanked else: @@ -118,26 +118,27 @@ else: raise YokadiException("Unknown frequency. Available: daily, weekly, monthly and yearly") - return RecurrenceRule(freq, - bymonth=bymonth, - bymonthday=bymonthday, - byweekday=byweekday, - byhour=byhour, - byminute=byminute, - ) + return RecurrenceRule( + freq, + bymonth=bymonth, + bymonthday=bymonthday, + byweekday=byweekday, + byhour=byhour, + byminute=byminute, + ) def toDict(self): if not self: return {} return dict( - freq=self._freq, - bymonth=self._bymonth, - bymonthday=self._bymonthday, - byweekday=self._byweekday, - byhour=self._byhour, - byminute=self._byminute - ) + freq=self._freq, + bymonth=self._bymonth, + bymonthday=self._bymonthday, + byweekday=self._byweekday, + byhour=self._byhour, + byminute=self._byminute + ) def _rrule(self): if isinstance(self._byweekday, dict): @@ -146,14 +147,15 @@ else: byweekday = self._byweekday - return rrule.rrule(freq=self._freq, - bymonth=self._bymonth, - bymonthday=self._bymonthday, - byweekday=byweekday, - byhour=self._byhour, - byminute=self._byminute, - bysecond=0 - ) + return rrule.rrule( + freq=self._freq, + bymonth=self._bymonth, + bymonthday=self._bymonthday, + byweekday=byweekday, + byhour=self._byhour, + byminute=self._byminute, + bysecond=0 + ) def getNext(self, refDate=None): """Return next date of recurrence after given date diff -Nru yokadi-1.1.1/yokadi/core/ydateutils.py yokadi-1.2.0/yokadi/core/ydateutils.py --- yokadi-1.1.1/yokadi/core/ydateutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/core/ydateutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -21,13 +21,13 @@ "%d/%m/%Y", "%d/%m/%y", "%d/%m", - ] +] TIME_FORMATS = [ "%H:%M:%S", "%H:%M", "%H", - ] +] def parseDateTimeDelta(line): @@ -38,7 +38,7 @@ raise YokadiException("Timeshift must be a float or an integer") suffix = line[-1].upper() - if suffix == "W": + if suffix == "W": return timedelta(days=delta * 7) elif suffix == "D": return timedelta(days=delta) @@ -92,7 +92,7 @@ out, fmt = testFormats(text, DATE_FORMATS) if not out: return None - if not "%y" in fmt and not "%Y" in fmt: + if "%y" not in fmt and "%Y" not in fmt: out = out.replace(year=today.year) return out.date() @@ -131,7 +131,7 @@ weekdayDict = { "today": today.weekday(), "tomorrow": (today.weekday() + 1) % 7, - } + } weekdayDict.update(WEEKDAYS) weekdayDict.update(SHORT_WEEKDAYS) weekday = weekdayDict.get(firstWord) @@ -245,7 +245,8 @@ elif day in WEEKDAYS: dayNumber = WEEKDAYS[day] else: - raise YokadiException("Day must be one of the following: [mo]nday, [tu]esday, [we]nesday, [th]ursday, [fr]iday, [sa]turday, [su]nday") + raise YokadiException("Day must be one of the following: [mo]nday, [tu]esday, [we]nesday, [th]ursday, [fr]iday," + " [sa]turday, [su]nday") return dayNumber @@ -264,7 +265,7 @@ (">=", operator.__ge__, TIME_HINT_BEGIN), (">", operator.__gt__, TIME_HINT_END), ("<", operator.__lt__, TIME_HINT_BEGIN), - ] + ] op = operator.__le__ hint = TIME_HINT_END diff -Nru yokadi-1.1.1/yokadi/createdemodb.py yokadi-1.2.0/yokadi/createdemodb.py --- yokadi-1.1.1/yokadi/createdemodb.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/createdemodb.py 2019-02-10 11:35:44.000000000 +0000 @@ -19,6 +19,7 @@ KEYWORDS = ["phone", "grocery", "_note"] + def main(): parser = ArgumentParser() parser.add_argument('db', metavar='') diff -Nru yokadi-1.1.1/yokadi/__init__.py yokadi-1.2.0/yokadi/__init__.py --- yokadi-1.1.1/yokadi/__init__.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/__init__.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,4 +6,4 @@ @license:GPL v3 or later """ -__version__ = "1.1.1" +__version__ = "1.2.0" diff -Nru yokadi-1.1.1/yokadi/tests/aliastestcase.py yokadi-1.2.0/yokadi/tests/aliastestcase.py --- yokadi-1.1.1/yokadi/tests/aliastestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/aliastestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -13,7 +13,7 @@ from yokadi.core import db from yokadi.core.db import Alias from yokadi.ycli.aliascmd import AliasCmd -from yokadi.ycli import colors as C +from yokadi.ycli import colors from yokadi.ycli import tui @@ -31,8 +31,8 @@ self.cmd.do_a_list("") content = out.getvalue() self.assertEqual(content, - C.BOLD + "a".ljust(10) + C.RESET + "=> t_list\n" + - C.BOLD + "b".ljust(10) + C.RESET + "=> t_add\n") + colors.BOLD + "a".ljust(10) + colors.RESET + "=> t_list\n" + + colors.BOLD + "b".ljust(10) + colors.RESET + "=> t_add\n") def testList_empty(self): out = StringIO() diff -Nru yokadi-1.1.1/yokadi/tests/argstestcase.py yokadi-1.2.0/yokadi/tests/argstestcase.py --- yokadi-1.1.1/yokadi/tests/argstestcase.py 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/argstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,86 @@ +""" +Command line argument test cases +@author: Aurélien Gâteau +@license: GPL v3 or later +""" +import os + +from argparse import ArgumentParser +from tempfile import TemporaryDirectory + +from yokadi.tests.yokaditestcase import YokadiTestCase + +from yokadi.core import basepaths +from yokadi.ycli import commonargs + + +def parseArgs(argv): + parser = ArgumentParser() + commonargs.addArgs(parser) + return parser.parse_args(argv) + + +class ArgsTestCase(YokadiTestCase): + def setUp(self): + super().setUp() + self.defaultDataDir = basepaths.getDataDir() + self.defaultDbPath = basepaths.getDbPath(self.defaultDataDir) + + def testNoArguments(self): + args = parseArgs([]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, self.defaultDataDir) + self.assertEqual(dbPath, self.defaultDbPath) + + self.assertTrue(os.path.isdir(dataDir)) + self.assertTrue(os.path.isdir(os.path.dirname(dbPath))) + + def testDataDir(self): + with TemporaryDirectory(prefix="yokadi-tests-") as tmpDir: + args = parseArgs(["--datadir", tmpDir]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, tmpDir) + self.assertEqual(dbPath, os.path.join(tmpDir, basepaths.DB_NAME)) + + def testRelativeDataDir(self): + with TemporaryDirectory(prefix="yokadi-tests-") as tmpDir: + os.chdir(tmpDir) + args = parseArgs(["--datadir", "."]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, tmpDir) + self.assertEqual(dbPath, os.path.join(tmpDir, basepaths.DB_NAME)) + + def testDataDirDoesNotExist(self): + args = parseArgs(["--datadir", "/does/not/exist"]) + self.assertRaises(SystemExit, commonargs.processArgs, args) + + def testCantUseBothDataDirAndDb(self): + self.assertRaises(SystemExit, parseArgs, ["--datadir", "foo", "--db", "bar"]) + + def testDb(self): + with TemporaryDirectory(prefix="yokadi-tests-") as tmpDir: + args = parseArgs(["--db", os.path.join(tmpDir, "foo.db")]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, self.defaultDataDir) + self.assertEqual(dbPath, os.path.join(tmpDir, "foo.db")) + + def testRelativeDb(self): + with TemporaryDirectory(prefix="yokadi-tests-") as tmpDir: + os.chdir(tmpDir) + args = parseArgs(["--db", "foo.db"]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, self.defaultDataDir) + self.assertEqual(dbPath, os.path.join(tmpDir, "foo.db")) + + def testDbDirDoesNotExist(self): + args = parseArgs(["--db", "/does/not/exist/foo.db"]) + self.assertRaises(SystemExit, commonargs.processArgs, args) + + def testArgsOverrideEnvVar(self): + with TemporaryDirectory(prefix="yokadi-tests-") as tmpDir: + os.environ["YOKADI_DB"] = os.path.join(tmpDir, "env.db") + os.chdir(tmpDir) + args = parseArgs(["--db", "arg.db"]) + dataDir, dbPath = commonargs.processArgs(args) + self.assertEqual(dataDir, self.defaultDataDir) + self.assertEqual(dbPath, os.path.join(tmpDir, "arg.db")) diff -Nru yokadi-1.1.1/yokadi/tests/basepathstestcase.py yokadi-1.2.0/yokadi/tests/basepathstestcase.py --- yokadi-1.1.1/yokadi/tests/basepathstestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/basepathstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,63 +7,48 @@ import os import shutil import tempfile -import unittest from pathlib import Path from yokadi.core import basepaths +from yokadi.tests.yokaditestcase import YokadiTestCase -def saveEnv(): - return dict(os.environ) - - -def restoreEnv(env): - # Do not use `os.environ = env`: this would replace the special os.environ - # object with a plain dict. We must update the *existing* object. - os.environ.clear() - os.environ.update(env) - - -class BasePathsUnixTestCase(unittest.TestCase): +class BasePathsUnixTestCase(YokadiTestCase): def setUp(self): + YokadiTestCase.setUp(self) self._oldWindows = basepaths._WINDOWS basepaths._WINDOWS = False - self._oldEnv = saveEnv() - self.testHomeDir = tempfile.mkdtemp(prefix="yokadi-basepaths-testcase") - os.environ["HOME"] = self.testHomeDir - def tearDown(self): - shutil.rmtree(self.testHomeDir) - restoreEnv(self._oldEnv) basepaths._WINDOWS = self._oldWindows + YokadiTestCase.tearDown(self) def testMigrateOldDb(self): oldDb = Path(self.testHomeDir) / '.yokadi.db' - newDb = Path(basepaths.getDbPath()) + newDb = Path(basepaths.getDbPath(basepaths.getDataDir())) oldDb.touch() - basepaths.migrateOldDb() + basepaths.migrateOldDb(str(newDb)) self.assertFalse(oldDb.exists()) self.assertTrue(newDb.exists()) def testMigrateNothingToDo(self): - newDb = Path(basepaths.getDbPath()) - basepaths.migrateOldDb() + newDb = Path(basepaths.getDbPath(basepaths.getDataDir())) + basepaths.migrateOldDb(str(newDb)) basepaths.migrateOldHistory() self.assertFalse(newDb.exists()) def testMigrateOldDbFails(self): oldDb = Path(self.testHomeDir) / '.yokadi.db' - newDb = Path(basepaths.getDbPath()) + newDb = Path(basepaths.getDbPath(basepaths.getDataDir())) oldDb.touch() newDb.parent.mkdir(parents=True) newDb.touch() - self.assertRaises(basepaths.MigrationException, basepaths.migrateOldDb) + self.assertRaises(basepaths.MigrationException, basepaths.migrateOldDb, str(newDb)) def testMigrateOldHistory(self): old = Path(self.testHomeDir) / '.yokadi_history' @@ -99,13 +84,13 @@ def testDbEnvVar(self): path = "foo" os.environ["YOKADI_DB"] = path - self.assertEqual(basepaths.getDbPath(), path) + self.assertEqual(basepaths.getDbPath(basepaths.getDataDir()), path) -class BasePathsWindowsTestCase(unittest.TestCase): +class BasePathsWindowsTestCase(YokadiTestCase): def setUp(self): + YokadiTestCase.setUp(self) self._oldWindows = basepaths._WINDOWS - self._oldEnv = saveEnv() basepaths._WINDOWS = True self.testAppDataDir = tempfile.mkdtemp(prefix="yokadi-basepaths-testcase") os.environ["APPDATA"] = self.testAppDataDir @@ -113,7 +98,7 @@ def tearDown(self): shutil.rmtree(self.testAppDataDir) basepaths._WINDOWS = self._oldWindows - restoreEnv(self._oldEnv) + YokadiTestCase.tearDown(self) def testGetCacheDir(self): expected = os.path.join(self.testAppDataDir, "yokadi", "cache") diff -Nru yokadi-1.1.1/yokadi/tests/bugtestcase.py yokadi-1.2.0/yokadi/tests/bugtestcase.py --- yokadi-1.1.1/yokadi/tests/bugtestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/bugtestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,8 +7,6 @@ import unittest -import testutils - from yokadi.ycli import tui from yokadi.ycli.main import YokadiCmd from yokadi.core import db, dbutils diff -Nru yokadi-1.1.1/yokadi/tests/completerstestcase.py yokadi-1.2.0/yokadi/tests/completerstestcase.py --- yokadi-1.1.1/yokadi/tests/completerstestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/completerstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,8 +7,6 @@ import unittest -import testutils - from yokadi.core import db from yokadi.core.db import Project, setDefaultConfig from yokadi.ycli import completers @@ -32,9 +30,9 @@ def testCompleteParameterPosition(self): data = [ - (("bla", "t_add bla", 6, 10), 1), - (("bli", "t_add bla bli", 10, 14), 2), - ] + (("bla", "t_add bla", 6, 10), 1), + (("bli", "t_add bla bli", 10, 14), 2), + ] for params, expectedResult in data: result = completers.computeCompleteParameterPosition(*params) self.assertEqual(result, expectedResult) diff -Nru yokadi-1.1.1/yokadi/tests/conftestcase.py yokadi-1.2.0/yokadi/tests/conftestcase.py --- yokadi-1.1.1/yokadi/tests/conftestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/conftestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -39,15 +39,6 @@ self.assertRaises(YokadiException, self.cmd.do_c_set, "ALARM_SUSPEND -1") self.assertRaises(YokadiException, self.cmd.do_c_set, "PURGE_DELAY -1") - def testPassphraseCacheValue(self): - # Test PASSPHRASE_CACHE error - self.assertRaises(YokadiException, self.cmd.do_c_set, "PASSPHRASE_CACHE 2") - - # Test PASSPHRASE_CACHE valid value (if that work we don't have exception) - self.cmd.do_c_set("PASSPHRASE_CACHE 0") - self.cmd.do_c_set("PASSPHRASE_CACHE 1") - def testWrongKey(self): - # Test PASSPHRASE_CACHE error self.assertRaises(YokadiException, self.cmd.do_c_set, "BAD_KEY value") self.assertRaises(YokadiException, self.cmd.do_c_get, "BAD_KEY") diff -Nru yokadi-1.1.1/yokadi/tests/cryptotestcase.py yokadi-1.2.0/yokadi/tests/cryptotestcase.py --- yokadi-1.1.1/yokadi/tests/cryptotestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/cryptotestcase.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,73 +0,0 @@ -# -*- coding: UTF-8 -*- -""" -Crypto functions test cases -@author: Sébastien Renard -@license: GPL v3 or later -""" - -import unittest - -import testutils - -from yokadi.ycli import tui -from yokadi.core.cryptutils import YokadiCryptoManager -from yokadi.core.yokadiexception import YokadiException -from yokadi.core import db -from yokadi.core.db import setDefaultConfig - - -class CryptoTestCase(unittest.TestCase): - def setUp(self): - db.connectDatabase("", memoryDatabase=True) - setDefaultConfig() - self.session = db.getSession() - tui.clearInputAnswers() - - def testEncrypt(self): - mgr = YokadiCryptoManager() - mgr.force_decrypt = True # Simulate user ask for decryption - tui.addInputAnswers("mySecretPassphrase") - important_sentence = "Don't tell anyone" - encrypted_sentence = mgr.encrypt(important_sentence) - decrypted_sentence = mgr.decrypt(encrypted_sentence) - self.assertEqual(important_sentence, decrypted_sentence) - # Enter again same passphrase and check it is ok - mgr = YokadiCryptoManager() - tui.addInputAnswers("mySecretPassphrase") - - def testEncryptLongSentence(self): - mgr = YokadiCryptoManager() - mgr.force_decrypt = True # Simulate user ask for decryption - tui.addInputAnswers("mySecretPassphrase") - important_sentence = '''This sentence is long long long long - This sentence is long - This sentence is long - This sentence is long - This sentence is long long long''' - encrypted_sentence = mgr.encrypt(important_sentence) - decrypted_sentence = mgr.decrypt(encrypted_sentence) - self.assertEqual(important_sentence, decrypted_sentence) - - def testBadPassphrase(self): - mgr = YokadiCryptoManager() - mgr.force_decrypt = True # Simulate user ask for decryption - tui.addInputAnswers("mySecretPassphrase") - important_sentence = "Don't tell anyone" - encrypted_sentence = mgr.encrypt(important_sentence) - - mgr = YokadiCryptoManager() # Define new manager with other passphrase - mgr.force_decrypt = True # Simulate user ask for decryption - tui.addInputAnswers("theWrongSecretPassphrase") - self.assertRaises(YokadiException, mgr.decrypt, encrypted_sentence) - - def testIfEncrypted(self): - mgr = YokadiCryptoManager() - mgr.force_decrypt = True # Simulate user ask for decryption - tui.addInputAnswers("mySecretPassphrase") - important_sentence = "Don't tell anyone" - encrypted_sentence = mgr.encrypt(important_sentence) - self.assertTrue(mgr.isEncrypted(encrypted_sentence)) - self.assertFalse(mgr.isEncrypted(important_sentence)) - - # Should not fail with empty data - self.assertFalse(mgr.isEncrypted(None)) diff -Nru yokadi-1.1.1/yokadi/tests/dbutilstestcase.py yokadi-1.2.0/yokadi/tests/dbutilstestcase.py --- yokadi-1.1.1/yokadi/tests/dbutilstestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/dbutilstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -9,8 +9,6 @@ from datetime import datetime -import testutils - from yokadi.core import dbutils, db from yokadi.ycli import tui from yokadi.core.db import Keyword, Project @@ -30,6 +28,12 @@ task = dbutils.getTaskFromId(str(t1.id)) self.assertEqual(task, t1) + task = dbutils.getTaskFromId(t1.id) + self.assertEqual(task, t1) + + task = dbutils.getTaskFromId(t1.uuid) + self.assertEqual(task, t1) + def testGetOrCreateKeyword(self): # interactive tui.addInputAnswers("y") diff -Nru yokadi-1.1.1/yokadi/tests/helptestcase.py yokadi-1.2.0/yokadi/tests/helptestcase.py --- yokadi-1.1.1/yokadi/tests/helptestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/helptestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -11,8 +11,6 @@ from cmd import Cmd from contextlib import contextmanager -import testutils - from yokadi.core import db from yokadi.ycli.main import YokadiCmd @@ -55,7 +53,7 @@ # We use Cmd implementation of onecmd() because YokadiCmd # overrides it to catch exceptions Cmd.onecmd(cmd, "help " + yokadiCommand) - except Exception as exc: + except Exception: print("'help %s' failed" % yokadiCommand) raise diff -Nru yokadi-1.1.1/yokadi/tests/icaltestcase.py yokadi-1.2.0/yokadi/tests/icaltestcase.py --- yokadi-1.1.1/yokadi/tests/icaltestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/icaltestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,14 +7,11 @@ import unittest -import testutils -import datetime from yokadi.ycli import tui from yokadi.yical import yical from yokadi.core import dbutils from yokadi.core import db -from yokadi.core.db import Task class IcalTestCase(unittest.TestCase): @@ -116,7 +113,7 @@ def testTaskDoneMapping(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) - v1 = yical.createVTodoFromTask(t1) + yical.createVTodoFromTask(t1) # v1["completed"] = datetime.datetime.now() # yical.updateTaskFromVTodo(t1, v1) diff -Nru yokadi-1.1.1/yokadi/tests/keywordtestcase.py yokadi-1.2.0/yokadi/tests/keywordtestcase.py --- yokadi-1.1.1/yokadi/tests/keywordtestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/keywordtestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,11 +6,11 @@ """ import unittest -import testutils +from sqlalchemy.orm.exc import NoResultFound from yokadi.core import dbutils from yokadi.ycli import tui -from yokadi.ycli.keywordcmd import KeywordCmd +from yokadi.ycli.keywordcmd import KeywordCmd, _listKeywords from yokadi.core.yokadiexception import YokadiException from yokadi.core import db @@ -69,4 +69,21 @@ self.assertTrue("k2" in kwDict) taskKeyword = self.session.query(db.TaskKeyword).filter_by(taskId=t1.id).one() self.assertEqual(taskKeyword.keyword.name, "k2") + + def testKRemove_unused(self): + self.cmd.do_k_add("kw") + self.session.query(db.Keyword).filter_by(name="kw").one() + self.cmd.do_k_remove("kw") + self.assertRaises(NoResultFound, self.session.query(db.Keyword).filter_by(name="kw").one) + + def testKList(self): + t1 = dbutils.addTask("x", "t1", dict(k1=12, k2=None), interactive=False) + t2 = dbutils.addTask("x", "t2", dict(k1=None, k3=None), interactive=False) + + lst = list(_listKeywords(self.session)) + lst = [(name, list(ids)) for name, ids in lst] + self.assertEqual(lst, [("k1", [t1.id, t2.id]), + ("k2", [t1.id]), + ("k3", [t2.id]), + ]) # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/tests/massedittestcase.py yokadi-1.2.0/yokadi/tests/massedittestcase.py --- yokadi-1.1.1/yokadi/tests/massedittestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/massedittestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -123,8 +123,8 @@ def testOnlyListTasks(self): prj = dbutils.getOrCreateProject("p1", interactive=False) - t1 = dbutils.addTask("p1", "Task", {}) - t2 = dbutils.addTask("p1", "Note", {NOTE_KEYWORD: None}) + dbutils.addTask("p1", "Task", {}) + dbutils.addTask("p1", "Note", {NOTE_KEYWORD: None}) oldList = massedit.createEntriesForProject(prj) self.assertEqual(len(oldList), 1) diff -Nru yokadi-1.1.1/yokadi/tests/parseutilstestcase.py yokadi-1.2.0/yokadi/tests/parseutilstestcase.py --- yokadi-1.1.1/yokadi/tests/parseutilstestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/parseutilstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -9,11 +9,12 @@ from yokadi.ycli import parseutils gTaskLineToParsedStructList = [ - ("project some text @keyword1 @keyword2=12 some other text", ("project", "some text some other text", {"keyword1":None, "keyword2":12})), + ("project some text @keyword1 @keyword2=12 some other text", ("project", "some text some other text", + {"keyword1": None, "keyword2": 12})), ("project ééé", ("project", "ééé", {})), ("project let's include quotes\"", ("project", "let's include quotes\"", {})), (" project this one has extra spaces ", ("project", "this one has extra spaces", {})), - ] +] class ParseUtilsTestCase(unittest.TestCase): diff -Nru yokadi-1.1.1/yokadi/tests/projecttestcase.py yokadi-1.2.0/yokadi/tests/projecttestcase.py --- yokadi-1.1.1/yokadi/tests/projecttestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/projecttestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -10,7 +10,7 @@ import testutils from yokadi.core import db, dbutils -from yokadi.core.db import Project, Keyword, Task +from yokadi.core.db import Project, Task from yokadi.core.yokadiexception import YokadiException from yokadi.ycli.main import YokadiCmd from yokadi.ycli import tui @@ -56,7 +56,7 @@ # Create project p1 with one associated task tui.addInputAnswers("y") self.cmd.do_p_add("p1") - project = self.session.query(Project).one() + self.session.query(Project).one() task = dbutils.addTask("p1", "t1", interactive=False) taskId = task.id @@ -78,4 +78,32 @@ self.cmd.do_p_set_active("p1") self.assertEqual(project.active, True) + def testMerge(self): + COUNT = 4 + for x in range(COUNT): + dbutils.addTask('p1', 'p1-t{}'.format(x), interactive=False) + dbutils.addTask('p2', 'p2-t{}'.format(x), interactive=False) + + # Merge p1 into p2 + tui.addInputAnswers("y") + self.cmd.do_p_merge("p1 p2") + + # p2 should have both its tasks and all p1 tasks now + project = self.session.query(Project).filter_by(name="p2").one() + tasks = set([x.title for x in project.tasks]) + + expected = set() + for x in range(COUNT): + expected.add('p1-t{}'.format(x)) + expected.add('p2-t{}'.format(x)) + self.assertEqual(tasks, expected) + + # p1 should be gone + testutils.assertQueryEmpty(self, self.session.query(Project).filter_by(name="p1")) + + def testMergeItselfFails(self): + project = Project(name="p1") + self.assertRaises(YokadiException, project.merge, self.session, project) + + # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/tests/recurrenceruletestcase.py yokadi-1.2.0/yokadi/tests/recurrenceruletestcase.py --- yokadi-1.1.1/yokadi/tests/recurrenceruletestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/recurrenceruletestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -43,25 +43,29 @@ ), TestRow( "quarterly 2 8:27", - {"freq": rrule.YEARLY, "bymonth": (1, 4, 7, 10), "bymonthday": (2,), "byweekday": (), "byhour": (8,), "byminute": (27,)}, + {"freq": rrule.YEARLY, "bymonth": (1, 4, 7, 10), "bymonthday": (2,), "byweekday": (), "byhour": (8,), + "byminute": (27,)}, RecurrenceRule(rrule.YEARLY, bymonth=(1, 4, 7, 10), bymonthday=2, byhour=8, byminute=27), REF_DATE.replace(month=4, day=2, hour=8, minute=27) ), TestRow( "monthly first wednesday 8:27", - {"freq": rrule.MONTHLY, "bymonth": (), "bymonthday": (), "byweekday": {"pos": 1, "weekday": 2}, "byhour": (8,), "byminute": (27,)}, + {"freq": rrule.MONTHLY, "bymonth": (), "bymonthday": (), "byweekday": {"pos": 1, "weekday": 2}, "byhour": (8,), + "byminute": (27,)}, RecurrenceRule(rrule.MONTHLY, byweekday=RecurrenceRule.createWeekDay(pos=1, weekday=2), byhour=8, byminute=27), REF_DATE.replace(month=4, day=2, hour=8, minute=27) ), TestRow( "monthly last sunday 8:27", - {"freq": rrule.MONTHLY, "bymonth": (), "bymonthday": (), "byweekday": {"pos": -1, "weekday": 6}, "byhour": (8,), "byminute": (27,)}, + {"freq": rrule.MONTHLY, "bymonth": (), "bymonthday": (), "byweekday": {"pos": -1, "weekday": 6}, "byhour": (8,), + "byminute": (27,)}, RecurrenceRule(rrule.MONTHLY, byweekday=RecurrenceRule.createWeekDay(pos=-1, weekday=6), byhour=8, byminute=27), REF_DATE.replace(month=3, day=30, hour=8, minute=27) ), TestRow( "yearly 23/2 8:27", - {"freq": rrule.YEARLY, "bymonth": (2,), "bymonthday": (23,), "byweekday": (), "byhour": (8,), "byminute": (27,)}, + {"freq": rrule.YEARLY, "bymonth": (2,), "bymonthday": (23,), "byweekday": (), "byhour": (8,), + "byminute": (27,)}, RecurrenceRule(rrule.YEARLY, bymonth=2, bymonthday=23, byhour=8, byminute=27), REF_DATE.replace(year=2201, month=2, day=23, hour=8, minute=27) ), @@ -78,18 +82,22 @@ ("weekly Fr 23:00", RecurrenceRule(rrule.WEEKLY, byweekday=4, byhour=23)), ("weekly Friday 23:00", RecurrenceRule(rrule.WEEKLY, byweekday=4, byhour=23)), ("monthly 3 13:00", RecurrenceRule(rrule.MONTHLY, bymonthday=3, byhour=13)), - ("monthly second friday 13:00", RecurrenceRule(rrule.MONTHLY, byweekday=RecurrenceRule.createWeekDay(weekday=4, pos=2), byhour=13)), + ("monthly second friday 13:00", RecurrenceRule(rrule.MONTHLY, + byweekday=RecurrenceRule.createWeekDay(weekday=4, pos=2), + byhour=13)), ("yearly 3/07 11:20", RecurrenceRule(rrule.YEARLY, bymonth=7, bymonthday=3, byhour=11, byminute=20)), - ("quarterly 14 11:20", RecurrenceRule(rrule.YEARLY, bymonth=(1, 4, 7, 10), bymonthday=14, byhour=11, byminute=20)), - ("quarterly first monday 23:20", RecurrenceRule(rrule.YEARLY, bymonth=(1, 4, 7, 10), byweekday=RecurrenceRule.createWeekDay(weekday=0, pos=1), byhour=23, byminute=20)), - ] + [(x.text, x.rule) for x in TEST_DATA] + ("quarterly 14 11:20", RecurrenceRule(rrule.YEARLY, bymonth=(1, 4, 7, 10), bymonthday=14, byhour=11, + byminute=20)), + ("quarterly first monday 23:20", RecurrenceRule(rrule.YEARLY, bymonth=(1, 4, 7, 10), + byweekday=RecurrenceRule.createWeekDay(weekday=0, pos=1), + byhour=23, byminute=20)), + ] + [(x.text, x.rule) for x in TEST_DATA] for text, expected in testData: with self.subTest(text=text): output = RecurrenceRule.fromHumaneString(text) self.assertEqual(output, expected, - '\ninput: {}\noutput: {}\nexpected: {}'.format(text, output, expected) - ) + '\ninput: {}\noutput: {}\nexpected: {}'.format(text, output, expected)) def testFromHumaneString_badInput(self): for badInput in ("foo", # Unknown recurrence @@ -109,8 +117,7 @@ with self.subTest(text=row.text): rule = RecurrenceRule.fromDict(row.dct) self.assertEqual(rule, row.rule, - '\ninput: {}\nrule: {}\nexpected: {}'.format(row.dct, rule, row.rule) - ) + '\ninput: {}\nrule: {}\nexpected: {}'.format(row.dct, rule, row.rule)) dct = rule.toDict() self.assertEqual(dct, row.dct) diff -Nru yokadi-1.1.1/yokadi/tests/tasktestcase.py yokadi-1.2.0/yokadi/tests/tasktestcase.py --- yokadi-1.1.1/yokadi/tests/tasktestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/tasktestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -11,7 +11,6 @@ from yokadi.ycli import tui from yokadi.ycli.main import YokadiCmd -from yokadi.core import cryptutils from yokadi.core import db from yokadi.core import dbutils from yokadi.core.db import Task, TaskLock, Keyword, setDefaultConfig, Project, TaskKeyword @@ -48,11 +47,6 @@ "x"): # No task name self.assertRaises(BadUsageException, self.cmd.do_t_add, bad_input) - # Crypto stuff - tui.addInputAnswers("a Secret passphrase") - self.cmd.do_t_add("-c x encrypted t1") - self.assertTrue(self.session.query(Task).get(3).title.startswith(cryptutils.CRYPTO_PREFIX)) - def testEdit(self): tui.addInputAnswers("y") self.cmd.do_t_add("x txt @_note") @@ -261,6 +255,21 @@ "@%", "@k%", "!@%", "!@kw1", "-f plain", "-f xml", "-f html", "-f csv"): self.cmd.do_t_list(line) + def testTlistUrgency0(self): + # Given a project with two tasks, one with a negative urgency + prj = Project(name="prj") + self.session.add(prj) + t1 = Task(project=prj, title="t1") + self.session.add(t1) + t2 = Task(project=prj, title="t2", urgency=-1) + self.session.add(t2) + self.session.flush() + # When I list tasks with -u 0 + renderer = testutils.TestRenderer() + self.cmd.do_t_list("-u 0", renderer=renderer) + # Then the task with a negative urgency is not listed + self.assertEqual(renderer.tasks, [t1]) + def testNlist(self): tui.addInputAnswers("y") self.cmd.do_n_add("x t1") @@ -333,4 +342,30 @@ for bad_input in ("coucou", "+1s"): self.assertRaises(YokadiException, self.cmd.do_t_due, "1 %s" % bad_input) + def testToNote(self): + tui.addInputAnswers("y") + self.cmd.do_t_add("x t1") + + self.cmd.do_t_to_note(1) + task = self.session.query(Task).get(1) + self.assertTrue(task.isNote(self.session)) + + # Doing it twice should not fail + self.cmd.do_t_to_note(1) + task = self.session.query(Task).get(1) + self.assertTrue(task.isNote(self.session)) + + def testToTask(self): + tui.addInputAnswers("y") + self.cmd.do_n_add("x t1") + + self.cmd.do_n_to_task(1) + task = self.session.query(Task).get(1) + self.assertFalse(task.isNote(self.session)) + + # Doing it twice should not fail + self.cmd.do_n_to_task(1) + task = self.session.query(Task).get(1) + self.assertFalse(task.isNote(self.session)) + # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/tests/tests.py yokadi-1.2.0/yokadi/tests/tests.py --- yokadi-1.1.1/yokadi/tests/tests.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/tests.py 2019-02-10 11:35:44.000000000 +0000 @@ -13,41 +13,41 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) -from yokadi.core import db try: - import icalendar + import icalendar # noqa: F401 hasIcalendar = True except ImportError: hasIcalendar = False print("icalendar is not installed, some tests won't be run") -from parseutilstestcase import ParseUtilsTestCase -from yokadioptionparsertestcase import YokadiOptionParserTestCase -from ydateutilstestcase import YDateUtilsTestCase -from dbutilstestcase import DbUtilsTestCase -from projecttestcase import ProjectTestCase -from completerstestcase import CompletersTestCase -from tasktestcase import TaskTestCase -from bugtestcase import BugTestCase -from aliastestcase import AliasTestCase -from textlistrenderertestcase import TextListRendererTestCase +from parseutilstestcase import ParseUtilsTestCase # noqa: F401 +from yokadioptionparsertestcase import YokadiOptionParserTestCase # noqa: F401 +from ydateutilstestcase import YDateUtilsTestCase # noqa: F401 +from dbutilstestcase import DbUtilsTestCase # noqa: F401 +from projecttestcase import ProjectTestCase # noqa: F401 +from completerstestcase import CompletersTestCase # noqa: F401 +from tasktestcase import TaskTestCase # noqa: F401 +from bugtestcase import BugTestCase # noqa: F401 +from aliastestcase import AliasTestCase # noqa: F401 +from textlistrenderertestcase import TextListRendererTestCase # noqa: F401 if hasIcalendar: - from icaltestcase import IcalTestCase -from keywordtestcase import KeywordTestCase -from cryptotestcase import CryptoTestCase -from tuitestcase import TuiTestCase -from helptestcase import HelpTestCase -from conftestcase import ConfTestCase -from massedittestcase import MassEditTestCase -from basepathstestcase import BasePathsUnixTestCase, BasePathsWindowsTestCase -from keywordfiltertestcase import KeywordFilterTestCase -from recurrenceruletestcase import RecurrenceRuleTestCase + from icaltestcase import IcalTestCase # noqa: F401 +from keywordtestcase import KeywordTestCase # noqa: F401 +from tuitestcase import TuiTestCase # noqa: F401 +from helptestcase import HelpTestCase # noqa: F401 +from conftestcase import ConfTestCase # noqa: F401 +from massedittestcase import MassEditTestCase # noqa: F401 +from basepathstestcase import BasePathsUnixTestCase, BasePathsWindowsTestCase # noqa: F401 +from keywordfiltertestcase import KeywordFilterTestCase # noqa: F401 +from recurrenceruletestcase import RecurrenceRuleTestCase # noqa: F401 +from argstestcase import ArgsTestCase # noqa: F401 def main(): unittest.main() + if __name__ == "__main__": main() # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/tests/testutils.py yokadi-1.2.0/yokadi/tests/testutils.py --- yokadi-1.1.1/yokadi/tests/testutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/testutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,6 +6,8 @@ """ from collections import OrderedDict +import os + def multiLinesAssertEqual(test, str1, str2): lst1 = str1.splitlines() @@ -16,16 +18,53 @@ test.assertEqual(len(lst1), len(lst2)) +def assertQueryEmpty(test, query): + lst = list(query) + test.assertEqual(lst, []) + + class TestRenderer(object): """ - A fake renderer, which stores all rendered tasks in taskDict + A fake renderer, which stores all rendered tasks in: + - taskDict: a dict for each section + - tasks: a list of all tasks """ def __init__(self): self.taskDict = OrderedDict() + self.tasks = [] def addTaskList(self, sectionName, taskList): self.taskDict[sectionName] = taskList + self.tasks.extend(taskList) def end(self): pass + + +class EnvironSaver(object): + """ + This class saves and restore the environment. + + Can be used manually or as a context manager. + """ + def __init__(self): + self.save() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.restore() + return False + + def save(self): + self.oldEnv = dict(os.environ) + + def restore(self): + # Do not use `os.environ = env`: this would replace the special os.environ + # object with a plain dict. We must update the *existing* object. + os.environ.clear() + os.environ.update(self.oldEnv) + + # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/tests/textlistrenderertestcase.py yokadi-1.2.0/yokadi/tests/textlistrenderertestcase.py --- yokadi-1.1.1/yokadi/tests/textlistrenderertestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/textlistrenderertestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,27 +8,21 @@ import unittest from io import StringIO -import yokadi.ycli.colors as C +from yokadi.ycli import colors from yokadi.core import dbutils -import testutils from yokadi.ycli import tui from yokadi.ycli.textlistrenderer import TextListRenderer, TitleFormater -from yokadi.core.cryptutils import YokadiCryptoManager from yokadi.core import db def stripColor(text): - for colorcode in C.BOLD, C.RED, C.GREEN, C.ORANGE, C.PURPLE, C.CYAN, C.GREY, C.RESET: + for colorcode in (colors.BOLD, colors.RED, colors.GREEN, colors.ORANGE, colors.PURPLE, colors.CYAN, colors.GREY, + colors.RESET): text = text.replace(colorcode, '') return text -class StubCryptoMgr: - def decrypt(self, title): - return title - - class TextListRendererTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) @@ -57,7 +51,7 @@ for task, width, expected in TEST_DATA: with self.subTest(task=task, width=width): - formater = TitleFormater(width, StubCryptoMgr()) + formater = TitleFormater(width) out = formater(task)[0] out = stripColor(out) @@ -68,24 +62,24 @@ dbutils.getOrCreateProject("x", interactive=False) dbutils.getOrCreateKeyword("k1", interactive=False) dbutils.getOrCreateKeyword("k2", interactive=False) - t1 = dbutils.addTask("x", "t1", {}) + dbutils.addTask("x", "t1", {}) t2 = dbutils.addTask("x", "t2", {"k1": None, "k2": 12}) longTask = dbutils.addTask("x", "A longer task name", {}) longTask.description = "And it has a description" out = StringIO() - renderer = TextListRenderer(out, termWidth=80, cryptoMgr=YokadiCryptoManager()) + renderer = TextListRenderer(out, termWidth=80) renderer.addTaskList("Foo", [t2, longTask]) self.assertEqual(renderer.maxTitleWidth, len(longTask.title) + 1) renderer.end() out = stripColor(out.getvalue()) expected = \ - " Foo \n" \ - + "ID│Title │U │S│Age │Due date\n" \ - + "──┼───────────────────┼───┼─┼────────┼────────\n" \ - + "2 │t2 (k1, k2) │0 │N│0m │ \n" \ - + "3 │A longer task name*│0 │N│0m │ \n" + " Foo \n" \ + "ID│Title │U │S│Age │Due date\n" \ + "──┼───────────────────┼───┼─┼────────┼────────\n" \ + "2 │t2 (k1, k2) │0 │N│0m │ \n" \ + "3 │A longer task name*│0 │N│0m │ \n" self.assertMultiLineEqual(out, expected) diff -Nru yokadi-1.1.1/yokadi/tests/ydateutilstestcase.py yokadi-1.2.0/yokadi/tests/ydateutilstestcase.py --- yokadi-1.1.1/yokadi/tests/ydateutilstestcase.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/ydateutilstestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -21,7 +21,7 @@ ("5H", timedelta(hours=5)), ("6.5D", timedelta(days=6, hours=12)), ("12W", timedelta(days=12 * 7)), - ] + ] for text, expected in testData: output = ydateutils.parseDateTimeDelta(text) @@ -60,7 +60,7 @@ ("-1d", None, datetime(2009, 1, 2)), ("+3h", None, datetime(2009, 1, 3, 3, 0)), ("-1M", None, datetime(2009, 1, 2, 23, 59)), - ] + ] for text, hint, expected in testData: output = ydateutils.parseHumaneDateTime(text, hint=hint, today=today) @@ -72,7 +72,7 @@ testData = [ ("1:00pm", now.replace(hour=13, minute=0)), ("10:00am", now.replace(hour=10, minute=0) + timedelta(days=1)), - ] + ] for text, expected in testData: output = ydateutils.parseHumaneDateTime(text, hint=None, today=now) @@ -90,7 +90,7 @@ (timedelta(days=80), "2M, 20d"), (timedelta(days=365), "1Y"), (timedelta(days=400), "1Y, 1M"), - ] + ] for input, expected in testData: output = ydateutils.formatTimeDelta(input) @@ -115,7 +115,7 @@ ("tomorrow 18:00", operator.__le__, today + timedelta(days=1, hours=18)), ("sunday", operator.__le__, datetime(2009, 1, 4).replace(**endOfDay)), ("tu 11:45", operator.__le__, datetime(2009, 1, 6, 11, 45)), - ] + ] for text, expectedOp, expectedDate in testData: output = ydateutils.parseDateLimit(text, today=today) @@ -125,11 +125,11 @@ def testGuessTime(self): for invalidTime in ("+5M", "+1m", "+2H", "+3h", "+9D", "+14d", "+432W", "+0w", - "01/01/2009", "10/10/2008 12", "7/7/2007 10:15", "1/2/2003 1:2:3"): + "01/01/2009", "10/10/2008 12", "7/7/2007 10:15", "1/2/2003 1:2:3"): self.assertIsNone(ydateutils.guessTime(invalidTime)) - - for text, expected in (('12:05:20', time(hour=12, minute=5, second=20)), ('10:00am', time(hour=10)), ('7:30pm', time(hour=19, minute=30))): + for text, expected in (('12:05:20', time(hour=12, minute=5, second=20)), ('10:00am', time(hour=10)), + ('7:30pm', time(hour=19, minute=30))): output = ydateutils.guessTime(text) self.assertEqual(expected, output) diff -Nru yokadi-1.1.1/yokadi/tests/yokaditestcase.py yokadi-1.2.0/yokadi/tests/yokaditestcase.py --- yokadi-1.1.1/yokadi/tests/yokaditestcase.py 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/yokadi/tests/yokaditestcase.py 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,32 @@ +""" +Yokadi base class for test cases +@author: Aurélien Gâteau +@license: GPL v3 or later +""" +import os +import shutil +import tempfile +import unittest + +from yokadi.tests.testutils import EnvironSaver + + +class YokadiTestCase(unittest.TestCase): + """ + A TestCase which takes care of isolating the test from the user home dir + and environment. + """ + def setUp(self): + self.__envSaver = EnvironSaver() + self.testHomeDir = tempfile.mkdtemp(prefix="yokadi-basepaths-testcase") + os.environ["HOME"] = self.testHomeDir + os.environ["XDG_DATA_HOME"] = "" + os.environ["XDG_CACHE_HOME"] = "" + os.environ["YOKADI_DB"] = "" + os.environ["YOKADI_HISTORY"] = "" + self.__cwd = os.getcwd() + + def tearDown(self): + shutil.rmtree(self.testHomeDir) + self.__envSaver.restore() + os.chdir(self.__cwd) diff -Nru yokadi-1.1.1/yokadi/update/update10to11.py yokadi-1.2.0/yokadi/update/update10to11.py --- yokadi-1.1.1/yokadi/update/update10to11.py 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update10to11.py 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,35 @@ +""" +Update from version 10 to version 11 of Yokadi DB + +- Make the tuple (task_id, keyword_id) unique in TaskKeyword + +@author: Aurélien Gâteau +@license: GPL v3 or newer +""" +from collections import defaultdict + +from yokadi.update import updateutils + + +def removeTaskKeywordDuplicates(cursor): + sql = "select id, task_id, keyword_id from task_keyword" + + # Create a dict of (task_id, keyword_id) => [id...] + dct = defaultdict(list) + for row in cursor.execute(sql).fetchall(): + tk_id, task_id, keyword_id = row + dct[(task_id, keyword_id)].append(tk_id) + + # Delete all extra ids + for (task_id, keyword_id), tk_ids in dct.items(): + for tk_id in tk_ids[1:]: + cursor.execute("delete from task_keyword where id = ?", (tk_id,)) + + +def update(cursor): + removeTaskKeywordDuplicates(cursor) + + +if __name__ == "__main__": + updateutils.main(update) +# vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/update/update11to12.py yokadi-1.2.0/yokadi/update/update11to12.py --- yokadi-1.1.1/yokadi/update/update11to12.py 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update11to12.py 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,110 @@ +""" +Update from version 11 to version 12 of Yokadi DB + +- Decrypt all encrypted tasks + +@author: Aurélien Gâteau +@license: GPL v3 or newer +""" +import base64 + +from getpass import getpass + +from yokadi.update import updateutils +from yokadi.ycli import tui + +try: + from Crypto.Cipher import AES as CRYPTO_ALGO +except ImportError: + CRYPTO_ALGO = None + +CRYPTO_PREFIX = "---YOKADI-ENCRYPTED-MESSAGE---" +KEY_LENGTH = 32 + +CRYPTO_CHECK_KEY = "CRYPTO_CHECK" +CONFIG_KEYS = "PASSPHRASE_CACHE", CRYPTO_CHECK_KEY + + +def getPassphrase(): + phrase = getpass(prompt="Enter passphrase: ") + phrase = phrase[:KEY_LENGTH] + return phrase.ljust(KEY_LENGTH, " ") + + +def decryptData(cypher, data): + if not data: + return data + data = data[len(CRYPTO_PREFIX):] # Remove crypto prefix + data = base64.b64decode(data) + return cypher.decrypt(data).rstrip().decode(encoding="utf-8") + + +def decryptTask(cursor, cypher, row): + taskId, title, description = row + title = decryptData(cypher, title) + description = decryptData(cypher, description) + cursor.execute("update task set title = ?, description = ? where id = ?", + (title, description, taskId)) + + +def getCheckText(cursor): + sql = "select value from config where name like ?" + row = cursor.execute(sql, (CRYPTO_CHECK_KEY,)).fetchone() + return row[0] + + +def checkPassphrase(cypher, checkText): + try: + decryptData(cypher, checkText) + return True + except UnicodeDecodeError: + return False + + +def decryptEncryptedTasks(cursor): + sql = "select id, title, description from task where title like ?" + + rows = cursor.execute(sql, (CRYPTO_PREFIX + "%",)).fetchall() + if not rows: + return + + if CRYPTO_ALGO is None: + msg = ("This database contains encrypted data but pycrypto is not" + " installed.\n" + "Please install pycrypto and try again.") + raise updateutils.UpdateError(msg) + + if not tui.confirm("This database contains encrypted tasks, but Yokadi no " + "longer supports encryption.\n" + "These tasks need to be decrypted to continue using " + "Yokadi.\n" + "Do you want to decrypt your tasks?"): + raise updateutils.UpdateCanceledError() + + checkText = getCheckText(cursor) + while True: + phrase = getPassphrase() + cypher = CRYPTO_ALGO.new(phrase) + if checkPassphrase(cypher, checkText): + break + else: + if not tui.confirm("Wrong passphrase, try again?"): + raise updateutils.UpdateCanceledError() + for row in rows: + decryptTask(cursor, cypher, row) + + +def removeCryptoConfigKeys(cursor): + sql = "delete from config where name like ?" + for key in CONFIG_KEYS: + cursor.execute(sql, (key,)) + + +def update(cursor): + decryptEncryptedTasks(cursor) + removeCryptoConfigKeys(cursor) + + +if __name__ == "__main__": + updateutils.main(update) +# vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/update/update1to2.py yokadi-1.2.0/yokadi/update/update1to2.py --- yokadi-1.1.1/yokadi/update/update1to2.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update1to2.py 2019-02-10 11:35:44.000000000 +0000 @@ -5,6 +5,8 @@ @author: Aurélien Gâteau @license: GPL v3 or newer """ + + def createConfigTable(cursor): cursor.execute("""create table config ( id integer not null, @@ -18,18 +20,18 @@ );""") rows = [ - ("DB_VERSION" , "2" , True , "Database schema release number"), - ("TEXT_WIDTH" , "60" , False, "Width of task display output with t_list command"), + ("DB_VERSION", "2", True, "Database schema release number"), + ("TEXT_WIDTH", "60", False, "Width of task display output with t_list command"), ("DEFAULT_PROJECT", "default", False, "Default project used when no project name given"), - ("ALARM_DELAY_CMD", '''kdialog --sorry "task {TITLE} ({ID}) is due for {DATE}" --title "Yokadi Daemon"''', False, - "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), - ("ALARM_DUE_CMD" , '''kdialog --error "task {TITLE} ({ID}) should be done now" --title "Yokadi Daemon"''', False, - "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY") - ] + ("ALARM_DELAY_CMD", '''kdialog --sorry "task {TITLE} ({ID}) is due for {DATE}" --title "Yokadi Daemon"''', + False, "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY"), + ("ALARM_DUE_CMD", '''kdialog --error "task {TITLE} ({ID}) should be done now" --title "Yokadi Daemon"''', + False, "Command executed by Yokadi Daemon when a tasks due date is reached soon (see ALARM_DELAY") + ] for name, value, system, desc in rows: system = 1 if system else 0 cursor.execute("insert into config(name, value, system, \"desc\")\n" - "values (?, ?, ?, ?)", (name, value, system, desc)) + "values (?, ?, ?, ?)", (name, value, system, desc)) def addProjectActiveColumn(cursor): diff -Nru yokadi-1.1.1/yokadi/update/update2to3.py yokadi-1.2.0/yokadi/update/update2to3.py --- yokadi-1.1.1/yokadi/update/update2to3.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update2to3.py 2019-02-10 11:35:44.000000000 +0000 @@ -5,6 +5,8 @@ @author: Sébastien Renard @license: GPL v3 or newer """ + + def createProjectKeywordTable(cursor): cursor.execute(""" create table project_keyword ( diff -Nru yokadi-1.1.1/yokadi/update/update3to4.py yokadi-1.2.0/yokadi/update/update3to4.py --- yokadi-1.1.1/yokadi/update/update3to4.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update3to4.py 2019-02-10 11:35:44.000000000 +0000 @@ -5,6 +5,8 @@ @author: Sébastien Renard @license: GPL v3 or newer """ + + def createRecurrenceTable(cursor): cursor.execute(""" create table recurrence ( @@ -16,8 +18,7 @@ def addTaskRecurrenceIdColumn(cursor): - cursor.execute("alter table task add column recurrence_id integer" \ - " references recurrence(id)") + cursor.execute("alter table task add column recurrence_id integer references recurrence(id)") def removeDefaultProject(cursor): diff -Nru yokadi-1.1.1/yokadi/update/update4to5.py yokadi-1.2.0/yokadi/update/update4to5.py --- yokadi-1.1.1/yokadi/update/update4to5.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update4to5.py 2019-02-10 11:35:44.000000000 +0000 @@ -5,6 +5,8 @@ @author: Sébastien Renard @license: GPL v3 or newer """ + + def updateBugsKeywordsNames(cursor): for keyword in ("bug", "severity", "likelihood"): cursor.execute("update keyword set name='_%s' where name='%s'" % (keyword, keyword)) diff -Nru yokadi-1.1.1/yokadi/update/update7to8.py yokadi-1.2.0/yokadi/update/update7to8.py --- yokadi-1.1.1/yokadi/update/update7to8.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update7to8.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,6 +6,8 @@ @author: Aurélien Gâteau @license: GPL v3 or newer """ + + def dropProjectKeywordTable(cursor): cursor.execute('drop table project_keyword') diff -Nru yokadi-1.1.1/yokadi/update/update8to9.py yokadi-1.2.0/yokadi/update/update8to9.py --- yokadi-1.1.1/yokadi/update/update8to9.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update8to9.py 2019-02-10 11:35:44.000000000 +0000 @@ -39,8 +39,7 @@ return for name, command in aliases.items(): uuid = str(uuid1()) - cursor.execute("insert into alias(uuid, name, command) values(?, ?, ?)", - (uuid, name, command)) + cursor.execute("insert into alias(uuid, name, command) values(?, ?, ?)", (uuid, name, command)) cursor.execute("delete from config where name = 'ALIASES'") @@ -50,8 +49,7 @@ for row in cursor.execute("select id from {}".format(tableName)).fetchall(): id = row[0] uuid = str(uuid1()) - cursor.execute("update {} set uuid = ? where id = ?".format(tableName), - (uuid, id)) + cursor.execute("update {} set uuid = ? where id = ?".format(tableName), (uuid, id)) def update(cursor): diff -Nru yokadi-1.1.1/yokadi/update/update.py yokadi-1.2.0/yokadi/update/update.py --- yokadi-1.1.1/yokadi/update/update.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/update.py 2019-02-10 11:35:44.000000000 +0000 @@ -17,15 +17,20 @@ from yokadi.core import db from yokadi.update import updateutils -from yokadi.update import update1to2 -from yokadi.update import update2to3 -from yokadi.update import update3to4 -from yokadi.update import update4to5 -from yokadi.update import update5to6 -from yokadi.update import update6to7 -from yokadi.update import update7to8 -from yokadi.update import update8to9 -from yokadi.update import update9to10 + +# Those modules look unused, but they are used "dynamically" +from yokadi.update import update1to2 # noqa +from yokadi.update import update2to3 # noqa +from yokadi.update import update3to4 # noqa +from yokadi.update import update4to5 # noqa +from yokadi.update import update5to6 # noqa +from yokadi.update import update6to7 # noqa +from yokadi.update import update7to8 # noqa +from yokadi.update import update8to9 # noqa +from yokadi.update import update9to10 # noqa +from yokadi.update import update10to11 # noqa +from yokadi.update import update11to12 # noqa + def getVersion(fileName): database = db.Database(fileName, createIfNeeded=False, updateMode=True) @@ -57,7 +62,7 @@ assert os.path.exists(workPath) print("Recreating the database") - database = db.Database(destPath, createIfNeeded=True, updateMode=True) + database = db.Database(destPath, createIfNeeded=True, updateMode=True) # noqa print("Importing content to the new database") srcConn = sqlite3.connect(workPath) @@ -138,13 +143,13 @@ # Parse args parser = ArgumentParser() parser.add_argument('current', metavar='', - help="Path to the database to update.") + help="Path to the database to update.") parser.add_argument('updated', metavar='', - help="Path to the destination database. Mandatory unless --inplace is used", - nargs="?") + help="Path to the destination database. Mandatory unless --inplace is used", + nargs="?") parser.add_argument("-i", "--in-place", - dest="inplace", action="store_true", - help="Replace current file") + dest="inplace", action="store_true", + help="Replace current file") args = parser.parse_args() @@ -155,7 +160,11 @@ else: newDbPath = os.path.abspath(args.updated) - return update(dbPath, newDbPath, inplace=args.inplace) + try: + return update(dbPath, newDbPath, inplace=args.inplace) + except updateutils.UpdateError as exc: + err(str(exc)) + return 1 if __name__ == "__main__": diff -Nru yokadi-1.1.1/yokadi/update/updateutils.py yokadi-1.2.0/yokadi/update/updateutils.py --- yokadi-1.1.1/yokadi/update/updateutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/update/updateutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,6 +8,15 @@ import sys +class UpdateError(Exception): + pass + + +class UpdateCanceledError(UpdateError): + def __init__(self): + super(UpdateError, self).__init__("Canceled") + + def getTableList(cursor): cursor.execute("select name from sqlite_master where type='table' and name!='sqlite_sequence'") return [x[0] for x in cursor.fetchall()] @@ -30,7 +39,7 @@ "create table {table}({columns})", "insert into {table} select {columns} from {table}_backup", "drop table {table}_backup", - ) + ) for sql in sqlCommands: cursor.execute(sql.format(table=table, columns=columns)) diff -Nru yokadi-1.1.1/yokadi/ycli/aliascmd.py yokadi-1.2.0/yokadi/ycli/aliascmd.py --- yokadi-1.1.1/yokadi/ycli/aliascmd.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/aliascmd.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,9 +7,9 @@ """ from yokadi.core import db from yokadi.core.yokadiexception import BadUsageException, YokadiException -from yokadi.ycli import parseutils +from yokadi.ycli.basicparseutils import parseOneWordName from yokadi.ycli import tui -from yokadi.ycli import colors as C +from yokadi.ycli import colors class AliasCmd(object): @@ -24,7 +24,7 @@ if self.aliases: lst = sorted(self.aliases.items(), key=lambda x: x[0]) for name, command in lst: - print(C.BOLD + name.ljust(10) + C.RESET + "=> " + command) + print(colors.BOLD + name.ljust(10) + colors.RESET + "=> " + command) else: print("No alias defined. Use a_add to create one") @@ -50,11 +50,11 @@ a_edit_name """ session = db.getSession() name = line - if not name in self.aliases: + if name not in self.aliases: raise YokadiException("There is no alias named {}".format(name)) newName = tui.editLine(name) - newName = parseutils.parseOneWordName(newName) + newName = parseOneWordName(newName) if newName in self.aliases: raise YokadiException("There is already an alias named {}.".format(newName)) @@ -69,7 +69,7 @@ a_edit_command """ session = db.getSession() name = line - if not name in self.aliases: + if name not in self.aliases: raise YokadiException("There is no alias named {}".format(name)) command = tui.editLine(self.aliases[name]) diff -Nru yokadi-1.1.1/yokadi/ycli/colors.py yokadi-1.2.0/yokadi/ycli/colors.py --- yokadi-1.1.1/yokadi/ycli/colors.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/colors.py 2019-02-10 11:35:44.000000000 +0000 @@ -9,20 +9,20 @@ import sys if sys.stdout.isatty(): - BOLD = '\033[01m' - RED = '\033[31m' - GREEN = '\033[32m' - ORANGE = '\033[33m' - PURPLE = '\033[35m' - CYAN = '\033[36m' - GREY = '\033[37m' - RESET = '\033[0;0m' + BOLD = '\033[01m' + RED = '\033[31m' + GREEN = '\033[32m' + ORANGE = '\033[33m' + PURPLE = '\033[35m' + CYAN = '\033[36m' + GREY = '\033[37m' + RESET = '\033[0;0m' else: - BOLD = '' - RED = '' - GREEN = '' - ORANGE = '' - PURPLE = '' - CYAN = '' - GREY = '' - RESET = '' \ No newline at end of file + BOLD = '' + RED = '' + GREEN = '' + ORANGE = '' + PURPLE = '' + CYAN = '' + GREY = '' + RESET = '' diff -Nru yokadi-1.1.1/yokadi/ycli/commonargs.py yokadi-1.2.0/yokadi/ycli/commonargs.py --- yokadi-1.1.1/yokadi/ycli/commonargs.py 1970-01-01 00:00:00.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/commonargs.py 2019-02-10 11:35:44.000000000 +0000 @@ -0,0 +1,63 @@ +""" +Handling of common command line arguments + +@author: Aurelien Gateau +@license: GPL v3 or later +""" +import os +import sys +import yokadi +from yokadi.core import basepaths +from yokadi.ycli import tui + + +def addArgs(parser): + group = parser.add_mutually_exclusive_group() + group.add_argument('--datadir', dest='dataDir', help='Database dir (default: %s)' % basepaths.getDataDir(), + metavar='DATADIR') + group.add_argument('-d', '--db', dest='dbPath', + help='TODO database (default: {}). This option is deprecated and will be removed in the next' + ' version of Yokadi. Use --datadir instead.' + .format(os.path.join('$DATADIR', basepaths.DB_NAME)), + metavar='FILE') + parser.add_argument('-v', '--version', dest='version', action='store_true', help='Display Yokadi current version') + + +def processDataDirArg(dataDir): + if dataDir: + dataDir = os.path.abspath(dataDir) + if not os.path.isdir(dataDir): + tui.error("Directory '{}' does not exist".format(dataDir)) + sys.exit(1) + else: + dataDir = basepaths.getDataDir() + os.makedirs(dataDir, exist_ok=True) + return dataDir + + +def processDbPathArg(dbPath, dataDir): + if not dbPath: + return basepaths.getDbPath(dataDir) + dbPath = os.path.abspath(dbPath) + dbDir = os.path.dirname(dbPath) + tui.warning('--db option is deprecated and will be removed in the next version, use --datadir instead') + if not os.path.isdir(dbDir): + tui.error("Directory '{}' does not exist".format(dbDir)) + sys.exit(1) + return dbPath + + +def warnYokadiDbEnvVariable(): + if os.getenv('YOKADI_DB'): + tui.warning('The YOKADI_DB environment variable is deprecated and will be removed in the next version, use the' + ' --datadir command-line option instead') + + +def processArgs(args): + if args.version: + print('Yokadi - {}'.format(yokadi.__version__)) + sys.exit(0) + warnYokadiDbEnvVariable() + dataDir = processDataDirArg(args.dataDir) + dbPath = processDbPathArg(args.dbPath, dataDir) + return dataDir, dbPath diff -Nru yokadi-1.1.1/yokadi/ycli/completers.py yokadi-1.2.0/yokadi/ycli/completers.py --- yokadi-1.1.1/yokadi/ycli/completers.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/completers.py 2019-02-10 11:35:44.000000000 +0000 @@ -5,7 +5,7 @@ @author: Aurélien Gâteau @license: GPL v3 or later """ -from yokadi.ycli import parseutils +from yokadi.ycli.basicparseutils import parseParameters, simplifySpaces from yokadi.core import db from yokadi.core.db import Config, Keyword, Project, Task from yokadi.core.ydateutils import WEEKDAYS @@ -13,7 +13,7 @@ def computeCompleteParameterPosition(text, line, begidx, endidx): - before = parseutils.simplifySpaces(line[:begidx].strip()) + before = simplifySpaces(line[:begidx].strip()) return before.count(" ") + 1 @@ -49,12 +49,27 @@ return [] +class MultiCompleter(object): + """A completer which takes multiple completers and apply them in turn, + according to their position""" + def __init__(self, *completers): + self.completers = completers + + def __call__(self, text, line, begidx, endidx): + for completer in self.completers: + lst = completer(text, line, begidx, endidx) + if lst: + return lst + else: + return [] + + def projectAndKeywordCompleter(cmd, text, line, begidx, endidx, shift=0): """@param shift: argument position shift. Used when command is omitted (t_edit usecase)""" position = computeCompleteParameterPosition(text, line, begidx, endidx) - position -= len(parseutils.parseParameters(line)[0]) # remove arguments from position count + position -= len(parseParameters(line)[0]) # remove arguments from position count position += shift # Apply argument shift - if position == 1: # Projects + if position == 1: # Projects return ["%s" % x for x in getItemPropertiesStartingWith(Project, Project.name, text)] elif position >= 2 and line[-1] != " " and line.split()[-1][0] == "@": # Keywords (we ensure that it starts with @ return ["%s" % x for x in getItemPropertiesStartingWith(Keyword, Keyword.name, text)] diff -Nru yokadi-1.1.1/yokadi/ycli/confcmd.py yokadi-1.2.0/yokadi/ycli/confcmd.py --- yokadi-1.1.1/yokadi/ycli/confcmd.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/confcmd.py 2019-02-10 11:35:44.000000000 +0000 @@ -19,7 +19,7 @@ parser = YokadiOptionParser(prog="c_get") parser.description = "Display the value of a configuration key. If no key is given, all keys are shown." parser.add_argument("-s", dest="system", default=False, action="store_true", - help="Display value of system keys instead of user ones") + help="Display value of system keys instead of user ones") parser.add_argument("key", nargs='?') return parser @@ -64,14 +64,6 @@ @param key: parameter name @param value: parameter value @return: True if parameter is ok, else False""" - # Boolean parameters - if name in ("PASSPHRASE_CACHE",): - try: - value = int(value) - assert(value == 0 or value == 1) - return True - except (ValueError, AssertionError): - return False # Positive int parameters if name in ("ALARM_DELAY", "ALARM_SUSPEND", "PURGE_DELAY"): try: diff -Nru yokadi-1.1.1/yokadi/ycli/csvlistrenderer.py yokadi-1.2.0/yokadi/ycli/csvlistrenderer.py --- yokadi-1.1.1/yokadi/ycli/csvlistrenderer.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/csvlistrenderer.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,13 +8,12 @@ """ import csv -from yokadi.ycli import tui - -TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "description", "urgency", "status", "project", "keywords"] +TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "description", + "urgency", "status", "project", "keywords"] class CsvListRenderer(object): - def __init__(self, out, cryptoMgr=None): + def __init__(self, out): self.writer = csv.writer(out, dialect="excel") self._writerow(TASK_FIELDS) # Header diff -Nru yokadi-1.1.1/yokadi/ycli/htmllistrenderer.py yokadi-1.2.0/yokadi/ycli/htmllistrenderer.py --- yokadi-1.1.1/yokadi/ycli/htmllistrenderer.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/htmllistrenderer.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,7 +8,33 @@ """ import xml.sax.saxutils as saxutils -TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "urgency", "status", "description", "keywords"] +from collections import namedtuple + +TaskField = namedtuple("TaskField", ("title", "format")) + + +HTML_HEADER = """ + + + + + + Yokadi tasks export + + +""" + + +HTML_FOOTER = "" def escape(text): @@ -18,30 +44,19 @@ def printRow(out, tag, lst): print("", file=out) for value in lst: - text = escape(value).encode("utf-8") or " " + if value: + text = escape(value).replace("\n", "
") + else: + text = " " print("<%s>%s" % (tag, text, tag), file=out) print("", file=out) class HtmlListRenderer(object): - def __init__(self, out, cryptoMgr): + def __init__(self, out): self.out = out - self.cryptoMgr = cryptoMgr - # TODO: make this fancier - print(""" - - - - Yokadi tasks export - - - - """, file=self.out) + print(HTML_HEADER, file=self.out) def addTaskList(self, sectionName, taskList): """Store tasks for this section @@ -50,20 +65,31 @@ @param taskList: list of tasks to display @type taskList: list of db.Task instances """ + TASK_FIELDS = [ + TaskField("Id", lambda x: str(x.id)), + TaskField("Title", self._titleFormater), + TaskField("Due date", lambda x: str(x.dueDate)), + TaskField("Urgency", lambda x: str(x.urgency)), + TaskField("Status", lambda x: x.status), + ] - print(("

%s

" % escape(sectionName)).encode("utf-8"), file=self.out) + print("

%s

" % escape(sectionName), file=self.out) print("", file=self.out) - printRow(self.out, "th", TASK_FIELDS) + printRow(self.out, "th", [x.title for x in TASK_FIELDS]) for task in taskList: - lst = [self.cryptoMgr.decrypt(task.title), ] - lst.extend([getattr(task, field) for field in TASK_FIELDS if field not in ("title", - "description", - "keywords")]) - lst.append(self.cryptoMgr.decrypt(task.description)) - lst.append(task.getKeywordsAsString()) + lst = [x.format(task) for x in TASK_FIELDS] printRow(self.out, "td", lst) print("
", file=self.out) def end(self): - print("", file=self.out) + print(HTML_FOOTER, file=self.out) + + def _titleFormater(self, task): + title = task.title + keywords = task.getKeywordsAsString() + if keywords: + title += " " + keywords + if task.description: + title += "\n" + task.description + return title # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/ycli/keywordcmd.py yokadi-1.2.0/yokadi/ycli/keywordcmd.py --- yokadi-1.1.1/yokadi/ycli/keywordcmd.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/keywordcmd.py 2019-02-10 11:35:44.000000000 +0000 @@ -17,12 +17,21 @@ from yokadi.ycli.completers import KeywordCompleter +def _listKeywords(session): + for keyword in sorted(session.query(Keyword).all(), key=lambda x: x.name.lower()): + taskIds = sorted([x.id for x in keyword.tasks if x]) + yield keyword.name, taskIds + + class KeywordCmd(object): def do_k_list(self, line): """List all keywords.""" - for keyword in db.getSession().query(Keyword).all(): - tasks = ", ".join(str(task.id) for task in keyword.tasks) - print("%s (tasks: %s)" % (keyword.name, tasks)) + for name, taskIds in _listKeywords(db.getSession()): + if taskIds: + tasks = ", ".join([str(x) for x in taskIds]) + else: + tasks = "none" + print("{} (tasks: {})".format(name, tasks)) def do_k_add(self, line): """Add a keyword @@ -46,12 +55,13 @@ keyword = dbutils.getKeywordFromName(line) if keyword.tasks: - print("The keyword %s is used by the following tasks: %s" % (keyword.name, - ", ".join(str(task.id) for task in keyword.tasks))) - if tui.confirm("Do you really want to remove this keyword"): - session.delete(keyword) - session.commit() - print("Keyword %s has been removed" % keyword.name) + taskList = ", ".join(str(task.id) for task in keyword.tasks) + print("The keyword {} is used by the following tasks: {}".format(keyword.name, taskList)) + if not tui.confirm("Do you really want to remove this keyword"): + return + session.delete(keyword) + session.commit() + print("Keyword {} has been removed".format(keyword.name)) complete_k_remove = KeywordCompleter(1) @@ -89,7 +99,8 @@ if len(conflictingTasks) > 0: # We cannot merge - tui.error("Cannot merge keywords %s and %s because they are both used with different values in these tasks:" % (oldName, newName)) + tui.error("Cannot merge keywords %s and %s because they are both" + " used with different values in these tasks:" % (oldName, newName)) for task in conflictingTasks: print("- %d, %s" % (task.id, task.title)) print("Edit these tasks and try again") @@ -98,7 +109,7 @@ # Merge for task in keyword.tasks: kwDict = task.getKeywordDict() - if not newName in kwDict: + if newName not in kwDict: kwDict[newName] = kwDict[oldName] del kwDict[oldName] task.setKeywordDict(kwDict) diff -Nru yokadi-1.1.1/yokadi/ycli/main.py yokadi-1.2.0/yokadi/ycli/main.py --- yokadi-1.1.1/yokadi/ycli/main.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/main.py 2019-02-10 11:35:44.000000000 +0000 @@ -38,12 +38,11 @@ import yokadi from yokadi.core import db -from yokadi.ycli import tui from yokadi.core import basepaths -from yokadi.core import cryptutils from yokadi.core import fileutils from yokadi.update import update +from yokadi.ycli import tui, commonargs from yokadi.ycli.aliascmd import AliasCmd, resolveAlias from yokadi.ycli.confcmd import ConfCmd from yokadi.ycli.keywordcmd import KeywordCmd @@ -65,7 +64,6 @@ self.prompt = "yokadi> " self.historyPath = basepaths.getHistoryPath() self.loadHistory() - self.cryptoMgr = cryptutils.YokadiCryptoManager() # Load shared cryptographic manager def emptyline(self): """Executed when input is empty. Reimplemented to do nothing.""" @@ -129,7 +127,8 @@ except Exception as e: tui.error("Unhandled exception (oups)\n\t%s" % e) print("This is a bug of Yokadi, sorry.") - print("Send the above message by email to Yokadi developers (ml-yokadi@sequanux.org) to help them make Yokadi better.") + print("Send the above message by email to Yokadi developers (ml-yokadi@sequanux.org) to help them make" + " Yokadi better.") cut = "---------------------8<----------------------------------------------" print(cut) traceback.print_exc() @@ -190,49 +189,40 @@ return names -def main(): - locale.setlocale(locale.LC_ALL, os.environ.get("LANG", "C")) +def createArgumentParser(): parser = ArgumentParser() - - parser.add_argument("-d", "--db", dest="filename", - help="TODO database (default: %s)" % basepaths.getDbPath(), metavar="FILE") + commonargs.addArgs(parser) parser.add_argument("-c", "--create-only", - dest="createOnly", default=False, action="store_true", - help="Just create an empty database") + dest="createOnly", default=False, action="store_true", + help="Just create an empty database") parser.add_argument("-u", "--update", - dest="update", action="store_true", - help="Update database to the latest version") - - parser.add_argument("-v", "--version", - dest="version", action="store_true", - help="Display Yokadi current version") + dest="update", action="store_true", + help="Update database to the latest version") parser.add_argument('cmd', nargs='*') + return parser - args = parser.parse_args() - if args.version: - print("Yokadi - %s" % yokadi.__version__) - return 0 +def main(): + locale.setlocale(locale.LC_ALL, os.environ.get("LANG", "C")) + parser = createArgumentParser() + args = parser.parse_args() + dataDir, dbPath = commonargs.processArgs(args) basepaths.migrateOldHistory() try: - basepaths.migrateOldDb() + basepaths.migrateOldDb(dbPath) except basepaths.MigrationException as exc: print(exc) return 1 - if not args.filename: - args.filename = basepaths.getDbPath() - fileutils.createParentDirs(args.filename) - if args.update: - return update.update(args.filename) + return update.update(dbPath) try: - db.connectDatabase(args.filename) + db.connectDatabase(dbPath) except db.DbUserException as exc: print(exc) return 1 @@ -256,6 +246,7 @@ cmd.writeHistory() return 0 + if __name__ == "__main__": sys.exit(main()) # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/ycli/massedit.py yokadi-1.2.0/yokadi/ycli/massedit.py --- yokadi-1.1.1/yokadi/ycli/massedit.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/massedit.py 2019-02-10 11:35:44.000000000 +0000 @@ -104,7 +104,7 @@ status = "started" elif statusChar == "d": status = "done" - elif id == None: + elif id is None: # Special case: if this is a new task, then statusChar is actually a # one-letter word starting the task title status = "new" diff -Nru yokadi-1.1.1/yokadi/ycli/parseutils.py yokadi-1.2.0/yokadi/ycli/parseutils.py --- yokadi-1.1.1/yokadi/ycli/parseutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/parseutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,10 +6,8 @@ @author: Sébastien Renard @license: GPL v3 or later """ -import re - from yokadi.ycli import tui -from yokadi.ycli.basicparseutils import simplifySpaces, parseParameters, parseOneWordName +from yokadi.ycli.basicparseutils import simplifySpaces from yokadi.core import db from yokadi.core.db import Keyword from yokadi.core.dbutils import KeywordFilter @@ -78,9 +76,9 @@ session = db.getSession() doesNotExist = False for keyword in [k.name for k in keywordFilters]: - if session.query(Keyword).filter(Keyword.name.like(keyword)).count() == 0: - tui.error("Keyword %s is unknown." % keyword) - doesNotExist = True + if session.query(Keyword).filter(Keyword.name.like(keyword)).count() == 0: + tui.error("Keyword %s is unknown." % keyword) + doesNotExist = True return doesNotExist diff -Nru yokadi-1.1.1/yokadi/ycli/plainlistrenderer.py yokadi-1.2.0/yokadi/ycli/plainlistrenderer.py --- yokadi-1.1.1/yokadi/ycli/plainlistrenderer.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/plainlistrenderer.py 2019-02-10 11:35:44.000000000 +0000 @@ -6,13 +6,10 @@ @license: GPL v3 or later """ -from yokadi.ycli import tui - class PlainListRenderer(object): - def __init__(self, out, cryptoMgr): + def __init__(self, out): self.out = out - self.cryptoMgr = cryptoMgr self.first = True def addTaskList(self, sectionName, taskList): @@ -30,8 +27,7 @@ print(sectionName, file=self.out) for task in taskList: - title = self.cryptoMgr.decrypt(task.title) - print(("- " + title), file=self.out) + print(("- " + task.title), file=self.out) def end(self): pass diff -Nru yokadi-1.1.1/yokadi/ycli/projectcmd.py yokadi-1.2.0/yokadi/ycli/projectcmd.py --- yokadi-1.1.1/yokadi/ycli/projectcmd.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/projectcmd.py 2019-02-10 11:35:44.000000000 +0000 @@ -10,8 +10,8 @@ from sqlalchemy.exc import IntegrityError from yokadi.ycli import tui -from yokadi.ycli.completers import ProjectCompleter -from yokadi.ycli import parseutils +from yokadi.ycli.completers import MultiCompleter, ProjectCompleter +from yokadi.ycli.basicparseutils import parseOneWordName from yokadi.core import db from yokadi.core.db import Project, Task from yokadi.core.yokadiexception import YokadiException, BadUsageException @@ -42,7 +42,7 @@ if not line: print("Missing project name.") return - projectName = parseutils.parseOneWordName(line) + projectName = parseOneWordName(line) session = db.getSession() try: project = Project(name=projectName) @@ -66,7 +66,7 @@ line = tui.editLine(project.name) # Update project - projectName = parseutils.parseOneWordName(line) + projectName = parseOneWordName(line) try: project.name = projectName session.commit() @@ -84,7 +84,8 @@ active = "" else: active = "(inactive)" - print("%s %s %s" % (project.name.ljust(20), str(session.query(Task).filter_by(project=project).count()).rjust(4), active)) + taskCount = session.query(Task).filter_by(project=project).count() + print("{:20} {:>4} {}".format(project.name, taskCount, active)) def do_p_set_active(self, line): """Activate the given project""" @@ -109,7 +110,7 @@ parser.usage = "p_remove [options] " parser.description = "Remove a project and all its associated tasks." parser.add_argument("-f", dest="force", default=False, action="store_true", - help="Skip confirmation prompt") + help="Skip confirmation prompt") parser.add_argument("project") return parser @@ -127,4 +128,28 @@ print("Project removed") complete_p_remove = ProjectCompleter(1) + def parser_p_merge(self): + parser = YokadiOptionParser() + parser.usage = "p_remove " + parser.description = "Merge into ." + parser.add_argument("source_project") + parser.add_argument("destination_project") + parser.add_argument("-f", dest="force", default=False, action="store_true", + help="Skip confirmation prompt") + return parser + + def do_p_merge(self, line): + session = db.getSession() + parser = self.parser_p_merge() + args = parser.parse_args(line) + + src = getProjectFromName(args.source_project) + dst = getProjectFromName(args.destination_project) + if not args.force: + if not tui.confirm("Merge project '{}' into '{}'".format(src.name, dst.name)): + return + dst.merge(session, src) + print("Project '{}' merged into '{}'".format(src.name, dst.name)) + complete_p_merge = MultiCompleter(ProjectCompleter(1), ProjectCompleter(2)) + # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/ycli/taskcmd.py yokadi-1.2.0/yokadi/ycli/taskcmd.py --- yokadi-1.1.1/yokadi/ycli/taskcmd.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/taskcmd.py 2019-02-10 11:35:44.000000000 +0000 @@ -10,7 +10,7 @@ import readline import re from datetime import datetime, timedelta -from sqlalchemy import or_, and_, desc +from sqlalchemy import or_, desc from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from yokadi.core.db import Keyword, Project, Task, TaskKeyword, NOTE_KEYWORD @@ -20,10 +20,11 @@ from yokadi.core import ydateutils from yokadi.core.recurrencerule import RecurrenceRule from yokadi.ycli import massedit +from yokadi.ycli.basicparseutils import parseOneWordName from yokadi.ycli import parseutils from yokadi.ycli import tui from yokadi.ycli.completers import ProjectCompleter, projectAndKeywordCompleter, \ - taskIdCompleter, recurrenceCompleter, dueDateCompleter + taskIdCompleter, recurrenceCompleter, dueDateCompleter from yokadi.core.dbutils import DbFilter, KeywordFilter from yokadi.core.yokadiexception import YokadiException, BadUsageException from yokadi.ycli.textlistrenderer import TextListRenderer @@ -39,7 +40,7 @@ csv=CsvListRenderer, html=HtmlListRenderer, plain=PlainListRenderer, - ) +) class TaskCmd(object): @@ -60,10 +61,8 @@ parser = YokadiOptionParser() parser.usage = "%s [options] [@] [@] " % cmd parser.description = "Add new %s. Will prompt to create keywords if they do not exist." % cmd - parser.add_argument("-c", dest="crypt", default=False, action="store_true", - help="Encrypt title") parser.add_argument("-d", "--describe", dest="describe", default=False, action="store_true", - help="Directly open editor to describe task") + help="Directly open editor to describe task") parser.add_argument('cmd', nargs='*') return parser @@ -80,15 +79,6 @@ if not title: raise BadUsageException("Missing title") - if args.crypt: - # Obfuscate line in history - length = readline.get_current_history_length() - if length > 0: # Ensure history is positive to avoid crash with bad readline setup - readline.replace_history_item(length - 1, "%s %s " % (cmd, - line.replace(title, "<...encrypted...>"))) - # Encrypt title - title = self.cryptoMgr.encrypt(title) - task = dbutils.addTask(projectName, title, keywordDict) if not task: tui.reinjectInRawInput("%s %s" % (cmd, line)) @@ -104,13 +94,9 @@ t_add <projectName> [@<keyword1>] [@<keyword2>] <title>""" task = self._t_add("t_add", line) if task: - if self.cryptoMgr.isEncrypted(task.title): - title = "<... encrypted data...>" - else: - title = task.title self.session.add(task) self.session.commit() - print("Added task '%s' (id=%d)" % (title, task.id)) + print("Added task '%s' (id=%d)" % (task.title, task.id)) complete_t_add = projectAndKeywordCompleter @@ -127,14 +113,9 @@ task.setKeywordDict(keywordDict) task.urgency = bugutils.computeUrgency(keywordDict) - if self.cryptoMgr.isEncrypted(task.title): - title = "<... encrypted data...>" - else: - title = task.title - self.session.add(task) self.session.commit() - print("Added bug '%s' (id=%d, urgency=%d)" % (title, task.id, task.urgency)) + print("Added bug '%s' (id=%d, urgency=%d)" % (task.title, task.id, task.urgency)) complete_bug_add = ProjectCompleter(1) @@ -149,12 +130,8 @@ keywordDict = task.getKeywordDict() keywordDict[NOTE_KEYWORD] = None task.setKeywordDict(keywordDict) - if self.cryptoMgr.isEncrypted(task.title): - title = "<... encrypted data...>" - else: - title = task.title self.session.commit() - print("Added note '%s' (id=%d)" % (title, task.id)) + print("Added note '%s' (id=%d)" % (task.title, task.id)) complete_n_add = projectAndKeywordCompleter def do_bug_edit(self, line): @@ -179,18 +156,11 @@ """Starts an editor to enter a longer description of a task. t_describe <id>""" def updateDescription(description): - if self.cryptoMgr.isEncrypted(task.title): - task.description = self.cryptoMgr.encrypt(description) - else: - task.description = description + task.description = description task = self.getTaskFromId(line) try: - if self.cryptoMgr.isEncrypted(task.title): - # As title is encrypted, we assume description will be encrypted as well - self.cryptoMgr.force_decrypt = True # Decryption must be turned on to edit - - description = tui.editText(self.cryptoMgr.decrypt(task.description), + description = tui.editText(task.description, onChanged=updateDescription, lockManager=dbutils.TaskLockManager(task), prefix="yokadi-%s-%s-" % (task.project, task.title)) @@ -263,7 +233,8 @@ self.session.commit() if task.recurrence and status == "done": print("Task '%s' next occurrence is scheduled at %s" % (task.title, task.dueDate)) - print("To *really* mark this task done and forget it, remove its recurrence first with t_recurs %s none" % task.id) + print("To *really* mark this task done and forget it, remove its recurrence first" + " with t_recurs %s none" % task.id) else: print("Task '%s' marked as %s" % (task.title, status)) @@ -278,8 +249,8 @@ line = line.replace("__", ",".join([str(i) for i in self.lastTaskIds])) else: raise BadUsageException("You must select tasks with t_list prior to use __") - rangeId = re.compile("(\d+)-(\d+)") - tokens = re.split("[\s|,]", line) + rangeId = re.compile(r"(\d+)-(\d+)") + tokens = re.split(r"[\s|,]", line) if len(tokens) < 2: raise BadUsageException("Give at least a task id and a command") @@ -316,7 +287,7 @@ parser.usage = "t_remove [options] <id>" parser.description = "Delete a task." parser.add_argument("-f", dest="force", default=False, action="store_true", - help="Skip confirmation prompt") + help="Skip confirmation prompt") parser.add_argument("id") return parser @@ -342,10 +313,11 @@ parser.usage = "t_purge [options]" parser.description = "Remove old done tasks from all projects." parser.add_argument("-f", "--force", dest="force", default=False, action="store_true", - help="Skip confirmation prompt") + help="Skip confirmation prompt") delay = int(db.getConfigKey("PURGE_DELAY", environ=False)) parser.add_argument("-d", "--delay", dest="delay", default=delay, - type=int, help="Delay (in days) after which done tasks are destroyed. Default is %d." % delay) + type=int, help="Delay (in days) after which done tasks are destroyed." + " Default is %d." % delay) return parser def do_t_purge(self, line): @@ -377,63 +349,63 @@ "t_list @home, t_list @_bug=2394" parser.add_argument("-a", "--all", dest="status", - action="store_const", const="all", - help="all tasks (done and to be done)") + action="store_const", const="all", + help="all tasks (done and to be done)") parser.add_argument("--started", dest="status", - action="store_const", const="started", - help="only started tasks") + action="store_const", const="started", + help="only started tasks") rangeList = ["today", "thisweek", "thismonth", "all"] parser.add_argument("-d", "--done", dest="done", - help="only done tasks. <range> must be either one of %s or a date using the same format as t_due" % ", ".join(rangeList), - metavar="<range>") + help="only done tasks. <range> must be either one of %s or a date using the same format" + " as t_due" % ", ".join(rangeList), + metavar="<range>") parser.add_argument("-u", "--urgency", dest="urgency", - type=int, - help="tasks with urgency greater or equal than <urgency>", - metavar="<urgency>") + type=int, + help="tasks with urgency greater or equal than <urgency>", + metavar="<urgency>") parser.add_argument("-t", "--top-due", dest="topDue", - default=False, action="store_true", - help="top 5 urgent tasks of each project based on due date") + default=False, action="store_true", + help="top 5 urgent tasks of each project based on due date") parser.add_argument("--overdue", dest="due", - action="append_const", const="now", - help="all overdue tasks") + action="append_const", const="now", + help="all overdue tasks") parser.add_argument("--due", dest="due", - action="append", - help="""only list tasks due before/after <limit>. <limit> is a - date optionaly prefixed with a comparison operator. - Valid operators are: <, <=, >=, and >. - Example of valid limits: - - - tomorrow: due date <= tomorrow, 23:59:59 - - today: due date <= today, 23:59:59 - - >today: due date > today: 23:59:59 - """, - metavar="<limit>") + action="append", + help="""only list tasks due before/after <limit>. <limit> is a + date optionaly prefixed with a comparison operator. + Valid operators are: <, <=, >=, and >. + Example of valid limits: + + - tomorrow: due date <= tomorrow, 23:59:59 + - today: due date <= today, 23:59:59 + - >today: due date > today: 23:59:59 + """, + metavar="<limit>") parser.add_argument("-k", "--keyword", dest="keyword", - help="Group tasks by given keyword instead of project. The %% wildcard can be used.", - metavar="<keyword>") + help="Group tasks by given keyword instead of project. The %% wildcard can be used.", + metavar="<keyword>") parser.add_argument("-s", "--search", dest="search", - action="append", - help="only list tasks whose title or description match <value>. You can repeat this option to search on multiple words.", - metavar="<value>") + action="append", + help="only list tasks whose title or description match <value>. You can repeat this" + " option to search on multiple words.", + metavar="<value>") formatList = ["auto"] + list(gRendererClassDict.keys()) parser.add_argument("-f", "--format", dest="format", - default="auto", choices=formatList, - help="how should the task list be formated. <format> can be %s" % ", ".join(formatList), - metavar="<format>") + default="auto", choices=formatList, + help="how should the task list be formated. <format> can be %s" % ", ".join(formatList), + metavar="<format>") parser.add_argument("-o", "--output", dest="output", - help="Output task list to <file>", - metavar="<file>") - parser.add_argument("--decrypt", dest="decrypt", default=False, action="store_true", - help="Decrypt task title and description") + help="Output task list to <file>", + metavar="<file>") parser.add_argument("filter", nargs="*", metavar="<project_or_keyword_filter>") @@ -520,7 +492,9 @@ for keyword in sorted(keywords, key=lambda x: x.name.lower()): if str(keyword.name).startswith("_") and not groupKeyword.startswith("_"): - # BUG: cannot filter on db side because sqlobject does not understand ESCAPE needed with _. Need to test it with sqlalchemy + # BUG: cannot filter on db side because sqlobject does not + # understand ESCAPE needed with _. Need to test it with + # sqlalchemy continue taskList = self.session.query(Task).filter(TaskKeyword.keywordId == keyword.id) taskList = taskList.outerjoin(TaskKeyword, Task.taskKeywords) @@ -591,11 +565,11 @@ filters.append(DbFilter(Task.status == "started")) else: filters.append(DbFilter(Task.status != "done")) - if args.urgency: + if args.urgency is not None: order = [desc(Task.urgency), ] filters.append(DbFilter(Task.urgency >= args.urgency)) if args.topDue: - filters.append(DbFilter(Task.dueDate != None)) + filters.append(DbFilter(Task.dueDate is not None)) order = [Task.dueDate, ] limit = 5 if args.due: @@ -603,8 +577,6 @@ dueOperator, dueLimit = ydateutils.parseDateLimit(due) filters.append(DbFilter(dueOperator(Task.dueDate, dueLimit))) order = [Task.dueDate, ] - if args.decrypt: - self.cryptoMgr.force_decrypt = True # Define output if args.output: @@ -615,7 +587,7 @@ # Instantiate renderer if renderer is None: rendererClass = selectRendererClass() - renderer = rendererClass(out, cryptoMgr=self.cryptoMgr) + renderer = rendererClass(out) # Fill the renderer self._renderList(renderer, projectList, filters, order, limit, args.keyword) @@ -625,33 +597,30 @@ parser = YokadiOptionParser() parser.usage = "n_list [options] <project_or_keyword_filter>" parser.description = "List notes filtered by project and/or keywords. " \ - "'%' can be used as a wildcard in the project name: " \ - "to list projects starting with 'foo', use 'foo%'. " \ - "Keyword filtering is achieved with '@'. Ex.: " \ - "n_list @home, n_list @_bug=2394" + "'%' can be used as a wildcard in the project name: " \ + "to list projects starting with 'foo', use 'foo%'. " \ + "Keyword filtering is achieved with '@'. Ex.: " \ + "n_list @home, n_list @_bug=2394" parser.add_argument("-s", "--search", dest="search", - action="append", - help="only list notes whose title or description match <value>. You can repeat this option to search on multiple words.", - metavar="<value>") + action="append", + help="only list notes whose title or description match <value>." + " You can repeat this option to search on multiple words.", + metavar="<value>") parser.add_argument("-k", "--keyword", dest="keyword", - help="Group tasks by given keyword instead of project. The '%%' wildcard can be used.", - metavar="<keyword>") - parser.add_argument("--decrypt", dest="decrypt", default=False, action="store_true", - help="Decrypt note title and description") + help="Group tasks by given keyword instead of project. The '%%' wildcard can be used.", + metavar="<keyword>") parser.add_argument("filter", nargs="*", metavar="<project_or_keyword_filter>") return parser def do_n_list(self, line): args, projectList, filters = self._parseListLine(self.parser_n_list(), line) - if args.decrypt: - self.cryptoMgr.force_decrypt = True filters.append(KeywordFilter(NOTE_KEYWORD)) order = [Task.creationDate, ] - renderer = TextListRenderer(tui.stdout, cryptoMgr=self.cryptoMgr, renderAsNotes=True) + renderer = TextListRenderer(tui.stdout, renderAsNotes=True) self._renderList(renderer, projectList, filters, order, limit=None, groupKeyword=args.keyword) complete_n_list = projectAndKeywordCompleter @@ -675,7 +644,7 @@ ids = [] for line in text.split("\n"): line = line.strip() - if not "," in line: + if "," not in line: continue ids.append(int(line.split(",")[0])) @@ -700,7 +669,7 @@ """ if not line: raise BadUsageException("Missing parameters") - projectName = parseutils.parseOneWordName(line) + projectName = parseOneWordName(line) projectName = self._realProjectName(projectName) project = dbutils.getOrCreateProject(projectName) if not project: @@ -745,12 +714,10 @@ parser.description = "Display details of a task." choices = ["all", "summary", "description"] parser.add_argument("--output", dest="output", - choices=choices, - default="all", - help="<output> can be one of %s. If not set, it defaults to all." % ", ".join(choices), - metavar="<output>") - parser.add_argument("--decrypt", dest="decrypt", default=False, action="store_true", - help="Decrypt task title and description") + choices=choices, + default="all", + help="<output> can be one of %s. If not set, it defaults to all." % ", ".join(choices), + metavar="<output>") parser.add_argument("id") return parser @@ -758,14 +725,8 @@ parser = self.parser_t_show() args = parser.parse_args(line) - if args.decrypt: - self.cryptoMgr.force_decrypt = True - task = self.getTaskFromId(args.id) - title = self.cryptoMgr.decrypt(task.title) - description = self.cryptoMgr.decrypt(task.description) - if args.output in ("all", "summary"): keywordDict = task.getKeywordDict() keywordArray = [] @@ -778,7 +739,7 @@ keywords = ", ".join(keywordArray) if task.recurrence: - recurrence = "{} (next: {})".format( + recurrence = "{} (next: {})".format( task.recurrence.getFrequencyAsString(), task.recurrence.getNext() ) @@ -787,7 +748,7 @@ fields = [ ("Project", task.project.name), - ("Title", title), + ("Title", task.title), ("ID", task.id), ("Created", task.creationDate), ("Due", task.dueDate), @@ -795,7 +756,7 @@ ("Urgency", task.urgency), ("Recurrence", recurrence), ("Keywords", keywords), - ] + ] if task.status == "done": fields.append(("Done", task.doneDate)) @@ -805,7 +766,7 @@ if args.output in ("all", "description") and task.description: if args.output == "all": print() - print(description) + print(task.description) complete_t_show = taskIdCompleter @@ -833,14 +794,10 @@ task = self.getTaskFromId(line) - if self.cryptoMgr.isEncrypted(task.title): - self.cryptoMgr.force_decrypt = True # Decryption must be turned on to edit - title = self.cryptoMgr.decrypt(task.title) - # Create task line keywordDict = task.getKeywordDict() userKeywordDict, keywordDict = dbutils.splitKeywordDict(keywordDict) - taskLine = parseutils.createLine("", title, userKeywordDict) + taskLine = parseutils.createLine("", task.title, userKeywordDict) oldCompleter = readline.get_completer() # Backup previous completer to restore it in the end readline.set_completer(editComplete) # Switch to specific completer @@ -859,8 +816,6 @@ print("Cancelled") return None _, title, userKeywordDict = parseutils.parseLine(task.project.name + " " + line) - if self.cryptoMgr.isEncrypted(task.title): - title = self.cryptoMgr.encrypt(title) if dbutils.createMissingKeywords(userKeywordDict.keys()): # We were able to create missing keywords if there were any, @@ -967,7 +922,7 @@ newKwDict = parseutils.keywordFiltersToDict(keywordFilters) if garbage: raise YokadiException("Cannot parse line, got garbage (%s). Maybe you forgot to add @ before keyword ?" - % garbage) + % garbage) if not dbutils.createMissingKeywords(list(newKwDict.keys())): # User cancel keyword creation @@ -1024,4 +979,18 @@ prompt += " %s" % (" ".join([str(k) for k in keywordFilters])) self.prompt = "%s> " % prompt + def do_t_to_note(self, line): + """Turns a task into a note + """ + task = self.getTaskFromId(line) + task.toNote(self.session) + self.session.commit() + + def do_n_to_task(self, line): + """Turns a note into a task + """ + task = self.getTaskFromId(line) + task.toTask(self.session) + self.session.commit() + # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/ycli/textlistrenderer.py yokadi-1.2.0/yokadi/ycli/textlistrenderer.py --- yokadi-1.1.1/yokadi/ycli/textlistrenderer.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/textlistrenderer.py 2019-02-10 11:35:44.000000000 +0000 @@ -7,12 +7,9 @@ @license: GPL v3 or later """ from datetime import datetime, timedelta -from sqlalchemy.sql import func import yokadi.ycli.colors as C from yokadi.core import ydateutils -from yokadi.core import db -from yokadi.core.db import Task from yokadi.ycli import tui @@ -25,7 +22,8 @@ def colorizer(value, reverse=False): """Return a color according to value. - @param value: value used to determine color. Low (0) value means not urgent/visible, high (100) value means important + @param value: value used to determine color. Low (0) value means not urgent/visible, high (100) value means + important @param reverse: If false low value means important and vice versa @return: a color code or None for no color""" if reverse: @@ -72,8 +70,7 @@ class TitleFormater(object): - def __init__(self, width, cryptoMgr): - self.cryptoMgr = cryptoMgr + def __init__(self, width): self.width = width def __call__(self, task): @@ -86,8 +83,8 @@ maxWidth -= 1 # Create title - title = self.cryptoMgr.decrypt(task.title) - if keywords and len(task.title) < maxWidth: + title = task.title + if keywords and len(title) < maxWidth: title += ' (' colorizer.setColorAt(len(title), C.BOLD) title += keywords @@ -160,11 +157,10 @@ class TextListRenderer(object): - def __init__(self, out, termWidth=None, cryptoMgr=None, renderAsNotes=False, splitOnDate=False): + def __init__(self, out, termWidth=None, renderAsNotes=False, splitOnDate=False): """ @param out: output target @param termWidth: terminal width (int) - @param decrypt: whether to decrypt or not (bool) @param renderAsNotes: whether to display task as notes (with dates) instead of tasks (with age). (boot)""" self.out = out self.termWidth = termWidth or tui.getTermWidth() @@ -172,7 +168,6 @@ self.maxTitleWidth = len("Title") self.today = datetime.today().replace(microsecond=0) self.firstHeader = True - self.cryptoMgr = cryptoMgr self.splitOnDate = splitOnDate if self.termWidth < 100: @@ -192,17 +187,19 @@ # All fields set to None must be defined in end() self.columns = [ - Column("ID" , None , idFormater), - Column("Title" , None , None), - Column("U" , 3 , urgencyFormater), - Column("S" , 1 , statusFormater), - Column(creationDateTitle, creationDateColumnWidth , AgeFormater(self.today, renderAsNotes)), - Column("Due date" , dueColumnWidth , DueDateFormater(self.today, shortDateFormat)), - ] + Column("ID", None, idFormater), + Column("Title", None, None), + Column("U", 3, urgencyFormater), + Column("S", 1, statusFormater), + Column(creationDateTitle, creationDateColumnWidth, AgeFormater(self.today, renderAsNotes)), + Column("Due date", dueColumnWidth, DueDateFormater(self.today, shortDateFormat)), + ] self.idColumn = self.columns[0] self.titleColumn = self.columns[1] + self.maxId = 0 + def addTaskList(self, sectionName, taskList): """Store tasks for this section @param sectionName: name of the task groupment section @@ -213,7 +210,7 @@ self.taskLists.append((sectionName, taskList)) # Find max title width for task in taskList: - title = self.cryptoMgr.decrypt(task.title) + title = task.title keywords = task.getUserKeywordsNameAsString() if keywords: title = "{} ({})".format(title, keywords) @@ -221,19 +218,19 @@ if task.description: titleWidth += 1 self.maxTitleWidth = max(self.maxTitleWidth, titleWidth) + self.maxId = max(self.maxId, task.id) def end(self): today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) # Adjust idColumn - maxId = db.getSession().query(func.max(Task.id)).one()[0] - self.idColumn.width = max(2, len(str(maxId))) + self.idColumn.width = max(2, len(str(self.maxId))) # Adjust titleColumn self.titleColumn.width = self.maxTitleWidth totalWidth = sum([x.width for x in self.columns]) + len(self.columns) - 1 if totalWidth >= self.termWidth: self.titleColumn.width = self.termWidth - (totalWidth - self.titleColumn.width) - self.titleColumn.formater = TitleFormater(self.titleColumn.width, self.cryptoMgr) + self.titleColumn.formater = TitleFormater(self.titleColumn.width) # Print table for sectionName, taskList in self.taskLists: diff -Nru yokadi-1.1.1/yokadi/ycli/tui.py yokadi-1.2.0/yokadi/ycli/tui.py --- yokadi-1.1.1/yokadi/ycli/tui.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/tui.py 2019-02-10 11:35:44.000000000 +0000 @@ -19,7 +19,8 @@ from collections import namedtuple from getpass import getpass -from yokadi.ycli import colors as C +from yokadi.ycli import colors +from yokadi.core.yokadiexception import YokadiException # Number of seconds between checks for end of process PROC_POLL_INTERVAL = 0.5 @@ -46,10 +47,19 @@ else: self.__original_flow.write(text) + stdout = IOStream(sys.stdout) stderr = IOStream(sys.stderr) +isInteractive = sys.stdin.isatty() + + +def _checkIsInteractive(): + if not isInteractive: + raise YokadiException("This command cannot be used in non-interactive mode") + + def editText(text, onChanged=None, lockManager=None, prefix="yokadi-", suffix=".md"): """Edit text with external editor @param onChanged: function parameter that is call whenever edited data change. Data is given as a string @@ -57,7 +67,9 @@ @param prefix: temporary file prefix. @param suffix: temporary file suffix. @return: newText""" + _checkIsInteractive() encoding = locale.getpreferredencoding() + def readFile(name): with open(name, encoding=encoding) as data: return str(data.read()) @@ -66,9 +78,10 @@ start = time.time() while (time.time() - start) < MTIME_POLL_INTERVAL: proc.poll() - if not proc.returncode is None: + if proc.returncode is not None: return time.sleep(PROC_POLL_INTERVAL) + prefix = NON_SIMPLE_ASCII.sub("-", prefix) prefix = MULTIPLE_DASH.sub("-", prefix) prefix = unicodedata.normalize('NFKD', prefix) @@ -95,7 +108,7 @@ mtime = newMtime onChanged(readFile(name)) if proc.returncode != 0: - raise Exception() + raise Exception("The command {} failed. It exited with code {}.".format(proc.args, proc.returncode)) return readFile(name) finally: os.close(fd) @@ -127,6 +140,7 @@ """Edit a line using readline @param prompt: change prompt @param echo: whether to echo user text or not""" + _checkIsInteractive() if line: reinjectInRawInput(line) @@ -197,6 +211,8 @@ def confirm(prompt): + if not isInteractive: + return True while True: answer = editLine("", prompt=prompt + " (y/n)? ") answer = answer.lower() @@ -214,7 +230,7 @@ @param fields: list of tuple (caption, value) """ maxWidth = max([len(x) for x, y in fields]) - format = C.BOLD + "%" + str(maxWidth) + "s" + C.RESET + ": %s" + format = colors.BOLD + "%" + str(maxWidth) + "s" + colors.RESET + ": %s" for caption, value in fields: print(format % (caption, value), file=stdout) @@ -229,15 +245,15 @@ def error(message): - print(C.BOLD + C.RED + "Error: %s" % message + C.RESET, file=stderr) + print(colors.BOLD + colors.RED + "Error: %s" % message + colors.RESET, file=stderr) def warning(message): - print(C.RED + "Warning: " + C.RESET + message, file=stderr) + print(colors.RED + "Warning: " + colors.RESET + message, file=stderr) def info(message): - print(C.CYAN + "Info: " + C.RESET + message, file=stderr) + print(colors.CYAN + "Info: " + colors.RESET + message, file=stderr) def addInputAnswers(*answers): @@ -272,10 +288,10 @@ self._dct[pos] = color def setResetAt(self, pos): - self._dct[pos] = C.RESET + self._dct[pos] = colors.RESET def crop(self, width): - self._dct = { pos: color for pos, color in self._dct.items() if pos < width } + self._dct = {pos: color for pos, color in self._dct.items() if pos < width} def render(self, text): """ diff -Nru yokadi-1.1.1/yokadi/ycli/xmllistrenderer.py yokadi-1.2.0/yokadi/ycli/xmllistrenderer.py --- yokadi-1.1.1/yokadi/ycli/xmllistrenderer.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/ycli/xmllistrenderer.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,13 +8,11 @@ """ from xml.dom import minidom as dom -from yokadi.ycli import tui - TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "description", "urgency", "status", "keywords"] class XmlListRenderer(object): - def __init__(self, out, cryptoMgr=None): + def __init__(self, out): self.out = out self.doc = dom.Document() self.rootElement = self.doc.createElement("yokadi") diff -Nru yokadi-1.1.1/yokadi/yical/icalutils.py yokadi-1.2.0/yokadi/yical/icalutils.py --- yokadi-1.1.1/yokadi/yical/icalutils.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/yical/icalutils.py 2019-02-10 11:35:44.000000000 +0000 @@ -8,6 +8,10 @@ import icalendar +def _clamp(value, minimum, maximum): + return minimum if value < minimum else maximum if value > maximum else value + + def convertIcalType(attr): """Convert data from icalendar types (vDates, vInt etc.) to python standard equivalent @param attr: icalendar type @@ -27,9 +31,7 @@ @param priority: ical priority @return: yokadi urgency""" urgency = 100 - 20 * priority - if urgency > 100: urgency = 100 - if urgency < -99: urgency = -99 - return urgency + return _clamp(urgency, -99, 100) def yokadiUrgencyToIcalPriority(urgency): @@ -37,8 +39,6 @@ @param urgency: yokadi urgency @return: ical priority""" priority = int(-(urgency - 100) / 20) - if priority > 9: priority = 9 - if priority < 1: priority = 1 - return priority + return _clamp(priority, 1, 9) # vi: ts=4 sw=4 et diff -Nru yokadi-1.1.1/yokadi/yical/yical.py yokadi-1.2.0/yokadi/yical/yical.py --- yokadi-1.1.1/yokadi/yical/yical.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/yical/yical.py 2019-02-10 11:35:44.000000000 +0000 @@ -34,7 +34,7 @@ # UID pattern UID_PREFIX = "yokadi" TASK_UID = UID_PREFIX + "-task-%s" -TASK_RE = re.compile(TASK_UID.replace("%s", "(\d+)")) +TASK_RE = re.compile(TASK_UID.replace("%s", r"(\d+)")) PROJECT_UID = UID_PREFIX + "-project-%s" # Default project where new task are added @@ -58,7 +58,7 @@ cal.add("prodid", '-//Yokadi calendar //yokadi.github.io//') cal.add("version", "2.0") # Add projects - for project in session.query(Project).filter(Project.active == True): + for project in session.query(Project).filter(Project.active == True): # noqa vTodo = icalendar.Todo() vTodo.add("summary", project.name) vTodo["uid"] = PROJECT_UID % project.id @@ -113,7 +113,7 @@ attr = icalutils.convertIcalType(attr) if yokadiAttribute == "title": # Remove (id) - attr = re.sub("\s?\(%s\)" % task.id, "", attr) + attr = re.sub(r"\s?\(%s\)" % task.id, "", attr) if yokadiAttribute == "doneDate": # A done date defined indicate that task is done task.status = "done" @@ -144,6 +144,7 @@ dbutils.createMissingKeywords(list(newKwDict.keys()), interactive=False) task.setKeywordDict(newKwDict) + class IcalHttpRequestHandler(http.server.BaseHTTPRequestHandler): """Simple Ical http request handler that only implement GET method""" newTask = {} # Dict recording new task origin UID @@ -198,7 +199,7 @@ print("New task %s (%s)" % (vTodo["summary"], vTodo["UID"])) keywordDict = {} task = dbutils.addTask(INBOX_PROJECT, vTodo["summary"], - keywordDict, interactive=False) + keywordDict, interactive=False) session.add(task) session.commit() # Keep record of new task origin UID to avoid duplicate diff -Nru yokadi-1.1.1/yokadi/yokadid.py yokadi-1.2.0/yokadi/yokadid.py --- yokadi-1.1.1/yokadi/yokadid.py 2016-11-11 18:30:29.000000000 +0000 +++ yokadi-1.2.0/yokadi/yokadid.py 2019-02-10 11:35:44.000000000 +0000 @@ -14,7 +14,6 @@ from signal import SIGTERM, SIGHUP, signal from subprocess import Popen from argparse import ArgumentParser -import imp from yokadi.core import fileutils @@ -28,15 +27,16 @@ from yokadi.core.daemon import Daemon from yokadi.core import basepaths -from yokadi.ycli import tui from yokadi.yical.yical import YokadiIcalServer from yokadi.core import db -from yokadi.core.db import Config, Project, Task, getConfigKey +from yokadi.core.db import Project, Task, getConfigKey +from yokadi.ycli import commonargs # Daemon polling delay (in seconds) -DELAY = 30 +PROCESS_INTERVAL = 30 +EVENTLOOP_INTERVAL = 1 # Ical daemon default port DEFAULT_TCP_ICAL_PORT = 8000 @@ -72,9 +72,9 @@ triggeredDueTasks = {} activeTaskFilter = [Task.status != "done", Task.projectId == Project.id, - Project.active == True] - while event[0]: - now = datetime.today().replace(microsecond=0) + Project.active == True] # noqa + + def process(now): delayTasks = session.query(Task).filter(Task.dueDate < now + delta, Task.dueDate > now, *activeTaskFilter) @@ -82,7 +82,14 @@ *activeTaskFilter) processTasks(delayTasks, triggeredDelayTasks, cmdDelayTemplate, suspend) processTasks(dueTasks, triggeredDueTasks, cmdDueTemplate, suspend) - time.sleep(DELAY) + + nextProcessTime = datetime.today().replace(microsecond=0) + while event[0]: + now = datetime.today().replace(microsecond=0) + if now > nextProcessTime: + process(now) + nextProcessTime = now + timedelta(seconds=PROCESS_INTERVAL) + time.sleep(EVENTLOOP_INTERVAL) def processTasks(tasks, triggeredTasks, cmdTemplate, suspend): @@ -121,61 +128,59 @@ def parseOptions(defaultPidFile, defaultLogFile): parser = ArgumentParser() - parser.add_argument("-d", "--db", dest="filename", - help="TODO database", metavar="FILE") + commonargs.addArgs(parser) parser.add_argument("-i", "--icalserver", - dest="icalserver", default=False, action="store_true", - help="Start the optional HTTP Ical Server") + dest="icalserver", default=False, action="store_true", + help="Start the optional HTTP Ical Server") parser.add_argument("-p", "--port", - dest="tcpPort", default=DEFAULT_TCP_ICAL_PORT, - help="TCP port of ical server (default: %s)" % DEFAULT_TCP_ICAL_PORT, - metavar="PORT") + dest="tcpPort", default=DEFAULT_TCP_ICAL_PORT, + help="TCP port of ical server (default: %s)" % DEFAULT_TCP_ICAL_PORT, + metavar="PORT") parser.add_argument("-l", "--listen", - dest="tcpListen", default=False, action="store_true", - help="Listen on all interface (not only localhost) for ical server") + dest="tcpListen", default=False, action="store_true", + help="Listen on all interface (not only localhost) for ical server") parser.add_argument("-k", "--kill", - dest="kill", default=False, action="store_true", - help="Kill the Yokadi daemon. The daemon is found from the process ID stored in the file specified with --pid") + dest="kill", default=False, action="store_true", + help="Kill the Yokadi daemon. The daemon is found from the process ID stored in the file" + " specified with --pid") parser.add_argument("--restart", - dest="restart", default=False, action="store_true", - help="Restart the Yokadi daemon. The daemon is found from the process ID stored in the file specified with --pid") + dest="restart", default=False, action="store_true", + help="Restart the Yokadi daemon. The daemon is found from the process ID stored in the file" + " specified with --pid") parser.add_argument("-f", "--foreground", - dest="foreground", default=False, action="store_true", - help="Don't fork background. Useful for debug") + dest="foreground", default=False, action="store_true", + help="Don't fork background. Useful for debug") parser.add_argument("--pid", - dest="pidFile", default=defaultPidFile, - help="File in which Yokadi daemon stores its process ID (default: %s)" % defaultPidFile) + dest="pidFile", default=defaultPidFile, + help="File in which Yokadi daemon stores its process ID (default: %s)" % defaultPidFile) parser.add_argument("--log", - dest="logFile", default=defaultLogFile, - help="File in which Yokadi daemon stores its log output (default: %s)" % defaultLogFile) + dest="logFile", default=defaultLogFile, + help="File in which Yokadi daemon stores its log output (default: %s)" % defaultLogFile) return parser.parse_args() class YokadiDaemon(Daemon): - def __init__(self, options): + def __init__(self, dbPath, options): Daemon.__init__(self, options.pidFile, stdout=options.logFile, stderr=options.logFile) + self.dbPath = dbPath self.options = options def run(self): - filename = self.options.filename - if not filename: - filename = basepaths.getDbPath() - print("Using default database (%s)" % filename) - - db.connectDatabase(filename, createIfNeeded=False) + db.connectDatabase(self.dbPath, createIfNeeded=False) + print("Using %s" % self.dbPath) session = db.getSession() # Basic tests : - if not len(session.query(db.Config).all()) >=1: + if not len(session.query(db.Config).all()) >= 1: print("Your database seems broken or not initialised properly. Start yokadi command line tool to do it") sys.exit(1) @@ -204,6 +209,7 @@ defaultPidFile = os.path.join(basepaths.getRuntimeDir(), "yokadid.pid") defaultLogFile = os.path.join(basepaths.getLogDir(), "yokadid.log") args = parseOptions(defaultPidFile, defaultLogFile) + _, dbPath = commonargs.processArgs(args) if args.kill: killYokadid(args.pidFile) @@ -219,14 +225,15 @@ signal(SIGHUP, sigHupHandler) if args.restart: - daemon = YokadiDaemon(args) + daemon = YokadiDaemon(dbPath, args) daemon.restart() - daemon = YokadiDaemon(args) + daemon = YokadiDaemon(dbPath, args) if args.foreground: daemon.run() else: daemon.start() + if __name__ == "__main__": main()