#!/usr/bin/perl =pod =head1 NAME todoist-fetch.pl - Simple todoist backup / restore script, to reassure you that you won't lose all of your data if Todoist suddenly goes belly-up. =head1 SYNOPSIS todoist-fetch.pl [B<--help>] [B<--manual>] [B<--project>=B>] [B<--username>=B>] [B<--password>=B>] B<--text> | B<--reorder> [B>] | B<--import> [B>] | B<--backup> [B<--nocompleted>] =head1 DESCRIPTION =head2 Modes This script has four modes: =over =item --text Fetch all of the items in a specific project and print them one per line, including the item ID and content for each item. =item --reorder Read a list in the format produced by --text and reorder the project so it matches the (presumably reordered) list. =item --import Read a list of to-do items and import them into the specified project. Blank lines are ignored; all other lines are considered to-do items. Whitespace indentation in the input list causes indented to-do items. The use of tab indentation is discouraged, but if it is used, then tab stops are assumed to be eight spaces apart. Be consistent in indentation or the results may be confusing. =item --backup Fetch all of your project and item data (not notes or labels, since I don't use them, but patches are welcome :-) and print them in JSON format with some text annotations explaining what the various JSON blobs are. =back =head2 Backup notes The format of the backup mode output isn't intended to be particularly user-friendly, or easy to read, or easy to import into another application. If/when you need to do anything real with the data, you'll probably need to write another script to convert it into something useful. All it's intended to do is snapshot your data so you'll have it in an emergency. Backup mode uses a non-public API call, because the public API doesn't allow all completed tasks to be fetched except for premium users, and I'm not a premium user. If the folks at todoist change the non-public API, this will break. =head1 OPTIONS Other options in addition to the mode options described above: =over =item --help Print usage message and exit. =item --manual Print entire manual and exit. =item --username (or set $TODOIST_USERNAME) Specify Todoist username. =item --password (or set $TODOIST_PASSWORD) Specify Todoist password. =item --project (or set $TODOIST_PROJECT) Specify Todoist password for --text, --reorder, or --import. =item --nocompleted Don't export completed items in backup. =back =head1 AUTHOR Jonathan Kamens . Please feel free to contact me with questions, comments, or suggestions! Also, please consider making a donation at L to support future development of this and other free tools. =head1 COPYRIGHT Copyright (C) 2012 Jonathan Kamens. Released under the terms of the GNU General Public License, Version 3 or any subsequent version at your discretion. See L. =head1 VERSION $Id: todoist-fetch.pl,v 1.8 2012/12/10 16:10:26 jik Exp $ =cut # Better formatted HTML: # sed -e 's/=head2/=head3/' -e 's/=head1/=head2/' todoist-fetch.pl | perl -MPod::Html -e pod2html >| todoist-fetch.html use strict; use warnings; use Data::Dumper; use File::Basename; use Getopt::Long; use JSON; use Pod::Usage; use WWW::Mechanize; use Term::ReadPassword; use Text::Tabs; use URI::Escape; my $whoami = basename $0; my($text, $backup, $reorder, $import); my $password = $ENV{"TODOIST_PASSWORD"}; my $project_name = $ENV{"TODOIST_PROJECT"}; my $username = $ENV{"TODOIST_USERNAME"}; my $completed = 1; pod2usage(-exitval => 2, -verbose => 0) if (! GetOptions("help" => sub { pod2usage(-exitval => 0, -verbose => 0); exit; }, "manual" => sub { pod2usage(-exitval => 0, -verbose => 2); exit; }, "project=s" => \$project_name, "username=s" => \$username, "password=s" => \$password, "text" => \$text, "backup" => \$backup, "reorder" => \$reorder, "import" => \$import, "completed!" => \$completed, )); pod2usage(-msg => "$whoami: Must set \$TODOIST_USERNAME or specify --username\n", -exitval => 2, -verbose => 0) if (! $username); pod2usage(-msg => "$whoami: Specify only one of --text, --backup, --reorder, --import\n", -exitval => 2, -verbose => 0) if (!!$text + !!$backup + !!$reorder + !!$import > 1); $text = 1 if (! ($text || $backup || $reorder || $import)); $password = read_password("Todoist password for $username: ") if (! $password); my $ua = new WWW::Mechanize; my $token = &login; if ($text) { &text; } elsif ($reorder) { &reorder; } elsif ($import) { &do_import; } elsif ($backup) { &backup; } sub login { my $response = $ua->get("http://todoist.com/"); $response = $ua->get("http://todoist.com/Users/showLogin?mini=1"); $ua->submit_form("with_fields" => { "email" => $username, "password" => $password, }); $response = $ua->get("https://todoist.com/API/login?email=$username&password=$password"); my $ret = decode_json($response->decoded_content); return $ret->{"api_token"}; } sub get_project_id { my($project_name) = @_; my($project_id, $response, $ret); pod2usage(-msg => "$whoami: Must set \$TODOIST_PROJECT or specify --project\n", -exitval => 2, -verbose => 0) if (! $project_name); $response = $ua->get("http://todoist.com/API/getProjects?token=$token"); $ret = decode_json($response->decoded_content); foreach my $project (@{$ret}) { if ($project->{"name"} eq $project_name) { $project_id = $project->{"id"}; last; } } die "$whoami: unknown project: $project_name\n" if (! $project_id); return $project_id; } sub reorder { my $project_id = &get_project_id($project_name); my($ret, $response); # Storing new order, not fetching my(@ids); while (<>) { if (! /^(\d+)\t/) { die "Invalid task: $_"; } push(@ids, $1); } die if (! @ids); $ret = encode_json(\@ids); $response = $ua->get("http://todoist.com/API/updateOrders?project_id=$project_id&token=$token&item_id_list=$ret"); exit; } sub do_import { my $project_id = &get_project_id($project_name); my($ret, $response); my(@tabs); my($indent) = 1; local($_); while (<>) { chomp; next if (/^\s*$/); $_ = expand($_); if (s/^(\s+)//) { my $len = length($1); while (@tabs and $len < $tabs[-1]) { pop(@tabs); $indent--; } if (! @tabs or $len > $tabs[-1]) { push(@tabs, $len); $indent++; } } else { $indent = 1; @tabs = (); } my $url = "http://todoist.com/API/addItem?project_id=$project_id&token=$token&indent=$indent&priority=1&content=" . uri_escape($_); $ua->get($url); } } sub text { my $project_id = &get_project_id($project_name); my($response, $ret); $response = $ua->get("http://todoist.com/API/getUncompletedItems?project_id=$project_id&token=$token"); $ret = decode_json($response->decoded_content); foreach my $item (@{$ret}) { print $item->{"id"}, "\t"; print " " x ($item->{"indent"} - 1); my $content = $item->{"content"}; $content =~ s/\n/\\n/g; print $content, "\n"; } } sub backup { my($response, $ret); $response = $ua->get("http://todoist.com/API/getProjects?token=$token"); print $response->decoded_content, "\n\n"; $ret = decode_json($response->decoded_content); foreach my $project (@{$ret}) { print "Project: $project->{name}\n\n"; my $project_id = $project->{"id"}; $response = $ua->get("http://todoist.com/API/getUncompletedItems?token=$token&project_id=$project_id"); print " Uncompleted items:\n\n"; print " ", $response->decoded_content, "\n\n"; if ($completed) { my $limit = 0; print " Completed items:\n\n"; while (1) { $response = $ua->post("http://todoist.com/Items/getMoreHistory", { "current_limit" => $limit, "project_id" => $project_id, }); my $content = $response->decoded_content; $content =~ s/new Date\((".*?")\)/$1/g; $ret = decode_json($content); my $new = @{$ret->{"completed"}}; last if (! $new); print " ", $content, "\n"; $limit += $new; } print "\n"; } } }