yokadi-1.2.0/0000775000175000017500000000000013430006221014415 5ustar aurelienaurelien00000000000000yokadi-1.2.0/man/0000775000175000017500000000000013430006221015170 5ustar aurelienaurelien00000000000000yokadi-1.2.0/man/yokadi.10000664000175000017500000000257513430006220016542 0ustar aurelienaurelien00000000000000.TH YOKADI 1 "July 10, 2009" .SH NAME yokadi \- commandline todo system .SH SYNOPSIS .B yokadi .RI [ options ]... .br .SH DESCRIPTION .B yokadi is a command-line oriented, SQLite powered, TODO list tool. It helps you organize all the things you have to do and you must not forget. It aims to be simple, intuitive and very efficient. In Yokadi you manage projects, which contains tasks. At the minimum, a task has a title, but it can also have a description, a due date, an urgency or keywords. Keywords can be any word that help you to find and sort your tasks. .PP .SH OPTIONS These programs follow the usual GNU command line syntax, with long options starting with two dashes (`-'). A summary of options is included below. .TP .B \-\-datadir= Database directory. .TP .B \-c, \-\-create-only Just create an empty database. .TP .B \-u, \-\-update Update database to the latest version. .TP .B \-h, \-\-help Show summary of options and exit. .TP .B \-v, \-\-version Show version of program and exit. .SH SEE ALSO .BR yokadid (1). .br .SH SEE ALSO Website: http://yokadi.github.io Mailing List: http://sequanux.org/cgi-bin/mailman/listinfo/ml-yokadi .SH AUTHOR yokadi was written by Aurélien Gâteau and Sébastien Renard . .PP This manual page was written by Kartik Mistry , for the Debian project (and may be used by others). yokadi-1.2.0/man/yokadid.10000664000175000017500000000254313430006220016701 0ustar aurelienaurelien00000000000000.TH YOKADID 1 "July 10, 2009" .SH NAME yokadid \- commandline todo system .SH SYNOPSIS .B yokadid .RI [ options ]... .br .SH DESCRIPTION .B yokadid is a Yokadi daemon that remind you due tasks. If you want to be automatically reminded of due tasks, you can use the Yokadi daemon. The Yokadi daemon can be launched via desktop autostart services. In most desktop environments, you just need to create a symbolic link to yokadid (or a shell script that calls it) in $HOME/.config/autostart/. ln \-s \`which yokadid\` $HOME/.config/autostart/ .PP .SH OPTIONS These programs follow the usual GNU command line syntax, with long options starting with two dashes (`-'). A summary of options is included below. .TP .B \-\-datadir= Database directory. .TP .B \-k, \-\-kill Kill Yokadi Daemon (you can specify database with \-db if you run multiple Yokadid). .TP .B \-f, \-\-foreground Don't fork background. Useful for debug. .TP .B \-h, \-\-help Show summary of options and exit. .SH SEE ALSO .BR yokadi (1). .br .SH SEE ALSO Website: http://yokadi.github.io Mailing List: http://sequanux.org/cgi-bin/mailman/listinfo/ml-yokadi .SH AUTHOR yokadi was written by Aurélien Gâteau and Sébastien Renard . .PP This manual page was written by Kartik Mistry , for the Debian project (and may be used by others). yokadi-1.2.0/scripts/0000775000175000017500000000000013430006221016104 5ustar aurelienaurelien00000000000000yokadi-1.2.0/scripts/coverage0000775000175000017500000000047413430006220017631 0ustar aurelienaurelien00000000000000#!/bin/sh set -e cd $(dirname $0)/.. COVERAGE_BIN=${COVERAGE_BIN=python3-coverage} if ! which $COVERAGE_BIN > /dev/null ; then echo "Could not find $COVERAGE_BIN make sure Coverage is installed" exit 1 fi $COVERAGE_BIN run --source=yokadi --omit="yokadi/tests/*" yokadi/tests/tests.py $COVERAGE_BIN html yokadi-1.2.0/scripts/diffinst0000775000175000017500000000373013430006220017642 0ustar aurelienaurelien00000000000000#!/usr/bin/env python3 """ @author: Aurélien Gâteau @license: GPL v3 or newer """ import argparse import fnmatch import os import tarfile import subprocess import sys DESCRIPTION = """\ Compare a tarball and a git tree, list files unique on each side. """ GIT_IGNORE = ( '.gitignore', ) TARBALL_IGNORE = ( 'PKG-INFO', ) def list_git_dir(root): out = subprocess.check_output(['git', 'ls-files'], cwd=root) return [x.decode() for x in out.splitlines()] def remove_first_dir(path): lst = path.split(os.sep) return os.path.join(*lst[1:]) def list_tarball(tarball): with tarfile.open(tarball) as tf: for info in tf.getmembers(): if info.isfile(): yield remove_first_dir(info.name) def apply_blacklist(lst, blacklist): for item in lst: for pattern in blacklist: if fnmatch.fnmatch(item, pattern): break else: yield item def print_set(st): for item in sorted(list(st)): print(item) def main(): parser = argparse.ArgumentParser() parser.description = DESCRIPTION parser.add_argument('-q', '--quiet', action='store_true', help='Do not list changes') parser.add_argument('tarball') parser.add_argument('git_dir', nargs='?', default='.') args = parser.parse_args() dir_set = set(apply_blacklist(list_git_dir(args.git_dir), GIT_IGNORE)) tb_set = set(apply_blacklist(list_tarball(args.tarball), TARBALL_IGNORE)) only_in_dir = dir_set.difference(tb_set) only_in_tb = tb_set.difference(dir_set) if not args.quiet: if only_in_dir: print('# Only in {}'.format(args.git_dir)) print_set(only_in_dir) if only_in_tb: print('# Only in {}'.format(args.tarball)) print_set(only_in_tb) if only_in_dir or only_in_tb: return 1 else: return 0 if __name__ == '__main__': sys.exit(main()) # vi: ts=4 sw=4 et yokadi-1.2.0/scripts/mkdist.sh0000775000175000017500000000243713430006220017743 0ustar aurelienaurelien00000000000000#!/bin/sh set -e PROGNAME="$(basename "$0")" die() { echo "$PROGNAME: ERROR: $*" | fold -s -w "${COLUMNS:-80}" >&2 exit 1 } log() { echo "### $*" >&2 } [ $# = 1 ] || die "USAGE: $PROGNAME " SRC_DIR=$(cd "$(dirname $0)/.." ; pwd) DST_DIR=$(cd "$1" ; pwd) [ -d "$DST_DIR" ] || die "Destination dir '$SRC_DIR' does not exist" WORK_DIR=$(mktemp -d "$DST_DIR/yokadi-dist.XXXXXX") log "Copying source" cp -a --no-target-directory "$SRC_DIR" "$WORK_DIR" log "Cleaning" cd "$WORK_DIR" git reset --hard HEAD git clean -q -dxf log "Building archives" ./setup.py -q sdist --formats=gztar,zip log "Installing archive" cd dist/ YOKADI_TARGZ=$(ls ./*.tar.gz) tar xf "$YOKADI_TARGZ" ARCHIVE_DIR="$PWD/${YOKADI_TARGZ%.tar.gz}" virtualenv --python python3 "$WORK_DIR/venv" ( . "$WORK_DIR/venv/bin/activate" # Install Yokadi in the virtualenv and make sure it can be started # That ensures dependencies got installed by pip log "Smoke test" pip3 install "$ARCHIVE_DIR" yokadi exit log "Installing extra requirements" pip3 install -r "$ARCHIVE_DIR/extra-requirements.txt" log "Running tests" "$ARCHIVE_DIR/yokadi/tests/tests.py" ) log "Moving archives out of work dir" cd "$WORK_DIR/dist" mv ./*.tar.gz ./*.zip "$DST_DIR" rm -rf "$WORK_DIR" log "Done" yokadi-1.2.0/doc/0000775000175000017500000000000013430006221015162 5ustar aurelienaurelien00000000000000yokadi-1.2.0/doc/tips.md0000664000175000017500000000441013430006220016461 0ustar aurelienaurelien00000000000000# Intro This document presents practical advices on how to get the best out of Yokadi. # Completion Yokadi supports completion of command names, and in many commands it can complete project names. Do not hesitate to try the `[tab]` key! # Setting up a project hierarchy You can set up a project hierarchy by adopting a name convention. For example if you want to track tasks related to a program which is made of many plugins, you could have the main project named `fooplayer`, all tasks for the .ogg plugin stored in `fooplayer_ogg` and all tasks about the .s3m plugin in `fooplayer_s3m`. This makes it easy to categorize your tasks and also to have a general overview. For example to list all `fooplayer` related tasks you can use: t_list fooplayer% # Using keywords Keywords are great to group tasks in different ways. For example you can create a keyword named `phone`, and assign it to tasks which you must accomplish on the phone. Another useful keyword is `diy_store`: Every time you find that you need to buy some supply from a do-it-yourself store, add it with this keyword. Next time you are planning a trip to the store, get the list of what to buy with: t_list @diy_store Or even nicer, directly print your list (from the shell): yokadi "t_list @diy_store --format plain" | lp # Keep track of your meetings To track my meetings, I like to use a `meeting` keyword together with an assigned due date. Yokadi ability to add long descriptions to tasks is also handy to associate address or contact information to a meeting task. # Keep track of tasks you delegate to people When you delegate a task to someone, add a keyword with its name to the task. So you can check that people really do what they promise to do even if they are not as organized as you are. To list all tasks assigned to Bob: t_list @bob To check all task that Bob should have done: t_list --overdue @bob # Some useful shortcuts Yokadi relies on readline library, so you can use very useful readline shortcuts such as: - up/down arrows to browse history - ctrl-r to search backward in Yokadi history - ctrl-l to clear the screen - ctrl-t to swap two letters - ctrl-a to go the begin of the line - ctrl-e to go the end of the line - ctrl-w delete last word yokadi-1.2.0/doc/bugtracking.md0000664000175000017500000000651013430006220020005 0ustar aurelienaurelien00000000000000# Introduction Yokadi comes with a set of commands tailored to help you track bugs. These commands are `bug_add` and `bug_edit`. They are similar to `t_add` and `t_edit` except they will ask you a few questions to help you decide which bug to fix next. # Entering a bug Enter a new bug like you would enter a new task: bug_add fooplayer Fooplayer crashes when opening a .bar file Before adding the task to the project "fooplayer", `bug_add` will ask you the severity of the bug: 1: Documentation 2: Localization 3: Aesthetic issues 4: Balancing: Enables degenerate usage strategies that harm the experience 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 Severity: _ Enter 7 here, this is a crash. Now `bug_add` wants to know about the likelihood of the bug: 1: Will affect almost no one 2: Will only affect a few users 3: Will affect average number of users 4: Will affect most users 5: Will affect all users Likelihood: _ .bar files are quite uncommon, enter 2 here. We reach the last question: bug: _ This last question is optional: `bug_add` wants to know the id of this bug. This is where you can enter the Bugzilla/Trac/Mantis/... id of the bug. If you just noticed this bug and have not yet entered it in a centralized bug tracker, just press Enter. Yokadi will now add a task for your bug: Added bug 'Fooplayer crashes when opening a .bar file' (id=12, urgency=40) If you edit the task with `t_edit 12` you will only be able to fix the task title. To be asked for severity, likelihood and bug id again, use `bug_edit 12`. # What's next? Based on the severity and likelihood, Yokadi computes the urgency of the bug. The formula used is: likelihood * severity * 100 urgency = ----------------------------- max_likelihood * max_severity This is based on the concept of "User Pain", as described by Danc here: Now, when you list your tasks with `t_list`, the most urgent tasks will be listed first, making it easy to fix the most important bugs first. # Behind the scenes Likelihood, severity and bug are stored as Yokadi keywords (Yokadi keywords can be associated with an integer value). The bug urgency is computed from likelihood and severity, then stored in the task urgency field. Yes, this means there is duplication and you may get likelihood/severity and urgency out of sync if you manually adjust urgency with `t_set_urgency`. In practice, I found it was not a problem. # Tricks Here are a few tricks I came up with while using Yokadi to do bug tracking: - List all crashers: `t_list fooplayer -k severity=7` - Make use of Yokadi keywords. For example I often use: - backport: I should backport the fix when done - i18n: This bug requires translation changes, better fix it before i18n freeze - patch: This bug as an attached patch (You can paste the patch in the bug description with `t_describe`) - Find a bug by id: `t_list fooplayer -k bug=12` - I often keep two projects in Yokadi, one for the stable release, another for development. For example I have `yokadi_stable` and `yokadi_dev`. yokadi-1.2.0/doc/dev/0000775000175000017500000000000013430006221015740 5ustar aurelienaurelien00000000000000yokadi-1.2.0/doc/dev/release.md0000664000175000017500000000204713430006220017704 0ustar aurelienaurelien00000000000000# Release check list ## Introduction This doc assumes there is a checkout of yokadi.github.com next to the checkout of yokadi. ## In yokadi checkout export version= Check dev is clean git checkout dev git pull git status Update `NEWS` file (add changes, check release date) Ensure `yokadi/__init__.py` file contains $version Build archives ./scripts/mkdist.sh ../yokadi.github.com/download Push changes git push When CI has checked the branch, merge changes in master git checkout master git pull git merge dev git push Tag the release git tag -a $version -m "Releasing $version" git push --tags ## In yokadi.github.com checkout Ensure checkout is up to date Update documentation ./updatedoc.py ../yokadi . Update version in download page (`download.md`) Write a blog entry in `_posts/` Test it: jekyll serve Upload archives on PyPI cd download/ twine upload yokadi-.* Publish blog post git add . git commit -m "Releasing $version" git push yokadi-1.2.0/doc/dev/debug.md0000664000175000017500000000024513430006220017350 0ustar aurelienaurelien00000000000000# 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. yokadi-1.2.0/doc/dev/db-updates.md0000664000175000017500000000306213430006220020312 0ustar aurelienaurelien00000000000000# Database updates ## How the update system works Lets assume current version is x and target version is x+n. The update process goes like this: - Copy yokadi.db to work.db - for each v between x and x + n - 1: - run `updateto.update()` - Create an empty database in recreated.db - Fill recreated.db with the content of work.db - If we are updating the database in place, rename yokadi.db to yokadi-$date.db and recreated.db to yokadi.db - If we are creating a new database (only possible by directly calling update/update.py), rename recreated.db to the destination name; The recreation steps ensure that: - All fields are created in the same order (when adding a new column, you can't specify its position) - All constraints are in place (when adding a new column, you can't mark it 'non null') - The updated database has the exact same structure as a brand new database. ## Database schema changes If you want to modify the database schema (adding, removing, changing tables or fields). You should: - Present the changes on the mailing-list - Implement your changes in db.py - Increase the database version number (`DB_VERSION` in db.py) - Write an update script in update/ - When the changes are merged in master, tag the merge commit using the tag name `db-v`, like this: # Note the -a! git tag -a db-v git push --tags Note: up to db-v4, `db-v*` have been created on the last commit before the update to a new version, so `db-v4` is on the last commit before `DB_VERSION` was bumped to 5. yokadi-1.2.0/doc/dev/hacking.md0000664000175000017500000000444613430006220017675 0ustar aurelienaurelien00000000000000# Coding style ## Naming Classes use CamelCase. Functions use mixedCase. Here is an example: class MyClass(object): def myMethod(self, arg1, arg2): pass def anotherMethod(self, arg1, *args, **kwargs): pass Exception: Classes which implement command methods should use underscores, since the name of the method is used to create the name of the command: class MyCmd(object): def do_t_cmd1(self, line): pass def parser_t_cmd1(self): return SomeParser def someMethod(self): pass Note: This naming convention is historic, we would like to switch to a more PEP-8 compliant coding style where words in function and variable names are separated with `_`. If you feel like doing the conversion, get in touch. Filenames are lowercase. If they contain a class they should match the name of the class they contain. Internal functions and methods should be prefixed with `_`. ## Spacing Indentation is 4 spaces. Try to keep two blank lines between functions. One space before and after operators, except in optional arguments. a = 12 if a > 14 or a == 15: print a myFunction(a, verbose=True) ## Import Use one import per line: import os import sys Avoid polluting the local namespace with `from module import function`. Good: import os os.listdir(x) Bad: from os import listdir listdir(x) You should however import classes like this: from module import SomeClass Keep import in blocks, in this order: 1. Standard Python modules 2. Third-party modules 3. Yokadi modules Keep import blocks sorted. It makes it easier to check if an import line is already there. # Command docstrings All commands are documented either through their parser or using the command docstring. To ensure consistency all usage string should follow the same guidelines. For example assuming your command is named `t_my_command`, which accepts a few options, two mandatory arguments (a task id and a search text) and an optional filename argument. The usage string should look like this: t_my_command [options] [] No need to detail the options in the usage string, they will be listed by the parser below the usage string. yokadi-1.2.0/doc/ical.md0000664000175000017500000000456613430006220016426 0ustar aurelienaurelien00000000000000# Intro This document presents how to use Yokadi with a third party calendar/todolist application that supports the ical format (RFC2445). To use ical Yokadi features, start the Yokadi daemon with the --icalserver switch. This daemon also manages alarms for due tasks. The ical server listens on TCP port 8000. You can choose another TCP port with the --port switch. For example, to start Yokadi daemon with the icalserver on TCP port 9000: yokadid --icalserver --port=9000 # Read your Yokadi tasks in a third party tool If your third party tool supports ical format and is able to read it through HTTP, just set it up to read on localhost:8000 (or whatever port you setup) and enjoy. If your calendar/todo tool only supports local files: * complain to your software broker to include ical over HTTP ;-) * make a simple shell script that downloads the ical file and put it on your crontab. You can use wget for that: wget -O yokadi.ical http://localhost:8000 Each Yokadi task is defined as an ical VTODO object. Yokadi projects are represented as special tasks to which included tasks are related. # Create and update yokadi tasks from a third party tool On the same TCP socket, you can write tasks with the PUT HTTP method. Only new and updated tasks will be considered. # Supported third party ical tool Yokadi should support any tool which implements RFC2345. But we are not in a perfect world. The following tools are known to work properly with Yokadi ical server: - Kontact/KOrganizer (4.4) from the KDE Software Compilation If you successfully plugged Yokadi with another calendar/todolist tool, please let us now in order to complete this list. # Some security considerations By default, the ical server only listens on localhost (loopback). You can bypass this restriction with the --listen switch which makes the ical server listen on all interfaces. If you do this, you will be able to access to the ical HTTP stream from another computer. But this have some security issues if you don't setup a firewall to restrict who can access to your Yokadi daemon: * everybody could access to your task list * even worse, everybody could be able to modify you task list * the ical server has not been build with strong security as design goals. You have been warned. That's why listening only to localhost (which is the default) is strongly recommended. yokadi-1.2.0/bin/0000775000175000017500000000000013430006221015165 5ustar aurelienaurelien00000000000000yokadi-1.2.0/bin/yokadid0000775000175000017500000000052513430006220016540 0ustar aurelienaurelien00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """This is just a wrapper to yokadi package that rely in standard python site-package This wrapper is intended to be placed in user PATH and to be executable @author: Sébastien Renard (sebastien.renard@digitalfox.org) @license:GPL v3 or later """ from yokadi import yokadid yokadid.main() yokadi-1.2.0/bin/yokadi0000775000175000017500000000052413430006220016373 0ustar aurelienaurelien00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- """This is just a wrapper to yokadi package that rely in standard python site-package This wrapper is intended to be placed in user PATH and to be executable @author: Sébastien Renard (sebastien.renard@digitalfox.org) @license:GPL v3 or later """ from yokadi.ycli import main main.main() yokadi-1.2.0/icon/0000775000175000017500000000000013430006221015345 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/22x22/0000775000175000017500000000000013430006221016124 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/22x22/yokadi.png0000664000175000017500000000111213430006220020104 0ustar aurelienaurelien00000000000000PNG  IHDRĴl;sBIT|d pHYstEXtSoftwarewww.inkscape.org<IDAT8=oPDCKoHCT!g``ÐNU KC,lK0vH BkDD[Q,ϵs?d8Βg(@ZŶ꺎imut:bdnn&ՃRD" Q,H`2 f 2L(d -䢫^G ͢i:y}%9W P(4Pg>{wo_XJaR)!a 0˲hZ }(}N~U=<|>DaPaqT*v-oM8lnb+~XzB>m|}vaQX:8xWϡP~?`>Ry,zcQX]P@<,7@؏Git]vzxLNԶXY~I۶sIENDB`yokadi-1.2.0/icon/64x64/0000775000175000017500000000000013430006221016140 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/64x64/yokadi.png0000664000175000017500000000137113430006220020127 0ustar aurelienaurelien00000000000000PNG  IHDR@@iqsBIT|d pHYs7]7]F]tEXtSoftwarewww.inkscape.org<vIDATxMrPFOn=4I܀q-nB>*3CFH ½ܟnK1P}\._8FL ΟDr<m?0 .@:W=n׻nY.c0ưC=2ϙf!j%`}_EL&!  VUbn4nWۏ ,Au]s88NTU5Hi7`,Ȳ3ɞp IY_O>dLKM$x? +|[8$]{DKK 룳P%y:ܯĂ=* s=Ub< jz/n 曨  H E$A)xP @<WSlZxywM(x!w $h38`)w,!fpZ!Pw~,.w>ѕ`RKHwTRƒ: 0b%K<|-A <CIyix0+A<Jg4Cma*|KFx4oK  x$@?uU鈿8IENDB`yokadi-1.2.0/icon/128x128/0000775000175000017500000000000013430006221016302 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/128x128/yokadi.png0000664000175000017500000000253313430006220020272 0ustar aurelienaurelien00000000000000PNG  IHDR>asBIT|d pHYsnnޱtEXtSoftwarewww.inkscape.org<IDATx?n[GQZ'ހ%/WK@"% !Qs ;R'sualnY/@آnnnrC$zpppppppNy:c20>DqBǜ[/-/\c W|-滯l1}`+[w_b W|-滯l 8x1>^w=8-~LvV1"z}˟?*rcgX,n1"("U{)F rL~E ^f :#內'tD*x^L&oE{տ }w~|>g>7y ʯN*"@K~EH 3Wt@d$.(L_6\+D`~w-1aۙ?߆_V:c-YDEk " `ݲX,њ>ɇB8LSRZ7P`Álz^J+( {nNߒvՊrb~KB}o/8} HD7At`(AfHAbhA4fA4bA2dN2tA>(O"<xã|P_\^oMjz|\s?7%gs?7rkz\$2`1SS|OE!Ss?6ʇ &i1D $ z7#Is$9'D|%!ɇC3C`!gC`!ᧁC`!}`!A}|tM`""2^ܗ<ɇ7`pgPxFW`x{x)x#. by?"|H@?RT.Hq E )6@):7}ߜ^#v&@ӫxߍ#%;g`aG/_D<ssssssյ=+;IENDB`yokadi-1.2.0/icon/yokadi.desktop0000664000175000017500000000037713430006220020226 0ustar aurelienaurelien00000000000000[Desktop Entry] Name=Yokadi GenericName=TODO list manager Comment=Command-line oriented TODO list manager Exec=yokadi Terminal=true Icon=yokadi Type=Application Categories=Office;ProjectManagement;ConsoleOnly; Keywords=todo;projectmanagement;commandline; yokadi-1.2.0/icon/yokadi.svg0000664000175000017500000004240713430006220017354 0ustar aurelienaurelien00000000000000 image/svg+xml yokadi-1.2.0/icon/16x16/0000775000175000017500000000000013430006221016132 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/16x16/yokadi.png0000664000175000017500000000046513430006220020124 0ustar aurelienaurelien00000000000000PNG  IHDRasBIT|d pHYs B(xtEXtSoftwarewww.inkscape.org<IDAT8 EMH'cQP h [2"br=wAC5@u?J8TDP7csIB1DB'/Ӑ,H3! 'ȁGmTp`؃Q@|̬  XK=ip@D|U`D.<*&A$ R)/~*IENDB`yokadi-1.2.0/icon/48x48/0000775000175000017500000000000013430006221016144 5ustar aurelienaurelien00000000000000yokadi-1.2.0/icon/48x48/yokadi.png0000664000175000017500000000113313430006220020127 0ustar aurelienaurelien00000000000000PNG  IHDR00WsBIT|d pHYs))"ߌtEXtSoftwarewww.inkscape.org<IDATh0El;c!ڀڎh9V6K}ftc AWq8 ΙpfC/n(Qg`sөxd\$ 9&W, fLdL/0|&/\Z5}s{`e㜗HkD\q dYrZ)|6 g yNiu`. - Add --describe option to start describing the task right after adding it. - t_describe, n_describe: - Safer task description editing: task is updated each time the editor saves, a lock manager now prevents multiple edits. - Use .md suffix instead of .txt for the temporary filename to allow some smart things with editors that understand Markdown. - Use project and task name for the temporary filename. Useful when using graphical editors or when your terminal title shows the current running command. - t_due: - When called with a time argument which is before current time, set due date to the day after. - t_show: - Show the task ID. - t_list: - Use month and year for the task age if the task is older than 12 months. - Add support for arbitrary minimum date for --done. - Fixed broken help. - n_list: - Display creation date instead of age. - Notes are now grouped by date. - p_list: - Show task count per project. - p_remove: - Show the number of associated tasks in the prompt. - p_edit: - Handle case where user tries to rename a project using the name of an existing project. - yokadid: - Add --restart option and --log option. - Set process name with setproctitle. - Configuration keys can now be overridden using environment variables. - Misc: - Date/time commands now support %d/%m/%y date format. - Replaced xyokadi with a desktop file. - Updated README to match real output. - Developer specific changes: - Command parser has been ported from optparse to argparse. - Code is now PEP 8 compliant, with the exception of camelCase usage. - All imports have been changed to absolute imports (ie `import yokadi.`). - Code has been reorganized into different sub directories. - The scripts in bin/ are now smart enough to run the source tree version instead of the installed version if possible. - We now use Travis for continuous integration. v0.13 2011/04/09 - cryptographic support to encrypt tasks title and description - t_apply now accept id range (x-y) - Special keyword __ can used in t_apply to affect all tasks previously select by t_list v0.12 2010/07/06 - Negative keyword support. Ex.: t_list !@home - Permanent filters on keyword or project. 't_filter @foo' will filter any further call to t_list on @foo keyword. v0.11.1 2009/11/02 - yokadi symlink (useful to run yokadi without installing it) was broken v0.11 2009/11/01 - dynamic display width according to user terminal - display keywords in t_list - bugs keywords are prefixed with a '_' to distinguish them from user keywords - YOKADI_DB environment variable can be defined to set default yokadi database path - tasks can be grouped by keyword instead of project - special character _ can be used to represent last task id - custom aliases can be defined for all commands with a_add - switch from GPL 3 to GPL v3 or newer license v0.10 2009/07/08 - ability to assign keywords to a project - shortened some commands (old ones still available but deprecated): o t_set_due => t_due o t_set_project => t_project o t_set_urgency => t_urgency - changed keyword syntax: use @foo instead of -k foo - added t_recurs command to define task recursion (weekly, monthly, yearly...) - added full text search with t_list -s foo - enhanced t_list display - added purge command (t_purge) to remove old tasks - added Windows support - fixed install script to be more friendly to both users and packagers v0.9 2009/02/07 First public release. Fully usable for home and work. yokadi-1.2.0/README.md0000664000175000017500000002426213430006220015701 0ustar aurelienaurelien00000000000000[![Build Status](https://travis-ci.org/agateau/yokadi.png?branch=master)](https://travis-ci.org/agateau/yokadi) [![Coverage Status](https://coveralls.io/repos/agateau/yokadi/badge.png)](https://coveralls.io/r/agateau/yokadi) # What is it? Yokadi is a command-line oriented, SQLite powered, TODO list tool. It helps you organize all the things you have to do and must not forget. It aims to be simple, intuitive and very efficient. In Yokadi you manage projects, which contain tasks. At the minimum, a task has a title, but it can also have a description, a due date, an urgency or keywords. Keywords can be any word that help you find and sort your tasks. # Dependencies Yokadi should run on any Unix-like systems. There is also some support for Windows but it is not as tested. Yokadi requires Python 3.4 or more and a few other modules, which you can install with: pip install -r requirements.txt It can also make use of other modules listed in extra-requirements.txt. You can install them with: pip install -r extra-requirements.txt These modules are needed for the Yokadi Daemon. # Quickstart Here is an example of a short Yokadi session: Start Yokadi: ./bin/yokadi Creating database Added keyword '_severity' Added keyword '_likelihood' Added keyword '_bug' Added keyword '_note' Create your first task: yokadi> t_add birthday Buy food and drinks Project 'birthday' does not exist, create it (y/n)? y Added project 'birthday' Added task 'Buy food and drinks' (id=1) Add two other tasks, you can use _ to refer to last project used: yokadi> t_add _ Invite Bob Added task 'Invite Bob' (id=2) yokadi> t_add _ Invite Wendy Added task 'Invite Wendy' (id=3) List tasks for project "birthday": yokadi> t_list birthday birthday ID |Title |U |S|Age |Due date -------------------------------------------------------------------------------------------------- 1 |Buy food and drinks |0 |N|1m | 2 |Invite Bob |0 |N|0m | 3 |Invite Wendy |0 |N|0m | Once you have called Bob, you can mark task 2 as done: yokadi> t_mark_done 2 Task 'Invite Bob' marked as done yokadi> t_list birthday birthday ID |Title |U |S|Age |Due date -------------------------------------------------------------------------------------------------- 1 |Buy food and drinks |0 |N|2m | 3 |Invite Wendy |0 |N|1m | Task 2 has not disappeared, but `t_list` skips done tasks by default. To list all tasks use: yokadi> t_list birthday --all birthday ID |Title |U |S|Age |Due date -------------------------------------------------------------------------------------------------- 1 |Buy food and drinks |0 |N|2m | 2 |Invite Bob |0 |D|1m | 3 |Invite Wendy |0 |N|1m | To list only tasks marked as done today: yokadi> t_list birthday --done today birthday ID |Title |U |S|Age |Due date -------------------------------------------------------------------------------------------------- 2 |Invite Bob |0 |D|1m | You may want to attach your grocery list to task 1. This can be done with `t_describe`. yokadi> t_describe 1 This will start the editor specified in $EDITOR (or `vi` if not set) to enter a longer text, attached to the task. You can now display details of task 1: yokadi> t_show 1 Project: birthday Title: Buy food and drinks ID: 1 Created: 2009-01-09 08:57:33 Due: None Status: new Urgency: 0 Recurrence: None Keywords: - Orange juice - Coke - Beer - Cookies - Pizzas Note: `t_show` is not mandatory, just entering the task number will display its details. `t_list` indicates tasks which have a longer description with a `*` character: yokadi> t_list birthday birthday ID |Title |U |S|Age |Due date -------------------------------------------------------------------------------------------------- 1 |Buy food and drinks *|0 |N|3m | 3 |Invite Wendy |0 |N|2m | There is much more, we only scratched the surface, but this should get you started. You can get a list of all commands by typing `help` and get the detailed documentation of a command with `help `. # Advanced stuff ## Quick access to last task When you execute multiple commands on the same task, you can use `_` as a shortcut to the last task id. Assuming you created a task like this: yokadi> t_add home Buy chocolate Added task 'Buy chocolate' (id=1069) Then the following commands are equivalents (until you work on another task): yokadi> t_edit 1069 yokadi> t_edit _ ## Due dates You can define due dates for your tasks with `t_due`. This can be done with a relative or absolute date: yokadi> t_due 21 +3d Due date for task 'Buy chocolate' set to Sat Jul 11 17:16:20 2009 yokadi> t_due 21 23/07 10:30 Due date for task 'Buy chocolate' set to Thu Jul 23 10:30:00 2009 Due dates are shown by `t_list`. Due date is colored according to time left. If you want to be reminded when a task is due, you can use the Yokadi Daemon for that. See below for details. ## Periodic tasks If you have periodic tasks, you can tell it to Yokadi with `t_recurs`: yokadi> t_recurs 1 weekly monday 21:30 yokadi> t_recurs 1 monthly 3 11:00 yokadi> t_recurs 1 monthly last saturday 11:00 yokadi> t_recurs 1 yearly 23/2 14:00 Type `help t_recurs` to see all possible syntaxes. ## Tasks range and magic __ keyword `t_apply` is a very powerful function but sometimes you have to use it on numerous tasks. First, you can use task range like this: yokadi> t_apply 1-3 t_urgency 10 Executing: t_urgency 1 10 Executing: t_urgency 2 10 Executing: t_urgency 3 10 yokadi> But sometimes tasks are not consecutive and you would like to use wonderful `t_list` options to select your tasks. Here's the trick: each time you display tasks with `t_list`, Yokadi stores the id list in the magic keyword `__` that you can give to `t_apply` like this: yokadi> t_list @keyword myProject (...) yokadi> t_apply __ t_urgency 35 Oh, by the way, one Yokadi dev uses the following alias which is quite self explanatory: yokadi> a_list procrastinate => t_apply __ t_due +1d ## Mass editing tasks `t_medit` lets you edit all tasks of a project at once by opening a text editor with all the tasks and let you editing them, applying the changes when you quit. If you are familiar with `git`, this is similar to the way the `git rebase --interactive` command works. For example to edit all the tasks of the "birthday" project do the following: yokadi> t_medit birthday Make adjustments to the task list (the syntax is documented as comments in the text opened in the editor), then save the file and quit to apply the changes. Yokadi provides Vim syntax highlighting files to make mass edit more convenient. You can find them in `editors/vim`. To install them, run the following: cd place/to/editors/vim mkdir -p ~/.vim/ftdetect mkdir -p ~/.vim/syntax cp ftdetect/medit.vim ~/.vim/ftdetect cp syntax/medit.vim ~/.vim/syntax If you use another editor and can provide support for highlighting files, your contribution is very welcome! Get in touch so that we can add your work to the next version of Yokadi. # Integration ## Database location By default, Yokadi creates a database in `$HOME/.local/share/yokadi/yokadi.db`, 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: alias y=yokadi The single letter `y` will start Yokadi with your favorite database from wherever you are. ## History location By default, Yokadi will store input history in `$HOME/.cache/yokadi/history`. This file stores commands used in Yokadi for future use and reference. If you do now want to use the default history file location, you can define the `YOKADI_HISTORY` environment variable to point to your history file: export YOKADI_HISTORY=$HOME/.hist/yokadi_history ## Yokadid, the Yokadid daemon If you want to be automatically reminded of due tasks, you can use the Yokadi daemon. The Yokadi daemon can be launched via desktop autostart services. In most desktop environments, you just need to create a symbolic link to yokadid (or a shell script that calls it) in `$HOME/.config/autostart/`: ln -s `which yokadid` $HOME/.config/autostart/ # Contact The project is hosted on . All discussions happen on Yokadi mailing-list, hosted by our friends from the Sequanux LUG. To join, visit . You can also find some of us on #yokadi, on the Freenode IRC network. # Authors Yokadi has been brought to you by: - Aurélien Gâteau : Developer, founder - Sébastien Renard : Developer - Benjamin Port : Developer Other people contributed to Yokadi: - Olivier Hervieu : first working setup.py release - Marc-Antoine Gouillart : Windows port - Kartik Mistry : man pages - Jonas Christian Drewsen : quarterly recurrence feature yokadi-1.2.0/MANIFEST.in0000664000175000017500000000040013430006220016144 0ustar aurelienaurelien00000000000000include doc/*.md include doc/dev/*.md include man/*.1 include icon/* include scripts/* include editors/vim/*/*.vim include *py include README.md include LICENSE include MANIFEST.in include version include NEWS include *requirements.txt include .travis.yml yokadi-1.2.0/.travis.yml0000664000175000017500000000103313430006220016522 0ustar aurelienaurelien00000000000000language: python python: - "3.4" - "3.5" install: - pip install -r requirements.txt - pip install -r extra-requirements.txt - 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 notifications: email: false irc: "chat.freenode.net#yokadi" yokadi-1.2.0/yokadi/0000775000175000017500000000000013430006221015675 5ustar aurelienaurelien00000000000000yokadi-1.2.0/yokadi/createdemodb.py0000775000175000017500000000371713430006220020677 0ustar aurelienaurelien00000000000000#!/usr/bin/env python3 # -*- coding: UTF-8 -*- """ Command line oriented, sqlite powered, todo list @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import os import sys from argparse import ArgumentParser from yokadi.core import db from yokadi.core import dbutils from yokadi.core import ydateutils PROJECTS = ["birthday", "work", "home"] KEYWORDS = ["phone", "grocery", "_note"] def main(): parser = ArgumentParser() parser.add_argument('db', metavar='') args = parser.parse_args() dbname = args.db if os.path.exists(dbname): os.unlink(dbname) db.connectDatabase(dbname) db.setDefaultConfig() session = db.getSession() for name in PROJECTS: session.add(db.Project(name=name)) for name in KEYWORDS: dbutils.getOrCreateKeyword(name, interactive=False) dbutils.addTask("birthday", "Buy food", {"grocery": None}) dbutils.addTask("birthday", "Buy drinks", {"grocery": None}) dbutils.addTask("birthday", "Invite Bob", {"phone": None}) dbutils.addTask("birthday", "Invite Wendy", {"phone": None}) dbutils.addTask("birthday", "Bake a yummy cake") dbutils.addTask("birthday", "Decorate living-room") task = dbutils.addTask("home", "Fix leak in the roof") task.dueDate = ydateutils.parseHumaneDateTime("-2d") dbutils.addTask("home", "Buy AAA batteries for kid toys", {"grocery": None}) task = dbutils.addTask("home", "Bring the car to the garage") task.dueDate = ydateutils.parseHumaneDateTime("-1d") task.status = "done" task = dbutils.addTask("work", "Finish weekly report") task.dueDate = ydateutils.parseHumaneDateTime("+4d") task.description = """Include results from Acme department: http://acme.intranet/results. Don't forget to CC boss@acme.intranet. """ session.commit() return 0 if __name__ == "__main__": sys.exit(main()) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/0000775000175000017500000000000013430006221016635 5ustar aurelienaurelien00000000000000yokadi-1.2.0/yokadi/ycli/plainlistrenderer.py0000664000175000017500000000150713430006220022737 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Simple rendering of t_list output @author: Aurélien Gâteau @license: GPL v3 or later """ class PlainListRenderer(object): def __init__(self, out): self.out = out self.first = True def addTaskList(self, sectionName, taskList): """Store tasks for this section @param sectionName: name of the task groupement section @type sectionName: unicode @param taskList: list of tasks to display @type taskList: list of db.Task instances """ if not self.first: print(file=self.out) else: self.first = False print(sectionName, file=self.out) for task in taskList: print(("- " + task.title), file=self.out) def end(self): pass # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/main.py0000775000175000017500000002042313430006220020136 0ustar aurelienaurelien00000000000000#!/usr/bin/env python3 # -*- coding: UTF-8 -*- """ Command line oriented, sqlite powered, todo list @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import locale import os import sys try: import readline except ImportError: print("You don't have a working readline library.") print("Windows users must install Pyreadline.") print("Get it on https://launchpad.net/pyreadline/+download") print("Or use 'pip install pyreadline'") sys.exit(1) readline.parse_and_bind("set show-all-if-ambiguous on") import traceback from cmd import Cmd from argparse import ArgumentParser try: import sqlalchemy except ImportError: print("You must install SQL Alchemy to use Yokadi") print("Get it on http://www.sqlalchemy.org/") print("Or use 'pip install sqlalchemy'") sys.exit(1) import yokadi from yokadi.core import db from yokadi.core import basepaths 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 from yokadi.ycli.projectcmd import ProjectCmd from yokadi.ycli.taskcmd import TaskCmd from yokadi.core.yokadiexception import YokadiException, BadUsageException from yokadi.core.yokadioptionparser import YokadiOptionParserNormalExitException # TODO: move YokadiCmd to a separate module in ycli package class YokadiCmd(TaskCmd, ProjectCmd, KeywordCmd, ConfCmd, AliasCmd, Cmd): def __init__(self): Cmd.__init__(self) TaskCmd.__init__(self) ProjectCmd.__init__(self) KeywordCmd.__init__(self) AliasCmd.__init__(self) ConfCmd.__init__(self) self.prompt = "yokadi> " self.historyPath = basepaths.getHistoryPath() self.loadHistory() def emptyline(self): """Executed when input is empty. Reimplemented to do nothing.""" return def default(self, line): nline = resolveAlias(line, self.aliases) if nline != line: return self.onecmd(nline) elif nline.isdigit(): self.do_t_show(nline) elif nline == "_": self.do_t_show(nline) else: raise YokadiException("Unknown command. Use 'help' to see all available commands") def completedefault(self, text, line, begidx, endidx): """Default completion command. Try to see if command is an alias and find the appropriate complete function if it exists""" nline = resolveAlias(line, self.aliases) compfunc = getattr(self, 'complete_' + nline.split()[0]) matches = compfunc(text, line, begidx, endidx) return matches def do_EOF(self, line): """Quit.""" print() return True # Some standard alias do_quit = do_EOF do_q = do_EOF do_exit = do_EOF def onecmd(self, line): """This method is subclassed just to be able to encapsulate it with a try/except bloc""" try: # Decode user input line = line return Cmd.onecmd(self, line) except YokadiOptionParserNormalExitException: pass except UnicodeDecodeError as e: tui.error("Unicode decoding error. Please check you locale and terminal settings (%s)." % e) except UnicodeEncodeError as e: tui.error("Unicode encoding error. Please check you locale and terminal settings (%s)." % e) except BadUsageException as e: tui.error("*** Bad usage ***\n\t%s" % e) cmd = line.split(' ')[0] self.do_help(cmd) except YokadiException as e: tui.error("*** Yokadi error ***\n\t%s" % e) except IOError as e: # We can get I/O errors when yokadi is piped onto another shell commands # that breaks. print("*** I/O error ***\n\t%s" % e, file=sys.stderr) except KeyboardInterrupt: print("*** Break ***") 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.") cut = "---------------------8<----------------------------------------------" print(cut) traceback.print_exc() print("--") print("Python: %s" % sys.version.replace("\n", " ")) print("SQL Alchemy: %s" % sqlalchemy.__version__) print("OS: %s (%s)" % os.uname()[0:3:2]) print("Yokadi: %s" % yokadi.__version__) print(cut) print() def loadHistory(self): """Tries to load previous history list from disk""" try: readline.read_history_file(self.historyPath) except Exception: # Cannot load any previous history. Start from a clear one pass def writeHistory(self): """Writes shell history to disk""" try: fileutils.createParentDirs(self.historyPath) # Open r/w and close file to create one if needed historyFile = open(self.historyPath, "w", encoding='utf-8') historyFile.close() readline.set_history_length(1000) readline.write_history_file(self.historyPath) except Exception as e: tui.warning("Fail to save history to %s. Error was:\n\t%s" % (self.historyPath, e)) def do_help(self, arg): """Type help to see the help for a given topic""" """ Overload do_help to show help from the command parser if it exists: if there is a parser_foo() method, assume this method returns a YokadiOptionParser for the do_foo() method and show the help of the parser, instead of do_foo() docstring. """ if arg in self.aliases: # If arg is an alias, get help on the underlying command arg = self.aliases[arg].split()[0] if hasattr(self, "parser_" + arg): parserMethod = getattr(self, "parser_" + arg) parserMethod().print_help(sys.stderr) else: print("Usage: ", end=' ') Cmd.do_help(self, arg) def completenames(self, text, *ignored): """Complete commands names. Same as Cmd.cmd one but with support for command aliases. Code kindly borrowed to Pysql""" dotext = 'do_' + text names = [a[3:] for a in self.get_names() if a.startswith(dotext)] names.extend([a for a in list(self.aliases.keys()) if a.startswith(text)]) return names def createArgumentParser(): parser = ArgumentParser() commonargs.addArgs(parser) parser.add_argument("-c", "--create-only", 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('cmd', nargs='*') return parser 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(dbPath) except basepaths.MigrationException as exc: print(exc) return 1 if args.update: return update.update(dbPath) try: db.connectDatabase(dbPath) except db.DbUserException as exc: print(exc) return 1 if args.createOnly: return 0 db.setDefaultConfig() # Set default config parameters cmd = YokadiCmd() try: if len(args.cmd) > 0: print(" ".join(args.cmd)) cmd.onecmd(" ".join(args.cmd)) else: cmd.cmdloop() except KeyboardInterrupt: print("\n\tBreak ! (the nice way to quit is 'quit' or 'EOF' (ctrl-d)") return 1 # Save history cmd.writeHistory() return 0 if __name__ == "__main__": sys.exit(main()) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/textlistrenderer.py0000664000175000017500000002142613430006220022622 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Text rendering of t_list output @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ from datetime import datetime, timedelta import yokadi.ycli.colors as C from yokadi.core import ydateutils from yokadi.ycli import tui VLINE = "│" HLINE = "─" CROSS = "┼" LINE_COLOR = C.CYAN 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 reverse: If false low value means important and vice versa @return: a color code or None for no color""" if reverse: value = 100 - value if value > 75: return C.RED elif value > 50: return C.PURPLE elif value > 25: return C.ORANGE else: return None class Column(object): __slots__ = ["title", "width", "formater"] def __init__(self, title, width, formater): """ formater is a callable which accepts a task and returns a tuple of the form (string, color) color may be None if no color should be applied """ self.title = title self.width = width self.formater = formater def createHeader(self): return self.title.ljust(self.width) def createCell(self, task): value, color = self.formater(task) if color: cell = color + value.ljust(self.width) + C.RESET else: cell = value.ljust(self.width) return cell def idFormater(task): return str(task.id), None class TitleFormater(object): def __init__(self, width): self.width = width def __call__(self, task): colorizer = tui.TextColorizer() keywords = task.getUserKeywordsNameAsString() hasDescription = task.description is not None and task.description != "" maxWidth = self.width if hasDescription: maxWidth -= 1 # Create title title = task.title if keywords and len(title) < maxWidth: title += ' (' colorizer.setColorAt(len(title), C.BOLD) title += keywords colorizer.setResetAt(len(title)) title += ')' # Crop title to fit in self.width titleWidth = len(title) if titleWidth > maxWidth: title = title[:maxWidth - 1] + ">" colorizer.crop(maxWidth - 1) colorizer.setResetAt(maxWidth - 1) else: title = title.ljust(maxWidth) if hasDescription: title = title + "*" title = colorizer.render(title) return title, None def urgencyFormater(task): return str(task.urgency), colorizer(task.urgency) def statusFormater(task): if task.status == "started": color = C.BOLD else: color = None return task.status[0].upper(), color class AgeFormater(object): def __init__(self, today, asDate=False): self.today = today self.asDate = asDate def __call__(self, task): delta = self.today - task.creationDate.replace(microsecond=0) if self.asDate: return task.creationDate.strftime("%x %H:%M"), None else: return ydateutils.formatTimeDelta(delta), colorizer(delta.days) class DueDateFormater(object): def __init__(self, today, shortFormat): self.today = today self.shortFormat = shortFormat def __call__(self, task): if not task.dueDate: return "", None delta = task.dueDate - self.today if delta.days != 0: value = task.dueDate.strftime("%x %H:%M") else: value = task.dueDate.strftime("%H:%M") if self.shortFormat: value = ydateutils.formatTimeDelta(delta) else: value += " (%s)" % ydateutils.formatTimeDelta(delta) color = colorizer(delta.days * 33, reverse=True) return value, color class TextListRenderer(object): def __init__(self, out, termWidth=None, renderAsNotes=False, splitOnDate=False): """ @param out: output target @param termWidth: terminal width (int) @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() self.taskLists = [] self.maxTitleWidth = len("Title") self.today = datetime.today().replace(microsecond=0) self.firstHeader = True self.splitOnDate = splitOnDate if self.termWidth < 100: dueColumnWidth = 8 shortDateFormat = True else: dueColumnWidth = 26 shortDateFormat = False if renderAsNotes: self.splitOnDate = True creationDateColumnWidth = 16 creationDateTitle = "Creation date" else: creationDateColumnWidth = 8 creationDateTitle = "Age" # 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)), ] 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 @type sectionName: unicode @param taskList: list of tasks to display @type taskList: list of db.Task instances """ self.taskLists.append((sectionName, taskList)) # Find max title width for task in taskList: title = task.title keywords = task.getUserKeywordsNameAsString() if keywords: title = "{} ({})".format(title, keywords) titleWidth = len(title) 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 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) # Print table for sectionName, taskList in self.taskLists: dateSplitters = [(1, "day"), (7, "week"), (30, "month"), (30 * 4, "quarter"), (365, "year")] splitterRange, splitterName = dateSplitters.pop() splitterText = None self._renderTaskListHeader(sectionName) for task in taskList: while self.splitOnDate and task.creationDate > today - timedelta(splitterRange): splitterText = "Last %s" % splitterName if len(dateSplitters) > 0: splitterRange, splitterName = dateSplitters.pop() else: self.splitOnDate = False if splitterText: print(C.GREEN + splitterText.center(totalWidth) + C.RESET, file=self.out) splitterText = None self._renderTaskListRow(task) def _renderTaskListHeader(self, sectionName): """ @param sectionName: name used for list header @type sectionName: unicode""" cells = [x.createHeader() for x in self.columns] width = sum([len(x) for x in cells]) + len(cells) - 1 if self.firstHeader: self.firstHeader = False else: print(file=self.out) # section name print(C.CYAN + sectionName.center(width) + C.RESET, file=self.out) # header titles line = (LINE_COLOR + VLINE + C.RESET).join(cells) print(line, file=self.out) # header separator line cells = [HLINE * len(x) for x in cells] print(LINE_COLOR + CROSS.join(cells) + C.RESET, file=self.out) def _renderTaskListRow(self, task): cells = [column.createCell(task) for column in self.columns] sep = LINE_COLOR + VLINE + C.RESET print(sep.join(cells), file=self.out) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/parseutils.py0000664000175000017500000000725313430006220021410 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Parse utilities. Used to manipulate command line text. @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ from yokadi.ycli import tui from yokadi.ycli.basicparseutils import simplifySpaces from yokadi.core import db from yokadi.core.db import Keyword from yokadi.core.dbutils import KeywordFilter from yokadi.core.yokadiexception import YokadiException def parseLine(line): """Parse line of form: project some text @keyword1 @keyword2=12 some other text @return: a tuple of ("project", "some text some other text", keywordDict""" # First extract project name line = simplifySpaces(line) if line.count(" "): project, line = line.split(" ", 1) else: project = line line = "" line, keywordFilters = extractKeywords(line) return project, line, keywordFiltersToDict(keywordFilters) def extractKeywords(line): """Extract keywords (@k1 @k2=n..) from line @param line: line from which keywords are extracted @returns: (remaining_text, keywordFilters)""" keywordFilters = [] remainingText = [] for token in line.split(): if token.startswith("@") or token.startswith("!@"): keywordFilters.append(parseKeyword(token)) else: remainingText.append(token) return (" ".join(remainingText), keywordFilters) def createLine(projectName, title, keywordDict): tokens = [] for keywordName, value in list(keywordDict.items()): if value: tokens.append("@" + keywordName + "=" + str(value)) else: tokens.append("@" + keywordName) if projectName: tokens.insert(0, projectName) tokens.append(title) return " ".join(tokens) def keywordFiltersToDict(keywordFilters): """Convert a list of KeywordFilter instnance to a simple keyword dictionary""" keywordDict = {} for keywordFilter in keywordFilters: keywordDict[keywordFilter.name] = keywordFilter.value return keywordDict def warnIfKeywordDoesNotExist(keywordFilters): """Warn user is keyword does not exist @return: True if at least one keyword does not exist, else False""" 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 return doesNotExist def parseKeyword(line): """Parse given line to create a keyword filter @return: a KeywordFilter instance""" operators = ("!=", "=") if " " in line: raise YokadiException("Keyword filter should not contain spaces") name = None negative = False value = None valueOperator = None if line.startswith("!"): negative = True line = line[1:] if not line.startswith("@"): raise YokadiException("Keyword name must be prefixed with a @") line = line[1:] # Squash @ line = line.replace("==", "=") # Tolerate == syntax for operator in operators: if operator in line: name, value = line.split(operator, 1) valueOperator = operator try: value = int(value) except ValueError: raise YokadiException("Value of %s keyword must be an integer (got %s)" % (name, value)) break else: # No operator found, only keyword name has been provided name = line return KeywordFilter(name, negative=negative, value=value, valueOperator=valueOperator) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/tui.py0000664000175000017500000002072313430006220020013 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Helper functions to build CLI applications @author: Aurélien Gâteau @license: GPL v3 or later """ import os import readline import subprocess import sys import tempfile import time import unicodedata import re import locale import shutil from collections import namedtuple from getpass import getpass 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 # Number of seconds between checks for file modification MTIME_POLL_INTERVAL = 10 # Filter out bad characters for filenames NON_SIMPLE_ASCII = re.compile("[^a-zA-Z0-9]+") MULTIPLE_DASH = re.compile("-+") _answers = [] class IOStream: def __init__(self, original_flow): self.__original_flow = original_flow if sys.platform == 'win32': import pyreadline self.__console = pyreadline.GetOutputFile() def write(self, text): if sys.platform == 'win32': self.__console.write_color(text) 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 @param lockManager: function parameter that is called to 'acquire', 'update' or 'release' an editing lock @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()) def waitProcess(proc): start = time.time() while (time.time() - start) < MTIME_POLL_INTERVAL: proc.poll() 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) (fd, name) = tempfile.mkstemp(suffix=suffix, prefix=prefix) if text is None: text = "" try: if lockManager: lockManager.acquire() fl = open(name, "w", encoding=encoding) fl.write(text) fl.close() editor = os.environ.get("EDITOR", "vi") proc = subprocess.Popen([editor, name]) mtime = os.stat(name).st_mtime while proc.returncode is None: waitProcess(proc) if proc.returncode is None and lockManager is not None: lockManager.update() if proc.returncode is None and onChanged is not None: newMtime = os.stat(name).st_mtime if newMtime > mtime: mtime = newMtime onChanged(readFile(name)) if proc.returncode != 0: raise Exception("The command {} failed. It exited with code {}.".format(proc.args, proc.returncode)) return readFile(name) finally: os.close(fd) os.unlink(name) if lockManager: lockManager.release() def reinjectInRawInput(line): """Next call to input() will have line set as default text @param line: The default text """ assert isinstance(line, str) # Set readline.pre_input_hook to feed it with our line # (Code copied from yagtd) def pre_input_hook(): readline.insert_text(line) readline.redisplay() # Unset the hook again readline.set_pre_input_hook(None) if sys.platform != 'win32': readline.set_pre_input_hook(pre_input_hook) def editLine(line, prompt="edit> ", echo=True): """Edit a line using readline @param prompt: change prompt @param echo: whether to echo user text or not""" _checkIsInteractive() if line: reinjectInRawInput(line) if len(_answers) > 0: line = _answers.pop(0) else: try: if echo: line = input(prompt) else: line = getpass(prompt) except EOFError: line = "" # Remove edited line from history: # oddly, get_history_item is 1-based, # but remove_history_item is 0-based if sys.platform != 'win32': length = readline.get_current_history_length() if length > 0: readline.remove_history_item(length - 1) return line def selectFromList(lst, default=None, prompt="Select", valueForString=int): """ Takes a list of tuples (value, caption), returns the value of the selected entry. @param default indicates the default value and may be None @param prompt customize the prompt @param valueForString a function to turn a string into a valid value """ if default is not None: default = str(default) possibleValues = {x[0] for x in lst} for value, caption in lst: print("{}: {}".format(value, caption)) while True: line = editLine(default, prompt=prompt + ": ") try: value = valueForString(line) except Exception: error("Wrong value") continue if value in possibleValues: return value else: error("Wrong value") def enterInt(default=None, prompt="Enter a number"): if default is None: line = "" else: line = str(default) while True: answer = editLine(line, prompt=prompt + ": ") if answer == "": return None try: value = int(answer) return value except ValueError: error("Wrong value") def confirm(prompt): if not isInteractive: return True while True: answer = editLine("", prompt=prompt + " (y/n)? ") answer = answer.lower() if answer == "y": return True elif answer == "n": return False else: error("Wrong value") def renderFields(fields): """Print on screen tabular array represented by fields @param fields: list of tuple (caption, value) """ maxWidth = max([len(x) for x, y in fields]) format = colors.BOLD + "%" + str(maxWidth) + "s" + colors.RESET + ": %s" for caption, value in fields: print(format % (caption, value), file=stdout) def warnDeprecated(old, new): """Warn user that a command is now deprecated and incitate him to use the new one @param old: the old one (str) @param new: the new one (str)""" warning("Command '%s' is deprecated, use '%s' instead" % (old, new)) info("Command %s has been executed" % new) def error(message): print(colors.BOLD + colors.RED + "Error: %s" % message + colors.RESET, file=stderr) def warning(message): print(colors.RED + "Warning: " + colors.RESET + message, file=stderr) def info(message): print(colors.CYAN + "Info: " + colors.RESET + message, file=stderr) def addInputAnswers(*answers): """Add answers to tui internal answer buffer. Next call to editLine() will pop the first answer from the buffer instead of prompting the user. This is useful for unit-testing.""" _answers.extend(answers) def clearInputAnswers(): """Remove all added answers. Useful to avoid making a test depend on a "y" added by another test. """ global _answers _answers = [] def getTermWidth(): """Gets the terminal width""" size = shutil.get_terminal_size() return size.columns ColorBlock = namedtuple("ColorBlock", ("pos", "color")) class TextColorizer: def __init__(self): self._dct = {} def setColorAt(self, pos, color): self._dct[pos] = color def setResetAt(self, pos): self._dct[pos] = colors.RESET def crop(self, width): self._dct = {pos: color for pos, color in self._dct.items() if pos < width} def render(self, text): """ Apply color blocks to text """ start = 0 out = [] blockList = sorted(self._dct.items()) for pos, color in blockList: out.append(text[start:pos] + color) start = pos # Add remaining text, if any out.append(text[start:]) return "".join(out) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/keywordcmd.py0000664000175000017500000001026213430006220021357 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Keyword related commands. @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ from sqlalchemy.exc import IntegrityError from yokadi.core import dbutils from yokadi.ycli import tui from yokadi.core import db from yokadi.core.db import Keyword from yokadi.core.yokadiexception import BadUsageException 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 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 k_add @ [@...]""" session = db.getSession() if not line: raise BadUsageException("You must provide at least one keyword name") for keyword in line.split(): try: session.add(Keyword(name=keyword)) session.commit() print("Keyword %s has been created" % keyword) except IntegrityError: session.rollback() print("Keyword %s already exist" % keyword) def do_k_remove(self, line): """Remove a keyword k_remove @""" session = db.getSession() keyword = dbutils.getKeywordFromName(line) if keyword.tasks: 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) def do_k_edit(self, line): """Edit a keyword k_edit @""" session = db.getSession() keyword = dbutils.getKeywordFromName(line) oldName = keyword.name newName = tui.editLine(oldName) if newName == "": print("Cancelled") return lst = session.query(Keyword).filter_by(name=newName).all() if len(lst) == 0: # Simple case: newName does not exist, just rename the existing keyword keyword.name = newName session.merge(keyword) session.commit() print("Keyword %s has been renamed to %s" % (oldName, newName)) return # We already have a keyword with this name, we need to merge print("Keyword %s already exists" % newName) if not tui.confirm("Do you want to merge %s and %s" % (oldName, newName)): return # Check we can merge conflictingTasks = [] for task in keyword.tasks: kwDict = task.getKeywordDict() if oldName in kwDict and newName in kwDict and kwDict[oldName] != kwDict[newName]: conflictingTasks.append(task) 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)) for task in conflictingTasks: print("- %d, %s" % (task.id, task.title)) print("Edit these tasks and try again") return # Merge for task in keyword.tasks: kwDict = task.getKeywordDict() if newName not in kwDict: kwDict[newName] = kwDict[oldName] del kwDict[oldName] task.setKeywordDict(kwDict) session.delete(keyword) session.commit() print("Keyword %s has been merged with %s" % (oldName, newName)) complete_k_edit = KeywordCompleter(1) yokadi-1.2.0/yokadi/ycli/csvlistrenderer.py0000664000175000017500000000157513430006220022434 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Csv rendering of t_list output @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import csv TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "description", "urgency", "status", "project", "keywords"] class CsvListRenderer(object): def __init__(self, out): self.writer = csv.writer(out, dialect="excel") self._writerow(TASK_FIELDS) # Header def addTaskList(self, project, taskList): for task in taskList: row = [getattr(task, field) for field in TASK_FIELDS if field != "keywords"] row.append(task.getKeywordsAsString()) self._writerow(row) def end(self): pass def _writerow(self, row): self.writer.writerow([str(x) for x in row]) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/xmllistrenderer.py0000664000175000017500000000440613430006220022435 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Xml rendering of t_list output @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ from xml.dom import minidom as dom TASK_FIELDS = ["title", "creationDate", "dueDate", "doneDate", "description", "urgency", "status", "keywords"] class XmlListRenderer(object): def __init__(self, out): self.out = out self.doc = dom.Document() self.rootElement = self.doc.createElement("yokadi") self.doc.appendChild(self.rootElement) def addTaskList(self, sectionName, taskList): """Store tasks for this section @param sectionName: name of the task groupement section @type sectionName: unicode @param taskList: list of tasks to display @type taskList: list of db.Task instances """ sectionElement = self.doc.createElement("section") self.rootElement.appendChild(sectionElement) sectionElement.setAttribute("name", sectionName) for task in taskList: taskElement = self.doc.createElement("task") sectionElement.appendChild(taskElement) taskElement.setAttribute("id", str(task.id)) for field in TASK_FIELDS: if field == "keywords": self._exportKeywords(taskElement, task.getKeywordDict()) elif field == "description": if task.description: descriptionElement = self.doc.createElement("description") taskElement.appendChild(descriptionElement) descriptionElement.appendChild(self.doc.createTextNode(task.description)) else: taskElement.setAttribute(field, str(task.__getattribute__(field))) def _exportKeywords(self, taskElement, keywordDict): for key, value in list(keywordDict.items()): keywordElement = self.doc.createElement("keyword") taskElement.appendChild(keywordElement) keywordElement.setAttribute("name", str(key)) if value: keywordElement.setAttribute("value", str(value)) def end(self): self.out.write(self.doc.toprettyxml(indent=" ")) # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/basicparseutils.py0000664000175000017500000000213613430006220022405 0ustar aurelienaurelien00000000000000""" Parse utilities which do not need the db module. @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import re from yokadi.core.yokadiexception import YokadiException gSimplifySpaces = re.compile(" +") def simplifySpaces(line): line = gSimplifySpaces.subn(" ", line)[0] line = line.strip() return line def parseParameters(line): """Parse line of form -a -b -c some text @return: ((a, b, c), some text) """ parameters = [] text = [] line = simplifySpaces(line) for word in line.split(): if word.startswith("-") and len(word) == 2: parameters.append(word[1]) else: text.append(word) return (parameters, " ".join(text)) def parseOneWordName(line): """Parse line, check it is a one word project name and return it @return: the name """ line = line.strip() if " " in line: raise YokadiException("Name cannot contain spaces") if not line: raise YokadiException("Name cannot be empty") return line yokadi-1.2.0/yokadi/ycli/confcmd.py0000664000175000017500000000547513430006220020632 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Configuration management related commands. @author: Sébastien Renard @license: GPL v3 or later """ from yokadi.core import db from yokadi.core.db import Config from yokadi.core.yokadiexception import YokadiException, BadUsageException from yokadi.ycli.completers import confCompleter from yokadi.ycli import tui from yokadi.core.yokadioptionparser import YokadiOptionParser class ConfCmd(object): def parser_c_get(self): 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") parser.add_argument("key", nargs='?') return parser def do_c_get(self, line): parser = self.parser_c_get() args = parser.parse_args(line) key = args.key if not key: key = "%" session = db.getSession() k = session.query(Config).filter(Config.name.like(key)).filter_by(system=args.system).all() fields = [(x.name, "%s (%s)" % (x.value, x.desc)) for x in k] if fields: tui.renderFields(fields) else: raise YokadiException("Configuration key %s does not exist" % line) complete_c_get = confCompleter def do_c_set(self, line): """Set a configuration key to value : c_set """ line = line.split() if len(line) < 2: raise BadUsageException("You should provide two arguments : the parameter key and the value") name = line[0] value = " ".join(line[1:]) session = db.getSession() p = session.query(Config).filter_by(name=name, system=False) if p.count() == 0: raise YokadiException("Sorry, no parameter match") else: if self.checkParameterValue(name, value): p[0].value = value tui.info("Parameter updated") else: raise YokadiException("Parameter value is incorrect") complete_c_set = confCompleter def checkParameterValue(self, name, value): """Control that the value if ok for a parameter @param key: parameter name @param value: parameter value @return: True if parameter is ok, else False""" # Positive int parameters if name in ("ALARM_DELAY", "ALARM_SUSPEND", "PURGE_DELAY"): try: value = int(value) assert(value >= 0) return True except (ValueError, AssertionError): return False else: # No check for this parameter, so tell everything is fine return True yokadi-1.2.0/yokadi/ycli/projectcmd.py0000664000175000017500000001262313430006220021344 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Project related commands. @author: Aurélien Gâteau @license: GPL v3 or later """ from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.exc import IntegrityError from yokadi.ycli import tui 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 from yokadi.core.yokadioptionparser import YokadiOptionParser from yokadi.core import dbutils def getProjectFromName(name, parameterName="project_name"): """ Helper function which returns a project given its name, or raise a YokadiException if it does not exist. """ name = name.strip() if len(name) == 0: raise BadUsageException("Missing <%s> parameter" % parameterName) try: session = db.getSession() return session.query(Project).filter_by(name=name).one() except NoResultFound: raise YokadiException("Project '%s' not found. Use p_list to see all projects." % name) class ProjectCmd(object): def do_p_add(self, line): """Add new project. p_add """ if not line: print("Missing project name.") return projectName = parseOneWordName(line) session = db.getSession() try: project = Project(name=projectName) session.add(project) session.commit() except IntegrityError: session.rollback() raise YokadiException("A project named %s already exists. Please find another name" % projectName) print("Added project '%s'" % projectName) def do_p_edit(self, line): """Edit a project. p_edit """ session = db.getSession() project = dbutils.getOrCreateProject(line, createIfNeeded=False) if not project: raise YokadiException("Project does not exist.") # Edit line = tui.editLine(project.name) # Update project projectName = parseOneWordName(line) try: project.name = projectName session.commit() except IntegrityError: session.rollback() raise YokadiException("A project named %s already exists. Please find another name" % projectName) complete_p_edit = ProjectCompleter(1) def do_p_list(self, line): """List all projects.""" session = db.getSession() for project in session.query(Project).all(): if project.active: active = "" else: active = "(inactive)" 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""" session = db.getSession() project = getProjectFromName(line) project.active = True session.merge(project) session.commit() complete_p_set_active = ProjectCompleter(1) def do_p_set_inactive(self, line): """Desactivate the given project""" session = db.getSession() project = getProjectFromName(line) project.active = False session.merge(project) session.commit() complete_p_set_inactive = ProjectCompleter(1) def parser_p_remove(self): parser = YokadiOptionParser() 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") parser.add_argument("project") return parser def do_p_remove(self, line): session = db.getSession() parser = self.parser_p_remove() args = parser.parse_args(line) project = getProjectFromName(args.project) nbTasks = len(project.tasks) if not args.force: if not tui.confirm("Remove project '%s' and its %d tasks" % (project.name, nbTasks)): return session.delete(project) session.commit() 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 yokadi-1.2.0/yokadi/ycli/aliascmd.py0000664000175000017500000000647513430006220020777 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Alias related commands. @author: Sébastien Renard @license: GPL v3 or later """ from yokadi.core import db from yokadi.core.yokadiexception import BadUsageException, YokadiException from yokadi.ycli.basicparseutils import parseOneWordName from yokadi.ycli import tui from yokadi.ycli import colors class AliasCmd(object): def __init__(self): self._updateAliasDict() def _updateAliasDict(self): self.aliases = db.Alias.getAsDict(db.getSession()) def do_a_list(self, line): """List all aliases.""" if self.aliases: lst = sorted(self.aliases.items(), key=lambda x: x[0]) for name, command in lst: print(colors.BOLD + name.ljust(10) + colors.RESET + "=> " + command) else: print("No alias defined. Use a_add to create one") def do_a_add(self, line): """Add an alias on a command Ex. create an alias 'la' for 't_list -a': a_add la t_list -a""" tokens = line.split() if len(tokens) < 2: raise BadUsageException("You should provide an alias name and a command") name = tokens[0] if name in self.aliases: raise YokadiException("There is already an alias named {}.".format(name)) command = " ".join(tokens[1:]) session = db.getSession() db.Alias.add(session, name, command) session.commit() self._updateAliasDict() def do_a_edit_name(self, line): """Edit the name of an alias. a_edit_name """ session = db.getSession() name = line if name not in self.aliases: raise YokadiException("There is no alias named {}".format(name)) newName = tui.editLine(name) newName = parseOneWordName(newName) if newName in self.aliases: raise YokadiException("There is already an alias named {}.".format(newName)) session = db.getSession() db.Alias.rename(session, name, newName) session.commit() self._updateAliasDict() def do_a_edit_command(self, line): """Edit the command of an alias. a_edit_command """ session = db.getSession() name = line if name not in self.aliases: raise YokadiException("There is no alias named {}".format(name)) command = tui.editLine(self.aliases[name]) session = db.getSession() db.Alias.setCommand(session, name, command) session.commit() self._updateAliasDict() def do_a_remove(self, line): """Remove an alias""" if line in self.aliases: session = db.getSession() del self.aliases[line] alias = session.query(db.Alias).filter_by(name=line).one() session.delete(alias) session.commit() else: tui.error("No alias with that name. Use a_list to display all aliases") def resolveAlias(line, aliasDict): """Look for alias in alias and replace it with rela command @param line : string to analyse @param aliasDict: aliases dictionnary @return: modified line""" tokens = line.split() if len(tokens) > 0 and tokens[0] in aliasDict: line = "%s %s" % (aliasDict[tokens[0]], " ".join(tokens[1:])) return line yokadi-1.2.0/yokadi/ycli/commonargs.py0000664000175000017500000000423213430006220021354 0ustar aurelienaurelien00000000000000""" 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 yokadi-1.2.0/yokadi/ycli/htmllistrenderer.py0000664000175000017500000000501713430006220022600 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ HTML rendering of t_list output @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import xml.sax.saxutils as saxutils from collections import namedtuple TaskField = namedtuple("TaskField", ("title", "format")) HTML_HEADER = """ Yokadi tasks export """ HTML_FOOTER = "" def escape(text): return saxutils.escape(str(text)) def printRow(out, tag, lst): print("", file=out) for value in lst: 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): self.out = out print(HTML_HEADER, file=self.out) def addTaskList(self, sectionName, taskList): """Store tasks for this section @param sectionName: name of the task groupement section @type sectionName: unicode @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), file=self.out) print("", file=self.out) printRow(self.out, "th", [x.title for x in TASK_FIELDS]) for task in taskList: lst = [x.format(task) for x in TASK_FIELDS] printRow(self.out, "td", lst) print("
", file=self.out) def end(self): 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 yokadi-1.2.0/yokadi/ycli/__init__.py0000664000175000017500000000031213430006220020741 0ustar aurelienaurelien00000000000000# -*- coding: utf-8 -*- """ Yokadi command line interface package @author: Aurélien Gâteau @author: Sébastien Renard @license:GPL v3 or later """ yokadi-1.2.0/yokadi/ycli/completers.py0000664000175000017500000001077113430006220021371 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Implementation of completers for various Yokadi objects. @author: Aurélien Gâteau @license: GPL v3 or later """ 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 from yokadi.core.recurrencerule import FREQUENCIES def computeCompleteParameterPosition(text, line, begidx, endidx): before = simplifySpaces(line[:begidx].strip()) return before.count(" ") + 1 def getItemPropertiesStartingWith(item, field, text): """Return a list of item.field starting with text @param item: the object item, example : Task, Project, Keyword... @param field: the item's field lookup : Project.q.name, Task.q.title, Keyword.q.name. Don't forget the magic q @param text: The begining of the text as a str @return: list of matching strings""" session = db.getSession() return [x.name for x in session.query(item).filter(field.like(str(text) + "%"))] class ProjectCompleter(object): def __init__(self, position): self.position = position def __call__(self, text, line, begidx, endidx): if computeCompleteParameterPosition(text, line, begidx, endidx) == self.position: return ["%s " % x for x in getItemPropertiesStartingWith(Project, Project.name, text)] else: return [] class KeywordCompleter(object): def __init__(self, position): self.position = position def __call__(self, text, line, begidx, endidx): if computeCompleteParameterPosition(text, line, begidx, endidx) == self.position: return getItemPropertiesStartingWith(Keyword, Keyword.name, text) else: 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(parseParameters(line)[0]) # remove arguments from position count position += shift # Apply argument shift 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)] def confCompleter(cmd, text, line, begidx, endidx): return getItemPropertiesStartingWith(Config, Config.name, text) def taskIdCompleter(cmd, text, line, begidx, endidx): # TODO: filter on parameter position # TODO: potential performance issue with lots of tasks, find a better way to do it session = db.getSession() tasks = [x for x in session.query(Task).filter(Task.status != 'done') if str(x.id).startswith(text)] print() for task in tasks: # Move that in a renderer class ? print("%s: %s / %s" % (task.id, task.project.name, task.title)) return [str(x.id) for x in tasks] def recurrenceCompleter(cmd, text, line, begidx, endidx): frequencies = [x.lower() for x in FREQUENCIES.values()] + ["none"] weekdays = [x.lower() for x in WEEKDAYS.keys()] position = computeCompleteParameterPosition(text, line, begidx, endidx) if position == 1: # Task id return taskIdCompleter(cmd, text, line, begidx, endidx) elif position == 2: # frequency return [x for x in frequencies if x.startswith(text.lower())] elif position == 3 and "weekly" in line.lower(): return [x for x in weekdays if x.startswith(text.lower())] def dueDateCompleter(cmd, text, line, begidx, endidx): position = computeCompleteParameterPosition(text, line, begidx, endidx) if position == 1: # Task id return taskIdCompleter(cmd, text, line, begidx, endidx) elif position == 2 and not text.startswith("+"): # week day return [str(x) for x in list(WEEKDAYS.keys()) if str(x).lower().startswith(text.lower())] # vi: ts=4 sw=4 et yokadi-1.2.0/yokadi/ycli/taskcmd.py0000664000175000017500000011575113430006220020646 0ustar aurelienaurelien00000000000000# -*- coding: UTF-8 -*- """ Task related commands. @author: Aurélien Gâteau @author: Sébastien Renard @license: GPL v3 or later """ import os import readline import re from datetime import datetime, timedelta from sqlalchemy import or_, desc from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from yokadi.core.db import Keyword, Project, Task, TaskKeyword, NOTE_KEYWORD from yokadi.core import bugutils from yokadi.core import dbutils from yokadi.core import db 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 from yokadi.core.dbutils import DbFilter, KeywordFilter from yokadi.core.yokadiexception import YokadiException, BadUsageException from yokadi.ycli.textlistrenderer import TextListRenderer from yokadi.ycli.xmllistrenderer import XmlListRenderer from yokadi.ycli.csvlistrenderer import CsvListRenderer from yokadi.ycli.htmllistrenderer import HtmlListRenderer from yokadi.ycli.plainlistrenderer import PlainListRenderer from yokadi.core.yokadioptionparser import YokadiOptionParser gRendererClassDict = dict( text=TextListRenderer, xml=XmlListRenderer, csv=CsvListRenderer, html=HtmlListRenderer, plain=PlainListRenderer, ) class TaskCmd(object): def __init__(self): self.lastTaskId = None # Last id created, used self.lastProjectName = None # Last project name used self.lastTaskIds = [] # Last list of ids selected with t_list self.kFilters = [] # Permanent keyword filters (List of KeywordFilter) self.pFilter = "" # Permanent project filter (name of project) self.session = db.getSession() for name in bugutils.PROPERTY_NAMES: dbutils.getOrCreateKeyword(name, interactive=False) dbutils.getOrCreateKeyword(NOTE_KEYWORD, interactive=False) self.session.commit() def _parser_t_add(self, cmd): """Code shared by t_add, bug_add and n_add parsers.""" 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("-d", "--describe", dest="describe", default=False, action="store_true", help="Directly open editor to describe task") parser.add_argument('cmd', nargs='*') return parser def _t_add(self, cmd, line): """Code shared by t_add, bug_add and n_add.""" parser = self._parser_t_add(cmd) args = parser.parse_args(line) line = " ".join(args.cmd) if not line: raise BadUsageException("Missing parameters") projectName, title, keywordDict = parseutils.parseLine(line) projectName = self._realProjectName(projectName) if not title: raise BadUsageException("Missing title") task = dbutils.addTask(projectName, title, keywordDict) if not task: tui.reinjectInRawInput("%s %s" % (cmd, line)) return None self.lastTaskId = task.id if args.describe: self.do_t_describe(self.lastTaskId) return task def do_t_add(self, line): """Add new task. Will prompt to create keywords if they do not exist. t_add <projectName> [@<keyword1>] [@<keyword2>] <title>""" task = self._t_add("t_add", line) if task: self.session.add(task) self.session.commit() print("Added task '%s' (id=%d)" % (task.title, task.id)) complete_t_add = projectAndKeywordCompleter def do_bug_add(self, line): """Add a bug-type task. Will create a task and ask additional info. bug_add <project_name> [@<keyword1>] [@<keyword2>] <title> """ task = self._t_add("bug_add", line) if not task: return keywordDict = task.getKeywordDict() bugutils.editBugKeywords(keywordDict) task.setKeywordDict(keywordDict) task.urgency = bugutils.computeUrgency(keywordDict) self.session.add(task) self.session.commit() print("Added bug '%s' (id=%d, urgency=%d)" % (task.title, task.id, task.urgency)) complete_bug_add = ProjectCompleter(1) def do_n_add(self, line): """Add a note. A note is a task with the @_note keyword. n_add <project_name> [@<keyword1>] [@<keyword2>] <title> """ task = self._t_add("n_add", line) if not task: return self.session.add(task) keywordDict = task.getKeywordDict() keywordDict[NOTE_KEYWORD] = None task.setKeywordDict(keywordDict) self.session.commit() print("Added note '%s' (id=%d)" % (task.title, task.id)) complete_n_add = projectAndKeywordCompleter def do_bug_edit(self, line): """Edit a bug. bug_edit <id>""" task = self._t_edit(line, keywordEditor=bugutils.editBugKeywords) if task: self.session.commit() complete_bug_edit = taskIdCompleter def getTaskFromId(self, tid): if tid == '_': if self.lastTaskId is None: raise YokadiException("No previous task defined") tid = self.lastTaskId task = dbutils.getTaskFromId(tid) if tid != '_': self.lastTaskId = task.id return task def do_t_describe(self, line): """Starts an editor to enter a longer description of a task. t_describe <id>""" def updateDescription(description): task.description = description task = self.getTaskFromId(line) try: description = tui.editText(task.description, onChanged=updateDescription, lockManager=dbutils.TaskLockManager(task), prefix="yokadi-%s-%s-" % (task.project, task.title)) except Exception as e: raise YokadiException(e) updateDescription(description) self.session.merge(task) self.session.commit() complete_t_describe = taskIdCompleter def do_t_set_urgency(self, line): """@deprecated: should be removed""" tui.warnDeprecated("t_set_urgency", "t_urgency") self.do_t_urgency(line) def do_t_urgency(self, line): """Defines urgency of a task. t_urgency <id> <value>""" tokens = parseutils.simplifySpaces(line).split(" ") if len(tokens) != 2: raise BadUsageException("You must provide a taskId and an urgency value") task = self.getTaskFromId(tokens[0]) try: # Do not use isdigit(), so that we can set negative urgency. This # make it possible to stick tasks to the bottom of the list. urgency = int(tokens[1]) except ValueError: raise BadUsageException("Task urgency must be a digit") if urgency > 100: tui.warning("Max urgency is 100") urgency = 100 elif urgency < -99: tui.warning("Min urgency is -99") urgency = -99 task.urgency = urgency self.session.merge(task) self.session.commit() complete_t_set_urgency = taskIdCompleter complete_t_urgency = taskIdCompleter def do_t_mark_started(self, line): """Mark task as started. t_mark_started <id>""" self._t_set_status(line, 'started') complete_t_mark_started = taskIdCompleter def do_t_mark_done(self, line): """Mark task as done. t_mark_done <id>""" self._t_set_status(line, 'done') complete_t_mark_done = taskIdCompleter def do_t_mark_new(self, line): """Mark task as new (not started). t_mark_new <id>""" self._t_set_status(line, 'new') complete_t_mark_new = taskIdCompleter def _t_set_status(self, line, status): task = self.getTaskFromId(line) task.setStatus(status) 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) else: print("Task '%s' marked as %s" % (task.title, status)) def do_t_apply(self, line): """Apply a command to several tasks. t_apply <id1>[,<id2>,[<id3>]...]] <command> <args> Use x-y to select task range from x to y Use __ to select all tasks previously selected with t_list""" ids = [] if "__" in line: if self.lastTaskIds: 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(r"(\d+)-(\d+)") tokens = re.split(r"[\s|,]", line) if len(tokens) < 2: raise BadUsageException("Give at least a task id and a command") idScan = True # Indicate we are parsing ids cmdTokens = [] # Command that we want to apply for token in tokens: if token == "": continue if idScan: result = rangeId.match(token) if result: ids.extend(list(range(int(result.group(1)), int(result.group(2)) + 1))) elif token.isdigit(): ids.append(int(token)) else: # Id list is finished. Grab rest of line. cmdTokens.append(token) idScan = False else: cmdTokens.append(token) if not cmdTokens: raise BadUsageException("Give a command to apply") cmd = cmdTokens.pop(0) for taskId in ids: line = " ".join([cmd, str(taskId), " ".join(cmdTokens)]) print("Executing: %s" % line) self.onecmd(line.strip()) complete_t_apply = taskIdCompleter def parser_t_remove(self): parser = YokadiOptionParser() 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") parser.add_argument("id") return parser def do_t_remove(self, line): parser = self.parser_t_remove() args = parser.parse_args(line) task = self.getTaskFromId(args.id) if not args.force: if not tui.confirm("Remove task '%s'" % task.title): return project = task.project self.session.delete(task) print("Task '%s' removed" % (task.title)) # Delete project with no associated tasks if self.session.query(Task).filter_by(project=project).count() == 0: self.session.delete(project) self.session.commit() complete_t_remove = taskIdCompleter def parser_t_purge(self): parser = YokadiOptionParser() 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") 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) return parser def do_t_purge(self, line): parser = self.parser_t_purge() args = parser.parse_args(line) filters = [] filters.append(Task.status == "done") filters.append(Task.doneDate < (datetime.now() - timedelta(days=args.delay))) tasks = self.session.query(Task).filter(*filters) if tasks.count() == 0: print("No tasks need to be purged") return print("The following tasks will be removed:") print("\n".join(["%s: %s" % (task.id, task.title) for task in tasks])) if args.force or tui.confirm("Do you really want to remove those tasks (this action cannot be undone)?"): self.session.delete(tasks) self.session.commit() print("Tasks deleted") else: print("Purge canceled") def parser_t_list(self): parser = YokadiOptionParser() parser.usage = "t_list [options] <project_or_keyword_filter>" parser.description = "List tasks 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.: " \ "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)") parser.add_argument("--started", dest="status", 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>") parser.add_argument("-u", "--urgency", dest="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") parser.add_argument("--overdue", dest="due", 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>") 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("-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>") 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>") parser.add_argument("-o", "--output", dest="output", help="Output task list to <file>", metavar="<file>") parser.add_argument("filter", nargs="*", metavar="<project_or_keyword_filter>") return parser def _realProjectName(self, name): if name == '_': if self.lastProjectName is None: raise YokadiException("No previous project used") else: self.lastProjectName = name return self.lastProjectName def _parseListLine(self, parser, line): """ Parse line with parser, returns a tuple of the form (options, projectList, filters) """ args = parser.parse_args(line) if len(args.filter) > 0: projectName, filters = parseutils.extractKeywords(" ".join(args.filter)) else: projectName = "" filters = [] if self.kFilters: # Add keyword filter filters.extend(self.kFilters) if not projectName: if self.pFilter: # If a project filter is defined, use it as none was provided projectName = self.pFilter else: # Take all project if none provided projectName = "%" if projectName.startswith("!"): projectName = self._realProjectName(projectName[1:]) projectList = self.session.query(Project).filter(Project.name.notlike(projectName)).all() else: projectName = self._realProjectName(projectName) projectList = self.session.query(Project).filter(Project.name.like(projectName)).all() if len(projectList) == 0: raise YokadiException("Found no project matching '%s'" % projectName) # Check keywords exist parseutils.warnIfKeywordDoesNotExist(filters) # Search if args.search: for word in args.search: if word.startswith("@"): tui.warning("Maybe you want keyword search (without -s option) " "instead of plain text search?") condition = or_(Task.title.like("%" + word + "%"), Task.description.like("%" + word + "%")) filters.append(DbFilter(condition)) return args, projectList, filters def _renderList(self, renderer, projectList, filters, order, limit=None, groupKeyword=None): """ Render a list using renderer, according to the restrictions set by the other parameters @param renderer: renderer class (for example: TextListRenderer) @param projectList: list of project name (as unicode string) @param filters: filters in sql alchemy format (example: Task.status == 'done') @param order: ordering in sqlalchemy format (example: desc(Task.urgency)) @param limit: limit number tasks (int) or None for no limit @param groupKeyword: keyword used for grouping (as unicode string) or None """ def applyFilters(lst): for filter in filters: lst = filter.apply(lst) return lst if groupKeyword: if groupKeyword.startswith("@"): groupKeyword = groupKeyword[1:] keywords = self.session.query(Keyword).filter(Keyword.name.like(groupKeyword)) 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 continue taskList = self.session.query(Task).filter(TaskKeyword.keywordId == keyword.id) taskList = taskList.outerjoin(TaskKeyword, Task.taskKeywords) taskList = applyFilters(taskList) taskList = taskList.order_by(*order).limit(limit).distinct() taskList = list(taskList) if projectList: taskList = [x for x in taskList if x.project in projectList] if len(taskList) > 0: self.lastTaskIds.extend([t.id for t in taskList]) # Keep selected id for further use renderer.addTaskList(str(keyword), taskList) renderer.end() else: hiddenProjectNames = [] for project in sorted(projectList, key=lambda x: x.name.lower()): if not project.active: hiddenProjectNames.append(project.name) continue taskList = self.session.query(Task).filter(Task.project == project) taskList = taskList.outerjoin(TaskKeyword, Task.taskKeywords) taskList = applyFilters(taskList) taskList = taskList.order_by(*order).limit(limit).distinct() taskList = list(taskList) if len(taskList) > 0: self.lastTaskIds.extend([t.id for t in taskList]) # Keep selected id for further use renderer.addTaskList(str(project), taskList) renderer.end() if len(hiddenProjectNames) > 0: tui.info("hidden projects: %s" % ", ".join(hiddenProjectNames)) def do_t_list(self, line, renderer=None): def selectRendererClass(): if args.format != "auto": return gRendererClassDict[args.format] defaultRendererClass = TextListRenderer if not args.output: return defaultRendererClass ext = os.path.splitext(args.output)[1] if not ext: return defaultRendererClass return gRendererClassDict.get(ext[1:], defaultRendererClass) # Reset last tasks id list self.lastTaskIds = [] # BUG: completion based on parameter position is broken when parameter is given" args, projectList, filters = self._parseListLine(self.parser_t_list(), line) # Skip notes filters.append(KeywordFilter(NOTE_KEYWORD, negative=True)) # Handle t_list specific options order = [desc(Task.urgency), Task.creationDate] limit = None if args.done: filters.append(DbFilter(Task.status == 'done')) if args.done != "all": minDate = ydateutils.parseMinDate(args.done) filters.append(DbFilter(Task.doneDate >= minDate)) elif args.status == "all": pass elif args.status == "started": filters.append(DbFilter(Task.status == "started")) else: filters.append(DbFilter(Task.status != "done")) 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 is not None)) order = [Task.dueDate, ] limit = 5 if args.due: for due in args.due: dueOperator, dueLimit = ydateutils.parseDateLimit(due) filters.append(DbFilter(dueOperator(Task.dueDate, dueLimit))) order = [Task.dueDate, ] # Define output if args.output: out = open(args.output, "w", encoding='utf-8') else: out = tui.stdout # Instantiate renderer if renderer is None: rendererClass = selectRendererClass() renderer = rendererClass(out) # Fill the renderer self._renderList(renderer, projectList, filters, order, limit, args.keyword) complete_t_list = projectAndKeywordCompleter def parser_n_list(self): 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" 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>") 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("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) filters.append(KeywordFilter(NOTE_KEYWORD)) order = [Task.creationDate, ] renderer = TextListRenderer(tui.stdout, renderAsNotes=True) self._renderList(renderer, projectList, filters, order, limit=None, groupKeyword=args.keyword) complete_n_list = projectAndKeywordCompleter def do_t_reorder(self, line): """Reorder tasks of a project. It works by starting an editor with the task list: you can then change the order of the lines and save the list. The urgency field will be updated to match the order. t_reorder <project_name>""" try: project = self.session.query(Project).filter_by(name=line).one() except (MultipleResultsFound, NoResultFound): raise BadUsageException("You must provide a valid project name") taskList = self.session.query(Task).filter(Task.projectId == project.id, Task.status != 'done').order_by(desc(Task.urgency)) lines = ["%d,%s" % (x.id, x.title) for x in taskList] text = tui.editText("\n".join(lines)) ids = [] for line in text.split("\n"): line = line.strip() if "," not in line: continue ids.append(int(line.split(",")[0])) ids.reverse() for urgency, taskId in enumerate(ids): task = self.session.query(Task).get(taskId) task.urgency = urgency self.session.merge(task) self.session.commit() complete_t_reorder = ProjectCompleter(1) def do_t_medit(self, line): """Mass edit tasks of a project. t_medit <project_name> Starts a text editor with the task list, you can then: - edit tasks text and keywords - mark tasks as done or started - add new tasks - adjust urgency - delete tasks """ if not line: raise BadUsageException("Missing parameters") projectName = parseOneWordName(line) projectName = self._realProjectName(projectName) project = dbutils.getOrCreateProject(projectName) if not project: return oldList = massedit.createEntriesForProject(project) oldText = massedit.createMEditText(oldList) newText = oldText while True: newText = tui.editText(newText, suffix=".medit") if newText == oldText: print("No changes") return try: newList = massedit.parseMEditText(newText) except massedit.ParseError as exc: print(exc) print() if tui.confirm("Modify text and try again"): lst = newText.splitlines() lst.insert(exc.lineNumber, "# ^ " + exc.message) newText = "\n".join(lst) continue else: return try: massedit.applyChanges(project, oldList, newList) self.session.commit() break except YokadiException as exc: print(exc) print() if not tui.confirm("Modify text and try again"): return complete_t_medit = ProjectCompleter(1) def parser_t_show(self): parser = YokadiOptionParser() parser.usage = "t_show [options] <id>" 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("id") return parser def do_t_show(self, line): parser = self.parser_t_show() args = parser.parse_args(line) task = self.getTaskFromId(args.id) if args.output in ("all", "summary"): keywordDict = task.getKeywordDict() keywordArray = [] for name, value in list(keywordDict.items()): txt = name if value: txt += "=" + str(value) keywordArray.append(txt) keywordArray.sort() keywords = ", ".join(keywordArray) if task.recurrence: recurrence = "{} (next: {})".format( task.recurrence.getFrequencyAsString(), task.recurrence.getNext() ) else: recurrence = "None" fields = [ ("Project", task.project.name), ("Title", task.title), ("ID", task.id), ("Created", task.creationDate), ("Due", task.dueDate), ("Status", task.status), ("Urgency", task.urgency), ("Recurrence", recurrence), ("Keywords", keywords), ] if task.status == "done": fields.append(("Done", task.doneDate)) tui.renderFields(fields) if args.output in ("all", "description") and task.description: if args.output == "all": print() print(task.description) complete_t_show = taskIdCompleter def _t_edit(self, line, keywordEditor=None): """Code shared by t_edit and bug_edit. if keywordEditor is not None it will be called after editing the task. Returns the modified task if OK, None if cancelled""" def editComplete(text, state): """ Specific completer for the edit prompt. This subfunction should stay here because it needs to access to cmd members""" if state == 0: origline = readline.get_line_buffer() line = origline.lstrip() stripped = len(origline) - len(line) begidx = readline.get_begidx() - stripped endidx = readline.get_endidx() - stripped if begidx > 0: self.completion_matches = projectAndKeywordCompleter("", text, line, begidx, endidx, shift=1) else: self.completion_matches = [] try: return self.completion_matches[state] except IndexError: return None task = self.getTaskFromId(line) # Create task line keywordDict = task.getKeywordDict() userKeywordDict, keywordDict = dbutils.splitKeywordDict(keywordDict) 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 # Edit try: while True: print("(Press Ctrl+C to cancel)") try: line = tui.editLine(taskLine) if not line.strip(): tui.warning("Missing title") continue except KeyboardInterrupt: print() print("Cancelled") return None _, title, userKeywordDict = parseutils.parseLine(task.project.name + " " + line) if dbutils.createMissingKeywords(userKeywordDict.keys()): # We were able to create missing keywords if there were any, # we can now exit the edit loop break finally: readline.set_completer(oldCompleter) keywordDict.update(userKeywordDict) if keywordEditor: keywordEditor(keywordDict) task.title = title task.setKeywordDict(keywordDict) return task def do_t_edit(self, line): """Edit a task. t_edit <id>""" self._t_edit(line) self.session.commit() complete_t_edit = taskIdCompleter def do_t_set_project(self, line): """@deprecated: should be removed""" tui.warnDeprecated("t_set_project", "t_project") self.do_t_project(line) def do_t_project(self, line): """Set task's project. t_project <id> <project>""" tokens = parseutils.simplifySpaces(line).split(" ") if len(tokens) != 2: raise YokadiException("You should give two arguments: <task id> <project>") projectName = tokens[1] projectName = self._realProjectName(projectName) project = dbutils.getOrCreateProject(projectName) if not project: return task = self.getTaskFromId(tokens[0]) task.project = project self.session.commit() if task.project: print("Moved task '%s' to project '%s'" % (task.title, projectName)) complete_t_set_project = ProjectCompleter(2) complete_t_project = ProjectCompleter(2) def do_t_set_due(self, line): """@deprecated: should be removed""" tui.warnDeprecated("t_set_due", "t_due") self.do_t_due(line) def do_t_due(self, line): """Set task's due date t_due <id> <date> Date can be specified as a relative offset: - +5M: in 5 minutes - +3H: in 3 hours - +1D: in 1 day - +6W: in 6 weeks As a day in the week: - tomorrow: tomorrow, same hour - tuesday 12:10: next tuesday, at 12:10 - fr 15:30: next friday, at 15:30 Or as an absolute date or time: - 10:38: at 10:38 today - 25/09/2010 12:10: on the 25th of September, 2010, at 12:10 - 23/02/2010: on the 23th of February, 2010 - 01/04: on the 1st of April - 12: on the 12th of current month To reset a due date, use "none".""" line = parseutils.simplifySpaces(line) if len(line.split()) < 2: raise YokadiException("Give a task id and time, date or date & time") taskId, line = line.strip().split(" ", 1) task = self.getTaskFromId(taskId) if line.lower() == "none": task.dueDate = None print("Due date for task '%s' reset" % task.title) else: task.dueDate = ydateutils.parseHumaneDateTime(line) print("Due date for task '%s' set to %s" % (task.title, task.dueDate.ctime())) self.session.merge(task) self.session.commit() complete_t_set_due = dueDateCompleter complete_t_due = dueDateCompleter def do_t_add_keywords(self, line): """Add keywords to an existing task t_add_keywords <id> <@keyword1> <@keyword2>[=<value>]... """ tokens = parseutils.simplifySpaces(line).split(" ", 1) if len(tokens) < 2: raise YokadiException("You should give at least two arguments: <task id> <keyword>") task = dbutils.getTaskFromId(tokens[0]) garbage, keywordFilters = parseutils.extractKeywords(tokens[1]) newKwDict = parseutils.keywordFiltersToDict(keywordFilters) if garbage: raise YokadiException("Cannot parse line, got garbage (%s). Maybe you forgot to add @ before keyword ?" % garbage) if not dbutils.createMissingKeywords(list(newKwDict.keys())): # User cancel keyword creation return kwDict = task.getKeywordDict() kwDict.update(newKwDict) task.setKeywordDict(kwDict) self.session.merge(task) self.session.commit() def do_t_recurs(self, line): """Make a task recurs t_recurs <id> yearly <dd/mm> <HH:MM> t_recurs <id> monthly <dd> <HH:MM> t_recurs <id> monthly <first/second/third/last> <mo, tu, we, th, fr, sa, su> <hh:mm> t_recurs <id> quarterly <dd> <HH:MM> t_recurs <id> quarterly <first/second/third/last> <mo, tu, we, th, fr, sa, su> <hh:mm> t_recurs <id> weekly <mo, tu, we, th, fr, sa, su> <hh:mm> t_recurs <id> daily <HH:MM> t_recurs <id> none (remove recurrence)""" tokens = parseutils.simplifySpaces(line).split(" ", 1) if len(tokens) < 2: raise YokadiException("You should give at least two arguments: <task id> <recurrence>") task = self.getTaskFromId(tokens[0]) rule = RecurrenceRule.fromHumaneString(tokens[1]) task.setRecurrenceRule(rule) self.session.commit() complete_t_recurs = recurrenceCompleter def do_t_filter(self, line): """Define permanent keyword filter used by t_list Ex.: - t_filter @work (filter all task that have the "work" keyword) - t_filter none (remove filter)""" # TODO: add completion if not line: raise YokadiException("You must give keyword as argument or 'none' to reset filter") if parseutils.simplifySpaces(line).lower() == "none": self.kFilters = [] self.pFilter = "" self.prompt = "yokadi> " else: projectName, keywordFilters = parseutils.extractKeywords(line) self.kFilters = keywordFilters self.pFilter = projectName prompt = "y" if self.pFilter: prompt += " %s" % projectName if self.kFilters: parseutils.warnIfKeywordDoesNotExist(self.kFilters) 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 �����������������������yokadi-1.2.0/yokadi/ycli/colors.py������������������������������������������������������������������0000664�0001750�0001750�00000000757�13430006220�020520� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Standard codes for shell colors. @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ 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' else: BOLD = '' RED = '' GREEN = '' ORANGE = '' PURPLE = '' CYAN = '' GREY = '' RESET = '' �����������������yokadi-1.2.0/yokadi/ycli/massedit.py����������������������������������������������������������������0000664�0001750�0001750�00000013546�13430006220�021030� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Database utilities. @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ from collections import namedtuple from datetime import datetime from sqlalchemy import desc from yokadi.core import db from yokadi.core.db import Task, NOTE_KEYWORD from yokadi.core.yokadiexception import YokadiException from yokadi.core import dbutils from yokadi.core.dbutils import KeywordFilter from yokadi.ycli import parseutils MEditEntry = namedtuple("MEditEntry", ["id", "status", "title", "keywords"]) DOC_COMMENT = """ Line format: <id> <status> <task title> To change the title of a task, just change the text after the status character. You can add or remove keywords like you do when using t_add. To change the status of a task, change the status character to one of: N new S started D done To add a new task, add a new line using '-' for the task id. If you do not specify a status, the task will be marked as new. Examples: - This is a new task - N This is another new task - S This task has already been started To adjust task urgencies, re-order the lines. To remove a task, delete its line or comment it out with '#'. To cancel changes, quit without saving. Empty lines and lines starting with a '#' are ignored. Warning: Do NOT edit the task id, this will confuse Yokadi. """ class ParseError(YokadiException): def __init__(self, message, lineNumber, line): fullMessage = "Error line %d (\"%s\"): %s" % (lineNumber + 1, line, message) YokadiException.__init__(self, fullMessage) self.lineNumber = lineNumber self.message = message def createMEditText(entries, docComment=DOC_COMMENT): def formatLine(entry): status = entry.status[0].upper() line = parseutils.createLine(None, entry.title, entry.keywords) return "%d %s %s" % (entry.id, status, line) def prefixComment(comment): for line in comment.strip().splitlines(): if line: yield "# " + line else: yield "#" lines = [formatLine(x) for x in entries] lines.append('') lines.extend(prefixComment(docComment)) return "\n".join(lines) + "\n" def parseMEditLine(line): tokens = line.split(" ", 2) nbTokens = len(tokens) if nbTokens < 3: if nbTokens == 2 and tokens[0] == "-": # Special case: adding a one-word new task tokens.append("") else: raise Exception("Invalid line") if tokens[0] == "-": id = None else: try: id = int(tokens[0]) except ValueError: raise Exception("Invalid id value") statusChar = tokens[1].lower() line = tokens[2] if statusChar == "n": status = "new" elif statusChar == "s": status = "started" elif statusChar == "d": status = "done" 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" line = tokens[1] + ((" " + line) if line else "") else: raise Exception("Invalid status") _, title, keywords = parseutils.parseLine("dummy " + line) return MEditEntry(id, status, title, keywords) def parseMEditText(text): lst = [] ids = set() for num, line in enumerate(text.split("\n")): line = line.strip() if not line or line[0] == "#": continue try: entry = parseMEditLine(line) except Exception as exc: exc = ParseError(str(exc), lineNumber=num + 1, line=line) raise exc if entry.id is not None: if entry.id in ids: exc = ParseError("Duplicate id value", lineNumber=num + 1, line=line) raise exc ids.add(entry.id) lst.append(entry) return lst def createEntriesForProject(project): session = db.getSession() lst = session.query(Task).filter(Task.projectId == project.id, Task.status != 'done') lst = KeywordFilter(NOTE_KEYWORD, negative=True).apply(lst) lst = lst.order_by(desc(Task.urgency)) return [createEntryForTask(x) for x in lst] def createEntryForTask(task): return MEditEntry(task.id, task.status, task.title, task.getKeywordDict()) def applyChanges(project, oldList, newList, interactive=True): """ Modify a project so that its task list is newList @param project: the project name @param oldList: a list of Task instances @param newList: a list of MEditEntry @param interactive: whether to confirm creation of new keywords """ session = db.getSession() # Sanity check: all ids in newList should be in oldList oldIds = set([x.id for x in oldList]) newIds = set([x.id for x in newList if x.id is not None]) unknownIds = newIds.difference(oldIds) if unknownIds: idString = ", ".join([str(x) for x in unknownIds]) raise YokadiException("Unknown id(s): %s" % idString) # Check keywords for entry in newList: for name in entry.keywords: dbutils.getOrCreateKeyword(name, interactive=interactive) # Remove tasks whose lines have been deleted for id in oldIds.difference(newIds): task = dbutils.getTaskFromId(id) session.delete(task) # Update existing tasks, add new ones nbTasks = len(newList) for pos, newEntry in enumerate(newList): if newEntry.id: task = dbutils.getTaskFromId(newEntry.id) else: task = Task(creationDate=datetime.now().replace(second=0, microsecond=0), project=project) task.title = newEntry.title task.setKeywordDict(newEntry.keywords) task.setStatus(newEntry.status) task.urgency = nbTasks - pos if newEntry.id: session.merge(task) else: session.add(task) # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/���������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�016625� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/dbutils.py�����������������������������������������������������������������0000664�0001750�0001750�00000021445�13430006220�020652� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Database utilities. @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ from datetime import datetime, timedelta import os from sqlalchemy import and_ from sqlalchemy.orm import aliased from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from yokadi.ycli import tui from yokadi.core import db from yokadi.core.db import Keyword, Project, Task, TaskKeyword, TaskLock from yokadi.core.yokadiexception import YokadiException def addTask(projectName, title, keywordDict=None, interactive=True): """Adds a task based on title and keywordDict. @param projectName: name of project as a string @param title: task title as a string @param keywordDict: dictionary of keywords (name : value) @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() if keywordDict is None: keywordDict = {} # Create missing keywords if not createMissingKeywords(keywordDict.keys(), interactive=interactive): return None # Create missing project project = getOrCreateProject(projectName, interactive=interactive) if not project: return None # Create task 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) return task def getTaskFromId(tid): """Returns a task given its id, or raise a YokadiException if it does not exist. @param tid: Task id or uuid @return: Task instance or None if existingTask is False""" session = db.getSession() 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(**filters).one() except NoResultFound: raise YokadiException("Task %s does not exist. Use t_list to see all tasks" % taskId) return task def getOrCreateKeyword(keywordName, interactive=True): """Get a keyword by its name. Create it if needed @param keywordName: keyword name as a string @param interactive: Ask user before creating keyword (this is the default) @type interactive: Bool @return: Keyword instance or None if user cancel creation""" session = db.getSession() try: return session.query(Keyword).filter_by(name=keywordName).one() except (NoResultFound, MultipleResultsFound): if interactive and not tui.confirm("Keyword '%s' does not exist, create it" % keywordName): return None keyword = Keyword(name=keywordName) session.add(keyword) print("Added keyword '%s'" % keywordName) return keyword def getOrCreateProject(projectName, interactive=True, createIfNeeded=True): """Get a project by its name. Create it if needed @param projectName: project name as a string @param interactive: Ask user before creating project (this is the default) @type interactive: Bool @param createIfNeeded: create project if it does not exist (this is the default) @type createIfNeeded: Bool @return: Project instance or None if user cancel creation or createIfNeeded is False""" session = db.getSession() result = session.query(Project).filter_by(name=projectName).all() if len(result): return result[0] if not createIfNeeded: return None if interactive and not tui.confirm("Project '%s' does not exist, create it" % projectName): return None project = Project(name=projectName) session.add(project) print("Added project '%s'" % projectName) return project def createMissingKeywords(lst, interactive=True): """Create all keywords from lst which does not exist @param lst: list of keyword @return: True, if ok, False if user canceled""" for keywordName in lst: if not getOrCreateKeyword(keywordName, interactive=interactive): return False return True def getKeywordFromName(name): """Returns a keyword from its name, which may start with "@" raises a YokadiException if not found @param name: the keyword name @return: The keyword""" session = db.getSession() if not name: raise YokadiException("No keyword supplied") if name.startswith("@"): name = name[1:] lst = session.query(Keyword).filter_by(name=name).all() if len(lst) == 0: raise YokadiException("No keyword named '%s' found" % name) return lst[0] def splitKeywordDict(dct): """Take a keyword dict and return a tuple of the form (userDict, reservedDict) """ userDict = {} reservedDict = {} for key, value in dct.items(): if key[0] == '_': reservedDict[key] = value else: userDict[key] = value return userDict, reservedDict class TaskLockManager: """Handle a lock to prevent concurrent editing of the same task""" def __init__(self, task): """ @param task: a Task instance @param session: sqlalchemy session""" self.task = task self.session = db.getSession() def _getLock(self): """Retrieve the task lock if it exists (else None)""" try: return db.getSession().query(TaskLock).filter(TaskLock.task == self.task).one() except NoResultFound: return None def acquire(self, pid=None, now=None): """Acquire a lock for that task and remove any previous stale lock""" if now is None: now = datetime.now() if pid is None: pid = os.getpid() lock = self._getLock() if lock: if lock.updateDate < now - 2 * timedelta(seconds=tui.MTIME_POLL_INTERVAL): # Stale lock, reusing it lock.pid = pid lock.updateDate = now else: raise YokadiException("Task %s is already locked by process %s" % (lock.task.id, lock.pid)) else: # Create a lock self.session.add(TaskLock(task=self.task, pid=pid, updateDate=now)) self.session.commit() def update(self, now=None): """Update lock timestamp to avoid it to expire""" if now is None: now = datetime.now() lock = self._getLock() lock.updateDate = now self.session.merge(lock) self.session.commit() def release(self): """Release the lock for that task""" # Only release our lock lock = self._getLock() if lock and lock.pid == os.getpid(): self.session.delete(lock) self.session.commit() class DbFilter(object): """ Light wrapper around SQL Alchemy filters. Makes it possible to have the same interface as KeywordFilter """ def __init__(self, condition): self.condition = condition def apply(self, lst): return lst.filter(self.condition) class KeywordFilter(object): """Represent a filter on a keyword""" def __init__(self, name, negative=False, value=None, valueOperator=None): self.name = name # Keyword name assert self.name, "Keyword name cannot be empty" self.negative = negative # Negative filter self.value = value # Keyword value self.valueOperator = valueOperator # Operator to compare value def __repr__(self): return "<KeywordFilter name={} negative={} value={} valueOperator={}>".format( self.name, self.negative, self.value, self.valueOperator) def apply(self, query): """Apply keyword filters to query @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)) return query.filter(~Task.id.in_(excludedTaskIds)) else: keywordAlias = aliased(Keyword) taskKeywordAlias = aliased(TaskKeyword) query = query.outerjoin(taskKeywordAlias, Task.taskKeywords) query = query.outerjoin(keywordAlias, taskKeywordAlias.keyword) filter = keywordAlias.name.like(self.name) if self.valueOperator == "=": filter = and_(filter, taskKeywordAlias.value == self.value) elif self.valueOperator == "!=": filter = and_(filter, taskKeywordAlias.value != self.value) return query.filter(filter) # vi: ts=4 sw=4 et ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/basepaths.py���������������������������������������������������������������0000664�0001750�0001750�00000005723�13430006220�021157� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Provide standard ways to get various dirs This is similar to th pyxdg module but it does not automatically creates the dirs. Not creating the dirs is important to be able to show default values in `yokadid --help` output without creating anything. @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import os import getpass import shutil import tempfile from yokadi.core import fileutils _WINDOWS = os.name == "nt" DB_NAME = "yokadi.db" class MigrationException(Exception): pass def _getAppDataDir(): assert _WINDOWS return os.environ["APPDATA"] def getRuntimeDir(): value = os.getenv("XDG_RUNTIME_DIR") if not value: # Running on a system where XDG_RUNTIME_DIR is not set, fallback to # $tempdir/yokadi-$user tmpdir = tempfile.gettempdir() value = os.path.join(tmpdir, "yokadi-" + getpass.getuser()) return value def getLogDir(): return getCacheDir() def getCacheDir(): if _WINDOWS: value = os.path.join(_getAppDataDir(), "yokadi", "cache") else: cacheBaseDir = os.getenv("XDG_CACHE_HOME") if not cacheBaseDir: cacheBaseDir = os.path.expandvars("$HOME/.cache") value = os.path.join(cacheBaseDir, "yokadi") return value def getDataDir(): xdgDataDir = os.environ.get("XDG_DATA_HOME") if xdgDataDir: return os.path.join(xdgDataDir, "yokadi") if _WINDOWS: return os.path.join(_getAppDataDir(), "yokadi", "data") return os.path.expandvars("$HOME/.local/share/yokadi") def getHistoryPath(): path = os.getenv("YOKADI_HISTORY") if path: return path return os.path.join(getCacheDir(), "history") def getDbPath(dataDir): path = os.getenv("YOKADI_DB") if path: return path return os.path.join(dataDir, "yokadi.db") def _getOldHistoryPath(): if _WINDOWS: return os.path.join(_getAppDataDir(), ".yokadi_history") else: return os.path.expandvars("$HOME/.yokadi_history") def migrateOldHistory(): oldHistoryPath = _getOldHistoryPath() if not os.path.exists(oldHistoryPath): return newHistoryPath = getHistoryPath() if os.path.exists(newHistoryPath): # History is not critical, just overwrite the new file os.unlink(newHistoryPath) fileutils.createParentDirs(newHistoryPath) shutil.move(oldHistoryPath, newHistoryPath) print("Moved %s to %s" % (oldHistoryPath, newHistoryPath)) def migrateOldDb(newDbPath): oldDbPath = os.path.normcase(os.path.expandvars("$HOME/.yokadi.db")) if not os.path.exists(oldDbPath): return 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)) fileutils.createParentDirs(newDbPath) shutil.move(oldDbPath, newDbPath) print("Moved %s to %s" % (oldDbPath, newDbPath)) ���������������������������������������������yokadi-1.2.0/yokadi/core/yokadiexception.py���������������������������������������������������������0000664�0001750�0001750�00000000570�13430006220�022377� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ A simple exception class for Yokadi @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ class YokadiException(Exception): """Yokadi Exceptions""" pass class BadUsageException(YokadiException): """Exception when user does not pass correct arguments to a command""" pass # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/fileutils.py���������������������������������������������������������������0000664�0001750�0001750�00000000430�13430006220�021173� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Various file utility functions @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import os def createParentDirs(path, mode=0o777): parent = os.path.dirname(path) if os.path.exists(parent): return os.makedirs(parent, mode=mode) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/bugutils.py����������������������������������������������������������������0000664�0001750�0001750�00000003531�13430006220�021036� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Bug related commands. @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ from yokadi.ycli import tui SEVERITY_PROPERTY_NAME = "_severity" LIKELIHOOD_PROPERTY_NAME = "_likelihood" BUG_PROPERTY_NAME = "_bug" PROPERTY_NAMES = SEVERITY_PROPERTY_NAME, LIKELIHOOD_PROPERTY_NAME, BUG_PROPERTY_NAME SEVERITY_LIST = [ (1, "Documentation"), (2, "Localization"), (3, "Aesthetic issues"), (4, "Balancing: Enables degenerate usage strategies that harm the experience"), (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"), (2, "Will only affect a few users"), (3, "Will affect average number of users"), (4, "Will affect most users"), (5, "Will affect all users"), ] def computeUrgency(keywordDict): likelihood = keywordDict[LIKELIHOOD_PROPERTY_NAME] severity = keywordDict[SEVERITY_PROPERTY_NAME] maxUrgency = LIKELIHOOD_LIST[-1][0] * SEVERITY_LIST[-1][0] return int(100 * likelihood * severity / maxUrgency) def editBugKeywords(keywordDict): severity = keywordDict.get(SEVERITY_PROPERTY_NAME, None) likelihood = keywordDict.get(LIKELIHOOD_PROPERTY_NAME, None) bug = keywordDict.get(BUG_PROPERTY_NAME, None) severity = tui.selectFromList(SEVERITY_LIST, prompt="Severity", default=severity) likelihood = tui.selectFromList(LIKELIHOOD_LIST, prompt="Likelihood", default=likelihood) bug = tui.enterInt(prompt="bug", default=bug) keywordDict[BUG_PROPERTY_NAME] = bug if severity: keywordDict[SEVERITY_PROPERTY_NAME] = severity if likelihood: keywordDict[LIKELIHOOD_PROPERTY_NAME] = likelihood # vi: ts=4 sw=4 et �����������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/db.py����������������������������������������������������������������������0000664�0001750�0001750�00000033311�13430006220�017564� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Database access layer using SQL Alchemy @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ import json import os import sys 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 scoped_session, sessionmaker, relationship from sqlalchemy.orm.exc import NoResultFound 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 from yokadi.core.yokadiexception import YokadiException # Yokadi database version needed for this code # If database config key DB_VERSION differs from this one a database migration # is required DB_VERSION = 12 DB_VERSION_KEY = "DB_VERSION" class DbUserException(Exception): """ This exception is for errors which are not caused by a failure in our code and which must be fixed by the user. """ pass Base = declarative_base() NOTE_KEYWORD = "_note" def uuidGenerator(): return str(uuid1()) class Project(Base): __tablename__ = "project" id = Column(Integer, primary_key=True) uuid = Column(Unicode, unique=True, default=uuidGenerator, nullable=False) name = Column(Unicode, unique=True) active = Column(Boolean, default=True) tasks = relationship("Task", cascade="all", backref="project") 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" id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) tasks = association_proxy("taskKeywords", "task") taskKeywords = relationship("TaskKeyword", cascade="all", backref="keyword") def __repr__(self): return self.name class TaskKeyword(Base): __tablename__ = "task_keyword" id = Column(Integer, primary_key=True) taskId = Column("task_id", Integer, ForeignKey("task.id"), nullable=False) 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 "<TaskKeyword task={} keyword={} value={}>".format(self.task, self.keyword, self.value) class RecurrenceRuleColumnType(TypeDecorator): """Represents an ydateutils.RecurrenceRule column """ impl = VARCHAR def process_bind_param(self, value, dialect): if value: value = json.dumps(value.toDict()) else: value = "" return value def process_result_value(self, value, dialect): if value: dct = json.loads(value) value = RecurrenceRule.fromDict(dct) else: value = RecurrenceRule() return value class Task(Base): __tablename__ = "task" id = Column(Integer, primary_key=True) uuid = Column(Unicode, unique=True, default=uuidGenerator, nullable=False) title = Column(Unicode) creationDate = Column("creation_date", DateTime, nullable=False, default=datetime.now) dueDate = Column("due_date", DateTime, default=None) doneDate = Column("done_date", DateTime, default=None) description = Column(Unicode, default="", nullable=False) urgency = Column(Integer, default=0, nullable=False) status = Column(Enum("new", "started", "done"), default="new") recurrence = Column(RecurrenceRuleColumnType, nullable=False, default=RecurrenceRule()) projectId = Column("project_id", Integer, ForeignKey("project.id"), nullable=False) taskKeywords = relationship("TaskKeyword", cascade="all", backref="task") lock = relationship("TaskLock", cascade="all", backref="task") def setKeywordDict(self, dct): """ Defines keywords of a task. Dict is of the form: keywordName => value """ session = getSession() for taskKeyword in self.taskKeywords: session.delete(taskKeyword) for name, value in list(dct.items()): keyword = session.query(Keyword).filter_by(name=name).one() session.add(TaskKeyword(task=self, keyword=keyword, value=value)) def getKeywordDict(self): """ Returns all keywords of a task as a dict of the form: keywordName => value """ dct = {} for taskKeyword in self.taskKeywords: dct[taskKeyword.keyword.name] = taskKeyword.value return dct def getKeywordsAsString(self): """ Returns all keywords as a string like "key1=value1, key2=value2..." """ return ", ".join(list(("%s=%s" % k for k in list(self.getKeywordDict().items())))) def getUserKeywordsNameAsString(self): """ Returns all keywords keys as a string like "key1, key2, key3...". Internal keywords (starting with _) are ignored. """ keywords = [k for k in list(self.getKeywordDict().keys()) if not k.startswith("_")] keywords.sort() if keywords: return ", ".join(keywords) else: return "" def setStatus(self, status): """ Defines the status of the task, taking care of updating the done date and doing the right thing for recurrent tasks """ if self.recurrence and status == "done": self.dueDate = self.recurrence.getNext(self.dueDate) else: self.status = status if status == "done": self.doneDate = datetime.now().replace(second=0, microsecond=0) else: self.doneDate = None session = getSession() session.merge(self) def setRecurrenceRule(self, rule): """Set recurrence and update the due date accordingly""" 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 "<Task id={} title={}>".format(self.id, self.title) class Config(Base): """yokadi config""" __tablename__ = "config" id = Column(Integer, primary_key=True) name = Column(Unicode, unique=True) value = Column(Unicode) system = Column(Boolean) desc = Column(Unicode) class TaskLock(Base): __tablename__ = "task_lock" id = Column(Integer, primary_key=True) taskId = Column("task_id", Integer, ForeignKey("task.id"), unique=True, nullable=False) pid = Column(Integer, default=None) updateDate = Column("update_date", DateTime, default=None) class Alias(Base): __tablename__ = "alias" uuid = Column(Unicode, unique=True, default=uuidGenerator, nullable=False, primary_key=True) name = Column(Unicode, unique=True, nullable=False) command = Column(Unicode, nullable=False) @staticmethod def getAsDict(session): dct = {} for alias in session.query(Alias).all(): dct[alias.name] = alias.command return dct @staticmethod def add(session, name, command): alias = Alias(name=name, command=command) session.add(alias) @staticmethod def rename(session, name, newName): alias = session.query(Alias).filter_by(name=name).one() alias.name = newName @staticmethod def setCommand(session, name, command): alias = session.query(Alias).filter_by(name=name).one() alias.command = command def getConfigKey(name, environ=True): session = getSession() if environ: return os.environ.get(name, session.query(Config).filter_by(name=name).one().value) else: return session.query(Config).filter_by(name=name).one().value _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) class Database(object): def __init__(self, dbFileName, createIfNeeded=True, memoryDatabase=False, updateMode=False): """Connect to database and create it if needed @param dbFileName: path to database file @type dbFileName: str @param createIfNeeded: Indicate if database must be created if it does not exists (default True) @type createIfNeeded: bool @param memoryDatabase: create db in memory. Only usefull for unit test. Default is false. @type memoryDatabase: bool @param updateMode: allow to use it without checking version. Default is false. @type updateMode: bool """ dbFileName = os.path.abspath(dbFileName) if sys.platform == 'win32': connectionString = 'sqlite://' + dbFileName[0] + '|' + dbFileName[2:] else: connectionString = 'sqlite:///' + dbFileName if memoryDatabase: connectionString = "sqlite:///:memory:" 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: raise DbUserException("Database file (%s) does not exist or is not readable." % dbFileName) if not memoryDatabase: print("Creating %s" % dbFileName) self.createTables() # Set database version according to current yokadi release # 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: self.checkVersion() def createTables(self): """Create all defined tables""" Base.metadata.create_all(self.engine) def getVersion(self): if not self.engine.has_table("config"): # There was no Config table in v1 return 1 try: return int(self.session.query(Config).filter_by(name=DB_VERSION_KEY).one().value) except NoResultFound: raise YokadiException("Configuration key '%s' does not exist. This should not happen!" % DB_VERSION_KEY) def setVersion(self, version): assert self.engine.has_table("config") instance = self.session.query(Config).filter_by(name=DB_VERSION_KEY).one() instance.value = str(version) self.session.add(instance) self.session.commit() def checkVersion(self): """Check version and exit if it is not suitable""" version = self.getVersion() if version != DB_VERSION: msg = "Your database version is {} but Yokadi wants version {}.\n".format(version, DB_VERSION) msg += "Please run Yokadi with the --update option to update your database." raise DbUserException(msg) 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"), } session = getSession() for name, value in defaultConfig.items(): if session.query(Config).filter_by(name=name).count() == 0: session.add(Config(name=name, value=value[0], system=value[1], desc=value[2])) # vi: ts=4 sw=4 et �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/recurrencerule.py����������������������������������������������������������0000664�0001750�0001750�00000014224�13430006220�022226� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Date utilities. @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ from datetime import datetime from dateutil import rrule from yokadi.core.ydateutils import getHourAndMinute, getWeekDayNumberFromDay, parseHumaneDateTime from yokadi.core.yokadiexception import YokadiException FREQUENCIES = {0: "Yearly", 1: "Monthly", 2: "Weekly", 3: "Daily"} ALL_DAYS = (rrule.MO, rrule.TU, rrule.WE, rrule.TH, rrule.FR, rrule.SA, rrule.SU) class RecurrenceRule(object): """Thin wrapper around dateutil.rrule which brings: - Serialization to/from dict - Parsing methods - Sane defaults (byhour = byminute = bysecond = 0) - __eq__ operator - Readable name Dict format: freq: 0..3, see FREQUENCIES dict bymonth: tuple<1..12> bymonthday: tuple<1..31> byweekday: tuple<0..6> or {pos: -1;1..4, weekday: 0..6} byhour: tuple<0..23> byminute: tuple<0..59> Constructor arguments: same as dict format except tuples can be int or None for convenience """ def __init__(self, freq=None, bymonth=None, bymonthday=None, byweekday=None, byhour=0, byminute=0): def tuplify(value): if value is None: return () if isinstance(value, int): return (value,) else: return tuple(value) self._freq = freq self._bymonth = tuplify(bymonth) self._bymonthday = tuplify(bymonthday) if isinstance(byweekday, dict): self._byweekday = byweekday else: self._byweekday = tuplify(byweekday) self._byhour = tuplify(byhour) self._byminute = tuplify(byminute) @staticmethod def fromDict(dct): if not dct: return RecurrenceRule() return RecurrenceRule(**dct) @staticmethod def fromHumaneString(line): """Take a string following t_recurs format, returns a RecurrenceRule instance or None """ freq = byminute = byhour = byweekday = bymonthday = bymonth = None tokens = line.split() tokens[0] = tokens[0].lower() if tokens[0] == "none": return RecurrenceRule() if tokens[0] == "daily": if len(tokens) != 2: raise YokadiException("You should give time for daily task") freq = rrule.DAILY byhour, byminute = getHourAndMinute(tokens[1]) elif tokens[0] == "weekly": freq = rrule.WEEKLY if len(tokens) != 3: raise YokadiException("You should give day and time for weekly task") byweekday = getWeekDayNumberFromDay(tokens[1].lower()) byhour, byminute = getHourAndMinute(tokens[2]) elif tokens[0] in ("monthly", "quarterly"): if tokens[0] == "monthly": freq = rrule.MONTHLY else: # quarterly freq = rrule.YEARLY bymonth = (1, 4, 7, 10) if len(tokens) < 3: raise YokadiException("You should give day and time for %s task" % (tokens[0],)) try: bymonthday = int(tokens[1]) byhour, byminute = getHourAndMinute(tokens[2]) except ValueError: 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]]) byhour, byminute = getHourAndMinute(tokens[3]) bymonthday = None # Default to current day number - need to be blanked else: raise YokadiException("Unable to understand date. See help t_recurs for details") elif tokens[0] == "yearly": freq = rrule.YEARLY rDate = parseHumaneDateTime(" ".join(tokens[1:])) bymonth = rDate.month bymonthday = rDate.day byhour = rDate.hour byminute = rDate.minute else: raise YokadiException("Unknown frequency. Available: daily, weekly, monthly and yearly") 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 ) def _rrule(self): if isinstance(self._byweekday, dict): day = ALL_DAYS[self._byweekday["weekday"]] byweekday = day(self._byweekday["pos"]) 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 ) def getNext(self, refDate=None): """Return next date of recurrence after given date @param refDate: reference date used to compute the next occurence of recurrence @type refDate: datetime @return: next occurence (datetime)""" if not self: return None if refDate is None: refDate = datetime.now() refDate.replace(second=0, microsecond=0) return self._rrule().after(refDate) def getFrequencyAsString(self): """Return a string for the frequency""" if not self: return "" return FREQUENCIES[self._freq] @staticmethod def createWeekDay(pos, weekday): return dict(pos=pos, weekday=weekday) def __eq__(self, other): return self.toDict() == other.toDict() def __bool__(self): return self._freq is not None def __repr__(self): return repr(self.toDict()) # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/__init__.py����������������������������������������������������������������0000664�0001750�0001750�00000000270�13430006220�020734� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: utf-8 -*- """ Yokadi core package @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license:GPL v3 or later """ ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/yokadioptionparser.py������������������������������������������������������0000664�0001750�0001750�00000002355�13430006220�023131� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ An ArgumentParser which accepts a single string as input and raise an exception instead of calling sys.exit() in case of error @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ from argparse import ArgumentParser import sys from yokadi.core.yokadiexception import YokadiException class YokadiOptionParserNormalExitException(YokadiException): """A dummy exception which makes it possible to have --help exit silently""" pass class YokadiOptionParser(ArgumentParser): def __init__(self, prog=None): ArgumentParser.__init__(self, prog=prog) def parse_args(self, line): argv = line.split(" ") # Splitting an empty line gives us [""], not an empty array if argv == [""]: argv = [] # Unknown options will throw an error args = ArgumentParser.parse_args(self, argv) return args def exit(self, status=0, msg=None): if msg: sys.stderr.write(msg) if status == 0: raise YokadiOptionParserNormalExitException() else: raise YokadiException(msg) def error(self, msg): self.print_usage(sys.stderr) raise YokadiException(msg) # vi: ts=4 sw=4 et �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/daemon.py������������������������������������������������������������������0000664�0001750�0001750�00000007655�13430006220�020456� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#!/usr/bin/env python3 """ This class comes from: http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/ and is licensed as Public Domain (see comment in article) """ import sys import os import time import atexit import errno from signal import SIGTERM class Daemon: """ A generic daemon class. Usage: subclass the Daemon class and override the run() method """ def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): self.stdin = stdin self.stdout = stdout self.stderr = stderr self.pidfile = pidfile def daemonize(self): """ do the UNIX double-fork magic, see Stevens' "Advanced Programming in the UNIX Environment" for details (ISBN 0201563177) http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 """ try: pid = os.fork() if pid > 0: # exit first parent sys.exit(0) except OSError as e: sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror)) sys.exit(1) # decouple from parent environment os.chdir("/") os.setsid() os.umask(0) # do second fork try: pid = os.fork() if pid > 0: # exit from second parent sys.exit(0) except OSError as e: sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror)) sys.exit(1) # redirect standard file descriptors sys.stdout.flush() sys.stderr.flush() si = open(self.stdin, 'r', encoding='utf-8', buffering=1) so = open(self.stdout, 'a+', encoding='utf-8', buffering=1) se = open(self.stderr, 'a+', encoding='utf-8', buffering=1) os.dup2(si.fileno(), sys.stdin.fileno()) os.dup2(so.fileno(), sys.stdout.fileno()) os.dup2(se.fileno(), sys.stderr.fileno()) # write pidfile atexit.register(self.delpid) pid = str(os.getpid()) open(self.pidfile, 'w+', encoding='utf-8').write("%s\n" % pid) def delpid(self): os.remove(self.pidfile) def start(self): """ Start the daemon """ # Check for a pidfile to see if the daemon already runs try: pf = open(self.pidfile, 'r', encoding='utf-8') pid = int(pf.read().strip()) pf.close() except IOError: pid = None if pid: message = "pidfile %s already exist. Daemon already running?\n" sys.stderr.write(message % self.pidfile) sys.exit(1) # Start the daemon self.daemonize() self.run() def stop(self): """ Stop the daemon """ # Get the pid from the pidfile try: pf = open(self.pidfile, 'r', encoding='utf-8') pid = int(pf.read().strip()) pf.close() except IOError: pid = None if not pid: message = "pidfile %s does not exist. Daemon not running?\n" sys.stderr.write(message % self.pidfile) return # not an error in a restart # Try killing the daemon process try: while 1: os.kill(pid, SIGTERM) time.sleep(0.1) except OSError as err: if err.errno == errno.ESRCH: # No such process, meaning daemon has stopped if os.path.exists(self.pidfile): os.remove(self.pidfile) else: print(str(err)) sys.exit(1) def restart(self): """ Restart the daemon """ self.stop() self.start() def run(self): """ You should override this method when you subclass Daemon. It will be called after the process has been daemonized by start() or restart(). """ �����������������������������������������������������������������������������������yokadi-1.2.0/yokadi/core/ydateutils.py��������������������������������������������������������������0000664�0001750�0001750�00000020651�13430006220�021371� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Date utilities. @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ import operator from datetime import date, datetime, timedelta from yokadi.ycli import basicparseutils from yokadi.core.yokadiexception import YokadiException WEEKDAYS = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6} SHORT_WEEKDAYS = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6} TIME_HINT_BEGIN = "begin" TIME_HINT_END = "end" DATE_FORMATS = [ "%d/%m/%Y", "%d/%m/%y", "%d/%m", ] TIME_FORMATS = [ "%H:%M:%S", "%H:%M", "%H", ] def parseDateTimeDelta(line): # FIXME: Do we really want to support float deltas? try: delta = float(line[:-1]) except ValueError: raise YokadiException("Timeshift must be a float or an integer") suffix = line[-1].upper() if suffix == "W": return timedelta(days=delta * 7) elif suffix == "D": return timedelta(days=delta) elif suffix == "H": return timedelta(hours=delta) elif suffix == "M": return timedelta(minutes=delta) else: raise YokadiException("Unable to understand time shift. See help t_set_due") def testFormats(text, formats): for fmt in formats: try: return datetime.strptime(text, fmt), fmt except ValueError: pass return None, None def guessTime(text): afternoon = False # We do not use the "%p" format to handle AM/PM because its behavior is # locale-dependent suffix = text[-2:] if suffix == "am": text = text[:-2].strip() elif suffix == "pm": afternoon = True text = text[:-2].strip() out, fmt = testFormats(text, TIME_FORMATS) if out is None: return None if afternoon: out += timedelta(hours=12) return out.time() def parseHumaneDateTime(line, hint=None, today=None): """Parse human date and time and return structured datetime object Datetime can be absolute (23/10/2008 10:38) or relative (+5M, +3H, +1D, +6W) @param line: human date / time @param hint: optional hint to tell whether time should be set to the beginning or the end of the day when not specified. @param today: optional parameter to define a fake today date. Useful for unit testing. @type line: str @return: datetime object""" def guessDate(text): out, fmt = testFormats(text, DATE_FORMATS) if not out: return None if "%y" not in fmt and "%Y" not in fmt: out = out.replace(year=today.year) return out.date() def applyTimeHint(date, hint): if not hint: return date if hint == TIME_HINT_BEGIN: return date.replace(hour=0, minute=0, second=0) elif hint == TIME_HINT_END: return date.replace(hour=23, minute=59, second=59) else: raise Exception("Unknown hint %s" % hint) line = basicparseutils.simplifySpaces(line).lower() if not line: raise YokadiException("Date is empty") if today is None: today = datetime.today().replace(microsecond=0) if line == "now": return today if line == "today": return applyTimeHint(today, hint) # Check for "+<delta>" format if line.startswith("+"): return today + parseDateTimeDelta(line[1:]) if line.startswith("-"): return today - parseDateTimeDelta(line[1:]) # Check for "<weekday> [<time>]" format firstWord = line.split()[0] weekdayDict = { "today": today.weekday(), "tomorrow": (today.weekday() + 1) % 7, } weekdayDict.update(WEEKDAYS) weekdayDict.update(SHORT_WEEKDAYS) weekday = weekdayDict.get(firstWord) if weekday is not None: date = today + timedelta(days=(weekday - today.weekday()) % 7) if " " in line: timeText = line.split(' ', 1)[1] tTime = guessTime(timeText) if tTime is None: raise YokadiException("Unable to understand time '%s'" % timeText) date = datetime.combine(date, tTime) else: date = applyTimeHint(date, hint) return date if " " in line: # Absolute date and time? dateText, timeText = line.split(' ', 1) tDate = guessDate(dateText) if tDate is not None: tTime = guessTime(timeText) if tTime is not None: return datetime.combine(tDate, tTime) # Only date? tDate = guessDate(line) if tDate is not None: dt = datetime.combine(tDate, today.time()) return applyTimeHint(dt, hint) # Only time? tTime = guessTime(line) if tTime is not None: tDate = datetime.combine(today.date(), tTime) if tTime > today.time(): return tDate else: return tDate + timedelta(days=1) raise YokadiException("Unable to understand date '%s'" % line) def formatTimeDelta(delta): """Friendly format a time delta: - Show only days if delta > 1 day - Show only hours and minutes otherwise @param timeLeft: Remaining time @type timeLeft: timedelta (from datetime) @return: formated str""" prefix = "" value = "" if delta < timedelta(0): delta = -delta prefix = "-" if delta.days >= 365: value = "%dY" % (delta.days / 365) days = delta.days % 365 if days > 30: value += ", %dM" % (days / 30) elif delta.days > 50: value = "%dM" % (delta.days / 30) days = delta.days % 30 if days > 0: value += ", %dd" % days elif delta.days >= 7: value = "%dw" % (delta.days / 7) days = delta.days % 7 if days > 0: value += ", %dd" % days elif delta.days > 0: value = "%dd" % delta.days else: minutes = delta.seconds / 60 hours = minutes / 60 minutes = minutes % 60 if hours >= 1: value = "%dh " % hours else: value = "" value += "%dm" % minutes return prefix + value def getHourAndMinute(token): """Extract hour and minute from HH:MM token #TODO: move this in date utils @param token: HH:MM string @return: (int, int)""" try: hour, minute = token.split(":") except ValueError: hour = token minute = 0 try: hour = int(hour) minute = int(minute) except ValueError: raise YokadiException("You must provide integer for hour/minute") return hour, minute def getWeekDayNumberFromDay(day): """Return week day number (0-6) from week day name (short or long) @param day: week day as a string in short or long format (in english) @type day: str @return: week day number (int)""" if len(day) == 2 and day in SHORT_WEEKDAYS: dayNumber = SHORT_WEEKDAYS[day] 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") return dayNumber def parseDateLimit(line, today=None): """Parse a string of the form <operator><limit> - operator is one of: < <= >= > (default to <=) - limit is a date as understood by parseHumaneDateTime() @param line: the string to parse @param today: optional specification of current day, for unit testing @return: (operator, date)""" # Order matters: match longest operators first! operators = [ ("<=", operator.__le__, TIME_HINT_END), (">=", operator.__ge__, TIME_HINT_BEGIN), (">", operator.__gt__, TIME_HINT_END), ("<", operator.__lt__, TIME_HINT_BEGIN), ] op = operator.__le__ hint = TIME_HINT_END for txt, loopOp, loopHint in operators: if line.startswith(txt): op = loopOp hint = loopHint line = line[len(txt):] break limit = parseHumaneDateTime(line, today=today, hint=hint) return op, limit def parseMinDate(line): # Parse the line string and return a minimum date minDate = date.today() if line == "today": pass elif line == "thisweek": minDate -= timedelta(minDate.weekday()) elif line == "thismonth": minDate = minDate.replace(day=1) else: minDate = parseHumaneDateTime(line).date() return minDate # vi: ts=4 sw=4 et ���������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/�������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�017157� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update9to10.py�����������������������������������������������������������0000664�0001750�0001750�00000004062�13430006220�021611� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 9 to version 10 of Yokadi DB - Remove Task.recurrence_id column - Add Task.recurrence column - Import recurrence from the Recurrence table - Remove Recurrence table @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ import json import pickle from yokadi.update import updateutils def tuplify(value): if value is None: return () if isinstance(value, int): return (value,) else: return tuple(value) def createByweekdayValue(rule): if rule._bynweekday: # Special case: recurrence every 1st, 2nd, 3rd, 4th or last $weekday of month weekday, pos = rule._bynweekday[0] return dict(weekday=weekday, pos=pos) return tuplify(rule._byweekday) def createJsonStringFromRule(pickledRule): rule = pickle.loads(pickledRule) dct = {} dct["freq"] = rule._freq dct["bymonth"] = tuplify(rule._bymonth) dct["bymonthday"] = tuplify(rule._bymonthday) dct["byweekday"] = createByweekdayValue(rule) dct["byhour"] = tuplify(rule._byhour) dct["byminute"] = tuplify(rule._byminute) return json.dumps(dct) def addRecurrenceColumn(cursor): cursor.execute("alter table task add column recurrence") sql = "select t.id, r.rule from task t left join recurrence r on t.recurrence_id = r.id" for row in cursor.execute(sql).fetchall(): id, pickledRule = row ruleStr = "" if pickledRule: try: ruleStr = createJsonStringFromRule(bytes(pickledRule, "utf-8")) except Exception as exc: print("Failed to import recurrence for task {}: {}".format(id, exc)) cursor.execute("update task set recurrence = ? where id = ?", (ruleStr, id)) def deleteRecurrenceTable(cursor): cursor.execute("drop table recurrence") def update(cursor): addRecurrenceColumn(cursor) deleteRecurrenceTable(cursor) updateutils.deleteTableColumns(cursor, "task", ("recurrence_id",)) if __name__ == "__main__": updateutils.main(update) # vi: ts=4 sw=4 et ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update6to7.py������������������������������������������������������������0000664�0001750�0001750�00000000666�13430006220�021542� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 6 to version 7 of Yokadi DB This one is empty, database schema don't change but we want to prevent use of SQLObject against a database created with SQLAlchemy because SQLObject cannot read timestamps written by SQLAlchemy @author: Benjamin Port <contact@benjaminport.fr> @license: GPL v3 or newer """ def update(dbpath): pass if __name__ == "__main__": pass # vi: ts=4 sw=4 et ��������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/updateutils.py�����������������������������������������������������������0000664�0001750�0001750�00000002663�13430006220�022102� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Utilities to handle database schema updates @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ import sqlite3 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()] def getTableColumnList(cursor, table): cursor.execute("select * from %s" % table) return [x[0] for x in cursor.description] def deleteTableColumns(cursor, table, columnsToDelete): columnList = getTableColumnList(cursor, table) for column in columnsToDelete: columnList.remove(column) columns = ",".join(columnList) sqlCommands = ( "create temporary table {table}_backup({columns})", "insert into {table}_backup select {columns} from {table}", "drop table {table}", "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)) def main(function): """ Runs an update function on a database. Useful as a test main """ dbpath = sys.argv[1] with sqlite3.connect(dbpath) as conn: function(conn.cursor()) �����������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update5to6.py������������������������������������������������������������0000664�0001750�0001750�00000001621�13430006220�021530� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 5 to version 6 of Yokadi DB @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or newer """ from sqlite3 import OperationalError def createTaskLockTable(cursor): cursor.execute(""" create table task_lock ( id integer not null, task_id integer, pid integer, update_date datetime, primary key (id), unique (task_id), foreign key(task_id) references task (id) ) """) def removeTaskTitleUniqConstraint(cursor): try: cursor.execute("drop index task_uniqTaskTitlePerProject") except OperationalError as exc: if str(exc) == "no such index: task_uniqTaskTitlePerProject": pass def update(cursor): removeTaskTitleUniqConstraint(cursor) createTaskLockTable(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ���������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update7to8.py������������������������������������������������������������0000664�0001750�0001750�00000000723�13430006220�021536� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 7 to version 8 of Yokadi DB Drops the projectkeyword table, since we are removing this feature. @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ def dropProjectKeywordTable(cursor): cursor.execute('drop table project_keyword') def update(cursor): dropProjectKeywordTable(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ���������������������������������������������yokadi-1.2.0/yokadi/update/update2to3.py������������������������������������������������������������0000664�0001750�0001750�00000001233�13430006220�021521� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 2 to version 3 of Yokadi DB @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or newer """ def createProjectKeywordTable(cursor): cursor.execute(""" create table project_keyword ( id integer not null, project_id integer, keyword_id integer, primary key (id), unique (project_id, keyword_id), foreign key(project_id) references project (id), foreign key(keyword_id) references keyword (id) ) """) def update(cursor): createProjectKeywordTable(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update.py����������������������������������������������������������������0000775�0001750�0001750�00000012070�13430006220�021015� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#!/usr/bin/env python3 # -*- coding: UTF-8 -*- """ This script updates a Yokadi database to the latest version @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ import os import shutil import sqlite3 import sys import time from argparse import ArgumentParser from tempfile import TemporaryDirectory from yokadi.core import db from yokadi.update import updateutils # 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) return database.getVersion() def setVersion(fileName, version): database = db.Database(fileName, createIfNeeded=False, updateMode=True) database.setVersion(version) def importTable(dstCursor, srcCursor, table): columns = updateutils.getTableColumnList(dstCursor, table) columnString = ", ".join(columns) sql = "select {} from {}".format(columnString, table) placeHolders = ", ".join(["?"] * len(columns)) insertSql = "insert into {}({}) values({})".format(table, columnString, placeHolders) query = srcCursor.execute(sql) while True: rows = query.fetchmany(size=100) if not rows: break dstCursor.executemany(insertSql, rows) def recreateDb(workPath, destPath): assert os.path.exists(workPath) print("Recreating the database") database = db.Database(destPath, createIfNeeded=True, updateMode=True) # noqa print("Importing content to the new database") srcConn = sqlite3.connect(workPath) srcCursor = srcConn.cursor() dstConn = sqlite3.connect(destPath) dstCursor = dstConn.cursor() for table in updateutils.getTableList(dstCursor): importTable(dstCursor, srcCursor, table) dstConn.commit() def err(message): print("error: " + message, file=sys.stderr) def update(dbPath, newDbPath=None, inplace=True): # Check paths if not os.path.exists(dbPath): err("'{}' does not exist.".format(dbPath)) return 1 if not inplace and os.path.exists(newDbPath): err("'{}' already exists.".format(newDbPath)) return 1 # Check version version = getVersion(dbPath) print("Found version %d" % version) if version == db.DB_VERSION: print("Nothing to do") return 0 if inplace: destDir = os.path.dirname(dbPath) else: destDir = os.path.dirname(newDbPath) with TemporaryDirectory(prefix="yokadi-update-", dir=destDir) as tempDir: # Copy the DB workDbPath = os.path.join(tempDir, "work.db") shutil.copy(dbPath, workDbPath) # Start import oldVersion = getVersion(workDbPath) with sqlite3.connect(workDbPath) as conn: cursor = conn.cursor() for version in range(oldVersion, db.DB_VERSION): moduleName = "update{}to{}".format(version, version + 1) print("Updating to {}".format(version + 1)) function = globals()[moduleName].update function(cursor) setVersion(workDbPath, db.DB_VERSION) # Recreate the DB recreatedDbPath = os.path.join(tempDir, "recreated.db") recreateDb(workDbPath, recreatedDbPath) # Move to final paths if inplace: base, ext = os.path.splitext(dbPath) timestamp = time.strftime("%Y%m%d") backupPath = base + "-v{}-{}".format(oldVersion, timestamp) + ext os.rename(dbPath, backupPath) print("Old database renamed to {}".format(backupPath)) os.rename(recreatedDbPath, dbPath) else: os.rename(recreatedDbPath, newDbPath) return 0 def main(): # Parse args parser = ArgumentParser() parser.add_argument('current', metavar='<path/to/current.db>', help="Path to the database to update.") parser.add_argument('updated', metavar='<path/to/updated.db>', 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") args = parser.parse_args() dbPath = os.path.abspath(args.current) if args.inplace: newDbPath = None else: newDbPath = os.path.abspath(args.updated) try: return update(dbPath, newDbPath, inplace=args.inplace) except updateutils.UpdateError as exc: err(str(exc)) return 1 if __name__ == "__main__": sys.exit(main()) # vi: ts=4 sw=4 et ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update3to4.py������������������������������������������������������������0000664�0001750�0001750�00000001435�13430006220�021527� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 3 to version 4 of Yokadi DB @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or newer """ def createRecurrenceTable(cursor): cursor.execute(""" create table recurrence ( id integer not null, rule varchar, primary key (id) ) """) def addTaskRecurrenceIdColumn(cursor): cursor.execute("alter table task add column recurrence_id integer references recurrence(id)") def removeDefaultProject(cursor): cursor.execute("delete from config where name='DEFAULT_PROJECT'") def update(cursor): createRecurrenceTable(cursor) addTaskRecurrenceIdColumn(cursor) removeDefaultProject(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update10to11.py����������������������������������������������������������0000664�0001750�0001750�00000001636�13430006220�021666� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" 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 <mail@agateau.com> @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 ��������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update11to12.py����������������������������������������������������������0000664�0001750�0001750�00000005723�13430006220�021671� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Update from version 11 to version 12 of Yokadi DB - Decrypt all encrypted tasks @author: Aurélien Gâteau <mail@agateau.com> @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 ���������������������������������������������yokadi-1.2.0/yokadi/update/update1to2.py������������������������������������������������������������0000664�0001750�0001750�00000003526�13430006220�021526� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 1 to version 2 of Yokadi DB @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ def createConfigTable(cursor): cursor.execute("""create table config ( id integer not null, name varchar, value varchar, system boolean, "desc" varchar, primary key (id), unique (name), check (system in (0, 1)) );""") rows = [ ("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") ] 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)) def addProjectActiveColumn(cursor): cursor.execute("alter table project add column active boolean") cursor.execute("update project set active = 1") def addTableDueDateColumn(cursor): cursor.execute("alter table task add column due_date datetime") def update(cursor): createConfigTable(cursor) addProjectActiveColumn(cursor) addTableDueDateColumn(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update4to5.py������������������������������������������������������������0000664�0001750�0001750�00000001206�13430006220�021525� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 4 to version 5 of Yokadi DB @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @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)) def removeTextWidthParam(cursor): cursor.execute("delete from config where name='TEXT_WIDTH'") def update(cursor): updateBugsKeywordsNames(cursor) removeTextWidthParam(cursor) if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/update/update8to9.py������������������������������������������������������������0000664�0001750�0001750�00000003514�13430006220�021541� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Update from version 8 to version 9 of Yokadi DB - Delete invalid TaskKeyword rows - Move aliases to an Alias table - Add an uuid column to Task and Project tables @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or newer """ from uuid import uuid1 def deleteInvalidTaskKeywordRows(cursor): cursor.execute('delete from task_keyword where task_id is null or keyword_id is null') def addAliasTable(cursor): cursor.execute("""create table alias ( uuid varchar not null primary key, name varchar not null, command varchar not null, unique(name) )""") def migrateAliases(cursor): row = cursor.execute("select value from config where name = 'ALIASES'").fetchone() if not row: return aliasesString = row[0] try: aliases = eval(aliasesString) except Exception: # Failed to parse aliases print("Failed to parse v9 aliases: {}".format(aliasesString)) return for name, command in aliases.items(): uuid = str(uuid1()) cursor.execute("insert into alias(uuid, name, command) values(?, ?, ?)", (uuid, name, command)) cursor.execute("delete from config where name = 'ALIASES'") def addUuidColumn(cursor, tableName): cursor.execute("alter table {} add column uuid varchar".format(tableName)) 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)) def update(cursor): deleteInvalidTaskKeywordRows(cursor) addAliasTable(cursor) migrateAliases(cursor) addUuidColumn(cursor, "project") addUuidColumn(cursor, "task") if __name__ == "__main__": import updateutils updateutils.main(update) # vi: ts=4 sw=4 et ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/__init__.py���������������������������������������������������������������������0000664�0001750�0001750�00000000317�13430006220�020006� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: utf-8 -*- """ Yokadi main package @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license:GPL v3 or later """ __version__ = "1.2.0" �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/yical/��������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�016776� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/yical/icalutils.py��������������������������������������������������������������0000664�0001750�0001750�00000002404�13430006220�021340� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# coding:utf-8 """ Ical utils functions @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or later """ 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 @return: python type""" if isinstance(attr, (icalendar.vDate, icalendar.vDatetime, icalendar.vDuration, icalendar.vDDDTypes)): return attr.dt elif isinstance(attr, (icalendar.vInt, icalendar.vFloat)): return int(attr) else: # Default to unicode string return str(attr) def icalPriorityToYokadiUrgency(priority): """Convert ical priority (1 / 9) to yokadi urgency (100 / -99) @param priority: ical priority @return: yokadi urgency""" urgency = 100 - 20 * priority return _clamp(urgency, -99, 100) def yokadiUrgencyToIcalPriority(urgency): """Convert yokadi urgency (100 / -99) to ical priority (1 / 9) @param urgency: yokadi urgency @return: ical priority""" priority = int(-(urgency - 100) / 20) return _clamp(priority, 1, 9) # vi: ts=4 sw=4 et ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/yical/yical.py������������������������������������������������������������������0000664�0001750�0001750�00000020424�13430006220�020452� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Yokadi iCalendar interface @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ import sys try: import icalendar except ImportError: print("You don't have the icalendar package.") print("Get it on http://pypi.python.org/pypi/icalendar/") print("Or use 'easy_install icalendar'") sys.exit(1) if not hasattr(icalendar.Calendar, "from_ical"): print("Your version of icalendar is outdated: you need icalendar > 3.0.") sys.exit(1) import http.server from threading import Thread import re from yokadi.core import db from yokadi.core.db import Task, Project from yokadi.core import dbutils from yokadi.yical import icalutils from yokadi.ycli import parseutils from yokadi.core.yokadiexception import YokadiException # UID pattern UID_PREFIX = "yokadi" TASK_UID = UID_PREFIX + "-task-%s" TASK_RE = re.compile(TASK_UID.replace("%s", r"(\d+)")) PROJECT_UID = UID_PREFIX + "-project-%s" # Default project where new task are added # TODO: make this a configurable items via c_set INBOX_PROJECT = "inbox" # Yokadi task <=> iCalendar VTODO attribute mapping YOKADI_ICAL_ATT_MAPPING = {"title": "summary", "urgency": "priority", "creationDate": "dtstart", "dueDate": "due", "doneDate": "completed", "description": "description"} def generateCal(): """Generate an ical calendar from yokadi database @return: icalendar.Calendar object""" session = db.getSession() cal = icalendar.Calendar() 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): # noqa vTodo = icalendar.Todo() vTodo.add("summary", project.name) vTodo["uid"] = PROJECT_UID % project.id cal.add_component(vTodo) # Add tasks for task in session.query(Task).filter(Task.status != "done"): vTodo = createVTodoFromTask(task) cal.add_component(vTodo) return cal def createVTodoFromTask(task): """Create a VTodo object from a yokadi task @param task: yokadi task (db.Task object) @return: ical VTODO (icalendar.Calendar.Todo object)""" vTodo = icalendar.Todo() vTodo["uid"] = TASK_UID % task.id vTodo["related-to"] = PROJECT_UID % task.project.id # Add standard attribute for yokadiAttribute, icalAttribute in list(YOKADI_ICAL_ATT_MAPPING.items()): attr = getattr(task, yokadiAttribute) if attr: if yokadiAttribute == "urgency": attr = icalutils.yokadiUrgencyToIcalPriority(attr) if yokadiAttribute == "title": attr += " (%s)" % task.id vTodo.add(icalAttribute, attr) # Add categories from keywords categories = [] if task.taskKeywords: for name, value in list(task.getKeywordDict().items()): if value: categories.append("%s=%s" % (name, value)) else: categories.append(name) vTodo.add("categories", categories) return vTodo def updateTaskFromVTodo(task, vTodo): """Update a yokadi task with an ical VTODO object @param task: yokadi task (db.Task object) @param vTodo: ical VTODO (icalendar.Calendar.Todo object)""" for yokadiAttribute, icalAttribute in list(YOKADI_ICAL_ATT_MAPPING.items()): attr = vTodo.get(icalAttribute) if attr: # Convert ical type (vDates, vInt..) to sql alchemy understandable type (datetime, int...) attr = icalutils.convertIcalType(attr) if yokadiAttribute == "title": # Remove (id) attr = re.sub(r"\s?\(%s\)" % task.id, "", attr) if yokadiAttribute == "doneDate": # A done date defined indicate that task is done task.status = "done" # BUG: Done date is UTC, we must compute local time for yokadi if yokadiAttribute == "urgency": if attr == icalutils.yokadiUrgencyToIcalPriority(task.urgency): # Priority does not change - don't update it continue else: # Priority has changed, we need to update urgency attr = icalutils.icalPriorityToYokadiUrgency(int(attr)) # Update attribute setattr(task, yokadiAttribute, attr) # Update keywords from categories if vTodo.get("categories"): if isinstance(vTodo.get("categories"), (list)): categories = vTodo.get("categories") else: categories = vTodo.get("categories").split(",") keywords = ["@%s" % k for k in categories] garbage, keywordFilters = parseutils.extractKeywords(" ".join(keywords)) newKwDict = parseutils.keywordFiltersToDict(keywordFilters) if garbage: print("Got garbage while parsing categories: %s" % garbage) 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 def do_GET(self): """Serve a GET request with complete todolist ignoring path""" self.send_response(200) self.end_headers() cal = generateCal() self.wfile.write(cal.to_ical()) def do_PUT(self): """Receive a todolist for updating""" length = int(self.headers.getheader('content-length')) cal = icalendar.Calendar.from_ical(self.rfile.read(length)) for vTodo in cal.walk(): if "UID" in vTodo: try: self._processVTodo(vTodo) except YokadiException as e: self.send_response(503, e) # Tell caller everything is ok self.send_response(200) self.end_headers() def _processVTodo(self, vTodo): session = db.getSession() if vTodo["UID"] in self.newTask: # This is a recent new task but remote ical calendar tool is not # aware of new Yokadi UID. Update it here to avoid duplicate new tasks print("update UID to avoid duplicate task") vTodo["UID"] = TASK_UID % self.newTask[vTodo["UID"]] if vTodo["UID"].startswith(UID_PREFIX): # This is a yokadi Task. if vTodo["LAST-MODIFIED"].dt > vTodo["CREATED"].dt: # Task has been modified print("Modified task: %s" % vTodo["UID"]) result = TASK_RE.match(vTodo["UID"]) if result: id = result.group(1) task = dbutils.getTaskFromId(id) print("Task found in yokadi db: %s" % task.title) updateTaskFromVTodo(task, vTodo) session.merge(task) session.commit() else: raise YokadiException("Task %s does exist in yokadi db " % id) else: # This is a new task print("New task %s (%s)" % (vTodo["summary"], vTodo["UID"])) keywordDict = {} task = dbutils.addTask(INBOX_PROJECT, vTodo["summary"], keywordDict, interactive=False) session.add(task) session.commit() # Keep record of new task origin UID to avoid duplicate # if user update it right after creation without reloading the # yokadi UID # TODO: add purge for old UID self.newTask[vTodo["UID"]] = task.id class YokadiIcalServer(Thread): def __init__(self, port, listen): self.port = port if listen: self.address = "" else: self.address = "127.0.0.1" Thread.__init__(self) self.setDaemon(True) def run(self): """Method executed when the thread object start() method is called""" print("IcalServer starting...") icalServer = http.server.HTTPServer((self.address, self.port), IcalHttpRequestHandler) icalServer.serve_forever() print("IcalServer crash. Oups !") # vi: ts=4 sw=4 et ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/yical/__init__.py���������������������������������������������������������������0000664�0001750�0001750�00000000277�13430006220�021114� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: utf-8 -*- """ Yokadi ical daemon package @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license:GPL v3 or later """ ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/yokadid.py����������������������������������������������������������������������0000775�0001750�0001750�00000020433�13430006220�017677� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#!/usr/bin/env python3 # -*- coding: UTF-8 -*- """ Yokadi daemon. Used to monitor due tasks and warn user. @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ import sys import os import time from datetime import datetime, timedelta from signal import SIGTERM, SIGHUP, signal from subprocess import Popen from argparse import ArgumentParser from yokadi.core import fileutils try: import setproctitle except ImportError: print("You don't have the setproctitle package.") print("Get it on http://pypi.python.org/pypi/setproctitle/") print("Or use 'easy_install setproctitle'") sys.exit(1) from yokadi.core.daemon import Daemon from yokadi.core import basepaths from yokadi.yical.yical import YokadiIcalServer from yokadi.core import db from yokadi.core.db import Project, Task, getConfigKey from yokadi.ycli import commonargs # Daemon polling delay (in seconds) PROCESS_INTERVAL = 30 EVENTLOOP_INTERVAL = 1 # Ical daemon default port DEFAULT_TCP_ICAL_PORT = 8000 # Event sender to main loop event = [True, ""] def sigTermHandler(signal, stack): """Handler when yokadid receive SIGTERM""" print("Receive SIGTERM. Exiting") print("End of yokadi Daemon") event[0] = False event[1] = "SIGTERM" def sigHupHandler(signal, stack): """Handler when yokadid receive SIGHUP""" print("Receive SIGHUP. Reloading configuration") event[0] = False event[1] = "SIGHUP" def eventLoop(): """Main event loop""" delta = timedelta(hours=float(getConfigKey("ALARM_DELAY"))) suspend = timedelta(hours=float(getConfigKey("ALARM_SUSPEND"))) cmdDelayTemplate = getConfigKey("ALARM_DELAY_CMD") cmdDueTemplate = getConfigKey("ALARM_DUE_CMD") session = db.getSession() # For the two following dict, task id is key, and value is (duedate, triggerdate) triggeredDelayTasks = {} triggeredDueTasks = {} activeTaskFilter = [Task.status != "done", Task.projectId == Project.id, Project.active == True] # noqa def process(now): delayTasks = session.query(Task).filter(Task.dueDate < now + delta, Task.dueDate > now, *activeTaskFilter) dueTasks = session.query(Task).filter(Task.dueDate < now, *activeTaskFilter) processTasks(delayTasks, triggeredDelayTasks, cmdDelayTemplate, suspend) processTasks(dueTasks, triggeredDueTasks, cmdDueTemplate, suspend) 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): """Process a list of tasks and trigger action if needed @param tasks: list of tasks @param triggeredTasks: dict of tasks that has been triggered. Dict can be updated @param cmdTemplate: command line template to execute if task trigger @param suspend: timedelta beetween to task trigger""" now = datetime.now() for task in tasks: if task.id in triggeredTasks and triggeredTasks[task.id][0] == task.dueDate: # This task with the same dueDate has already been triggered if now - triggeredTasks[task.id][1] < suspend: # Task has been trigger recently, skip to next continue print("Task %s is due soon" % task.title) cmd = cmdTemplate.replace("{ID}", str(task.id)) cmd = cmd.replace("{TITLE}", task.title.replace('"', '\"')) cmd = cmd.replace("{PROJECT}", task.project.name.replace('"', '\"')) cmd = cmd.replace("{DATE}", str(task.dueDate)) process = Popen(cmd, shell=True) process.wait() # TODO: redirect stdout/stderr properly to Log (not so easy...) triggeredTasks[task.id] = (task.dueDate, datetime.now()) def killYokadid(pidFile): """Kill Yokadi daemon @param pidFile: file where the pid of the daemon is stored """ # reuse Daemon.stop() code daemon = Daemon(pidFile) daemon.stop() def parseOptions(defaultPidFile, defaultLogFile): parser = ArgumentParser() commonargs.addArgs(parser) parser.add_argument("-i", "--icalserver", 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") parser.add_argument("-l", "--listen", 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") 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") parser.add_argument("-f", "--foreground", 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) parser.add_argument("--log", 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, dbPath, options): Daemon.__init__(self, options.pidFile, stdout=options.logFile, stderr=options.logFile) self.dbPath = dbPath self.options = options def run(self): 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: print("Your database seems broken or not initialised properly. Start yokadi command line tool to do it") sys.exit(1) # Start ical http handler if self.options.icalserver: yokadiIcalServer = YokadiIcalServer(self.options.tcpPort, self.options.tcpListen) yokadiIcalServer.start() # Start the main event Loop try: while event[1] != "SIGTERM": eventLoop() event[0] = True except KeyboardInterrupt: print("\nExiting...") def main(): # TODO: check that yokadid is not already running for this database ? Not very harmful... # Set process name to "yokadid" setproctitle.setproctitle("yokadid") # Make the event list global to allow communication with main event loop global event 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) sys.exit(0) if args.pidFile == defaultPidFile: fileutils.createParentDirs(args.pidFile, mode=0o700) if args.logFile == defaultLogFile: fileutils.createParentDirs(args.logFile, mode=0o700) signal(SIGTERM, sigTermHandler) signal(SIGHUP, sigHupHandler) if args.restart: daemon = YokadiDaemon(dbPath, args) daemon.restart() daemon = YokadiDaemon(dbPath, args) if args.foreground: daemon.run() else: daemon.start() if __name__ == "__main__": main() �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/��������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�017037� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/keywordtestcase.py��������������������������������������������������������0000664�0001750�0001750�00000006224�13430006220�022634� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Task test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from sqlalchemy.orm.exc import NoResultFound from yokadi.core import dbutils from yokadi.ycli import tui from yokadi.ycli.keywordcmd import KeywordCmd, _listKeywords from yokadi.core.yokadiexception import YokadiException from yokadi.core import db class KeywordTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() tui.clearInputAnswers() self.cmd = KeywordCmd() def testKEditNoMerge(self): t1 = dbutils.addTask("x", "t1", dict(k1=12, k2=None), interactive=False) tui.addInputAnswers("newk1") self.cmd.do_k_edit("k1") kwDict = t1.getKeywordDict() self.assertTrue("k1" not in kwDict) self.assertEqual(kwDict.get("newk1"), 12) self.assertRaises(YokadiException, dbutils.getKeywordFromName, "k1") def testKEditMerge(self): t1 = dbutils.addTask("x", "t1", dict(k1=None, k2=None), interactive=False) t2 = dbutils.addTask("x", "t2", dict(k1=None), interactive=False) tui.addInputAnswers("k2", "y") self.cmd.do_k_edit("k1") kwDict = t1.getKeywordDict() self.assertTrue("k1" not in kwDict) self.assertTrue("k2" in kwDict) kwDict = t2.getKeywordDict() self.assertTrue("k1" not in kwDict) self.assertTrue("k2" in kwDict) self.assertRaises(YokadiException, dbutils.getKeywordFromName, "k1") def testKEditCannotMerge(self): """ One can't merge keywords if they have different values """ t1 = dbutils.addTask("x", "t1", dict(k1=12, k2=None), interactive=False) tui.addInputAnswers("k2", "y") self.cmd.do_k_edit("k1") kwDict = t1.getKeywordDict() self.assertTrue("k1" in kwDict) self.assertTrue("k2" in kwDict) dbutils.getKeywordFromName("k1") def testKRemove(self): t1 = dbutils.addTask("x", "t1", dict(k1=12, k2=None), interactive=False) tui.addInputAnswers("y") self.cmd.do_k_remove("k1") kwDict = t1.getKeywordDict() self.assertFalse("k1" in kwDict) 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 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/dbutilstestcase.py��������������������������������������������������������0000664�0001750�0001750�00000004766�13430006220�022627� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Date utilities test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from datetime import datetime from yokadi.core import dbutils, db from yokadi.ycli import tui from yokadi.core.db import Keyword, Project from yokadi.core.yokadiexception import YokadiException class DbUtilsTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() tui.clearInputAnswers() def testGetTaskFromId(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) 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") dbutils.getOrCreateKeyword("k1") self.session.query(Keyword).filter_by(name="k1").one() # !interactive dbutils.getOrCreateKeyword("k2", interactive=False) self.session.query(Keyword).filter_by(name="k2").one() def testGetOrCreateProject(self): # interactive tui.addInputAnswers("y") dbutils.getOrCreateProject("p1") self.session.query(Project).filter_by(name="p1").one() # !interactive dbutils.getOrCreateProject("p2", interactive=False) self.session.query(Project).filter_by(name="p2").one() def testGetKeywordFromName(self): tui.addInputAnswers("y") k1 = dbutils.getOrCreateKeyword("k1", self.session) self.assertRaises(YokadiException, dbutils.getKeywordFromName, "") self.assertRaises(YokadiException, dbutils.getKeywordFromName, "foo") self.assertEqual(k1, dbutils.getKeywordFromName("k1")) def testTaskLockManagerStaleLock(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) taskLockManager = dbutils.TaskLockManager(t1) # Lock the task taskLockManager.acquire(pid=1, now=datetime(2014, 1, 1)) lock1 = taskLockManager._getLock() self.assertEqual(lock1.pid, 1) # Try to lock again, the stale lock should get reused taskLockManager.acquire(pid=2, now=datetime(2015, 1, 1)) lock2 = taskLockManager._getLock() self.assertEqual(lock1.id, lock2.id) self.assertEqual(lock2.pid, 2) # vi: ts=4 sw=4 et ����������yokadi-1.2.0/yokadi/tests/argstestcase.py�����������������������������������������������������������0000664�0001750�0001750�00000006341�13430006220�022104� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Command line argument test cases @author: Aurélien Gâteau <mail@agateau.com> @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")) �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/massedittestcase.py�������������������������������������������������������0000664�0001750�0001750�00000011151�13430006220�022754� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Mass edit test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from yokadi.core import db from yokadi.core.db import NOTE_KEYWORD from yokadi.core import dbutils from yokadi.core.yokadiexception import YokadiException from yokadi.ycli import massedit from yokadi.ycli import tui from yokadi.ycli.massedit import MEditEntry, parseMEditText, ParseError class MassEditTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) # FIXME: Do this in db dbutils.getOrCreateKeyword(NOTE_KEYWORD, interactive=False) self.session = db.getSession() tui.clearInputAnswers() def testApplyMEditChanges(self): prj = dbutils.getOrCreateProject("p1", interactive=False) t1 = dbutils.addTask("p1", "Change text", {}) tui.addInputAnswers("y", "y") t2 = dbutils.addTask("p1", "Change keywords", {"k1": None, "k2": 1}) t3 = dbutils.addTask("p1", "Done", {}) t3.status = "started" self.session.merge(t3) t4 = dbutils.addTask("p1", "Deleted", {}) t5 = dbutils.addTask("p1", "Moved", {}) self.session.commit() deletedId = t4.id oldList = massedit.createEntriesForProject(prj) newList = [ MEditEntry(None, "new", u"Added", {}), MEditEntry(t1.id, "new", u"New text", {}), MEditEntry(t2.id, "new", u"Change keywords", {"k2": 2, "k3": None}), MEditEntry(t5.id, "new", u"Moved", {}), MEditEntry(t3.id, "done", u"Done", {}), ] massedit.applyChanges(prj, oldList, newList, interactive=False) self.session.commit() newTask = self.session.query(db.Task).filter_by(title=u"Added").one() self.assertEqual(t1.title, u"New text") self.assertEqual(t2.getKeywordDict(), {"k2": 2, "k3": None}) self.assertEqual(t3.status, "done") self.assertTrue(t3.doneDate) self.assertRaises(YokadiException, dbutils.getTaskFromId, deletedId) self.assertEqual(newTask.urgency, 5) self.assertEqual(t1.urgency, 4) self.assertEqual(t2.urgency, 3) self.assertEqual(t5.urgency, 2) self.assertEqual(t3.urgency, 1) def testApplyMEditChangesUnknownIds(self): prj = dbutils.getOrCreateProject("p1", interactive=False) t1 = dbutils.addTask("p1", "Foo", {}) t2 = dbutils.addTask("p1", "Bar", {}) oldList = massedit.createEntriesForProject(prj) newList = [ MEditEntry(t1.id, "new", t1.title, {}), MEditEntry(t2.id + 1, "new", t2.title, {}), ] self.assertRaises(YokadiException, massedit.applyChanges, prj, oldList, newList, interactive=False) def testParseMEditText(self): text = """1 N Hello 4 N Some keywords @foo @bar=1 6 S A started task 12 D A done task - A newly added task - OneWordNewTask # A comment """ expected = [ MEditEntry(1, "new", u"Hello", {}), MEditEntry(4, "new", u"Some keywords", {"foo": None, "bar": 1}), MEditEntry(6, "started", u"A started task", {}), MEditEntry(12, "done", u"A done task", {}), MEditEntry(None, "new", u"A newly added task", {}), MEditEntry(None, "new", u"OneWordNewTask", {}), ] output = parseMEditText(text) self.assertEqual(output, expected) def testParseMEditTextErrors(self): testData = [ # Duplicate id """ 1 N X 1 N Y """, # Invalid id """ A N X """, # Invalid status """ 1 z Y """, # Invalid line """ bla """ ] for text in testData: self.assertRaises(ParseError, parseMEditText, text) def testOnlyListTasks(self): prj = dbutils.getOrCreateProject("p1", interactive=False) dbutils.addTask("p1", "Task", {}) dbutils.addTask("p1", "Note", {NOTE_KEYWORD: None}) oldList = massedit.createEntriesForProject(prj) self.assertEqual(len(oldList), 1) def testCreateMEditText(self): e1 = MEditEntry(1, "N", "Hello", {}) e2 = MEditEntry(2, "S", "Started", {}) EXPECTED_TEXT = """1 N Hello 2 S Started # doc1 # # doc2 """ txt = massedit.createMEditText([e1, e2], docComment="doc1\n\ndoc2\n") self.assertEqual(txt, EXPECTED_TEXT) # vi: ts=4 sw=4 et �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/testutils.py��������������������������������������������������������������0000664�0001750�0001750�00000003207�13430006220�021452� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Utils for unit-test @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ from collections import OrderedDict import os def multiLinesAssertEqual(test, str1, str2): lst1 = str1.splitlines() lst2 = str2.splitlines() for row, lines in enumerate(zip(lst1, lst2)): line1, line2 = lines test.assertEqual(line1, line2, "Error line %d:\n%r\n!=\n%r" % (row + 1, line1, line2)) 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 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 �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/bugtestcase.py������������������������������������������������������������0000664�0001750�0001750�00000003422�13430006220�021722� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Bug test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from yokadi.ycli import tui from yokadi.ycli.main import YokadiCmd from yokadi.core import db, dbutils from yokadi.core.db import Task, setDefaultConfig from yokadi.core.yokadiexception import YokadiException class BugTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() setDefaultConfig() tui.clearInputAnswers() self.cmd = YokadiCmd() def testAdd(self): tui.addInputAnswers("y", "2", "4", "123") self.cmd.do_bug_add("x t1") tui.addInputAnswers("n") self.cmd.do_bug_add("notExistingProject newBug") tasks = self.session.query(Task).all() result = [x.title for x in tasks] expected = ["t1"] self.assertEqual(result, expected) kwDict = self.session.query(Task).get(1).getKeywordDict() self.assertEqual(kwDict, dict(_severity=2, _likelihood=4, _bug=123)) for bad_input in ("", # No project "x"): # No task name self.assertRaises(YokadiException, self.cmd.do_bug_add, bad_input) def testEdit(self): task = dbutils.addTask("prj", "bug", interactive=False) kwDict = dict(_severity=1, _likelihood=2, _bug=3) task.setKeywordDict(kwDict) self.session.commit() tui.addInputAnswers("bug edited", "2", "4", "6") self.cmd.do_bug_edit(str(task.id)) task = dbutils.getTaskFromId(task.id) self.assertEqual(task.title, "bug edited") kwDict = task.getKeywordDict() self.assertEqual(kwDict, dict(_severity=2, _likelihood=4, _bug=6)) # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/helptestcase.py�����������������������������������������������������������0000664�0001750�0001750�00000003276�13430006220�022104� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Help test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import os import sys import unittest from cmd import Cmd from contextlib import contextmanager from yokadi.core import db from yokadi.ycli.main import YokadiCmd @contextmanager def to_devnull(out): out_fd = out.fileno() with open(os.devnull, "wb") as null, \ os.fdopen(os.dup(out_fd), "wb") as copied: out.flush() os.dup2(null.fileno(), out_fd) try: yield finally: out.flush() os.dup2(copied.fileno(), out_fd) class HelpTestCase(unittest.TestCase): """ A basic test for the command helps: it just execute 'help <cmd>' on all commands. This catches invalid format characters in the help strings. """ def setUp(self): # Some help commands look into the db for default values db.connectDatabase("", memoryDatabase=True) db.setDefaultConfig() def testHelp(self): cmd = YokadiCmd() for attr in dir(cmd): if not attr.startswith("do_"): continue yokadiCommand = attr[3:] try: # Execute the command, but redirect stdout and stderr to # /dev/null to avoid flooding the terminal with to_devnull(sys.stdout), to_devnull(sys.stderr): # We use Cmd implementation of onecmd() because YokadiCmd # overrides it to catch exceptions Cmd.onecmd(cmd, "help " + yokadiCommand) except Exception: print("'help %s' failed" % yokadiCommand) raise # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/keywordfiltertestcase.py��������������������������������������������������0000664�0001750�0001750�00000002376�13430006220�024046� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Keyword filter test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from yokadi.core import db, dbutils from yokadi.core.dbutils import KeywordFilter class KeywordFilterTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() def testSimpleFilter(self): t1 = dbutils.addTask("p1", "t1", interactive=False) t2 = dbutils.addTask("p1", "t2", keywordDict={"k1": None}, interactive=False) t3 = dbutils.addTask("p1", "t3", keywordDict={"k2": None}, interactive=False) t4 = dbutils.addTask("p1", "t4", keywordDict={"k1": None, "k2": None}, interactive=False) testData = [ (KeywordFilter("k1"), {t2, t4}), (KeywordFilter("k%"), {t2, t3, t4}), (KeywordFilter("k1", negative=True), {t1, t3}), (KeywordFilter("k%", negative=True), {t1}), ] for flt, expected in testData: query = self.session.query(db.Task) query = flt.apply(query) resultSet = {x.title for x in query} expectedSet = {x.title for x in expected} self.assertEqual(resultSet, expectedSet) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/projecttestcase.py��������������������������������������������������������0000664�0001750�0001750�00000006651�13430006220�022622� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Project test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest import testutils from yokadi.core import db, dbutils from yokadi.core.db import Project, Task from yokadi.core.yokadiexception import YokadiException from yokadi.ycli.main import YokadiCmd from yokadi.ycli import tui class ProjectTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() tui.clearInputAnswers() self.cmd = YokadiCmd() def testAdd(self): tui.addInputAnswers("y") self.cmd.do_p_add("p1") self.cmd.do_p_add("p2") projects = self.session.query(Project).all() result = [x.name for x in projects] expected = ["p1", "p2"] self.assertEqual(result, expected) def testEdit(self): # Create project p1 and rename it to p2 self.cmd.do_p_add("p1") project = self.session.query(Project).filter_by(id=1).one() self.assertEqual(project.name, "p1") tui.addInputAnswers("p2") self.cmd.do_p_edit("p1") self.assertEqual(project.name, "p2") # Create project p3 and try to rename it to p2 self.cmd.do_p_add("p3") project = self.session.query(Project).filter_by(name="p3").one() self.assertEqual(project.name, "p3") tui.addInputAnswers("p2") self.assertRaises(YokadiException, self.cmd.do_p_edit, "p3") self.assertEqual(project.name, "p3") def testRemove(self): # Create project p1 with one associated task tui.addInputAnswers("y") self.cmd.do_p_add("p1") self.session.query(Project).one() task = dbutils.addTask("p1", "t1", interactive=False) taskId = task.id # Remove project, its task should be removed tui.addInputAnswers("y") self.cmd.do_p_remove("p1") self.assertEqual(list(self.session.query(Task).filter_by(id=taskId)), []) def testStatus(self): # Create project p1 and test set active and set inactive method self.cmd.do_p_add("p1") project = self.session.query(Project).filter_by(id=1).one() self.assertEqual(project.name, "p1") self.assertEqual(project.active, True) self.cmd.do_p_set_inactive("p1") self.assertEqual(project.active, False) 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 ���������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/conftestcase.py�����������������������������������������������������������0000664�0001750�0001750�00000002577�13430006220�022104� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Alias test cases @author: Benjamin Port <benjamin.port@ben2367.fr> @license: GPL v3 or later """ import unittest import sys from io import StringIO from yokadi.core import db from yokadi.core.db import setDefaultConfig from yokadi.core.yokadiexception import YokadiException from yokadi.ycli import tui from yokadi.ycli.main import YokadiCmd class ConfTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) setDefaultConfig() self.session = db.getSession() tui.clearInputAnswers() self.cmd = YokadiCmd() def testConfig(self): out = StringIO() oldstdout = sys.stdout tui.stdout = out self.cmd.do_c_set("ALARM_DELAY 69") self.cmd.do_c_get("ALARM_DELAY") self.assertTrue("ALARM_DELAY" in out.getvalue()) self.assertTrue("69" in out.getvalue()) tui.stdout = oldstdout def testPositiveValueConfig(self): self.assertRaises(YokadiException, self.cmd.do_c_set, "ALARM_DELAY -1") self.assertRaises(YokadiException, self.cmd.do_c_set, "ALARM_SUSPEND -1") self.assertRaises(YokadiException, self.cmd.do_c_set, "PURGE_DELAY -1") def testWrongKey(self): self.assertRaises(YokadiException, self.cmd.do_c_set, "BAD_KEY value") self.assertRaises(YokadiException, self.cmd.do_c_get, "BAD_KEY") ���������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/aliastestcase.py����������������������������������������������������������0000664�0001750�0001750�00000004074�13430006220�022242� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Alias test cases @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or later """ import unittest from contextlib import redirect_stdout from io import StringIO from yokadi.core import db from yokadi.core.db import Alias from yokadi.ycli.aliascmd import AliasCmd from yokadi.ycli import colors from yokadi.ycli import tui class AliasTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() self.cmd = AliasCmd() def testList(self): self.cmd.do_a_add("b t_add") self.cmd.do_a_add("a t_list") out = StringIO() with redirect_stdout(out): self.cmd.do_a_list("") content = out.getvalue() self.assertEqual(content, 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() with redirect_stdout(out): self.cmd.do_a_list("") content = out.getvalue() self.assertTrue("No alias defined" in content) def testAdd(self): self.cmd.do_a_add("l t_list") self.cmd.do_a_add("la t_list -a") aliases = Alias.getAsDict(self.session) self.assertEqual(aliases["l"], "t_list") self.cmd.do_a_remove("l") self.cmd.do_a_remove("la") self.cmd.do_a_remove("unknown") aliases = Alias.getAsDict(self.session) self.assertEqual(aliases, {}) def testEditName(self): self.cmd.do_a_add("l t_list") tui.addInputAnswers("ls") self.cmd.do_a_edit_name("l") aliases = Alias.getAsDict(self.session) self.assertEqual(aliases["ls"], "t_list") def testEditCommand(self): self.cmd.do_a_add("l t_list") tui.addInputAnswers("foo") self.cmd.do_a_edit_command("l") aliases = Alias.getAsDict(self.session) self.assertEqual(aliases["l"], "foo") ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/parseutilstestcase.py�����������������������������������������������������0000664�0001750�0001750�00000002441�13430006220�023340� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Parser utilities test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest 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 ééé", ("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): def testExtractKeywords(self): for src, dst in gTaskLineToParsedStructList: result = parseutils.parseLine(src) self.assertEqual(result, dst) def testCreateLine(self): for dummy, parsedStruct in gTaskLineToParsedStructList: # We do not check the result of createLine() against the # original task line because there are many ways to write the same # taskLine. taskLine = parseutils.createLine(*parsedStruct) result = parseutils.parseLine(taskLine) self.assertEqual(result, parsedStruct) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/yokaditestcase.py���������������������������������������������������������0000664�0001750�0001750�00000001566�13430006220�022434� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Yokadi base class for test cases @author: Aurélien Gâteau <mail@agateau.com> @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) ������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/tuitestcase.py������������������������������������������������������������0000664�0001750�0001750�00000002315�13430006220�021746� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ TUI module test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import os import unittest from yokadi.ycli import tui class TuiTestCase(unittest.TestCase): def setUp(self): tui.clearInputAnswers() def testEditEmptyText(self): os.environ["EDITOR"] = "/bin/true" out = tui.editText(None) self.assertEqual(out, "") def testEnterInt(self): tui.addInputAnswers("") self.assertEqual(tui.enterInt(), None) tui.addInputAnswers("a", "12") self.assertEqual(tui.enterInt(default=4), 12) def testSelectFromList(self): lst = [("a", "alpha"), ("b", "bravo"), ("c", "charlie")] tui.addInputAnswers("a") value = tui.selectFromList(lst, valueForString=str) self.assertEqual(value, "a") tui.addInputAnswers("z", "b") value = tui.selectFromList(lst, valueForString=str) self.assertEqual(value, "b") def testConfirm(self): tui.addInputAnswers("zog", "y") value = tui.confirm("bla") self.assertTrue(value) tui.addInputAnswers("zog", "n") value = tui.confirm("bla") self.assertFalse(value) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/textlistrenderertestcase.py�����������������������������������������������0000664�0001750�0001750�00000006304�13430006220�024556� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ TextListRenderer test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from io import StringIO from yokadi.ycli import colors from yokadi.core import dbutils from yokadi.ycli import tui from yokadi.ycli.textlistrenderer import TextListRenderer, TitleFormater from yokadi.core import db def stripColor(text): 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 TextListRendererTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() tui.clearInputAnswers() def testTitleFormater(self): dbutils.getOrCreateProject("x", interactive=False) dbutils.getOrCreateKeyword("key1", interactive=False) dbutils.getOrCreateKeyword("key2", interactive=False) task = dbutils.addTask("x", "t1", {}) taskWithKeywords = dbutils.addTask("x", "t2", {"key1": None, "key2": 12}) longTask = dbutils.addTask("x", "01234567890123456789", {}) longTask.description = "And it has a description" TEST_DATA = ( (task, 20, "t1"), (taskWithKeywords, 20, "t2 (key1, key2)"), (taskWithKeywords, 4, "t2 >"), (longTask, 10, longTask.title[:8] + ">*"), (longTask, len(longTask.title), longTask.title[:-2] + ">*"), (longTask, len(longTask.title) + 1, longTask.title + "*"), (longTask, 40, longTask.title.ljust(39) + "*"), ) for task, width, expected in TEST_DATA: with self.subTest(task=task, width=width): formater = TitleFormater(width) out = formater(task)[0] out = stripColor(out) expected = expected.ljust(width) self.assertEqual(out, expected) def testFullRendering(self): dbutils.getOrCreateProject("x", interactive=False) dbutils.getOrCreateKeyword("k1", interactive=False) dbutils.getOrCreateKeyword("k2", interactive=False) 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) 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" self.assertMultiLineEqual(out, expected) # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/tasktestcase.py�����������������������������������������������������������0000664�0001750�0001750�00000032273�13430006220�022115� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Task test cases @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <sebastien.renard@digitalfox.org> @license: GPL v3 or later """ import unittest import testutils from yokadi.ycli import tui from yokadi.ycli.main import YokadiCmd from yokadi.core import db from yokadi.core import dbutils from yokadi.core.db import Task, TaskLock, Keyword, setDefaultConfig, Project, TaskKeyword from yokadi.core.yokadiexception import YokadiException, BadUsageException class TaskTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) setDefaultConfig() self.session = db.getSession() tui.clearInputAnswers() self.cmd = YokadiCmd() def testAdd(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") tui.addInputAnswers("y", "y") self.cmd.do_t_add("x @kw1 @kw2=12 t2") tui.addInputAnswers("n") self.cmd.do_t_add("notExistingProject newTask") tasks = self.session.query(Task).all() result = [x.title for x in tasks] expected = ["t1", "t2"] self.assertEqual(result, expected) kwDict = self.session.query(Task).get(2).getKeywordDict() self.assertEqual(kwDict, dict(kw1=None, kw2=12)) for bad_input in ("", # No project "x"): # No task name self.assertRaises(BadUsageException, self.cmd.do_t_add, bad_input) def testEdit(self): tui.addInputAnswers("y") self.cmd.do_t_add("x txt @_note") tui.addInputAnswers("newtxt") self.cmd.do_t_edit("1") task = self.session.query(Task).get(1) self.assertEqual(task.title, "newtxt") self.assertEqual(task.getKeywordDict(), {"_note": None}) def testEditAddKeyword(self): tui.addInputAnswers("y") self.cmd.do_t_add("x txt") tui.addInputAnswers("txt @kw", "y") self.cmd.do_t_edit("1") task = self.session.query(Task).get(1) self.assertEqual(task.title, "txt") self.assertEqual(task.getKeywordDict(), {"kw": None}) def testEditRemoveKeyword(self): tui.addInputAnswers("y", "y") self.cmd.do_t_add("x txt @kw") tui.addInputAnswers("txt") self.cmd.do_t_edit("1") task = self.session.query(Task).get(1) self.assertEqual(task.title, "txt") self.assertEqual(task.getKeywordDict(), {}) def testRemove(self): # Create a recurrent task with one keyword tui.addInputAnswers("y", "y") self.cmd.do_t_add("x @kw bla") task = self.session.query(Task).one() self.cmd.do_t_recurs("1 daily 10:00") keyword = self.session.query(Keyword).filter_by(name="kw").one() self.assertEqual(keyword.tasks, [task]) # Pretend we edit the task description so that we have a TaskLock for # this task taskLockManager = dbutils.TaskLockManager(task) taskLockManager.acquire() self.session.query(TaskLock).one() self.assertEqual(self.session.query(TaskLock).count(), 1) # Remove it, the keyword should no longer be associated with any task, # the lock should be gone tui.addInputAnswers("y") self.cmd.do_t_remove(str(task.id)) self.assertEqual(keyword.tasks, []) self.assertEqual(self.session.query(TaskLock).count(), 0) # Should not crash taskLockManager.release() def testMark(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") task = self.session.query(Task).get(1) self.assertEqual(task.status, "new") self.cmd.do_t_mark_started("1") self.assertEqual(task.status, "started") self.cmd.do_t_mark_new("1") self.assertEqual(task.status, "new") self.cmd.do_t_mark_done("1") self.assertEqual(task.status, "done") def testAddKeywords(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") task = self.session.query(Task).get(1) tui.addInputAnswers("y", "y") self.cmd.do_t_add_keywords("1 @kw1 @kw2=12") kwDict = task.getKeywordDict() self.assertEqual(kwDict, dict(kw1=None, kw2=12)) for bad_input in ("", # No task "1", # No keyword "1 kw1"): # No @ before kw1 self.assertRaises(YokadiException, self.cmd.do_t_add_keywords, bad_input) def testSetProject(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") tui.addInputAnswers("y") self.cmd.do_t_project("1 y") task1 = self.session.query(Task).get(1) self.assertEqual(task1.project.name, "y") self.cmd.do_t_add("x t2") self.cmd.do_t_project("1 _") task1 = self.session.query(Task).get(1) self.assertEqual(task1.project.name, "x") tui.addInputAnswers("n") self.cmd.do_t_project("1 doesnotexist") task1 = self.session.query(Task).get(1) self.assertEqual(task1.project.name, "x") def testLastTaskId(self): # Using "_" with no prior task activity should raise an exception self.assertRaises(YokadiException, self.cmd.getTaskFromId, "_") tui.addInputAnswers("y") self.cmd.do_t_add("x t1") task1 = self.session.query(Task).get(1) self.assertEqual(self.cmd.getTaskFromId("_"), task1) self.cmd.do_t_add("x t2") task2 = self.session.query(Task).get(2) self.assertEqual(self.cmd.getTaskFromId("_"), task2) self.cmd.do_t_mark_started("1") self.assertEqual(self.cmd.getTaskFromId("_"), task1) def testLastProjectName(self): # Using "_" with no prior project used should raise an exception self.assertRaises(YokadiException, self.cmd.do_t_add, "_ t1") tui.addInputAnswers("y") self.cmd.do_t_add("x t1") task1 = self.session.query(Task).get(1) self.cmd.do_t_add("_ t2") task2 = self.session.query(Task).get(2) self.assertEqual(task1.project, task2.project) def testRecurs(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") task = self.session.query(Task).get(1) self.cmd.do_t_recurs("1 daily 10:00") self.assertTrue(task.recurrence) self.assertEqual(task.status, "new") self.cmd.do_t_mark_done("1") self.assertEqual(task.status, "new") self.cmd.do_t_recurs("1 none") self.assertFalse(task.recurrence) self.cmd.do_t_mark_done("1") self.assertEqual(task.status, "done") for bad_input in ("", # No task "1", # No recurence "1 foo", # Unknown recurrence "1 daily", # No time "1 weekly", # No day "1 weekly monday", # No time "1 monthly", # No day "1 monthly 10", # No time "1 quarterly", # No day "1 quarterly 10", # No time "1 monthly foo 12:00", # Bad date ): self.assertRaises(YokadiException, self.cmd.do_t_recurs, bad_input) def testRenderListSectionOrder(self): projectNames = "ccc", "aaa", "UPPER_CASE", "zzz", "mmm" projectList = [] for name in projectNames: prj = Project(name=name) task = Task(project=prj, title="Hello") self.session.add(prj) self.session.add(task) projectList.append(prj) self.session.flush() renderer = testutils.TestRenderer() self.cmd._renderList(renderer, projectList, filters=[], order=[]) self.assertEqual(list(renderer.taskDict.keys()), sorted(projectNames, key=lambda x: x.lower())) def testRenderListSectionOrderKeywords(self): prj = Project(name="prj") keywordNames = ["kw_" + x for x in ("ccc", "aaa", "UPPER_CASE", "zzz", "mmm")] keywordList = [] for name in keywordNames: keyword = Keyword(name=name) task = Task(project=prj, title="Hello") TaskKeyword(task=task, keyword=keyword) self.session.add(task) keywordList.append(prj) self.session.flush() renderer = testutils.TestRenderer() self.cmd._renderList(renderer, [prj], filters=[], order=[], groupKeyword="kw_%") self.assertEqual(list(renderer.taskDict.keys()), sorted(keywordNames, key=lambda x: x.lower())) def testTlist(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") tui.addInputAnswers("y", "y") self.cmd.do_t_add("x @kw1 @kw2=12 t2") for line in ("", "-a", "-t", "-d today", "-u 10", "-k %", "-k _%", "-s t", "--overdue", "@%", "@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") self.cmd.do_t_add("x t2") tui.addInputAnswers("y", "y") self.cmd.do_n_add("x @kw1 @kw2=12 t3") self.cmd.do_t_add("x @kw1 @kw2=12 t4") for line in ("", "-k %", "-k _%", "-s t", "@%", "@k%", "!@%", "!@kw1", "-f plain"): self.cmd.do_t_list(line) def testTfilter(self): t1 = dbutils.addTask("x", "t1", interactive=False) t2 = dbutils.addTask("x", "t2", keywordDict={"kw1": None, "kw2": 12}, interactive=False) t3 = dbutils.addTask("y", "t3", interactive=False) testData = [ ("@kw1", {"x": [t2]}), ("@kw1 @kw2", {"x": [t2]}), ("x", {"x": [t1, t2]}), ("x @kw1", {"x": [t2]}), ("none", {"x": [t1, t2], "y": [t3]}), ] for filter, expectedTaskDict in testData: self.cmd.do_t_filter(filter) renderer = testutils.TestRenderer() self.cmd.do_t_list("", renderer=renderer) self.assertEqual(renderer.taskDict.keys(), expectedTaskDict.keys()) for key in renderer.taskDict.keys(): self.assertEqual([x.title for x in renderer.taskDict[key]], [x.title for x in expectedTaskDict[key]]) self.assertRaises(YokadiException, self.cmd.do_t_filter, "") def testTApply(self): self.cmd.do_k_add("lala") for i in range(10): tui.addInputAnswers("y") self.cmd.do_t_add("x t%s" % i) ids = [1, 2, 4, 5, 6, 9] self.cmd.do_t_apply("1 2,4-6 9 t_add_keywords @lala") for taskId in range(1, 10): kwDict = self.session.query(Task).get(taskId).getKeywordDict() if taskId in ids: self.assertEqual(kwDict, dict(lala=None)) else: self.assertNotEqual(kwDict, dict(lala=None)) # raise error if t_list had not been called previously self.assertRaises(BadUsageException, self.cmd.do_t_apply, "__ t_add_keywords @toto") self.cmd.do_t_list("@lala") self.cmd.do_t_apply("__ t_add_keywords @toto") for taskId in range(1, 10): kwDict = self.session.query(Task).get(taskId).getKeywordDict() if taskId in ids: self.assertEqual(kwDict, dict(lala=None, toto=None)) else: self.assertNotEqual(kwDict, dict(lala=None, toto=None)) def testReorder(self): self.assertRaises(BadUsageException, self.cmd.do_t_reorder, "unknown_project") self.assertRaises(BadUsageException, self.cmd.do_t_reorder, "too much args") def testDue(self): tui.addInputAnswers("y") self.cmd.do_t_add("x t1") for valid_input in ("+1d", "+1m"): self.cmd.do_t_due("1 %s" % valid_input) 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 �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/ydateutilstestcase.py�����������������������������������������������������0000664�0001750�0001750�00000013727�13430006220�023345� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Date utilities test cases @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or later """ import unittest import operator from datetime import datetime, timedelta, time from yokadi.core import ydateutils from yokadi.core.yokadiexception import YokadiException class YDateUtilsTestCase(unittest.TestCase): def testParseDateTimeDelta(self): testData = [ ("1m", timedelta(minutes=1)), ("3M", timedelta(minutes=3)), ("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) self.assertEqual(expected, output) def testParseHumaneDateTime(self): for date 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"): ydateutils.parseHumaneDateTime(date) for invalidDate in ("2008", "01/2009", "01//02/01", "02/20/2009", "", "+3e", "lkjljlkjlkj", "200/200/2009"): self.assertRaises(YokadiException, ydateutils.parseHumaneDateTime, invalidDate) # Fake today to a fixed date. This is a saturday (weekday=5). today = datetime(2009, 1, 3) endOfDay = dict(hour=23, minute=59, second=59) startOfDay = dict(hour=0, minute=0, second=0) testData = [ ("06/02/2009", None, datetime(2009, 2, 6)), ("06/02/09", None, datetime(2009, 2, 6)), ("06/02/2009 12:30", None, datetime(2009, 2, 6, 12, 30)), ("06/02/2009", ydateutils.TIME_HINT_BEGIN, datetime(2009, 2, 6, 0, 0, 0)), ("06/02/2009", ydateutils.TIME_HINT_END, datetime(2009, 2, 6, 23, 59, 59)), ("tomorrow 18:00", None, today + timedelta(days=1, hours=18)), ("tomorrow", ydateutils.TIME_HINT_END, today.replace(day=4, hour=23, minute=59, second=59)), ("sunday", None, datetime(2009, 1, 4)), ("tu 11:45", None, datetime(2009, 1, 6, 11, 45)), ("today", ydateutils.TIME_HINT_END, today.replace(**endOfDay)), ("today", ydateutils.TIME_HINT_BEGIN, today.replace(**startOfDay)), ("now", None, today), ("today 5 PM", None, today.replace(hour=17)), ("6:32pm", None, today.replace(hour=18, minute=32)), ("06/02 2:40 am", None, datetime(2009, 2, 6, 2, 40)), ("+2w", None, datetime(2009, 1, 17)), ("+1d", None, datetime(2009, 1, 4)), ("-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) self.assertEqual(expected, output) def testPastTime(self): # Fake now to a fixed date and time now = datetime(2009, 1, 3, 11, 0) 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) self.assertEqual(expected, output) def testFormatTimeDelta(self): testData = [ (timedelta(minutes=1), "1m"), (timedelta(hours=1), "1h 0m"), (timedelta(hours=1, minutes=1), "1h 1m"), (timedelta(days=2, hours=5), "2d"), (timedelta(days=7), "1w"), (timedelta(days=10), "1w, 3d"), (timedelta(days=60), "2M"), (timedelta(days=80), "2M, 20d"), (timedelta(days=365), "1Y"), (timedelta(days=400), "1Y, 1M"), ] for input, expected in testData: output = ydateutils.formatTimeDelta(input) self.assertEqual(expected, output) output = ydateutils.formatTimeDelta(-input) self.assertEqual("-" + expected, output) def testParseDateLimit(self): # Fake today to a fixed date. This is a saturday (weekday=5). today = datetime(2009, 1, 3) endOfDay = dict(hour=23, minute=59, second=59) startOfDay = dict(hour=0, minute=0, second=0) testData = [ ("today", operator.__le__, today.replace(**endOfDay)), ("<=today", operator.__le__, today.replace(**endOfDay)), ("<today", operator.__lt__, today.replace(**startOfDay)), (">today", operator.__gt__, today.replace(**endOfDay)), (">=today", operator.__ge__, today.replace(**startOfDay)), ("<=06/02/2009", operator.__le__, datetime(2009, 2, 6).replace(**endOfDay)), ("<06/02/2009", operator.__lt__, datetime(2009, 2, 6).replace(**startOfDay)), ("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) output = ydateutils.parseDateLimit(text, today=today) self.assertEqual(expectedOp, output[0]) self.assertEqual(expectedDate, output[1]) 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"): 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))): output = ydateutils.guessTime(text) self.assertEqual(expected, output) # vi: ts=4 sw=4 et �����������������������������������������yokadi-1.2.0/yokadi/tests/tests.py������������������������������������������������������������������0000775�0001750�0001750�00000003470�13430006220�020561� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#!/usr/bin/env python3 # -*- coding: UTF-8 -*- """ Yokadi unit tests @author: Aurélien Gâteau <mail@agateau.com> @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or later """ import unittest import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) try: 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 # 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 # 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 ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/yokadioptionparsertestcase.py���������������������������������������������0000664�0001750�0001750�00000002210�13430006220�025065� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Yokadi parser test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from yokadi.core.yokadioptionparser import YokadiOptionParser from yokadi.core.yokadiexception import YokadiException class YokadiOptionParserTestCase(unittest.TestCase): def testQuote(self): parser = YokadiOptionParser() parser.add_argument("cmd", nargs='*') src = "There's a quote here" args = parser.parse_args(src) # Recreate the line line = " ".join(args.cmd) self.assertEqual(line, src) def testDash(self): parser = YokadiOptionParser() parser.add_argument("cmd", nargs="*") srcs = ["foo-bar", "foo - bar"] for src in srcs: args = parser.parse_args(src) # Recreate the line line = " ".join(args.cmd) self.assertEqual(line, src) def testUknownOption(self): def parseUnknownOption(): parser.parse_args("blabla -b") parser = YokadiOptionParser() self.assertRaises(YokadiException, parseUnknownOption) # vi: ts=4 sw=4 et ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/completerstestcase.py�����������������������������������������������������0000664�0001750�0001750�00000002225�13430006220�023322� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Project test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import unittest from yokadi.core import db from yokadi.core.db import Project, setDefaultConfig from yokadi.ycli import completers class CompletersTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) setDefaultConfig() self.session = db.getSession() def testProjectCompleter(self): self.session.add_all([Project(name="foo"), Project(name="foo2"), Project(name="bar")]) expected = ["foo ", "foo2 "] completer = completers.ProjectCompleter(1) result = completer("f", "t_add f", 6, 8) self.assertEqual(result, expected) def testCompleteParameterPosition(self): data = [ (("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) # vi: ts=4 sw=4 et ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/icaltestcase.py�����������������������������������������������������������0000664�0001750�0001750�00000007746�13430006220�022072� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# coding:utf-8 """ Ical features test cases @author: Sébastien Renard <Sebastien.Renard@digitalfox.org> @license: GPL v3 or later """ import unittest from yokadi.ycli import tui from yokadi.yical import yical from yokadi.core import dbutils from yokadi.core import db class IcalTestCase(unittest.TestCase): def setUp(self): db.connectDatabase("", memoryDatabase=True) self.session = db.getSession() tui.clearInputAnswers() def testUrgencyMapping(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) v1 = yical.createVTodoFromTask(t1) self.assertEqual(v1.get("priority"), None) # No priority t1.urgency = 45 v1 = yical.createVTodoFromTask(t1) self.assertEqual(v1.get("priority"), 2) yical.updateTaskFromVTodo(t1, v1) self.assertEqual(t1.urgency, 45) # Ensure urgency does change v1["priority"] = 4 yical.updateTaskFromVTodo(t1, v1) self.assertEqual(t1.urgency, 20) # Check urgency is updated def testTitleMapping(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) v1 = yical.createVTodoFromTask(t1) # Check id is here self.assertEqual(v1.get("summary")[-4:], " (%s)" % t1.id) # Title and id should not change with update origin_id = t1.id origin_title = t1.title yical.updateTaskFromVTodo(t1, v1) self.assertEqual(t1.id, origin_id) self.assertEqual(t1.title, origin_title) # Update vtodo summary and remove (id) or space before (id) info. # Only task title should be changed for new_summary in ("hello", "hello(%s)" % origin_id, "hello (%s)" % origin_id, "(%s)hello" % origin_id, " (%s)hello" % origin_id): v1["summary"] = new_summary yical.updateTaskFromVTodo(t1, v1) self.assertEqual(t1.id, origin_id) self.assertEqual(t1.title, "hello") # Update votod with fake id info. # Should be present in task title for new_summary in ("hello", "hello()", "hello(123456)", "hello (123456)"): v1["summary"] = new_summary yical.updateTaskFromVTodo(t1, v1) self.assertEqual(t1.id, origin_id) self.assertEqual(t1.title, new_summary) def testKeywordMapping(self): tui.addInputAnswers("y") tui.addInputAnswers("y") tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {"k1": None, "k2": 123}) v1 = yical.createVTodoFromTask(t1) # Check categories are created categories = [str(c) for c in v1.get("categories")] categories.sort() self.assertEqual(categories, ["k1", "k2=123"]) # Check keywords are still here yical.updateTaskFromVTodo(t1, v1) keywords = list(t1.getKeywordDict().keys()) self.session.commit() keywords.sort() self.assertEqual(keywords, ["k1", "k2"]) self.assertEqual(t1.getKeywordDict()["k2"], 123) # Remove k2 category v1["categories"] = ["k1"] yical.updateTaskFromVTodo(t1, v1) self.session.commit() self.assertEqual(list(t1.getKeywordDict().keys()), ["k1", ]) # Set k1 value v1["categories"] = ["k1=456", ] yical.updateTaskFromVTodo(t1, v1) self.session.commit() self.assertEqual(t1.getKeywordDict()["k1"], 456) # Create a category v1["categories"] = ["k1", "k4=789"] yical.updateTaskFromVTodo(t1, v1) keywords = list(t1.getKeywordDict().keys()) keywords.sort() self.assertEqual(keywords, ["k1", "k4"]) self.assertEqual(t1.getKeywordDict()["k4"], 789) def testTaskDoneMapping(self): tui.addInputAnswers("y") t1 = dbutils.addTask("x", "t1", {}) yical.createVTodoFromTask(t1) # v1["completed"] = datetime.datetime.now() # yical.updateTaskFromVTodo(t1, v1) # self.assertEqual(t1.status, "done") ��������������������������yokadi-1.2.0/yokadi/tests/recurrenceruletestcase.py�������������������������������������������������0000664�0001750�0001750�00000013533�13430006220�024176� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import unittest from collections import namedtuple from datetime import datetime from dateutil import rrule from yokadi.core.recurrencerule import RecurrenceRule from yokadi.core.yokadiexception import YokadiException # Use a date far away in the future because rrule does not work with dates in # the past. # This is a wednesday. REF_DATE = datetime(2200, 3, 19, 21, 30) TestRow = namedtuple("TestRow", ("text", "dct", "rule", "nextDate")) TEST_DATA = [ TestRow( "none", {}, RecurrenceRule(), None, ), TestRow( "daily 17:15", {"freq": rrule.DAILY, "bymonth": (), "bymonthday": (), "byweekday": (), "byhour": (17,), "byminute": (15,)}, RecurrenceRule(rrule.DAILY, byhour=17, byminute=15), REF_DATE.replace(day=20, hour=17, minute=15) ), TestRow( "weekly monday 10:00", {"freq": rrule.WEEKLY, "bymonth": (), "bymonthday": (), "byweekday": (0,), "byhour": (10,), "byminute": (0,)}, RecurrenceRule(rrule.WEEKLY, byweekday=0, byhour=10), REF_DATE.replace(day=24, hour=10, minute=0) ), TestRow( "monthly 2 8:27", {"freq": rrule.MONTHLY, "bymonth": (), "bymonthday": (2,), "byweekday": (), "byhour": (8,), "byminute": (27,)}, RecurrenceRule(rrule.MONTHLY, bymonthday=2, byhour=8, byminute=27), REF_DATE.replace(month=4, day=2, hour=8, minute=27) ), TestRow( "quarterly 2 8: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,)}, 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,)}, 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,)}, RecurrenceRule(rrule.YEARLY, bymonth=2, bymonthday=23, byhour=8, byminute=27), REF_DATE.replace(year=2201, month=2, day=23, hour=8, minute=27) ), ] class RecurrenceRuleTestCase(unittest.TestCase): def testFromHumaneString(self): testData = [ ("daily 10:00", RecurrenceRule(rrule.DAILY, byhour=10)), ("weekly FR 23:00", RecurrenceRule(rrule.WEEKLY, byweekday=4, byhour=23)), ("none", RecurrenceRule()), ("weekly fr 23:00", RecurrenceRule(rrule.WEEKLY, byweekday=4, byhour=23)), ("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)), ("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] 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)) def testFromHumaneString_badInput(self): for badInput in ("foo", # Unknown recurrence "daily", # No time "weekly", # No day "weekly monday", # No time "monthly", # No day "monthly 10", # No time "quarterly", # No day "quarterly 10", # No time "monthly foo 12:00", # Bad date ): self.assertRaises(YokadiException, RecurrenceRule.fromHumaneString, badInput) def testToFromDict(self): for row in TEST_DATA: 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)) dct = rule.toDict() self.assertEqual(dct, row.dct) def testGetNext(self): for row in TEST_DATA: with self.subTest(text=row.text): nextDate = row.rule.getNext(REF_DATE) self.assertEqual(nextDate, row.nextDate) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/yokadi/tests/basepathstestcase.py������������������������������������������������������0000664�0001750�0001750�00000006663�13430006220�023131� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# -*- coding: UTF-8 -*- """ Basepaths test cases @author: Aurélien Gâteau <mail@agateau.com> @license: GPL v3 or later """ import os import shutil import tempfile from pathlib import Path from yokadi.core import basepaths from yokadi.tests.yokaditestcase import YokadiTestCase class BasePathsUnixTestCase(YokadiTestCase): def setUp(self): YokadiTestCase.setUp(self) self._oldWindows = basepaths._WINDOWS basepaths._WINDOWS = False def tearDown(self): basepaths._WINDOWS = self._oldWindows YokadiTestCase.tearDown(self) def testMigrateOldDb(self): oldDb = Path(self.testHomeDir) / '.yokadi.db' newDb = Path(basepaths.getDbPath(basepaths.getDataDir())) oldDb.touch() basepaths.migrateOldDb(str(newDb)) self.assertFalse(oldDb.exists()) self.assertTrue(newDb.exists()) def testMigrateNothingToDo(self): 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(basepaths.getDataDir())) oldDb.touch() newDb.parent.mkdir(parents=True) newDb.touch() self.assertRaises(basepaths.MigrationException, basepaths.migrateOldDb, str(newDb)) def testMigrateOldHistory(self): old = Path(self.testHomeDir) / '.yokadi_history' new = Path(basepaths.getHistoryPath()) old.touch() basepaths.migrateOldHistory() self.assertFalse(old.exists()) self.assertTrue(new.exists()) def testMigrateOldHistoryOverwriteNew(self): old = Path(self.testHomeDir) / '.yokadi_history' new = Path(basepaths.getHistoryPath()) with old.open('w') as f: f.write('old') new.parent.mkdir(parents=True) with new.open('w') as f: f.write('new') basepaths.migrateOldHistory() self.assertFalse(old.exists()) with new.open() as f: newData = f.read() self.assertEqual(newData, 'old') def testHistoryEnvVar(self): path = "foo" os.environ["YOKADI_HISTORY"] = path self.assertEqual(basepaths.getHistoryPath(), path) def testDbEnvVar(self): path = "foo" os.environ["YOKADI_DB"] = path self.assertEqual(basepaths.getDbPath(basepaths.getDataDir()), path) class BasePathsWindowsTestCase(YokadiTestCase): def setUp(self): YokadiTestCase.setUp(self) self._oldWindows = basepaths._WINDOWS basepaths._WINDOWS = True self.testAppDataDir = tempfile.mkdtemp(prefix="yokadi-basepaths-testcase") os.environ["APPDATA"] = self.testAppDataDir def tearDown(self): shutil.rmtree(self.testAppDataDir) basepaths._WINDOWS = self._oldWindows YokadiTestCase.tearDown(self) def testGetCacheDir(self): expected = os.path.join(self.testAppDataDir, "yokadi", "cache") self.assertEqual(basepaths.getCacheDir(), expected) def testGetDataDir(self): expected = os.path.join(self.testAppDataDir, "yokadi", "data") self.assertEqual(basepaths.getDataDir(), expected) def testOldHistoryPath(self): expected = os.path.join(self.testAppDataDir, ".yokadi_history") self.assertEqual(basepaths._getOldHistoryPath(), expected) �����������������������������������������������������������������������������yokadi-1.2.0/extra-requirements.txt�����������������������������������������������������������������0000664�0001750�0001750�00000000045�13430006220�021020� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������icalendar==3.6.1 setproctitle==1.1.8 �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/LICENSE��������������������������������������������������������������������������������0000664�0001750�0001750�00000104513�13430006220�015425� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) <year> <name of author> This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: <program> Copyright (C) <year> <name of author> This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>. �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/PKG-INFO�������������������������������������������������������������������������������0000664�0001750�0001750�00000000373�13430006221�015515� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������Metadata-Version: 1.0 Name: yokadi Version: 1.2.0 Summary: Command line oriented todo list system Home-page: http://yokadi.github.io/ Author: The Yokadi Team Author-email: ml-yokadi@sequanux.org License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/setup.py�������������������������������������������������������������������������������0000775�0001750�0001750�00000004307�13430006220�016135� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Setup script used to build and install Yokadi @author: Sébastien Renard (sebastien.renard@digitalfox.org) @license:GPL v3 or newer """ from distutils.core import setup import sys import os from fnmatch import fnmatch from os.path import isdir, dirname, join sys.path.insert(0, dirname(__file__)) import yokadi def createFileList(sourceDir, *patterns): """ List files from sourceDir which match one of the pattern in patterns Returns the path including sourceDir """ for name in os.listdir(sourceDir): for pattern in patterns: if fnmatch(name, pattern): yield join(sourceDir, name) # Additional files data_files = [] data_files.append(["share/yokadi", ["README.md", "NEWS", "LICENSE"]]) # Doc data_files.append(["share/yokadi/doc", createFileList("doc", "*.md")]) # Man data_files.append(["share/man/man1", createFileList("man", "*.1")]) # Editor scripts data_files.append(["share/yokadi/editors/vim/ftdetect", ["editors/vim/ftdetect/yokadimedit.vim"]]) data_files.append(["share/yokadi/editors/vim/syntax", ["editors/vim/syntax/yokadimedit.vim"]]) # Icon 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/applications", ["icon/yokadi.desktop"]]) # Scripts scripts = ["bin/yokadi", "bin/yokadid"] # Windows post install script if "win" in " ".join(sys.argv[1:]): 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=[ "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=[ "sqlalchemy", "python-dateutil", ], scripts=scripts, data_files=data_files ) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/�������������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�016066� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/vim/���������������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�016661� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/vim/ftdetect/������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�020463� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/vim/ftdetect/yokadimedit.vim���������������������������������������������������0000664�0001750�0001750�00000000103�13430006220�023474� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������au BufRead,BufNewFile *.medit set filetype=yokadimedit textwidth=0 �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/vim/syntax/��������������������������������������������������������������������0000775�0001750�0001750�00000000000�13430006221�020207� 5����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/editors/vim/syntax/yokadimedit.vim�����������������������������������������������������0000664�0001750�0001750�00000001511�13430006220�023224� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������" Vim syntax file " Language: Yokadi t_medit " Maintainer: Aurélien Gâteau <mail@agateau.com> " Filenames: *.medit if exists("b:current_syntax") finish endif syn case match syn match yokadimeditComment "^\s*#.*$" skipwhite syn match yokadimeditTaskId "\v^\s*(\d+|-)" nextgroup=yokadimeditStatus skipwhite syn match yokadimeditError "^\s*[^-0-9#].*" skipwhite syn match yokadimeditStatus "[NSDnsd] " nextgroup=yokadimeditTitle contained syn match yokadimeditTitle ".*" contains=yokadimeditKeyword contained syn match yokadimeditKeyword "@\w\+" contained syn match yokadimeditKeyword "@\w\+=\d\+" contained hi def link yokadimeditComment Comment hi def link yokadimeditTaskId Constant hi def link yokadimeditStatus Statement hi def link yokadimeditKeyword Type hi def link yokadimeditError Error let b:current_syntax = "yokadimedit" ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������yokadi-1.2.0/requirements.txt�����������������������������������������������������������������������0000664�0001750�0001750�00000000056�13430006220�017701� 0����������������������������������������������������������������������������������������������������ustar �aurelien������������������������aurelien������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������sqlalchemy>=0.9, <0.9.99 python-dateutil==2.2 ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������