root/misc/todo.pl

Revision 2014 (checked in by miyagawa, 13 years ago)

Unicode string

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