File Coverage

lib/WWW/Workflowy.pm
Criterion Covered Total %
statement 281 326 86.2
branch 99 170 58.2
condition 16 38 42.1
subroutine 38 44 86.3
pod 9 10 90.0
total 443 588 75.3


line stmt bran cond sub pod time code
1              
2             package WWW::Workflowy;
3              
4 3     3   158048 use strict;
  3         7  
  3         105  
5 3     3   15 use warnings;
  3         6  
  3         83  
6              
7 3     3   12214 use LWP;
  3         258315  
  3         110  
8 3     3   146 use LWP::UserAgent;
  3         9  
  3         66  
9              
10 3     3   1471 use Data::Dumper;
  3         10324  
  3         231  
11 3     3   2585 use JSON::PP;
  3         29799  
  3         280  
12 3     3   2871 use POSIX 'floor';
  3         22077  
  3         20  
13 3     3   3570 use Carp;
  3         8  
  3         337  
14              
15             our $VERSION = '0.5';
16              
17             # XXX need a public get_parent( $node ), and other traversal stuff. we have a _find_parent() (which uses the recursive find logic).
18             # notes in /home/scott/projects/perl/workflowy_notes.txt
19              
20             # use autobox::Closure::Attributes; # XXX hacked up local copy that permits lvalue assigns
21              
22             =head1 NAME
23              
24             WWW::Workflowy - Faked up API interface to the workflowy.com collaborative outlining webapp
25              
26             =head1 SYNOPSIS
27              
28             B
29              
30             use WWW::Workflowy;
31              
32             my $wf = WWW::Workflowy->new(
33             url => 'https://workflowy.com/shared/b141ebc1-4c8d-b31a-e3e8-b9c6c633ca25/',
34             # or else: guid => 'b141ebc1-4c8d-b31a-e3e8-b9c6c633ca25',
35             );
36              
37             $node = $wf->dump;
38              
39             $node = $wf->find(
40             sub {
41             my $node = shift;
42             my @parent_nodes = @{ shift() };
43             return 1 if $node->{nm} eq 'The node you are looking for';
44             return 1 if $node->{id} eq 'Jxn637Zp-uA5O-Anw2-A4kq-4zqKx7WuJNBN';
45             },
46             );
47              
48             $node_id = $wf->create(
49             parent_id => 'Jxn637Zp-uA5O-Anw2-A4kq-4zqKx7WuJNBN',
50             priority => 3, # which position in the list of items under the parent to insert this node
51             text => "Don't forget to shave the yak",
52             );
53              
54              
55             $node = $wf->edit(
56             save_id => 'Jxn637Zp-uA5O-Anw2-A4kq-4zqKx7WuJNBN',
57             text => "Think of an idea for a color for the bikeshed",
58             );
59              
60             $wf->delete( node_id => $node->{id} );
61              
62             sleep $wf->polling_interval; $wf->sync;
63              
64             $wf->fetch;
65              
66             =head1 DESCRIPTION
67              
68             All methods C on error. Trap errors with L, C, or similar to attempt to recover from them.
69              
70             Each node has this structure:
71              
72             {
73             'lm' => 1270, # time delta last modified; usually not too interesting
74             'nm' => 'Test 2.1', # text
75             'id' => '63c98305-cd96-2016-4c4f-a20f7384ad9c' # id
76             }
77              
78             It may also have a C<'ch'> containing an arrayref of additional nodes.
79             To make things interesting, the root node does not have a C<'ch'> of nodes under it.
80             Use the C method to avoid dealing with this special case.
81              
82             The value from the C field is used as the value for C, C, or C
83             in other calls.
84              
85             =head2 new
86              
87             Takes C resembling C or a L C such as C.
88              
89             May also be initialized from a serialized copy of a previous C<$wf->outline>. See C for an example of that.
90              
91             Returns a coderef.
92              
93             =head2 dump
94              
95             Produces an ASCII representation of the outline tree.
96              
97             =head2 find
98              
99             Recurses through the entire outline tree, calling the callback for each item. The callback is passed the node currently being examined and an
100             arrayref of parents, top most parent first.
101              
102             =head2 edit
103              
104             Changes the text of a node.
105              
106             =head2 create
107              
108             Created a new node.
109              
110             =head2 delete
111              
112             Deletes a node.
113              
114             =head2 move
115              
116             No class method yet. The thing handles C commands sent down by the L server (when data was moved by another L client) but doesn't
117             yet let you send that command to the server.
118              
119             =head2 sync
120              
121             C fetches changes other people have made to the current L outline and attempts to merge them into the local outline.
122              
123             C, C, and C minipulate data locally but only queue it to later be sent to the L server.
124             Executing a C causes pending operations to be sent.
125              
126             B C returns B and does nothing if C<< $wf->polling_interval >> seconds have not yet passed
127             since the last request to the F server. Calling C generally results in a request to the F server.
128             To avoid C returning C and doing nothing, use this idiom:
129              
130             sleep $wf->polling_interval;
131             $wf->sync;
132              
133             C<< $wf->last_poll_time >> contains a timestamp of the time that the last request was made.
134             The value for C<< $wf->polling_interval >> may change in response to a request to the server.
135              
136             =head2 fetch
137              
138             Fetches the latest copy of the outline from the L server, blowing away any local changes made to it that haven't yet been pushed up.
139             This happens automatically on C.
140              
141             =head2 get_children
142              
143             Takes a node id. Returns an arrayref of a node's children if it has children, or false otherwise.
144              
145             =cut
146              
147             package autobox::Closure::XAttributes::Methods;
148              
149 3     3   17 use base 'autobox';
  3         6  
  3         2670  
150 3     3   16384 use B;
  3         7  
  3         260  
151 3     3   2326 use PadWalker;
  3         2351  
  3         1068  
152              
153             sub AUTOLOAD :lvalue {
154 27     27   24422 my $code = shift;
155 27         177 (my $method = our $AUTOLOAD) =~ s/.*:://;
156 27 50       82 return if $method eq 'DESTROY';
157              
158             # we want the scalar unless the method name already a sigil
159 27 50       102 my $attr = $method =~ /^[\$\@\%\&\*]/ ? $method : '$' . $method;
160              
161 27         286 my $closed_over = PadWalker::closed_over($code);
162              
163             # is there a method of that name in the package the coderef was created in?
164             # if so, run it.
165             # give methods priority over the variables we close over.
166             # XXX this isn't lvalue friendly, but sdw can't figure out how to make it be and not piss off old perls.
167              
168 27         332 my $stash = B::svref_2object($code)->STASH->NAME;
169 27 100 66     377 if( $stash and $stash->can($method) ) {
170             # t/003-live-test.t .............. Can't modify non-lvalue subroutine call at lib/WWW/Workflowy.pm line 170. in perl 5.14.2
171             # goto apparently cheats lvalue detection; cheating detection is adequate for our purposes.
172             # return $stash->can($method)->( $code, @_ );
173 9         27 @_ = ( $code, @_ ); goto &{ $stash->can($method) };
  9         12  
  9         67  
174             }
175              
176 18 50       49 exists $closed_over->{$attr} or Carp::croak "$code does not close over $attr";
177              
178 18         45 my $ref = ref $closed_over->{$attr};
179              
180 18 50       40 if (@_) {
181 0 0       0 return @{ $closed_over->{$attr} } = @_ if $ref eq 'ARRAY';
  0         0  
182 0 0       0 return %{ $closed_over->{$attr} } = @_ if $ref eq 'HASH';
  0         0  
183 0         0 return ${ $closed_over->{$attr} } = shift;
  0         0  
184             }
185              
186 18 50 33     79 $ref eq 'HASH' || $ref eq 'ARRAY' ? $closed_over->{$attr} : ${ $closed_over->{$attr} }; # lvalue friendly return
  18         10000275  
187              
188             }
189              
190             #
191             #
192             #
193              
194             package WWW::Workflowy;
195              
196 3     3   18 use autobox CODE => 'autobox::Closure::XAttributes::Methods'; # XXX temp since we can't 'use' it because it's inline
  3         5  
  3         20  
197              
198             sub import {
199 3     3   32 my $class = shift;
200 3         42 $class->autobox::import(CODE => 'autobox::Closure::XAttributes::Methods');
201             }
202              
203             sub new {
204              
205 3     3 1 133 my $package = shift;
206 3         13 my %args = @_;
207              
208             #
209              
210 3         7 my $outline;
211             my $client_id;
212 0         0 my $date_joined;
213 0         0 my $last_transaction_id; # transaction ids are much alrger than the lastModified/lm values; eg 106551357; comes from initialMostRecentOperationTransactionId then $result_json->{results}->[0]->{new_most_recent_operation_transaction_id}
214 3         7 my $operations = []; # edits we've made but not yet posted
215 3         7 my $polling_interval; # from $outline->{initialPollingIntervalInMs} and then ->{results}->[0]->{new_polling_interval_in_ms}
216             my $last_poll_time;
217              
218             #
219              
220 3 50 33     59 if( $args{guid} and ! $args{url} ) {
    100 66        
    50 33        
    50          
221             # https://workflowy.com/shared/b141ebc1-4c8d-b31a-e3e8-b9c6c633ca25/
222 0         0 $args{url} = "http://workflowy.com/shared/$args{guid}/";
223             } elsif( ! $args{guid} and $args{url} ) {
224 1 50       15 ($args{guid}) = $args{url} =~ m{/shared/(.*?)/\w*$} or confess "workflowy url doesn't match pattern of ``.*/shared/.*/''";
225             } elsif( $args{guid} and $args{url} ) {
226 0         0 confess "don't pass both guid and url parameters; pass one or the other";
227             } elsif( $args{outline} ) {
228             # testing -- pass in an outline
229 2         8 $outline = delete $args{outline};
230 2 50       12 $last_transaction_id = $outline->{initialMostRecentOperationTransactionId} or confess "no initialMostRecentOperationTransactionId in serialized outline";
231 2         5 $date_joined = $outline->{dateJoinedTimestampInSeconds}; # XXX probably have to compute clock skew (diff between time() and this value) and use that when computing $client_timestamp
232             } else {
233 0         0 confess "pass guid or url";
234             }
235              
236 3         10 my $workflowy_url = delete $args{url};
237 3         8 my $shared_projectid = delete $args{guid};
238              
239             #
240              
241 3 50       15 confess "unknown args to new(): " . join ', ', keys %args if keys %args;
242              
243             #
244              
245 3         43 my $user_agent = LWP::UserAgent->new(agent => "Mozilla/5.0 (Windows NT 5.1; rv:5.0.1) Gecko/20100101 Firefox/5.0.1");
246 3 50       16624 $user_agent->cookie_jar or $user_agent->cookie_jar( { } );
247              
248             #
249              
250             my $fetch_outline = sub {
251              
252 1     1   10 my $http_request = HTTP::Request->new( GET => "http://workflowy.com/get_initialization_data?shared_projectid=$shared_projectid" );
253            
254 1         10411 my $response = $user_agent->request($http_request);
255 1 50       928467 if( $response->is_error ) {
256 0         0 confess $response->error_as_HTML ;
257             }
258            
259 1 50       27 my $decoded_content = $response->decoded_content or die "no response content";
260              
261 1 50       654 my $response_json = decode_json $decoded_content or die "failed to decode response as JSON";
262              
263 1 50       42147 $client_id = $response_json->{projectTreeData}->{clientId} or die "couldn't find clientId in project JSON";
264              
265 1         5 $outline = $response_json->{projectTreeData}->{mainProjectTreeInfo};
266              
267 1 50       7 $last_transaction_id = $outline->{initialMostRecentOperationTransactionId} or die "couldn't find initialMostRecentOperationTransactionId in project JSON";
268              
269 1 50       6 $date_joined = $outline->{dateJoinedTimestampInSeconds} or die "couldn't find dateJoinedTimestampInSeconds in project JSON"; # XXX probably have to compute clock skew (diff between time() and this value) and use that when computing $client_timestamp
270 1 50       5 $outline->{initialPollingIntervalInMs} or die "couldn't find initialPollingIntervalInMs in project JSON";
271 1         5 $polling_interval = $outline->{initialPollingIntervalInMs} / 1000;
272 1         3 $last_poll_time = time;
273              
274 1         87 return $outline;
275            
276 3         35079 };
277              
278 3 100       31 $fetch_outline->() if ! $outline;
279              
280             #
281            
282             my $get_client_timestamp = sub {
283             # adapted from this JS:
284             # var a = datetime.getCurrentTimeInMS() / 1E3 - this.dateJoinedTimestampInSeconds; # / 1E3 should take it to seconds, I think
285             # return Math.floor(a / 60)
286 10     10   63 floor( ( time() - $date_joined ) / 60 );
287 3         20 };
288              
289             my $local_create_node = sub {
290 5     5   17 my %args = @_;
291 5 50 66     26 my $parent_id = $args{parent_id} || $args{parent_node}->{id} or confess;
292 5 50       18 my $new_node = $args{new_node} or confess;
293 5 50       9 my $priority = $args{priority}; defined $priority or confess;
  5         15  
294 5 50       16 my( $parent_node, $children ) = _find_node( $outline, $parent_id ) or confess "couldn't find node for $parent_id in edit in create_node";
295 5 100       15 if( ! $children ) {
296 1 50       4 if( $parent_id eq $shared_projectid ) {
297             # root node
298 0   0     0 $children = ( $outline->{rootProjectChildren} ||= [] );
299             } else {
300 1   50     9 $children = ( $parent_node->{ch} ||= [] );
301             }
302             }
303 5 100       15 $priority = @$children if $priority > $#$children;
304 5         8 splice @{ $children }, $priority, 0, $new_node;
  5         14  
305 5         39 1;
306 3         23 };
307              
308             my $local_edit_node = sub {
309 4     4   48 my %args = @_;
310 4   66     22 $args{node} ||= _find_node( $outline, $args{node_id} );
311 4 50       13 my $node = $args{node} or confess "no node or node_id passed to local_edit_node, or can't find the node: " . Data::Dumper::Dumper \%args;
312 4 50       12 exists $args{text} or confess;
313 4         10 $node->{nm} = $args{text};
314 4         9 $node->{lm} = $get_client_timestamp->();
315 4         17 1;
316 3         39 };
317              
318             my $local_delete_node = sub {
319 2     2   7 my %args = @_;
320 2         4 my $node_id = $args{node_id};
321 2 50 33     11 $node_id = $args{node}->{id} if $args{node} and ! $node_id;
322 2 50       8 $node_id or confess;
323 2         7 my ( $parent_node, $node, $priority, $siblings ) = _find_parent($outline, $node_id );
324 2         9 _filter_out( $siblings, $node_id );
325 2         8 1;
326 3         21 };
327              
328             my $local_move_node = sub {
329             # XXX
330             # executing ``move'' on data: $VAR1 = {
331             # 'priority' => 0,
332             # 'projectid' => 'acafae16-c8f0-b7a6-d44c-4672f68815da',
333             # 'parentid' => 'c23ef558-3b78-cd2e-59df-7e578cded1e1'
334             # };
335 1     1   5 my %args = @_;
336 1 50       5 my $node_id = $args{node_id} or confess;
337 1 50       3 my $parent_id = $args{parent_id} or confess; # new parent
338 1 50       3 my $priority = $args{priority}; defined $priority or confess;
  1         3  
339              
340 1 50       3 my $node = _find_node( $outline, $node_id ) or confess "couldn't find node for $node_id in local_move_node";
341              
342             # remove it from where it was
343 1         8 $local_delete_node->( node_id => $node_id );
344              
345             # insert it where it's going
346 1         3 $local_create_node->( parent_id => $parent_id, new_node => $node, priority => $priority, );
347              
348 3         20 };
349              
350             #
351              
352             my $update_outline = sub {
353              
354             # XXX currently only pushing changes up to workflowy, not merging in changes from workflowy; we have to re-fetch the outline to update our copy of it
355              
356 3     3   14 my %args = @_;
357            
358 3         7 my $cmd = delete $args{cmd};
359 3         6 my $node_id = delete $args{node_id};
360 3         5 my $text = delete $args{text};
361              
362             # for cmd=create
363 3         5 my $parent_id = delete $args{parent_id};
364 3         6 my $priority = delete $args{priority};
365              
366 3 50       10 confess "unknown args to update_outline: " . join ', ', keys %args if keys %args;
367              
368 3         7 my $client_timestamp = $get_client_timestamp->();
369              
370 3         6 my $new_node_id; # set on cmd='create' # XXX should return the created/modified node
371              
372 3 100       13 if( $cmd eq 'edit' ) {
    100          
    50          
373              
374 1 50       4 my $node = _find_node( $outline, $node_id ) or confess "couldn't find node for $node_id in edit in update_outline";
375              
376             # queue the changes to get pushed up to workflowy
377              
378 1         7 push @$operations, {
379             type => 'edit',
380             client_timestamp => $client_timestamp,
381             data => {
382             name => $text,
383             projectid => $node_id,
384             },
385             undo_data => {
386             previous_last_modified => $node->{lm},
387             previous_name => $node->{nm},
388             },
389             };
390              
391 1         4 $local_edit_node->( node => $node, text => $text );
392              
393             } elsif( $cmd eq 'create' ) {
394              
395             # my ( $parent_node, $node, $priority, $siblings) = _find_parent( $outline, $parent_id );
396             # confess 'create cannot create additional root nodes' unless $parent_node; # really, we can't, not even if we wanted to! the array of siblings is faked up from the one root node ... but no, this can't happen, because the user is passing the parent's ID. we don't want the parent's parent.
397              
398 1 50       5 my( $parent, $children ) = _find_node( $outline, $parent_id ) or confess "couldn't find node for $node_id in edit in update_outline";
399              
400             my $n_rand_chrs = sub {
401 5         5 my $n = shift;
402 5         92 join('', map { $_->[int rand scalar @$_] } (['a'..'z', 'A'..'Z', '0' .. '9']) x $n);
  32         88  
403 1         4 };
404              
405             # 0da22641-65bf-9e96-70e7-dcc42c388cf3
406 1         5 $new_node_id = join '-', map $n_rand_chrs->($_), 8, 4, 4, 4, 12;
407              
408 1         14 push @$operations, {
409             type => 'create',
410             undo_data => {},
411             client_timestamp => $client_timestamp,
412             data => {
413             priority => $priority,
414             projectid => $new_node_id,
415             parentid => $parent_id,
416             },
417             }, {
418             type => 'edit',
419             undo_data => {
420             previous_last_modified => $client_timestamp,
421             previous_name => '',
422             },
423             client_timestamp => $client_timestamp,
424             data => {
425             name => $text,
426             projectid => $new_node_id,
427             },
428             };
429              
430 1         4 my $new_node = {
431             id => $new_node_id,
432             nm => $text,
433             lm => $client_timestamp,
434             };
435              
436 1         3 $local_create_node->( parent_node => $parent, new_node => $new_node, priority => $priority, );
437              
438             } elsif( $cmd eq 'delete' ) {
439              
440 1         4 my ( $parent_node, $node, $priority, $siblings ) = _find_parent($outline, $node_id );
441              
442 1 50       11 push @$operations, {
443             undo_data => {
444             priority => $priority,
445             previous_last_modified => $node->{lm},
446             parentid => $parent_node ? $parent_node->{id} : 'None',
447             },
448             client_timestamp => $client_timestamp,
449             type => 'delete',
450             data => {
451             projectid => $node_id, # the node id of the node being deleted; not the actual shared_projectid
452             },
453             };
454              
455 1         3 $local_delete_node->( node_id => $node_id );
456              
457             }
458              
459             #
460              
461 3         11 return $new_node_id; # set if cmd = 'create'
462              
463 3         25 };
464              
465             #
466              
467             my $run_remote_operations = sub {
468 3     3   6 my $run_ops = shift;
469 3 50       12 $run_ops->{ops} or confess Data::Dumper::Dumper $run_ops;
470 3         4 for my $op ( @{ $run_ops->{ops} } ) {
  3         6  
471              
472 7         13 my $type = $op->{type};
473 7         10 my $data = $op->{data};
474              
475 7 100       27 if( $type eq 'create' ) {
    100          
    50          
    50          
476              
477 3         7 my $client_timestamp = $get_client_timestamp->();
478              
479 3         12 my $new_node = {
480             id => $data->{projectid},
481             nm => '',
482             lm => $client_timestamp,
483             };
484              
485 3         7 my $parent_id = $data->{parentid};
486 3 50       6 $parent_id = $shared_projectid if $parent_id eq 'None';
487              
488 3         10 $local_create_node->( parent_id => $parent_id, new_node => $new_node, priority => $data->{priority}, );
489            
490             } elsif( $type eq 'edit' ) {
491              
492 3         11 $local_edit_node->( node_id => $data->{projectid}, text => $data->{name}, );
493              
494             } elsif( $type eq 'delete' ) {
495              
496 0         0 $local_delete_node->( node_id => $data->{projectid}, );
497              
498             } elsif( $type eq 'move' ) {
499              
500 1         5 $local_move_node->( node_id => $data->{projectid}, parent_id => $data->{parentid}, priority => $data->{priority}, );
501              
502             }
503             }
504              
505 3         18 };
506              
507             #
508              
509             my $sync_changes = sub {
510              
511 1     1   12 my $r = HTTP::Request->new( POST => "https://workflowy.com/push_and_poll" );
512              
513 1         190 $r->header( 'X-Requested-With' => 'XMLHttpRequest' );
514 1         89 $r->header( 'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8' );
515 1         46 $r->header( 'Referer' => $workflowy_url );
516              
517 1 50       44 $last_transaction_id or confess "no value in last_transaction_id in sync_changes";
518              
519 1         8 my $push_poll_data = [{
520             most_recent_operation_transaction_id => $last_transaction_id,
521             shared_projectid => $shared_projectid,
522             operations => $operations,
523             }];
524              
525 1         3 my $post = '';
526 1         5 $post .= 'client_id=' . _escape($client_id);
527 1         3 $post .= '&client_version=10';
528 1         25 $post .= '&push_poll_id=' . join('', map { $_->[int rand scalar @$_] } (['a'..'z', 'A'..'Z', '0' .. '9']) x 8); # XX guessing; seems to work though
  8         23  
529 1         9 $post .= '&shared_projectid=' . $shared_projectid;
530 1         8 $post .= '&push_poll_data=' . _escape( encode_json( $push_poll_data ) );
531              
532             # warn "JSON sending: " . JSON::PP->new->pretty->encode( $push_poll_data );
533              
534 1         10 $r->content( $post );
535              
536 1         31 my $response = $user_agent->request($r);
537 1 50       717933 if( $response->is_error ) {
538 0         0 confess "error: " . $response->error_as_HTML;
539 0         0 return;
540             }
541              
542 1         21 my $decoded_content = $response->decoded_content;
543              
544 1         121 my $result_json = decode_json $decoded_content;
545              
546             # "new_most_recent_operation_transaction_id": "106843573"
547             # warn Data::Dumper::Dumper $result_json;
548              
549             # warn JSON::PP->new->pretty->encode( $push_poll_data ); # <--- good for debugging
550             # warn JSON::PP->new->pretty->encode( $result_json );
551              
552 1 50       11494 $result_json->{results}->[0]->{error} and die "workflowy.com request failed with an error: ``$result_json->{results}->[0]->{error}''; response was: $decoded_content\npush_poll_data is: " . JSON::PP->new->pretty->encode( $push_poll_data );
553              
554 1 50       10 $last_transaction_id = $result_json->{results}->[0]->{new_most_recent_operation_transaction_id} or confess "no new_most_recent_operation_transaction_id in sync changes\nresponse was: $decoded_content\npush_poll_data was: " . JSON::PP->new->pretty->encode( $push_poll_data );
555              
556 1   50     8 $polling_interval = ( $result_json->{results}->[0]->{new_polling_interval_in_ms} || 1000 ) / 1000; # XXX this was probably just undef when we ignored an error before the checking above was added
557 1         2 $last_poll_time = time;
558              
559             #
560              
561             # $results->[*]->{server_run_operation_transaction_json} is what we already did to our own copy of the outline; not sure if we should double check
562              
563             # XXX call fetch_outline if the server sent us any deltas; or else attempt to mirror those changes
564              
565 1         4 my $run_operations = $result_json->{results}->[0]->{concurrent_remote_operation_transactions};
566 1         5 for my $run_op ( @$run_operations ) {
567 0         0 my $decoded_run_op = decode_json $run_op;
568 0         0 $run_remote_operations->( $decoded_run_op );
569             }
570              
571             #
572              
573 1         68 $operations = [];
574              
575 3         73 };
576              
577             #
578              
579             my $self = sub {
580              
581 4     4   7 my $action = shift;
582              
583             # important symbols
584              
585 4 50       12 $outline or confess "no outline"; # shouldn't happen
586 4 50       11 $shared_projectid or confess "no shared_projectid"; # shouldn't happen
587              
588 4 100       15 if( $action eq 'edit' ) {
589 1         3 my %args = @_;
590 1 50       5 my $save_id = delete $args{save_id} or confess "pass a save_id parameter";
591 1 50       4 my $text = delete $args{text} or confess "pass a text parameter";
592              
593 1         5 $update_outline->(
594             cmd => 'edit',
595             text => $text,
596             node_id => $save_id,
597             );
598              
599 1         4 return 1;
600             }
601              
602 3 100       9 if( $action eq 'create' ) {
603              
604             # $update_outline returns the id of the newly created node for cmd='create'
605              
606 1         4 my %args = @_;
607 1         5 my $parent_id = delete $args{parent_id};
608 1         3 my $text = delete $args{text};
609 1         2 my $priority = delete $args{priority};
610              
611 1         3 return $update_outline->(
612             cmd => 'create',
613             text => $text,
614             parent_id => $parent_id, # for cmd=create
615             priority => $priority, # for cmd=create
616             );
617              
618             }
619              
620 2 100       8 if( $action eq 'delete' ) {
621 1         2 my %args = @_;
622 1 50       35 my $node_id = delete $args{node_id} or confess "pass a node_id parameter";
623              
624 1         6 $update_outline->(
625             cmd => 'delete',
626             node_id => $node_id,
627             );
628              
629 1         4 return 1;
630              
631             }
632              
633 1 50       5 if( $action eq 'sync' ) {
634              
635 1 50       8 if( ( time - $last_poll_time ) < $polling_interval ) {
636 0         0 return;
637             }
638              
639 1         4 $sync_changes->();
640              
641 1         8 return 1;
642             }
643              
644 0 0 0     0 if( $action eq 'fetch' or $action eq 'read' or $action eq 'get' ) {
      0        
645             # XXX reconcile this with sync
646 0         0 $fetch_outline->();
647 0         0 return 1;
648             }
649              
650 3         35 };
651             }
652              
653 1     1 1 2 sub edit { my $self = shift; $self->( 'edit', @_ ); }
  1         4  
654 1     1 1 3 sub create { my $self = shift; $self->( 'create', @_ ); }
  1         4  
655 1     1 1 2 sub delete { my $self = shift; $self->( 'delete', @_ ); }
  1         3  
656 1     1 1 2 sub sync { my $self = shift; $self->( 'sync', @_ ); }
  1         5  
657 0     0 1 0 sub fetch { my $self = shift; $self->( 'fetch', @_ ); }
  0         0  
658              
659             sub find {
660             # external API takes $self
661 5     5 1 6 my $self = shift;
662 5 50       13 my $cb = shift or confess "pass a callback";
663              
664 5         28 _find( $self->outline, $cb);
665             }
666              
667             sub find_by_id {
668             # external API takes $self
669 0     0 0 0 my $self = shift;
670 0 0       0 my $id = shift or confess "pass id";
671 0     0   0 _find( $self->outline, sub { $_[0]->{id} eq $id } );
  0         0  
672             }
673              
674             sub _find {
675 22     22   32 my $outline = shift;
676 22 50       53 my $cb = shift or confess "pass a callback";
677              
678             # $outline->{rootProject} points to the root node; $outline->{rootProjectChlidren} has its children; this is wonky; normally, $node->{ch} has a nodes children
679             # temporarily put rootProjectChildren under rootProject so we can recurse through this nicely
680              
681 22         75 local $outline->{rootProject}->{ch} = $outline->{rootProjectChildren};
682 22         102 my $fake_root = { lm => 0, nm => '', id => '0', ch => [ $outline->{rootProject} ], fake => 1, };
683              
684 22         50 return _find_inner( $fake_root, $cb, );
685             }
686              
687              
688             sub _find_inner {
689             # there's no $self inside the coderef so stuff in there calls this directly
690 68     68   81 my $node = shift;
691 68 50       126 my $cb = shift or confess;
692 68   100     167 my $stack = shift() || [ $node ];
693 68         79 my $position = 0;
694 68         72 for my $child ( @{ $node->{ch} } ) {
  68         146  
695 131 100       238 return $child if $cb->( $child, $stack, $position );
696 111 100       475 if( $child->{ch} ) {
697 46         138 my $node = _find_inner( $child, $cb, [ @$stack, $child ], );
698 46 100       183 return $node if $node;
699             }
700 83         133 $position++;
701             }
702             }
703              
704             sub _find_node {
705 11     11   15 my $outline = shift;
706 11         24 my $node_id = shift;
707              
708 11         14 my $node;
709             my $children; # since we temporarily attached the tree to the root node, $node->{ch} won't be valid if we return the root node
710              
711             _find( $outline, sub {
712 42     42   41 my $child = shift;
713 42 100       90 if( $child->{id} eq $node_id ) {
714 11         14 $node = $child;
715 11         16 $children = $node->{ch};
716 11         43 return 1; # stop looking
717             }
718 31         64 return 0; # keep looking
719 11         56 } );
720              
721 11 100       76 return wantarray ? ( $node, $children ) : $node;
722             }
723              
724             sub _find_parent {
725 6     6   9 my $outline = shift; # we want this
726 6         11 my $node_id = shift;
727              
728             # return if $outline->{rootProject}->{id} eq $node_id; # not an error, just no parent; rootProject->id is the same as $shared_projectid; should be redundant
729              
730             # $outline->{rootProject} points to the root node; $outline->{rootProjectChlidren} has its children; this is wonky; normally, $node->{ch} has a nodes children
731             # temporarily put rootProjectChildren under rootProject so we can recurse through this nicely
732              
733             # $outline->{rootProject}->{ch} = $outline->{rootProjectChildren}; # _find doesthis now
734              
735 6         9 my $parent_node;
736             my $node;
737 0         0 my $priority;
738 0         0 my $parents_children; # since we temporarily attached the tree to the root node, $node->{ch} won't be valid if we return the root node
739              
740             _find( $outline, sub {
741 26     26   28 my $child = shift;
742 26         24 my @parent_nodes = @{ shift() };
  26         44  
743 26 100       74 if( $child->{id} eq $node_id ) {
744 6         10 $node = $child;
745 6 50       20 $parent_node = @parent_nodes ? $parent_nodes[-1] : undef;
746 6         11 $priority = shift;
747 6         10 $parents_children = $parent_node->{ch};
748 6         26 return 1; # stop looking
749             }
750 20         49 return 0; # keep looking
751 6         38 } );
752              
753             # delete $outline->{rootProject}->{ch}; # _find handles this now
754              
755 6 100       40 $parent_node = undef if $parent_node->{fake}; # don't return our faked up root node
756              
757 6 50       27 return wantarray ? ( $parent_node, $node, $priority, $parents_children ) : $parent_node;
758              
759             }
760              
761             sub get_children {
762 0     0 1 0 my $self = shift;
763 0 0       0 my $node_id = shift or confess "pass a node id";
764 0 0       0 (undef, my $children) = _find_node( $self->outline, $node_id ) or confess;
765 0         0 return $children;
766             }
767              
768             sub _filter_out {
769 2 50   2   8 my $arr = shift or confess;
770 2 50       7 my $node_id = shift or confess;
771 2         16 for my $i ( 0 .. $#$arr ) {
772 5 100       16 if( $arr->[$i]->{id} eq $node_id ) {
773 2         4 splice @$arr, $i, 1, ();
774 2         5 return 1;
775             }
776             }
777             }
778              
779             sub dump {
780 0     0 1 0 my $self = shift;
781              
782 0         0 my $output = '';
783              
784             $self->find( sub {
785 0     0   0 my $child = shift;
786 0         0 my @parent_nodes = @{ shift() };
  0         0  
787 0         0 $output .= $child->{id} . ' ';
788 0         0 $output .= ' ' x scalar @parent_nodes;
789 0         0 $output .= $child->{nm} . "\n";
790 0         0 0;
791 0         0 } );
792              
793 0         0 return $output;
794              
795             }
796              
797             sub _escape {
798 2     2   1496 my $arg = shift;
799 2         11 $arg =~ s/([^a-zA-Z0-9_.-])/uc sprintf("%%%02x",ord($1))/eg;
  215         731  
800 2         15 $arg;
801             }
802              
803              
804             =head1 SEE ALSO
805              
806             =head1 BUGS
807              
808             Remote changes are not merged with forgiveness. For example, if you delete a node, someone else edits the node concurrently, and then you do a
809             C operation, L will blow up when it can't find the node to edit. Forgiveness should be optional.
810              
811             L versions their protocol. This module targets C<10>. The protocol is not a documented API. This module will likely stop working without
812             notice. This module does things like parse out JSON from JavaScript code using regexen.
813              
814             =head1 AUTHOR
815              
816             Scott Walters, Escott@slowass.netE
817              
818             =head1 COPYRIGHT AND LICENSE
819              
820             Copyright (C) 2013 by Scott Walters
821              
822             This library is free software; you can redistribute it and/or modify
823             it under the same terms as Perl itself, either Perl version 5.8.9 or,
824             at your option, any later version of Perl 5 you may have available.
825              
826             =cut
827              
828             #
829             # pasted in copy of my hacked up autobox::Attribute::Closures
830             #
831              
832              
833              
834             1;
835              
836             __DATA__