package Mac::Safari::JavaScript;
use base qw(Exporter);

# This isn't a problem, all Macs have at least 5.8
use 5.008;

use strict;
use warnings;

use Mac::AppleScript qw(RunAppleScript);
use JSON::XS qw(decode_json);
use Encode qw(encode decode);
use Carp qw(croak);

use Mac::Safari::JavaScript::Exception;

our @EXPORT_OK;
our $VERSION = "1.01";

=head1 NAME

Mac::Safari::JavaScript - Run JavaScript in Safari on Mac OS X

=head1 SYNOPSIS

  use Mac::Safarai::JavaScript qw(safari_js);

  # do an alert
  safari_js 'alert("Hello Safari User")';

  # return some value
  var $arrayref = safari_js 'return [1,2,3]';

  # multiple lines are okay
  safari_js <<'JAVASCRIPT';
    var fred = "bob";
    return fred;
  JAVASCRIPT
  
  # You can set variables to pass in
  safari_js 'return document.getElementById(id).href', id => "mainlink";

=head1 DESCRIPTION

This module allows you to execute JavaScript code in the Safari web
browser on Mac OS X.

The current implementation wraps the JavaScript in Applescript,
compiles it, and executes it in order to control Safari.

=head1 FUNCTIONS

Functions are exported on request, or may be called fully qualified.

=over

=item safari_js($javascript, @named_parameters)

Runs the JavaScript in the first tab of the front window of the
currently running Safari.

=over 8

=item The script

This script may safely contain newlines, unicode characters, comments etc.
Any line numbers in error messages should match up with error messages

=item Return value

C<safari_js> will do a passable job of mapping whatever you returned from
your JavaScript (using the C<return> keyword) into a Perl data structure it
will return.  If you do not return a value from JavaScript (i.e. the return
keyword is not executed) then C<safari_js> will return the empty list.  If
you return nothing (i.e. use C<return;> in your script), C<safari_js> will
return C<undef>.

Whatever you return from your JavaScript will be encoded into JSON with
Safari's native C<JSON.stringify> function and decoded on the Perl side
using the JSON::XS module.

JavaScript data structures are mapped as you might expect:  Objects to
hashrefs, Arrays to arrayrefs, strings and numbers to their normal scalar
representation, and C<null>, C<true> and C<false> to C<undef>, JSON::XS::true
(which you can treat like the scalar C<1>) and JSON::XS::false (which you
can treat like the scalar C<0>) respectivly.  Please see L<JSON::XS>
for more information.

You cannot return anything from JavaScript that has a ciruclar reference
in it (as this cannot be represented by JSON.)

=item Passing Parameters

You may pass in named parameters by passing them as name/value pairs

  safari_js $js_code_to_run, name1 => $value1, name2 => $value2, ...

The parameters are simply availble as variables in your code.

Internally parameters are converted from Perl data structures into JavaScript
using JSON::XS using the reverse mapping described above.  You may not pass
in circular data structures.  Again, see L<JSON::XS> for more infomation.

=item Exception Handling

If what you pass causes an uncaught exception within the Safari web browser
(including exceptions during by parsing your script) then a
Mac::Safari::JavaScript::Exception exception object will be raised by
C<safari_js>.  This will stringify to the exception you normally would see
in your browser and can be integated for extra info such as the line number,
etc.

=back

=cut

sub safari_js($;@) {
  my $javascript = shift;

  # create a coder objects
  my $coder = JSON::XS->new;
  $coder->allow_nonref(1);

  # handle the arguments passed in
  if (@_ % 2) {
    croak "Uneven number of parameters passed to safari_js";
  }
  my %params;
  while (@_) {
    my $key = shift;
    if (exists $params{ $key }) {
      croak "Duplicate parameter '$key' passed twice to safari_js";
    }
    # we're going to put the value into a string to
    # eval.  This means we need to escape all the meta chars
    my $value = $coder->encode(shift);
    $value =~ s/\\/\\\\/gx;  # \ -> \\
    $value =~ s/"/\\"/gx;    # " -> \"

    $params{ $key } = $value;
  }
  my $args = join ",", keys %params;
  my $values = join ",", values %params;

  # we're going to put the javascript into a string to
  # eval.  This means we need to escape all the meta chars
  $javascript =~ s/\\/\\\\/gx;  # \ -> \\
  $javascript =~ s/"/\\"/gx;    # " -> \"

  # since we've now effectivly got a multiline string (and
  # JavaScript doesn't support that) we better fix that up
  # Note that we're trying not to mess up the line numers
  # inside the eval of what we were passed
  $javascript = join '\\n"+"',split /\n/x, $javascript;

  # wrap the javascript in helper functions
  #  
  #  - use (function () { })() to avoid poluting the global namespace
  #  - use eval "" to allow syntax errors to be caught and returned as a
  #    data structure we can re-throw on the Perl side
  #  - JSON.stringify(undefined) returns, not the string "null".  We detect
  #    this and return the string "null"

  $javascript = <<"ENDOFJAVASCRIPT";
try{var result=eval("JSON.stringify((function($args){ $javascript;throw'NothingReturned'})($values));");(result===undefined)?'{"undefined":1}':'{"result":'+result+'}';}catch(e){ (e == "NothingReturned")?'{"noresult":1}':(function(){var r={error:e,name:'CustomError'};var v=['name',"line","expressionBeginOffset","expressionEndOffset","message","sourceId","sourceURL"];for(var i=0;i<v.length;i++)if(e[v[i]]!=undefined)r[v[i]]=e[v[i]];if(r.hasOwnProperty("expressionEndOffset")) r.expressionEndOffset-=28;if(r.hasOwnProperty("expressionBeginOffset")) r.expressionBeginOffset-=28;return JSON.stringify(r);})(); }
ENDOFJAVASCRIPT

  # escape the string escapes again as we're going to pass
  # the whole thing via Applescript now
  $javascript =~ s/\\/\\\\/gx;     # \ -> \\
  $javascript =~ s/"/\\"/gx;       # " -> \"

  # wrap it in applescript
  my $applescript = <<"ENDOFAPPLESCRIPT";
tell application "Safari"
  -- execute the javascript
  set result to do JavaScript "$javascript" in document 1

  -- then make sure we're returning a string to be consistent'
  "" & result
end tell
ENDOFAPPLESCRIPT

  # compile it an execute it using the cocca api
  # (make sure to pass it in as utf-8 bytes)
  my $json = RunAppleScript($applescript);

  # $json is now a string where each character represents a byte
  # in a utf-8 encoding of the real characters (ARGH!).  Fix that so
  # each character actually represents the character it should, um,
  # represent.
  $json = encode("iso-8859-1", $json);
  $json = decode("utf-8", $json);

  # strip off any applescript string wrapper
  $json =~ s/\A"//x;
  $json =~ s/"\z//x;
  $json =~ s/\\"/"/gx;
  $json =~ s/\\\\/\\/gx;

  # and decode this from json
  my $ds = $coder->decode($json);

  return undef
    if exists $ds->{undefined};
  return
    if exists $ds->{noresult};
  return $ds->{result}
    if exists $ds->{result};
  croak(Mac::Safari::JavaScript::Exception->new(%{ $ds }))
    if exists $ds->{error};
  croak("Unexpected error");
}
push @EXPORT_OK, "safari_js";

=back

=head1 AUTHOR

Written by Mark Fowler <mark@twoshortplanks.com>

Copryright Mark Fowler 2011. All Rights Reserved.

This program is free software; you can redistribute it
and/or modify it under the same terms as Perl itself.

=head1 BUGS

Bugs should be reported to me via the CPAN RT system. http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Mac::Safari::JavaScript

Some pages (e.g. http://developer.apple.com/) cause array stringifcation to break.  I haven't worked out why yet.

=head1 SEE ALSO

L<Mac::Applescript>, L<Mac::Safari::JavaScript::Exception>

=cut

1;
