File Coverage

lib/WWW/Workflowy.pm
Criterion Covered Total %
statement 299 385 77.6
branch 106 202 52.4
condition 20 58 34.4
subroutine 38 45 84.4
pod 10 11 90.9
total 473 701 67.4


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