# Copyright (C) 2003, 2004, 2005, 2006, 2007  Alex Schroeder <alex@gnu.org>
#
# 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 3 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, see <http://www.gnu.org/licenses/>.

use strict;
use v5.10;

AddModuleDescription('permanent-anchors.pl', 'Permanent Anchors');

our ($q, $OpenPageName, %IndexHash, $DataDir, $ScriptName, @MyRules, @MyInitVariables, $FS, $FreeLinkPattern, @IndexOptions);

=encoding utf8

=head1 Permanent Anchors

This module allows you to create link targets within a page. These
link targets are called named anchors in HTML. The anchors provided by
this module are permanent, because moving the anchor from one page to
another does not affect the links pointing to it. You link to these
named anchors as if they were pagenames. For users, it makes no
difference.

=cut

our (%PermanentAnchors, %PagePermanentAnchors, $PermanentAnchorsFile);

$PermanentAnchorsFile = "$DataDir/permanentanchors";

=head2 Definition

Permanent anchors are defined by using square brackets and a double
colon, like this: C<[::Example]>.

If you define a permanent anchor that already exists, the new
definition will have no effect. Instead you will be shown a link to
the existing permanent anchor so that you can easily resolve the
conflict.

If you define a permanent anchor and a page of the same name already
exists, the definition will work, and all links will point to the
permanent anchor. You will also be given a link to the existing page
so that you can easily resolve the conflict (eg. by deleting the
page). Note that if you mark the page for deletion, you will still
have to wait for page expiry to kick in and actually delete the page
before the message disappears.

During anchor definition a lock is created in the temporary directory.
If Oddmuse encounters a lock while defining a permanent anchor, it
will wait a few seconds and try again. If the lock cannot be obtained,
the definition fails. The unlock action available from the
administration page allows you to remove any stale locks once you're
sure the locks have been left behind by a crash. After having removed
the stale lock, edit the page with the permanent anchor definition
again.

When linking to a permanent anchor on the same page, you'll notice
that this only works flawlessly if the definition comes first. When
rendering a page, permanent anchor definitions and links are parsed in
order. Thus, if the link comes first, the permanent anchor definition
is not yet available. Once you invalidate the HTML cache (by editing
another page or by removing the C<pageidx> file from the data
directory), this situation will have fixed itself.

=cut

push(@MyRules, \&PermanentAnchorsRule);

sub PermanentAnchorsRule {
  my ($locallinks, $withanchors) = @_;
  if (m/\G(\[::$FreeLinkPattern\])/cg) {
    #[::Free Link] permanent anchor create only $withanchors
    Dirty($1);
    if ($withanchors) {
      print GetPermanentAnchor($2);
    } else {
      print $q->span({-class=>'permanentanchor'}, $2);
    }
    return '';
  }
  return;
}

sub GetPermanentAnchor {
  my $id = FreeToNormal(shift);
  my $text = NormalToFree($id);
  my ($class, $resolved, $title, $exists) = ResolveId($id);
  if ($class eq 'alias' and $title ne $OpenPageName) {
    return '[' . Ts('anchor first defined here: %s',
		    ScriptLink(UrlEncode($resolved), $text, 'alias')) . ']';
  } elsif ($PermanentAnchors{$id} ne $OpenPageName
	   # 10 tries, 3 second wait, die on error
	   and RequestLockDir('permanentanchors', 10, 3, 1)) {
    # Somebody may have added a permanent anchor in the mean time.
    # Comparing $LastUpdate to the $IndexFile mtime does not work for
    # subsecond changes and updates are rare, so just reread the file!
    PermanentAnchorsInit();
    $PermanentAnchors{$id} = $OpenPageName;
    WritePermanentAnchors();
    ReleaseLockDir('permanentanchors');
  }
  $PagePermanentAnchors{$id} = 1; # add to the list of anchors in page
  my $html = GetSearchLink($id, 'definition', $id,
    T('Click to search for references to this permanent anchor'));
  $html .= ' [' . Ts('the page %s also exists',
		     ScriptLink("action=browse;anchor=0;id="
				. UrlEncode($id), NormalToFree($id), 'local'))
    . ']' if $exists;
  return $html;
}

=head2 Storage

Permanent anchor definitions need to be stored in a separate file.
Otherwise linking to a permanent anchor would require a search of the
entire page database. The permanent anchors are stored in a file
called C<permanentanchors> in the data directory. The location can be
changed by setting C<$PermanentAnchorsFile>.

The format of the file is simple: permanent anchor names and the name
of the page they are defined on follow each other, separated by
whitespace. Spaces within permanent anchor names and page names are
replaced with underlines, as always. Thus, the keys of
C<%PermanentAnchors> is the name of the permanent anchor, and
C<$PermanentAnchors{$name}> is the name of the page it is defined on.

=cut

push(@MyInitVariables, \&PermanentAnchorsInit);

sub PermanentAnchorsInit {
  %PagePermanentAnchors = %PermanentAnchors = ();
  my ($status, $data) = ReadFile($PermanentAnchorsFile);
  return unless $status; # not fatal
  # $FS was used in 1.417 and earlier!
  %PermanentAnchors = split(/\n| |$FS/,$data);
}

sub WritePermanentAnchors {
  my $data = '';
  foreach my $name (keys %PermanentAnchors) {
    $data .= $name . ' ' . $PermanentAnchors{$name} ."\n";
  }
  WriteStringToFile($PermanentAnchorsFile, $data);
}

=head2 Deleting Anchors

When deleting a page Oddmuse needs to delete the corresponding
permanent anchors from its file. This is why the
C<DeletePermanentAnchors> function is called from C<DeletePage>.

When a page is edited, we want to make sure that Oddmuse deletes the
permanent anchors no longer needed from its file. The safest way to do
this is to delete all permanent anchors defined on the page being
edited and redefine them when it is rendered for the first time. This
is achieved by calling C<DeletePermanentAnchors> from C<Save>. After
hitting the save button, the user is automatically redirected to the
new page. This will render the page, and redefine all permanent
anchors.

=cut

*OldPermanentAnchorsDeletePage = \&DeletePage;
*DeletePage = \&NewPermanentAnchorsDeletePage;

sub NewPermanentAnchorsDeletePage {
  my $status = OldPermanentAnchorsDeletePage(@_);
  return $status if $status; # this would be the error message
  DeletePermanentAnchors(@_); # the only parameter is $id
}

*OldPermanentAnchorsSave = \&Save;
*Save = \&NewPermanentAnchorsSave;

sub NewPermanentAnchorsSave {
  OldPermanentAnchorsSave(@_);
  DeletePermanentAnchors(@_); # the first parameter is $id
}

sub DeletePermanentAnchors {
  my $id = shift;
  # 10 tries, 3 second wait, die on error
  RequestLockDir('permanentanchors', 10, 3, 1);
  foreach (keys %PermanentAnchors) {
    if ($PermanentAnchors{$_} eq $id and !$PagePermanentAnchors{$_}) {
      delete($PermanentAnchors{$_}) ;
    }
  }
  WritePermanentAnchors();
  ReleaseLockDir('permanentanchors');
}

=head2 Name Resolution

Name resolution is done by C<ResolveId>. This function returns a list
of several items: The CSS class to use, the resolved id, the title
(eg. for popups), and a boolean saying whether the page actually
exists or not. When resolving a permanent anchor, the CSS class used
will be “alias”, the resolved id will be the C<pagename#anchorname>,
the title will be the page name.

You can override this behaviour by providing the parameter
C<anchor=0>. This is used for the link in the warning message “the
page foo also exists.”

=cut

*OldPermanentAnchorsResolveId = \&ResolveId;
*ResolveId = \&NewPermanentAnchorsResolveId;

sub NewPermanentAnchorsResolveId {
  my $id = shift;
  my $page = $PermanentAnchors{$id};
  if (GetParam('anchor', 1) and $page and $page ne $id) {
    return ('alias', $page . '#' . $id, $page, $IndexHash{$id})
  } else {
    return OldPermanentAnchorsResolveId($id, @_);
  }
}

=head2 Anchor Objects

An anchor object is the text that starts after the anchor definition
and goes up to the next heading, horizontal line, or the end of the
page. By redefining C<GetPageContent> to work on anchor objects we
automatically allow internal transclusion.

=cut

*OldPermanentAnchorsGetPageContent = \&GetPageContent;
*GetPageContent = \&NewPermanentAnchorsGetPageContent;

sub NewPermanentAnchorsGetPageContent {
  my $id = shift;
  my $result = OldPermanentAnchorsGetPageContent($id);
  if (not $result and $PermanentAnchors{$id}) {
    $result = OldPermanentAnchorsGetPageContent($PermanentAnchors{$id});
    $result =~ s/^(.*\n)*.*\[::$id\]// or return '';
    $result =~ s/(\n=|\n----|\[::$FreeLinkPattern\])(.*\n)*.*$//;
  }
  return $result;
}

=head2 User Interface Changes

Some user interface changes are required as well.

=over

=item *

Allow the page index to list permanent anchors or not by setting
C<@IndexOptions>.

=cut

push(@IndexOptions, ['permanentanchors', T('Include permanent anchors'),
		     1, sub { keys %PermanentAnchors }]);

=item *

Make sure that you can view old revisions of pages that have a
permanent anchor of the same name. This requires link munging for all
browse links from C<GetHistoryLine>.

=back

=cut

*OldPermanentAnchorsGetHistoryLine = \&GetHistoryLine;
*GetHistoryLine = \&NewPermanentAnchorsGetHistoryLine;

sub NewPermanentAnchorsGetHistoryLine {
  my $id = shift;
  my $html = OldPermanentAnchorsGetHistoryLine($id, @_);
  if ($PermanentAnchors{$id}) {
    my $encoded_id = UrlEncode($id);
    # link to the current revision; ignore dependence on $UsePathInfo
    $html =~ s!$ScriptName[/?]$encoded_id!$ScriptName?action=browse;anchor=0;id=$encoded_id!;
    # link to old revisions
    $html =~ s!action=browse;id=$encoded_id!action=browse;anchor=0;id=$encoded_id!g;
  }
  return $html;
}
