Giter Club home page Giter Club logo

todo.txt-cli-dorecur's Introduction

dorecur

dorecur is an add-on for the todo.txt-cli task manager that provides a drop-in replacement for the do action. It handles recurring tasks by adding a new task when the old task is finished. It does not require an external scheduler such as cron.

Keys (tags) used by this action

  • The rec: value specifies how the task recurs.
  • The t: start (deferral/threshold) date, if it exists, is incremented as specified by the rec: value.
  • The due: date, if it exists, is incremented as specified by the rec: value.

Recurrence types

dorecur supports two different recurrence types.

  • Normal recurrence is suitable for a task that recurs based on the date of completion, such as "water the flowers".
  • Strict recurrence is suitable for a task that recurs based on the start date and/or due date specified in the task, such as "pay rent".

Recurrence specification

The rec: value follows the format (+)X(d|b|w|m|y).

  • Normal recurrence is indicated by omitting the + prefix. Include the + prefix to specify strict recurrence.
  • X is an integer representing the magnitude of adjustment.
  • An optional suffix indicates the time unit: days, business days, weeks, months, or years, respectively. The default is "days" if the suffix is omitted. Business day calculations ignore holidays. Month calculations simply increment the month and subtract days if necessary (see the last example in Behavior, below).

Behavior

This section describes dorecur's exact behavior, with examples (and abbreviated todo-txt output).

  • No rec: key: the task is marked as completed and no new task is created. (The built-in todo-txt do action is executed directly.)

    $ todo-txt ls
    1 Fix lamp
    $ todo-txt do 1
    (No new task)
  • No start date or due date: a new task is created with no changes.

    $ todo-txt ls
    1 Meet friend for tea rec:1
    $ todo-txt do 1
    1 Meet friend for tea rec:1
  • A start date or a due date but not both: the adjustment is applied to the date given.

    $ todo-txt ls
    1 Water flowers t:2021-01-01 rec:5d
    $ date -I && todo-txt do 1
    2021-01-02
    1 Water flowers t:2021-01-07 rec:5d
    
    $ todo-txt ls
    1 Send birthday greeting to friend t:2021-04-04 rec:+1y
    $ todo-txt do 1
    1 Send birthday greeting to friend t:2022-04-04 rec:+1y
  • Both start date and due date with strict recurrence: the adjustment is applied to both dates individually.

    $ todo-txt ls
    1 Pay rent t:2021-01-28 due:2021-02-01 rec:+1m
    $ todo-txt do 1
    1 Pay rent t:2021-02-28 due:2021-03-01 rec:+1m
  • Both start date and due date with normal recurrence: the adjustment is applied to the start date only. The due date is adjusted by applying the offset between the start date and due date of the original task (as number of days).

    $ todo-txt ls
    1 Do offline backup t:2021-01-01 due:2021-01-08 rec:2w
    $ date -I && todo-txt do 1
    2021-01-03
    1 Do offline backup t:2021-01-17 due:2021-01-24 rec:2w
  • A start or due date near the end of the month with a month unit for recurrence: a day-of-month greater than 28 may migrate backward toward the 28th.

    $ todo-txt ls
    1 Get groceries t:2021-01-14 rec:1m
    $ date -I && todo-txt do 1
    2021-01-31
    1 Get groceries t:2021-02-28 rec:1m
    
    $ todo-txt ls
    1 Pay rent t:2021-01-31 due:2021-02-01 rec:+1m
    $ todo-txt do 1
    1 Pay rent t:2021-02-28 due:2021-03-01 rec:+1m

Installing

There are two ways to install dorecur.

  • As a Git repository: clone the repository into the todo.txt-cli actions directory (~/.todo.actions.d, by default) and create a symlink. Future updates only require running git pull from inside the repository. Note that todo.txt-cli will ignore a broken symlink—see todo.txt-cli issue #359.

    $ cd ~/.todo.actions.d/
    $ git clone "https://github.com/owenh000/todo.txt-cli-dorecur.git"
    $ ln -s todo.txt-cli-dorecur/dorecur.py do
  • As a single file: save the dorecur.py file to your todo.txt-cli actions directory (~/.todo.actions.d/, by default) and rename it to do. This works in cases where Git and/or filesystem symlink support are unavailable.

Development

To run tests: ./dorecur.py test

Credits

This add-on is inspired by:

  • todo.txt-cli, for which dorecur is an add-on
  • The again add-on, which was written in Bash and has a slightly different feature set
  • The recurrence system of the topydo task manager (though the recurrence behavior of dorecur is not identical)

Contributing

If you would like to contribute:

  • Share this project with someone else who may be interested
  • Contribute a fix for a currently open issue (if any) using a GitHub pull request (please discuss before working on any large changes)
  • Open a new issue for a problem you've discovered or a possible enhancement
  • Sponsor my work through GitHub Sponsors (see also owenh.net/support)

Copyright 2021-2024 Owen T. Heisler. GNU General Public License v3 (GPLv3).

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 <https://www.gnu.org/licenses/>.

todo.txt-cli-dorecur's People

Contributors

ferki avatar owenh000 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

todo.txt-cli-dorecur's Issues

Support optional task creation date

First, thank you for this project, @owenh000! 🙏

I'm using the optional creation dates in my todo.txt file, but todo.txt-cli-dorecur doesn't seem to like them out of the box.

It treats the original creation date as part of the full task text, so when calling dorecur.py the newly created task still has its original creation date on top of the new creation date, e.g.:

(B) 2021-11-07 my task rec:+1m due:2021-12-10 t:2021-12-01

becomes

(B) 2021-12-06 2021-11-07 my task rec:+1m due:2022-01-10 t:2022-01-01

while I'd expect

(B) 2021-12-06 my task rec:+1m due:2022-01-10 t:2022-01-01

I'll try to send a PR about fixing it soon.

SyntaxWarning on use

Thanks really a lot for this "action". I would like to report that it throws the following SyntaxWarning on its use:

/Users/XXX/.todo/.todo.actions.d/do:241: SyntaxWarning: invalid escape sequence '\('
  line = re.sub("^(?P<pri>\([A-Z]\) )?\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ", "\g<pri>", line)
/Users/XXX/.todo/.todo.actions.d/do:241: SyntaxWarning: invalid escape sequence '\g'
  line = re.sub("^(?P<pri>\([A-Z]\) )?\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ", "\g<pri>", line)

I would be happy to dig further to solve it, as it might be a Python-related change.

Task Notes Archival

Firstly, thanks for writing this very useful script which I use a few times a week for all my recurring tasks!

I am using Manuel J. Garrido's Note add-on script and have tried to modify your script to grap the task note filename from the task line that is prefixed with 'note:', usually named something like "SjY.md" and move it from the "~/Todo/notes" folder to a subfolder named "~/Todo/notes/archive".

I can't seem to get the 'archive_file' on line #46 to work and I wonder if you would consider taking a look at what I might be doing wrong here?

I've fed it through ChatGPT and it couldn't find anything wrong. Also tried vscode debugger and pdb debug but couldn't isolate the problem. I'm not so experienced with developing in Python.

Below is your script that I've made the updates on:

#!/usr/bin/env python3
# A todo.txt-cli add-on for recurring tasks
# Copyright 2021 Owen T. Heisler. GNU General Public License, version 3.
#
# 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 <https://www.gnu.org/licenses/>.
#
"""Mark task(s) as done, adding new task(s) if required for recurrence."""

import argparse
import calendar
import datetime
import os
import re
import subprocess
import sys
import shutil
import logging
import pdb

# Configure logging
logging.basicConfig(filename='debug.log', level=logging.DEBUG, format='%(asctime)s %(message)s')

# Replace these paths with your actual paths
file_to_read = os.path.expanduser("~/Todo/todo.txt")
source_folder = os.path.expanduser("~/Todo/notes")
archive_folder = os.path.expanduser("~/Todo/notes/archive")


def move_files_with_note_prefix(file_path, source_folder, archive_folder, task):
    task_line = get_line(task)
    
    if task_line.strip().startswith("note:"):
        note_string = task_line.strip().split(':', 1)[1].strip()
        source_file = os.path.join(source_folder, note_string)
        archive_file = os.path.join(archive_folder, note_string)
        
        if os.path.exists(source_file):
            if os.path.exists(archive_file):
                print("Archive file already exists.")
            else:
                try:
                    shutil.move(source_file, archive_file)
                    print("Moved {} to {}".format(source_file, archive_file))
                except Exception as e:
                    print("Error moving file:", e)
        else:
            print("Source file not found.")


def add_new_task(line):
    """Add a new task by running todo-txt *add*."""
    subprocess.run(
        [os.environ['TODO_FULL_SH'], 'command', 'add', line],
        check=True,
        stdout=sys.stdout,
        stderr=sys.stderr,
    )


def adjust_date(date, adjust):
    """Return adjusted date, ignoring the `+` prefix if it exists.

    If the date is None, return None.

    >>> adjust_date(datetime.date(1970, 1, 1), '1')
    datetime.date(1970, 1, 2)
    >>> adjust_date(datetime.date(1970, 1, 1), '+1')
    datetime.date(1970, 1, 2)
    >>> adjust_date(datetime.date(1970, 1, 1), '1d')
    datetime.date(1970, 1, 2)
    >>> adjust_date(datetime.date(1970, 1, 1), '1b')
    datetime.date(1970, 1, 2)
    >>> adjust_date(datetime.date(1970, 1, 1), '2b')
    datetime.date(1970, 1, 5)
    >>> adjust_date(datetime.date(1970, 1, 1), '2w')
    datetime.date(1970, 1, 15)
    >>> adjust_date(datetime.date(1970, 1, 1), '3m')
    datetime.date(1970, 4, 1)
    >>> adjust_date(datetime.date(1970, 1, 31), '2m')
    datetime.date(1970, 3, 31)
    >>> adjust_date(datetime.date(1970, 1, 31), '1m')
    datetime.date(1970, 2, 28)
    >>> adjust_date(datetime.date(1970, 1, 1), '4y')
    datetime.date(1974, 1, 1)

    """
    if date is None:
        return None
    m = re.fullmatch(r'(\+?)(\d+)([dbwmy]?)', adjust)
    if not m:
        raise Exception('Malformed `rec:` value')
    num = int(m.group(2))
    unit = m.group(3)
    if unit in ['', 'd']:
        # Add days
        return date + datetime.timedelta(days=num)
    elif unit == 'b':
        # Add business days
        while num > 0:
            date += datetime.timedelta(days=1)
            weekday = date.weekday()
            if weekday >= 5:
                continue
            num -= 1
        return date
    elif unit == 'w':
        # Add weeks
        return date + datetime.timedelta(weeks=num)
    elif unit == 'm':
        # Add months
        month = date.month - 1 + num
        year = date.year + month // 12
        month = month % 12 + 1
        day = min(date.day, calendar.monthrange(year, month)[1])
        return datetime.date(year, month, day)
    elif unit == 'y':
        # Add years
        return date.replace(year=(date.year + num))


def get_date(line, key):
    """Return the date given in the specified key.

    Return None if the key does not exist.

    >>> get_date('Test task t:1970-01-01', 't')
    datetime.date(1970, 1, 1)
    >>> get_date('Test task', 't')
    >>> get_date('Test task t:malformed', 't')
    Traceback (most recent call last):
        ...
    Exception: Malformed `t:` date
    >>> get_date('Test task due:1970-01-01', 'due')
    datetime.date(1970, 1, 1)
    >>> get_date('Test task', 'due')
    >>> get_date('Test task due:malformed', 'due')
    Traceback (most recent call last):
        ...
    Exception: Malformed `due:` date

    """
    value = get_key_value(line, key)
    if value is None:
        return None
    else:
        try:
            return datetime.date.fromisoformat(value)
        except ValueError:
            raise Exception('Malformed `{}:` date'.format(key))


def get_key_value(line, key):
    """Return the value of key as a string, or None if it does not exist.

    Raise an Exception if the key is specified more than once.

    >>> get_key_value('Test task', 'key')
    >>> get_key_value('Test task key:val', 'key')
    'val'
    >>> get_key_value('key:val Test task', 'key')
    'val'
    >>> get_key_value('Test task key:val1 key:val2', 'key')
    Traceback (most recent call last):
        ...
    Exception: Task has multiple `key:` keys
    >>> get_key_value('Test task rec:1', 'rec')
    '1'
    >>> get_key_value('(A) Test task rec:3d', 'rec')
    '3d'

    """
    matches = re.findall(r'(?:^| ){}:([^ ]*)'.format(key), line)
    count = len(matches)
    if count == 1:
        return matches[0].lstrip(' ')
    elif count == 0:
        return None
    else:
        raise Exception('Task has multiple `{}:` keys'.format(key))


def get_line(item_number):
    """Get task by number and return as string."""
    with open(os.environ['TODO_FILE']) as f:
        for i, line in enumerate(f):
            if i+1 == item_number:
                return line.rstrip('\n')
    raise Exception('Task {} does not exist'.format(item_number))


def make_new_task(line, now=datetime.date.today()):
    """Replace dates in line and return new line.

    >>> now = datetime.date(1970, 1, 3)
    >>> make_new_task('Test task', now)
    >>> make_new_task('Test task rec:3d', now)
    'Test task rec:3d'
    >>> make_new_task('1970-01-01 Test task rec:3d', now)
    'Test task rec:3d'
    >>> make_new_task('(A) 1970-01-01 Test task rec:3d', now)
    '(A) Test task rec:3d'
    >>> make_new_task('(A) 1970 Test task rec:3d', now)
    '(A) 1970 Test task rec:3d'
    >>> make_new_task('(A) Test task with 1970-01-01 in it rec:3d', now)
    '(A) Test task with 1970-01-01 in it rec:3d'
    >>> make_new_task('Test task rec:+3d', now)
    'Test task rec:+3d'
    >>> make_new_task('Test task t:1970-01-01 rec:3d', now)
    'Test task t:1970-01-06 rec:3d'
    >>> make_new_task('Test task t:1970-01-01 rec:+3d', now)
    'Test task t:1970-01-04 rec:+3d'
    >>> make_new_task('Test task due:1970-01-01 rec:3d', now)
    'Test task due:1970-01-06 rec:3d'
    >>> make_new_task('Test task due:1970-01-01 rec:+3d', now)
    'Test task due:1970-01-04 rec:+3d'
    >>> make_new_task('Test task t:1970-01-01 due:1970-01-05 rec:3d', now)
    'Test task t:1970-01-06 due:1970-01-10 rec:3d'
    >>> make_new_task('Test task t:1970-01-01 due:1970-01-05 rec:+3d', now)
    'Test task t:1970-01-04 due:1970-01-08 rec:+3d'
    >>> make_new_task('Test task rec:1 rec:2', now)
    Traceback (most recent call last):
        ...
    Exception: Task has multiple `rec:` keys
    >>> make_new_task('Test task t:1970-01-01 t:1970-01-02, rec:1m', now)
    Traceback (most recent call last):
        ...
    Exception: Task has multiple `t:` keys
    >>> make_new_task('Test task due:1970-01-01 due:1970-01-02 rec:1', now)
    Traceback (most recent call last):
        ...
    Exception: Task has multiple `due:` keys

    # README examples
    >>> make_new_task('Fix lamp')
    >>> make_new_task('Meet friend for tea rec:1')
    'Meet friend for tea rec:1'
    >>> now = datetime.date(2021, 1, 2)
    >>> make_new_task('Water flowers t:2021-01-01 rec:5d', now)
    'Water flowers t:2021-01-07 rec:5d'
    >>> make_new_task('Send birthday greeting to friend t:2021-04-04 rec:+1y')
    'Send birthday greeting to friend t:2022-04-04 rec:+1y'
    >>> make_new_task('Pay rent t:2021-01-28 due:2021-02-01 rec:+1m')
    'Pay rent t:2021-02-28 due:2021-03-01 rec:+1m'
    >>> now = datetime.date(2021, 1, 3)
    >>> old_task = 'Do offline backup t:2021-01-01 due:2021-01-08 rec:2w'
    >>> make_new_task(old_task, now)
    'Do offline backup t:2021-01-17 due:2021-01-24 rec:2w'
    >>> now = datetime.date(2021, 1, 31)
    >>> make_new_task('Get groceries t:2021-01-14 rec:1m', now)
    'Get groceries t:2021-02-28 rec:1m'
    >>> make_new_task('Pay rent t:2021-01-31 due:2021-02-01 rec:+1m')
    'Pay rent t:2021-02-28 due:2021-03-01 rec:+1m'

    """
    adjustment = get_key_value(line, 'rec')
    if adjustment is None:
        return None
    start_date = get_date(line, 't')
    due_date = get_date(line, 'due')

    # Remove optional creation date
    line = re.sub("^(?P<pri>\([A-Z]\) )?\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ", "\g<pri>", line)

    # Remove notes: and min:
    # line = re.sub("^(?P<pri>\([A-Z]\) )?\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) ", "\g<pri>", line)

    if not start_date and not due_date:
        # Neither date is specified
        return line
    elif start_date and due_date:
        if adjustment.startswith('+'):
            # Both dates are specified, with strict recurrence
            # Original + adjustment for both dates
            start_date = adjust_date(start_date, adjustment)
            due_date = adjust_date(due_date, adjustment)
        else:
            # Both dates are specified, with normal recurrence
            offset = due_date - start_date
            # Today + adjustment for start date
            start_date = adjust_date(now, adjustment)
            # Today + offset for due date
            due_date = start_date + offset
    elif adjustment.startswith('+'):
        # Only one date is specified, with strict recurrence.
        # Original + adjustment for whichever date is specified
        start_date = adjust_date(start_date, adjustment)
        due_date = adjust_date(due_date, adjustment)
    else:
        # Only one date is specified, with normal recurrence.
        # Today + adjustment for whichever date is specified
        if start_date:
            start_date = adjust_date(now, adjustment)
        if due_date:
            due_date = adjust_date(now, adjustment)
    # Apply date changes
    if start_date:
        line = set_key_value(line, 't', start_date.isoformat())
    if due_date:
        line = set_key_value(line, 'due', due_date.isoformat())
    return line


def mark_done(item_number):
    """Mark task as complete by executing todo-txt *do* action."""
    subprocess.run(
        [os.environ['TODO_FULL_SH'], 'command', 'do', str(item_number)],
        check=True,
        stdout=sys.stdout,
        stderr=sys.stderr,
    )


def parse_args():
    p = argparse.ArgumentParser(
        add_help=False,
        description=globals()['__doc__'],
        formatter_class=argparse.RawDescriptionHelpFormatter)
    subp = p.add_subparsers(title='actions', dest='action', required=True)
    usage = subp.add_parser('usage')
    usage = subp.add_parser('test')
    do = subp.add_parser('do')
    do.add_argument('item', type=int, nargs='+', metavar='ITEM#',
                    help='The task number')
    return p.parse_args()


def set_key_value(line, key, value):
    """Set value of key and return new line.

    A value of None deletes the key and its value.

    This does not check for duplicate keys; that is done by
    get_key_value().

    >>> set_key_value('Test task', 'key', 'val')
    'Test task key:val'
    >>> set_key_value('Test task key:val1 b key2:val2', 'key', 'val')
    'Test task key:val b key2:val2'
    >>> set_key_value('Test task key:val', 'key', None)
    'Test task'

    """
    if value is None:
        space = ''
        replacement = ''
    else:
        space = r'\1'
        replacement = '{}:{}'.format(key, value)
    line, repl_count = re.subn(r'(^| ){}:[^ ]*'.format(key),
                               space + replacement, line)
    if repl_count == 0:
        line += ' ' + replacement
    return line


def usage():
    """Return usage text suitable for todo-txt."""
    text = """
    do ITEM#[, ITEM#, ITEM#, ...]
      Mark ITEM# as complete. If `rec:` is set, add a new task, updating
      any start/due dates based on the value of `rec:`.
"""
    return text.strip('\n') + '\n'


if __name__ == '__main__':
    args = parse_args()
    if args.action == 'usage':
        print(usage())
        quit()
    elif args.action == 'test':
        import doctest
        doctest.testmod()
    elif args.action == 'do':
        for task in args.item:
            old_task = get_line(task)
            if re.match(r'^x ', old_task):
                print('Task {} is already marked as done!'.format(task))
                sys.exit(1)
            new_task = make_new_task(old_task)
            mark_done(task)
            # Archive old_task notes file after marking tasks as done
            move_files_with_note_prefix(file_to_read, source_folder,archive_folder, task)

            if new_task:
                add_new_task(new_task)
                

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.