root/misc/todo.pl

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

update todo.pl

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