#!/usr/bin/perl # # note - console notes management with database and encryption support. # Copyright (C) 1999-2024 T.v.Dein (see README for details!) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # # - Thomas von Dein # # latest version on: # http://www.daemon.de/note/ # https://github.com/TLINDEN/note/ # use lib qw(blib/lib); BEGIN { # works on unix or cygwin only! my $path = $0; $path =~ s#/[^/]*$##; unshift @INC, "$path/.."; } use strict; no strict 'refs'; use Getopt::Long; use FileHandle; use File::Spec; use YAML; use JSON::PP; # # prototypes # sub usage; # print usage message for us thumb userz :-) sub find_editor; # returns an external editor for use sub output; # used by &list and &display sub C; # print colourized sub num_bereich; # returns array from "1-4" (1,2,3,4) sub getdate; # return pretty formatted day sub new; # crate new note sub edit; # edit a note sub del; # delete a note sub display; # display one or more notes sub list; # note-listing sub help; # interactive help screen sub import; # import from notedb-dump sub display_tree; # show nice tree-view sub tree; # build the tree sub print_tree; # print the tree, contributed by Jens Heunemann . THX! sub ticket; # return a random string which is used as ticket number for new note entries # # globals # my ( # # commandline options # $opt_, $opt_i, $opt_r, $opt_e, $opt_d, $opt_enc, $opt_s, $opt_t, $opt_T, $opt_l, $opt_L, $opt_c, $opt_D, $opt_I, $opt_o, $opt_h, $opt_n, $opt_v, $opt_j, $opt_new, # # set from commandline (or interactive) # $number, $searchstring, $dump_file, $ImportType, $StdinMode, $Raw, $TOPIC, # # configuration options %conf, %driver, # # processed colors # $BORDERC, $_BORDERC, $NOTEC, $NUMC, $_NUMC, $_NOTEC, $TIMEC, $_TIMEC, $TOPICC, $_TOPICC, # # config presets # $DEFAULTDBNAME, $USER, $PATH, $CONF, # # internals # $TYPE, $mode, $NoteKey, %Color, @LastTopic, $timelen, $maxlen, $VERSION, $CurTopic, $CurDepth, $WantTopic, $db, $sizeof, %TP, $TreeType, $ListType, $SetTitle, $clearstring, @ArgTopics, $key, $typedef, @NumBlock, $has_nothing, @completion_topics, @completion_notes, @randomlist, $hardparams ); # # DEFAULTS, allows one to use note without a config # don't change them, instead use the config file! # %conf = ( 'numbercolor' => 'blue', 'bordercolor' => 'black', 'timecolor' => 'black', 'topiccolor' => 'black', 'notecolor' => 'green', 'alwaysinteractive' => 1, 'keeptimestamp' => 0, 'readonly' => 0, 'shortcd' => 1, 'autoclear' => 0, 'maxlen' => 'auto', 'defaultlong' => 0, 'dbdriver' => 'binary', # will be depcrecated in 1.5.0 and replaced by dumper. 'timeformat' => 'DD.MM.YYYY hh:mm:ss', 'usecolors' => 0, 'addticket' => 0, 'formattext' => 0, 'alwayseditor' => 1, 'useencryption' => 0, 'tempdirectory' => File::Spec->tmpdir(), 'topicseparator' => '/', 'printlines' => 0, 'cache' => 0, 'preferrededitor' => '', 'motd' => '', 'usejson' => 0, # will be the default in the future ); # these are not customizable at runtime! $hardparams = "(readonly|maxlen|dbdriver|useencryption|cryptmethod)"; $CONF = File::Spec->catfile($ENV{HOME}, ".noterc"); $USER = getlogin || getpwuid($<); chomp $USER; $TOPIC = 1; $VERSION = "1.4.2"; $CurDepth = 1; # the current depth inside the topic "directory" structure... $maxlen = "auto"; $timelen = 22; @randomlist = ('a'..'z', 0..9, 'A'..'Z'); # colors available # \033[1m%30s\033[0m %Color = ( 'black' => '0;30', 'red' => '0;31', 'green' => '0;32', 'yellow' => '0;33', 'blue' => '0;34', 'magenta' => '0;35', 'cyan' => '0;36', 'white' => '0;37', 'B' => '1;30', 'BLACK' => '1;30', 'RED' => '1;31', 'GREEN' => '1;32', 'YELLOW' => '1;33', 'BLUE' => '1;34', 'MAGENTA' => '1;35', 'CYAN' => '1;36', 'WHITE' => '1;37', 'black_' => '4;30', 'red_' => '4;31', 'green_' => '4;32', 'yellow_' => '4;33', 'blue_' => '4;34', 'magenta_' => '4;35', 'cyan_' => '4;36', 'white_' => '4;37', 'blackI' => '7;30', 'redI' => '7;31', 'greenI' => '7;32', 'yellowI' => '7;33', 'blueI' => '7;34', 'magentaI' => '7;35', 'cyanI' => '7;36', 'whiteI' => '7;37', 'white_black' => '40;37;01', 'bold' => ';01', 'hide' => '44;34' ); # # process command line args # if ($ARGV[0] eq "") { $mode = "new"; } elsif ($#ARGV == 0 && $ARGV[0] eq "-") { $mode = "new"; $StdinMode = 1; # read from STDIN until EOF shift; undef $has_nothing; } else { Getopt::Long::Configure( qw(no_ignore_case)); GetOptions ( "interactive|i!" => \$opt_i, # no arg "config|c=s" => \$opt_c, # string, required "raw|r!" => \$opt_r, # no arg "new|n:s" => \$opt_new, # no arg or optional string "edit|e=i" => \$opt_e, # integer, required "delete|d=s" => \$opt_d, # integer, required "search|s=s" => \$opt_s, # string, required "tree|topic|t!" => \$opt_t, # no arg "longtopic|T!" => \$opt_T, # no arg "list|l:s" => \$opt_l, # string, optional "longlist|L:s" => \$opt_L, # string, optional "dump|Dump|D:s" => \$opt_D, # string, optional "json|j" => \$opt_j, # bool, optional "import|Import|I:s" => \$opt_I, # string, optional "overwrite|o!" => \$opt_o, # no arg "help|h|?!" => \$opt_h, # no arg "version|v!" => \$opt_v, # no arg "encrypt=s" => \$opt_enc, # string, required ); # after that @ARGV contains eventually a note-number or a single dash $opt_n = shift; # # determine mode # if ($opt_i) { $mode = "interactive"; } elsif (defined $opt_new) { $mode = "new"; if ($opt_new eq "-") { $StdinMode = 1; # read from STDIN } } elsif (defined $opt_l || defined $opt_L) { $mode = "list"; if (defined $opt_l) { @ArgTopics = split /$conf{topicseparator}/, $opt_l; } else { $ListType = "LONG"; @ArgTopics = split /$conf{topicseparator}/, $opt_L; } $CurDepth += $#ArgTopics + 1 if($opt_l || $opt_L); $CurTopic = $ArgTopics[$#ArgTopics]; # use the last element everytime... } elsif ($opt_t || $opt_T) { $mode = "tree"; $mode = "display_tree"; $TreeType = "LONG" if($opt_T); } elsif (defined $opt_s) { $mode = "search"; $searchstring = $opt_s; } elsif ($opt_e) { $mode = "edit"; $number = $opt_e; } elsif ($opt_d) { $mode = "del"; $number = $opt_d; } elsif ($opt_enc) { $mode = "encrypt_passwd"; $clearstring = $opt_enc; } elsif (defined $opt_D) { $mode = "dump"; if (!$opt_) { if ($opt_D ne "") { $dump_file = $opt_D; } else { $dump_file = "note.dump.$$"; print "no dumpfile specified, using $dump_file.\n"; } } else { $dump_file = "-"; # use STDIN } if (defined $opt_j) { $conf{usejson} = 1; # force JSON } } elsif (defined $opt_I) { $mode = "import"; if (!$opt_) { if ($opt_I ne "") { $dump_file = $opt_I; } else { print "Import-error! No dump_file specified!\n"; exit(1); } } else { $dump_file = "-"; } } elsif ($opt_v) { print "This is note $VERSION by Thomas von Dein .\n"; exit(0); } elsif ($opt_h) { &usage; } else { if ($opt_c && $mode eq "" && !$opt_n) { $mode = "new"; } elsif ($opt_c && $mode eq "") { $mode = ""; # huh?! } else { $has_nothing = 1; } } ### determine generic options if ($opt_n =~ /^\d[\d\-?\,]*$/) { # first arg is a digit! if ($mode eq "") { $number = $opt_n; $mode = "display"; undef $has_nothing; } else { print "mode <$mode> does not take a numerical argument!\n"; exit(1); } } elsif ($opt_n eq "-") { $StdinMode = 1; # read from STDIN $mode = "new"; } elsif ($opt_n ne "") { print "Unknown option: $opt_n\n"; &usage; } if ($opt_r) { $Raw = 1; } if ($opt_o) { $ImportType = "overwrite"; if (!$opt_I) { print "--overwrite is only suitable for use with --import!\n"; exit(1); } } ##### } if ($has_nothing && $mode eq "") { &usage; } # read the configfile. $CONF = $opt_c if($opt_c); # if given by commandline, use this. if (-e $CONF) { &getconfig($CONF); } elsif ($opt_c) { # only wrong, if specified by commandline! else use default values! print STDERR "Could not open \"$CONF\": file does not exist or permission denied!\n"; exit(1); } # directly jump to encrypt, 'cause this sub does # not require a database connection if ($mode eq "encrypt_passwd") { &encrypt_passwd; exit; } # Always interactive? with the exception if stdin was requested if ($conf{alwaysinteractive} && $mode ne "dump" && $mode ne "import" && !$StdinMode && !defined $opt_new) { $mode = "interactive"; } # OK ... Long-Listing shall be default ... You wanted it!!! if ($conf{defaultlong}) { # takes only precedence in commandline mode $ListType="LONG"; } # calculate some constants... $BORDERC = "<$conf{bordercolor}>"; $_BORDERC = ""; $NUMC = "<$conf{numbercolor}>"; $_NUMC = ""; $NOTEC = "<$conf{notecolor}>"; $_NOTEC = ""; $TIMEC = "<$conf{timecolor}>"; $_TIMEC = ""; $TOPICC = "<$conf{topiccolor}>"; $_TOPICC = ""; $NoteKey = $conf{topicseparator} . "notes" . $conf{topicseparator}; # default permissions on new files (tmp) umask 077; # load the parent module &load_driver(1); # check wether the user wants to use encryption: if ($conf{useencryption} && $NOTEDB::crypt_supported == 1) { if ($conf{cryptmethod} eq "") { $conf{cryptmethod} = "Crypt::IDEA"; } if (!exists $ENV{'NOTE_PASSWD'}) { print "note password: "; eval { local($|) = 1; local(*TTY); open(TTY,"/dev/tty") or die "No /dev/tty!"; system ("stty -echo ); print STDERR "\r\n"; system ("stty echo ; } } else { $key = $ENV{'NOTE_PASSWD'}; } chomp $key; if ($conf{dbdriver} eq "mysql") { eval { require Crypt::CBC; my $cipher = new Crypt::CBC($key, $conf{cryptmethod}); # decrypt the dbpasswd, if it's encrypted! $driver{mysql}->{dbpasswd} = $cipher->decrypt(unpack("u", $driver{mysql}->{dbpasswd})) if($driver{mysql}->{encrypt_passwd}); &load_driver(); }; die "Could not connect to db: $@!\n" if($@); } else { &load_driver(); } $db->use_crypt($key,$conf{cryptmethod}); undef $key; # verify correctness of passwd my ($cnote, $cdate) = $db->get_single(1); if ($cdate ne "") { if ($cdate !~ /^\d+\.\d+?/) { print "access denied.\n"; # decrypted $date is not a number! exit(1); } } #else empty database! } elsif ($conf{useencryption} && $NOTEDB::crypt_supported == 0) { print STDERR "WARNING: You enabled database encryption but neither Crypt::CBC\n"; print STDERR "WARNING: or Crypt::$conf{cryptmethod} are installed! Please turn\n"; print STDERR "WARNING: off encryption or install the desired modules! Thanks!\n"; exit 1; } else { # as of 1.3.5 we do not fall back to cleartext anymore # I consider this as unsecure, if you don't, fix your installation! &load_driver(); $db->no_crypt; # does: NOTEDB::crypt_supported = 0; my %all = $db->get_all(); if(scalar keys %all > 0) { my $id = (keys %all)[0]; if($all{$id}->{date} !~ /^\d+\.\d+?/) { print "Warning! Encryption is not enabled, but notedb seems to be encrypted or in a non-text binary format. Please check your db file and your config!\n"; exit(1); } } } # do we use the db cache? if ($conf{cache}) { $db->use_cache(); } # add the backend version to the note version: $VERSION .= ", " . $conf{dbdriver} . " " . $db->version(); # main loop: ############### &$mode; exit(0); ################## EOP ################ ############ encrypt a given password ############## sub encrypt_passwd { my($key, $crypt_string); print "password: "; eval { local($|) = 1; local(*TTY); open(TTY,"/dev/tty") or die "No /dev/tty!"; system ("stty -echo ); print STDERR "\r\n"; system ("stty echo ; } chomp $key; eval { require Crypt::CBC; my $cipher = new Crypt::CBC($key, $conf{cryptmethod}); $crypt_string = pack("u", $cipher->encrypt($clearstring)); }; if ($@) { print "Something went wrong: $@\n"; exit 1; } else { print "Encrypted password:\n$crypt_string\n"; } } ############################### MOTD ################################## sub motd { my($N,$match,$note,$date,$num); # display a configured motd note, if any ($note, $date) = $db->get_single($conf{motd}); if ($note) { print "\n\n$note\n\n"; } } ############################### DISPLAY ################################## sub display { my($N,$match,$note,$date,$num); # display a certain note print "\n"; &num_bereich; # get @NumBlock from $numer my $count = scalar @NumBlock; foreach $N (@NumBlock) { ($note, $date) = $db->get_single($N); if ($note) { if ($Raw) { print "$N\n$date\n$note\n\n"; } else { output($N, $note, $date, "SINGLE", $count); print "\n"; } $match = 1; } $count--; } if (!$match) { print "no note with that number found!\n"; } } ############################### SEARCH ################################## sub search { my($n,$match,$note,$date,$num,%res); if ($searchstring eq "") { print "No searchstring specified!\n"; } else { print "searching the database $conf{dbname} for \"$searchstring\"...\n\n"; %res = $db->get_search($searchstring); my $nummatches = scalar keys %res; &determine_width; foreach $num (sort { $a <=> $b } keys %res) { if ($nummatches == 1) { output($num, $res{$num}->{'note'}, $res{$num}->{'date'}, "SINGLE"); } else { output($num, $res{$num}->{'note'}, $res{$num}->{'date'}, "search"); } $match = 1; } if (!$match) { print "no matching note found!\n"; } print "\n"; } } ############################### LIST ################################## sub list { my(@topic,@RealTopic, $i,$t,$n,$num,@CurItem,$top,$in,%res); if ($mode ne "interactive" && !$Raw) { print "\nList of all existing notes:\n\n"; } else { print "\n"; } # list all available notes (number and firstline) %res = $db->get_all(); if ($TOPIC) { undef %TP; } foreach $num (sort { $a <=> $b } keys %res) { $n = $res{$num}->{'note'}; $t = $res{$num}->{'date'}; if ($TOPIC) { # this allows us to have multiple topics (subtopics!) my ($firstline,$dummy) = split /\n/, $n, 2; if ($firstline =~ /^($conf{topicseparator})/) { @topic = split(/$conf{topicseparator}/,$firstline); } else { @topic = (); } # looks like: "\topic\" # collect a list of topics under the current topic if ($topic[$CurDepth-1] eq $CurTopic && $topic[$CurDepth] ne "") { if (exists $TP{$topic[$CurDepth]}) { $TP{$topic[$CurDepth]}++; } else { # only if the next item *is* a topic! $TP{$topic[$CurDepth]} = 1 if(($CurDepth) <= $#topic); } } elsif ($topic[$CurDepth-1] eq $CurTopic || ($topic[$CurDepth] eq "" && $CurDepth ==1)) { # cut the topic off the note-text if ($n =~ /^($conf{topicseparator})/) { $CurItem[$i]->{'note'} = $dummy; } else { $CurItem[$i]->{'note'} = $n; } # save for later output() call $CurItem[$i]->{'num'} = $num; $CurItem[$i]->{'time'} = $t; $i++; # use this note for building the $PATH! if ($RealTopic[0] eq "") { @RealTopic = @topic; } } } else { output($num, $n, $t); } } if ($TOPIC) { if ($CurTopic ne "") { if ($i) { # only if there were notes under current topic undef $PATH; foreach (@RealTopic) { $PATH .= $_ . $conf{topicseparator}; last if($_ eq $CurTopic); } } else { # it is an empty topic, no notes here $PATH = join $conf{topicseparator}, @LastTopic; $PATH .= $conf{topicseparator} . $CurTopic . $conf{topicseparator}; $PATH =~ s/^\Q$conf{topicseparator}$conf{topicseparator}\E/$conf{topicseparator}/; } } else { $PATH = $conf{topicseparator}; } @completion_topics = (); @completion_notes = (); # we are at top level, print a list of topics... foreach $top (sort(keys %TP)) { push @completion_topics, $top; output("-", " => ". $top . "$conf{topicseparator} ($TP{$top} notes)", " Sub Topic "); } #print Dumper(@CurItem); for ($in=0;$in<$i;$in++) { push @completion_notes, $CurItem[$in]->{'num'}; output( $CurItem[$in]->{'num'}, $CurItem[$in]->{'note'}, $CurItem[$in]->{'time'} ); } } print "\n"; } ############################### NEW ################################## sub new { my($TEMP,$editor, $date, $note, $WARN, $c, $line, $num, @topic); if ($conf{readonly}) { print "readonly\n"; return; } $date = &getdate; $note = ""; $line = ""; return if $db->lock(); if ($StdinMode) { # create a new note from STDIN print STDERR "Reading from STDIN ...\n"; while () { $note .= $_; } } elsif ($conf{alwayseditor} && &is_interactive()) { # read a new note interactively or by using the editor $TEMP = &gettemp; # security! unlink $TEMP; # let the user edit it... $editor = &find_editor; if ($editor) { # create the temp file open NEW, "> $TEMP" or die "Could not write $TEMP: $!\n"; close NEW; system "chattr", "+s", $TEMP; # ignore errors, since only on ext2 supported! system $editor, $TEMP; } else { print "Could not find an editor to use!\n"; $db->unlock(); exit(0); } # read it in ($note) $note = ""; open E, "<$TEMP" or $WARN = 1; if ($WARN) { print "...edit process interupted! No note has been saved.\n"; undef $WARN; $db->unlock(); return; } $c = 0; while () { $note = $note . $_; } chomp $note; close E; # privacy! unlink $TEMP; } else { print "enter the text of the note, end with a single .\n"; do { $line = ; $note = $note . $line; } until $line eq ".\n"; # remove the . ! chop $note; chop $note; } # look if the note was empty, so don't store it! if ($note =~ /^\s*$/) { print "...your note was empty and will not be saved.\n"; $db->unlock(); return; } # since we have not a number, look for the next one available: $number = $db->get_nextnum(); if ($TOPIC && $CurTopic ne "") { @topic = split(/$conf{topicseparator}/,$note); if ($topic[1] eq "") { $note = $PATH . "\n$note"; } } $note = &add_ticket($note); $db->set_new($number,$note,$date); # everything ok until here! print "note stored. it has been assigned the number $number.\n\n"; $db->unlock(); } sub add_ticket { my $orignote = shift; if ($conf{addticket}) { my ($topic, $title, $rest) = split /\n/, $orignote, 3; my $note = ""; if ($topic =~ /^\//) { # topic path, keep it $note .= "$topic\n"; } else { # no topic $rest = "$title\n$rest"; $title = $topic; } if ($title !~ /^\[[a-z0-9A-Z]+\]/) { # no ticket number, so create one my $ticket = &ticket(); $title = "[" . ticket() . "] " . $title; } $note .= "$title\n$rest"; return $note; } else { return $orignote; } } ############################### DELETE ################################## sub del { my($i,@count, $setnum, $pos, $ERR); if ($conf{readonly}) { print "readonly\n"; return; } # delete a note &num_bereich; # get @NumBlock from $number return if $db->lock(); foreach $_ (@NumBlock) { $ERR = $db->set_del($_); if ($ERR) { print "no note with number $_ found!\n"; } else { print "note number $_ has been deleted.\n"; } } # recount the notenumbers: $db->set_recountnums(); $db->unlock(); @NumBlock = (); } ############################### EDIT ################################## sub edit { my($keeptime, $date, $editor, $TEMP, $note, $t, $num, $match, $backup); if ($conf{readonly}) { print "readonly\n"; return; } # edit a note $date = &getdate; return if $db->lock(); ($note, $keeptime) = $db->get_single($number); if ($keeptime eq "") { print "no note with that number found ($number)!\n\n"; if($mode ne "interactive") { $db->unlock(); exit(0); } else { $db->unlock(); return; } } $TEMP = &gettemp; open NOTE,">$TEMP" or die "Could not open $TEMP\n"; select NOTE; system "chattr", "+s", $TEMP; # ignore errors, like in new() print $note; close NOTE; select STDOUT; $editor = &find_editor; $backup = $note; if ($editor) { system ($editor, $TEMP) and die "Could not execute $editor: $!\n"; } else { print "Could not find an editor to use!\n"; exit(0); } $note = ""; open NOTE,"<$TEMP" or die "Could not open $TEMP\n"; while () { $note = $note . $_; } chomp $note; close NOTE; unlink $TEMP || die $!; if ($note ne $backup) { if ($conf{keeptimestamp}) { $t = $keeptime; } else { $t = $date; } # we got it, now save to db $db->set_edit($number, $note, $t); print "note number $number has been changed.\n"; } else { print "note number $number has not changed, no save done.\n"; } $db->unlock(); } sub dump { my(%res, $num, $DUMP); # $dump_file if ($dump_file eq "-") { $DUMP = *STDOUT; } else { open (DUMPFILE, ">$dump_file") or die "could not open $dump_file\n"; $DUMP = *DUMPFILE; } select $DUMP; %res = $db->get_all(); # FIXME: prepare hashing in NOTEDB class foreach $num (sort { $a <=> $b } keys %res) { print STDOUT "dumping note number $num to $dump_file\n" if($dump_file ne "-"); my($title, $path, $body); if ($res{$num}->{note} =~ /^\//) { ($path, $title, $body) = split /\n/, $res{$num}->{note}, 3; } else { ($title, $body) = split /\n/, $res{$num}->{note}, 2; $path = ''; } my $date = $res{$num}->{date}; $res{$num} = { body => $body, title => $title, path => $path, date => $date}; } if($conf{usejson}) { my $json = JSON::PP->new->utf8->pretty; print $json->encode(\%res); } else { warn "Deprecation notice: YAML export format will not be supported in the future! Enable JSON using the UseJSON config parameter or the -j commandline parameter!"; print Dump(\%res); } close(DUMPFILE); select STDOUT; } sub import { my($num, $start, $complete, $dummi, $note, $date, $time, $number, $stdin, $DUMP, %data); # open $dump_file and import it into the notedb $stdin = 1 if($dump_file eq "-"); if ($stdin) { $DUMP = *STDIN; } else { open (DUMPFILE, "<$dump_file") or die "could not open $dump_file\n"; $DUMP = *DUMPFILE; } my $serialized = join '', <$DUMP>; my $res; if($serialized =~ /^\{/) { $res = decode_json($serialized); } else { $res = Load($serialized); } foreach my $number (keys %{$res}) { my $note; if ($res->{$number}->{path}) { $note = "$res->{$number}->{path}\n$res->{$number}->{title}\n$res->{$number}->{body}"; } else { $note = "$res->{$number}->{title}\n$res->{$number}->{body}"; } $data{$number} = { date => $res->{$number}->{date}, note => &add_ticket($note) }; print "fetched note number $number from $dump_file from $res->{$number}->{date}.\n" if(!$stdin); $number++; } $db->set_del_all() if($ImportType ne ""); $db->import_data(\%data); } sub determine_width { # determine terminal wide, if possible if ($maxlen eq "auto") { eval { my $wide = `stty -a`; if ($wide =~ /columns (\d+?);/) { $maxlen = $1 - 32; # (32 = timestamp + borders) } elsif ($wide =~ /; (\d+?) columns;/) { # bsd $maxlen = $1 - 32; # (32 = timestamp + borders) } else { # stty didn't work $maxlen = 80 - 32; } }; } } sub clear { # first, try to determine the terminal height return if(!$conf{autoclear}); my $hoch; eval { my $height = `stty -a`; if ($height =~ /rows (\d+?);/) { $hoch = $1; } elsif ($height =~ /; (\d+?) rows;/) { # bsd $hoch = $1; } }; if (!$hoch) { # stty didn't work $hoch = 25; } print "\n" x $hoch; } sub interactive { my($B, $BB, $menu, $char, $Channel); $Channel = $|; local $| = 1; # create menu: $B = ""; $BB = ""; $menu = "[" . $B . "L" . $BB . "-List "; if ($TOPIC) { $menu .= $B . "T" . $BB . "-Topics "; } $menu .= $B . "N" . $BB . "-New " . $B . "D" . $BB . "-Delete " . $B . "S" . $BB . "-Search " . $B . "E" . $BB . "-Edit " . $B . "?" . $BB . "-Help " . $B . "Q" . $BB . "-Quit] "; # $CurTopic will be empty if $TOPIC is off! # per default let's list all the stuff: # Initially do a list command! &determine_width; $ListType = ($conf{defaultlong}) ? "LONG" : ""; # show initial note entry if ($conf{motd}) { &motd; } # show initial listing &list; my ($term, $prompt, $attribs); eval { require Term::ReadLine; }; if (!$@) { $term = new Term::ReadLine(''); $attribs = $term->Attribs; $attribs->{completion_function} = \&complete; } for (;;) { $ListType = ($conf{defaultlong}) ? "LONG" : ""; undef $SetTitle; if ($CurDepth > 2) { print C $menu . $TOPICC . "../" . $CurTopic . $_TOPICC; } else { print C $menu . $TOPICC . $CurTopic . $_TOPICC; } if ($conf{readonly}) { print " [readonly] "; } print ">"; # endless until user press "Q" or "q"! if ($term) { if (defined ($char = $term->readline(" "))) { $term->addhistory($char) if $char =~ /\S/; $char =~ s/\s*$//; # remove trailing whitespace (could come from auto-completion) } else { # shutdown $| = $Channel; print "\n\ngood bye!\n"; exit(0); } } else { $char = ; chomp $char; } &determine_width; &clear; if ($char =~ /^\d+\s*[\di*?,*?\-*?]*$/) { $ListType = ""; #overrun # display notes $number = $char; &display; } elsif ($char =~ /^n$/i) { # create a new one &new; } elsif ($char =~ /^$/) { &list; } elsif ($char =~ /^l$/) { $ListType = ""; &list; } elsif ($char =~ /^L$/) { $ListType = "LONG"; &list; undef $SetTitle; } elsif ($char =~ /^h$/i || $char =~ /^\?/) { # zu dumm der Mensch ;-) &help; } elsif ($char =~ /^d\s+([\d*?,*?\-*?]*)$/i) { # delete one! $number = $1; &del; } elsif ($char =~ /^d$/i) { # we have to ask her: print "enter number(s) of note(s) you want to delete: "; $char = ; chomp $char; $number = $char; &del; } elsif ($char =~ /^e\s+(\d+\-*\,*\d*)/i) { # edit one! $number = $1; &edit; } elsif ($char =~ /^e$/i) { # we have to ask her: print "enter number of the note you want to edit: "; $char = ; chomp $char; $number = $char; &edit; } elsif ($char =~ /^s\s+/i) { # she want's to search $searchstring = $'; chomp $searchstring; &search; } elsif ($char =~ /^s$/i) { # we have to ask her: print "enter the string you want to search for: "; $char = ; chomp $char; $char =~ s/^\n//; $searchstring = $char; &search; } elsif ($char =~ /^q$/i) { # schade!!! $| = $Channel; print "\n\ngood bye!\n"; exit(0); } elsif ($char =~ /^t$/) { $TreeType = ""; &display_tree; } elsif ($char =~ /^T$/) { $TreeType = "LONG"; &display_tree; $TreeType = ""; } elsif ($char =~ /^c\s*$/) { print "Missing parameter (parameter=value), available ones:\n"; foreach my $var (sort keys %conf) { if ($var !~ /^$hardparams/ && $var !~ /::/) { printf "%20s = %s\n", $var, $conf{$var}; } } } elsif ($char =~ /^c\s*(.+?)\s*=\s*(.+?)/) { # configure my $param = $1; my $value = $2; if ($param !~ /^$hardparams/ && $param !~ /::/ && exists $conf{$param}) { print "Changing $param from $conf{$param} to $value\n"; $conf{$param} = $value; } else { print "Unknown config parameter $param!\n"; } } elsif ($char =~ /^\.\.$/ || $char =~ /^cd\s*\.\.$/) { $CurDepth-- if ($CurDepth > 1); $CurTopic = $LastTopic[$CurDepth]; pop @LastTopic; # remove last element &list; } elsif ($char =~ /^l\s+(\w+)$/) { # list $WantTopic = $1; if (exists $TP{$WantTopic}) { my %SaveTP = %TP; $LastTopic[$CurDepth] = $CurTopic; $CurTopic = $1; $CurDepth++; &list; $CurTopic = $LastTopic[$CurDepth]; $CurDepth--; %TP = %SaveTP; } else { print "\nunknown command!\n"; } } else { # unknown my $unchar = $char; $unchar =~ s/^cd //; # you may use cd now! if ($unchar =~ /^\d+?$/ && $conf{short_cd}) { # just a number! my @topic; my ($cnote, $cdate) = $db->get_single($unchar); my ($firstline,$dummy) = split /\n/, $cnote, 2; if ($firstline =~ /^($conf{topicseparator})/) { @topic = split(/$conf{topicseparator}/,$firstline); } else { @topic = (); } if (@topic) { # only jump, if, and only if there were at least one topic! $CurDepth = $#topic + 1; $CurTopic = pop @topic; @LastTopic = (""); push @LastTopic, @topic; } &list; } elsif ($unchar eq $conf{topicseparator}) { # cd / $CurDepth = 1; $CurTopic = ""; &list; } elsif (exists $TP{$char} || exists $TP{$unchar}) { $char = $unchar if(exists $TP{$unchar}); $LastTopic[$CurDepth] = $CurTopic; $CurTopic = $char; $CurDepth++; &list; } else { # try incomplete match my @matches; foreach my $topic (keys %TP) { if ($topic =~ /^$char/) { push @matches, $topic; } } my $nm = scalar @matches; if ($nm == 1) { # match on one incomplete topic, use this $LastTopic[$CurDepth] = $CurTopic; $CurTopic = $matches[0]; $CurDepth++; &list; } elsif ($nm > 1) { print "available topics: " . join( "," , @matches) . "\n"; } else { print "\nunknown command!\n"; } } undef $unchar; } } } sub usage { print qq~This is the program note $VERSION by T.v.Dein (c) 1999-2017. It comes with absolutely NO WARRANTY. It is distributed under the terms of the GNU General Public License. Use it at your own risk :-) Usage: note [ options ] [ number [,number...]] Options: -c, --config file Use another config file than the default \$HOME/.noterc. -n, --new Create a new note entry. -l, --list [topic] Lists all existing notes. If no topic were specified, it will display a list of all existing topics. See the section TOPICS for details about topics. -L, --longlist [topic] The same as -l but prints also the timestamp of the notes. -t, --topic Prints a list of all topics as a tree. -T, --longtopic Prints the topic-tree with the notes under each topic. -s, --search string Searches for trough the notes database. See the section SEARCHING for details about the search engine. -e, --edit number Edit the note with the number using your default editor or the one you specified in the config file. -d, --delete number Delete the note with the number . You can delete multiple notes with one command. "1-4" deletes the notes 1,2,3,4. And "1,5,7" deletes the specified ones. -D, --Dump [file | -] Dumps all notes to the textfile . If is a "-" it will be printed out to standard output (STDOUT). -I, --Import file | - Imports a previously dumped textfile into the note database. Data will be appended by default. You can also specify a dash note -I - instead of a , which causes note, silently to read in a dump from STDIN. -o, --overwrite Only suitable for use with --Import. Overwrites an existing notedb. Use with care. -r, --raw Raw mode, output will not be formatted. Works not in interactive mode, only on cmd-line for list and display. That means, no colors will be used and no lines or titles. -i, --interactive Start note in interactive mode. See the section INTERACTIVE MODE for details on this mode. --encrypt cleartext Encrypt the given clear text string. You would need that if you want to store the mysql password not in cleartext in the config(if you are using the mysql backend!). -h, --help Display this help screen. -v, --version Display the version number. - If you run note just with one dash: note -, then it will read in a new note from STDIN until EOF. This makes it possible to pipe text into a new note, i.e.: cat sometextfile | note - Read the note(1) manpage for more details. ~; exit 1; } sub find_editor { return $conf{preferrededitor} || $ENV{"VISUAL"} || $ENV{"EDITOR"} || "vi"; } #/ sub format { # make text bold/underlined/inverse using current $NOTEC my($note) = @_; if ($conf{formattext}) { # prepare colors to be used for replacement my $BN = uc($NOTEC); my $_BN = uc($_NOTEC); my $UN = $NOTEC; $UN =~ s/<(.*)>/<$1_>/; my $_UN = $UN; $_UN =~ s/<(.*)>/<\/$1>/; my $IN = $NOTEC; my $_IN = $_NOTEC; $IN =~ s/<(.*)>/<$1I>/; $_IN =~ s/<(.*)>/<$1I>/; if ($conf{formattext} eq "simple") { $note =~ s/\*([^\*]*)\*/$BN$1$_BN/g; $note =~ s/_([^_]*)_/$UN$1$_UN/g; $note =~ s/{([^}]*)}/$IN$1$_IN/g; $note =~ s#(?$1#g; } else { $note =~ s/\*\*([^\*]{2,})\*\*/$BN$1$_BN/g; $note =~ s/__([^_]{2,})__/$UN$1$_UN/g; $note =~ s/\{\{([^}]{2,})\}\}/$IN$1$_IN/g; $note =~ s#//([^/]{2,})//#$1#g; } $note =~ s/(<\/.*>)/$1$NOTEC/g; } $note; } sub output { my($SSS, $LINE, $num, $note, $time, $TYPE, $L, $LONGSPC, $R, $PathLen, $SP, $title, $CUTSPACE, $VersionLen, $len, $diff, $Space, $nlen, $txtlen, $count); ($num, $note, $time, $TYPE, $count) = @_; $txtlen = ($ListType eq "LONG") ? $maxlen : $timelen + $maxlen; $note = &format($note); $SSS = "-" x ($maxlen + 30); $nlen = length("$num"); $LINE = "$BORDERC $SSS $_BORDERC\n"; $LONGSPC = " " x (25 - $nlen); if ($conf{printlines}) { $L = $BORDERC . "[" . $_BORDERC; $R = $BORDERC . "]" . $_BORDERC; } $PathLen = length($PATH); # will be ZERO, if not in TOPIC mode! $VersionLen = length($VERSION) + 7; if ($TYPE ne "SINGLE") { if (!$SetTitle) { $SP = ""; # print only if it is the first line! $SP = " " x ($maxlen - 2 - $PathLen - $VersionLen); if (!$Raw) { # no title in raw-mode! print C $LINE if ($conf{printlines}); print C "$L $NUMC#$_NUMC "; if ($ListType eq "LONG") { print C " $TIMEC" . "creation date$_TIMEC "; } else { print $LONGSPC if ($conf{printlines}); } if ($TOPIC) { print C $TOPICC . "$PATH $_TOPICC$SP" . " note $VERSION $R\n"; } else { print C $NOTEC . "note$_NOTEC$SP" . " note $VERSION $R\n"; } print C $LINE if ($conf{printlines}); } $SetTitle = 1; } $title = ""; $CUTSPACE = " " x $txtlen; if ($TYPE eq "search") { $note =~ s/^\Q$conf{topicseparator}\E.+?\Q$conf{topicseparator}\E\n//; } $note =~ s/\n/$CUTSPACE/g; $len = length($note); if ($len < ($txtlen - 2 - $nlen)) { $diff = $txtlen - $len; if (!$Raw) { if ($num eq "-") { $Space = " " x $diff; $title = $BORDERC . $TOPICC . $note . " " . $_TOPICC . $Space . "$_BORDERC"; } else { $Space = " " x ($diff - ($nlen - 1)); $title = $BORDERC . $NOTEC . $note . " " . $_NOTEC . $Space . "$_BORDERC"; } } else { $title = $note; } } else { $title = substr($note,0,($txtlen - 2 - $nlen)); if (!$Raw) { $title = $BORDERC . $NOTEC . $title . " $_NOTEC$_BORDERC"; } } if ($Raw) { print "$num "; print "$time " if($ListType eq "LONG"); if ($title =~ /^ => (.*)$conf{topicseparator} (.*)$/) { $title = "$1$conf{topicseparator} $2"; # seems to be a topic! } print "$title\n"; } else { # $title should now look as: "A sample note " print C "$L $NUMC$num$_NUMC $R"; if ($ListType eq "LONG") { print C "$L$TIMEC" . $time . " $_TIMEC$R"; } print C "$L $NOTEC" . $title . "$_NOTEC $R\n"; print C $LINE if ($conf{printlines}); } } else { # we will not reach this in raw-mode, therefore no decision here! chomp $note; $Space = " " x (($maxlen + $timelen) - $nlen - 16); *CHANNEL = *STDOUT; my $usecol = $conf{usecolors}; if ($conf{less}) { my $less = "less"; if ($conf{less} ne 1) { # use given less command line $less = $conf{less}; } if (open LESS, "|$less") { *CHANNEL = *LESS; $conf{usecolors} = 0; } } print CHANNEL C $LINE if ($conf{printlines}); print CHANNEL C "$L $NUMC$num$_NUMC $R$L$TIMEC$time$_TIMEC $Space$R\n"; print CHANNEL C "\n"; print CHANNEL C $NOTEC . $note . $_NOTEC . "\n"; print CHANNEL C $LINE if ($count == 1 && $conf{printlines}); if ($conf{less}) { close LESS; } $conf{usecolors} = $usecol; } } sub C { my($default, $S, $Col, $NC, $T); $default = "\033[0m"; $S = $_[0]; foreach $Col (%Color) { if ($S =~ /<$Col>/g) { if ($conf{usecolors}) { $NC = "\033[" . $Color{$Col} . "m"; $S =~ s/<$Col>/$NC/g; $S =~ s/<\/$Col>/$default/g; } else { $S =~ s/<$Col>//g; $S =~ s/<\/$Col>//g; } } } return $S; } sub num_bereich { my($m,@LR,@Sorted_LR,$i); # $number is the one we want to delete! # But does it contain commas? @NumBlock = (); #reset $m = 0; if ($number =~ /\,/) { # accept -d 3,4,7 @NumBlock = split(/\,/,$number); } elsif ($number =~ /^\d+\-\d+$/) { # accept -d 3-9 @LR = split(/\-/,$number); @Sorted_LR = (); if ($LR[0] > $LR[1]) { @Sorted_LR = ($LR[1], $LR[0]); } elsif ($LR[0] == $LR[1]) { # 0 and 1 are the same @Sorted_LR = ($LR[0], $LR[1]); } else { @Sorted_LR = ($LR[0], $LR[1]); } for ($i=$Sorted_LR[0]; $i<=$Sorted_LR[1]; $i++) { # from 3-6 create @NumBlock (3,4,5,6) $NumBlock[$m] = $i; $m++; } } else { @NumBlock = ($number); } } sub getdate { my($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time); $year += 1900; $mon += 1; $mon =~ s/^(\d)$/0$1/; $hour =~ s/^(\d)$/0$1/; $min =~ s/^(\d)$/0$1/; $sec =~ s/^(\d)$/0$1/; $mday =~ s/^(\d)$/0$1/; if ($conf{timeformat}) { my $back = $conf{timeformat}; $back =~ s/YYYY/$year/; $back =~ s/YY/substr($year, 1, 2)/e; $back =~ s/MM/$mon/; $back =~ s/DD/$mday/; $back =~ s/mm/$min/; $back =~ s/hh/$hour/; $back =~ s/ss/$sec/; return $back; } return "$mday.$mon.$year $hour:$min:$sec"; } sub gettemp { my($random, @range); @range=('0'..'9','a'..'z','A'..'Z'); srand(time||$$); for (0..10) { $random .= $range[rand(int($#range)+1)]; } my $tempfile = File::Spec->catfile($conf{tempdirectory}, $USER . $random); if (-e $tempfile) { # avoid race conditions! unlink $tempfile; } return $tempfile; } sub help { my $B = ""; my $BB = ""; my($S, $L, $T, $Q, $H, $N, $D, $E, $C); $L = $B . "L" . $BB . $NOTEC; $T = $B . "T" . $BB . $NOTEC; $Q = $B . "Q" . $BB . $NOTEC; $H = $B . "?" . $BB . $NOTEC; $N = $B . "N" . $BB . $NOTEC; $D = $B . "D" . $BB . $NOTEC; $E = $B . "E" . $BB . $NOTEC; $S = $B . "S" . $BB . $NOTEC; $C = $B . "C" . $BB . $NOTEC; print C qq~$BORDERC ----------------------------------------------------------------------$_BORDERC $TOPICC HELP for interactive note $VERSION $_TOPICC $NOTEC The following commands are available: $L List notes. L=long, with timestamp and l=short without timestamp. You can also just hit for short list. If you specify a subtopic, then list will display it's contents, i.e.: "l mytopic" will dislpay notes under mytopic. $N Create a new note. $D Delete a note. You can either hit "d 1" or "d 1-4" or just hit "d". If you don't specify a number, you will be asked for. $S Search trough the notes database. Usage is similar to Delete, use a string instead of a number to search for. $E Edit a note. Usage is similar to Delete but you can only edit note a time. $C Change note config online. Use with care! $H This help screen. $Q Exit the program.~; if ($TOPIC) { print C qq~ $T print a list of all existing topics as a tree. T prints the tree with all notes under each topic.~; } print C qq~ All commands except the List and Topic commands are case insensitive. Read the note(1) manpage for more details.$BORDERC ----------------------------------------------------------------------$_BORDERC ~; } sub display_tree { # displays a tree of all topics my(%TREE, %res, $n, $t, $num, @nodes, $firstline, $text, $untext); %res = $db->get_all(); foreach $num (keys %res) { $n = $res{$num}->{'note'}; $t = $res{$num}->{'date'}; # this allows us to have multiple topics (subtopics!) my ($firstline,$text,$untext) = split /\n/, $n, 3; if ($firstline =~ /^($conf{topicseparator})/) { $firstline =~ s/($conf{topicseparator})*$//; #remove Topicseparator @nodes = split(/$conf{topicseparator}/,$firstline); } else { @nodes = (); #("$conf{topicseparator}"); $text = $firstline; } &determine_width; # ensure $maxlen values for &tree in non interactive modes &tree($num, $text, \%TREE, @nodes); } #return if ($num == 0); # now that we have build our tree (in %TREE) go on t display it: print C $BORDERC . "\n[" . $conf{topicseparator} . $BORDERC . "]\n"; &print_tree(\%{$TREE{''}},"") if(%TREE); print C $BORDERC . $_BORDERC . "\n"; } sub tree { my($num, $text, $LocalTree, $node, @nodes) = @_; if (@nodes) { if (! exists $LocalTree->{$node}->{$NoteKey}) { $LocalTree->{$node}->{$NoteKey} = []; } &tree($num, $text, $LocalTree->{$node}, @nodes); } else { if (length($text) > ($maxlen - 5)) { $text = substr($text, 0, ($maxlen -5)); } $text = $text . " (" . $NUMC . "#" . $num . $_NUMC . $NOTEC . ")" . $_NOTEC if($text ne ""); push @{$LocalTree->{$node}->{$NoteKey}}, $text; } } sub print_tree { # thanks to Jens for his hints and this sub! my $hashref=shift; my $prefix=shift; my @notes=@{$hashref->{$NoteKey}}; my @subnotes=sort grep { ! /^$NoteKey$/ } keys %$hashref; if ($TreeType eq "LONG") { for my $note (@notes) { if ($note ne "") { print C $BORDERC ; # . $prefix. "|\n"; print C "$prefix+---<" . $NOTEC . $note . $BORDERC . ">" . $_NOTEC . "\n"; } } } for my $index (0..$#subnotes) { print C $BORDERC . $prefix. "|\n"; print C "$prefix+---[" . $TOPICC . $subnotes[$index] . $BORDERC . "]\n"; &print_tree($hashref->{$subnotes[$index]},($index == $#subnotes?"$prefix ":"$prefix| ")); } } sub getconfig { my($configfile) = @_; my ($home, $value, $option); # checks are already done, so trust myself and just open it! open CONFIG, "<$configfile" || die $!; while () { chomp; next if(/^\s*$/ || /^\s*#/); my ($option,$value) = split /\s\s*=?\s*/, $_, 2; $value =~ s/\s*$//; $value =~ s/\s*#.*$//; if ($value =~ /^(~\/)(.*)$/) { $value = File::Spec->catfile($ENV{HOME}, $2); } if ($value =~ /^(yes|on|1)$/i) { $value = 1; } elsif ($value =~ /^(no|off|0)$/i) { $value = 0; } $option = lc($option); if ($option =~ /^(.+)::(.*)$/) { # driver option $driver{$1}->{$2} = $value; } else { # other option $conf{$option} = $value; } } close CONFIG; } sub complete { my ($text, $line, $start) = @_; if ($line =~ /^\s*$/) { # notes or topics allowed return @completion_topics, @completion_notes; } if ($line =~ /^cd/) { # only topics allowed return @completion_topics, ".."; } if ($line =~ /^l/i) { # only topics allowed return @completion_topics; } if ($line =~ /^[ed]/) { # only notes allowed return @completion_notes; } if ($line =~ /^[snt\?q]/i) { # nothing allowed return (); } } sub load_driver { my ($parent) = @_; if ($parent) { my $pkg = "NOTEDB"; eval "use $pkg;"; if ($@) { die "Could not load the NOTEDB module: $@\n"; } } else { # load the backend driver my $pkg = "NOTEDB::$conf{dbdriver}"; eval "use $pkg;"; if ($@) { die "$conf{dbdriver} backend unsupported: $@\n"; } else { $db = $pkg->new(%{$driver{$conf{dbdriver}}}); } } } sub ticket { return join "", (map { $randomlist[int(rand($#randomlist))] } (0 .. 10) ); } sub is_interactive { return -t STDIN && -t STDOUT; } __END__