Socialtext-Resting-0.38/0000755000374200037420000000000011745146143014546 5ustar kevinjkevinjSocialtext-Resting-0.38/README0000644000374200037420000000175111743342534015432 0ustar kevinjkevinjSocialtext::Resting =================== Socialtext::Resting is an interface to the Socialtext REST API. Developers can use the methods in this module to perform various actions. Included in this distribution is 'strut', a command line utility which accesses the Socialtext REST API using Socialtext::Resting methods. INSTALLATION To install this module, run the following commands: perl Makefile.PL make make test make install Note that if you answer 'yes' to the 'Socialtext server' question, you will create a page on Socialtext's server - if you want to avoid this simply answer 'no'. SUPPORT AND DOCUMENTATION You can look for information at: Socialtext REST Documentation http://www.socialtext.net/st-rest-docs/index.cgi Search CPAN http://search.cpan.org/dist/Socialtext-Resting COPYRIGHT AND LICENSE Copyright (C) 2006 Socialtext This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. Socialtext-Resting-0.38/bin/0000755000374200037420000000000011745146143015316 5ustar kevinjkevinjSocialtext-Resting-0.38/bin/strut0000755000374200037420000004064011743342534016431 0ustar kevinjkevinj#!/usr/bin/env perl # strut use strict; use warnings; use lib 'lib'; use LWP::UserAgent; use LWP::MediaTypes qw(guess_media_type); use Socialtext::Resting; use File::Temp; use Pod::Usage; my %opts; use App::Options ( values => \%opts, options => [ "username", "password", "server", ], option => { username => { description => "Username to use for REST actions", env => "STRUT_USERNAME", required => 0, }, password => { description => "Password for REST", env => "STRUT_PASSWORD", required => 0, }, server => { description => "Server URL for REST", env => "STRUT_SERVER", required => 0, }, verbose => { description => "Verbosity", env => "STRUT_VERBOSE", required => 0, }, accept => { description => "Accept header", env => "STRUT_ACCEPT", required => 0, }, count => { description => "Number to return", env => "STRUT_FILTER", required => 0, }, filter => { description => "Filter name", env => "STRUT_FILTER", required => 0, }, query => { description => "Query string", env => "STRUT_QUERY", required => 0, }, order => { description => "Return order", env => "STRUT_ORDER", required => 0, }, author => { description => "Page author", env => "STRUT_AUTHOR", required => 0, }, date => { description => "Date for page", env => "STRUT_DATE", required => 0, }, }, ); =head1 NAME strut - command line interface (using Socialtext::Resting) to the Socialtext REST services =head1 SYNOPSIS strut help strut configure strut list_workspaces strut list_pages strut get_page strut set_page strut list_tags strut put_tag strut set_tags strut list_tagged_pages strut list_attachments strut get_attachment strut add_attachment strut show_breadcrumbs strut show_backlinks strut show_frontlinks All list operations can further be controlled with the following operations: --query (search term for within the results) --filter (filter the titles of the results) --order (only accepts 'newest' right now) --count (restrict number of returned results) --accept (for your accept headers - text/html, text/plain, application/json) Example: strut --query=searchterm --filter=titlefilter --order=newest --count=number list_pages myworkspace =cut my $BASE_URI = '/data/workspaces'; # Globals Rool! our $User; our $Password; our $Server; our @Workspaces; our $Workspace; our @Pages; our $Page; our $Content; our @Tags; my %strut_command; if ( (!$opts{username} || !$opts{password} || !$opts{server}) && ($ARGV[0] !~ /\bhelp\b/i)) { print "You must first configure strut with your server information.\n"; dispatch('configure'); } our $Strutter = Socialtext::Resting->new( username => $opts{username}, password => $opts{password}, server => $opts{server}, accept => $opts{accept}, query => $opts{query}, order => $opts{order}, filter => $opts{filter}, count => $opts{count}, verbose => $opts{verbose}, ); dispatch(); sub dispatch { my $command = shift; init_commands(); if ($command) { $strut_command{$command}(); exit; } if (!@ARGV) { usage(); } else { my $count = 0; my $verb = shift(@ARGV); while (my $check = shift(@ARGV)) { $opts{$count++} = $check; } if ( exists $strut_command{$verb} ) { $strut_command{$verb}(); } else { print "No such command $verb - for a complete list of subcommands type 'strut help'\n"; } } exit; } sub init_commands { %strut_command = ( help => sub {usage()}, list_workspaces => sub {list_workspaces()}, list_pages => sub {list_pages()}, list_workspace_tags => sub {list_workspace_tags()}, list_tagged_pages => sub {list_tagged_pages()}, get_page => sub {get_page()}, set_page => sub {set_page()}, list_tags => sub {list_tags()}, set_tags => sub {set_tags()}, put_tag => sub {put_tag()}, list_attachments => sub {list_attachments()}, get_attachment => sub {get_attachment()}, add_attachment => sub {add_attachment()}, configure => sub {configure()}, show_breadcrumbs => sub {show_breadcrumbs()}, show_backlinks => sub {show_backlinks()}, show_frontlinks => sub {show_frontlinks()}, ); } =head1 COMMANDS The following commands are supported =head2 help Standard man page for this program =cut sub usage { my $msg = shift || ''; pod2usage( { -verbose => 2} ); exit; } =head2 configure Configure strut with username, password, and server information. See the CONFIGURATION section below for a discussion of your configuration options. =cut sub configure { if (! -d "$ENV{HOME}/.app") { mkdir "$ENV{HOME}/.app"; chmod(0700, "$ENV{HOME}/.app"); } open (FILE, ">$ENV{HOME}/.app/strut.conf"); @ARGV = (); foreach my $opt ('username', 'password', 'server') { print "$opt: "; $opts{$opt} = <>; print FILE "$opt = $opts{$opt}"; } chmod (0600, "$ENV{HOME}/.app/strut.conf"); } ################################################## # # Workspace actions # pick_workspace # show_workspaces # show_pages # list_pages # list_tagged_pages # list_workspace_tags # show_breadcrumbs # ################################################## =head2 show_breadcrumbs Get the breadcrumbs for the current user in this workspace. =cut sub show_breadcrumbs { use_positional( "workspace" => 0 ); _pick_workspace( $opts{workspace} ); printemout(_show_breadcrumbs()); } sub _show_breadcrumbs { $Strutter->get_breadcrumbs(); } =head2 list_workspaces Give a list of all workspaces on the server =cut sub list_workspaces { printemout(_list_workspaces()); } sub _pick_workspace { my $workspace = shift; if ($workspace) { $Strutter->workspace($workspace); return; } @Workspaces = _list_workspaces(); $Strutter->workspace ( _pick_thing('Workspace', \@Workspaces) ); } sub _list_workspaces { $Strutter->get_workspaces(); } sub _list_pages { use_positional("workspace" => 0); _pick_workspace($opts{workspace}); $Strutter->get_pages(); } sub _list_workspace_tags { use_positional("workspace" => 0); _pick_workspace($opts{workspace}); $Strutter->get_workspace_tags(); } sub _list_tagged_pages { use_positional("workspace" => 0, "tag" => 1); _pick_workspace($opts{workspace}); $Strutter->get_taggedpages($opts{tag}); } =head2 list_workspace_tags List the tags for a workspace. =cut sub list_workspace_tags { printemout(_list_workspace_tags()); } =head2 list_pages Give a list of all pages in the given workspace. If no workspace is given you will be prompted to pick from available workspaces. =cut sub list_pages { printemout(_list_pages()); } =head2 list_tagged_pages Give a list of all pages in the given workspace with the given tag. =cut sub list_tagged_pages { printemout(_list_tagged_pages()); } ################################################## # # Page actions - these actions need a workspace # and page to function # pick_page # get_page # set_page # list_tags # list_attachments # get_attachment # set_tags # show_frontlinks # show_backlinks # ################################################## sub pick_page { my $workspace = shift; if (my $page = shift) { $Strutter->workspace($workspace); return $page; } @Pages = _list_pages($workspace); my $page = _pick_thing ('Page', \@Pages); return $page; } =head2 get_page Retrieve the contents of the specified page. If no workspace or page are given the user will be prompted to select from the available workspaces/pages. =cut sub get_page { use_positional ("workspace" => 0, "page" => 1); if ($opts{workspace} && !$opts{page}) { usage("get_page needs a workspace and page name to operate.\n\n" . "You can pass these in via the command line:\n\t" . "get_page \n"); } $Page = pick_page($opts{workspace}, $opts{page}); my $content = $Strutter->get_page($Page); print "Content of $Page:\n$content\n"; } =head2 set_page Save the specified page on the system. =cut sub set_page { use_positional ("workspace" => 0, "page" => 1, "filename" => 2); if (!$opts{page} || !$opts{filename}) { usage("set_page needs a workspace, page name, and filename to operate.\n\n" . " You can pass these in via the command line:\n\t" . " set_page \n"); } my $Page = pick_page($opts{workspace}, $opts{page}); my $content; if ( $opts{author} || $opts{date} ) { my %content_object; $content_object{content} = readfile($opts{filename}); if ( $opts{date} ) { $content_object{date} = $opts{date}; } if ( $opts{author} ) { $content_object{from} = $opts{author}; } $content = \%content_object; } else { $content = readfile($opts{filename}); } $Strutter->put_page($Page, $content); } =head2 add_attachment Add the attachment to the specifed page on the system. =cut sub add_attachment { use_positional ("workspace" => 0, "page" => 1, "filename" => 2); if (!$opts{filename}) { usage ("I don't wanna"); } my $Page = pick_page($opts{workspace}, $opts{page}); open (FILE, "$opts{filename}") or die "Can't open $opts{filename}: $!\n"; my $content = readfile($opts{filename}); my $type = guess_media_type($opts{filename}); my $location = $Strutter->post_attachment( $Page, $opts{filename}, $content, $type); print "$opts{filename} attached as $location\n"; } =head2 list_attachments List all attachments on the specified page. =cut sub list_attachments { printemout(_list_attachments()); } sub _list_attachments { use_positional ("workspace" => 0, "page" => 1); if (!$opts{workspace} || !$opts{page}) { usage("list_attachments needs a workspace and page name to operate."); } my $Page = pick_page($opts{workspace}, $opts{page}); $Strutter->get_page_attachments($Page); } =head2 list_pagetags List all tags on the specified page. =cut sub list_tags { printemout(_list_tags()); } sub _list_tags { use_positional ("workspace" => 0, "page" => 1); if ($opts{workspace} && !$opts{page}) { usage("list_tags needs a workspace and page name to operate.\n\n" . "You can pass these in via the command line:\n\t" . "list_tags \n"); } my $Page = pick_page($opts{workspace}, $opts{page}); $Strutter->get_pagetags($Page); } =head2 show_backlinks Show backlinks to the specified page name. =cut sub show_backlinks { use_positional( "workspace" => 0, "page" => 1, ); if ( !$opts{page} ) { usage( "show_backlinks needs a workspace and page name.\n\n" . "You can pass these in via the command line:\n\t" . "show_backlinks \n" ); } my $Page = pick_page( $opts{workspace}, $opts{page} ); printemout($Strutter->get_backlinks( $Page )); } =head2 show_frontlinks Show frontlinks to the specified page name. =cut sub show_frontlinks { use_positional( "workspace" => 0, "page" => 1, ); if ( !$opts{page} ) { usage( "show_frontlinks needs a workspace and page name.\n\n" . "You can pass these in via the command line:\n\t" . "show_frontlinks \n" ); } my $Page = pick_page( $opts{workspace}, $opts{page} ); printemout($Strutter->get_frontlinks( $Page )); } =head2 put_tag Add the specified tag to the specified page name. =cut sub put_tag { use_positional( "workspace" => 0, "page" => 1, "tag" => 2 ); if ( !$opts{tag} ) { usage( "put_tag needs a workspace, page name, and a tag name.\n\n" . "You can pass these in via the command line:\n\t" . "put_tag \n" ); } my $Page = pick_page( $opts{workspace}, $opts{page} ); $Strutter->put_pagetag( $Page, $opts{tag} ); my @now_tags = $Strutter->get_pagetags($Page); print "Page now has @now_tags\n"; } =head2 set_tags Set the tags for the specified page name. =cut sub set_tags { use_positional ("workspace" => 0, "page" => 1); if ($opts{workspace} && !$opts{page}) { usage("set_tags needs a workspace and page name and a list of tags.\n\n" . "You can pass these in via the command line:\n\t" . "get_page \n"); } my $count = 2; my @new_tags; while (my $tag = $opts{$count++}) { push (@new_tags, $tag); } my $Page = pick_page($opts{workspace}, $opts{page}); my @old_tags = list_tags(); my %tag_map = map { $_ => $_ } @old_tags; my %new_tag_map = map { $_ => $_ } @new_tags; foreach my $tag (@new_tags) { $Strutter->put_pagetag($Page, $tag) unless $tag_map{$tag}; } foreach my $tag (@old_tags) { $Strutter->delete_pagetag( $Page, $tag ) unless $new_tag_map{$tag}; } my @now_tags = $Strutter->get_pagetags($Page); print "Page now has @now_tags\n"; } # Utility subroutines used elsewhere in the code sub readfile { my ($filename) = shift; if (! open (NEWFILE, $filename)) { print STDERR "$filename could not be opened for reading: $!\n"; return; } my ($savedreadstate) = $/; undef $/; my $data = ; $/ = $savedreadstate; close (NEWFILE); return ($data); } sub _pick_thing { my $name = shift; my $array_ref = shift; my $count = 0; foreach my $thing (@$array_ref) { print $count++, "\t$thing\n"; } my $index = <>; chomp($index); if ( length $index ) { return $array_ref->[$index]; } return undef; } sub use_positional { my %vars = @_; foreach my $name (keys %vars) { if ( $opts{$vars{$name}} ) { $opts{$name} = $opts{$vars{$name}}; } } } sub printemout { foreach (@_) { print "$_\n"; } } =head1 CONFIGURATION In order to run correctly, strut needs to have a username, password, and server name. This can be configured in one of several ways: =head2 Command line: strut --username --password --server =head2 Environment variables: STRUT_USERNAME STRUT_PASSWORD STRUT_SERVER =head2 Configuration file: ~/.app/strut.conf If strut can't determine your username/password/server, it will call the 'configure' subcommand to create a configuration file for you. =head1 AUTHORS Socialtext-Resting-0.38/bin/st-webhook0000755000374200037420000000452111743342534017330 0ustar kevinjkevinj#!/usr/bin/env perl # @COPYRIGHT@ use strict; use warnings; use Socialtext::Resting::Getopt qw/get_rester/; use Getopt::Long; use JSON::XS; my $r = get_rester(); usage("No rester server is specified!") unless $r->server; my %args; GetOptions( \%args, 'class=s', 'url=s', 'id=s', 'account-id=s', 'workspace-id=s', 'group-id=s', ); $args{account_id} = delete $args{'account-id'} if $args{'account-id'}; $args{workspace_id} = delete $args{'workspace-id'} if $args{'workspace-id'}; $args{group_id} = delete $args{'group-id'} if $args{'group-id'}; my $command = shift || usage(); my %commands = ( create => sub { usage("Must specify class and url.") unless $args{class} and $args{url}; my $id = $r->post_webhook( %args ) || ''; print "Created webhook $id\n"; }, list => sub { my $hooks = $r->get_webhooks(); if (@$hooks) { for my $h (@$hooks) { next if $args{class} and $args{class} ne $h->{class}; dump_hook($h); } } else { print "No webhooks have been created.\n"; } }, 'delete' => sub { usage("Must specify a hook ID to delete") unless $args{id}; $r->delete_webhook(%args); print "Webhook $args{id} deleted.\n"; }, ); my $sub = $commands{$command} || usage("Sorry, $command is not a valid command."); $sub->(); exit; sub usage { my $msg = shift || ''; $msg .= "\n\n" if $msg; die <{id} - $h->{class} - $h->{url}\n"; print " Creator user_id: $h->{creator_id}\n"; print " Workspace filter: $h->{workspace_id}\n" if $h->{workspace_id}; print " Account filter: $h->{account_id}\n" if $h->{account_id}; if (keys %{ $h->{details} }) { print " Hook details:\n " . encode_json($h->{details}) . "\n"; } print "\n"; } Socialtext-Resting-0.38/Changes0000644000374200037420000000607611743342534016052 0ustar kevinjkevinj0.36 - not yet released - Added a dep on Net::SSLeay since we now require https for tests - Added ->delete_page - Added ->get_persontags 0.35 - Mon Nov 29 16:48:45 PST 2010 - adds a --group-id option to the command line - adds a ->offset method for requests - adds a ->get_sheet_cell for Socialcalc sheet read API 0.34 - Fri Jul 16 09:35:49 PDT 2010 - Include st-webhook in the MANIFEST. Doh. 0.33 - Wed Jul 14 15:14:40 PDT 2010 - Make sure to install st-webhook 0.32 - Wed Jul 14 12:43:34 PDT 2010 - Use a more correct HTTP verb (POST) when creating a webhook. 0.31 - Wed Jul 14 11:32:33 PDT 2010 - Add support for sending signals in reply to another signal. - Add support for sending signals with annotations. - Add "on_behalf_of" to set header for "X-On-Behalf-Of". (not docced yet) - Add `st-webhook` tool to create/list/delete webhooks 0.30 - Thu Feb 4 10:19:42 PST 2010 - Add group and account ids to post_signal() - Doc that get_signals() accepts query parameters (such as group & account ids) 0.29 - Thu Jan 28 13:16:49 PST 2010 - Specify a Content-Length for all PUT requests 0.28 - Fri Aug 28 13:15:23 PDT 2009 - added put_webhook() 0.27 - Fri Apr 17 15:39:18 PDT 2009 - Fixed problem due to a change to the REST server response for some redirects. - added put_persontag() - added get_signals() and post_signal() 0.26 - Thu Apr 17 11:15:07 PDT 2008 - Add get_workspace() - Add get_revision() - thanks, Michele Berg 0.25 - Fri Mar 21 02:19:35 PDT 2008 - Fixed JSON dependency to be JSON::XS 0.24 - Thu Mar 20 17:48:50 PDT 2008 - Updated docs based on some ingy feedback - Add a customizable user agent string - s/JSON/JSON::XS/ 0.23 - Wed Oct 10 09:09:54 PDT 2007 - Added missing JSON dependency 0.22 - Tue Oct 9 10:03:42 PDT 2007 - Add undocumented 'perl_hash' accept type. The type may change in the future. 0.21 - Wed Aug 8 14:29:38 PDT 2007 - check if server is set, die if not - make name_to_id method public, as many things already use _name_to_id. - store etag_cache per-workspace 0.20 - Thu Jun 21 15:30:30 PDT 2007 - added json_verbose flag to return json + wikitext in get_page() - fixed warning when an invalid workspace is used with get_homepage() - support setting a cookie for requests 0.19 - Sun Mar 11 17:05:49 PDT 2007 - Added get_homepage() 0.18 - Tue Feb 20 10:35:31 PST 2007 - Added get_tag() - Added put_workspacetag() - Added delete_workspacetag() - Fixed collection methods in scalar context - Too many version bumps since last release 0.15 - Mon Feb 12 17:16:27 PST 2007 - Added method to return previous HTTP::Response object - Added support for breadcrumbs, backlinks, frontlinks - Added docs for accept() 0.12 - Thu Jan 25 11:14:35 PST 2007 0.11 - (failed to upload to cpan) - Added etag caching support to allow for collision detection - etags are cached on all get_page() calls, and sent to server in put_page() calls in the 'If-Match' header. If the etags don't match, the server will return 412. - Added unit tests using mocked LWP packages. - ~92X faster and they already have better coverage Socialtext-Resting-0.38/MANIFEST0000644000374200037420000000040011743342534015671 0ustar kevinjkevinjbin/strut bin/st-webhook lib/Socialtext/Resting.pm Makefile.PL MANIFEST This list of files README t/compile.t t/file.jpg t/filename.txt t/resting.t t/resting-mocked.t Changes META.yml Module meta-data (added by MakeMaker) Socialtext-Resting-0.38/t/0000755000374200037420000000000011745146143015011 5ustar kevinjkevinjSocialtext-Resting-0.38/t/file.jpg0000644000374200037420000023423211743342534016440 0ustar kevinjkevinjJFIFPhotoshop 3.08BIMICC_PROFILEapplmntrRGB XYZ 2acspAPPL-applk'\ы!*`q>*rXYZ,gXYZ@bXYZTwtpthchad|,rTRCgTRCbTRCvcgtndin>desc,ddscmmmod(cprt-XYZ ]L4XYZ t"~XYZ %KXYZ Rsf32 B&lcurvcurvcurvvcgt      !"""##$%%$&&'')'*)+*,,--.///012134445466779:::;;<===>=??ABBBCCDCEEFFHHIIJIKLLLNMOOPPQRRSTTUUVWWWXXZZ[[\\]^^^``aabbcdddeegghhiijikklmmmopppqrrrsrttutvvxxyxzz{{||}}~~%4?HMQUY]bgmry~p       !!"##"%%&&'&(')(**++,---/00/1222324455788899:;;;<;==?@@@AABACCDDEEGFHGIJJJKJLLNNOPPQQQRRTUUUVVWWXXYYZ[\[]]^^__`aaaccddeeffgfhhijjjlmmmnooopoqqrqssttutvvwwyyzz{{||}}~~*7ELRY_fnv~e@\1  r E  QX'Qu?U  !"o#0#$%z&<&'()E* *+,M--./E0012C33456l7@889:;Y<.==>?@^A0BBCD}EIFFGHIkJ:KKLMNLOOPQROSSTUyVFW WXY`Z*Z[\x]<^^_`PaabcMd defghi{jukllpm`n[oEp8q,r"sttuvwxyz{o|N}7~$ ԁ~hH*Ӊy\;ܐoO/ޘgQ:ݫԬ̭Įƹ̺ӻڼ0IlÕPɈ#v)њԑ&h'fmndin68VT0'P T9!GE %7Me 5a+d_4Fpt-R d G 2 & !a$/C]CyXHI[j }!7!"##o$0$%&~'F'(()*y+J,,-\-./y0U132234G4567q8\9I:8;(<=>>>?@ABCDEFGHJ KL!M0N@ORPfQ{RSTUVXY?Za[\]^`%aRbdfgGh|ijl'mdnoq"restv6xz{c|~UV Š!G{䖼+ jZզϨNΫQZi )w§A{ʺ[JԛEٟޱdAv6Be %7Me 5a+d_4pt-\R d  G 2 & !a$/C]C+yXHIP[ }!7!"Q"#o$0$%&~&'F(()*y+J,,-\-./y0U132234567q78\9I:8;(<=>>?@ABCDEFGHJ KL!M0N@ORPfQ{RSTUVXY?Za[\]^`%aRbcdfgGh|ijl'mdoq"restv6wxz{c|~UV eŠ!G{䖼+ jZեQϨNάԮZi~ )MwA{ʺ[JԛEٟNdAv|Be#9Uu T Q9@oL86 @ ] % eeMYQU" !r"W#9$,%& &'()*+,./ 0162O3j45689A:p;<>-?n@ACRDF GgHIJKMN4OQPaQRSUV1W[XYZ\6]y^`aFbce;fgiDjkmknp8qs tuwnxz\{}Z~efu@ԑc$zspvAӱd;˼[̿Ywwrj?ϨvA֞/vۼEy1DWhqpcS@- VNBt e:4#_desc Color LCDmluc itITfrFRBnbNOesES,fiFI>ptPTNzhTWfjaJPtnlNLdeDEkoKR enUSsvSEdaDKzhCN LCD coloricran cristaux liquides couleurFarge-LCDLCD colorVri-LCDLCD colorido_irmfvoy:Vh000 LCDKleuren-LCDFarb-LCD LCDColor LCDFrg-LCDLCD-farveskrm_ir LCDmmodV{textCopyright Apple Computer, Inc., 2005C     C  " }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz ?;--P VZV蛰Ef1E\p5rź0˸q-5_שʒ}J1dX$%4,D6pLc>U{&S7q%D !1pB S 6W- /͊ڀQ@Ƥg?5e&dߐni7" 1j`oǹU\Vū a8VUa`n]qKrw_F|]UKgQGʧN^][ij=?_0MI :38'? ,myac܈Nv[+lVƢ4$_/ocҿeJm,ۆrI=xƋ/ R[U8ڮ\L@FBH +k  qԒQ0R #L)y@>Յuqrz{S#& jιm%v' ⸠M^]TZEc0==1rd3Pv$tioZ@"19lT^11pTvsJ67KSzIUd,I 8V8zcxqUڴf=ssfHW+3jUrۡI+#kXsӚIECW':X6$LcQOjЌM$Ber:tV3UL4e9{:ȡ]Xdy;b9,7gscT2)Qs:U;]>[iC[Y\ ֹ^.롺q)p@ 5QGeۉ73IMQ1*#cZ0C[6μVgF|ӱarj~줙9L`03Z:qq!`WF#qgD-!7vZFd@bKyN.]6X׈©ZF+GE{trF7Ϛ|?Bg5Ag`~k{kȦA@\.%. [݆]kt* N1X3=+N3w]|b 0^[{ o(j1 11_hdCK| #^q]k&pȣu?1jŽ])^,NGǝG}x;K9kmBex}no۷'|><:sK!'72.>" joA v068*01>>$iZxZc=l?Z%[+o0,q:a?Ҙt OQ6`:>8Ԉlu?r^>NxjJ[@ɿˉ9TP"\^88(+e1¶.Dj]9Wv}JŚKx,-h#0<6* rP©k,LTբ9~SJ']}EP;͸YpIo=J?Ռ̤QخqAʯbA x,,b~fgp&-u*]0M;JXqHʴFU(: ʬ28ҫlBhׂ `nh'`%seBĻW?loc[%yT8F]1HІr;!r~^Iڴ,$6H:rKxc,1 UjЩ`9;x*VTci1trݪԗFi%@ T؊ɈƓ;G)%}s^0U`FXQw9cیS&*sxx:% 8@3[s4TzV~fPgD.w*2NVuƉEMq9کyģ{dʗi}caJjjn..]IrA+ #ֹiA^uv CDUOOyyucsHV$QZy`cs4F10j[iA>qҽ%?CNJJM24pvykiK* x5$ $/'NJϷ58&K_pF)M)g1~bO&ЎćnӞ9;@k!Rd ZZhP3<Vq|M_.kߡiC*X)ۊĬInz޴$`V<&AecW}kvQc3`)=+|{f}^mJŁj<:o:汕8zҶBEh c uvV[2Ї%(mVt,Xw)⧉tUiFsE('^G˱fFnH[AdznB!TBb6WqvPL Ʒ ݌dִuΰ -25 :ZɿO[$\k޼˹'޿ յ{uGҼǍ?kIo`V aJUw?גha1⼇>/~5v]H'=8{(ajmrsZ"1_մ<3Thly`w^3Zfqx+4.x.]w$*mOǂU2(@Ċt*ICu#u9y?u˨lm2 ldvKIOYdҾng-zK-$qeӊMGUo!o常c<]z\7O R>GY%YQp.%( W++`Tg?$aT*Z͎ȓ@u NplFuǫyem*|Eo⇖6py*Ppz3$G͔s`(M55G:eBUǪ02_I$vI'j/j!te[N 례9+1o\  xHfŅ%nINWx{ayl=ț6+.)-beC?xWSZjaS_<;C7js";dیz}!oi`\}VPW+L'(ry=<l[vwL:fo0u\y-Wb#="aTecݫڏms+Ê}M<)3is_ ;UIqW?oݬkÏ|Į|zםK6ˆ6U.1gm`6du}uZyTڙ+omHM?]gT#ab @%/ԯ⺍B$\%1 t{W=>GN姗saZ e%Y)X!J9*ݜRJ/@ #-w7"+lWG'WtJ>g׊RF1m=V.u,,$'Xa~2 XbM},s^C4.OEy$qtHUݞ*%^/޶Ƒævq^l[쑱1SIwj#f|U #ⲅI9r'4 ˀIW ݂:ʴA W>*0V`2Wvs+{=I-ݜ+0WJe2N20Iq`@T?_j%.[1_JoR wS$PtB弇I;~U-(UI댐9Qw9%̍)s7g!NNz0<`}}[W/k |Q6`O?JiWz0ssTO\N=A>|rurCӛ̪~PNqMsQH[ǚp~ʷQ#@O\ň}^\ SONnߩP0>c!`+ n’ j$xU Ԩ9cOb۔y`잿zg- 5bHS'VV*]hJŸ"+H",B _)Oݏ2[_>nfqz“v;W1kܿbq9*tdfU 6bHF=1J.R:sCO &@p~b:U'FŬ6Ic&2w+hyӌyn}@ϥ6~ 2N,B$ΊU);y9Uɩ=:bFYYN B_I4K573sMMGEsOClk/˂82Y!|@gV>dFk+5?'͵-|pW_WTZnk!>=*8Ek&%~ gp:U{wxcb v_ZEov..,>ZobQu)pprZ>OI~!xRk:o;ebmAdiN<_}+wO}Ҏrg^qmpG;{Wfٜ5\_wah>II$|3sj"Y%0dc޸C3G5TqysI<˕@8j)8:V1f֯]KvگPf, wQ+nj =QYewJPѓ6ъٴ^cxîꖓy$uVҲ|PYg]F[9nṪ>1Ĩl&AK6)r_Ɨ̥vRjQlq}k޼5Ž֨L7+?7[.f&2*]j]QxgSwǯҮ(kzechzOK<9qp\Z'q׎zsɶ W1'd?<My7,j:HFЮr=ykߍ,/ BA|Iٿ;B[\jơ\oBQCfB#!䐼_HMF _زxPΜ/B~ x5=G,Y'':5nOɘV̕/+zj|cc~kSOt6ǦGdNb`2O${ ZӼaLoPp;@nեo1hwUޟ2xe!c9I@+̩/TgNmo#𿀼[lc{Au9[c`>OQ_B~Gu @UxWh_&5uH<>]qD##qԊfCJ9Pf92`yO9wwZ8)g+sSmomⵝPuHTy{W'Sd+ھھ2W.>[L&+ŚDڗ|e_ i mJ =++<5:ﯗsiJAomv>rKE21^Wbzmi kmMr7WM5}%e'zVCӵ`സ6:u8oݒE,q)-:EU(M\. J- Ŏ[Bᇖ4N?ҵΘXz(n4-F?³kh:/m:Qm!EH0^=fDk"uA~~Pgi=:g'rWmcWDh{d[ݨ8qf$H^yZHI;QP]2-G1њ{)' n3ҡLVs?W{pTq#&fx(ddV&"t=FnX.F2:Ұ&ngd::5{qѡ]Ӛ\J&oR:)zk336ҽJ쬴ȣg gEѼ͵A^wYb1 LkiQ 1zJҋJUmoʾ:-kVM'Jÿ*(vqƼ~P(sƗ4 !bTddqS5-S]>{R78<j;I8ե:-7o"yɎ؁+\ $#ҹyu]Če\y?\ 2R{56uejʟPA[VRa)pG=fItJ G-I4tϨ_XOnE^Q$s2]1|?* W4Fӯ/yjt_38 \\J!°K,yP%,,N}Irȉ2gU$#~`r21x,^In*<ԱB>f&w[}ŒS:wg-\djH. w/W5Cc>:S#"X^dӔ]}^Nm,ʹ)8dq3ڰ4N s8yu! *coy`A{Eل=vEl> 䟡Wo`F{gӮAV -׎,hUpǡu=!`\9`kedƴբĭ䲢&H8 ˀמ~2EH^sR?%<*(F]M7=UH,U|W+f)K"(;cKfʍ(} \f%1}sR{;̺XJ\#.oգIW!5EmVO|I鎕]|E>&pXQUޤnN>'ʼn.ĵ$i'*2p $yuI^5 l@#y Tr_MY-V|ޱ'קkcwd%zsj\΂oQʳj:AY.>UFHsX\0stZO"٥g7:k.7S!*_1KH2p} c*E]ue,s58=#p[+UZ}漎MZ |XܰH5kk/eq*T_3gNJ#jMϽ [*jOA(xa'+ԙe|OOp䭸f4&sM54 8+D(]b<^#su\P%yhwG~w`+HPibtgI=Nqƽ?NMC}l=ہ\z~[(/zԑHcx, [2heتWeV5~g_wyoiWK,቎@\t߃D2X`r{uҿt}R NݟPRVEg {`1M;PuXpgb7c>Y5eIUV o2E*VWEe`FA S-;Ú,!pwFrGLI_CiS~7<) Iԯcj.88nǽ|w5ޡeVqM}|PwxÐٰ#P.^?ƸK7Y 5k;-ZjN2{+W^ ڧ>k 1Go>? i,n_` D xNSRӭ@3]g#rG6$6A2pw]60Z`k,pç\d59zcXƌ\_q[uoDco,{w+2mTHJ+ 9 } /3 6r:t򫔥qqif.cǁ뎝=ZXdP9M,}V5;n~EV M(fGaF rEtM&$t;No[GjѠXNg+t1ctDOk`ɹҮQ(y+V1rkkFb)cՋZݻeYԞ+֥YjW#/-X[o#Vu1Psg?ur_̝bϬk7,t\+/5Ljm?(~Ui432p*3IY=NX.\ dY|Z#3Wt-`s\v:+]jI4疈/bd!F:\^8VJ^Һc+KB,C%\灌5J{.x`z2H;F08fDbYG*}Hi}1R 6炢4V"4s8;`WЗ.úJ[nfMu$T*1V!6;=ߜ9X"G8dydZ&i3ɦu4݀/9PhLn<e,3?}:G>UR1.R^n 9u#ND\s&{棷fi>H]A˭<`XR6n2hG2; +nr.2vcj#tgp rFMW!;  t&ŐpWv󘩧Q2|8$Rʀ*ýEó7QD'\`")9uvۙ_Վ$QPz܁@[s\hZFp}k]e2k+Go/ nf[kgsU`q8R2gcr*e1sIzRo!oJbf]]H09^KB|xk*͙ӂorͼ19Tb8wFRe˕#3^[0+ǭs ٪9HZH,8Q3\MԗWNYc=H98&溕|WXIbܥ>[jX#O|s$Zj20qz673KtWj:[@\}+ӡ^m$e\ h,X H^aKQ8:p%e6C sc\Ms䄸c}#MXqgں{风VXT{|ՄK*zD&-H22"*77!ϵZyQ} F˔,ޝkHc)G)yi y1CsGdz֊FI;9)>PdwjܔzĄN9̍co݋*Nh|gq\N]Y%wse('s^Us0,~t .y%YZ| 2>z:֤wB ,YTU{RF&{ jO9hbO<;\U&`T$CZ )9#ּ1uG9Eiy{SYCu9'W *.G+H% 9䖮#N FX2 ~UZ5X%?05CsٞSu<= tv3IvsbiJ7:T6i9^N+мAPsڸ} 욍_]·Wxyh5a=G=.z$v{ڼ0_gmS^ (b=]__F_⚵_SՖnt "G\ƒeVd8޸gyÞAYCc0p:~Z N֦=#=J{&W]uSt3wm2e\ȧ*nyz>vV0:1a-*i.VR-\ s5^_𷈤W?+}m2P.-eIaDzq"eFEuWě:!3D9yNegh5ΰ 3r}:Wa,V4i% x?O+|HowKҳM"arMZ_SS}YK0fl3՚Y;t8=ίŔ-cڴPq;(ҍ=7-IThK]]fnGZ]3Wr,Eu@)_Ǒ{H&ҹ1SbgD-C/7QUV(!YdBO ZK9bu sWJu+XSj~d7MԮquk+5_ɹo`|tKƞkn~Mc=$#K2k:^F<ͻKrY[ʹw/J箥eclr^l +.K9 $g柳% OP^<#5ܱnx98Z[1*Y'7C!n崄pKqW'&<"K'ԞQ Wkt$Z]5֥$xJڂCq8R⼷]L3Jd2|pH\_,m7T:uIk8jt빌N[7bTRC!l jO:-+IQ0q:U:)acszB#"%(^Rfejײ SХQ´pA4NBQY;zUa;ivV&z? YA442;β@EWUl->ҲfƯq-HY֨dcTx87Wo'4aB(y?Zu;،rU 9^ad=QCZFўݣjqΜd ~P1WKO{|yb-v;WhFRI&Հ+ L'r+wIX5e8[> /iu8 SKkF٩YJhz <6\JS㸞8m1#bΏ0Vxۅ}Xz׺|0׭-MOi|åy8h-9q֮QMxaH8[vĶ>%$bڨ̟Ï;KSi*qV xzoE&U+$,Μ_cގ #v_cCk$|yN `X%`рg=+Eڵ03<ѼUsgv` ʒº451wi^U +ˆ!"rQd A㯥km:N$$9 kmu-ۨ! q($2^(1LۀXpϥ8pAs)`(vnyO6J*Z3gF]/ˍDDy`I>6ջ`z9sj(9{T}VL0f ֪h3LA ߽eI ȌFOJi,T\Ng4/K8ٌm"(8=Oʸ'7դJ(۞sy}G7p@}һeaH"V;-9)Qk<%sEd{r.Y!6 UKdPsx-*<S $51HNIIJIu7 5tz֬#\W9'=+|Odž7k+)V@M]mJcyc(ګ:S6Y`9"m*Kⷐik4r* !cmb+40v2i&y+9nwgu5ZlVQPѵevG\_Eim1x5eZItb'2pm+EjA 糀2o;N+cKȭ]c o3y5i|/e*1Hgn#Xeiֶ mr9=);6~w:֢i"_(䏭j:vv4G31hȎ%&J*8yCϗ^oii.sGVO]7TOMyGǐpWi⓽iE]4E쩜ף'A vN|?cI(nWN)RSqˈ7aKާ^m?cqH=~%"Ĉ^}T3< xZU}Q}ul?L[h,r߭Ay"7?)}9ܮ.$pzu\!=`ob!}Ghݽ2夋~l/qr7q[&4 }`bkgn.r*w~C?BDSXeXа+5ǽO6f."<89?]x/}}mSKch!a|AxB[x[>t>g FU[Īm =A6G_T:%p;V8SANG c.uk}"[t'Em9z_5++]<;m !p͎57RC`HsVfXlT牢l )>d}y)'+]S1͔ؖ(>Jϖ:ø54g dT$z^Eٝ>ح9_@Wm .ĭk u\WvlD^Gj? )-˶s#oƸqprwZ#]cI ɧ7|XtWk62|rˏ ghıx;Q9hp@#j"i#8 s׀J)ϣ".iox#g.m,fnc-c&Tk4:|`6Z߅u-:Eaa~cgcIխf[g28Eb#Mn5;.mݰEarE;1Qݟ4:\eHR =zO>#pc%~Q2p=EW<+}d\UqN++;. p^/kJ\SjyWJ4Fnln}"11gWΖ!g2ZܼEFvJj1C `\u0";a%l^ Gz’h w9[E%yc+%Ar:ݤq7Ҋ3Ч%i);UAVkWx5WZF_)p@k95&Gh"f5z4Li9 p+PhIVJ 孯uA%)mkH$wFjڲ}ts8+OqSq)Dv@u\ڭRkk!" sZxJAct-w"C^_8SR[zUP%r ?FIu_Ccmh3_Cb4_584s)fepr6 pO`+~ϧ^:G%6(0p+}k[uas-bT{?z5-:E;H5#Jg֫xnV?*Uz: Qp1+ZW;mJIH`5' hg\\^$m,Z5`HA'kqdx=yW:Z.YK:<'Qϫx܉-mimL 0Sy9oh_3,]: ޿sxI' ^|vU WC )M 2\ݶ*O8o tgo|<e]kx{D SH3wjW+_y)|/k\kao_0v#װ8|7.]%U3vz?ìH J gfO "P+GQE]O^|9ȱ!|²Hoh1nL Z|[NmmkelO˅9)|uMٮuFbv!Uy`k5[9aUG7,@j_<` xO%?Jyu+Gq=cg$,+WO,6ߑ<֫@=  +Ծ*r2b7|RH;rp8#Ssjan[nԕmת\h.5DBXS9]|`բchۓnĀg^n6R3Gz(JI>g"k eru]( ' EYAH6ZUʱ94[X;= <>ZqؚE6]Ϸ7wjk6sңmG5mKAZ׿ޡa1\0*?J4՗UĆ)&?flu\u^]N-[{H6黃ްjp5adя^tvoy7%U㪰[$)q銩q\^o-ln<6+xxM[:C ex#~F`9'Wkn*.@ qܱ/^Tq1(*l[u0Inu%jd>E[tϺ =>yگA4u,p3"!2kj**0_6uVх1u5}rwO[HyY54 ǥt&ֱiį9?c[ R1[-u;!Y9=:Z| c7w y&2I5M3ЍjoH"[p@-2!ʟJb`.Һ 3tsz՝h'dӺ=Eб5 d0ʼ@u{dfCƶۗPyne7)L`7޼Qqw)UkrypH̡ٱA wqg9c :uCMY2( YUOԚ%{}T% ϙM5{!s-F`ALq'W:?ROpcpWV4~j,]Ê Yi,1PqyUQvVgۛT~{f!"C\|+3P.adbm\:WnѦQc% ׯx7z լQkVkd_v#׽r)E)%jzEѻimy tm+]*1_XF=qc(>5u۬8lҽ 86:8ۚcC vun c={4Rh's|7[]I^oo Ze1OFX'>oN$kx]l}9^T'=iX4xϙAbL#Iv=}exk&5i)<׮p9}_ϥzM=PЍu@Vzn_.) ti'続^چFl@lW54_Pjڣ`$Y.u[.&yy0Ζ cbtqHWWV) +3o{H Ou4^p.3׾_Ïk]ƼA9 ϻ[os]GBm:VY$ 61ӯ/k*};Y?UbZӌ[~]xUЪ*$<695|K+tf,J|Aھ47Sֳ-0AH1U%_vH$'",% s]RPrZc¡\z\rIhWW0F;ƨFW9t !ć#OJdNqAUB2@ 9jН8Z_¥Yşj<' '؊=Ѯ}8=ky$ ]18 0>ߗZ8&K91_WӸ[ĜMry1Rr0Hu3 vrSm;=Wn3>&kӺܬEIC$}krnNI`nJwV;I d+nٔ;OD1\l*=_^uzL7|Rx&'Ď+PTzL1`ם5;qqRʦWur#>3I=Za $o~Zp8,a:\+kuNIS~c(# 2qJ5ԯ+5vwi.J˕U2/66eBWE{"NN松-l6n@d`::ԵE3@ f=Y% d~HjFT@w8ϮMrF_SU:f̾g;1㿽q!cHml8YGCW?ܕVR@#q5e;xa7s? S߷hz0s] XWy>J /$UT@ 89< VfsvI6LV\-?R=ǵrd9;;T\hABہ%s;$vP꒭ݔKTe+`aѽb:sXuS\ |+Xi(鬰 ܁֯4qF-)ds_,opq%YbK7㚒;̑:PpEtC/'5'Awzud$LDg ;cz{W5Hǧ*SkM$[+o"cq]kip]N =PBN{J6g9c3llTOk⺴%,YOΫʜN2wV&J+ C yQYM2u;J>o }FOBMd=5m6bqnEU8[+XʚWlTqQG@$v\hBf*+"_ZnG'~N,T՞vi*a۬2Ƭ ޼9( OȯRc.qp0(Uq3]^ T~gΝ]AZY`-x䓊G͘UrCJKJ9DXO[:̱rM}% Z.KWj|-)Kɚr3 pWֱgAK+$\IhTm*.Ԉ )JL 6޻qN6>Waw&S,O1rM!i!IjLN2yJ_݃W]9P۷g``t=iV7vOCDc:(qyK~쭩I9i-p]i ]ʪP͜Z#Iu8 n,Z믈\B|s\(?>]GC,L.뛏x׮*E (׮Ws[ڭBgj=k[V{(4`;1Zͨ^Pil&HZ[G(Kʄw+$T2y,Tec}H:ծJjidq|"n9vxapy7Z1-Kt@Xե\'FnبéY-:O;JV˴bݮuHmR b}6 #}+ Tb~Pz3r[8`)P7V^ĕǚ\[3ZcIg(\m\Ij:;zy1ޒrjڡy #͜1ɭ;MTRq85S\-lٳg`fI0J"(Վ+;OG%1]=>Pa:WRYjztֆHN qZv>='D V 1ֹ}u4,‡ :A9\*tw)*N* &v) 8Έ$DшllJ4G9Ȧrg9b, ;ޜTnzr8?v=эA2@'f($^iSˁqMԺV \ Մ+"(ij#.\28~NbIFL$dNTcq{ٺ&ۧ\UE^ձET eVht$t 1(GH° cG-Ś?hLrF3\"ܗ 1L χOK5oOn q,d#$*qSCi qߏ*bgit/|Xf=xM}9m7,c~1Ѐ"uX9ZǻS3zxc[(#iқ'e ;v;8k]Ym}I]ۺn 0I:0tG^1"T ʭ׎1f`~\Z<XV|sfޠ8(ǛXU&"cQ~ՒK e< U&LcoZ۸}c5r{ 1KP 5qe4$m/\ g)aheA~ W zRZ9AX9\m{,2۩Oddg#Yo!TOC&5)=iήѮ aX Lsڵcd}*U4Uk-![fz EjyjT`uJrdQx$/_sQP[#qxUfBWid;qҐ,H#[9rORSigIicz &ې91Z3ێ>Z;Hń[ QkltO#p!wMb=YA2`ޕNA (V0gQ1=A9FaQx*yBIP*mcD15kbtSú]L#JOb*Rprq׵U<, e-UR٢%ƛǚ}cHrj1"yOCj*O[}z]NJ5Yv{1Ұ5Y]{;qL@ɹv} D󀍳 "Tsԫ'.Idf5U$ds&PN1X[Y$ %+f8sMisKqy~n 2H⹩An1YEYCuԩEjRk ø8Ͻ})}/W |;wOZ༱~zNOv]7]Ν'9ho9g⯹TO]Ao203ue7N 'i3o*c?\|Vn?y ';>|#շqe?uV dHWݟp++q\#5jiK3Tx(侠 q8tw*PHLg5̘a];Һi#͝K I+=uZ=;Ӌe)cdIUl+ dV=jёFsC񞝪IDW5Yr>b?&th`n퉍9բqtlx֝"@b9Aj$ä¡n$Ҩ\poxvHJ# P|6x 涱GHb>ckJA.vsW4,cJt4[Wdj{ܓ \nl2OjeQӎ4{q+A\ORPc ɮfzVUdi$R>b:ڮ^\O۠W<9[OE^RH< p+fͥ%lQY6 _Q+ӯ.^i8! ]?UlF9] 2<=3\̛w#5،1ۑmQQaYs’ @0GnًJh35ٙ9$`9`95/c Cb0dFX^f-sjīgQ[]h+vg_]N2l|Po@80 i,1#{UhM*/֧fΤ29.T188(0T0?COUHm)4ǐ! rFj@72;cڒCbzgUF#ZK˯kOTVP/ˆӊշ#$.:V<`dHc;Kzs 'fvᶼ!`vQOojM](ɧkd$ ~l2a޾9-fYҹ*ta"Ԕĥ1nQBA֞F·`y$ђ8D nPeE1VzTnR3lo7(swҹVd+܆Di JA냏ypmʸc|BIeg>@x9f?(8 >STpѤndM9{y`:]J<4!3̗GT WYu#kSYSPdzW>ȕJ:bk'@,UN+˩W XX/ uǭg\I*!BŽ m򨠁YZ3.ZRϕm[S&ka{ ;qY-unduoT~ oI'K}Lz9Xd#q6Cz+C^6 |bȦH2kΫʎ pJ^T?Yo ޮNf`[rp};W|AWI)\";g8<Vkc<ʕUP^H>)#JYAvp 9# b] lJIK B/EFm὜i% ݲ) '5[Mj$py"Kτ^?ٯG{X3Hm#-!,drG|0:AoXia𱛎x *䎴)ÞC J -G֐nj-l)E+j]ll/?sy}Wް'0H9#%@ڌnG,"Zit#!$W,\K5{9 ۽Ϊd3yRJ'ktOè^Uj!ڮ?¼),Θ?.<<]5^9N{.݄IM:(`edqWf"2G0 @Oo,^R D,~pXr+,J4# a⹨=sQ4p ā8LcjŘ";>IH$6w3>QZ$/jPI8H#m=wc*dudݤ[;.'VGj2s>)kȹ+rWǭsLFf=k,k\#r1]sgȔN )R` 4/F2˓Ua*nڣYxy$SpRn{hjxn?<=bTuXb CZ}R2 X,>ٶJx `bI_PuXHvI1Ҿ_hJMZ}OUI9 AO" `Ndf 3W|Gi8q\ O0g=WNS *VZ\l[}`Ւ+-k-]|9T"#s>>&Xc|0Mq-Cש\98z쓭IZK?O~xv/CbIr5H<޼1tctL0Q3"GJi;V u]Rll K*X]mbb%ͤ]$#݊/FJ==jH!*4c"#1`18tY\i~_J[wpS {XsJqx$ȧr$`@MG"S'k! \d},+D5<*;bYg.3IlY>~a9}i~}0w+6SctML<ⴢ۽d6Mu؈HQS汻i>O5^9ڼ!EA4DOzRs *c,p{ԵcE)7i+,@&|)H#}q^a;$] =\9+݅3c(@J=Z S#bI9Vr'',aw!H>Qvkm=K8tĹ݄1:+d,+0]=U׃WJ{ZǸ0W=EbQPDbn@_UB:P&pw/&;w`ȧyU*HI& g47RkU'^ÞN`6l^s*"@r>W5Z6k2"H+:*2jl ϙ'_@k".@um/v|EtC2Cּbgź*D?ƽG[BGQ7JuK AHuFk=8![ ^ӓQ T2{`WBgZ OcM-}n18'.0W_J H.Zݢ@x8ZUȟcfyg pa!u*8EMM-GW5z.]׆/9"݊k/[&YZ3^ۿ/2O\Dym3|?.7Rgrַŗa=ڭ33lO5 ʢ_ 5VH$#-AU11k߅Zx,ZF|6rDκ4T!gO25=1umÆ,[繯ah s z5߀[.,o6#yqV=r|ˀ . bzԇ!4.PQZɠ[Z/ Y / im>klY1DĻdۊĮk&^1Mnx 4saWrV\([bN6x'BVr<[WG Z.. 8譐9lz(V eqh[YYJ<:WB&*z9YTP psVSLpc] :?xcRTjxoaaŶ dnQWFKsPE+az9`[8]^Ԝ*KVmֽF_+$j]h9vcj|ǨxOUAwhG~EͮpBiY6+h0ST`Ѵ= B;D5?Sq[Am&y ^Eb6'W]ld^,(xRk[vS|FIXÑ f vKZ,6nx'tb|ҢkHۜ9&4Y_C"dJ}8g1=j,X@좮"3T_ybFќR0mqgnk~[ \{W3l=6 بq][  ѻ;I =5gx1p;)gk%G+s]M#^Vp#5?"g4M wHȠ1'crzSwlynȉné{$jf%IƥOfѝB\G$:j{KN|A +Jڿ*f;q?]hmtrZ-FU\b1WJt[E_ف[xwPq{]k60no;XX`A1m]'p 7To1>U|VT+\H^2O@F$9#9?|umC:rҧ*pRKUwYOHm. $޿o^4p|rȬ_?ֻɔ!L=k|M0ǚRy:Z7~?ag`r'˕Fse c۞[& $Gڽ濭7so$}֌_.vX YH^HmQLVzl|9t-Z90 *s$V . 'VA@VU|n.SPH9O$g 0X3XR·NzB]Gl%/#ңu6Fv:Utc6eFER7MmrA gּPAvyW)5IFR"I^3ʧJέX(p]ޗe%>]A/Tc,\˃jm35ǀ6/͓s9=Ϊuu$RJA,1R6CDry!\|ydzuN5rx_Zjiytib[|@&t1–,z]4TK՝RF⼼ZQwԗ4Z<[P#Fs^]NOޓOj[ F/v9Ϸy_侻[xmЮRP.Z1b؃o!?|Cum-5&$s^o dk(yh B4-/NM\b>Ra.HK?3eT>"@ݢKx #l; 9=imeh7nc>GE/$(8d+*zҽ(S]ns5 fn&-9! WJkUd G wi*]lՆ \Fg< Vӆ>D9=>o4p(̀@Z)=^K| n84 wF܌}+\FtB֥ 8Dzh%= Žxu t-> &ހVO@+G0݌Q5efj- buڋtxwgh\jUߖnLx\ߒ):1Ʋ7+x S;Vo=s֨ȟ'F9dTr͢RQbÂ5,_\犊DTɦRhe^A;ևPqoC3/P#RQx\_cQ:vG^!*H${֭+ s\eO0) vUU#xU *Gïs*G 猯Zɨ;\G,Aj2*FV8:zVov'Sg$O5Ĺyn2:׉wb mj2c?j;D{F%9;ԚRNN3֬ep1(K7ccpjGȲ *Ad$`dʓ9+lq5}lWT~L9 w!\g9vb,9sSd;*e3T.eg7j-qW Ir(4h<&屶a+ 8= x9e é9sy_ad#^p6؎bq%{PjK# v3sE~b5$t!L!:$T\T4 v#PxaV#^lA),;)dgXͨ$GyGGI0w)Fp 3W|GJ!'GA^6>t^pFӌ՜Ȱ@cK+nv`Hwj(6ة9Lf<犋6͢ L',zzcdsܙWr->SyRjXӊw^4d޹* Y/Oҹ]* CyTEɭܘ),Ѓ[WkCԄvp*o2@O͜ ?[F`zsvQ{ zeG.1׷jt6JLwRԉ4c~t_ k:яӶsWXol6G:6qo;3Tװ9<kn̷W|0+r ]:#@rWrZqqtOzaTH.|9dދo!/C|TdM~q*/= Zv ӦRN!xQfcluFYdyѴһK2FI5]"0=r;WGmy"Ff2;WL_BGrUgp4,\5fQ!X["M(lST=z,[Z\+H[q^v4&;\H+fX'ifaq\F6׍9JcJ5=V8:ƐF1NcZH wєGJԂ\+l03[m%c^́r8 ހg?jZ n~{}&5Ym鞞+J o-r@+ҭJ%ڨ`n;wB݈H~,`?:v6E>;%2 k'Y-㘠=߼72k#U(B|NNK}+ӮO\=a,@b%.xȬ{sӭz⻍v1j6e#^xoPfWخD6vqxn\bl%9<{z҆&-̫B F3\jw*:[f'Һ? ԒzѾZWȸxᢱyWhsO=vq1vXZ[p[ƛU`­ I>*0q.uf$ `@PTd҅\Js$w!TslެcdwsB2vnsXͧ8VbV[X JO`7\K0%twܽXR1!@!sshb`[9"4̝rsǽ!a8]|l8.prpjwzRס=2 !gj" RJ㊠pE;+7bI])ZN q4ez&U t` rۥ+Ѓ'֘ *ya7i;sީ܁mVd֪(0[hWZ]݊stpqUvH*fcvC ?tzV5WDQT.Kp@fbrS]rT@>JΝcrnˣ3$11-!zOz "|>2 0k)+79jźUNV?bavSQ|§doDY>Xb^] SЁݤK JE +Ri.U)Me O?N(I}~þ}Kো5f@j^|kCNÁ?ѳaW̓q5>(woeرyq1㩯kUWe;#܎0I|zY_u+3n"X#IWBYd$ݫс[v~.r[#+1t>ַ6s^)$T/P =+©䟙:nXtVFʐqzwj|ms"3x[stˇ5 ?x%Y8xU QʣTsNv]9EX`DW;$ Hí#eM_3N)?h $n%^;"rW tdpzW/"iZĥw c*9eF["93K3q957OrZ1{AAz lz}Vзd\ӧ}M"CHޕw6=>kDY62U5ۗ͒<5F vruiGBgIE "֬_Yiv KXXtÙBW:\U:_u4u=K:jū\6 L󎢲/&%\JK6?MkFuU9ixuq>[&dI0=^w*) NU < ~l(X_[,ސ}o-.װX{IY7PUs6BP+# y~!ygvNvH]qtFI`5 "0xT~L%=+[eBq8+[nm'f 9ִF]InG?y؊+h)(\BHZ֮-[1(gG`+>I'F-Ѓ^lb趦_¯I@nH[GD,u2g*=֍htӫ}bȡjY ,|MdB@aFdvO*gI5fo DDOVaq m炠,BN (y{VU2\pFsڹjZְwXw:h*F=Ls\.m/Ӆ,*ÿ z۷ˇ-kصmKt:Ó=:}3^76Ri1]֧u#xb e|6jbQ/NI-Q]X6K">z/R{ WYD]6] ^19Ѳ^|%q?O$Ls׵zowikqF3*@$y=Kܰ9>X<-2kLc=#Psf*p~'})˼|#/cTD-N:kp$;k|_'%JȾP9uWǕz(q|W6QiM_Khzy+=ЊBJU=1=)/ nRFg*0Oî0vui#DHvzUR9ǷHI|exӕC9ԝa,THU+~bDybWS&r:ܨF bwcZ++HUTq\GM9PyZMVX58ӞGO}1*gQݝF)2m˖kNYS aTp՘g7-̢8Y=ԚqԳ)>W7-Ǐ*fxK|z ]Hg8O*k>k"/TVkktbRҨ,23]L^N'@R =LTygk%?)GMwWvazs]vgu1|["Gcϛ~Wm ^/ ~7 |y(9Y|]D_}UIĎ0>O?I=?ywl J_cr89XJT$Jd?Z?jFY BkYcWI!FUKMd8 exųV4[+lW7$ ҷ"i,JcpÀ<2+ _[ /:G0O__c?m@Ӗ ;k8)&Nb57N#Ooq_jMek6oFX< r@ڊֽ,iP˸ÑRprQqᆱuc?V?ma>_Tdx"W-z3m0>>Dg?1܍.:c& O8}-)n<<+;n+'R+J1>np*Nz.n`RT-kMArW"x{{\~?YT[8Lz̗Uum:I𯇅?2;d!J0,ǧ֢8w0X>Λ&-c޲>(_c2!|#CnAu +2:֏%%dhUKW\ѐ*`9;!A+㙘-(PF#@ubܼh*htgN-=wdd%ry۴˲ Be\[i^k멏HU'=.tKv1,cH?ZZ4z~;`psN(xO¤3GںH/5vxck-H>\Y|QIp6ssfkSO:Mw7ΊnٯfU? T橳>^jz _L\.w`L\7VY–2~Ίݷׯe9( X+rAG4aMLG*SgWmD6?tSr}ES$}L"bNJ4FG ,Wڊ vHȂIX[cw:WDD$z䱶I»#t]vأPaCt^li-}霉hLʹ ch9N: ES,|A'zTʷP Za{>tq},pGJfi[HO5rC Ӌ^ UW}:N162%-҅"gN8a銟ʑ+09&%'n/S& `;ra`҉r8w(ҼfXW(I$֐QňK.U16ș]ܨAOW3rNF85z IJt?MrSMٝyLqy^#() ?W(.#wY_UX3Z릕X+h;CnB7lu&\\J)( Hm[6䪔u- 89ZЂwǡZR5v29MŚc,8N(PW,Fv\`~uq9bI<0G9Őy*B[c5ʗ*:kG NyPn$/jg~4BLa+5u.}92T!=A"_YSauּy*ͩ]v<?/tu#9azßNK3B0BݺWujtxe =9*YIp>bpM:UZy`j6r|qԯpȱΈ@k. K{x~ozҸ-*UCn+קTj2f23z6#+?z ԫrc~UvxSǞDSw#rH8&PHUi.FRiq64ŤNrTii>ec7LUt-°ՆIFzZ`؛Onԙr } 0+25/Glt*?:{Z];;F 9;q& S=)˗%LFJĄ2zSY=tbYWj) uI+zߨ ycϭJ. B6y9ɤRVlc3E4@hz2iC*] S pT Hr*qވ;]]u2&h6hHƜfL;[cBm٭{٭5,$pGDVW$^iTV sQq؁^)n( q5Ђ sko Xp9[߂m& #_xsIր@|(*z뺑߂z\VgsѸ7,x= v?7|#`qM!l, WS_tiƚVkHX.Wֱ8H>쀣i+ˉzZՏH$KH;zie Wm\[K|v3oxtao4=qֽve\*0q^e;K9L.A^iFsuKTo:V_ &L4`ffgWF o RPINWfϩm5KylPV07ab2}G8+juLI~n[#Ib<kBKmOZJ[OItcc\ YGZ7rNe{$ӋdQP*yF1<Dfǥ0EGT&Rbxb92 #ZK$QqRUʱwQQ#:.Qb|OµHX% Q1" A[PG7ƅ Nr1ޡcr}_&cۇ_Z#ƵM>yPS&cAi ~d}ڑkX߸Hphc`qW ⛵o? to] )uަӜxs\ѓԒoF$֏{._tBNrka3%Xw /4 V2h`cL6j[c0:v[8PGGdR 0s=0jbNq^{t>lTfsұN&hHI09k퀬r֣6ʄ#G7 qTݑ]p(ӚorеoAN{l Q£'+Gq']3"#y=uld>[0 󻷹Igiپ`u((ku+q"狴lc^OVqZc"Bdm#u CԢtEK\fDY[sN.]O2*t{Y}w.kVl ^BӷWn}sP5K(=yÌ2SNHbّ /,8;s5`Xxuoܱ]Wt)<;F2\ֆsRH7[0+:9m7;kNVC&O29v䯨MJ1=k]͍ȉNFxVazzVpFa-dP$%rE2K`Tl#|uӶ48WʫϥrbW#NǧmΧwn€9sBdaQW$ H0 XQ_֬A9 'O5ˇْLB ۻ2NrI=q\7$AX*T]0SE]ZN>9RM q1*yl^8(WPJ\k:# ܎* cgyTg6KC':}ĐH}h`FP@Q 3}w~V9kd&!@22=!7ߵK+(Ub\䎇[kW]#ҠheU;BK]SL8RB\qSe%v^@6c  T@[CH 檴{G=bY(YӅݎjw. |TjF;2g 22pL"2vWxC[Ub}ؑRG)<I+Q~DÅ c=MOnuɌ 4U ;;sEJZHT ?J;yG @HsVdh`Ŋnfxe19Ly `M0`;K>vƓ)ܛF|DRXgBcUU(X ZU{{(ڒ$@2K ASܿ.RQ#h?Nh+Fs_X~5^!€@_'30* +zٓ~p:i)ڌݮ,\Sheq\M +`2zW6D&2EEjyOo,9Ga\[x#7 H<ڸk{o|z rqZ  f[C8-|6T?RRp~-H{?%a JoP[_bDf,3Њ&HbT@ִ\b;i+ R%ܼ<-JI_6HXGZ,CyALdT+ӁO]i156# J6 `d*@ʳ z=1Eگ&IF69 s1Rq@u{ܱ/{uA'hc'UF+/ȨX IQW$js'jqZGY6a1sޭE&t-&~@z`kN.G,9byǗwR;fQU,wc@w~@ܮ zckhStR½(y'5uril䁍k'R=i-Di6G Q*ڍ!7ʝk]#%sֽFS0Yc\-ePc=FW ~( T%sܵvXβ;d85 o`s٪6ȬCjAHWE\b\KKe3yyʜc$W UVc9@Ȯ䁷>4sHفddNB X679Y6?9 -ȭ5k ƸfdkZ(ϖ`wÊcِ`b HlpG5YeWh3Erzg:6K2}F7%*|1C4mnml  "9%$c*oM|q9;xczd.fiҝVXݻqα[TC HὲqU;)Wp>rNH=UUs+9o=${ȾҪ܁TS{^7 ԓsҙm_X\HqFH)ݞm9hDry⵴c*L125oڕ31Hg@lmW|(oS9W2ByH5|A,r+*܆oԚ ʳwN7:+}i ;j`%1JӍyѸkӻ08 7qQFwԖ҃H`4hq0 oǥBL,\aWG%kݰ+dJl\n1ÃNO91l TǖQJےeC n@)0MtDvo#h4rr;VRۜBȑv =)W!y@3_z2[F.d=xZʃp]Aܤ>eeeUOֻۀ9Isߑ֞>bHt!$:{SD0F7x S[ӜdyRDg2X+BbsԊ>-<'h-ڐCSt #\ c?ZRSBd9e8ң!`0k6]2KI@ Y[wҺVkTd8agCg=s5mCL'c9cYm6~maoF܌8? hƠoUR1ǥ~ [?ٷI;A3orrh[]~"vj=W ފkf?cuُi,F6"g$=9'5 r֧-Snj:mtfJȣH:}+xJҩˣ~vN p0!ݮ^kD(HDƏ <s6yeȮKw15k4M$%p9touN[WpGLb,*VH油J0*\2sPɖfF JSu6`r3⾚)-S9RI `21ؤ?0ʚ78,q+5m̭ #'_Ur;xktu]F2j҃ĖtK *Ҝ5m!INWEoD؅7) o㝫[錍uL8VI*.͜ٵy!m*'"[0$k[v{Z0C\FNI+ڲ)̤o}*ܞTqU#JvNc_5I z05VqT.c*FApܒ2=WH;X®evN[#;m眆?5{\bc]us sxRm3zU6ǀ>\(a*.@?:/_fǧG[}O+~p6L;kY~ C%1kHwr3ZEv|N襩5zwL#_!e%JWך͚'h Mh#m89% Jޖ>åUSaۧ?NS9$Mk#Em2p3ӵYͳ:UU8bxёHU檥lE7(9L`sR3~M3^㚠 r;b9ocئ99q8BT yq[2 %s*2%vְ4i)UDa?J$ml_DxS%'#5 +!x_ +rJD8+0ts4Q$.Q]1S r`VwfoQqv3EQkkR1~;]*)]_S0rxt+ϵN1GnV̛eJrư<dDjYάZM/CxzVX$W_^}*F AHB4ϚNT-Š*Hi(F8 v!&ݐ\Wo^.o5 IѐvV D۩5t{-xdtۆ뮷7*@kέӝR`d6W I 'N.۝AяTF'k) g5.`=•'fstCVGͭȮ9FNTߵ[y_JIcrsNI_su,j_ &&D^t!.."8a\e=UՉQFݜgƸ>Ⱪwc|YR eȇA_Z_ HeqW2K[ٶr1)ntCʒ3e%DiWZ<1ǭz5lyP=Lo9!QW. _-<@ʌyOӵZoN#|mczuy6 43Ұ7k' q\tJ]wdOv"FjCڵEO۸d-2l)Ka{+Ȃ٢+ǩfB%TǎJ,EBqҷt}tMjCßjݣ-;pkK$VTꏜ WbZ)Y$Wi -G&+.y$x .ؼ \W Eޕ?9%?^i-YFsZ6Das$p8ޥ(җ4QyE ;U4`!cIUQNR@ Y.ۀk,tUǍ_xtcriWvW!y9"NT cW~WSӓv愙\!$d9{vF+[$Xr5*UԌc/w[E讑XewHWW6#WKi@ʎ%W$`yUn]6֤$xNzUr0O5fZTR$.@ +GsS'gcfG’HS֤m݅+yjcRHPlcyQfRH <5,+GU: Fva[i! R3g TU^G`7d*)*ѣ~,^M\pYss+mxa2_\^6+HBzs?o\(Vn0++\\_5JVt;(%q9wmkl,faywzcRu1f ,cpHaRkfCEvxY1ӂIRP]bx;#+W?G Qˑ38›:L)?5V\xhqQuSoSGs3E =9=+*2C®͖OJך™ۢۄJ\1I;t2u+i"xA=Td|;ym [+51\C$qG~H q]S9C͞)"uW`g^kU26V/LN:z:ް\FMlWqZ)ɏ2UPOZK*څy7qdυAp# c5f6i(ʁGVn"8r֮8!vzU8'uDڞO_ ^O]@^mKk]r__A]‘YlA'ڿChx8O֓p bUXnslԳwkoǸlVFA8VSTH9 qzʋPngwm.sȷQ BrA5K̎l0on&fҿָ߉rX -חIg~U7<]8|~B =Nj)yMwu"E "8ۡ7B0ܷ*y\uq|h ufwp1A3u Jm_B];N ?ڃkg(+X|)~KJQ;nt$\T'W8ro՜ Uة69;8t'6 T:55J1(lxg¢} $qV-P&ҵN1\0\c8cМ O;.U)}·2sV&? سLQ X1$ M]z_ֆ! G9>Ԁ|'1ޕ0Tc`m?W\$mf,@`/(Ђ' -Ҙ ץFhrzv.elKmū3_K ALo:yUXw,oM^Ut-EQN*2zq K!e_ƻDDB0Gbk:iN)_$vlfCo}.J[mCpHTB~lcIwqjp¿SZɧ];QҒc9<+l¤e]OtF~ΦH)JQx\LS~T;Š"De#?5{Ҿo{.XtHt(B<1퀈Y{Bo+c29 t4@QO(Q|"+m9r8& HcU%aG ܀?:|Vt2wDžfR*7ǟ_i5G$R0W.y D=3B2 z"-j|29 y$gEI\W|ܚSCU爘r)qj,X>W*%?9觯Zϼ8$#ֹgU-[5W.]܉|<sre1'j묱䝪z: .x<ׁu.N[7M=@sʓ۞mdcJC WMge<3L8lcڼ8TNihdV~5Rխ2< c]VT~i)/ n֭RSi/ݔt8iOgu `8㞵5-m[ ۢ<くZ8Z1ʲ&xiֺg%k#vZۻkfyI|?\8.v־ks٢kR %ޛs 5*uXeS>l&ƮF "U8׵^|'u-FkmgRv_:L9w[pje[s͵*%<NUp⺃Ih`p3dxïٗ2hs3f+\6n uÑn; L8g?}D; G&ͧ,-d sYxNp2ǂğ)g\ӎc >TAhwi H ~oʻ=;^5kjڦ:ίo5sqrrվxq/ .,8S~yv9yjw·-vvK<=R:#{*/m`Z ii`G'8V5?xNhy&mui.m.m:Ȍ=ҔyE,>;JY-"ݹZ&e R!R|'mBrWsΝsO>\Cs8{Nɵp D=E`, h~u⻻IBֿ^%&ɷ܂k@H21ZZhpn@3R٪Z[ ŀRBA\W(/`f^s@vOZQ.2NWЏkw:޾x/y.{`u:V4?d4Z^f6iƭup%9Rs붶ͱA#OJ"eO8U*_i23{`80 sXX8XA䑓RW "^:q3Ri1õTb}E MWl̈ "ןª4.ݕ.]b9#'gsWmT 0ۓp v\%l˖nV8ȇ67#XX x;ě] GsrFR5~s}p.-{ 1iqrּ&K 91IWIyӑrsoYf;]8DHpwָ|)Ʌ(ӁtH8 xRLH͐~P+ }wl0;޴0qw=VpH'߆A ZuC VNywwX Uk=b$RǗC5̙`opM/'mĈ庞E 63Dwn2I5C}%+1}a.#?6=*"Fg;Թ-u)&[=Mi#"mbG#H2 (w%lU9Y+uRY|7yҡAq)nYBmqТ(g^SZpi(͜eup]HJ"``5>`U wnzc[2R,Ld5ݵ:H$Sn⥗[ )%gM#;0!j=o"ny,S:a)^ɕ2 U N9'>,FsxoCrO@zq^+)=^QHԲ\ZP|ǿK=bcTowsV0N8h݈f8.X9 ӃYwF.{ޯ6yS:c1b#׏ֲ b%2 zUNrGSgr%W>+Lmo0w{Vi-ٱ,I_B^Y"P!,@n:NI|n$/895I8΋K;[a {E5Ĭ>Y5̚ma2 @ɦ7_j2M,< yn՚]!=,tۖX1ץ=5fwVEk'ɀg'':;Q2pH)F۝pw>dw66w**ρ<o6ko2"Aֹ`i0H5bMI$` =+4iMiPu=S;L,mF|!=w,Dž^%Α`]r`::\yaLo(סӼd3yG*>lu_I*{X)YF(/ _Xky.-FXl]|=ʥCJ C0؂f% ᣂDg溰Kh=UgEgst4(1sZ/6[G\}+J;Zbn]b Em<2vdZ<#ūiS0Юqn~]Ir躴WK.0ZM!Dժ:d;(b@5 -JrU}|F[q5sy)woY g*Hd`NT0p?&kӮ +~kFkh|w1Nѝ];Ka7s dv=kib)b)Z3k`q8yՋn_+  c,\1_WԆeʲN+ڂ}1a<Rt"SM/3whh9PzTYWڹC?ZMYAޚBU,~C"TSqҘȲxjar0sԟJ6'K}==]>k }3J b j_ukP5.8(FьssX7e^i+5T[\m}ֆ/6i;iϯN+'ĸ_X "="S:VlcVrKd5]̬T1\Lv*nEWkG|nÌUfǵHp~jny GJ6m,܌NT I^asU,& }ivfWxv26Ƚ p9Ԛ6%䱉!DcL܂q T z{ӲDFmzSةZN1D6}۸k/^>=n6H\d;KnpQXOj"ө>C1)^=1VwbHQDoeu8A? հFlzTzI_-Y4ln ņ>cҡf\-Y7 .aYq[kf6(UM';gk2wnq%QY *}k|IM+HIH1䷰- :=o_G[uw _?xDzK6yoa\~=CZs,?, \^N"yp9I=2P6F*l/ːuI UӥɤN4^cvm#EP/= ,wUsMXϳjRރ'oQgr{}&٘-ҴUbL'j})?~l6:ը-p &LݡŨj+7+fK1 ]Xrq{UbA rr@涣\ģ̄esTJ ()UeSyʫ`nr3p۾_?֨<dcw([+"Q¡s5 Y8S jIIw`vtϽs, $;G,1]/9E\kk0N`j=i{2&U؂ҩw܋W՜o Q+MX+[-1/8| zqȫqm>[P)0p? M9Mr:Woux=Oawj[Z\N+޶fLZ Qn76:麇ڥWp,7`VdrHj")I=dT: ,PNEf d鶓oNMUUa{Gק"%4) r=׀Fv2"K}*^IXgu7VqT:йV03Ҿ sy8w5$t[ 05ֵxJRi-01wcsϭ8F'[>k OVu!Z̷6H҆!Nf(vl0ATD9\##*g:t)^ UR`0S^ʼnVf18D5]EO~dpF1)VoCTd5¢B77Nں d Aۊk+2$<~pȡC˼ UiY`쌝UaRF0N di0 gѼlf^~o:r$g5x5aQhHFKr9Hc‚X8RsvsҜ]1J:ħ 6}1J$l G7ᾜWl [u1MJKvrgnQ$Q2 H,oݸ:{W\,3.vd|gHb)j Ž8,{!5]cq1LrGUVcs$"!<J#(/D{*ݚF)->inI.{f5G $ l:|U N2FihG7R:^Mļ\芅,N>>#&y1pO x/`TO#uMݽAbӭ^ tvpOxF\Y^=QifG=YIEy5k9WMgؠ9=hNzpw֚**@I%@B5`DW$GcBMPA1>::}iQz4j HTcj{cQ#Iۆ6םCgr _kfB5YmC5;iQj[]'yLm'e˪nj yE݇]h-Dk;NesdgsPN }* ,کê[&A>k`_5Xs֮v T2MfILw^dYP\1%>YHh^zy-rkΚݮr.[y\;dިKqy:֣C)pQA7T\7UʐB6nԲD%;.rNj,Q@S$Y3?\4+ ^Sn6,}C)vd-*>|ٛ19?S)#v쒹v%A&䙪WBC2p85('cl<዆0%{T }v2]cmi_m=m|AUEYDo5*b'ȶG%|ѥؠ >r]Tbzbg 8>9^2)t3tҝo{LSIFճ!KFz+B`9<,lKc oqJ撂 ؏z*\Fg\ڰ֢HCdS+0_fUi-@URqYB2G$s:VôV#}ƥq K02j襀8ߗGԙ:;CZ8R,s'q} [-Τm E8k-`#n*y?*;-NI57EqD&}?Gӭhyd~Q[7 =J4y 0_o }rtݣ붚F醝0)l|Vxrx#K:Qb v9k:v[ㄟV#Z)NzuNI ä,DlEIaU|.hQh fDe .XRIeW䓃<̶숶<3gIDȡwqV[iK'ޮWS]-h$147HyW,az7mvY{6PʬRɽ2]X(Yv#AEK6a[=kvvYY_tI<G[(B뛯u}dYH- >})*`)nt/'?Xm'^+R^r˱R% 9嫮uƉV)]Tc92aO=A f-Ȏ F2JmxYcqp:Lc'-=xfሄtdCEzլŒАfKPx|ӼǞ}y}8kh#:w- 3Vgc#z[M] i&dNkqՕrhuvc x*dC1YJ#]`Ui@84Ӓ᙭9];]{ V0] 0KXWZ||ʌJ/ )9[!72X~džI?:X|(nZ?!v,\|\&#=u`kV$;mE;:q:7,x9P3Q3#eFwzt];{HxŸݕ|ė ٰ}mQO眐z*"Jt&\ԮYəzij>5?xqݞ{kTdY\X¸JVEBe0_ם;ʼnj"zqSOjQ2]NxɪGoǎ1ƣf܌!y50 P<z7|ѱI!~9rƛjcDbO'E$#6GLfWK3<ǀxM=bqЯ9[vgt.|ˢvQ+62m tcZ-*iN/fc!v^YVKQ(5: 5tc ~]#h$JWGܬ -u/ "LYcm0+9M8QNڙ^jrn[cVBեKBGm=5gc4$tRq߭m-,QW קBi/-̓Údv))gzH2#֚af#殻¥T k %.Y=׸p4DMl8+ֵh#+7c}-'$cubIe9y[Ƽ[T_.y2ա< Y3E ͵O`@A h[] j>f>ՙZ4@3?zy^58YIJLit?_Je㺞tXԵ-/{mn lF=*)9g{~qֳƤ!'y<+w>&3[SZr%% 'KhIf=Al>E5ӯ,[UGU[܏٦9K>"FΪ<BvJnZ%ه5{n'vx85U}GOi;XFe Z1C`vMcrż9p7 *~ZӱYJ)*$y2=ܖܳެ<|BFHƫ"eOp wHCT6i{QxDźj0i Dܬ~S(܏׭DɮYd W1CPrJQ ֯+mCMך,J%wy NbE\|{y5ǫ[D#QwK0^ +Аh\Ldh[ ;n5'IQe{7* 9_JWrVJ< QkkM=ca\=Idbd$+`t8K,:d(Wh 8UEa1⹸5SRI46u=Rf|쒂x zgtb`0vW`ew2#o/uzmt̸Td9+mobJNY03jXby ë}2>#sԞ=#?~ݷ*2v Eؕ'9iTH60r:1d˴/A'鞴VW;wӊܣ i,yKvc$8T԰' Z1u,ѶX wQZ+Nќ_24T8pI$OE HA|øpWNߔݓzЧqVGھc޶br|3M4 rYF?k +pku/tbV9䘷1 }kpehS\ }j6鎕lx_'%Hx)8;^F\g JqW ֠m.3֐ڳRtBIN{,LBI=kKf:,2(wlIU <_SX`T@?+pp@yh\J7c+Oή̫e dLhFwcpYX:X @5-yyZ};F0x|OA'MǭOi>7WQq,"S v)TR }?ZG,<\D"%t-Y@ ӵt(<`3DR$la%N Tѳ \cFxaNɹ~Wk^K8̀yքҤ% O~MM=ۘ Jfgm(>եdc ]*9{j [߃0?5q"Wo3 t9(i/_FJ?*S *ibPF+I47 XlWIQ#9ڣuZG.O1#àL6Gn*Cx8c c vWUtWvpf;׌f# yl1V50Tڻј/x_ mZdp`tM$Aj$9;- Y.+`ˑz*-+aWpXs8,۲{SF,6}'߭eB7wM+\q'Gw-ߚbc9NYg,w;wN2ocGd\KH+#1hTrHlTvwly1 9d]),q'8 ҏ72e'Eu[l;dZ"mETҳ ULăL[.XA_zJ2it[̗gGdhn-#utk 6N$N3ϭtikovpOjvF/*β+M8P7s6sRyQQ] Q$ Τq:zhJę @OO֤UYdf1r8 32<tqo^I'XD\l`s!ŹWD =Vo1]Nzi*3#қ}C^!4LΫ'dv9yhb#;wc5 rFr }.7gIYvC:RykYuU m%$9;O;Y'~^Qנ+̱iXf\GDϴ532g;Dd6zR+n?c_Ό$ޛREh_(2V6:$FN:Xq[G{6FקXȶ+,p1?yu8oe2g\ΞDFҀOzb ަd*nk*5.( tJ-[1{7!hؤ緽Zpfdhʯ3ӭTIHx`)۝; {#g=).gͫC̏- V%Ijv=+FsǿJWCqPOsY""qv4z+sް\%vCa#q֧$HN*lV 㞸㎵am]eG˻9ךsg^crZ-T+5 BIwm*΃i$ rHHw5N&#6Wt)$X9t…lP~f2rGPB;@یmR)Tc>dGSocialtext-Resting-0.38/t/filename.txt0000644000374200037420000000002411743342534017326 0ustar kevinjkevinjThis is a test file Socialtext-Resting-0.38/t/compile.t0000644000374200037420000000006711743342534016631 0ustar kevinjkevinjuse Test::More tests=>1; use_ok (Socialtext::Resting); Socialtext-Resting-0.38/t/resting.t0000644000374200037420000001025111745146077016656 0ustar kevinjkevinjuse Test::More; use IPC::Run; use strict; use warnings; # Put the page Setup: { eval { require Socialtext::Resting::Getopt }; if ($@) { plan skip_all => 'No Socialtext::Resting::Getopt'; exit; } Socialtext::Resting::Getopt->import('get_rester'); my $r = get_rester(workspace => 'st-rest-test'); eval { $r->put_page("Test page", "This is a\nfile thing here\n")}; if ($@) { plan skip_all => 'No access to socialtext server'; } else { plan tests => 21; } } Stuff: { my $r = get_rester(workspace => 'st-rest-test'); # Get it back and check it my $content = $r->get_page("Test page"); like ($content, qr/file thing here/, 'Content has both lines'); # Put 2 attachments my $text_content = readfile("t/filename.txt"); my $jpg_content = readfile("t/file.jpg"); my $text_id = $r->post_attachment( "Test page", "filename.txt", $text_content, "text/plain"); my $jpeg_id = $r->post_attachment( "Test page", "file.jpg", $jpg_content, "image/jpeg"); my $retrieved_text = $r->get_attachment($text_id); my $retrieved_jpeg = $r->get_attachment($jpeg_id); is ($text_content, $retrieved_text, "text attachment roundtrips"); is ($jpg_content, $retrieved_jpeg, "jpeg attachment roundtrips"); # Set a tag or two $r->put_pagetag("Test page", "Taggy"); $r->put_pagetag("Test page", "Taggity tag"); my $tags = join (' ', $r->get_pagetags("Test page")); like( $tags, qr/Taggity tag/, "Tag with spaces included"); my @tagged_pages = $r->get_taggedpages('Taggy'); is( $tagged_pages[0], 'Test page', 'Test pages is listed in Taggy pages' ); my $tagged_pages = $r->get_taggedpages('Taggy'); like( $tagged_pages, qr/^Test page/, "Collection methods behave smart in scalar context" ); } Get_homepage: { my $r = get_rester(workspace => 'st-rest-test'); is $r->get_homepage, 'rest_test', 'get homepage'; } Invalid_workspace: { my $r = get_rester(workspace => 'st-no-existy'); is $r->get_homepage, undef, 'homepage of invalid workspace'; } Get_user: { my $r = get_rester(workspace => 'st-rest-test'); my $user = $r->get_user($r->username); is $user->{email_address}, $r->username, 'get_user'; } Get_user_photo: { my $r = get_rester(workspace => 'st-rest-test'); my $photo = $r->get_profile_photo($r->username); ok $photo, 'Has photo'; my $large = $r->get_profile_photo($r->username, 'large'); my $medium = $r->get_profile_photo($r->username, 'medium'); my $small = $r->get_profile_photo($r->username, 'small'); ok length $large > length $medium, 'Large photo is bigger than the medium'; ok length $medium > length $small, 'Medium photo is bigger than the small'; } Get_workspace: { my $r = get_rester(workspace => 'st-rest-test'); $r->accept('perl_hash'); my $wksp = $r->get_workspace(); is $wksp->{name}, 'st-rest-test', 'get current workspace'; $wksp = $r->get_workspace('help'); is $wksp->{name}, 'help-en', 'get other workspace'; $wksp = $r->get_workspace(); is $wksp->{name}, 'st-rest-test', 'get current workspace'; } Get_TagHistory: { my $r = get_rester(workspace => 'st-rest-test'); $r->put_pagetag("Test page", "Tag 1"); $r->put_pagetag("Test page", "Tag 2"); my $history = $r->get_taghistory('Test page'); like($history, qr/Tags:.*Tag 1/, 'Has tag history'); } Name_to_id: { my $r = get_rester(workspace => 'st-rest-test'); is $r->name_to_id('Water bottle'), 'water_bottle', 'name_to_id'; is Socialtext::Resting::name_to_id('Water bottle'), 'water_bottle', 'name_to_id'; } Perl_hash_accept_type: { my $r = get_rester(workspace => 'st-rest-test'); $r->accept('perl_hash'); isa_ok scalar($r->get_page('Test Page')), 'HASH'; isa_ok scalar($r->get_pagetags('Test Page')), 'ARRAY'; isa_ok scalar($r->get_taggedpages('Taggy')), 'ARRAY'; } exit; sub readfile { my ($filename) = shift; if (! open (NEWFILE, $filename)) { print STDERR "$filename could not be opened for reading: $!\n"; return; } local $/; my $data = ; close (NEWFILE); return ($data); } Socialtext-Resting-0.38/t/resting-mocked.t0000644000374200037420000003276211743342534020123 0ustar kevinjkevinj#!/usr/bin/perl use strict; use warnings; use Test::More tests => 177; use Test::Mock::LWP; BEGIN { use_ok 'Socialtext::Resting'; } my %rester_opts = ( username => 'test-user@example.com', password => 'passw0rd', server => 'http://www.socialtext.net', workspace => 'st-rest-test', ); sub new_strutter { $Mock_ua->clear; $Mock_req->clear; $Mock_resp->clear; return Socialtext::Resting->new(%rester_opts); } Get_page: { my $rester = new_strutter(); $Mock_resp->set_always('content', 'bar'); is $rester->get_page('Foo'), 'bar'; result_ok( uri => '/pages/foo', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'text/x.socialtext-wiki' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'etag' ], ], ); } Get_json_verbose_page: { my $rester = new_strutter(); $Mock_resp->set_always('content', 'bar'); $rester->json_verbose(1); $rester->accept('application/json'); is $rester->get_page('Foo'), 'bar'; result_ok( uri => '/pages/foo?verbose=1', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'application/json' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'etag' ], ], ); } Get_page_fails: { my $rester = new_strutter(); $Mock_resp->set_always('content', 'no auth'); $Mock_resp->set_always('code', 403); eval { $rester->get_page('Foo') }; like $@, qr/403: no auth/; } Put_new_page: { my $rester = new_strutter(); $Mock_resp->set_always('code', 201); $rester->put_page('Foo', 'bar'); result_ok( uri => '/pages/Foo', method => 'PUT', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'text/x.socialtext-wiki' ], [ 'header' => 'Content-Length' => 3 ], [ 'content' => 'bar' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Put_existing_page: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); $rester->put_page('Foo', 'bar'); result_ok( uri => '/pages/Foo', method => 'PUT', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'text/x.socialtext-wiki' ], [ 'header' => 'Content-Length' => 3 ], [ 'content' => 'bar' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Put_existing_page_json: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); $rester->put_page( 'Foo' => { content => 'bar', } ); result_ok( uri => '/pages/Foo', method => 'PUT', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'application/json' ], [ 'header' => 'Content-Length' => 17 ], [ 'content' => '{"content":"bar"}' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Put_page_fails: { my $rester = new_strutter(); $Mock_resp->set_always('content', 'no auth'); $Mock_resp->set_always('code', 403); eval { $rester->put_page('Foo', 'bar') }; like $@, qr/403: no auth/; } Post_attachment: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); local $Test::Mock::HTTP::Response::Headers{location} = 'waa'; $rester->post_attachment('Foo', 'bar.txt', 'bar', 'text/plain'); result_ok( uri => '/pages/foo/attachments?name=bar.txt', method => 'POST', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'text/plain' ], [ 'content' => 'bar' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'location' ], ], ); } Put_tag: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); $rester->put_pagetag('Foo', 'taggy'); result_ok( uri => '/pages/foo/tags/taggy', method => 'PUT', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Length' => 0 ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Collision_detection: { my $rester = new_strutter(); $Mock_resp->set_always('code', 200); $Mock_resp->set_always('content', 'bar'); local $Test::Mock::HTTP::Response::Headers{etag} = '20070118070342'; $rester->get_page('Foo'); # should store etag result_ok( uri => '/pages/foo', method => 'GET', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'text/x.socialtext-wiki' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'etag' ], ], ); $Mock_resp->set_always('content', 'precondition failed'); $Mock_resp->set_always('code', 412); eval { $rester->put_page('Foo', 'bar') }; like $@, qr/412: precondition failed/; result_ok( uri => '/pages/Foo', method => 'PUT', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'text/x.socialtext-wiki' ], [ 'header' => 'If-Match', $Test::Mock::HTTP::Response::Headers{etag} ], [ 'header' => 'Content-Length' => 3 ], [ 'content' => 'bar' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); $Mock_resp->set_always('code', 200); } Get_revisions: { my $rester = new_strutter(); $rester->accept('text/plain'); $Mock_resp->set_always('content', 'bar'); $rester->get_revisions('foo'); result_ok( uri => '/pages/foo/revisions', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'text/plain' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Tag_a_person: { my $rester = new_strutter(); $rester->put_persontag('test@example.com', 'foo'); result_ok( uri => 'people/test%40example.com/tags', no_workspace => 1, method => 'POST', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type' => 'application/json' ], [ 'content' => '{"tag_name":"foo"}' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Get_signals: { my $rester = new_strutter(); $Mock_resp->set_always('content', "This\nThat"); $rester->get_signals(); result_ok( no_workspace => 1, uri => 'signals', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'text/plain' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Get_signals_w_args: { my $rester = new_strutter(); $Mock_resp->set_always('content', "This\nThat"); $rester->get_signals(account_id => 2); result_ok( no_workspace => 1, uri => 'signals?account_id=2', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Accept', 'text/plain' ], ], resp_calls => [ [ 'code' ], [ 'content' ], ], ); } Post_signal: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); local $Test::Mock::HTTP::Response::Headers{location} = 'waa'; $rester->post_signal('O HAI'); result_ok( no_workspace => 1, uri => 'signals', method => 'POST', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'application/json' ], [ 'content' => '{"signal":"O HAI"}' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'location' ], ], ); } Post_signal_to_group: { my $rester = new_strutter(); $Mock_resp->set_always('code', 204); local $Test::Mock::HTTP::Response::Headers{location} = 'waa'; $rester->post_signal('O HAI', group_id => 42, account_ids => [2,3,4]); result_ok( no_workspace => 1, uri => 'signals', method => 'POST', ua_calls => [ [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'application/json' ], [ 'content' => '{"signal":"O HAI","group_ids":[42],"account_ids":[2,3,4]}' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'header' => 'location' ], ], ); } Delete_page: { my $rester = new_strutter(); $Mock_resp->set_always('code', 201); $rester->put_page('Foo', 'bar'); $Mock_resp->set_always('code', 204); $rester->delete_page('Foo'); result_ok( uri => '/pages/Foo', method => 'DELETE', ua_calls => [ [ 'simple_request' => $Mock_req ], [ 'simple_request' => $Mock_req ], ], req_calls => [ [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'text/x.socialtext-wiki' ], [ 'header' => 'Content-Length' => 3 ], [ 'content' => 'bar' ], [ 'authorization_basic' => $rester_opts{username}, $rester_opts{password}, ], [ 'header' => 'Content-Type', 'application/json' ], [ 'content' => '{}' ], ], resp_calls => [ [ 'code' ], [ 'content' ], [ 'code' ], [ 'content' ], ], ); } exit; sub result_ok { local $Test::Builder::Level = $Test::Builder::Level + 1; my %args = ( method => 'GET', ua_calls => [], req_calls => [], resp_calls => [], @_, ); my $prefix = $args{no_workspace} ? 'data/' : "data/workspaces/$rester_opts{workspace}"; my $expected_uri = "$rester_opts{server}/$prefix$args{uri}"; is_deeply $Mock_req->new_args, ['HTTP::Request', $args{method}, $expected_uri], $expected_uri; for my $c (@{ $args{ua_calls} }) { my ($method, @args) = @$c; is_deeply [$Mock_ua->next_call], [ $method, [ $Mock_ua, @args ]], "$method ua - @args"; } is $Mock_ua->next_call, undef, 'no more ua calls'; for my $c (@{ $args{req_calls} }) { my ($method, @args) = @$c; is_deeply [$Mock_req->next_call], [ $method, [ $Mock_ua, @args ]], "$method req - @args"; } is $Mock_req->next_call, undef, 'no more req calls'; for my $c (@{ $args{resp_calls} }) { my ($method, @args) = @$c; is_deeply [$Mock_resp->next_call], [ $method, [ $Mock_ua, @args ]], "$method resp - @args"; } is $Mock_resp->next_call, undef, 'no more resp calls'; } Socialtext-Resting-0.38/META.yml0000644000374200037420000000154711745146143016026 0ustar kevinjkevinj--- #YAML:1.0 name: Socialtext-Resting version: 0.38 abstract: Simple tool to use Socialtext RESTful API author: - Chris Dent , Kirsten Jones license: unknown distribution_type: module configure_requires: ExtUtils::MakeMaker: 0 build_requires: ExtUtils::MakeMaker: 0 requires: App::Options: 0 Class::Field: 0 HTTP::Request: 0 IPC::Run: 0 JSON::XS: 2.1 LWP::UserAgent: 0 Net::SSLeay: 0 Pod::Usage: 0 Readonly: 0 Test::Mock::LWP: 0.05 URI::Escape: 1.31 no_index: directory: - t - inc generated_by: ExtUtils::MakeMaker version 6.56 meta-spec: url: http://module-build.sourceforge.net/META-spec-v1.4.html version: 1.4 Socialtext-Resting-0.38/lib/0000755000374200037420000000000011745146143015314 5ustar kevinjkevinjSocialtext-Resting-0.38/lib/Socialtext/0000755000374200037420000000000011745146143017433 5ustar kevinjkevinjSocialtext-Resting-0.38/lib/Socialtext/Resting.pm0000644000374200037420000007734211745146077021427 0ustar kevinjkevinjpackage Socialtext::Resting; use strict; use warnings; use URI::Escape; use LWP::UserAgent; use HTTP::Request; use Class::Field 'field'; use JSON::XS; use Readonly; our $VERSION = '0.38'; =head1 NAME Socialtext::Resting - module for accessing Socialtext REST APIs =head1 SYNOPSIS use Socialtext::Resting; my $Rester = Socialtext::Resting->new( username => $opts{username}, password => $opts{password}, server => $opts{server}, ); $Rester->workspace('wikiname'); $Rester->get_page('my_page'); } =head1 DESCRIPTION C is a module designed to allow remote access to the Socialtext REST APIs for use in perl programs. =head1 METHODS =cut Readonly my $BASE_URI => '/data'; Readonly my $BASE_WS_URI => $BASE_URI . '/workspaces'; Readonly my %ROUTES => ( backlinks => $BASE_WS_URI . '/:ws/pages/:pname/backlinks', breadcrumbs => $BASE_WS_URI . '/:ws/breadcrumbs', frontlinks => $BASE_WS_URI . '/:ws/pages/:pname/frontlinks', page => $BASE_WS_URI . '/:ws/pages/:pname', pagerevision => $BASE_WS_URI . '/:ws/pages/:pname/revisions/:revisionid', pages => $BASE_WS_URI . '/:ws/pages', pagetag => $BASE_WS_URI . '/:ws/pages/:pname/tags/:tag', pagetags => $BASE_WS_URI . '/:ws/pages/:pname/tags', pagetaghistory => $BASE_WS_URI . '/:ws/pages/:pname/taghistory', pagecomments => $BASE_WS_URI . '/:ws/pages/:pname/comments', pageattachment => $BASE_WS_URI . '/:ws/pages/:pname/attachments/:attachment_id', pageattachments => $BASE_WS_URI . '/:ws/pages/:pname/attachments', sheetcells => $BASE_WS_URI . '/:ws/sheets/:pname/cells/:cellid', revisions => $BASE_WS_URI . '/:ws/pages/:pname/revisions', taggedpages => $BASE_WS_URI . '/:ws/tags/:tag/pages', workspace => $BASE_WS_URI . '/:ws', workspaces => $BASE_WS_URI, workspacetag => $BASE_WS_URI . '/:ws/tags/:tag', workspacetags => $BASE_WS_URI . '/:ws/tags', workspaceattachment => $BASE_WS_URI . '/:ws/attachments/:attachment_id', workspaceattachments => $BASE_WS_URI . '/:ws/attachments', workspaceuser => $BASE_WS_URI . '/:ws/users/:user_id', workspaceusers => $BASE_WS_URI . '/:ws/users', user => '/data/users/:user_id', users => '/data/users', homepage => $BASE_WS_URI . '/:ws/homepage', people => $BASE_URI . '/people', person => $BASE_URI . '/people/:pname', person_tag => $BASE_URI . '/people/:pname/tags', profile_photo => $BASE_URI . '/people/:pname/photo/:version', signals => $BASE_URI . '/signals', webhooks => $BASE_URI . '/webhooks', webhook => $BASE_URI . '/webhooks/:id', ); field 'workspace'; field 'username'; field 'password'; field 'user_cookie'; field 'server'; field 'verbose'; field 'accept'; field 'filter'; field 'order'; field 'offset'; field 'count'; field 'query'; field 'etag_cache' => {}; field 'http_header_debug'; field 'response'; field 'json_verbose'; field 'cookie'; field 'agent_string'; field 'on_behalf_of'; field 'additional_headers' => {}; field 'siteminder'; =head2 new my $Rester = Socialtext::Resting->new( username => $opts{username}, password => $opts{password}, server => $opts{server}, ); or my $Rester = Socialtext::Resting->new( user_cookie => $opts{user_cookie}, server => $opts{server}, ); Creates a Socialtext::Resting object for the specified server/user/password, or server/cookie combination. =cut sub new { my $invocant = shift; my $class = ref($invocant) || $invocant; my $self = {@_}; return bless $self, $class; } =head2 accept $Rester->accept($mime_type); Sets the HTTP Accept header to ask the server for a specific representation in future requests. Standard representations: http://www.socialtext.net/st-rest-docs/index.cgi?standard_representations Common representations: =over 4 =item text/x.socialtext-wiki =item text/html =item application/json =back =head2 get_page $Rester->workspace('wikiname'); $Rester->get_page('page_name'); Retrieves the content of the specified page. Note that the workspace method needs to be called first to specify which workspace to operate on. =cut sub get_page { my $self = shift; my $pname = shift; return $self->_get_page_or_revision( 'page', $pname, ); } =head2 get_page_revision $Rester->workspace('wikiname'); $Rester->get_page_revision('page_name', 'revision_id'); Retrieves the content of the specified page revision. Note that the workspace method needs to be called first to specify which workspace to operate on. =cut sub get_page_revision { my $self = shift; my $pname = shift; my $revisionid = shift; return $self->_get_page_or_revision( 'pagerevision', $pname, $revisionid, ); } sub _get_page_or_revision { my $self = shift; my $route = shift; my $pname = shift; my $revisionid = shift; my $paccept = (ref $pname) ? $pname->{accept} : $self->accept; $pname = name_to_id($pname); my $accept = $paccept || 'text/x.socialtext-wiki'; my $workspace = $self->workspace; my $uri = $self->_make_uri( $route, { pname => $pname, ws => $workspace, revisionid => $revisionid } ); $uri .= '?verbose=1' if $self->json_verbose; $accept = 'application/json' if $accept eq 'perl_hash'; my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'GET', accept => $accept, ); if ( $status == 200 || $status == 404 ) { $self->{etag_cache}{$workspace}{$pname} = $response->header('etag'); return decode_json($content) if (($self->accept || '') eq 'perl_hash'); return $content; } else { die "$status: $content\n"; } } =head2 get_attachment $Rester->workspace('wikiname'); $Rester->get_attachment('attachment_id'); Retrieves the specified attachment from the workspace. Note that the workspace method needs to be called first to specify which workspace to operate on. =cut # REVIEW: dup with above, some sub get_attachment { my $self = shift; my $attachment_id = shift; my $uri = $self->_make_uri( 'workspaceattachment', { attachment_id => $attachment_id, ws => $self->workspace, } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'GET', ); if ( $status == 200 || $status == 404 ) { return $content; } else { die "$status: $content\n"; } } =head2 put_workspacetag $Rester->workspace('wikiname'); $Rester->put_workspacetag('tag'); Add the specified tag to the workspace. =cut sub put_workspacetag { my $self = shift; my $tag = shift; my $uri = $self->_make_uri( 'workspacetag', { ws => $self->workspace, tag => $tag } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'PUT', ); if ( $status == 204 || $status == 201 ) { return $content; } else { die "$status: $content\n"; } } =head2 put_pagetag $Rester->workspace('wikiname'); $Rester->put_pagetag('page_name', 'tag'); Add the specified tag to the page. =cut sub put_pagetag { my $self = shift; my $pname = shift; my $tag = shift; $pname = name_to_id($pname); my $uri = $self->_make_uri( 'pagetag', { pname => $pname, ws => $self->workspace, tag => $tag } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'PUT', ); if ( $status == 204 || $status == 201 ) { return $content; } else { die "$status: $content\n"; } } =head2 delete_workspacetag $Rester->workspace('wikiname'); $Rester->delete_workspacetag('tag'); Delete the specified tag from the workspace. =cut sub delete_workspacetag { my $self = shift; my $tag = shift; my $uri = $self->_make_uri( 'workspacetag', { ws => $self->workspace, tag => $tag } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'DELETE', ); if ( $status == 204 ) { return $content; } else { die "$status: $content\n"; } } =head2 delete_pagetag $Rester->workspace('wikiname'); $Rester->delete_pagetag('page_name', 'tag'); Delete the specified tag from the page. =cut sub delete_pagetag { my $self = shift; my $pname = shift; my $tag = shift; $pname = name_to_id($pname); my $uri = $self->_make_uri( 'pagetag', { pname => $pname, ws => $self->workspace, tag => $tag } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'DELETE', ); if ( $status == 204 ) { return $content; } else { die "$status: $content\n"; } } =head2 post_attachment $Rester->workspace('wikiname'); $Rester->post_attachment('page_name',$id,$content,$mime_type); Attach the file to the specified page =cut sub post_attachment { my $self = shift; my $pname = shift; my $attachment_id = shift; my $attachment_content = shift; my $attachment_type = shift; $pname = name_to_id($pname); my $uri = $self->_make_uri( 'pageattachments', { pname => $pname, ws => $self->workspace }, ); $uri .= "?name=$attachment_id"; my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'POST', type => $attachment_type, content => $attachment_content, ); my $location = $response->header('location'); $location =~ m{.*/attachments/([^/]+)}; $location = URI::Escape::uri_unescape($1); if ( $status == 204 || $status == 201 ) { return $location; } else { die "$status: $content\n"; } } =head2 post_comment $Rester->workspace('wikiname'); $Rester->post_comment( 'page_name', "me too" ); Add a comment to a page. =cut sub post_comment { my $self = shift; my $pname = shift; my $comment = shift; $pname = name_to_id($pname); my $uri = $self->_make_uri( 'pagecomments', { pname => $pname, ws => $self->workspace }, ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'POST', type => 'text/x.socialtext-wiki', content => $comment, ); die "$status: $content\n" unless $status == 204; } =head2 put_page $Rester->workspace('wikiname'); $Rester->put_page('page_name',$content); Save the content as a page in the wiki. $content can either be a string, which is treated as wikitext, or a hash with the following keys: =over =item content A string which is the page's wiki content. =item date RFC 2616 HTTP Date format string of the time the page was last edited =item from A username of the last editor of the page. If the the user does not exist it will be created, but will not be added to the workspace. =back =cut sub put_page { my $self = shift; my $pname = shift; my $page_content = shift; my $workspace = $self->workspace; my $uri = $self->_make_uri( 'page', { pname => $pname, ws => $workspace } ); my $type = 'text/x.socialtext-wiki'; if ( ref $page_content ) { $type = 'application/json'; $page_content = encode_json($page_content); } my %extra_opts; my $page_id = name_to_id($pname); if (my $prev_etag = $self->{etag_cache}{$workspace}{$page_id}) { $extra_opts{if_match} = $prev_etag; } my ( $status, $content ) = $self->_request( uri => $uri, method => 'PUT', type => $type, content => $page_content, %extra_opts, ); if ( $status == 204 || $status == 201 ) { return $content; } else { die "$status: $content\n"; } } =head2 delete_page $Rester->workspace('wikiname'); $Rester->delete_page('page_name'); Delete the specified page. =cut sub delete_page { my $self = shift; my $pname = shift; my $workspace = $self->workspace; my $uri = $self->_make_uri( 'page', { pname => $pname, ws => $workspace } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'DELETE', type => 'application/json', content => '{}', ); if ( $status == 204 ) { return $content; } else { die "$status: $content\n"; } } # REVIEW: This is here because of escaping problems we have with # apache web servers. This code effectively translate a Page->uri # to a Page->id. By so doing the troublesome characters are factored # out, getting us past a bug. This change should _not_ be maintained # any longer than strictly necessary, primarily because it # creates an informational dependency between client and server # code by representing name_to_id translation code on both sides # of the system. Since it is not used for page PUT, new pages # will safely have correct page titles. # # This method is useful for clients, so lets make it public. In the # future, this call could go to the server to reduce code duplication. =head2 name_to_id my $id = $Rester->name_to_id($name); my $id = Socialtext::Resting::name_to_id($name); Convert a page name into a page ID. Can be called as a method or as a function. =cut sub _name_to_id { name_to_id(@_) } sub name_to_id { my $id = shift; $id = shift if ref($id); # handle being called as a method $id = '' if not defined $id; $id =~ s/[^\p{Letter}\p{Number}\p{ConnectorPunctuation}\pM]+/_/g; $id =~ s/_+/_/g; $id =~ s/^_(?=.)//; $id =~ s/(?<=.)_$//; $id =~ s/^0$/_/; $id = lc($id); return $id; } sub _make_uri { my $self = shift; my $thing = shift; my $replacements = shift; my $uri = $ROUTES{$thing}; # REVIEW: tried to do this in on /g go but had issues where # syntax errors were happening... foreach my $stub ( keys(%$replacements) ) { my $replacement = URI::Escape::uri_escape_utf8( $replacements->{$stub} ); $uri =~ s{/:$stub\b}{/$replacement}; } return $uri; } =head2 get_pages $Rester->workspace('wikiname'); $Rester->get_pages(); List all pages in the wiki. =cut sub get_pages { my $self = shift; return $self->_get_things('pages'); } =head2 get_page_attachments $Rester->get_page_attachments($page) List all the attachments on a page. =cut sub get_page_attachments { my $self = shift; my $pname = shift; return $self->_get_things( 'pageattachments', pname => $pname ); } =head2 get_sheet_cell $Rester->get_sheet_cell($page_id, $cellid) Get the value of a cell in a spreadsheet. =cut sub get_sheet_cell { my $self = shift; my $pname = shift; my $cellid = shift; return $self->_get_things('sheetcells', pname => $pname, cellid => $cellid); } =head2 get_revisions $Rester->get_revisions($page) List all the revisions of a page. =cut sub get_revisions { my $self = shift; my $pname = shift; return $self->_get_things( 'revisions', pname => $pname ); } =head2 get_taghistory $Rester->workspace('wikiname'); $Rester->get_taghistory($page) Get a history, by revision, of all tags for a page. =cut sub get_taghistory { my $self = shift; my $pname = shift; return $self->_get_things( 'pagetaghistory', pname => $pname ); } sub _extend_uri { my $self = shift; my $uri = shift; my @extend; if ( $self->filter ) { push (@extend, "filter=" . $self->filter); } if ( $self->query ) { push (@extend, "q=" . $self->query); } if ( $self->order ) { push (@extend, "order=" . $self->order); } if ( $self->offset ) { push (@extend, "offset=" . $self->offset); } if ( $self->count ) { push (@extend, "count=" . $self->count); } if (@extend) { $uri .= "?" . join(';', @extend); } return $uri; } sub _get_things { my $self = shift; my $things = shift; my %replacements = @_; my $accept = $self->accept || 'text/plain'; my $uri = $self->_make_uri( $things, { ws => $self->workspace, %replacements } ); $uri = $self->_extend_uri($uri); # Add query parameters from a if ( exists $replacements{_query} ) { my @params; for my $q ( keys %{ $replacements{_query} } ) { push @params, "$q=" . $replacements{_query}->{$q}; } if (my $query = join( ';', @params )) { if ( $uri =~ /\?/ ) { $uri .= ";$query"; } else { $uri .= "?$query"; } } } $accept = 'application/json' if $accept eq 'perl_hash'; my ( $status, $content ) = $self->_request( uri => $uri, method => 'GET', accept => $accept, ); if ( $status == 200 and wantarray ) { return ( grep defined, ( split "\n", $content ) ); } elsif ( $status == 200 ) { return decode_json($content) if (($self->accept || '') eq 'perl_hash'); return $content; } elsif ( $status == 404 ) { return (); } elsif ( $status == 302 ) { return $self->response->header('Location'); } else { die "$status: $content\n"; } } =head2 get_workspace_tags $Rester->workspace('foo'); $Rester->get_workspace_tags() List all the tags in workspace foo. =cut sub get_workspace_tags { my $self = shift; return $self->_get_things( 'workspacetags' ) } =head2 get_homepage Return the page name of the homepage of the current workspace. =cut sub get_homepage { my $self = shift; my $uri = $self->_get_things( 'homepage' ); my $workspace = $self->workspace; $uri =~ s#.*/data/workspaces/\Q$workspace\E/pages/(.+)#$1# if $uri; return $uri; } =head2 get_backlinks $Rester->workspace('wikiname'); $Rester->get_backlinks('page_name'); List all backlinks to the specified page =cut sub get_backlinks { my $self = shift; my $pname = shift; $pname = name_to_id($pname); return $self->_get_things( 'backlinks', pname => $pname ); } =head2 get_frontlinks $Rester->workspace('wikiname'); $Rester->get_frontlinks('page_name'); List all 'frontlinks' on the specified page =cut sub get_frontlinks { my $self = shift; my $pname = shift; my $incipients = shift || 0; $pname = name_to_id($pname); return $self->_get_things( 'frontlinks', pname => $pname, ( $incipients ? ( _query => { incipient => 1 } ) : () ) ); } =head2 get_pagetags $Rester->workspace('wikiname'); $Rester->get_pagetags('page_name'); List all pagetags on the specified page =cut sub get_pagetags { my $self = shift; my $pname = shift; $pname = name_to_id($pname); return $self->_get_things( 'pagetags', pname => $pname ); } =head2 get_taggedpages $Rester->worksapce('wikiname'); $Rester->get_taggedpages('tag'); List all the pages that are tagged with 'tag'. =cut sub get_taggedpages { my $self = shift; my $tag = shift; return $self->_get_things( 'taggedpages', tag => $tag ); } =head2 get_tag $Rester->workspace('wikiname'); $Rester->get_tag('tag'); Retrieves the specified tag from the workspace. Note that the workspace method needs to be called first to specify which workspace to operate on. =cut # REVIEW: dup with above, some sub get_tag { my $self = shift; my $tag = shift; my $accept = $self->accept || 'text/html'; my $uri = $self->_make_uri( 'workspacetag', { tag => $tag, ws => $self->workspace, } ); my ( $status, $content ) = $self->_request( uri => $uri, accept => $accept, method => 'GET', ); if ( $status == 200 || $status == 404 ) { return $content; } else { die "$status: $content\n"; } } =head2 get_breadcrumbs $Rester->get_breadcrumbs('workspace') Get breadcrumbs for current user in this workspace =cut sub get_breadcrumbs { my $self = shift; return $self->_get_things('breadcrumbs'); } =head2 get_workspace $Rester->get_workspace(); Return the metadata about a particular workspace. =cut sub get_workspace { my $self = shift; my $wksp = shift; my $prev_wksp = $self->workspace(); $self->workspace($wksp) if $wksp; my $result = $self->_get_things('workspace'); $self->workspace($prev_wksp) if $wksp; return $result; } =head2 get_workspaces $Rester->get_workspaces(); List all workspaces on the server =cut sub get_workspaces { my $self = shift; return $self->_get_things('workspaces'); } =head2 get_user my $userinfo = $Rester->get_user($username); print $userinfo->{email_address}; Get information about a username =cut sub get_user { my $self = shift; my $uname = shift; my $uri = $self->_make_uri( 'user', { user_id => $uname, ws => $self->workspace } ); my ( $status, $content ) = $self->_request( uri => $uri, accept => 'application/json', method => 'GET' ); if ( $status == 200 ) { return decode_json( $content ); } elsif ( $status == 404 ) { return $content; } else { die "$status: $content\n"; } } =head2 create_user $Rester->create_user( { username => $username, email_address => $email, password => $password } ); Create a new user. Other parameters can be specified, see POD for Socialtext::User. username is optional and will default to the email address, as in most cases username and email_address will be the same. =cut sub create_user { my $self = shift; my $args = shift; $args->{ username } ||= $args->{ email_address }; $args = encode_json($args); my ( $status, $content ) = $self->_request( uri => $ROUTES{'users'}, method => 'POST', type => 'application/json', content => $args ); if ( $status == 201 || $status == 400 || $status == 409 ) { return $content; } else { die "$status: $content\n"; } } =head2 add_user_to_workspace $Rester->add_user_to_workspace( $workspace, { username => $user, rolename => $role, send_confirmation_invitation => 0 || 1, from_address => $from_email } ); Add a user that already exists to a workspace. rolename defaults to 'member', send_confirmation_invitation defaults to '0'. from_address must refer to a valid existing user, and is only needed if send_confirmation_invitation is set to '1'. If the user is already a member of the workspace, this will reset their role if you specify a role that's different from their current role. =cut sub add_user_to_workspace { my $self = shift; my $workspace = shift; my $args = shift; my $uri = $self->_make_uri( 'workspaceusers', { ws => $workspace } ); $args->{rolename} ||= 'member'; $args->{send_confirmation_invitation} ||= 0; $args = encode_json($args); my ( $status, $content ) = $self->_request( uri => $uri, method => 'POST', type => 'application/json', content => $args ); if ( $status == 201 || $status == 400 ) { return $content; } else { die "$status: $content\n"; } } =head2 get_users_for_workspace my @users = $Rester->get_users_for_workspace( $workspace ); for ( @users ) { print "$_->{name}, $_->{role}, $->{is_workspace_admin}\n" } Get a list of users in a workspace, and their roles and admin status. =cut sub get_users_for_workspace { my $self = shift; my $workspace = shift; my $uri = $self->_make_uri( 'workspaceusers', { ws => $workspace } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'GET', accept => 'application/json' ); if ( $status == 200 ) { return @{ decode_json( $content ) }; } else { die "$status: $content\n"; } } =head2 put_persontag $Rester->put_persontag( $person, $tag ) Tag a person. =cut sub put_persontag { my $self = shift; my $person = shift; my $tag = shift; my $uri = $self->_make_uri( 'person_tag', { pname => $person } ); my ( $status, $content ) = $self->_request( uri => $uri, method => 'POST', type => 'application/json', content => encode_json({ tag_name => $tag }), ); return if $status == 200; die "$status: $content\n"; } =head2 get_persontags $Rester->get_persontags($person); Retrieves all tags for a person =cut sub get_persontags { my ($self, $person, %opts) = @_; return $self->_get_things('person_tag', pname => $person, _query => \%opts); } =head2 get_people $Rester->get_people(); Retrieves all people. =cut sub get_people { my ($self, %opts) = @_; return $self->_get_things('people', _query => \%opts); } sub get_profile_photo { my $self = shift; my $pname = shift; my $version = shift; my $uri = $self->_make_uri( 'profile_photo', { pname => $pname, version => $version || 'max', }); my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'GET', ); if ( $status == 200 ) { return $content; } else { die "$status: $content\n"; } } =head2 get_person $Rester->get_person(); Retrieves a person. =cut sub get_person { my $self = shift; my $identifier = shift || $self->username; return $self->_get_things('person', pname => $identifier ); } =head2 get_signals $Rester->get_signals(); $Rester->get_signals(group_id => 42); $Rester->get_signals(account_id => 2); Retrieves the list of signals. Optional arguments are passed as query paramaters. =cut sub get_signals { my $self = shift; my %opts = @_; return $self->_get_things('signals', _query => \%opts); } =head2 post_signal $Rester->post_signal('O HAI'); $Rester->post_signal('O HAI', group_id => 42); $Rester->post_signal('O HAI', group_ids => [2,3,4]); $Rester->post_signal('O HAI', account_id => 42); $Rester->post_signal('O HAI', account_ids => [2,3,4]); $Rester->post_signal('O HAI', in_reply_to => { signal_id => 142 }); Posts a signal. Optional C and C arguments for targetting the signal. Optional C for specifying a signal_id this signal is in reply to. Optional C to annotate the signal. C should be an array ref containing hashrefs that have one key (the annotation type) and a value that is a hashref containing key/value pairs. =cut sub post_signal { my $self = shift; my $text = shift; my %args = @_; my %sig = ( signal => $text ); for my $k (qw(account_id group_id)) { my @ids = @{ $args{$k.'s'} || [] }; push @ids, $args{$k} if $args{$k}; # must be non-zero $sig{$k.'s'} = \@ids if @ids; } for my $k (qw(in_reply_to annotations attachments)) { next unless exists $args{$k}; $sig{$k} = $args{$k}; } my $uri = $self->_make_uri('signals'); my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'POST', type => "application/json", content => encode_json( \%sig ), ); my $location = $response->header('location'); $location = URI::Escape::uri_unescape($1); if ( $status == 204 || $status == 201 ) { return $location; } else { die "$status: $content\n"; } } =head2 post_webhook $Rester->post_webhook( %args ) Creates a webhook. Args will be encoded as JSON and put up. =cut sub post_webhook { my $self = shift; my %args = @_; my $uri = $self->_make_uri('webhooks'); my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'POST', type => "application/json", content => encode_json( \%args ), ); if ( $status == 204 || $status == 201 ) { return $response->header('Location'); } else { die "$status: $content\n"; } } =head2 get_webhooks my $hooks = $Rester->get_webhooks(); Returns an arrayref containing hashrefs of each webhook on the server. =cut sub get_webhooks { my $self = shift; my $uri = $self->_make_uri('webhooks'); my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'GET', type => "application/json", ); if ( $status == 200 ) { return decode_json($content); } else { die "$status: $content\n"; } } =head2 delete_webhook $Rester->delete_webhook( id => $webhook_id ); Deletes the specified webhook. =cut sub delete_webhook { my $self = shift; my %args = @_; die "id is mandatory" unless $args{id}; my $uri = $self->_make_uri('webhook', {id => $args{id}}); my ( $status, $content, $response ) = $self->_request( uri => $uri, method => 'DELETE', ); if ( $status == 204 ) { return; } else { die "$status: $content\n"; } } sub _request { my $self = shift; my %p = @_; my $ua = LWP::UserAgent->new(agent => $self->agent_string); my $server = $self->server; die "No server defined!\n" unless $server; $server =~ s#/$##; my $uri = "$server$p{uri}"; warn "uri: $uri\n" if $self->verbose; my $request = HTTP::Request->new( $p{method}, $uri ); if ( !$self->siteminder ) { if ( $self->user_cookie ) { $request->header( 'Cookie' => 'NLW-user=' . $self->user_cookie ); } else { $request->authorization_basic( $self->username, $self->password ); } } $request->header( 'Accept' => $p{accept} ) if $p{accept}; $request->header( 'Content-Type' => $p{type} ) if $p{type}; $request->header( 'If-Match' => $p{if_match} ) if $p{if_match}; $request->header( 'X-On-Behalf-Of' => $self->on_behalf_of ) if $self->on_behalf_of; foreach my $key (keys %{$self->additional_headers}) { $request->header($key => $self->additional_headers->{$key}); } if ($p{method} eq 'PUT') { my $content_len = 0; $content_len = do { use bytes; length $p{content} } if $p{content}; $request->header( 'Content-Length' => $content_len ); } if (my $cookie = $self->cookie) { $request->header('cookie' => $cookie); } $request->content( $p{content} ) if $p{content}; $self->response( $ua->simple_request($request) ); if ( $self->http_header_debug ) { use Data::Dumper; warn "Code: " . $self->response->code . "\n" . Dumper $self->response->headers; } # We should refactor to not return these response things return ( $self->response->code, $self->response->content, $self->response ); } =head2 response my $resp = $Rester->response; Return the HTTP::Response object from the last request. =head1 AUTHORS / MAINTAINERS Shawn Devlin C<< >> Kevin Jones C<< >> Brandon Noard C<< >> =head2 CONTRIBUTORS Luke Closs Jeremy Stashewsky Chris Dent Kirsten Jones Michele Berg - get_revisions() =cut 1; Socialtext-Resting-0.38/Makefile.PL0000644000374200037420000000253011743342534016520 0ustar kevinjkevinjuse strict; use warnings; use ExtUtils::MakeMaker; # See lib/ExtUtils/MakeMaker.pm for details of how to influence # the contents of the Makefile that is written. my %config = ( 'NAME' => 'Socialtext-Resting', 'VERSION_FROM' => 'lib/Socialtext/Resting.pm', 'PREREQ_PRINT' => 1, 'EXE_FILES' => [ 'bin/strut', 'bin/st-webhook', ], 'PREREQ_PM' => { 'URI::Escape' => 1.31, 'LWP::UserAgent' => 0, 'HTTP::Request' => 0, 'Class::Field' => 0, 'Readonly' => 0, 'Pod::Usage' => 0, 'App::Options' => 0, 'IPC::Run' => 0, 'Test::Mock::LWP' => '0.05', 'JSON::XS' => '2.1', 'Net::SSLeay' => 0, }, ( $] >= 5.005 ? ## Add these new keywords supported since 5.005 ( ABSTRACT => 'Simple tool to use Socialtext RESTful API', AUTHOR => 'Chris Dent , Kirsten Jones ' ) : () ), ); my $response = prompt("Perform tests against Socialtext servers?", "y"); my $files; if ($response !~ /^y/i) { $files = join ' ', (grep {$_ !~ /resting\.t/} glob("t/*.t")); } else { $files = 't/*.t'; } $config{test} = { TESTS => $files }; WriteMakefile(%config);