#! /usr/bin/perl -w
#
# bmsync - convert bookmark files between various browsers
# Copyright (C) 1999  Oskar Liljeblad
#
# 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
#

=head1 NAME

bmsync - convert bookmark files between various browsers

=head1 SYNOPSIS

bmsync [OPTION]... [COLLECTION]...

=head1 DESCRIPTION

bmsync reads bookmarks from any number of so called bookmark collections,
checks for conflicts among the bookmarks, and write them back to the bookmark
collections.

A bookmark collection is a file or directory, containing a number of titles
and a URL for each title (B<bookmarks>).  bmsync currently supports
two types of bookmark collections - namely those generated by Netscape Navigator
(C<bookmarks.html>) and Microsoft Internet Explorer (C<Favorites/>).

Bookmark collections are specified as "[type:]path", where path is a
file or directory depending on the type. The type part is not necessary
unless bmsync could not identify the type of bookmark collection from
path. See B<ENGINES> below for a more elaborate description
of type abbreviation and engines.

Five general commands exist: B<--sync>, B<--copy>, and B<--merge> to convert
bookmarks in various ways; B<--output> to write bookmarks to standard
out; B<--identify> to identify the type of a bookmarks collection.

To read bookmarks and write to all sources - a way of synchronizing -
use the B<--sync> (B<-s>) option. (This is the default if basename is 'bmsync'.)

If you rather wish to copy bookmarks from one source, and simply
recreate or overwrite the destination, use the B<--copy> (B<-c>) option.
(This is the default if basename is 'bmcopy'.)

To you wish to read bookmarks from a number of sources, but write
to the last specified, use the B<-m> (B<--merge>) option.

Here is an overview:

                         first   second  third   last arg
  -s, --sync, bmsync       RW      RW      RW      RW
  -c, --copy, bmcopy       R       R       R       W
  -m, --merge, bmmerge     R       R       R       RW

=head1 OPTIONS

=over 4

=item B<-s>, B<--sync>

Synchronize bookmark collections.

=item B<-c>, B<--copy>

Copy bookmarks to a bookmark collection.

=item B<-m>, B<--merge>

Merge bookmarks from other bookmark collection.

=item B<-o>, B<--output>

Output bookmarks in a human-readable form to standard out.

=item B<-r>, B<--identify>

Output format of bookmark collections to standard out.

=item B<-t>, B<--test>

Never actually write or make any changes to files.

=item B<-n>, B<--intersection>

Write only intersection of bookmarks. (That is, bookmarks which are common to
all of the collections that bookmarks were read from).

=item B<-a>, B<--all>

Write all bookmarks; do not skip some unportable bookmarks.

=item B<-e>, B<--exact>

Do not rename some bookmarks or directories which they reside in.
(There is a special toolbar folder which this option affects.)

=item B<-i>, B<--interactive>

Prompt what to do at conflicts. (This is the default behavior,
but may change with new options in newer version.)

=item B<-v>, B<--verbose>

Explain what is being done.

=item B<-R>, B<--real>

Write to the same collection path: overwrite if necessary. The default
without this option is to append ".new" to the file or directory. (E.g.
bookmarks.html becomes bookmarks.html.new.) 

This option exists only because this software is beta. It will most
likely be removed in the next version.

=item B<--help>

Display help with option overview and exit.

=item B<--version>

Output version information and exit.

=back

=head1 ENGINES

An engine is an internal part of bmsync which takes care of
identifying, reading, and writing bookmark collections.
bmsync currently implements the following engines:

=over 4

=item B<nn> - Netscape Navigator

Netscape Navigator and Communicator keeps bookmarks in a
single html file which usually resides in .netscape/bookmarks.html
in the user's home directory.

Converted bookmarks (--exact affects):
  "Personal Toolbar Folder" -> toolbar

Ignored bookmarks (--all affects):
  none

=item B<msie> - Microsoft Internet Explorer

Microsoft Internet Explorer stores bookmarks in separate url files -
one URL per file. These url files usually reside in C:\Windows\Favorites.

Converted bookmarks (--exact affects):
  "Links" -> toolbar

Ignored bookmarks (--all affects):
  "Channels", "Software Updates"

=item B<kde> - KDE browser/kfm/Konqueror

The bookmark storage scheme is pretty much like MSIE's.

=back

=head1 CONFLICTS

Due to the implementation of bookmark collection in browsers, there
are some restrictions on bookmark titles or names. For example,
MSIE bookmarks are url files, usually stored on a FAT file system, and
thus inherits all limitations of the file system. 

To resolve these conflicts, bmsync allows you to bump (rename) or
remove bookmarks. If B<--interactive> (B<-i>) was specified, you
will be prompted when there is a conflict. (At the moment,
this is the default behavior no matter if B<--interactive> is
specified.)

Two bookmarks are considered to be the same if

=over 4

=item * The folder depth (number of subdirectories) is the same.

=item * The two bookmarks may not coexist (determined by the engines) -OR- the folders and bookmark title are exactly the same.

=item * The URL is the same. 

=back

=head1 DRAWBACKS

=over 4

=item Empty directories will not be transferred among collections.

This is due to the internal format of a bookmark.

=item Multiple bookmarks with exactly same name and URL will be merged into one.

This is also due to the internal format of bookmarks. There are currently no
plans of changing this behavior. (Much since disk-based engines don't support
this at all.)

=back

=head1 EXAMPLES

bmmerge "nn:/mnt/C/Program Files/Netscape/Navigator/users/you/bookmarks.html" /mnt/C/windows/Favorites ~/.netscape/bookmarks.html

=head1 AUTHOR

Oskar Liljeblad E<lt>osk@hem.passagen.seE<gt>

=cut

# required modules
use Getopt::Long;             # required for option parsing
use File::Spec;               # required for directory scanning by msie engine
                              # (otherwise not necessary)
use Term::ReadLine;           # required for interactive conflict solving

# application information variables
$appname = 'bmsync';
$appversion = '0.1.0';
$appauthor = 'Oskar Liljeblad <osk@hem.passagen.se>';

# available bookmark collection engines
@available_engines = qw(nn kde msie);

# application options
@appopts = (
	'Commands'       , undef                , undef ,
  'sync|s'         , \$arg_synchronize    , 'synchronize collections',
  'copy|c'         , \$arg_copy           , 'copy collections to last',
  'merge|m'        , \$arg_merge          , 'merge contents of collection to last',
  'output|o'       , \$arg_output         , 'output bookmarks',
  'identify|r'     , \$arg_identify  			, 'output collection formats',

	'Modifiers'       , undef                , undef ,
  'test|t'         , \$arg_test           , 'never actually write or output anything',
# 'union|u'        , \$arg_union 					, 'work with union of collections (default)',
	'intersection|n' , \$arg_intersection 	, 'work with intersection instead of union', 
	'all|a'          , \$arg_all            , 'do not skip some unportable bookmarks',
	'exact|e'        , \$arg_exact          , 'do not renames some bookmarks or directories',
  'interactive|i'  , \$arg_interactive    , 'prompt at conflicts',
  'verbose|v'      , \$arg_verbose        , 'explain what is being done',
  'real|R'         , \$arg_real           , 'write to the same file/directory',

	'General'        , undef                , undef ,
	'help'           , \$arg_help,					, 'display help and exit',
	'version'        , \$arg_version,				, 'output version and exit',
);

# ---

$MAX_NAME_LEN = 60;             # have this short enough so that it won't be too long
                                # as a filename after appending, say, .kdelnk to it

# initialize arguments (to default because of recursion!)
$arg_synchronize = $arg_copy = $arg_merge = $arg_output = $arg_identify = 0;
$arg_test = $arg_intersection = $arg_all = $arg_exact = $arg_interactive = 0;
$arg_verbose = $arg_real = 0;
$arg_help = $arg_version = 0;

# parse arguments
my %appopts = ();
for (my $c=0 ; $c <= $#appopts ; $c += 3) {
	$appopts{$appopts[$c]} = $appopts[$c+1] if defined $appopts[$c+1];
}
Getopt::Long::config('bundling');
Getopt::Long::GetOptions(%appopts) || exit 1;

# help argument?
if ($arg_help) {
	print "Usage: $0 [OPTION]... [FILE]...\n";
	print "Synchonizes bookmarks between various browsers.\n\n";

	my ($full,$abbrev,$t);
	for (my $c=0 ; $c <= $#appopts ; $c += 3) {
		if (!defined $appopts[$c+1]) {
			print "\n" if $c;
			print "$appopts[$c]:\n";
		} else {
			($full, $abbrev) = split(/\|/,$appopts[$c]);
			$t = (defined $abbrev ? "  -$abbrev, " : ' 'x6) . "--$full";
			printf "%-25s%s\n", $t, $appopts[$c+2];
		}
	}

	print "\nReport bugs to $appauthor.\n";
	exit 0;
}

# version argument?
if ($arg_version) {
	print "$appname $appversion\n";
	exit 0;
}

# command argument control
my $cnt = $arg_synchronize + $arg_copy + $arg_merge + $arg_output + $arg_identify;
if ($cnt == 0) {
	if ($0 =~ /(^|\/)bmsync$/) { $arg_synchronize = 1; }
	elsif ($0 =~ /(^|\/)bmcopy$/) { $arg_copy = 1; }
	elsif ($0 =~ /(^|\/)bmmerge$/) { $arg_merge = 1; }
	elsif ($0 =~ /(^|\/)bmprint$/) { $arg_output = 1; }
	else { die "$appname: no operation specified\n"; }
} elsif ($cnt > 1) {
	die "$appname: multiple operations specified\n";
}

# test argument check
if ($arg_test && !$arg_synchronize && !$arg_copy && !$arg_merge && !$arg_output) {
	die "$appname: --test option requires a command -s, -c, -m or -o\n";
}

# remaining arguments: check number of, put in @dirs
if (!scalar(@ARGV)) {
	die "$appname: missing argument\n";
}
if ($arg_synchronize && scalar(@ARGV) == 1) {
	die "$appname: missing argument (need at least two bookmark collections)\n";
}
my @dirs = @ARGV;

# find out which engines to use (also remove possible engine from @dirs)
my @engines = ();
my ($engine, $dir);
for (my $c = 0 ; $c <= $#dirs ; $c++) {
	($engine, $dir) = ($dirs[$c] =~ /^([a-z]+):(.*)$/);
	if (defined $engine) {
		# an existing engine?
		if (!&is_engine($engine)) { die "$appname: there is no engine of type $engine\n"; }
		$dirs[$c] = $dir;
		$engines[$c] = $engine;
		print STDERR "Assumed engine of $engines[$c]:$dirs[$c].\n" if $arg_verbose;
	} else {
		# call engine methods
		foreach $engine (@available_engines) {
			if (&engine($engine, 'try_recognize', $dirs[$c])) { $engines[$c] = $engine; last; }
		}
		# found no engine at all?
		if (!defined $engines[$c]) {
			die "$appname: type of $dirs[$c] could not be identified\n";
		}
		print STDERR "Identified engine of $engines[$c]:$dirs[$c].\n" if $arg_verbose;
	}
}

# initialize list of engines to use
@checkengines = ();
OUTER:
foreach my $s (@engines) {
	foreach my $t (@checkengines) { next OUTER if ($t eq $s); }
	@checkengines = (@checkengines, $s);
}

# recognize argument?
if ($arg_identify) {
	for (my $c = 0 ; $c <= $#dirs ; $c++) {
		if (scalar(@dirs) > 1) {
			print $engines[$c], ':', $dirs[$c], "\n";
		} else {
			print $engines[$c], "\n";
		}
	}
	exit 0;
}

# synchronize or output argument?
if ($arg_synchronize || $arg_copy || $arg_merge || $arg_output) {

	# read bookmarks from each engine, and merge into one big array
	@allbookmarks = ();
	for (my $c = 0 ; $c <= $#dirs ; $c++) {
		next if ($arg_copy && $c == $#dirs);

		print STDERR "Reading bookmarks from $engines[$c]:$dirs[$c].\n" if $arg_verbose;

		# read the bookmarks, and process each
		my @bmlist = &engine($engines[$c], 'read_urls', $dirs[$c], ",$c,");

		print STDERR "Merging ",scalar(@bmlist)," bookmarks from $engines[$c]:$dirs[$c].\n" if $arg_verbose;

		&merge_bookmarks(1,@bmlist);
	}

	# now clean up array, check for conflicts, etc (this is a lengthy process)
	&scan_all_vs_all();

	# no bookmarks? better now erase all from disk...
	if (!scalar @allbookmarks) {
		print STDERR "$appname: no bookmarks found\n";
		exit 0;
	}

	# if output argument, print em all out
	if ($arg_output) {
		foreach my $bm (@allbookmarks) {
			if (!$arg_test && (!$arg_intersection || &all_in_bookmark_source(@{$bm}))) {
				print &get_bookmark_fullname(@{$bm}), ' - ', &get_bookmark_url(@{$bm}), "\n";
			}
		}
		exit 0;
	}

	# --synchronize, --copy, --merge: only possible options left here.
	# fix up the allbookmarks array and tell engines to write
	my $src;
	for (my $c = 0 ; $c <= $#dirs ; $c++) {
		print STDERR "Rearranging bookmarks for $engines[$c]:$dirs[$c].\n" if $arg_verbose;

		foreach my $bm (@allbookmarks) {
			# if status is REMOVE, do nothing
			next if &get_bookmark_status(@{$bm}) eq 'r';

			# if working with intersection, remove if not owned by all
			if ($arg_intersection && !&all_in_bookmark_source(@{$bm})) {
				&set_bookmark_status($bm, 'r');
				next;
			}

			# if collection is in source field, set status to KEEP, otherwise to ADD
			if (&is_in_bookmark_source($c, @{$bm})) {
				&set_bookmark_status($bm, 'k');
			} else {
				&set_bookmark_status($bm, 'a');
			}
		}

		print STDERR "Writing bookmarks for $engines[$c]:$dirs[$c].\n" if $arg_verbose;

		# call engine's write_urls (if arg_copy, write to last only)
		&engine($engines[$c], 'write_urls', $dirs[$c], @allbookmarks)
		  if (!$arg_test && ($arg_synchronize || ($arg_copy || $arg_merge) && $c == $#dirs));
	}

	exit 0;
}

# fail safe exit
exit;

# =========================================================================
# Subroutines which could've been part of main.
#
# =========================================================================

# merge_bookmarks:
# Add new bookmarks to the @allbookmarks array. If a similar copy was
# found, merge the bookmark with that one. Returns 1 if any bookmarks
# were merged this way.
#
# "Similar copy" means either:
# - same url, exactly same filename and paths.
# - same url, same depth (number of folders), and the bookmarks may not
#   coexist with eachother.
#
# First argument is a boolean, if true indicating that bookmarks
# should be added if not merged.
#
sub merge_bookmarks {
	my $add_bookmarks = shift;
	my $merged = 0;
	my (@bm1,@bm2);

	#print DEBUG "<> --> merge_bookmarks\n";

	OUTER:
	foreach my $bm1 (@_) {
		@bm1 = @{$bm1};
		foreach my $bm2 (@allbookmarks) {
			@bm2 = @{$bm2};
			next if &get_bookmark_status(@bm2) eq 'r';

			# assume same if exactly same name and url (quite natural...)
			if (&get_bookmark_url(@bm1) eq &get_bookmark_url(@bm2)
			    && (&equal_arrays([&get_bookmark_names(@bm1)], [&get_bookmark_names(@bm2)])
			        || &get_bookmark_depth(@bm1) == &get_bookmark_depth(@bm2)
			           && !&may_coexist($bm1, $bm2)
						 )
				 ) {
				&add_bookmark_source($bm2, &get_bookmark_source(@bm1));
				$merged = 1;
				next OUTER;
			}
		}

		# add bookmarks
		push @allbookmarks, $bm1 if $add_bookmarks;
	}

	#print DEBUG "<> <-- merge_bookmarks\n";

	return $merged;
}

# scan_internal_conflicts:
# Checks conflicts between bookmarks in an array (@allbookmarks is never used).
# Return 1 if there are conflicts.
#
sub scan_list_vs_list {
	my ($bm1, $bm2);

	foreach my $bm1 (@_) {
		next if &get_bookmark_status(@{$bm1}) eq 'r';

		foreach my $bm2 (@_) {
			next if $bm1 == $bm2;
			next if &get_bookmark_status(@{$bm2}) eq 'r';
			next if &get_bookmark_depth(@{$bm1}) > &get_bookmark_depth(@{$bm2});

			# same name, different url => conflict
			return 1 if &check_bookmark_conflict($bm1, $bm2);
		}
	}

	return 0;
}

sub scan_list_vs_all {
	my ($bm1, $bm2, @conflicts);

	print STDERR "Scanning for additional conflicts of ",scalar(@_)," new bookmarks.\n" if $arg_verbose;

	foreach my $bm1 (@_) {
		next if &get_bookmark_status(@{$bm1}) eq 'r';
	
		@conflicts = ();
		foreach my $bm2 (@allbookmarks) {
			next if &get_bookmark_status(@{$bm2}) eq 'r';
			next if &get_bookmark_depth(@{$bm1}) >= &get_bookmark_depth(@{$bm2});
		
			# same name, different url => conflict
			push @conflicts, $bm2 if &check_bookmark_conflict($bm1, $bm2);
		}
		&handle_conflicts($bm1, @conflicts);
	}

	foreach my $bm1 (@allbookmarks) {
		next if &get_bookmark_status(@{$bm1}) eq 'r';

		@conflicts = ();
		foreach my $bm2 (@_) {
			next if &get_bookmark_status(@{$bm2}) eq 'r';
			next if &get_bookmark_depth(@{$bm1}) > &get_bookmark_depth(@{$bm2});

			# same name, different url => conflict
			push @conflicts, $bm2 if &check_bookmark_conflict($bm1, $bm2);
		}
		&handle_conflicts($bm1, @conflicts);
	}
}

# scan_bookmark_conflicts:
# Can be called in two ways: with or without bookmarks as arguments.
# If called with args, check those against the @allbookmarks array.
# Otherwise, check @allbookmarks array against itself. (The latter
# includes an optimization that halves compare-times.)
#
sub scan_all_vs_all {
	my ($bm1, $bm2, @conflicts);

	print STDERR "Scanning conflicts among ",scalar(@allbookmarks)," bookmarks.\n" if $arg_verbose;

	foreach my $bm1 (@allbookmarks) {
		next if &get_bookmark_status(@{$bm1}) eq 'r';

		@conflicts = ();
		foreach my $bm2 (@allbookmarks) {
			next if $bm1 == $bm2;
			next if &get_bookmark_status(@{$bm2}) eq 'r';
			next if &get_bookmark_depth(@{$bm1}) > &get_bookmark_depth(@{$bm2});

			# same name, different url => conflict
			push @conflicts, $bm2 if &check_bookmark_conflict($bm1, $bm2);
		}
		&handle_conflicts($bm1, @conflicts);
	}

	return 0;
}

sub handle_conflicts {
	# need at least two conflicting bookmarks
	return if (scalar @_ <= 1);

	# list conflicting bookmarks
	print "Conflicting bookmarks: \n";
	for (my $d = 0 ; $d <= $#_ ; $d++) {
		print '   ',$d+1,'. ', &get_bookmark_fullname(@{$_[$d]}), "\n", ' 'x(5+length $d), &get_bookmark_url(@{$_[$d]}), "\n";
	}

	my $really_first = 1;				# used for optimization
	my @resolved = ();					# contains info on what actions were performed on a command
	my @bumped = ();						# list of new bookmarks with bumped names
	$term = new Term::ReadLine $appname;
	while (1) {

		# all already resolved?
		if (!$really_first && !&scan_list_vs_list(@_, @bumped)) {
			print STDERR "All conflicts resolved.\n";
			last;
		}

		# read line, and check it
		my $line = $term->readline('> ');
		if (!defined $line) {
			print STDERR "Use q (or quit) to get out of this menu.\n";
			next;
		}

		# parse command
		my ($cmd, $param) = split(/\s/,$line);
		if (!defined $cmd) {
          print STDERR "Type h for help\n";
          next;
		}

		# command: help
		if ($cmd eq '?' || $cmd eq 'h' || $cmd eq 'help') {
			print STDERR "Commands:\n";
			print STDERR "  l, list            list conflicting bookmarks\n";
			print STDERR "  b, bump RANGE      bump the specified bookmarks\n";
			print STDERR "  r, rename RANGE    rename the specified bookmarks\n";
			print STDERR "  i, ignore RANGE    ignore the specified bookmarks\n";
			print STDERR "  q, quit            quit this menu (use when finished)\n";
			print STDERR "  a, abort           quit; don't write anything to disk\n";
			print STDERR "  h, help            this info\n";
			next;
		}

		# command: list
		if ($cmd eq 'l' || $cmd eq 'list') {
			print "Conflicting bookmarks: \n";
			for (my $d = 0 ; $d <= $#_ ; $d++) {
				print (defined $resolved[$d] ? $resolved[$d] : '  ');
		    print ' ', $d+1,'. ', &get_bookmark_fullname(@{$_[$d]}), "\n", ' 'x(5+length $d), &get_bookmark_url(@{$_[$d]}), "\n";
			}
			next;
		}

		# command: quit
		if ($cmd eq 'q' || $cmd eq 'quit') {
			if (!$really_first && !&scan_list_vs_list(@_, @bumped)) {
				last;
			} else {
				print STDERR "There are still unresolved conflicts.\n";
				next;
			}
		}

		# command: abort
		if ($cmd eq 'a' || $cmd eq 'abort') {
			print STDERR "Operation aborted.\n";
			exit;
		}

		if (!defined $param) {
          print STDERR "Type h for help\n";
          next;
		}

		# parse range (commands below require this)
		my @range = &parse_range(1, $#_+1, $param);
		if (!scalar @range) {
			print STDERR "You need to specify a valid range (comma separated list of bookmark indexes)\n";
			next;
		}

		# command: bump
		if ($cmd eq 'b' || $cmd eq 'bump') {
			foreach my $n (@range) {
				$n--;
				my @tbm = @{$_[$n]};

				&set_bookmark_status($_[$n], 'r');
				print STDERR 'Changed "', &get_bookmark_name(@tbm), '" to "';
				&set_bookmark_name(\@tbm, &get_bookmark_name(@tbm) . '(1)');
				print STDERR &get_bookmark_name(@tbm), "\".\n";
				&set_bookmark_source(\@tbm, '');

				$resolved[$n] = 'BM';
				push @bumped, [ @tbm ];
			}
		}

		$really_first = 0;
	}

	# if all bumped bookmarks were merged with others, we don't need to check for conflicts
	my @need_add = ();
	foreach my $bm (@bumped) {
		push @need_add, $bm if !&merge_bookmarks(0, $bm);
	}
	if (scalar @need_add) {
		&scan_list_vs_all(@need_add);
		push @allbookmarks, @need_add;
	}
}

sub parse_range {
	my ($start,$end,$range) = @_;

	return if ($range =~ /[^0-9,]/);

	my @list = split(/[,]/, $range);
	foreach my $item (@list) {
		return if ($item < $start || $item > $end);
	}

	return @list;
}

sub check_bookmark_conflict {
	my ($bm1,$bm2) = @_;
	my @bm1 = @{$bm1};
	my @bm2 = @{$bm2};

	return 1 if (!&may_coexist($bm1,$bm2));
	return 1 if (&equal_arrays([&get_bookmark_names(@bm1)], [&get_bookmark_names(@bm2)]));
	return 0;
}

# =========================================================================
# Bookmark management functions
#
# =========================================================================
# Anatomy of a bookmark:
#	  [0] source (index of which engine this url comes from) - read-only
#	  [1] status (add, remove, keep) - read-only
#	  [2] date-added - optional
#	  [3] date-last-visited - optional
#	  [4] date-last-modified - optional
#   [5] url
#   [6] description (optional)
#   [7] folder (level1, level2, ...)
#	      name
#
# =========================================================================

# return folders+name (slash separated)
sub get_bookmark_fullname { return join('/', @_[7..$#_]); }
# return url
sub get_bookmark_url { return $_[5]; }
# return source of bookmark
sub get_bookmark_source { return $_[0]; }
# get bookmark name
sub get_bookmark_name { return $_[$#_]; }
# get number of folders in bookmark
sub get_bookmark_depth { return ($#_)-7; }
# return source of bookmark
sub get_bookmark_status { return $_[1]; }
# return bookmark dates (in array)
sub get_bookmark_dates { return @_[2..4]; }
# set source of bookmark
sub set_bookmark_status { my ($bmref, $status) = @_; ${$bmref}[1] = $status; }
# set bookmark folder component
sub set_bookmark_name { my ($bmref, $name) = @_; $name = substr($name, 0, $MAX_NAME_LEN); ${$bmref}[$#{$bmref}] = $name; }
# get list of bookmark folders and name
sub get_bookmark_names { return @_[7..$#_]; }

# add a number to the bookmark source
sub add_bookmark_source {
	my ($bmref, $source) = @_;
	${$bmref}[0] .= $source . ',' if (index(${$bmref}[0], $source . ',') == -1);
}
# set source of bookmark
sub set_bookmark_source { my ($bmref, $source) = @_; ${$bmref}[0] = $source; }

# return true if engine is in bookmark source
sub is_in_bookmark_source {
	my ($en, $src) = @_;
	return (index($src,",$en,") >= 0);
}

# return true if all engines are in bookmark source
sub all_in_bookmark_source {
	for (my $c = 0 ; $c <= $#dirs ; $c++) {
		return 0 if (index($_[0],",$c,") < 0);
	}
	return 1;
}

# get list of bookmark folders
sub get_bookmark_folders {
	my (@bm) = @_;
	# this is necessary because we run with -w
	if ($#bm > 7) { return @bm[7..$#bm-1]; }
	else { return (); }
}

# =========================================================================
# Meta character control
#
# =========================================================================

sub may_coexist {
	my ($bm1,$bm2) = @_;
	
	foreach my $en (@checkengines) {
		if (!&engine($en, 'may_coexist', $bm1, $bm2)) {
			return 0;
		}
	}

	return 1;
}

# =========================================================================
# Foldername functions
#
# =========================================================================

# return the name of the default special folder for toolbar list
sub get_foldername_toolbar { return "\0bmsyncToolbar\0"; }

# =========================================================================
# Misc helper functions
#
# =========================================================================

# return true if two arrays are exaclty the same
sub equal_arrays {
	my ($a, $b) = @_;

	return 0 if ($#{$a} != $#{$b});
	for (my $c = 0 ; $c <= $#{$a} ; $c++) {
		return 0 if (${$a}[$c] ne ${$b}[$c]);
	}
	return 1;

	# alternative:
	#	return ((join('\0',@{$a}) eq join('\0',@{$b}));
}

# returns the minimum of a few
sub min {
	my $min = shift;
	foreach my $c (@_) { $min = $c if $c < $min; }
	return $min;
}

# returns the maximum of a few
sub max {
	my $max = shift;
	foreach my $c (@_) { $max = $c if $c > $max; }
	return $max;
}

# ask user to choose
sub prompt_choice {
	my $question = undef;
	my $choices = '';
	my $ch;

	# generate a question string and get possible choices
	foreach my $s (@_) {
		$choices .= chop $s;
		$question .= ', ' if defined $question;
		$question .= $s;
	}
	$question .= ' (' . join('/',split(//,$choices)) . ')? ';

	my $first = 1;
	do {
		print STDERR "Please enter a character from the (case sensitive) listed choices.\n" if (!$first);
		print STDERR $question;
		$ch = <STDIN>; chop $ch;
		$first = 0;
	} while (index($choices, $ch) == -1);

	return substr($choices,0,1) if ($ch eq '');

	return $ch;
}

# =========================================================================
# Engine routines; general or specific to a certain engine
#
# =========================================================================

# the dispatcher function: calls the specified engine
sub engine {
	my ($engine, $func, @args) = @_;

	return &{$engine . '_' . $func}(@args);
}

# return true if such an engine exists
sub is_engine {
	my ($engine) = @_;

	foreach my $s (@available_engines) {
		return 1 if ($s eq $engine);
	}

	return 0;
}

sub nn_try_recognize {
	my ($path) = @_;

	# must be a readable file
	if (!-f $path || !-r $path) { return 0; }

	# open file and check for header
	open(URL, $path) || die "$appname: $path: $!\n";
	if (<URL> !~ /^<!DOCTYPE NETSCAPE-Bookmark-file.*>$/) { close URL; return 0; }
	close URL;

	return 1;
}

sub nn_read_urls {
	my ($path, $source) = @_;

	# open file and check for header
	open(URL, $path) || die "$appname: $path: $!\n";
	if (<URL> !~ /^<!DOCTYPE NETSCAPE-Bookmark-file.*>$/) { close URL; return (); }

	# read 7 unimportant lines
	for (1..7) { <URL>; }

	# start the reading process
	my @mybookmarks = ();
	my @cfolder = ();
	my $line;
	while (defined ($line = <URL>)) {
		chop $line;
	
		# did we read a folder head?
		my ($folder) = ($line =~ /^ +<DT><H3 (?:FOLDED )?ADD_DATE=\"\d+\">([^<]+)<\/H3>$/);
		if (defined $folder) {
			push @cfolder, $folder;
		}

		# did we read a folder foot? if so, close last folder
		if ($line =~ /^ +<\/DL><p>$/) {
			pop @cfolder;
		}

		# did we read a url?
		my ($url, $add_date, $last_visit, $last_modified, $name)
			= ($line =~ /^ +<DT><A HREF=\"([^\"]*)\" ADD_DATE=\"(\d+)\" LAST_VISIT=\"(\d+)\" LAST_MODIFIED=\"(\d+)\">([^<]*)<\/A>/);
		if (defined $url && defined $add_date && defined $last_visit && defined $last_modified && defined $name) {
			$cfolder[0] = &get_foldername_toolbar() if (!$arg_exact && $#cfolder == 0 && $cfolder[0] eq 'Personal Toolbar Folder');

            $name = substr($name, 0, $MAX_NAME_LEN);
			push @mybookmarks, [ $source, 0, $add_date, $last_visit, $last_modified, $url, '', @cfolder, $name ];
		}

		# if we read something else, just ignore it
	}

	close URL;
	return @mybookmarks;
}

# nn_may_coexist: all bookmarks may coexist simultaneously
sub nn_may_coexist {
	return 1;
}

sub nn_write_urls {
	my ($path, @bookmarks) = @_;

	# open file for writing
	$path .= '.new' if !$arg_real;
	open(URL, '>'.$path) || die "$appname: $path: $!\n";

	# translate special entries before sorting!
	foreach my $c (@bookmarks) {
		if ($#{$c} == 8 && ${$c}[7] eq &get_foldername_toolbar()) {
			${$c}[7] = 'Personal Toolbar Folder';
		}
	}

	# it's a good idea to sort the collection first
	@bookmarks = sort {
		my @afold = &get_bookmark_folders(@{$a});
		my @bfold = &get_bookmark_folders(@{$b});
		while ($#afold >= 0 && $#bfold >= 0) {
			if ($afold[0] ne $bfold[0]) { return $afold[0] cmp $bfold[0]; }
			pop @afold;
			pop @bfold;
		}
		return $#bfold <=> $#afold if ($#afold != $#bfold);
		return &get_bookmark_name(@{$a}) cmp &get_bookmark_name(@{$b});
	} @bookmarks;

	# output header
	print URL "<!DOCTYPE NETSCAPE-Bookmark-file-1>\
<!-- This is an automatically generated file.\
It will be read and overwritten.\
Do Not Edit! -->\
<TITLE>Bookmarks for $ENV{'USER'}</TITLE>\
<H1>Bookmarks for $ENV{'USER'}</H1>\
\
<DL><p>\n";

	# iterate over bookmarks
	my @cfolder = ();
	my (@bmfolder, @bmdates, $same, @matching);
	foreach my $bm (@bookmarks) {
		if (&get_bookmark_status(@{$bm}) ne 'r') {
			@bmdates = &get_bookmark_dates(@{$bm});
			@bmfolder = &get_bookmark_folders(@{$bm});

			if (!&equal_arrays(\@cfolder, \@bmfolder)) {
				# how many leading folders are the same?
				@matching = ();
				for (my $d = 0 ; $d <= $#bmfolder && $d <= $#cfolder; $d++) {
					push @matching, $bmfolder[$d] if ($bmfolder[$d] eq $cfolder[$d]);
				}

				# print closing
				for (my $d = 0 ; $d < scalar(@cfolder)-scalar(@matching) ; $d++) {
					print URL ('    ' x (scalar(@cfolder)-$d)), "</DL><p>\n";
				}

				# print opening
				for (my $d = 0 ; $d < scalar(@bmfolder)-scalar(@matching) ; $d++) {
					print URL ('    ' x ($d+scalar(@matching)+1)), "<DT><H3 FOLDED ADD_DATE=\"0\">",
					  $bmfolder[$d], "</H3>\n";
					print URL ('    ' x ($d+scalar(@matching)+1)), "<DL><p>\n";
				}

				@cfolder = @bmfolder;
			}

			# print the bookmark
			print URL ('    ' x (scalar(@cfolder)+1)), '<DT><A HREF="', &get_bookmark_url(@{$bm}),
			  "\" ADD_DATE=\"$bmdates[0]\" LAST_VISIT=\"$bmdates[1]\" LAST_MODIFIED=\"$bmdates[2]\">",
				&get_bookmark_name(@{$bm}), "</A>\n";
		}
	}

	# output footer
	print URL "</DL><p>\n";

	close URL;
}

sub msie_try_recognize {
	my ($path) = @_;

    # BUGBUG: we should open one of the files to see what it looks like
    # can probably do a `find`, but a more system-indepenent way is to recurse
    # the directories ourselves
	if (!-d $path) {
      return 0;
    } else {
      return 1;
    }

}

sub msie_get_metaname {
	my ($str,$mode) = @_;

	# fixme: this set isn't complete
	$str = lc $str;
	$str .= '.url' if !$mode;
	$str =~ tr[:?*/\\][]d;

	return $str;
}

sub msie_may_coexist {
	my ($bm1, $bm2) = @_;

	my $c;
	for ($c = 7 ; $c < $#{$bm1} && $c < $#{$bm2} ;  $c++) {
		if (&msie_get_metaname(${$bm1}[$c],1) eq &msie_get_metaname(${$bm2}[$c],1)) {
			warn "(msie) merged directory contents ${$bm1}[$c], ${$bm2}[$c]\n"
			  if (${$bm1}[$c] ne ${$bm2}[$c]);
		} else { return 1; }
	}

	if (&msie_get_metaname(${$bm1}[$c], ($c == $#{$bm1})) ne &msie_get_metaname(${$bm2}[$c], ($c == $#{$bm2}))) {
		return 1;
	}

	return 0;
}

sub msie_read_urls {
	my ($path, $source) = @_;

	# scan all files and subdirs into one big array
	my @files = &msie_scan_dir($path);
	my @mybookmarks = ();

	foreach my $s (@files) {
		my @bm = &msie_read_bookmark($s, substr($s, length($path)), $source);
		push @mybookmarks, \@bm if defined @bm;
	}

	return @mybookmarks;
}

sub msie_read_bookmark {
	my ($file, $name, $source) = @_;
    my $url;

	# open file and check header
	open(URL, $file) || die "$appname: $file: $!\n";

	while(<URL>) {
      chop; chop;	# cr/lf
      if ( (s/^URL=//i) ) { $url = $_; }
    }

	close URL;
	if (!defined $url) { return (); }

	# remove possible (/probable) extension from url
	$name =~ s/\.url$//i;

	# handle special folders
	my @names = split('/', $name);
	$names[0] = &get_foldername_toolbar() if (!$arg_exact && $#names == 1 && $names[0] eq 'Links');

    # make sure names are short enough
    @names = map { $_ = substr($_, 0, $MAX_NAME_LEN) } @names;

	return ($source, 0, 0, 0, 0, $url, '', @names);
}

# scan a dir recursively
sub msie_scan_dir {
	my ($path) = @_;

	opendir(DIR, $path) || die "$appname: $path: $!\n";
	my @sfiles = readdir(DIR);
	closedir(DIR);

	my @files = ();
	my $wfile;
	foreach my $file (@sfiles) {
		next if ($file eq '.' || $file eq '..');

		$wfile = File::Spec->catdir($path, $file);
		if (-d $wfile) {
			# next if $file eq 'Media' ?
			next if (!$arg_all && ($file eq 'Channels' || $file eq 'Software Updates'));
			push @files, &msie_scan_dir($wfile);
		} else {
			push @files, $wfile;
		}
	}

	return @files;
}

sub msie_write_urls {
	my ($path, @bookmarks) = @_;

	if (!$arg_real) {			# BUGBUG: what if want to copy AND create dir from scracth....
		$path =~ s[/$][];
		$path .= '.new';
		if (!-e $path) {
			mkdir ($path,0777) || warn "$appname: $path: $!\n";
		}
	}

	# translate special entries before sorting
	foreach my $c (@bookmarks) {
		if ($#{$c} == 8 && ${$c}[7] eq &get_foldername_toolbar()) {
			${$c}[7] = 'Links';
		}
	}

	# sort the collection first (to speed up disk operations)
	@bookmarks = sort {
		my @afold = &get_bookmark_folders(@{$a});
		my @bfold = &get_bookmark_folders(@{$b});
		while ($#afold >= 0 && $#bfold >= 0) {
			if ($afold[0] ne $bfold[0]) { return $afold[0] cmp $bfold[0]; }
			pop @afold;
			pop @bfold;
		}
		return $#bfold <=> $#afold if ($#afold != $#bfold);
		return &get_bookmark_name(@{$a}) cmp &get_bookmark_name(@{$b});
	} @bookmarks;

	# iterate over bookmarks
	my @folder = ();
	my (@bmfolder,$realdir);
	foreach my $bm (@bookmarks) {
		# for now, don't remove any bookmarks
		next if &get_bookmark_status(@{$bm}) eq 'r';

		# if we're in a new folder, make sure it exists
		@bmfolder = &get_bookmark_folders(@{$bm});
		# escape slashes into backslashes
		# BUGBUG should we store them escaped?
		@bmfolder = map { my $x = $_; $x =~ s^/^\\^g; $x } @bmfolder;
		if (scalar(@bmfolder) && !&equal_arrays(\@folder, \@bmfolder)) {

			# make sure each one exists or else create it
			@folder = ();
			foreach my $folder (@bmfolder) {
				push @folder,$folder;
				$realdir = File::Spec->catdir($path, join('/', @folder));

				if (!-e $realdir) {
					mkdir ($realdir,0777) || warn "$appname: $path: $!\n";
				}
			}
		}

		# write the bookmark
		my $fname = &get_bookmark_name(@{$bm});
		$fname =~ s^/^\\^g;		# escape slashes into backslashes
		$realdir = File::Spec->catfile(File::Spec->catdir($path, join('/', @folder)), "$fname.url");
		if (!-e $realdir) {
			&msie_write_bookmark($realdir, &get_bookmark_url(@{$bm}));
		}
	}
}

sub msie_write_bookmark {
	my ($file, $url) = @_;

	if (!open(URL, ">$file")) { warn "$appname: $file: $!\n"; return;  }

	print URL "[InternetShortcut]\r\n";
	print URL "URL=$url\r\n";
	#print URL "Modified=\r\n";
	
	close(URL);
}

sub kde_try_recognize{
	my ($path) = @_;

    # BUGBUG: we should open one of the files to see what it looks like
    # can probably do a `find`, but a more system-indepenent way is to recurse
    # the directories ourselves
	if (!-d $path) {
      return 0;
    } else {
      return 1;
    }

}

sub kde_get_metaname {
	my ($str,$mode) = @_;

	# fixme: this set isn't complete
	$str = lc $str;
	$str .= '.kdelnk' if !$mode;
	$str =~ tr[:?*/\\][]d;

	return $str;
}

sub kde_may_coexist {
	my ($bm1, $bm2) = @_;

	my $c;
	for ($c = 7 ; $c < $#{$bm1} && $c < $#{$bm2} ;  $c++) {
		if (&kde_get_metaname(${$bm1}[$c],1) eq &kde_get_metaname(${$bm2}[$c],1)) {
			warn "(kde) merged directory contents ${$bm1}[$c], ${$bm2}[$c]\n"
			  if (${$bm1}[$c] ne ${$bm2}[$c]);
		} else { return 1; }
	}

	if (&kde_get_metaname(${$bm1}[$c], ($c == $#{$bm1})) ne &kde_get_metaname(${$bm2}[$c], ($c == $#{$bm2}))) {
		return 1;
	}

	return 0;
}

sub kde_read_urls {
	my ($path, $source) = @_;

	# scan all files and subdirs into one big array
	my @files = &kde_scan_dir($path);
	my @mybookmarks = ();

	foreach my $s (@files) {
		my @bm = &kde_read_bookmark($s, substr($s, length($path)), $source);
		push @mybookmarks, \@bm if defined @bm;
	}

	return @mybookmarks;
}

sub kde_read_bookmark {
	my ($file, $name, $source) = @_;

	# open file and check header
	open(URL, $file) || die "$appname: $file: $!\n";
	while(<URL>) {
      chop; chop;	# cr/lf
      if ( (s/^URL=//i) ) { $url = $_; }
    }
	close URL;
	if (!defined $url) { return (); }

	# remove possible (/probable) extension from url
	$name =~ s/\.kdelnk$//i;

	my @names = split('/', $name);

    # make sure names are short enough
    @names = map { $_ = substr($_, 0, $MAX_NAME_LEN) } @names;

	return ($source, 0, 0, 0, 0, $url, '', ());
}

# scan a dir recursively
sub kde_scan_dir {
	my ($path) = @_;

	opendir(DIR, $path) || die "$appname: $path: $!\n";
	my @sfiles = readdir(DIR);
	closedir(DIR);

	my @files = ();
	my $wfile;
	foreach my $file (@sfiles) {
		next if ($file eq '.' || $file eq '..');

		$wfile = File::Spec->catdir($path, $file);
		if (-d $wfile) {
			# next if $file eq 'Media' ?
			push @files, &kde_scan_dir($wfile);
		} else {
			push @files, $wfile;
		}
	}

	return @files;
}

sub kde_write_urls {
	my ($path, @bookmarks) = @_;

	if (!$arg_real) {			# BUGBUG: what if want to copy AND create dir from scracth....
		$path =~ s[/$][];
		$path .= '.new';
		if (!-e $path) {
			mkdir ($path,0777) || warn "$appname: $path: $!\n";
		}
	}

	# translate special entries before sorting
	foreach my $c (@bookmarks) {
		if ($#{$c} == 8 && ${$c}[7] eq &get_foldername_toolbar()) {
			${$c}[7] = 'Links';
		}
	}

	# sort the collection first (to speed up disk operations)
	@bookmarks = sort {
		my @afold = &get_bookmark_folders(@{$a});
		my @bfold = &get_bookmark_folders(@{$b});
		while ($#afold >= 0 && $#bfold >= 0) {
			if ($afold[0] ne $bfold[0]) { return $afold[0] cmp $bfold[0]; }
			pop @afold;
			pop @bfold;
		}
		return $#bfold <=> $#afold if ($#afold != $#bfold);
		return &get_bookmark_name(@{$a}) cmp &get_bookmark_name(@{$b});
	} @bookmarks;

	# iterate over bookmarks
	my @folder = ();
	my (@bmfolder,$realdir);
	foreach my $bm (@bookmarks) {
		# for now, don't remove any bookmarks
		next if &get_bookmark_status(@{$bm}) eq 'r';

		# if we're in a new folder, make sure it exists
		@bmfolder = &get_bookmark_folders(@{$bm});
		# escape slashes into backslashes
		# BUGBUG should we store them escaped?
		@bmfolder = map { my $x = $_; $x =~ s^/^\\^g; $x } @bmfolder;
		if (scalar(@bmfolder) && !&equal_arrays(\@folder, \@bmfolder)) {

			# make sure each one exists or else create it
			@folder = ();
			foreach my $folder (@bmfolder) {
				push @folder,$folder;
				$realdir = File::Spec->catdir($path, join('/', @folder));

				if (!-e $realdir) {
					mkdir ($realdir,0777) || warn "$appname: $path: $!\n";
				}
			}
		}

		# write the bookmark
		my $fname = &get_bookmark_name(@{$bm});
		$fname =~ s^/^\\^g;		# escape slashes into backslashes
		$realdir = File::Spec->catfile(File::Spec->catdir($path, join('/', @folder)), "$fname.kdelnk");
		if (!-e $realdir) {
			&kde_write_bookmark($realdir, &get_bookmark_url(@{$bm})); # BUGBUG for testing
		}
	}
}

sub kde_write_bookmark {
	my ($file, $url) = @_;

	if (!open(URL, ">$file")) { warn "$appname: $file: $!\n"; return;  }

	print URL "\# KDE Config File created by bmsync\n";
	print URL "[KDE Desktop Entry]\n";
	print URL "URL=$url\n";
	print URL "Icon=html.xpm\n";		# BUGBUG -- can probably do better here by examining suffix
	print URL "MiniIcon=html.xpm\n";
	print URL "Type=Link\n";
	close(URL);
}
