1
# lite2do-irssi, a lightweight todo manager for Irssi
2
# Copyright (C) 2008, 2009 Jaromir Hradilek
3
4
# This program is free software;  you can redistribute it  and/or modify it
5
# under the  terms of the  GNU General Public License  as published  by the
6
# Free Software Foundation; version 3 of the License.
7
# 
8
# This  program is  distributed  in the  hope that  it will be useful,  but
9
# WITHOUT ANY WARRANTY;  without even the implied warranty of  MERCHANTABI-
10
# LITY  or  FITNESS FOR A PARTICULAR PURPOSE.  See  the  GNU General Public
11
# License for more details.
12
# 
13
# You should have received a copy of the  GNU General Public License  along
14
# with this program. If not, see <http://www.gnu.org/licenses/>.
15
16
use strict;
17
use warnings;
18
use Irssi;
19
use locale;
20
use File::Copy;
21
use File::Spec::Functions;
22
23
# General script information:
24
our $VERSION  = '1.1.3';
25
our %IRSSI    = (
26
  authors     => 'Jaromir Hradilek',
27
  contact     => 'jhradilek@gmail.com',
28
  name        => 'lite2do-irssi',
29
  description => 'A lightweight todo manager.  Being based on w2do and '.
30
                 'fully compatible with its save file format, it tries '.
31
                 'to provide a simple alternative for IRC network well '.
32
                 'capable of collaborative task management. ',
33
  url         => 'http://w2do.blackened.cz/tags/lite2do-irssi/',
34
  license     => 'GNU General Public License, version 3',
35
  changed     => '2009-08-13',
36
);
37
38
# General script settings:
39
our $HOMEDIR  = Irssi::get_irssi_dir();          # Irssi's  home directory.
40
our $SAVEFILE = catfile($HOMEDIR, 'lite2do');    # Save file location.
41
our $BACKEXT  = '.bak';                          # Backup file extension.
42
our $TRIGGER  = ':todo';                         # Script invoking command.
43
our $COLOURED = 0;                               # Whether to use colours.
44
our $LISTALL  = 0;                               # Whether to allow listing
45
                                                 # all tasks at once.
46
# Access control:
47
our @ALLOWED  = qw( *!*@* );                     # Allowed IRC masks.
48
our @BANNED   = qw( );                           # Banned IRC masks.
49
50
# Load selected data from the save file:
51
sub load_selection {
52
  my ($selected, $rest, $id, $group, $task) = @_;
53
54
  # Escape reserved characters:
55
  $group =~ s/([\\\^\.\$\|\(\)\[\]\*\+\?\{\}])/\\$1/g if $group;
56
  $task  =~ s/([\\\^\.\$\|\(\)\[\]\*\+\?\{\}])/\\$1/g if $task;
57
58
  # Remove colons if any:
59
  $group =~ s/://g if $group;
60
61
  # Use default pattern when none is provided:
62
  $id    ||= '\d+';
63
  $group ||= '[^:]*';
64
  $task  ||= '';
65
66
  # Open the save file for reading:
67
  if (open(SAVEFILE, "$SAVEFILE")) {
68
69
    # Process each line:
70
    while (my $line = <SAVEFILE>) {
71
72
      # Check whether the line matches given pattern:
73
      if ($line =~ /^$group:[^:]*:[1-5]:[ft]:.*$task.*:$id$/i) {
74
        # Add the line to selected tasks:
75
        push(@$selected, $line);
76
      }
77
      else {
78
        # Add the line to unselected tasks:
79
        push(@$rest, $line);
80
      }
81
    }
82
83
    # Close the save file:
84
    close(SAVEFILE);
85
  }
86
}
87
88
# Save given data to the save file:
89
sub save_data {
90
  my $data = shift;
91
92
  # Backup the save file:
93
  copy($SAVEFILE, "$SAVEFILE$BACKEXT") if (-r $SAVEFILE);
94
95
  # Open the save file for writing:
96
  if (open(SAVEFILE, ">$SAVEFILE")) {
97
98
    # Write data to the save file:
99
    foreach my $line (@$data) {
100
      print SAVEFILE $line;
101
    }
102
103
    # Close the save file:
104
    close(SAVEFILE);
105
  }
106
  else {
107
    # Report failure:
108
    Irssi::print($IRSSI{name} . ": Unable to write to `$SAVEFILE'.");
109
  }
110
}
111
112
# Add given data to the end of the save file:
113
sub add_data {
114
  my $data = shift;
115
116
  # Backup the save file:
117
  copy($SAVEFILE, "$SAVEFILE$BACKEXT") if (-r $SAVEFILE);
118
119
  # Open the save file for appending:
120
  if (open(SAVEFILE, ">>$SAVEFILE")) {
121
122
    # Write data to the save file:
123
    foreach my $line (@$data) {
124
      print SAVEFILE $line;
125
    }
126
127
    # Close the save file:
128
    close(SAVEFILE);
129
  }
130
  else {
131
    # Report failure:
132
    Irssi::print($IRSSI{name} . ": Unable to write to `$SAVEFILE'.");
133
  }
134
}
135
136
# Get hash of all groups:
137
sub get_groups {
138
  my %groups = ();
139
140
  # Open the save file for reading:
141
  if (open(SAVEFILE, "$SAVEFILE")) {
142
143
    # Build the list of used groups:
144
    while (my $line = <SAVEFILE>) {
145
146
      # Parse the task record:
147
      if ($line =~ /^([^:]*):/) {
148
        my $group = lc($1);
149
150
        # Check whether the group is already added:
151
        if ($groups{$group}) {
152
          # Increment the counter:
153
          $groups{$group} += 1;
154
        }
155
        else {
156
          # Initialize the counter:
157
          $groups{$group}  = 1;
158
        }
159
      }
160
    }
161
162
    # Close the save file:
163
    close(SAVEFILE);
164
  }
165
166
  # Return the result:
167
  return %groups;
168
}
169
170
# Choose first available ID:
171
sub choose_id {
172
  my @used   = ();
173
  my $chosen = 1;
174
175
  # Open the save file for reading:
176
  if (open(SAVEFILE, "$SAVEFILE")) {
177
178
    # Build the list of used IDs:
179
    while (my $line = <SAVEFILE>) {
180
      push(@used, int($1)) if ($line =~ /:(\d+)$/);
181
    }
182
183
    # Close the save file:
184
    close(SAVEFILE);
185
186
    # Find first unused ID:
187
    foreach my $id (sort {$a <=> $b} @used) {
188
      $chosen++ if ($chosen == $id);
189
    }
190
  }
191
192
  # Return the result:
193
  return $chosen;
194
}
195
196
# Fix the group name:
197
sub fix_group {
198
  my $group = shift || return 'general';
199
200
  # Remove forbidden characters:
201
  $group =~ s/://g;
202
203
  # Strip it to the maximal allowed length:
204
  $group = substr($group, 0, 10);
205
206
  # Return the result using the default when empty:
207
  return $group ? $group : 'general';
208
}
209
210
# Display script usage:
211
sub display_help {
212
  my $command = shift || '';
213
214
  # Parse command and return appropriate usage information:
215
  if ($command =~ /^(list|ls)$/) {
216
    return "Displays items in the task list. " .
217
           "Usage: $TRIGGER list [\@GROUP|%ID] [TEXT...]";
218
  }
219
  elsif ($command =~ /^add$/) {
220
    return "Adds new item to the task list. " .
221
           "Usage: $TRIGGER add [\@GROUP] TEXT...";
222
  }
223
  elsif ($command =~ /^(change|mv)$/) {
224
    return "Changes selected item in the task list. " .
225
           "Usage: $TRIGGER change ID \@GROUP|TEXT...";
226
  }
227
  elsif ($command =~ /^(finish|fn)$/) {
228
    return "Finishes selected item in the task list. " .
229
           "Usage: $TRIGGER finish ID";
230
  }
231
  elsif ($command =~ /^(revive|re)$/) {
232
    return "Revives selected item in the task list. " .
233
           "Usage: $TRIGGER revive ID";
234
  }
235
  elsif ($command =~ /^(remove|rm)$/) {
236
    return "Removes selected item from the task list. " .
237
           "Usage: $TRIGGER remove ID";
238
  }
239
  elsif ($command =~ /^(groups|gr)$/) {
240
    return "Displays groups in the task list. " .
241
           "Usage: $TRIGGER groups";
242
  }
243
  elsif ($command =~ /^version$/) {
244
    return "Displays version information. " .
245
           "Usage: $TRIGGER version";
246
  }
247
  elsif ($command =~ /^help$/) {
248
    return "Displays usage information. " .
249
           "Usage: $TRIGGER help [COMMAND]";
250
  }
251
  else {
252
    return "Allowed commands: list, add, change, finish, revive, remove, ".
253
           "groups, version, help. Try `$TRIGGER help COMMAND' for more " .
254
           "information.";
255
  }
256
}
257
258
# Display script version:
259
sub display_version {
260
  return $IRSSI{name} . " $VERSION";
261
}
262
263
# List items in the task list:
264
sub list_tasks {
265
  my ($group, $task, $id) = @_;
266
  my (@selected, $state, $tasks);
267
268
  # Load matching tasks:
269
  load_selection(\@selected, undef, $id, $group, $task);
270
271
  # Check whether the list is not empty:
272
  if (@selected) {
273
274
    # Process each task:
275
    foreach my $line (sort @selected) {
276
277
      # Parse the task record:
278
      $line   =~ /^([^:]*):[^:]*:[1-5]:([ft]):(.*):(\d+)$/;
279
      $state  = ($2 eq 'f') ? '-' : 'f';
280
281
      # Check whether to use coloured output:
282
      if ($COLOURED) {
283
284
        # Decide which colour to use:
285
        my $col = ($2 eq 'f') ? '06' : '03';
286
287
        # Create the task entry:
288
        $tasks .= sprintf("\x02%2d.\x0F ", $4) .
289
                  "\x02\x03$col\@$1\x0F " .
290
                  "\x02[$state]\x0F" .
291
                  "\x03$col: $3\x0F\n";
292
      }
293
      else {
294
        # Create the task entry:
295
        $tasks .= sprintf("%2d. @%s [%s]: %s\n", $4, $1, $state, $3);
296
      }
297
    }
298
299
    # Return the result:
300
    return $tasks;
301
  }
302
  else {
303
    # Report empty list:
304
    return "No matching task found.";
305
  }
306
}
307
308
# List groups in the task list:
309
sub list_groups {
310
  # Get list of all groups:
311
  my %groups = get_groups();
312
313
  # Make sure the task list is not empty:
314
  if (scalar(keys %groups)) {
315
    # Return the list of groups:
316
    return join(', ', map { "$_ (" . $groups{$_} . ")" }
317
                      sort keys(%groups));
318
  }
319
  else {
320
    # Report empty list:
321
    return "No matching task found.";
322
  }
323
}
324
325
# Add new item to the task list:
326
sub add_task {
327
  my $task  = shift || '';
328
  my $group = shift || 'general';
329
  my $id    = choose_id();
330
331
  # Create the task record:
332
  my @data  = (fix_group($group) . ":anytime:3:f:$task:$id\n");
333
334
  # Add data to the end of the save file:
335
  add_data(\@data);
336
337
  # Report success:
338
  return "Task has been successfully added with id $id.";
339
}
340
341
# Change selected item in the task list:
342
sub change_task {
343
  my $id     = shift;
344
  my $text   = shift;
345
  my $group  = shift || 0;
346
  my (@selected, @rest);
347
348
  # Load tasks:
349
  load_selection(\@selected, \@rest, $id);
350
351
  # Check whether the list is not empty:
352
  if (@selected) {
353
354
    # Parse the task record:
355
    pop(@selected) =~ /^([^:]*):([^:]*):([1-5]):([ft]):(.*):\d+$/;
356
357
    # Decide which part to edit:
358
    unless ($group) {
359
360
      # Update the task record:
361
      push(@rest, "$1:$2:$3:$4:$text:$id\n");
362
    }
363
    else {
364
      # Update the group record:
365
      push(@rest, fix_group($text) . ":$2:$3:$4:$5:$id\n");
366
    }
367
368
    # Store data to the save file:
369
    save_data(\@rest);
370
371
    # Report success:
372
    return "Task has been successfully changed.";
373
  }
374
  else {
375
    # Report empty list:
376
    return "No matching task found.";
377
  }
378
}
379
380
# Mark selected item in the task list as finished:
381
sub finish_task {
382
  my $id = shift;
383
  my (@selected, @rest);
384
385
  # Load tasks:
386
  load_selection(\@selected, \@rest, $id);
387
388
  # Check whether the list is not empty:
389
  if (@selected) {
390
391
    # Parse the task record:
392
    pop(@selected) =~ /^([^:]*):([^:]*):([1-5]):[ft]:(.*):\d+$/;
393
394
    # Update the task record:
395
    push(@rest, "$1:$2:$3:t:$4:$id\n");
396
397
    # Store data to the save file:
398
    save_data(\@rest);
399
400
    # Report success:
401
    return "Task has been finished.";
402
  }
403
  else {
404
    # Report empty list:
405
    return "No matching task found.";
406
  }
407
}
408
409
# Mark selected item in the task list as unfinished:
410
sub revive_task {
411
  my $id = shift;
412
  my (@selected, @rest);
413
414
  # Load tasks:
415
  load_selection(\@selected, \@rest, $id);
416
417
  # Check whether the list is not empty:
418
  if (@selected) {
419
420
    # Parse the task record:
421
    pop(@selected) =~ /^([^:]*):([^:]*):([1-5]):[ft]:(.*):\d+$/;
422
423
    # Update the task record:
424
    push(@rest, "$1:$2:$3:f:$4:$id\n");
425
426
    # Store data to the save file:
427
    save_data(\@rest);
428
429
    # Report success:
430
    return "Task has been revived.";
431
  }
432
  else {
433
    # Report empty list:
434
    return "No matching task found.";
435
  }
436
}
437
438
# Remove selected item from the task list:
439
sub remove_task {
440
  my $id = shift;
441
  my (@selected, @rest);
442
443
  # Load tasks:
444
  load_selection(\@selected, \@rest, $id);
445
446
  # Check whether the list is not empty:
447
  if (@selected) {
448
449
    # Store data to the save file:
450
    save_data(\@rest);
451
452
    # Report success:
453
    return "Task has been successfully removed.";
454
  }
455
  else {
456
    # Report empty list:
457
    return "No matching task found.";
458
  }
459
}
460
461
# Send given IRC message:
462
sub send_message {
463
  my ($server, $target, $message) = @_;
464
465
  # Process each line:
466
  foreach my $line (split(/\n/, $message)) {
467
468
    # Send it as an IRC message:
469
    $server->command("MSG $target $line") if $line;
470
  }
471
}
472
473
# Perform proper action and return its response:
474
sub run_command {
475
  my $command = shift;
476
477
  # Parse command:
478
  if ($command =~ /^(|list|ls)\s*$/) {
479
480
    # Check whether the all tasks listing is allowed:
481
    unless ($LISTALL) {
482
483
      # Get list of all groups:
484
      my %list   = get_groups();
485
      my $groups = join(', ', sort(keys %list));
486
487
      # Make sure the task list is not empty:
488
      if ($groups) {
489
        # Ask user to specify the group:
490
        return "Please specify one of the following groups: $groups.";
491
      }
492
      else {
493
        # Report empty list:
494
        return "No matching task found.";
495
      }
496
    }
497
    else {
498
      # List all tasks:
499
      return list_tasks();
500
    }
501
  }
502
  elsif ($command =~ /^(list|ls)\s+@(\S+)\s*(\S.*|)$/) {
503
    # List tasks in the selected group:
504
    return list_tasks($2, $3);
505
  }
506
  elsif ($command =~ /^(list|ls)\s+%(\d+)/) {
507
    # List task with selected ID:
508
    return list_tasks(undef, undef, $2);
509
  }
510
  elsif ($command =~ /^(list|ls)\s+([^@^%\s].*)$/) {
511
    # List tasks matching given pattern:
512
    return list_tasks(undef, $2);
513
  }
514
  elsif ($command =~ /^add\s+@(\S+)\s+(\S.*)/) {
515
    # Add new task to selected group:
516
    return add_task($2, $1);
517
  }
518
  elsif ($command =~ /^add\s+([^@\s].*)/) {
519
    # Add new task to default group:
520
    return add_task($1);
521
  }
522
  elsif ($command =~ /^(change|mv)\s+%?(\d+)\s+@(\S+)\s*$/) {
523
    # Change selected task group:
524
    return change_task($2, $3, 1);
525
  }
526
  elsif ($command =~ /^(change|mv)\s+%?(\d+)\s+([^@\s].*)/) {
527
    # Change selected task:
528
    return change_task($2, $3);
529
  }
530
  elsif ($command =~ /^(finish|fn)\s+%?(\d+)/) {
531
    # Mark selected task as finished:
532
    return finish_task($2);
533
  }
534
  elsif ($command =~ /^(revive|re)\s+%?(\d+)/) {
535
    # Mark selected task as unfinished:
536
    return revive_task($2);
537
  }
538
  elsif ($command =~ /^(remove|rm)\s+%?(\d+)/) {
539
    # Remove selected task:
540
    return remove_task($2);
541
  }
542
  elsif ($command =~ /^(groups|gr)\s*$/) {
543
    # Display groups in the task list:
544
    return list_groups();
545
  }
546
  elsif ($command =~ /^version\s*$/) {
547
    # Display version information:
548
    return display_version();
549
  }
550
  elsif ($command =~ /^help\s*$/) {
551
    # Display list of supported commands:
552
    return display_help();
553
  }
554
  elsif ($command =~ /^help\s+(\S+)/) {
555
    # Display information on a specific command:
556
    return display_help($1);
557
  }
558
  else {
559
    # Report invalid command:
560
    return "Invalid command or argument. Try `$TRIGGER help' ".
561
           "for more information.";
562
  }
563
}
564
565
# Handle incoming public messages:
566
sub message_public {
567
  my ($server,  $message, $nick, $address, $target) = @_;
568
  my ($command, $response);
569
570
  # Check whether to respond:
571
  return unless ($message =~ /^$TRIGGER/);
572
573
  # Check user's access permission:
574
  if ($server->masks_match(join(' ', @ALLOWED), $nick, $address) &&
575
     !$server->masks_match(join(' ', @BANNED),  $nick, $address)) {
576
577
    # Strip message:
578
    $command = $message;
579
    $command =~ s/^$TRIGGER\s*//;
580
581
    # Perform proper action:
582
    $response = run_command($command);
583
  }
584
  else {
585
    # Report denial:
586
    $response = "Access denied.";
587
  }
588
589
  # Send response:
590
  Irssi::signal_continue($server, $message, $nick, $address, $target);
591
  send_message($server, $target, $response);
592
}
593
594
# Handle incoming private messages:
595
sub message_private {
596
  my ($server, $message, $nick, $address) = @_;
597
598
  # Otherwise same as in public message:
599
  message_public($server, $message, $nick, $address, $nick);
600
}
601
602
# Handle outcoming public messages:
603
sub message_own_public {
604
  my ($server, $message, $target) = @_;
605
  my $nick    = $server->{nick};
606
  my $address = $server->{userhost};
607
608
  # Otherwise same as in public message:
609
  message_public($server, $message, $nick, $address, $target);
610
}
611
612
# Handle /todo command:
613
sub cmd_todo {
614
  my ($args, $server, $witem) = @_;
615
616
  # Strip args:
617
  $args =~ s/^\s*//;
618
619
  # Perform proper action:
620
  Irssi::print(run_command($args));
621
}
622
623
# Register signals:
624
Irssi::signal_add('message public',     'message_public');
625
Irssi::signal_add('message private',    'message_private');
626
Irssi::signal_add('message own_public', 'message_own_public');
627
628
# Register commands:
629
Irssi::command_bind('todo', 'cmd_todo');
630
631
=head1 NAME
632
633
lite2do-irssi - a lightweight todo manager for Irssi
634
635
=head1 SYNOPSIS
636
637
In public channel or private query:
638
639
  :todo command [argument...]
640
641
or anywhere in Irssi:
642
643
  /todo command [argument...]
644
645
=head1 DESCRIPTION
646
647
B<lite2do-irssi> is a lightweight todo manager for Irssi written in Perl.
648
Being based on C<w2do> and fully compatible with its save file format, it
649
tries to provide a simple alternative for IRC network capable of
650
collaborative task management.
651
652
=head1 COMMANDS
653
654
=over
655
656
=item B<list> [I<@group>] [I<text>...]
657
658
=item B<ls> [I<@group>] [I<text>...]
659
660
Display items in the task list. Desired subset can be easily selected
661
giving a I<group> name, I<text> pattern, or combination of both; listing
662
all tasks is usually disabled to avoid unnecessary flood.
663
664
=item B<list> %I<id>
665
666
=item B<ls> %I<id>
667
668
Display task with selected I<id>.
669
670
=item B<add> [I<@group>] I<text>...
671
672
Add new item to the task list.
673
674
=item B<change> I<id> I<text>...
675
676
=item B<mv> I<id> I<text>...
677
678
Change item with selected I<id> in the task list.
679
680
=item B<change> I<id> @I<group>
681
682
=item B<mv> I<id> @I<group>
683
684
Change I<group> the item with selected I<id> belongs to.
685
686
=item B<finish> I<id>
687
688
=item B<fn> I<id>
689
690
Mark item with selected I<id> as finished.
691
692
=item B<revive> I<id>
693
694
=item B<re> I<id>
695
696
Mark item with selected I<id> as unfinished.
697
698
=item B<remove> I<id>
699
700
=item B<rm> I<id>
701
702
Remove item with selected I<id> from the task list.
703
704
=item B<groups>
705
706
=item B<gr>
707
708
Display list of groups in the task list along with the number of tasks that
709
belong to them.
710
711
=item B<help> [I<command>]
712
713
Display usage information. By default, list of all supported commands is
714
displayed. If the I<command> is supplied, further information on its usage
715
are displayed instead.
716
717
=item B<version>
718
719
Display version information.
720
721
=back
722
723
=head1 FILES
724
725
=over
726
727
=item F<~/.irssi/lite2do>
728
729
Default save file.
730
731
=back
732
733
=head1 SEE ALSO
734
735
B<w2do>(1), B<lite2do>(1), B<irssi>(1).
736
737
=head1 BUGS
738
739
To report bugs or even send patches, you can either add new issue to the
740
project bugtracker at <http://code.google.com/p/w2do/issues/>, visit the
741
discussion group at <http://groups.google.com/group/w2do/>, or you can
742
contact the author directly via e-mail.
743
744
=head1 AUTHOR
745
746
Written by Jaromir Hradilek <jhradilek@gmail.com>.
747
748
Permission is granted to copy, distribute and/or modify this document under
749
the terms of the GNU Free Documentation License, Version 1.3 or any later
750
version published by the Free Software Foundation; with no Invariant
751
Sections, no Front-Cover Texts, and no Back-Cover Texts.
752
753
A copy of the license is included as a file called FDL in the main
754
directory of the lite2do-irssi source package.
755
756
=head1 COPYRIGHT
757
758
Copyright (C) 2008, 2009 Jaromir Hradilek
759
760
This program is free software; see the source for copying conditions. It is
761
distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
762
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
763
PARTICULAR PURPOSE.
764
765
=cut