root/misc/todo.pl

Revision 2006 (checked in by miyagawa, 14 years ago)

use YAML::Syck to avoid segfaults

  • Property svn:mime-type set to text/script
  • Property svn:executable set to *
Line 
1 #!/usr/bin/env perl
2 use strict;
3 use warnings;
4
5 =head1 DESCRIPTION
6
7 This is a simple command-line interface to Hiveminder that loosely
8 emulates the interface of Lifehacker.com's todo.sh script.
9
10 =cut
11
12 use Encode ();
13 use YAML::Syck ();
14 use LWP::UserAgent;
15 use Number::RecordLocator;
16 use Getopt::Long;
17 use Pod::Usage;
18 use Email::Address;
19 use Fcntl qw(:mode);
20
21 our $CONFFILE = "$ENV{HOME}/.hiveminder";
22 our %config = ();
23 our $ua = LWP::UserAgent->new;
24 our $locator = Number::RecordLocator->new();
25 our $default_query = "not/complete/starts/before/tomorrow/accepted/but_first/nothing";
26 our $pending_query = "pending/not/complete";
27 our $requests_query = "requestor/me/not/owner/me/not/complete";
28 our %args;
29
30 $ua->cookie_jar({});
31
32 # Load the user's proxy settings from %ENV
33 $ua->env_proxy;
34
35 my $encoding;
36 eval {
37     require Term::Encoding;
38     $encoding = Term::Encoding::get_encoding();
39 };
40 $encoding ||= "utf-8";
41
42 binmode STDOUT, ":encoding($encoding)";
43
44 main();
45
46 sub main {
47     GetOptions(\%args,
48                "tags=s",
49                "tag=s@", "group=s",
50                "priority|pri=s",
51                "due=s",
52                "hide=s",
53                "owner=s",
54                "help",
55                "config=s",)
56       or pod2usage(2);
57
58     $CONFFILE = $args{config} if $args{config};
59
60     pod2usage(0) if $args{help};
61
62     setup_config();
63
64     push @{$args{tag}}, split /\s+/, $args{tags} if $args{tags};
65
66     if($args{priority}) {
67         $args{priority} = priority_from_string($args{priority})
68           unless $args{priority} =~ /^[1-5]$/;
69         die("Invalid priority: $args{priority}")
70           unless$args{priority} =~ /^[1-5]$/;
71     }
72
73     $args{owner} ||= "me";
74
75     do_login() or die("Bad username/password -- edit $CONFFILE and try again.");
76
77     my %commands = (
78         list     => \&list_tasks,
79         ls       => \&list_tasks,
80         add      => \&add_task,
81         do       => \&do_task,
82         done     => \&do_task,
83         del      => \&del_task,
84         rm       => \&del_task,
85         edit     => \&edit_task,
86         tag      => \&tag_task,
87         pending  => sub {list_tasks($pending_query)},
88         accept   => \&accept_task,
89         decline  => \&decline_task,
90         assign   => \&assign_task,
91         requests => sub {list_tasks($requests_query)},
92         hide     => \&hide_task,
93         comment  => \&comment_task,
94         dl       => \&download_textfile,
95         download => \&download_textfile,
96         ul       => \&upload_textfile,
97         upload   => \&upload_textfile,
98        );
99    
100     my $command = shift @ARGV || "list";
101     $commands{$command} or pod2usage(-message => "Unknown command: $command", -exitval => 2);
102
103     $commands{$command}->();
104 }
105
106
107 =head1 CONFIG FILE
108
109 These methods deal with loading the config file, and populating it
110 with selections read from the terminal on our first run.
111
112 =cut
113
114 sub setup_config {
115     check_config_perms() unless($^O eq 'MSWin32');
116     load_config();
117     check_config();
118
119 }
120
121 sub check_config_perms {
122     return unless -e $CONFFILE;
123     my @stat = stat($CONFFILE);
124     my $mode = $stat[2];
125     if($mode & S_IRGRP || $mode & S_IROTH) {
126         warn("Config file $CONFFILE is readable by someone other than you, fixing.");
127         chmod 0600, $CONFFILE;
128     }
129 }
130
131 sub load_config {
132     return unless(-e $CONFFILE);
133     %config = %{YAML::Syck::LoadFile($CONFFILE) || {}};
134     my $sid = $config{sid};
135     if($sid) {
136         my $uri = URI->new($config{site});
137         $ua->cookie_jar->set_cookie(0, 'JIFTY_SID_' . $uri->port,
138                                     $sid, '/', $uri->host, $uri->port,
139                                     0, 0, undef, 1);
140     }
141     if($config{site}) {
142         # Somehow, localhost gets normalized to localhost.localdomain,
143         # and messes up HTTP::Cookies when we try to set cookies on
144         # localhost, since it doesn't send them to
145         # localhost.localdomain.
146         $config{site} =~ s/localhost/127.0.0.1/;
147     }
148 }
149
150 sub check_config {
151     new_config() unless $config{email};
152 }
153
154 sub new_config {
155     print <<"END_WELCOME";
156 Welcome to todo.pl! before we get started, please enter your
157 Hiveminder username and password so we can access your tasklist.
158
159 This information will be stored in $CONFFILE, should you ever need to
160 change it.
161
162 END_WELCOME
163
164     $config{site} ||= 'http://hiveminder.com';
165
166     while (1) {
167         print "First, what's your email address? ";
168         $config{email} = <stdin>;
169         chomp($config{email});
170
171         use Term::ReadKey;
172         print "And your password? ";
173         ReadMode('noecho');
174         $config{password} = <stdin>;
175         chomp($config{password});
176         ReadMode('restore');
177
178         print "\n";
179
180         last if do_login();
181         print "That combination doesn't seem to be correct. Try again?\n";
182     }
183
184     save_config();
185 }
186
187 sub save_config {
188     YAML::Syck::DumpFile($CONFFILE, \%config);
189     chmod 0600, $CONFFILE;
190 }
191
192 =head1 TASKS
193
194 methods related to manipulating tasks -- the meat of the script.
195
196 =cut
197
198 sub list_tasks {
199     my $query = shift || $default_query;
200
201     my $tag;
202     $query .= "/tag/$tag" while $tag = shift @{$args{tag}};
203
204     for my $key qw(group priority due) {
205         $query .= "/$key/$args{$key}" if $args{$key};
206     }
207
208     $query .= "/owner/$args{owner}";
209
210     my $tasks = download_tasks($query);
211    
212     for my $t (@$tasks) {
213         printf "%4s ", $locator->encode($t->{id});
214         print '(' . priority_to_string($t->{priority}) . ') ' if $t->{priority} != 3;
215         print "(Due " . $t->{due} . ") " if $t->{due};
216         print $t->{summary};
217         if($t->{tags}) {
218             print ' [' . $t->{tags} . ']';
219         }
220
221         $t->{owner} =~ s/<nobody>/<nobody\@localhost>/;
222         $t->{requestor} =~ s/<nobody>/<nobody\@localhost>/;
223        
224         my ($owner) = Email::Address->parse($t->{owner});
225         my ($requestor) = Email::Address->parse($t->{requestor});
226
227         my $not_owner = lc $owner->address ne lc $config{email};
228         my $not_requestor = lc $requestor->address ne lc $config{email};
229         if( $t->{group} || $not_owner || $not_requestor ) {
230             print ' (';
231             print join(", ",
232                        $t->{group} || "personal",
233                        $not_requestor ? "for " . $requestor->name : (),
234                        $not_owner ? "by " . $owner->name : (),
235                       );
236             print ')';
237         }
238        
239         print "\n";
240     }
241 }
242
243 sub do_task {
244     my $task = get_task_id('complete');
245     my $result = call(UpdateTask =>
246                       id         => $task,
247                       complete   => 1);
248     result_ok($result, "Completed task");
249 }
250
251 sub add_task {
252     my $summary = join(" ",@ARGV) or pod2usage(-message => "Must specify a task description");
253     my %task = %{args_to_task()};
254     $task{summary} = $summary;
255
256     my $result = call(CreateTask => %task);
257     result_ok($result, sub { "Created task " . $locator->encode($result->{_content}->{id}) });
258 }
259
260 sub edit_task {
261     my $task = get_task_id('edit');
262     my $summary = join(" ",@ARGV);
263     my %task = %{args_to_task()};
264     $task{id} = $task;
265     $task{summary} = $summary if $summary;
266
267     my $result = call(UpdateTask => %task);
268     result_ok($result, "Updated task");
269 }
270
271 sub tag_task {
272     my $task = get_task_id('tag');
273     my @tags = @ARGV;
274
275     my $tasks = download_tasks("id/$task");
276     my $tags = $tasks->[0]{tags} || '';
277
278     my $result = call(UpdateTask =>
279                       id      => $task,
280                       tags    => $tags . " " . join_tags(@tags));
281
282     result_ok($result, "Tagged task");
283 }
284
285 sub del_task {
286     my $task = get_task_id('delete');
287     my $result = call(DeleteTask => id => $task);
288
289     result_ok($result, "Deleted task");
290 }
291
292 sub accept_task {
293     my $task = get_task_id('accept');
294     my $result = call(UpdateTask =>
295                       id       => $task,
296                       accepted => 'TRUE');
297     result_ok($result, "Accepted task");
298 }
299
300
301 sub decline_task {
302     my $task = get_task_id('accept');
303     my $result = call(UpdateTask =>
304                       id       => $task,
305                       accepted => 'FALSE');
306     result_ok($result, "Declined task");
307 }
308
309 sub assign_task {
310     my $task = get_task_id('assign');
311     my $owner = shift @ARGV or die('Need an owner to assign task to');
312     my $result = call(UpdateTask => id => $task, owner_id => $owner);
313     result_ok($result, "Assigned task to $owner");
314 }
315
316 sub hide_task {
317     my $task = get_task_id('hide');
318     my $when = join(" ", @ARGV) or die('Need a date to hide the task until');
319     my $result = call(UpdateTask =>
320                       id         => $task,
321                       starts     => $when);
322     result_ok($result, "Hid task until $when");
323 }
324
325 sub comment_task {
326     my $task = get_task_id('comment on');
327     if(-t STDIN) {
328         print "Type your comment now. End with end-of-file or a dot on a line by itself.\n";
329     }
330     my $comment;
331     while(<STDIN>) {
332         chomp;
333         last if $_ eq ".";
334         $comment .= "\n$_";
335     }
336
337     my $result = call(UpdateTask =>
338                       id         => $task,
339                       comment    => $comment);
340     result_ok($result, "Commented on task");
341 }
342
343 sub get_task_id {
344     my $action = shift;
345     my $task = shift @ARGV or pod2usage(-message => "Need a task-id to $action.");
346     return $locator->decode($task) or die("Invalid task ID");
347 }
348
349 sub download_textfile {
350     my $query = shift || $default_query;
351     my $filename = shift @ARGV || 'tasks.txt';
352
353     my $tag;
354     $query .= "/tag/$tag" while $tag = shift @{$args{tag}};
355
356     for my $key qw(group priority due) {
357         $query .= "/$key/$args{$key}" if $args{$key};
358     }
359
360     $query .= "/owner/$args{owner}";
361
362     my $result = call(DownloadTasks =>
363                       query  => $query,
364                       format => 'sync');
365
366     # perl automatically does TRT with $filename eq '-'
367     open (my $file, ">:utf8", $filename) || die("Can't open file '$filename': $!");
368
369     print $file $result->{_content}{result};
370 }
371
372 sub upload_textfile {
373     my $filename = shift @ARGV;
374     pod2usage(-message => "Need to specify a file to upload.",
375               -exitval => 1
376     ) unless $filename;
377
378     open (my $file, "< $filename");
379
380     local $/;
381     my $content = <$file>;
382
383     my $result = call(UploadTasks =>
384                         content => $content,
385                         format => 'sync' );
386
387     print STDOUT $result->{message} . "\n";
388 }
389
390
391 =head1 BTDT API
392
393 These functions deal with calling the BTDT/Jifty api to communicate
394 with the server.
395
396 =cut
397
398 sub do_login {
399     return 1 if $config{sid};
400     my $result = call(Login =>
401                       address  => $config{email},
402                       password => $config{password});
403     if(!$result->{failure}) {
404         $config{sid} = get_session_id();
405         save_config();
406         return 1;
407     }
408     return;
409 }
410
411 sub get_session_id {
412     return undef unless $ua->cookie_jar->as_string =~ /JIFTY_SID_\d+=([^;]+)/;
413     return $1;
414 }
415
416 sub download_tasks {
417     my $query = shift || $default_query;
418
419     my $result = call(DownloadTasks =>
420                       query  => $query,
421                       format => 'yaml');
422     return YAML::Syck::Load($result->{_content}{result});
423 }
424
425 sub call ($@) {
426     my $class   = shift;
427     my %args    = (@_);
428     my $moniker = 'fnord';
429
430     my $res = $ua->post(
431         $config{site} . "/__jifty/webservices/yaml",
432         {   "J:A-$moniker" => $class,
433             map { ( "J:A:F-$_-$moniker" => $args{$_} ) } keys %args
434         }
435     );
436
437     if ( $res->is_success ) {
438         return YAML::Syck::Load( Encode::decode_utf8($res->content) )->{fnord};
439     } else {
440         die $res->status_line;
441     }
442 }
443
444 =head2 result_ok RESULT, MESSAGE
445
446 Make sure that a result returned by C<call> indicates success. If so,
447 print MESSAGE. If MESSAGE is a subroutine reference, execute it to get
448 the message. Otherwise, die with a descriptive error.
449
450 =cut
451
452 sub result_ok {
453     my $result = shift;
454     my $message = shift;
455
456     if(!$result->{failure}) {
457         print ref($message) ? $message->() . "\n" : "$message\n";
458     } else {
459         die(YAML::Syck::Dump($result));
460     }
461    
462 }
463
464 =head2 PRIORITY
465
466 Conversions between text priorities ('A' - 'Z'), and the 1-5 integer
467 scale Hiveminder uses internally.
468
469 =cut
470
471 sub priority_to_string {
472     my $pri = shift;
473     return chr(ord('A') + 5 - $pri);
474 }
475
476 sub priority_from_string {
477     my $pri = lc shift;
478     return 5 + ord('a') - ord($pri) if $pri =~ /^[a-e]$/;
479     my %primap = (
480         lowest  => 1,
481         low     => 2,
482         normal  => 3,
483         high    => 4,
484         highest => 5
485        );
486     return $primap{$pri} || $pri;
487 }
488
489 =head2 args_to_task
490
491 Convert argument passed on the command-line into a hash appropriate
492 for passing as arguments to BTDT actions.
493
494 =cut
495
496 sub args_to_task {
497     my %task;
498
499     $task{tags} = join_tags(@{$args{tag}}) if $args{tag};
500     $task{group_id} = $args{group} if $args{group};
501     $task{owner_id} = $config{email};
502     $task{priority} = $args{priority} if $args{priority};
503     $task{due} = $args{due} if $args{due};
504     $task{starts} = $args{hide} if $args{hide};
505    
506     return \%task;
507 }
508
509 sub join_tags {
510     my @tags = @_;
511     return join(" ", map {'"' . $_ . '"'} @tags);
512 }
513
514 __END__
515
516 =head1 NAME
517
518 todo.pl - a command-line interface to Hiveminder
519
520 =head1 SYNOPSIS
521
522   todo.pl [options] list
523   todo.pl [options] add <summary>
524   todo.pl [options] edit <task-id> [summary]
525
526   todo.pl tag <task-id> tag1 tag2
527
528   todo.pl done <task-id>
529   todo.pl del|rm <task-id>
530
531   todo.pl [options] pending
532   todo.pl accept <task-id>
533   todo.pl decline <task-id>
534
535   todo.pl assign <task-id> <email>
536   todo.pl [options] requests
537
538   todo.pl hide <task-id> date
539
540   todo.pl comment <task-id>
541
542   todo.pl [options] download [file]
543   todo.pl upload <file>
544
545     Options:
546        --group                          Operate on tasks in a group
547        --tag                            Operate on tasks with a given tag
548        --pri                            Operate on tasks with a given priority
549        --due                            Operate on tasks due on a given day
550        --hide                           Operate on tasks hidden until this day
551        --owner                          Operate on tasks with a given owner
552
553
554   todo.pl list
555         List all tasks in your todo list.
556
557   todo.pl --tag home --tag othertag --group personal list
558         List all personl tasks (not in a group with tags 'home' and 'othertag'.
559
560   todo.pl --tag cli --group hiveminders edit 3G Implement todo.pl
561         Move task 3G into the hiveminders group, set its tags to
562         "cli", and change the summary.
563
564   todo.pl --tag "" 4J
565         Delete all tags from task 4J
566
567   todo.pl tag 4J home
568         Add the tag ``home'' to task 4J
569
570 =cut
Note: See TracBrowser for help on using the browser.