1#!/usr/bin/env perl
2
3use strict;
4use warnings;
5use Cwd 'abs_path';
6use DateTime;
7use DateTime::Format::Strptime;
8use Encode;
9use Encode::Locale;
10use File::Basename qw(dirname basename);
11use File::Spec::Functions qw(abs2rel catfile);
12use File::Temp;
13use Getopt::Std;
14use JSON;
15use Path::Class;
16use POSIX qw();
17use WebService::Dropbox 2.06;
18
19our $VERSION = '2.13';
20
21my $limit = 10 * 1024 * 1024; # files_put_chunked method has large file support.
22
23if ($^O eq 'darwin') {
24    require Encode::UTF8Mac;
25    $Encode::Locale::ENCODING_LOCALE_FS = 'utf-8-mac';
26}
27
28binmode STDOUT, ':utf8';
29binmode STDERR, ':utf8';
30
31my $config_file = file( $ENV{DROPBOX_CONF} || ($ENV{HOME} || $ENV{HOMEPATH}, '.dropbox-api-config') );
32
33my $command = shift || '';
34my @args;
35for (@{ [ @ARGV ] }) {
36    last if $_ =~ qr{ \A - }xms;
37    push @args, shift;
38}
39
40my %opts;
41if ($command eq 'du') {
42    getopts('vDhed:', \%opts);
43} else {
44    getopts('ndvDshePp:', \%opts);
45}
46
47push @args, @ARGV;
48
49my $dry       = $opts{n};
50my $delete    = $opts{d};
51my $verbose   = $opts{v};
52my $debug     = $opts{D};
53my $human     = $opts{h};
54my $printf    = $opts{p};
55my $public    = $opts{P};
56my $env_proxy = $opts{e};
57my $max_depth = $opts{d};
58
59if ($opts{s}) {
60    die "-s is gone.";
61}
62
63if ($command eq '-v') {
64    &help('version');
65    exit(0);
66}
67
68if ($command eq 'setup' || !-f $config_file) {
69    &setup();
70}
71
72# connect dropbox
73my $config = decode_json($config_file->slurp);
74$config->{key} or die 'please set config key.';
75$config->{secret} or die 'please set config secret.';
76$config->{access_token} or die 'please set config access_token.';
77if ($config->{access_secret}) {
78    warn "Auto migration OAuth1 Token to OAuth2 token...";
79    my $oauth2_access_token = &token_from_oauth1($config->{key}, $config->{secret}, $config->{access_token}, $config->{access_secret});
80    if ($oauth2_access_token) {
81        delete $config->{access_secret};
82        $config->{access_token} = $oauth2_access_token;
83        $config_file->openw->print(encode_json($config));
84        warn "=> Suucess.";
85    } else {
86        die "please setup.";
87    }
88}
89if (my $access_level = delete $config->{access_level}) {
90    if ($access_level eq 'a') {
91        print "sandbox is gone, Are you sure you want to delete from the config the access_level? [y/n]: ";
92        chomp( my $y = <STDIN> );
93        if ($y =~ qr{ [yY] }xms) {
94            delete $config->{access_level};
95            $config_file->openw->print(encode_json($config));
96            warn "=> Suucess.";
97        } else {
98            die "cancelled.";
99        }
100    }
101}
102
103$ENV{HTTP_PROXY} = $ENV{http_proxy} if !$ENV{HTTP_PROXY} && $ENV{http_proxy};
104$ENV{NO_PROXY} = $ENV{no_proxy} if !$ENV{NO_PROXY} && $ENV{no_proxy};
105
106my $box = WebService::Dropbox->new($config);
107$box->env_proxy if $env_proxy;
108
109my $strp = new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%T' );
110my $strpz = new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%TZ' );
111
112my $format = {
113    i => 'id',
114    n => 'name',
115    b => 'size',
116    e => 'thumb_exists', # jpg, jpeg, png, tiff, tif, gif or bmp
117    d => 'is_dir',       # Check if .tag = "folder"
118    p => 'path_display',
119    P => 'path_lower',
120    s => 'format_size',
121    t => 'server_modified',
122    c => 'client_modified', # For files, this is the modification time set by the desktop client when the file was added to Dropbox.
123    r => 'rev', # A unique identifier for the current revision of a file. This field is the same rev as elsewhere in the API and can be used to detect changes and avoid conflicts.
124    R => 'rev',
125};
126
127# ProgressBar
128my $cols = 50;
129if ($verbose) {
130    eval {
131        my $stty = `stty -a 2>/dev/null`;
132        if ($stty =~ m|columns (\d+)| || $stty =~ m|(\d+) columns|) {
133            $cols = $1;
134        }
135    };
136}
137
138my $exit_code = 0;
139
140if ($command eq 'ls' or $command eq 'list') {
141    &list(@args);
142} elsif ($command eq 'find') {
143    &find(@args);
144} elsif ($command eq 'du') {
145    &du(@args);
146} elsif ($command eq 'copy' or $command eq 'cp') {
147    &copy(@args);
148} elsif ($command eq 'move' or $command eq 'mv') {
149    &move(@args);
150} elsif ($command eq 'mkdir' or $command eq 'mkpath') {
151    &mkdir(@args);
152} elsif ($command eq 'delete' or $command eq 'rm' or $command eq 'rmtree') {
153    &delete(@args);
154} elsif ($command eq 'upload' or $command eq 'up' or $command eq 'put') {
155    &upload(@args);
156} elsif ($command eq 'download' or $command eq 'dl' or $command eq 'get') {
157    &download(@args);
158} elsif ($command eq 'sync') {
159    &sync(@args);
160} elsif ($command eq 'help' or (not length $command)) {
161    &help(@args);
162} else {
163    die "unknown command $command";
164}
165
166exit($exit_code);
167
168sub help {
169    my ($command) = @_;
170
171    $command ||= '';
172
173    my $help;
174    if ($command eq 'ls' or $command eq 'list') {
175        $help = q{
176        Name
177            dropbox-api-ls - list directory contents
178
179        SYNOPSIS
180            dropbox-api ls <dropbox_path> [options]
181
182        Example
183            dropbox-api ls /Public
184            dropbox-api ls /Public -h
185            dropbox-api ls /Public -p "%d\t%s\t%TY/%Tm/%Td %TH:%TM:%TS\t%p\n"
186
187        Options
188            -h print sizes in human readable format (e.g., 1K 234M 2G)
189            -p print format.
190                %d ... is_dir ( d: dir, -: file )
191                %i ... id
192                %n ... name
193                %p ... path_display
194                %P ... path_lower
195                %b ... bytes
196                %s ... size (e.g., 1K 234M 2G)
197                %t ... server_modified
198                %c ... client_modified
199                %r ... rev
200                %Tk ... DateTime 'strftime' function (server_modified)
201                %Ck ... DateTime 'strftime' function (client_modified)
202        };
203    } elsif ($command eq 'find') {
204        $help = q{
205        Name
206            dropbox-api-find - walk a file hierarchy
207
208        SYNOPSIS
209            dropbox-api find <dropbox_path> [options]
210
211        Example
212            dropbox-api find /Public
213            dropbox-api find /Public -h
214            dropbox-api find /Public -p "%d\t%s\t%TY/%Tm/%Td %TH:%TM:%TS\t%p\n"
215
216        Options
217            -h print sizes in human readable format (e.g., 1K 234M 2G)
218            -p print format.
219                %d ... is_dir ( d: dir, -: file )
220                %i ... id
221                %n ... name
222                %p ... path_display
223                %P ... path_lower
224                %b ... bytes
225                %s ... size (e.g., 1K 234M 2G)
226                %t ... server_modified
227                %c ... client_modified
228                %r ... rev
229                %Tk ... DateTime 'strftime' function (server_modified)
230                %Ck ... DateTime 'strftime' function (client_modified)
231        };
232    } elsif ($command eq 'du') {
233        $help = q{
234        Name
235            dropbox-api-du - list directory contents
236
237        SYNOPSIS
238            dropbox-api du <dropbox_path> [options]
239
240        Example
241            dropbox-api du /Public
242            dropbox-api du / -h
243            dropbox-api du / -d 1
244
245        Options
246            -h print sizes in human readable format (e.g., 1K 234M 2G)
247            -d depth.
248        };
249    } elsif ($command eq 'copy' or $command eq 'cp') {
250        $help = q{
251        Name
252            dropbox-api-cp - copy file or directory
253
254        SYNOPSIS
255            dropbox-api cp <source_file> <target_file>
256
257        Example
258            dropbox-api cp /Public/hoge.txt /Public/foo.txt
259            dropbox-api cp /Public/work /Public/work_bak
260        };
261    } elsif ($command eq 'move' or $command eq 'mv') {
262        $help = q{
263        Name
264            dropbox-api-mv - move file or directory
265
266        SYNOPSIS
267            dropbox-api mv <source_file> <target_file>
268
269        Example
270            dropbox-api mv /Public/hoge.txt /Public/foo.txt
271            dropbox-api mv /Public/work /Public/work_bak
272        };
273    } elsif ($command eq 'mkdir' or $command eq 'mkpath') {
274        $help = q{
275        Name
276            dropbox-api-mkdir - make directory (Create intermediate directories as required)
277
278        SYNOPSIS
279            dropbox-api mkdir <directory>
280
281        Example
282            dropbox-api mkdir /Public/product/chrome-extentions/foo
283        };
284    } elsif ($command eq 'delete' or $command eq 'rm' or $command eq 'rmtree') {
285        $help = q{
286        Name
287            dropbox-api-rm - remove file or directory (Attempt to remove the file hierarchy rooted in each file argument)
288
289        SYNOPSIS
290            dropbox-api rm <file_or_directory>
291
292        Example
293            dropbox-api rm /Public/work_bak/hoge.tmp
294            dropbox-api rm /Public/work_bak
295        };
296    } elsif ($command eq 'upload' or $command eq 'up' or $command eq 'put') {
297        $help = q{
298        Name
299            dropbox-api-put - upload file
300
301        SYNOPSIS
302            dropbox-api put <file> dropbox:<dropbox_file>
303
304        Example
305            dropbox-api put README.md dropbox:/Public/product/dropbox-api/
306        };
307    } elsif ($command eq 'download' or $command eq 'dl' or $command eq 'get') {
308        $help = q{
309        Name
310            dropbox-api-get - download file
311
312        SYNOPSIS
313            dropbox-api get dropbox:<dropbox_file> <file>
314
315        Example
316            dropbox-api get dropbox:/Public/product/dropbox-api/README.md README.md
317        };
318    } elsif ($command eq 'sync') {
319        $help = q{
320        Name
321            dropbox-api-sync - sync directory
322
323        SYNOPSIS
324            dropbox-api sync dropbox:<source_dir> <target_dir> [options]
325            dropbox-api sync <source_dir> dropbox:<target_dir> [options]
326
327        Example
328            dropbox-api sync dropbox:/Public/product/dropbox-api/ ~/work/dropbox-api/
329            dropbox-api sync ~/work/dropbox-api/ dropbox:/Public/product/dropbox-api/ -vdn
330            dropbox-api sync ~/work/dropbox-api/ dropbox:/Public/product/dropbox-api/ -d
331
332        Options
333            -v increase verbosity
334            -n show what would have been transferred (dry-run)
335            -d delete files that don't exist on sender
336        };
337    } elsif ($command eq 'version') {
338        $help = qq{
339        This is dropbox-api-command, version $VERSION
340
341        Copyright 2016, Shinichiro Aska
342
343        Released under the MIT license.
344
345        Documentation
346            this system using "dropbox-api help".
347            If you have access to the Internet, point your browser at
348            https://github.com/s-aska/dropbox-api-command,
349            the dropbox-api-command Repository.
350        };
351    } else {
352        $help = qq{
353        Usage: dropbox-api <command> [args] [options]
354
355        Available commands:
356            setup get access_key and access_secret
357            ls    list directory contents
358            find  walk a file hierarchy
359            du    disk usage statistics
360            cp    copy file or directory
361            mv    move file or directory
362            mkdir make directory (Create intermediate directories as required)
363            rm    remove file or directory (Attempt to remove the file hierarchy rooted in each file argument)
364            put   upload file
365            get   download file
366            sync  sync directory (local => dropbox or dropbox => local)
367
368        Common Options
369            -e enable env_proxy ( HTTP_PROXY, NO_PROXY )
370            -D enable debug
371            -v verbose
372
373        See 'dropbox-api help <command>' for more information on a specific command.
374        };
375    }
376    $help =~ s|^ {8}||mg;
377    $help =~ s|^\s*\n||;
378    print "\n$help\n";
379}
380
381sub setup {
382    my $config = {};
383
384    print "Please Input API Key: ";
385    chomp( my $key = <STDIN> );
386    die 'Get API Key from https://www.dropbox.com/developers' unless $key;
387    $config->{key} = $key;
388
389    print "Please Input API Secret: ";
390    chomp( my $secret = <STDIN> );
391    die 'Get API Secret from https://www.dropbox.com/developers' unless $secret;
392    $config->{secret} = $secret;
393
394    my $box = WebService::Dropbox->new($config);
395    $box->env_proxy if $env_proxy;
396    my $login_link = $box->authorize;
397    die $box->error if $box->error;
398    print "1. Open the Login URL: $login_link\n";
399    print "2. Input code and press Enter: ";
400    chomp( my $code = <STDIN> );
401    unless ($box->token($code)) {
402        die $box->error;
403    }
404
405    $config->{access_token} = $box->access_token;
406    print "success! try\n";
407    print "> dropbox-api ls\n";
408    print "> dropbox-api find /\n";
409
410    $config_file->openw->print(encode_json($config));
411
412    chmod 0600, $config_file;
413
414    exit(0);
415}
416
417sub du {
418    my $remote_base = decode('locale_fs', slash(shift));
419    $remote_base =~ s|/$||;
420    my $entries = _find($remote_base);
421    my $dir_map = {};
422    for my $content (@{ $entries }) {
423        if ($content->{'.tag'} eq 'folder') {
424            next;
425        }
426        my @paths = _paths($remote_base, $content->{path_lower});
427        for my $path (@paths) {
428            $dir_map->{ lc $path } ||= 0;
429            $dir_map->{ lc $path } += $content->{size};
430        }
431    }
432    if (!$remote_base) {
433        my $size = $dir_map->{'/'} || 0;
434        if ($human) {
435            $size = format_bytes($size);
436        }
437        printf("%s\t%s\n", $size, '/');
438    }
439    for my $content (@{ $entries }) {
440        if ($content->{'.tag'} ne 'folder') {
441            next;
442        }
443        if (defined $max_depth) {
444            my $path = $content->{path_lower};
445            $path =~ s|^\Q$remote_base\E/?||i;
446            my $depth = $path ? scalar(split('/', $path)) : 0;
447            if ($depth > $max_depth) {
448                next;
449            }
450        }
451        my $size = $dir_map->{ lc $content->{path_lower} } || 0;
452        if ($human) {
453            $size = format_bytes($size);
454        }
455        printf("%s\t%s\n", $size, $content->{path_display});
456    }
457}
458
459sub _paths ($$) {
460    my ($base_path, $path) = @_;
461    $path =~ s|^\Q$base_path\E/?||i;
462    my @paths;
463    my $dir = $base_path || '/';
464    push @paths, $dir;
465    my @names = split '/', $path;
466    pop @names;
467    for my $name (@names) {
468        if ($dir ne '/') {
469            $dir .= '/';
470        }
471        $dir .= $name;
472        push @paths, $dir;
473    }
474    return @paths;
475}
476
477sub list {
478    my $remote_base = decode('locale_fs', slash(shift));
479    if ($remote_base eq '/') {
480        $remote_base = '';
481    }
482    my $list = $box->list_folder($remote_base) or die $box->error;
483    for my $entry (@{ $list->{entries} }) {
484        print &_line($entry);
485    }
486}
487
488sub _line {
489    my ($content) = @_;
490    $strp ||= new DateTime::Format::Strptime( pattern => '%Y-%m-%dT%T' );
491    my $dt;
492    my $ct;
493    my $get = sub {
494        my $key = $format->{ $_[0] };
495        if ($key eq 'format_size') {
496            return exists $content->{size} ? format_bytes($content->{size}) : '   -';
497        } elsif ($key eq 'is_dir') {
498            $content->{'.tag'} eq 'folder' ? 'd' : '-';
499        } elsif ($key eq 'thumb_exists') {
500            if ($content->{path_display} =~ qr{ \.(?:jpg|jpeg|png|tiff|tif|gif|bmp) \z }xms && $content->{size} < 20 * 1024 * 1024) {
501                return 'true';
502            } else {
503                return 'false';
504            }
505        } else {
506            return exists $content->{ $key } ? $content->{ $key } : '-';
507        }
508    };
509    if ($printf) {
510        my $line = eval qq{"$printf"};
511        if ($content->{server_modified}) {
512            $line=~s/\%T([^\%])/
513                $dt ||= $strpz->parse_datetime($content->{server_modified});
514                $dt->strftime('%'.$1);
515            /egx;
516        } else {
517            $line=~s/\%TY/----/g;
518            $line=~s/\%T([^\%])/--/g;
519        }
520        if ($content->{client_modified}) {
521            $line=~s/\%C([^\%])/
522                $ct ||= $strpz->parse_datetime($content->{client_modified});
523                $ct->strftime('%'.$1);
524            /egx;
525        } else {
526            $line=~s/\%CY/----/g;
527            $line=~s/\%C([^\%])/--/g;
528        }
529        $line=~s|\%([^\%])|$get->($1)|eg;
530        return $line;
531    } else {
532        return sprintf "%s %8s %s %s\n",
533            ($content->{'.tag'} eq 'folder' ? 'd' : '-'),
534            $get->($human ? 's' : 'b'),
535            $get->('t'),
536            $content->{path_display};
537    }
538}
539
540sub find {
541    my $remote_base = decode('locale_fs', slash(shift));
542    if ($remote_base eq '/') {
543        $remote_base = '';
544    }
545    $printf ||= "%p\n";
546    my $entries = _find($remote_base);
547    for my $entry (@{ $entries }) {
548        print &_line($entry);
549    }
550}
551
552sub _find ($) {
553    my $remote_base = decode('locale_fs', slash(shift));
554    if ($remote_base eq '/') {
555        $remote_base = '';
556    }
557    my @entries;
558    my $fetch;
559    my $count = 0;
560    $fetch = sub {
561        my $cursor = shift;
562        my $list;
563        if ($cursor) {
564            $list = $box->list_folder_continue($cursor) or die $box->error;
565        } else {
566            $list = $box->list_folder($remote_base, {
567                recursive => JSON::true,
568            }) or die $box->error;
569        }
570        push @entries, @{ $list->{entries} };
571        if ($list->{has_more}) {
572            if ($verbose) {
573                $| = 1;
574                $count++;
575                printf("\r" . (('.') x $count));
576            }
577            $fetch->($list->{cursor});
578        }
579    };
580    $fetch->();
581    if ($verbose) {
582        print "\n";
583    }
584    [ sort { $a->{path_lower} cmp $b->{path_lower} } @entries ];
585}
586
587sub copy {
588    my ($src, $dst) = @_;
589    my $res = $box->copy(decode('locale_fs', slash($src)), decode('locale_fs', slash($dst))) or die $box->error;
590    print pretty($res) if $verbose;
591}
592
593sub move {
594    my ($src, $dst) = @_;
595    my $res = $box->move(decode('locale_fs', slash($src)), decode('locale_fs', slash($dst))) or die $box->error;
596    print pretty($res) if $verbose;
597}
598
599sub mkdir {
600    my ($dir) = @_;
601    my $res = $box->create_folder(decode('locale_fs', slash($dir))) or die $box->error;
602    print pretty($res) if $verbose;
603}
604
605sub delete {
606    my ($file_or_dir) = @_;
607    my $res = $box->delete(decode('locale_fs', slash($file_or_dir))) or die $box->error;
608    print pretty($res) if $verbose;
609}
610
611sub upload {
612    my ($file, $path) = @_;
613    $path =~ s|^dropbox:/|/|
614        or die "Usage: \n    dropbox-api upload /tmp/local.txt dropbox:/Public/some.txt";
615    my $local_path = file($file);
616    if ((! length $path) or $path =~ m|/$|) {
617        $path.= basename($file);
618    }
619    my $res = &put($local_path, decode('locale_fs', $path)) or die $box->error;
620
621    if ($verbose) {
622        print pretty($res);
623    }
624
625    my $id = $res->{id};
626
627    if ($public) {
628        my $list_shared_links = $box->api({
629            url => 'https://api.dropboxapi.com/2/sharing/list_shared_links',
630            params => {
631                path => $id,
632            }
633        }) or die $box->error;
634        for (@{ $list_shared_links->{links} }) {
635            if ($id eq $_->{id} && $_->{link_permissions}{resolved_visibility}{'.tag'} eq 'public') {
636                print $_->{url}, "\n";
637                return;
638            }
639        }
640
641        my $res = $box->api({
642            url => 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings',
643            params => {
644                path => $path,
645                settings => {
646                    requested_visibility => 'public',
647                }
648            }
649        }) or die $box->error;
650        print $res->{url}, "\n";
651    }
652}
653
654sub download {
655    my ($path, $file) = @_;
656    $path =~ s|^dropbox:/|/|
657        or die "Usage: \n    dropbox-api download dropbox:/Public/some.txt /tmp/local.txt";
658    my $fh = file($file)->openw or die $!;
659    $box->download(decode('locale_fs', $path), $fh) or die $box->error;
660    $fh->close;
661}
662
663sub sync {
664    my ($arg1, $arg2) = @_;
665
666    if ($dry) {
667        print "!! enable dry run !!\n";
668    }
669
670    # download
671    if ($arg1 =~ qr{ \A dropbox: }xms and $arg2 !~ qr{ \A dropbox: }xms) {
672
673        my ($remote_base, $local_base) = ($arg1, $arg2);
674        $remote_base = decode('locale_fs', $remote_base);
675        $remote_base =~ s|^dropbox:||;
676
677        if ($remote_base eq '/' || $remote_base eq '') {
678            unless (-d $local_base) {
679                die "missing $local_base";
680            }
681            &sync_download('/', dir(abs_path($local_base)));
682        } else {
683            my $content = $box->get_metadata(chomp_slash($remote_base)) or die $box->error;
684            if ($content->{'.tag'} eq 'folder') {
685                unless (-d $local_base) {
686                    die "missing $local_base";
687                }
688                &sync_download($content->{path_display}, dir(abs_path($local_base)));
689            } else {
690                $local_base = -d $local_base ? dir(abs_path($local_base)) : -f $local_base ? file(abs_path($local_base)) : file($local_base);
691                &sync_download_file($content, file($local_base));
692            }
693        }
694    }
695
696    # upload
697    elsif ($arg1 !~ qr{ \A dropbox: }xms and $arg2 =~ qr{ \A dropbox: }xms) {
698
699        my ($local_base, $remote_base) = ($arg1, $arg2);
700        $remote_base = decode('locale_fs', $remote_base);
701        $remote_base =~ s|^dropbox:||;
702
703        if (-d $local_base) {
704            &sync_upload($remote_base, dir(abs_path($local_base)));
705        } elsif (-f $local_base) {
706            &sync_upload_file($remote_base, file(abs_path($local_base)));
707        } else {
708            die "missing $local_base";
709        }
710    }
711
712    # invalid command
713    else {
714        die "Usage: \n    dropbox-api sync dropbox:/Public/ /tmp/pub/\n" .
715                   "or    dropbox-api sync /tmp/pub/ dropbox:/Public/";
716    }
717}
718
719sub sync_download {
720    my ($remote_base, $local_base) = @_;
721
722    if ($verbose) {
723        print "remote_base: $remote_base\n";
724        print "local_base: $local_base\n";
725    }
726
727    print "** download **\n" if $verbose;
728
729    my $entries = _find($remote_base);
730    unless (@{ $entries }) {
731        return;
732    }
733
734    my $remote_map = {};
735    my $remote_inode_map = {};
736
737    for my $content (@{ $entries }) {
738        my $remote_path = $content->{path_display};
739        my $rel_path = remote_abs2rel($remote_path, $remote_base);
740        unless (length $rel_path) {
741            if ($content->{'.tag'} eq 'folder') {
742                next;
743            } else {
744                $rel_path = $content->{name};
745            }
746        }
747        my $rel_path_enc = encode('locale_fs', $rel_path);
748        $remote_map->{$rel_path}++;
749        printf "check: %s\n", $rel_path if $debug;
750        my $is_dir = $content->{'.tag'} eq 'folder' ? 1 : 0;
751        my $local_path = $is_dir ? dir($local_base, $rel_path_enc) : file($local_base, $rel_path_enc);
752        if ($is_dir) {
753            printf "remote: %s\n", $remote_path if $debug;
754            printf "local:  %s\n", $local_path if $debug;
755            if (!-d $local_path) {
756                $local_path->mkpath unless $dry;
757                printf "mkpath %s\n", decode('locale_fs', $local_path);
758            } else {
759                printf "skip %s\n", $rel_path if $verbose;
760            }
761        } else {
762
763            if ((!-f $local_path) || has_change($local_path, $content)) {
764
765                if ($dry) {
766                    printf "download %s\n", decode('locale_fs', $local_path);
767                    next;
768                }
769
770                # not displayed in the dry-run for the insurance
771                unless (-d $local_path->dir) {
772                    printf "mkpath %s\n", decode('locale_fs', $local_path->dir);
773                    $local_path->dir->mkpath;
774                }
775
776                my $local_path_tmp = $local_path . '.dropbox-api.tmp';
777                my $fh;
778                unless (open($fh, '>', $local_path_tmp)) {
779                    warn "open failure " . decode('locale_fs', $local_path) . " (" . $! . ")";
780                    $exit_code = 1;
781                    next;
782                }
783                if ($box->download($content->{path_display}, $fh)) {
784                    printf "download %s\n", decode('locale_fs', $local_path);
785                    close($fh);
786                    my $remote_epoch = $strpz->parse_datetime($content->{client_modified})->epoch;
787                    unless (utime($remote_epoch, $remote_epoch, $local_path_tmp)) {
788                        warn "set modification time failure " .  decode('locale_fs', $local_path);
789                        $exit_code = 1;
790                    }
791                    unless (rename($local_path_tmp, $local_path)) {
792                        unlink($local_path_tmp);
793                        warn "rename failure " . decode('locale_fs', $local_path_tmp);
794                        $exit_code = 1;
795                    }
796                } else {
797                    unlink($local_path_tmp);
798                    chomp( my $error = $box->error );
799                    warn "download failure " . decode('locale_fs', $local_path) . " (" . $error . ")";
800                    $exit_code = 1;
801                }
802            } else {
803                printf "skip %s\n", $rel_path if $verbose;
804            }
805        }
806        $remote_inode_map->{ &inode($local_path) } = $content;
807    }
808
809    if ($exit_code) {
810        return;
811    }
812
813    unless ($delete) {
814        return;
815    }
816
817    if ($verbose) {
818        print "** delete **\n";
819    }
820
821    my @deletes;
822    $local_base->recurse(
823        preorder => 0,
824        depthfirst => 1,
825        callback => sub {
826            my $local_path = shift;
827            if ($local_path eq $local_base) {
828                return;
829            }
830
831            my $rel_path_enc = abs2rel($local_path, $local_base);
832            my $rel_path = decode('locale_fs', $rel_path_enc);
833
834            if (exists $remote_map->{$rel_path}) {
835                if ($verbose) {
836                    printf "skip %s\n", $rel_path;
837                }
838            } elsif (my $content = $remote_inode_map->{ &inode($local_path) }) {
839                my $remote_path = $content->{path_display};
840                my $rel_path_remote = remote_abs2rel($remote_path, $remote_base);
841                if ($verbose) {
842                    if ($debug) {
843                        printf "skip %s ( is %s )\n", $rel_path, $rel_path_remote;
844                    } else {
845                        printf "skip %s\n", $rel_path;
846                    }
847                }
848            } elsif (-f $local_path) {
849                printf "remove %s\n", $rel_path;
850                push @deletes, $local_path;
851            } elsif (-d $local_path) {
852                printf "rmtree %s\n", $rel_path;
853                push @deletes, $local_path;
854            }
855        }
856    );
857
858    if ($dry) {
859        return;
860    }
861
862    for my $local_path (@deletes) {
863        if (-f $local_path) {
864            $local_path->remove;
865        } elsif (-d $local_path) {
866            $local_path->rmtree;
867        }
868    }
869}
870
871sub sync_download_file {
872    my ($content, $local_path) = @_;
873
874    if ($verbose) {
875        print "remote_base: " . $content->{name} . "\n";
876        print "local_base: $local_path\n";
877    }
878
879    if (-d $local_path) {
880        $local_path = file($local_path, $content->{name});
881    }
882
883    if ((!-f $local_path) || has_change($local_path, $content)) {
884
885        if ($dry) {
886            printf "download %s\n", decode('locale_fs', $local_path);
887            return;
888        }
889
890        unless (-d $local_path->dir) {
891            printf "mkpath %s\n", decode('locale_fs', $local_path->dir);
892            $local_path->dir->mkpath;
893        }
894
895        my $local_path_tmp = $local_path . '.dropbox-api.tmp';
896        my $fh;
897        unless (open($fh, '>', $local_path_tmp)) {
898            warn "open failure " . decode('locale_fs', $local_path) . " (" . $! . ")";
899            $exit_code = 1;
900            return;
901        }
902        if ($box->download($content->{path_display}, $fh)) {
903            printf "download %s\n", decode('locale_fs', $local_path);
904            close($fh);
905            my $remote_epoch = $strpz->parse_datetime($content->{client_modified})->epoch;
906            unless (utime($remote_epoch, $remote_epoch, $local_path_tmp)) {
907                warn "set modification time failure " .  decode('locale_fs', $local_path);
908                $exit_code = 1;
909            }
910            unless (rename($local_path_tmp, $local_path)) {
911                unlink($local_path_tmp);
912                warn "rename failure " . decode('locale_fs', $local_path_tmp);
913                $exit_code = 1;
914            }
915        } else {
916            unlink($local_path_tmp);
917            chomp( my $error = $box->error );
918            warn "download failure " . decode('locale_fs', $local_path) . " (" . $error . ")";
919            $exit_code = 1;
920        }
921    } else {
922        printf "skip %s\n", $content->{path_display} if $verbose;
923    }
924}
925
926sub sync_upload {
927    my ($remote_base, $local_base) = @_;
928
929
930    if ($verbose) {
931        print "remote_base: $remote_base\n";
932        print "local_base: $local_base\n";
933    }
934
935    print "** upload **\n" if $verbose;
936
937    my $remote_map = {};
938    my $remote_path_map = {};
939
940    my $entries = _find($remote_base);
941    for my $content (@{ $entries }) {
942        my $remote_path = $content->{path_display};
943        my $rel_path = remote_abs2rel($remote_path, $remote_base);
944        unless (length $rel_path) {
945            next;
946        }
947        $remote_map->{ lc $rel_path } = $content;
948        $remote_path_map->{ $content->{path_display} } = $content;
949        if ($debug) {
950            printf "find: %s\n", $rel_path;
951        }
952    }
953
954    my @makedirs;
955    $local_base->recurse(
956        preorder => 0,
957        depthfirst => 1,
958        callback => sub {
959            my $local_path = shift;
960            if ($local_path eq $local_base) {
961                return;
962            }
963            my $rel_path = decode('locale_fs', abs2rel($local_path, $local_base));
964            my $remote_path = file($remote_base, $rel_path);
965            my $content = delete $remote_map->{ lc $rel_path };
966
967            # exists file or directory
968            if ($content) {
969                delete $remote_path_map->{ $content->{path_display} };
970
971                unless (-f $local_path) {
972                    return;
973                }
974
975                if (has_change($local_path, $content)) {
976                    printf "upload %s %s\n", $rel_path, $remote_path;
977                    unless ($dry) {
978                        if ($content->{size} == -s $local_path) {
979                            $box->delete("$remote_path");
980                        }
981                        my $local_epoch = $local_path->stat->mtime;
982                        &put($local_path, "$remote_path", { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' }) or die $box->error;
983                    }
984                    push @makedirs, $rel_path;
985                } elsif ($verbose) {
986                    printf "skip %s\n", $rel_path;
987                }
988            }
989
990            # new file
991            elsif (-f $local_path) {
992                unless ($dry) {
993                    my $local_epoch = $local_path->stat->mtime;
994                    &put($local_path, "$remote_path", { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' });
995                }
996                if (!$dry && $box->error) {
997                    warn "upload failure $rel_path $remote_path (" . $box->error . ")";
998                } else {
999                    printf "upload %s %s\n", $rel_path, $remote_path;
1000                    push @makedirs, $rel_path;
1001                }
1002            }
1003
1004            # new directory
1005            elsif (-d $local_path) {
1006
1007                if (grep { $_ =~ qr{ \A\Q$rel_path }xms } @makedirs) {
1008                    return;
1009                }
1010
1011                printf "mktree %s %s\n", $rel_path, $remote_path;
1012
1013                unless ($dry) {
1014                    $box->create_folder("$remote_path") or die $box->error;
1015                }
1016
1017                push @makedirs, $rel_path;
1018            } else {
1019                printf "unknown %s\n", $rel_path;
1020            }
1021        }
1022    );
1023
1024    return unless $delete;
1025
1026    print "** delete **\n" if $verbose;
1027
1028    my @deletes;
1029    for my $content_path ( keys %$remote_path_map ) {
1030
1031        if (chomp_slash($content_path) eq chomp_slash($remote_base)) {
1032            next;
1033        }
1034
1035        if (grep { $content_path =~ qr{ \A\Q$_ }xms } @deletes) {
1036            next;
1037        }
1038
1039        unless ($dry) {
1040            $box->delete($content_path) or die $box->error;
1041        }
1042
1043        push @deletes, $content_path;
1044
1045        printf "delete %s\n", remote_abs2rel($content_path, $remote_base);
1046    }
1047}
1048
1049sub sync_upload_file {
1050    my ($remote_base, $local_path) = @_;
1051
1052    if ($verbose) {
1053        print "remote_base: $remote_base\n";
1054        print "local_path: $local_path\n";
1055    }
1056
1057    my $remote_path;
1058    my $content;
1059    {
1060        local $SIG{__WARN__} = sub {};
1061
1062        $content = $box->get_metadata(chomp_slash($remote_base));
1063
1064        # exists folder
1065        if ($content && $content->{'.tag'} eq 'folder') {
1066            $remote_path = file($remote_base, basename($local_path));
1067            my $remote_file_content = $box->get_metadata(chomp_slash("$remote_path"));
1068            if ($remote_file_content) {
1069                if ($remote_file_content->{'.tag'} eq 'folder') {
1070                    die "$remote_path is folder.";
1071                }
1072                $content = $remote_file_content;
1073            }
1074        } else {
1075            if ($remote_base =~ qr{ / \z }xms) {
1076                $remote_path = file($remote_base, basename($local_path));
1077            } else {
1078                $remote_path = $remote_base;
1079            }
1080        }
1081    }
1082
1083    # exists file
1084    if ($content && $content->{'.tag'} ne 'folder') {
1085        if ($debug) {
1086            printf "find: %s\n", $content->{path_display};
1087        }
1088        $remote_path = $content->{path_display};
1089
1090        if (has_change($local_path, $content)) {
1091            printf "upload %s %s\n", $local_path, $remote_path;
1092            unless ($dry) {
1093                if ($content->{size} == -s $local_path) {
1094                    $box->delete("$remote_path");
1095                }
1096                my $local_epoch = $local_path->stat->mtime;
1097                &put($local_path, "$remote_path", { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' }) or die $box->error;
1098            }
1099        } elsif ($verbose) {
1100            printf "skip %s\n", $local_path;
1101        }
1102        return;
1103    }
1104
1105    unless ($dry) {
1106        my $local_epoch = $local_path->stat->mtime;
1107        &put($local_path, "$remote_path", { client_modified => $strp->format_datetime(DateTime->from_epoch( epoch => $local_epoch )) . 'Z' });
1108    }
1109
1110    printf "upload %s %s\n", $local_path, $remote_path;
1111}
1112
1113sub has_change ($$) {
1114    my ($local_path, $content) = @_;
1115
1116    my $remote_epoch = $strpz->parse_datetime($content->{client_modified})->epoch;
1117    my $local_epoch = $local_path->stat->mtime;
1118    my $remote_size = $content->{size};
1119    my $local_size = $local_path->stat->size;
1120
1121    if ($debug) {
1122        printf "remote: %10s %10s %s\n", $remote_epoch, $remote_size, $content->{path_display};
1123        printf "local:  %10s %10s %s\n", $local_epoch, $local_size, decode('locale_fs', $local_path);
1124    }
1125
1126    if (($remote_size != $local_size) || ($remote_epoch != $local_epoch)) {
1127        return 1;
1128    }
1129
1130    return;
1131}
1132
1133sub put {
1134    my ($file, $path, $optional_params) = @_;
1135
1136    my $commit_params = {
1137        path => "$path",
1138        mode => 'overwrite',
1139        %{ $optional_params || +{} },
1140    };
1141
1142    my $content = $file->openr;
1143    my $size = -s $file;
1144    my $threshold = 10 * 1024 * 1024;
1145
1146    if ($size < $threshold) {
1147        return $box->upload("$path", $content, $commit_params);
1148    }
1149
1150    my $session_id;
1151    my $offset = 0;
1152
1153    my $limit = 4 * 1024 * 1024;
1154
1155    $| = 1;
1156
1157    my $upload;
1158    $upload = sub {
1159        my $buf;
1160        my $total = 0;
1161        my $chunk = 1024;
1162        my $tmp = File::Temp->new;
1163        my $is_last;
1164        while (my $read = read($content, $buf, $chunk)) {
1165            $tmp->print($buf);
1166            $total += $read;
1167            my $remaining = $limit - $total;
1168            if ($chunk > $remaining) {
1169                $chunk = $remaining;
1170            }
1171            unless ($chunk) {
1172                last;
1173            }
1174        }
1175
1176        $tmp->flush;
1177        $tmp->seek(0, 0);
1178
1179        # finish or small file
1180        if ($total < $limit) {
1181            if ($session_id) {
1182                my $params = {
1183                    cursor => {
1184                        session_id => $session_id,
1185                        offset     => $offset,
1186                    },
1187                    commit => $commit_params,
1188                };
1189                return $box->upload_session_finish($tmp, $params);
1190            } else {
1191                return $box->upload("$path", $tmp, $commit_params);
1192            }
1193        }
1194
1195        # append
1196        elsif ($session_id) {
1197            my $params = {
1198                cursor => {
1199                    session_id => $session_id,
1200                    offset     => $offset,
1201                },
1202            };
1203            unless ($box->upload_session_append_v2($tmp, $params)) {
1204                # some error
1205                return;
1206            }
1207            $offset += $total;
1208        }
1209
1210        # start
1211        else {
1212            my $res = $box->upload_session_start($tmp);
1213            if ($res && $res->{session_id}) {
1214                $session_id = $res->{session_id};
1215                $offset = $total;
1216            } else {
1217                # some error
1218                return;
1219            }
1220        }
1221
1222        # ProgressBar
1223        if ($verbose) {
1224            my $rate = sprintf('%2.1d%%', $offset / $size * 100);
1225            my $bar = '=' x int(($cols - length($rate) - 4) * $offset / $size);
1226            my $space = ' ' x ($cols - length($rate) - length($bar) - 4);
1227            printf "\r%s [%s>%s]", $rate, $bar, $space;
1228        }
1229
1230        $upload->();
1231    };
1232    $upload->();
1233}
1234
1235sub inode ($) {
1236    my $path = shift;
1237    my ($dev, $inode) = stat($path);
1238    return $dev . ':' . $inode if $inode;
1239    return $path;
1240}
1241
1242sub remote_abs2rel ($$) {
1243    my ($remote_path, $remote_base) = @_;
1244    $remote_path =~ s|^\Q$remote_base\E/?||i;
1245    return $remote_path;
1246}
1247
1248sub slash ($) {
1249    my $path = shift;
1250    unless (defined $path) {
1251        return '/';
1252    }
1253    if ($path !~ qr{ \A / }xms) {
1254        $path = '/' . $path;
1255    }
1256    $path;
1257}
1258
1259sub chomp_slash ($) {
1260    my $path = shift;
1261    unless (defined $path) {
1262        return '';
1263    }
1264    $path =~ s|/$||;
1265    $path;
1266}
1267
1268sub pretty($) {
1269    JSON->new->utf8->pretty->encode($_[0]);
1270}
1271
1272use constant UNITS => [
1273    [ 'P', 1024 ** 4 * 1000, 1024 ** 5 ],
1274    [ 'T', 1024 ** 3 * 1000, 1024 ** 4 ],
1275    [ 'G', 1024 ** 2 * 1000, 1024 ** 3 ],
1276    [ 'M', 1024 * 1000, 1024 ** 2 ],
1277    [ 'K', 1000, 1024 ],
1278    [ 'B', 0, 1 ],
1279];
1280
1281sub format_bytes ($) {
1282    my $size = shift;
1283    if ($size > 0) {
1284        for my $unit (@{ UNITS() }) {
1285            my ($unit_label, $unit_min, $unit_value) = @{ $unit };
1286            if ($size >= $unit_min) {
1287                my $size_unit = $size / $unit_value;
1288                if (round($size_unit) < 10) {
1289                    return sprintf('%1.1f%s', nearest(.1, $size_unit), $unit_label);
1290                } else {
1291                    return sprintf('%3s%s', round($size_unit), $unit_label);
1292                }
1293            }
1294        }
1295    }
1296    return '  0B';
1297}
1298
1299sub round ($) {
1300    POSIX::floor($_[0] + 0.50000000000008);
1301}
1302
1303sub nearest ($) {
1304    round($_[1] / $_[0]) * $_[0];
1305}
1306
1307sub token_from_oauth1 {
1308    my $key = shift;
1309    my $secret = shift;
1310    my $access_token = shift;
1311    my $access_secret = shift;
1312
1313    require WebService::Dropbox::TokenFromOAuth1;
1314
1315    WebService::Dropbox::TokenFromOAuth1->token_from_oauth1({
1316        consumer_key    => $key,
1317        consumer_secret => $secret,
1318        access_token    => $access_token,  # OAuth1 access_token
1319        access_secret   => $access_secret, # OAuth2 access_secret
1320    });
1321}
1322
1323exit(0);
1324