root/misc/todo.pl

Revision 2877 (checked in by miyagawa, 12 years ago)

script to force kill and restart app on Mac

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