├── MANIFEST ├── Makefile.PL ├── README.md ├── cloud ├── addnote.pl ├── picklist.pl └── shownotes.pl ├── joplin.png ├── lib ├── Joplin.pm └── Joplin │ ├── API.pm │ ├── Base.pm │ ├── Folder.pm │ ├── Note.pm │ ├── Resource.pm │ ├── Root.pm │ └── Tag.pm ├── script ├── addnote.pl ├── joplinfs.pl ├── listnotes.pl ├── listtags.pl └── pp.PL └── t ├── 00_load.t ├── 01_basic.t ├── 02_folder.t ├── 03_note.t ├── 04_note.t ├── 05_note.t ├── 06_tags.t ├── 07_tags.t ├── 50_load.t ├── 51_basic.t ├── 52_folders.t ├── 57_tags.t └── joplin.dat /MANIFEST: -------------------------------------------------------------------------------- 1 | cloud/addnote.pl 2 | cloud/picklist.pl 3 | cloud/shownotes.pl 4 | Makefile 5 | Makefile.PL 6 | README.md 7 | script/addnote.pl 8 | script/exportnote.pl 9 | script/listnotes.pl 10 | script/listtags.pl 11 | script/pp.PL 12 | t/00_load.t 13 | t/01_basic.t 14 | t/02_folder.t 15 | t/03_note.t 16 | t/04_note.t 17 | t/05_note.t 18 | t/06_tags.t 19 | t/07_tags.t 20 | t/50_load.t 21 | t/51_basic.t 22 | t/52_folders.t 23 | t/57_tags.t 24 | t/joplin.dat 25 | lib/Joplin.pm 26 | lib/Joplin/Note.pm 27 | lib/Joplin/Base.pm 28 | lib/Joplin/API.pm 29 | lib/Joplin/Resource.pm 30 | lib/Joplin/Root.pm 31 | lib/Joplin/Tag.pm 32 | lib/Joplin/Folder.pm 33 | -------------------------------------------------------------------------------- /Makefile.PL: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Ease the life of the CPAN testers. 4 | exit 0 if $ENV{AUTOMATED_TESTING} && $] < 5.010000; 5 | 6 | # EU::MM 6.5503 is the first version to understand *_REQUIRES. 7 | use ExtUtils::MakeMaker 6.5503; 8 | 9 | my $master = 'lib/Joplin/API.pm'; 10 | my $version = MM->parse_version($master); 11 | 12 | my @bins = qw( ); 13 | 14 | WriteMakefile 15 | ( 16 | NAME => 'Joplin::API', 17 | ABSTRACT_FROM => $master, 18 | VERSION => $version, 19 | AUTHOR => 'Johan Vromans ', 20 | LICENSE => "perl", 21 | MIN_PERL_VERSION => 5.010000, 22 | INSTALLDIRS => 'site', 23 | EXE_FILES => [ map { "script/$_" } @bins ], 24 | PL_FILES => { 25 | 'script/pp.PL' => [ map { "script/$_" } @bins ] 26 | }, 27 | 28 | PREREQ_PM => { 29 | 'LWP::UserAgent' => 6.13, 30 | 'JSON' => 2.94, 31 | }, 32 | 33 | CONFIGURE_REQUIRES => { 34 | "ExtUtils::MakeMaker" => 6.5503, 35 | }, 36 | 37 | # BUILD_REQUIRES => { 38 | # }, 39 | 40 | TEST_REQUIRES => { 41 | 'Test::More' => 0, 42 | }, 43 | 44 | META_MERGE => { 45 | resources => { 46 | license => "http://dev.perl.org/licenses/", 47 | repository => "https://github.com/sciurius/perl-Joplin-API", 48 | bugtracker => "https://github.com/sciurius/perl-Joplin-API/issues", 49 | }, 50 | provides => { 51 | "Joplin::API" 52 | => { file => "lib/Joplin/API.pm", 53 | version => $version }, 54 | }, 55 | no_index => { 56 | file => [ 57 | ], 58 | directory => [ 59 | ], 60 | }, 61 | }, 62 | 63 | ); 64 | 65 | # Note about the no_index: CPAN and MetaCPAN have differing opinions 66 | # on how no_index must be interpreted, in particular in combination 67 | # with provides. 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | README 2 | 3 | ## Joplin Tools 4 | 5 | This is a collection tools that implements an API to Joplin and can be used to perform some handy operations on Joplin notes. 6 | 7 | See [Joplin Home](https://joplin.cozic.net/) for more information on Joplin. 8 | 9 | **NOTE** This is WIP/WFM. YMMV. 10 | 11 | ### Joplin 12 | 13 | This module implements an object oriented interface to the Joplin notes system, using the Joplin clipper server as storage backend. 14 | 15 | The interface defines four classes: 16 | 17 | - Joplin::Folder - folder objects 18 | - Joplin::Note - note objects 19 | - Joplin::Tag - tag objects 20 | - Joplin::Resource - resource objects 21 | 22 | Using folder objects you can find and manipulate subfolders and notes. Notes can find and manipulate tags, and so on. 23 | 24 | Note that the Joplin data is considered as a folder on itself. This is 25 | handled by the class `Joplin::Root`. This class is a `Joplin::Folder` in 26 | all relevant aspects. 27 | 28 | #### Connecting to the Joplin server 29 | 30 | use Joplin; 31 | $root = Joplin->connect( server => "http://localhost:41884", 32 | apikey => "YourJoplinClipperAPIKey" ); 33 | 34 | When the connection is succesfull, a folder object is returned representing the root notebook. 35 | 36 | #### Finding folders 37 | 38 | For example, find the folder with name "Project". For simplicity, assume there is only one. 39 | 40 | $prj = $root->find_folders("Project")->[0]; 41 | 42 | All `find_...` methods take an optional argument which is a string or a pattern. If a string, it performs a case insensitive search on the names of the folders. A pattern can be used for more complex matches. If the argument is omitted, all results are returned. 43 | 44 | With a second, non-false argument, the search includes subfolders. 45 | 46 | #### Finding notes 47 | 48 | For example, find all notes in the Project folder that have "january" in the title. 49 | 50 | @notes = $prj->find_notes(qr/january/i); 51 | 52 | #### Creating and deleting notes 53 | 54 | To create a new note with the given name and markdown content: 55 | 56 | $note = $folder->create_note("Title", "Content goes *here*"); 57 | 58 | To delete a note: 59 | 60 | $note->delete; 61 | 62 | #### Finding tags 63 | 64 | @tags = $note->find_tags; 65 | 66 | This yields an array (that may be empty) with all tags associated with this note. Likewise, given a tag, you can find all notes that have this tag associated: 67 | 68 | @notes = $tag->find_notes; 69 | 70 | #### Creating and deleting tags 71 | 72 | To associate a tag with a note: 73 | 74 | $tag = $note->add_tag("my tag"); 75 | 76 | To delete the tag from this note: 77 | 78 | $note->delete_tag("my tag"); 79 | 80 | Alternatively: 81 | 82 | $note->delete_tag($tag); 83 | 84 | This deletes the tag from **all** notes **and** from the system: 85 | 86 | $tag->delete; 87 | 88 | #### Resources 89 | 90 | *To be implemented* 91 | 92 | ### Joplin::API 93 | 94 | This is a low level implementation of the [Joplin Web Clipper API](https://discourse.joplin.cozic.net/t/web-clipper-is-now-available-beta-feature/154/37). 95 | 96 | This API deals with JSON data and HTTP calls to the Joplin server. It can be used on itself but its main purpose is to support the higher level Joplin API. 97 | 98 | ### script/listnotes.pl 99 | 100 | This is a simple script that lists the titles of the notes and folders 101 | in hierarchical order. Optionally resources used by the notes can be 102 | listed and unused resources removed. 103 | 104 | Usage: 105 | 106 | perl listnotes.pl [ options ] 107 | 108 | Relevant options: 109 | 110 | --title=XXX title (optional) 111 | --resources include resources 112 | 113 | ### script/listtags.pl 114 | 115 | This is a simple script that lists the titles of the tags, with the 116 | number of notes that use this tag. With `-v`: also shows the title of 117 | the notes. 118 | 119 | Usage: 120 | 121 | perl listtags.pl [ options ] 122 | 123 | Relevant options: 124 | 125 | --title=XXX title (optional) 126 | --weed removes tags without notes 127 | 128 | ### script/addnote.pl 129 | 130 | This is a simple script that can be used to add notes to Joplin. 131 | 132 | Supported are text documents and images (jpg, gif, png). 133 | 134 | Usage: 135 | 136 | perl addnote.pl [ options ] document 137 | 138 | Relevant options: 139 | 140 | --parent=XXX note parent (defaults to "Imported Notes") 141 | 142 | --title=XXX title (optional) 143 | 144 | The document will be added to the notes collection into the parent folder. 145 | 146 | ### script/joplinfs.pl 147 | 148 | An experimental (proof-of-concept) implementation of a Joplin filesystem. It works (Linux) with FUSE. It uses the Joplinserver as a back end and provides a filesystem view on the notes. Notes and folders are identified by name. 149 | 150 | $ ls -l tmp/notes/joplin/Scratch/ 151 | -rw-r--r-- 1 jv jv 0 Mar 6 08:35 Checkboxes.md 152 | -rw-r--r-- 1 jv jv 0 Jan 28 2019 Lorem Ipsum.md 153 | -rw-r--r-- 1 jv jv 0 Mar 3 2019 README.md 154 | drwxr-xr-x 1 jv jv 3 Apr 30 11:41 SubScratch/ 155 | 156 | % cat tmp/notes/joplin/Scratch/Checkboxes.md 157 | - [ ] Seitan 158 | - [ ] Blanke bonen 159 | - [ ] Berglinzen 160 | - [ ] Couscous 161 | - [ ] Citroensap 162 | - [ ] Limoensap 163 | 164 | Dates are accurate. Size of a folder is the number of subnotes/folders. 165 | You can view, modify, rename, create notes and folders. All changes are immediately reflected in Joplin. 166 | 167 | Limitations: No duplicate file/folder names. 168 | 169 | Usage: Create an new directory and run joplinfs.pl with that directory 170 | as argument. Your notes will be visible in the `joplin` directory. 171 | 172 | To stop, run `fusermount -u dir` where _dir_ is the directory. 173 | 174 | ### cloud/addnote.pl 175 | 176 | This tool is similar to `script/addnote.pl`, but it doesn't use the Web Clipper API and hence does not require a running Joplin instance. Instead, it uses the cloud storage. 177 | 178 | In my setup, all Joplin clients synchronize to an ownCloud server. The relevant parts of the ownCloud storage are mirrored, using the native ownCloud client, to a desktop PC. So on the PC I have full access to the folder with Joplin notes. 179 | 180 | When `addnote_cloud.pl` adds a new note, it inspects the folder with existing notes, tries to find the parent folder, and creates a new note file that contains the data and Joplin metadata. This file will be synchronized to the cloud server and eventually all clients will receive the new note from the server. 181 | 182 | Usage: 183 | 184 | perl cloud/addnote.pl [ options ] document 185 | 186 | Relevant options: same as `addnote.pl`, plus: 187 | 188 | --dir=XXX the location of the Joplin notes on disk 189 | 190 | --folder create a new notebook 191 | 192 | ### cloud/shownotes.pl 193 | 194 | This program shows the notes and folders that are present in the Cloud 195 | folder. 196 | 197 | perl cloud/shownotes.pl colder 198 | 199 | -------------------------------------------------------------------------------- /cloud/addnote.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # Author : Johan Vromans 4 | # Created On : Mon Sep 3 10:45:33 2018 5 | # Last Modified By: Johan Vromans 6 | # Last Modified On: Sun Jan 6 20:24:37 2019 7 | # Update Count : 92 8 | # Status : Unknown, Use with caution! 9 | 10 | ################ Common stuff ################ 11 | 12 | use strict; 13 | use warnings; 14 | use Encode; 15 | 16 | # Package name. 17 | my $my_package = 'JoplinTools'; 18 | # Program name and version. 19 | my ($my_name, $my_version) = qw( addnote_cloud 0.04 ); 20 | 21 | ################ Command line parameters ################ 22 | 23 | use Getopt::Long 2.13; 24 | 25 | # Command line options. 26 | my $dir = "/home/jv/Cloud/ownCloud/Notes/Joplin"; 27 | my $folder; 28 | my $title; 29 | my $parent; 30 | my @tags; 31 | my $verbose = 1; # verbose processing 32 | 33 | # Development options (not shown with -help). 34 | my $debug = 0; # debugging 35 | my $trace = 0; # trace (show process) 36 | my $test = 0; # test mode. 37 | 38 | # Process command line options. 39 | app_options(); 40 | 41 | # Post-processing. 42 | $trace |= ($debug || $test); 43 | 44 | ################ Presets ################ 45 | 46 | my $TMPDIR = $ENV{TMPDIR} || $ENV{TEMP} || '/usr/tmp'; 47 | 48 | ################ The Process ################ 49 | 50 | my @tm = gmtime; 51 | my $ts = sprintf( "%04d-%02d-%02dT%02d:%02d:%02d.000Z", 52 | 1900+$tm[5], 1+$tm[4], @tm[3,2,1,0] ); 53 | my $author = (getpwuid($<))[6]; 54 | 55 | if ( $folder ) { 56 | make_folder( $parent, $title ); 57 | } 58 | else { 59 | my $file = $ARGV[0]; 60 | 61 | my $id; 62 | if ( $file =~ /\.(jpe?g|gif|png)$/ ) { # image 63 | $id = make_resource( $parent, $title ); 64 | } 65 | else { 66 | $id = make_note( $parent, $title ); 67 | } 68 | 69 | if ( $id && @tags ) { 70 | foreach my $tag ( @tags ) { 71 | my $tag_id = make_tag( $tag ); 72 | add_tag( $id, $tag_id ); 73 | } 74 | } 75 | } 76 | 77 | ################ Subroutines ################ 78 | 79 | sub make_folder { 80 | my ( $parent, $title ) = @_; 81 | $parent = find_folder( $parent ); 82 | $parent //= ""; 83 | my $id = uuid(); 84 | 85 | die("Folder needs title id!\n") unless $title; 86 | my $content = { id => $id, 87 | type => 2, 88 | data => $title, 89 | meta => < $id, type => 1 }; 111 | if ( @ARGV && !$title ) { 112 | ( $title = $ARGV[0] ) =~ s;^.*/;; 113 | } 114 | my $data = do { local $/; <> }; 115 | if ( $title ) { 116 | $data = $title . "\n\n". $data; 117 | } 118 | $content->{data} = $data; 119 | $content->{meta} = < $id, 172 | type => 4, 173 | data => $rsc, 174 | meta => <{data}, ": $!\n"); 200 | sysopen( my $dst, "$rscdir/$id", O_WRONLY|O_CREAT ) 201 | or die( "$rscdir/$id: $!\n" ); 202 | 203 | my $buf = ""; 204 | while ( ( my $n = sysread( $src, $buf, 10240 ) ) > 0 ) { 205 | syswrite( $dst, $buf, $n ); 206 | } 207 | 208 | close($src); 209 | close($dst); 210 | 211 | $id 212 | } 213 | 214 | sub make_tag { 215 | my ( $tag ) = @_; 216 | 217 | my $id = find_tag($tag); 218 | return $id if $id; 219 | 220 | $id = uuid(); 221 | my $content = { id => $id, 222 | type => 5, 223 | data => $tag, 224 | meta => < $id, 244 | type => 6, 245 | meta => < $id, 267 | type => 9, 268 | meta => <:raw', "$dir/" . $content->{id} . ".md" ); 287 | print $fd ( $content->{data}, "\n\n" ) if defined $content->{data}; 288 | print $fd ( encode_utf8($content->{meta}), 289 | "type_: ", $content->{type} ); 290 | close($fd); 291 | 292 | } 293 | 294 | sub find_folder { 295 | my ( $pat ) = @_; 296 | 297 | my @files = glob("$dir/????????????????????????????????.md"); 298 | 299 | if ( defined($pat) ) { 300 | if ( $pat =~ m;^/(.*); ) { 301 | $pat = $1; 302 | } 303 | else { 304 | $pat = qr/^.*$pat/i; # case insens substr 305 | } 306 | $folder = _find_folder( $pat, \@files ); 307 | } 308 | 309 | return $folder || _find_folder( "Imported Notes", \@files ); 310 | } 311 | 312 | sub _find_folder { 313 | my ( $pat, $files ) = @_; 314 | 315 | foreach ( @$files ) { 316 | open( my $fd, '<', "$_" ) or die("$_: $!\n"); 317 | my $data = do { local $/; <$fd> }; 318 | close($fd); 319 | 320 | if ( $data =~ /^type_: 2\z/m 321 | && $data =~ $pat 322 | && $data =~ /^id:\s*(.{32})$/m 323 | ) { 324 | return $1 325 | } 326 | } 327 | return; 328 | } 329 | 330 | sub find_tag { 331 | my ( $pat ) = @_; 332 | 333 | my @files = glob("$dir/????????????????????????????????.md"); 334 | my $id; 335 | 336 | if ( $pat =~ m;^/(.*); ) { 337 | $pat = $1; 338 | } 339 | else { 340 | $pat = qr/^.*$pat/i; # case insens substr 341 | } 342 | return _find_tag( $pat, \@files ); 343 | } 344 | 345 | sub _find_tag { 346 | my ( $pat, $files ) = @_; 347 | 348 | foreach ( @$files ) { 349 | open( my $fd, '<', "$_" ) or die("$_: $!\n"); 350 | my $data = do { local $/; <$fd> }; 351 | close($fd); 352 | 353 | if ( $data =~ /^type_: 5\z/m 354 | && $data =~ $pat 355 | && $data =~ /^id:\s*(.{32})$/m 356 | ) { 357 | return $1 358 | } 359 | } 360 | return; 361 | } 362 | 363 | sub uuid { 364 | my $uuid = ""; 365 | $uuid .= sprintf("%04x", rand() * 0xffff) for 1..8; 366 | return $uuid; 367 | } 368 | 369 | ################ Subroutines ################ 370 | 371 | sub app_options { 372 | my $help = 0; # handled locally 373 | my $ident = 0; # handled locally 374 | my $man = 0; # handled locally 375 | my @t; # tags 376 | 377 | my $pod2usage = sub { 378 | # Load Pod::Usage only if needed. 379 | require Pod::Usage; 380 | Pod::Usage->import; 381 | &pod2usage; 382 | }; 383 | 384 | # Process options. 385 | if ( @ARGV > 0 ) { 386 | GetOptions('parent=s' => \$parent, 387 | 'title=s' => \$title, 388 | 'folder' => \$folder, 389 | 'tags=s@' => \@t, 390 | 'dir=s' => \$dir, 391 | 'ident' => \$ident, 392 | 'verbose+' => \$verbose, 393 | 'quiet' => sub { $verbose = 0 }, 394 | 'trace' => \$trace, 395 | 'help|?' => \$help, 396 | 'man' => \$man, 397 | 'debug' => \$debug) 398 | or $pod2usage->(2); 399 | } 400 | if ( $ident or $help or $man ) { 401 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 402 | } 403 | if ( $man or $help ) { 404 | $pod2usage->(1) if $help; 405 | $pod2usage->(VERBOSE => 2) if $man; 406 | } 407 | die("Folders cannot have tags\n") if $folder && @t; 408 | foreach ( @t ) { 409 | push( @tags, split(/,\s*/, $_) ); 410 | } 411 | } 412 | 413 | __END__ 414 | 415 | ################ Documentation ################ 416 | 417 | =head1 NAME 418 | 419 | makenote - make a Joplin compliant note file 420 | 421 | =head1 SYNOPSIS 422 | 423 | makenote [options] [file ...] 424 | 425 | Options: 426 | --parent=XXX note parent (required) 427 | --folder create folder 428 | --title=XXX title (optional) 429 | --dir=XXX where the joplin notes reside 430 | --tag=YYY tags or tag ids 431 | --ident shows identification 432 | --quiet runs quietly 433 | --help shows a brief help message and exits 434 | --man shows full documentation and exits 435 | --verbose provides more verbose information 436 | 437 | =head1 OPTIONS 438 | 439 | =over 8 440 | 441 | =item B<--folder> 442 | 443 | Creates a folder instead of a note. For a folder, the title is mandatory 444 | and no content is needed. A parent is optional. 445 | 446 | =item B<--parent=>I 447 | 448 | Specifies the parent for the note or folder. 449 | 450 | The argument must be a 32 character hex string, otherwise it is 451 | interpreted as a search argument. 452 | 453 | If a search argument, it is used for case insensitive substring search 454 | on folder titles. If it starts with a C, it is interpreted as a 455 | regular expression patter to be matched against the folder titles. 456 | Note that this requires a valid <--dir> location. 457 | 458 | =item B<--dir=>I 459 | 460 | The location where the Joplin notes reside. Note this is only used to 461 | find folder ids when the B<--parent> option specifies a search 462 | argument. 463 | 464 | =item B<--tag=>I B<--tags=>I 465 | 466 | Specifies one or more tags to be associated with this note. Multiple 467 | options may be used to specify multiple tags. 468 | 469 | The tag is either an existing tag, a new tag, or the id of an existing tag. 470 | 471 | =item B<--title=>I 472 | 473 | Specifies the title for the note or folder. 474 | 475 | =item B<--help> 476 | 477 | Prints a brief help message and exits. 478 | 479 | =item B<--man> 480 | 481 | Prints the manual page and exits. 482 | 483 | =item B<--ident> 484 | 485 | Prints program identification. 486 | 487 | =item B<--verbose> 488 | 489 | Provides more verbose information. 490 | 491 | =item B<--quiet> 492 | 493 | Runs quietly. It suppresses the writing of the uid of the new note 494 | to standard output. 495 | 496 | =item I 497 | 498 | The input file(s) to process, if any. The contents will be 499 | concatenated to form the content of the new note. 500 | 501 | =back 502 | 503 | =head1 DESCRIPTION 504 | 505 | B will create Joplin compliant note and folder documents. 506 | 507 | =head1 AUTHOR 508 | 509 | Johan Vromans C<< >> 510 | 511 | =head1 SUPPORT 512 | 513 | Joplin-Tools development is hosted on GitHub, repository 514 | L. 515 | 516 | Please report any bugs or feature requests to the GitHub issue tracker, 517 | L. 518 | 519 | =head1 LICENSE 520 | 521 | Copyright (C) 2018 Johan Vromans, 522 | 523 | This program is free software. You can redistribute it and/or 524 | modify it under the terms of the Artistic License 2.0. 525 | 526 | This program is distributed in the hope that it will be useful, 527 | but without any warranty; without even the implied warranty of 528 | merchantability or fitness for a particular purpose. 529 | 530 | =cut 531 | 532 | 1; 533 | -------------------------------------------------------------------------------- /cloud/picklist.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # Author : Johan Vromans 4 | # Created On : Mon Sep 3 10:45:33 2018 5 | # Last Modified By: Johan Vromans 6 | # Last Modified On: Thu Sep 27 20:39:46 2018 7 | # Update Count : 54 8 | # Status : Unknown, Use with caution! 9 | 10 | ################ Common stuff ################ 11 | 12 | use strict; 13 | use warnings; 14 | use Encode; 15 | 16 | # Package name. 17 | my $my_package = 'JoplinTools'; 18 | # Program name and version. 19 | my ($my_name, $my_version) = qw( picklist 0.02 ); 20 | 21 | ################ Command line parameters ################ 22 | 23 | use Getopt::Long 2.13; 24 | 25 | # Command line options. 26 | my $dir = "/home/jv/Cloud/ownCloud/Notes/Joplin"; 27 | my $title; 28 | my $output; 29 | my $verbose = 1; # verbose processing 30 | 31 | # Development options (not shown with -help). 32 | my $debug = 0; # debugging 33 | my $trace = 0; # trace (show process) 34 | my $test = 0; # test mode. 35 | 36 | # Process command line options. 37 | app_options(); 38 | 39 | # Post-processing. 40 | $trace |= ($debug || $test); 41 | 42 | ################ Presets ################ 43 | 44 | my $TMPDIR = $ENV{TMPDIR} || $ENV{TEMP} || '/usr/tmp'; 45 | 46 | ################ The Process ################ 47 | 48 | my $id; 49 | if ( $output ) { 50 | ( $id ) = $output =~ m;(?:^|/)([0-9a-f]{32})\.md$;; 51 | die("Invalid output name: $output\n") unless $id; 52 | } 53 | else { 54 | $id = uuid(); 55 | } 56 | 57 | my @tm = gmtime; 58 | my $ts = sprintf( "%04d-%02d-%02dT%02d:%02d:%02d.000Z", 59 | 1900+$tm[5], 1+$tm[4], @tm[3,2,1,0] ); 60 | 61 | my $data = do { local $/; <> }; 62 | $data = decode_utf8($data); 63 | 64 | $data =~ s/^(id: ).*/$1$id/m; 65 | $data =~ s/^(source: ).*/${1}joplin-$my_name/m; 66 | $data =~ s/^(source_application: ).*/${1}nl.squirrel.joplin-$my_name/m; 67 | $data =~ s/^((?:user_)?updated_time: ).*/$1$ts/mg; 68 | $data =~ s/^- \[ \].*\n//mg; 69 | $data =~ s/^- \[\S\] (.*\n)/- $1/mg; 70 | $data =~ s/^##.*\n\n//mg; 71 | 72 | if ( $title ) { 73 | $data =~ s/^(.*)/$title/; 74 | } 75 | else { 76 | @tm = localtime; 77 | $ts = sprintf( "%04d-%02d-%02d %02d:%02d:%02d", 78 | 1900+$tm[5], 1+$tm[4], @tm[3,2,1,0] ); 79 | $data =~ s/^(.*)/$1 $ts/; 80 | } 81 | 82 | my $fd; 83 | open( $fd, '>:utf8', "$dir/$id.md" ); 84 | print $fd ( $data ); 85 | close($fd); 86 | 87 | print STDOUT ($id, "\n") if $verbose; 88 | 89 | ################ Subroutines ################ 90 | 91 | sub uuid { 92 | my $uuid = ""; 93 | $uuid .= sprintf("%04x", rand() * 0xffff) for 1..8; 94 | return $uuid; 95 | } 96 | 97 | ################ Subroutines ################ 98 | 99 | sub app_options { 100 | my $help = 0; # handled locally 101 | my $ident = 0; # handled locally 102 | my $man = 0; # handled locally 103 | 104 | my $pod2usage = sub { 105 | # Load Pod::Usage only if needed. 106 | require Pod::Usage; 107 | Pod::Usage->import; 108 | &pod2usage; 109 | }; 110 | 111 | # Process options. 112 | if ( @ARGV > 0 ) { 113 | GetOptions('output=s' => \$output, 114 | 'title=s' => \$title, 115 | 'dir=s' => \$dir, 116 | 'ident' => \$ident, 117 | 'verbose+' => \$verbose, 118 | 'quiet' => sub { $verbose = 0 }, 119 | 'trace' => \$trace, 120 | 'help|?' => \$help, 121 | 'man' => \$man, 122 | 'debug' => \$debug) 123 | or $pod2usage->(2); 124 | } 125 | if ( $ident or $help or $man ) { 126 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 127 | } 128 | if ( $man or $help ) { 129 | $pod2usage->(1) if $help; 130 | $pod2usage->(VERBOSE => 2) if $man; 131 | } 132 | } 133 | 134 | __END__ 135 | 136 | ################ Documentation ################ 137 | 138 | =head1 NAME 139 | 140 | picklist - make a picklist out of a Joplin note 141 | 142 | =head1 SYNOPSIS 143 | 144 | makenote [options] [file ...] 145 | 146 | Options: 147 | --title=XXX title (optional) 148 | --output=XXX output file (optional) 149 | --dir=XXX where the joplin notes reside 150 | --ident shows identification 151 | --quiet runs quietly 152 | --help shows a brief help message and exits 153 | --man shows full documentation and exits 154 | --verbose provides more verbose information 155 | 156 | =head1 OPTIONS 157 | 158 | =over 8 159 | 160 | =item B<--output=>I 161 | 162 | Specifies the output file for the new note. 163 | 164 | If used, the final (or only) component of the file name must be a 32 165 | character hex string, followed by C<.md>. 166 | 167 | By default the new content is written to a new note file B, even if B<--dir> is used. 169 | 170 | =item B<--dir=>I 171 | 172 | The location where the Joplin notes reside. This is currently not used. 173 | 174 | =item B<--title=>I 175 | 176 | Specifies a title for the note. If this is not used, a timestamp is 177 | appended to the current title of the note. 178 | 179 | =item B<--help> 180 | 181 | Prints a brief help message and exits. 182 | 183 | =item B<--man> 184 | 185 | Prints the manual page and exits. 186 | 187 | =item B<--ident> 188 | 189 | Prints program identification. 190 | 191 | =item B<--verbose> 192 | 193 | Provides more verbose information. 194 | 195 | =item B<--quiet> 196 | 197 | Runs quietly. It suppresses the writing of the uid of the new note 198 | to standard output. 199 | 200 | =item I 201 | 202 | The input file(s) to process, if any. The contents will be 203 | concatenated to form the content of the new note. 204 | 205 | =back 206 | 207 | =head1 DESCRIPTION 208 | 209 | B will read Joplin note and create a new note that has 210 | all unchecked list items, and possible section titles, removed. 211 | 212 | =head1 AUTHOR 213 | 214 | Johan Vromans C<< >> 215 | 216 | =head1 SUPPORT 217 | 218 | Joplin-Tools development is hosted on GitHub, repository 219 | L. 220 | 221 | Please report any bugs or feature requests to the GitHub issue tracker, 222 | L. 223 | 224 | =head1 LICENSE 225 | 226 | Copyright (C) 2018 Johan Vromans, 227 | 228 | This program is free software. You can redistribute it and/or 229 | modify it under the terms of the Artistic License 2.0. 230 | 231 | This program is distributed in the hope that it will be useful, 232 | but without any warranty; without even the implied warranty of 233 | merchantability or fitness for a particular purpose. 234 | 235 | =cut 236 | 237 | 1; 238 | -------------------------------------------------------------------------------- /cloud/shownotes.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # Author : Johan Vromans 4 | # Created On : Mon Sep 3 10:45:33 2018 5 | # Last Modified By: Johan Vromans 6 | # Last Modified On: Fri Oct 12 11:14:53 2018 7 | # Update Count : 231 8 | # Status : Unknown, Use with caution! 9 | 10 | ################ Common stuff ################ 11 | 12 | use strict; 13 | use warnings; 14 | 15 | # Package name. 16 | my $my_package = 'JoplinTools'; 17 | # Program name and version. 18 | my ($my_name, $my_version) = qw( shownotes 0.02 ); 19 | 20 | ################ Command line parameters ################ 21 | 22 | use Getopt::Long 2.13; 23 | 24 | # Command line options. 25 | my $verbose = 0; # verbose processing 26 | 27 | # Development options (not shown with -help). 28 | my $debug = 0; # debugging 29 | my $trace = 0; # trace (show process) 30 | my $test = 0; # test mode. 31 | 32 | # Process command line options. 33 | app_options(); 34 | 35 | # Post-processing. 36 | $trace |= ($debug || $test); 37 | 38 | ################ Presets ################ 39 | 40 | my $TMPDIR = $ENV{TMPDIR} || $ENV{TEMP} || '/usr/tmp'; 41 | 42 | ################ The Process ################ 43 | 44 | my $root = Joplin::Notebook->new( _title => "Root" ); 45 | my $res; 46 | 47 | while ( @ARGV ) { 48 | my $file = shift; 49 | if ( -d $file ) { 50 | unshift( @ARGV, glob( "$file/*.md" ) ); 51 | $res = { map { $_ => 1 } glob( "$file/.resource/*" ) }; 52 | redo; 53 | } 54 | my $fd; 55 | unless ( open( $fd, '<:utf8', $file ) ) { 56 | warn("$file: $!\n"); 57 | next; 58 | } 59 | 60 | my $raw = do { local $/; <$fd> }; 61 | close($fd); 62 | 63 | $root->loadnote( $file, $raw ); 64 | } 65 | 66 | print $root->to_string; 67 | 68 | foreach ( @{ $root->resources } ) { 69 | if ( $res->{$_} ) { 70 | delete $res->{$_}; 71 | } 72 | else { 73 | warn("Missing resource: $_\n"); 74 | } 75 | } 76 | foreach ( keys %$res ) { 77 | warn("Unreferences resource: $_\n"); 78 | } 79 | 80 | ################ Subroutines ################ 81 | 82 | 83 | ################ Modules ################ 84 | 85 | package Joplin; ################ 86 | 87 | package Joplin::Base; 88 | 89 | sub new { 90 | my $pkg = shift; 91 | bless { @_ } => $pkg; 92 | } 93 | 94 | sub id { $_[0]->{id} } 95 | sub data { $_[0]->{_data} } 96 | sub title { $_[0]->{_title} } 97 | sub type { $_[0]->{type_} } 98 | sub parent_id { $_[0]->{parent_id} || '' } 99 | sub root { $_[0]->{_root} } 100 | 101 | # Get or create parent. 102 | sub parent { 103 | my ( $self ) = @_; 104 | my $pid = $self->parent_id; 105 | $self->root->notes->{$pid} //= Joplin::Notebook->new; 106 | } 107 | 108 | # Type dependent content loader. Internal. 109 | sub load { 110 | my ( $self, $file, $data, $meta ) = @_; 111 | 112 | foreach (split( /\n/, $meta ) ) { 113 | if ( /^(.*?):\s*(.*)/ ) { 114 | $self->{$1} = $2; 115 | } 116 | } 117 | $self->{_title} = $1 if $data =~ /^(.*)/; 118 | $self->{_data} = $data; 119 | $self->{_size} = length($data); 120 | 121 | if ( $self->root->notes->{$self->id} ) { 122 | while ( my($k,$v) = each(%$self) ) { 123 | $self->root->notes->{$self->id}->{$k} = $v; 124 | } 125 | } 126 | else { 127 | $self->root->notes->{$self->id} = $self; 128 | } 129 | $self->parent->add($self) if $self->parent_id ne ''; 130 | return $self; 131 | } 132 | 133 | # Stringification. 134 | sub to_string { 135 | my ( $self ) = @_; 136 | my $res = ref($self); 137 | $res =~ s/^.*:://; 138 | $res .= ": " . $self->title; 139 | $res .= " (" . $self->{_size} . " bytes)" if defined $self->{_size}; 140 | $res . "\n"; 141 | } 142 | 143 | package Joplin::Notebook; ################ 144 | 145 | use parent -norequire => Joplin::Base::; 146 | 147 | sub new { 148 | shift->SUPER::new( type_ => 2, 149 | _notes => {}, 150 | _resources => [], 151 | _children => [], 152 | @_ ); 153 | } 154 | 155 | sub add { 156 | my ( $self, $note ) = @_; 157 | push( @{ $self->{_children} }, $note ); 158 | } 159 | 160 | sub get_type { 161 | my ( $self, $raw ) = @_; 162 | my ( $data, $meta ) = $raw =~ /^(.*)\n\n((?:[^\n]+\n)+[^\n]+)\z/s; 163 | my $type; ( $type ) = $meta =~ m/^type_:\s+(\d+)\z/m; 164 | wantarray ? ( $type, $data, $meta ) : $type; 165 | } 166 | 167 | my @notebjects; # STATIC 168 | 169 | # This is the one and only method to add a note (file) to the notebook. 170 | sub loadnote { 171 | my ( $self, $file, $data ) = @_; 172 | 173 | unless (defined $data ) { 174 | my $fd; 175 | unless ( open( $fd, '<:utf8', $file ) ) { 176 | warn("$file: $!\n"); 177 | return; 178 | } 179 | $data = do { local $/; <$fd> }; 180 | } 181 | 182 | my $meta; 183 | my $type; 184 | ( $type, $data, $meta ) = $self->get_type($data); 185 | unless ( defined $type ) { 186 | warn("$file: No type?\n"); 187 | return; 188 | } 189 | 190 | @notebjects = 191 | qw( Unknown Note Notebook Unknown Image 192 | Unknown Unknown Unknown Unknown Key ) 193 | unless @notebjects; 194 | 195 | my $handler = $notebjects[$type]; 196 | if ( !defined($handler) || $handler eq 'Unknown' ) { 197 | warn("$file: Unhandled type [$type] -- skipped\n"); 198 | return; 199 | } 200 | 201 | $handler = 'Joplin::' . $handler; 202 | my $new = $handler->new( _root => $self )->load( $file, $data, $meta ); 203 | $self->add($new) if $new->parent_id eq ''; 204 | push( @{ $self->{_resources} }, $new->{_resource} ) 205 | if $new->{_resource}; 206 | 207 | return $new; 208 | } 209 | 210 | sub children { 211 | my ( $self ) = @_; 212 | wantarray ? @{ $self->{_children} } : $self->{_children}; 213 | } 214 | 215 | # For root notebooks: *all* the notes in the tree, by id. 216 | sub notes { 217 | $_[0]->{_notes}; 218 | } 219 | 220 | sub resources { 221 | $_[0]->{_resources}; 222 | } 223 | 224 | sub titlesort { lc($a->title) cmp lc($b->title) } 225 | 226 | sub to_string { 227 | my ( $self ) = @_; 228 | my $res = " "; 229 | foreach ( sort titlesort $self->children ) { 230 | $res .= $_->to_string; 231 | } 232 | $res =~ s/\n/\n /g; 233 | $res =~ s/\n \z/\n/; 234 | ref($self) =~ s/^.*:://r . ": " . $self->title . "\n" . $res; 235 | } 236 | 237 | package Joplin::Note; ################ 238 | 239 | use parent -norequire => Joplin::Base::; 240 | 241 | sub new { 242 | shift->SUPER::new( type_ => 1, @_ ); 243 | } 244 | 245 | package Joplin::Image; ################ 246 | 247 | use parent -norequire => Joplin::Base::; 248 | 249 | sub new { 250 | shift->SUPER::new( type_ => 4, @_ ); 251 | } 252 | 253 | sub load { 254 | my ( $self, $file, $data, $meta ) = @_; 255 | $self->SUPER::load( $file, $data, $meta ); 256 | my $res = $file; 257 | $res =~ s;(/[0-9a-f]{32})\.md$;/.resource$1;; 258 | unless ( -e $res ) { 259 | warn("$file: No resource [$res]?\n"); 260 | } 261 | else { 262 | $self->{_resource} = $res; 263 | } 264 | return $self; 265 | } 266 | 267 | sub to_string { 268 | my ( $self ) = @_; 269 | my $res = ref($self) =~ s/^.*:://r . ": "; 270 | $res .= $self->{mime} . " (" . (-s $self->{_resource}) . " bytes)\n"; 271 | return $res; 272 | } 273 | 274 | package main; 275 | 276 | ################ Subroutines ################ 277 | 278 | sub app_options { 279 | my $help = 0; # handled locally 280 | my $ident = 0; # handled locally 281 | my $man = 0; # handled locally 282 | 283 | my $pod2usage = sub { 284 | # Load Pod::Usage only if needed. 285 | require Pod::Usage; 286 | Pod::Usage->import; 287 | &pod2usage; 288 | }; 289 | 290 | # Process options. 291 | if ( @ARGV > 0 ) { 292 | GetOptions('ident' => \$ident, 293 | 'verbose' => \$verbose, 294 | 'trace' => \$trace, 295 | 'help|?' => \$help, 296 | 'man' => \$man, 297 | 'debug' => \$debug) 298 | or $pod2usage->(2); 299 | } 300 | if ( $ident or $help or $man ) { 301 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 302 | } 303 | if ( $man or $help ) { 304 | $pod2usage->(1) if $help; 305 | $pod2usage->(VERBOSE => 2) if $man; 306 | } 307 | } 308 | 309 | __END__ 310 | 311 | ################ Documentation ################ 312 | 313 | =head1 NAME 314 | 315 | shownotes - reads a dir with notes and shows a summary 316 | 317 | =head1 SYNOPSIS 318 | 319 | sample [options] [dir | file ...] 320 | 321 | Options: 322 | --ident shows identification 323 | --help shows a brief help message and exits 324 | --man shows full documentation and exits 325 | --verbose provides more verbose information 326 | 327 | =head1 OPTIONS 328 | 329 | =over 8 330 | 331 | =item B<--help> 332 | 333 | Prints a brief help message and exits. 334 | 335 | =item B<--man> 336 | 337 | Prints the manual page and exits. 338 | 339 | =item B<--ident> 340 | 341 | Prints program identification. 342 | 343 | =item B<--verbose> 344 | 345 | Provides more verbose information. 346 | 347 | =item I 348 | 349 | The input file(s) to process, if any. 350 | 351 | =back 352 | 353 | =head1 DESCRIPTION 354 | 355 | B will read the Joplin notes in the given directory and 356 | produce a symmary. 357 | 358 | =cut 359 | -------------------------------------------------------------------------------- /joplin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sciurius/perl-Joplin-API/818f3f8a739e117a65c3ae64f72b284edc7b5a52/joplin.png -------------------------------------------------------------------------------- /lib/Joplin.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use Carp; 7 | 8 | =head1 NAME 9 | 10 | Joplin - Interface to Joplin notes 11 | 12 | =head1 SYNOPSIS 13 | 14 | # Connect to Joplin. 15 | $root = Joplin->connect( ... ); 16 | 17 | # Find folder with name "Project". Assume there is only one. 18 | $prj = $root->find_folders("Project")->[0]; 19 | 20 | # Find the notes in the Project folder that have "january" in the title. 21 | $notes = $prj->find_notes(qr/january/i); 22 | 23 | =head1 DESCRIPTION 24 | 25 | This class handles connecting to the Joplin server. 26 | 27 | =cut 28 | 29 | package Joplin; 30 | 31 | our $VERSION = "0.01"; 32 | 33 | =name1 METHODS 34 | 35 | =head2 connect 36 | 37 | Connects to the Joplin notes server. 38 | 39 | $root = Joplin->connect(%init); 40 | 41 | Returns a Joplin::Root pseudo-folder object representing the root of all notes. 42 | 43 | Initial arguments: 44 | 45 | =over 4 46 | 47 | =item host 48 | 49 | The name of the host running the Joplin API server. Default is the 50 | local system. 51 | 52 | =item port 53 | 54 | The port the Joplin API server listens on. Default is C<41184>. 55 | 56 | =item server 57 | 58 | The complete connection string, e.g. C. 59 | 60 | This is derived from C and C. 61 | 62 | =item token 63 | 64 | The Joplin API access token. 65 | 66 | =item apikey 67 | 68 | Alternative name for C. 69 | 70 | =back 71 | 72 | =cut 73 | 74 | use Joplin::Root; 75 | 76 | sub connect { 77 | my ( $pkg, %init ) = @_; 78 | return Joplin::Root->new(%init); 79 | } 80 | 81 | =head1 LICENSE 82 | 83 | Copyright (C) 2019, Johan Vromans 84 | 85 | This module is free software. You can redistribute it and/or 86 | modify it under the terms of the Artistic License 2.0. 87 | 88 | This program is distributed in the hope that it will be useful, 89 | but without any warranty; without even the implied warranty of 90 | merchantability or fitness for a particular purpose. 91 | 92 | =cut 93 | 94 | 1; 95 | -------------------------------------------------------------------------------- /lib/Joplin/API.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | package Joplin::API; 8 | 9 | use JSON; 10 | use Carp; 11 | use Data::Dumper; 12 | 13 | our $VERSION = "0.01"; 14 | 15 | =head1 NAME 16 | 17 | Joplin::API - Access methods for the Joplin REST API. 18 | 19 | =head1 SYNOPSIS 20 | 21 | use Joplin::API; 22 | 23 | my $api = Joplin::API->new( token => "YOUR KEY HERE" ); 24 | my $res = $api->get_folders; 25 | foreach ( @$res ) { 26 | print( $_->{id}, " ", $_->{title}, "\n"); 27 | } 28 | 29 | =head1 DESCRIPTION 30 | 31 | Joplin is a free, open source note taking and to-do application, which 32 | can handle a large number of notes organised into notebooks. The notes 33 | are searchable, can be copied, tagged and modified either from the 34 | applications directly or from your own text editor. The notes are in 35 | Markdown format. 36 | 37 | Clients exist for Linux, Windows and OS/X desktop systems, and for 38 | Android and iOS devices. Notes can be synchronized across clients. 39 | 40 | A running desktop client can also act as an API server, giving access 41 | to the notes database, 42 | 43 | B provides a set of methods to access such a running Joplin 44 | API server. 45 | 46 | See L for general information on Joplin. 47 | 48 | The API is described in L. 49 | 50 | =head1 METHODS 51 | 52 | B All methods will throw an exception in case of errors. 53 | 54 | All methods will return a property hash, or a reference to an array of property hashes, except: 55 | 56 | =over 3 57 | 58 | =item * 59 | 60 | The C methods will return an array of property hashes in 61 | list context, and a reference to this array in scalar context. 62 | 63 | =item * 64 | 65 | The C methods will return true if successful. 66 | 67 | =item * 68 | 69 | Method C returns the identification string from the Joplin 70 | server, usually C 71 | 72 | =back 73 | 74 | =head2 new 75 | 76 | Returns a new API object. 77 | 78 | $api = Joplin::API->new( token => $apikey ); 79 | 80 | Initial arguments: 81 | 82 | =over 4 83 | 84 | =item host 85 | 86 | The name of the host running the Joplin API server. Default is the 87 | local system. 88 | 89 | =item port 90 | 91 | The port the Joplin API server listens on. Default is C<41184>. 92 | 93 | =item server 94 | 95 | The complete connection string, e.g. C. 96 | 97 | This is derived from C and C. 98 | 99 | =item token 100 | 101 | The Joplin API access token. 102 | 103 | =item apikey 104 | 105 | Alternative name for C. 106 | 107 | =back 108 | 109 | =cut 110 | 111 | sub new { 112 | my ( $pkg, %init ) = @_; 113 | my $self = bless { %init }, $pkg; 114 | 115 | if ( $self->{host} ) { 116 | $self->{port} ||= 41184; 117 | $self->{server} = "http://" . $self->{host} . ":" . $self->{port}; 118 | } 119 | $self->{apikey} //= delete $self->{token}; 120 | 121 | return $self; 122 | } 123 | 124 | =head2 set_server 125 | 126 | Sets the name of the Joplin API server. 127 | 128 | $api->set_server($server); 129 | 130 | =cut 131 | 132 | sub set_server { 133 | my ( $self, $server ) = @_; 134 | $self->{server} = $server; 135 | } 136 | 137 | =head2 get_server 138 | 139 | Returns the name of the Joplin API server. 140 | 141 | $server = $api->get_server; 142 | 143 | =cut 144 | 145 | sub get_server { 146 | my ( $self ) = @_; 147 | $self->{server}; 148 | } 149 | 150 | =head2 set_apikey 151 | 152 | Sets the Joplin API access token. 153 | 154 | $api->set_apikey($token); 155 | 156 | C is an alternative name for the same method. 157 | 158 | =cut 159 | 160 | sub set_apikey { 161 | my ( $self, $apikey ) = @_; 162 | $self->{apikey} = $apikey; 163 | } 164 | *set_token = \&set_apikey; 165 | 166 | =head2 get_apikey 167 | 168 | Returns the Joplin API access token. 169 | 170 | $api->set_apikey($token); 171 | 172 | C is an alternative name for the same method. 173 | 174 | =cut 175 | 176 | sub get_apikey { 177 | my ( $self ) = @_; 178 | $self->{apikey}; 179 | } 180 | *get_token = \&get_apikey; 181 | 182 | =head2 set_debug 183 | 184 | Enables numerous debugging messages. 185 | 186 | $api->set_debug($state); 187 | 188 | =cut 189 | 190 | sub set_debug { 191 | my ( $self ) = @_; 192 | $self->{debug} = @_ == 1 ? 1 : $_[1]; 193 | } 194 | 195 | ################ Properties ################ 196 | 197 | my $properties = # type 1 198 | { note => 199 | { rw => [ qw(id parent_id title body 200 | latitude longitude altitude 201 | author source_url is_todo todo_due todo_completed 202 | source source_application application_data 203 | order user_created_time user_updated_time 204 | body_html base_url image_data_url crop_rect) ], 205 | ro => [ qw(created_time updated_time 206 | encryption_cipher_text encryption_applied) ] }, 207 | 208 | folder => # type 2 209 | { rw => [ qw(id parent_id title 210 | user_created_time user_updated_time) ], 211 | ro => [ qw(created_time updated_time 212 | encryption_cipher_text encryption_applied) ] }, 213 | 214 | root => # type 2 (pseudo) 215 | { rw => [ qw(id parent_id ) ], 216 | ro => [ ] }, 217 | 218 | # setting # type 3 219 | 220 | resource => # type 4 221 | { rw => [ qw(id title mime filename 222 | user_created_time user_updated_time 223 | file_extension) ], 224 | ro => [ qw(created_time updated_time 225 | encryption_cipher_text 226 | encryption_applied encryption_blob_encrypted) ] }, 227 | 228 | tag => # type 5 229 | { rw => [ qw(id title user_created_time user_updated_time) ], 230 | ro => [ qw(created_time updated_time 231 | encryption_cipher_text encryption_applied) ] }, 232 | 233 | # notetag # type 6 234 | # search # type 7 235 | # alarm # type 8 236 | # master key # type 9 237 | # item change # type 10 238 | # note resource # type 11 239 | # resource local state # type 11 240 | 241 | notediff => # type 12 242 | { rw => [ qw(id parent_id title_diff body_diff item_id 243 | item_updated_time 244 | body_html base_url image_data_url crop_rect) ], 245 | ro => [ qw(created_time updated_time 246 | encryption_cipher_text encryption_applied) ] }, 247 | 248 | # migration type 14 249 | }; 250 | 251 | sub properties { 252 | my ( $pkg, $item, $type ) = @_; 253 | croak("Joplin::API: No properties for $item") 254 | unless defined $properties->{$item}; 255 | if ( !$type ) { 256 | [ @{ $properties->{$item}->{ro} }, @{ $properties->{$item}->{rw} } ]; 257 | } 258 | elsif ( $type eq "ro" || $type eq "rw" ) { 259 | $properties->{$item}->{$type}; 260 | } 261 | else { 262 | croak("Joplin::API: No $type properties for $item") 263 | } 264 | } 265 | 266 | ################ Folders ################ 267 | 268 | =head1 FOLDER METHODS 269 | 270 | =head2 get_folders 271 | 272 | Returns an array with folder info. 273 | 274 | $res = $api->get_folders; 275 | 276 | Each element is a hash containing selected folder properties. 277 | 278 | The folders are returned as a tree. Subfolders of a folder are under 279 | the children key. 280 | 281 | =cut 282 | 283 | sub get_folders { 284 | my ( $self ) = @_; 285 | $self->query( "get", "/folders" ); 286 | } 287 | 288 | =head2 get_folders_recursive 289 | 290 | Returns an array with folder info. 291 | 292 | $res = $api->get_folders_recursive; 293 | $res = $api->get_folders_recursive($folder_id); 294 | 295 | Each element is a hash containing selected folder properties. 296 | 297 | With a folder_id, only this folder plus subfolders are returned. 298 | 299 | The folders are returned as a flattened list. 300 | 301 | =cut 302 | 303 | sub get_folders_recursive { 304 | my ( $self, $id, $list ) = @_; 305 | $list //= $self->get_folders; 306 | my @res; 307 | foreach ( @$list ) { 308 | if ( defined($id) && $_->{id} ne $id ) { 309 | push( @res, @{ $self->get_folders_recursive( $id, $_->{children} ) } ) 310 | if exists $_->{children}; 311 | next; 312 | } 313 | push( @res, $_ ); 314 | push( @res, @{ $self->get_folders_recursive( undef, $_->{children} ) } ) 315 | if exists $_->{children}; 316 | } 317 | wantarray ? @res : \@res; 318 | } 319 | 320 | =head2 get_folder 321 | 322 | Returns a hash containing the folder properties for a specific folder. 323 | 324 | $res = $api->get_folder($folder_id); 325 | 326 | =cut 327 | 328 | sub get_folder { 329 | my ( $self, $folder_id ) = @_; 330 | $folder_id 331 | ? $self->query( "get", "/folders/$folder_id" ) 332 | : undef; 333 | } 334 | 335 | =head2 get_folder_notes 336 | 337 | Returns an array with notes info for all the notes in this folder. 338 | 339 | $res = $api->get_folder_notes($folder_id); 340 | 341 | Each element is a hash containing note properties. 342 | 343 | =cut 344 | 345 | sub get_folder_notes { 346 | my ( $self, $folder_id ) = @_; 347 | $folder_id 348 | ? $self->query( "get", "/folders/$folder_id/notes" ) 349 | : $self->get_notes; 350 | } 351 | 352 | =head2 create_folder 353 | 354 | Creates a new folder with the given title and, optional, properties. 355 | 356 | $res = $api->create_folder($title, parent_id => $parent_id ); 357 | 358 | Properties: 359 | 360 | =over 4 361 | 362 | =item title 363 | 364 | The name (title) of the folder. Overrides the C<$title> argument. 365 | 366 | =item parent_id 367 | 368 | The id of the parent folder, if any. 369 | 370 | =back 371 | 372 | Returns a hash containing the properties of the new folder. 373 | 374 | =cut 375 | 376 | sub create_folder { 377 | my ( $self, $title, %args ) = @_; 378 | 379 | my $data = {}; 380 | for ( @{ $properties->{folder}->{rw} } ) { 381 | $data->{$_} = delete $args{$_} if exists $args{$_}; 382 | } 383 | $data->{title} //= $title; 384 | croak( "Joplin::API: Unhandled properties in create_folder: " . 385 | join(" ", sort keys %args) ) if %args; 386 | 387 | $self->query( "post", "/folders", $data ); 388 | } 389 | 390 | =head2 update_folder 391 | 392 | Updates the folder with new property values. 393 | 394 | $res = $api->update_folder($folder_id, title => $new_title); 395 | 396 | Properties: 397 | 398 | =over 4 399 | 400 | =item title 401 | 402 | The name (title) of the folder. 403 | 404 | =item parent_id 405 | 406 | The id of the parent folder. 407 | 408 | =back 409 | 410 | Returns a hash containing the properties of the new folder. 411 | 412 | =cut 413 | 414 | sub update_folder { 415 | my ( $self, $folder_id, %args ) = @_; 416 | 417 | my $data = {}; 418 | for ( @{ $properties->{folder}->{rw} } ) { 419 | next unless exists $args{$_}; 420 | $data->{$_} = delete $args{$_}; 421 | } 422 | croak( "Joplin::API: Unhandled properties in update_folder: " . 423 | join(" ", sort keys %args) ) if %args; 424 | 425 | $self->query( "put", "/folders/$folder_id", $data ); 426 | } 427 | 428 | =head2 delete_folder 429 | 430 | Deletes the folder. 431 | 432 | $res = $api->delete_folder($folder_id); 433 | 434 | Returns true if successful. 435 | 436 | =cut 437 | 438 | sub delete_folder { 439 | my ( $self, $folder_id ) = @_; 440 | $self->query( "delete", "/folders/$folder_id" ); 441 | } 442 | 443 | =head2 find_folders 444 | 445 | Finds folders by name. 446 | 447 | $res = $api->find_folders($pattern); 448 | 449 | The optional argument C<$pattern> must be a string or a pattern. If a 450 | string, it performs a case insensitive search on the name of the 451 | folder. A pattern can be used for more complex matches. 452 | If the pattern is omitted, all results are returned. 453 | 454 | Returns a (possibly empty) array of hashes with folder info if successful. 455 | 456 | =cut 457 | 458 | sub find_folders { 459 | $_[2] = $_[0]->get_folders; 460 | goto &find_selected; 461 | } 462 | 463 | sub find_folder_notes { 464 | my ( $self, $folder_id, $pat ) = @_; 465 | $self->find_selected( $pat, $self->get_folder_notes($folder_id) ); 466 | } 467 | 468 | ################ Notes ################ 469 | 470 | =head1 NOTE METHODS 471 | 472 | =head2 get_notes 473 | 474 | Returns an array with notes info. 475 | 476 | $res = $api->get_notes; 477 | 478 | Each element is a hash with note properties. 479 | 480 | =cut 481 | 482 | sub get_notes { 483 | my ( $self ) = @_; 484 | $self->query( "get", "/notes/" ); 485 | } 486 | 487 | =head2 get_note 488 | 489 | Gets the data for a specific note. 490 | 491 | $res = $api->get_note($note_id); 492 | 493 | Returns a hash with note properties. 494 | 495 | =cut 496 | 497 | sub get_note { 498 | my ( $self, $note_id ) = @_; 499 | $self->query( "get", "/notes/$note_id" ); 500 | } 501 | 502 | =head2 get_note_tags 503 | 504 | Returns an array with tags info for a specific note. 505 | 506 | $res = $api->get_note_tags($note_id); 507 | 508 | Each element is a hash with tag properties. 509 | 510 | =cut 511 | 512 | sub get_note_tags { 513 | my ( $self, $note_id ) = @_; 514 | $self->query( "get", "/notes/$note_id/tags" ); 515 | } 516 | 517 | =head2 create_note 518 | 519 | Creates a new note with the given title, body, parent_id and, 520 | optional, other properties. 521 | 522 | $res = $api->create_note($title, $body, $parent_id, is_todo => 1 ); 523 | 524 | Properties: 525 | 526 | =over 4 527 | 528 | =item author 529 | 530 | The name of the note's author. 531 | 532 | =item body 533 | 534 | The note contents if MarkDown. 535 | 536 | =item body_html 537 | 538 | The note contents if HTML. 539 | 540 | =item source_url 541 | 542 | The source URL for the note, if any. 543 | 544 | =item tags 545 | 546 | A list of comma separated tag names. 547 | 548 | =item is_todo 549 | 550 | The note is a TODO. 551 | 552 | =back 553 | 554 | Returns a hash containing the properties of the new folder. 555 | 556 | =cut 557 | 558 | sub create_note { 559 | my ( $self, $title, $body, $parent_id, %args ) = @_; 560 | my $data = { title => $title, 561 | body => $body, 562 | parent_id => $parent_id }; 563 | for ( @{ $properties->{note}->{rw} }, "tags" ) { 564 | $data->{$_} = delete $args{$_} if exists $args{$_}; 565 | } 566 | croak( "Joplin::API: Unhandled properties in create_note: " . 567 | join(" ", sort keys %args) ) if %args; 568 | 569 | $self->query( "post", "/notes/", $data ); 570 | } 571 | 572 | =head2 update_note 573 | 574 | Updates an existing note with new properties. 575 | 576 | $res = $api->update_note($note_id, title => $new_title ); 577 | 578 | Properties: 579 | 580 | =over 4 581 | 582 | =item title 583 | 584 | The new title for the note. 585 | 586 | =item body 587 | 588 | New markdown content for the note. 589 | 590 | =item parent_id 591 | 592 | Moves the note the another folder. 593 | 594 | =item author 595 | 596 | The name of the note's author. 597 | 598 | =item source_url 599 | 600 | The source URL for the note, if any. 601 | 602 | =item is_todo 603 | 604 | The note is a TODO. 605 | 606 | =item todo_due 607 | 608 | A timestamp when the TODO must be completed. 609 | 610 | The timestamp is epoch time in milliseconds. 611 | 612 | =item todo_completed 613 | 614 | A timestamp when the TODO was completed. 615 | 616 | =back 617 | 618 | Returns a hash containing the properties of the new folder. 619 | 620 | =cut 621 | 622 | sub update_note { 623 | my ( $self, $note_id, %args ) = @_; 624 | my $data = {}; 625 | for ( @{ $properties->{note}->{rw} } ) { 626 | $data->{$_} = delete $args{$_} if exists $args{$_}; 627 | } 628 | croak( "Joplin::API: Unhandled properties in update_note: " . 629 | join(" ", sort keys %args) ) if %args; 630 | # Only works with put. 631 | $self->query( "put", "/notes/$note_id", $data ); 632 | } 633 | 634 | =head2 delete_note 635 | 636 | Deletes the note. 637 | 638 | $res = $api->delete_note($note_id); 639 | 640 | Returns true if successful. 641 | 642 | =cut 643 | 644 | sub delete_note { 645 | my ( $self, $note_id ) = @_; 646 | $self->query( "delete", "/notes/$note_id" ); 647 | } 648 | 649 | =head2 find_notes 650 | 651 | Finds notes by name. 652 | 653 | $res = $api->find_notes($pattern); 654 | 655 | The optional argument C<$pattern> must be a string or a pattern. If a 656 | string, it performs a case insensitive search on the name of the note. 657 | A pattern can be used for more complex matches. If the pattern is 658 | omitted, all results are returned. 659 | 660 | Returns a (possibly empty) array of hashes with note info if successful. 661 | 662 | =cut 663 | 664 | sub find_notes { 665 | $_[2] = $_[0]->get_notes; 666 | goto &find_selected; 667 | } 668 | 669 | 670 | ################ Tags ################ 671 | 672 | =head1 TAG METHODS 673 | 674 | =head2 get_tag 675 | 676 | Gets the data for a specific tag. 677 | 678 | $res = $api->get_tag($tag_id); 679 | 680 | Returns a hash with tag properties. 681 | 682 | =cut 683 | 684 | sub get_tag { 685 | my ( $self, $tag_id ) = @_; 686 | $self->query( "get", "/tags/$tag_id" ); 687 | } 688 | 689 | =head2 get_tags 690 | 691 | Returns an array of data for all tags. 692 | 693 | $res = $api->get_tags; 694 | 695 | Each element is a hash with tag properties. 696 | 697 | =cut 698 | 699 | sub get_tags { 700 | my ( $self ) = @_; 701 | $self->query( "get", "/tags" ); 702 | } 703 | 704 | =head2 create_tag 705 | 706 | Creates a new tag. Note that Joplin downcases tag titles. 707 | 708 | $res = $api->create_tag($title); 709 | 710 | Returns a hash with tag properties. 711 | 712 | =cut 713 | 714 | sub create_tag { 715 | my ( $self, $title, %args ) = @_; 716 | my $data = { title => $title }; 717 | for ( @{ $properties->{tag}->{rw} } ) { 718 | $data->{$_} = delete $args{$_} if exists $args{$_}; 719 | } 720 | croak( "Joplin::API: Unhandled properties in create_tag: " . 721 | join(" ", sort keys %args) ) if %args; 722 | $self->query( "post", "/tags", $data ); 723 | } 724 | 725 | =head2 update_tag 726 | 727 | Updates the title for a specific tag. 728 | 729 | $res = $api->update_tag($tag_id); 730 | 731 | Returns a hash with updated tag properties. 732 | 733 | =cut 734 | 735 | sub update_tag { 736 | my ( $self, $tag_id, %args ) = @_; 737 | my $data = {}; 738 | for ( @{ $properties->{tag}->{rw} } ) { 739 | $data->{$_} = delete $args{$_} if exists $args{$_}; 740 | } 741 | croak( "Joplin::API: Unhandled properties in update_tag: " . 742 | join(" ", sort keys %args) ) if %args; 743 | $self->query( "put", "/tags/$tag_id", $data ); 744 | } 745 | 746 | =head2 delete_tag 747 | 748 | Deletes a specific tag. 749 | 750 | $res = $api->delete_tag($tag_id); 751 | 752 | Returns true if successful. 753 | 754 | =cut 755 | 756 | sub delete_tag { 757 | my ( $self, $tag_id ) = @_; 758 | $self->query( "delete", "/tags/$tag_id" ); 759 | } 760 | 761 | =head2 get_tag_notes 762 | 763 | Returns an array with data for all notes that have a specific tag. 764 | 765 | $res = $api->get_tag_notes($tag_id); 766 | 767 | Each element is a hash with note properties. 768 | 769 | =cut 770 | 771 | sub get_tag_notes { 772 | my ( $self, $note_id ) = @_; 773 | $self->query( "get", "/tags/$note_id/notes" ); 774 | } 775 | 776 | =head2 create_tag_note 777 | 778 | Associate a tag with a note. 779 | 780 | $res = $api->create_tag_note( $tag_id, $note_id ); 781 | 782 | Returns a hash describing the link between the tag and the note. 783 | 784 | =cut 785 | 786 | sub create_tag_note { 787 | my ( $self, $tag_id, $note_id ) = @_; 788 | my $data = { id => $note_id }; 789 | $self->query( "post", "/tags/$tag_id/notes", $data ); 790 | } 791 | 792 | =head2 delete_tag_note 793 | 794 | Deletes the association of the tag with the note. 795 | 796 | $res = $api->delete_tag_note( $tag_id, $note_id ); 797 | 798 | Returns true if successful. 799 | 800 | =cut 801 | 802 | sub delete_tag_note { 803 | my ( $self, $tag_id, $note_id ) = @_; 804 | $self->query( "delete", "/tags/$tag_id/notes/$note_id" ); 805 | } 806 | 807 | =head2 find_tags 808 | 809 | Finds tags by name. 810 | 811 | $res = $api->find_tags($pattern); 812 | 813 | The optional argument C<$pattern> must be a string or a pattern. If a 814 | string, it performs a case insensitive search on the name of the tag. 815 | A pattern can be used for more complex matches. If the pattern is 816 | omitted, all results are returned. 817 | 818 | Note that tag names (titles) are downcased by Joplin. Searching with a 819 | pattern that requires uppercase characters will never succeed. 820 | 821 | Returns a (possibly empty) array of hashes with tag info if successful. 822 | 823 | =cut 824 | 825 | sub find_tags { 826 | $_[2] = $_[0]->get_tags; 827 | goto &find_selected; 828 | } 829 | 830 | sub find_tag_notes { 831 | } 832 | 833 | 834 | ################ Resources ################ 835 | 836 | =head1 RESOURCE METHODS 837 | 838 | =head2 get_resource 839 | 840 | Gets info for a specific resource. 841 | 842 | $res = $api->get_resource($res_id); 843 | 844 | Returns a hash with resource properties. 845 | 846 | =cut 847 | 848 | sub get_resource { 849 | my ( $self, $resource_id ) = @_; 850 | $self->query( "get", "/resources/$resource_id" ); 851 | } 852 | 853 | =head2 get_resources 854 | 855 | Returns an array with info for all resources. 856 | 857 | $res = $api->get_resources; 858 | 859 | Each element is a hash with resource properties. 860 | 861 | =cut 862 | 863 | sub get_resources { 864 | my ( $self ) = @_; 865 | $self->query( "get", "/resources" ); 866 | } 867 | 868 | =head2 create_resource 869 | 870 | Creates a new resource. 871 | 872 | $res = $api->create_resource( $file, title => $title ); 873 | 874 | The named file must be accessible and its content will be copied to 875 | the resource. 876 | 877 | Properties: 878 | 879 | =over 4 880 | 881 | =item title 882 | 883 | The title for this resource. Defaults to the file name. 884 | 885 | =item mime 886 | 887 | The mime type for this resource. A suitable default will be guessed. 888 | 889 | =back 890 | 891 | Returns the properties for the new resource. 892 | 893 | =cut 894 | 895 | sub create_resource { 896 | my ( $self, $file, %args ) = @_; 897 | 898 | croak("Joplin::API: Resource $file not found") 899 | unless -e -s -r $file; 900 | 901 | my $data = { props => { title => $file } }; 902 | for ( @{ $properties->{resource}->{rw} } ) { 903 | $data->{props}->{$_} = delete $args{$_} if exists $args{$_}; 904 | } 905 | croak( "Joplin::API: Unhandled properties in create_resource: " . 906 | join(" ", sort keys %args) ) if %args; 907 | 908 | $data->{data} = $file; 909 | 910 | $self->query( "post+", "/resources", $data ); 911 | } 912 | 913 | =head2 update_resource 914 | 915 | Updates the properties of the resource. 916 | 917 | $res = $api->update_resource( $res_id, title => $title ); 918 | 919 | Properties: 920 | 921 | =over 4 922 | 923 | =item title 924 | 925 | The new title for the resource. 926 | 927 | =back 928 | 929 | Returns a hash with updated properties. 930 | 931 | =cut 932 | 933 | sub update_resource { 934 | my ( $self, $resource_id, %args ) = @_; 935 | 936 | my $data = {}; 937 | for ( @{ $properties->{resource}->{rw} } ) { 938 | $data->{$_} = delete $args{$_} if exists $args{$_}; 939 | } 940 | croak( "Joplin::API: Unhandled properties in update_resource: " . 941 | join(" ", sort keys %args) ) if %args; 942 | 943 | $self->query( "put", "/resources/$resource_id", $data ); 944 | } 945 | 946 | =head2 fetch_resource 947 | 948 | Fetches a resource. 949 | 950 | $data = $api->fetch_resource($res_id); 951 | 952 | Returns the raw data for the resource if successful. 953 | 954 | =cut 955 | 956 | sub fetch_resource { 957 | my ( $self, $resource_id ) = @_; 958 | $self->query( "get", "/resources/$resource_id/file", undef, "raw" ); 959 | } 960 | 961 | =head2 delete_resource 962 | 963 | Deletes a resource. 964 | 965 | $res = $api->delete_resource($res_id); 966 | 967 | Returns true if successful. 968 | 969 | =cut 970 | 971 | sub delete_resource { 972 | my ( $self, $resource_id ) = @_; 973 | $self->query( "delete", "/resources/$resource_id" ); 974 | } 975 | 976 | ################ Low level ################ 977 | 978 | =head1 MISCELLANEOUS METHODS 979 | 980 | =head2 ping 981 | 982 | Checks whether the API server is accessible. 983 | 984 | $status = $api->ping 985 | 986 | Returns true if successful. 987 | 988 | =cut 989 | 990 | sub ping { 991 | my ( $self ) = @_; 992 | $self->query( "get", "/ping", undef, "raw" ); 993 | } 994 | 995 | =head2 query 996 | 997 | This is the API communication handler used internally by all methods. 998 | 999 | No user servicable parts inside. 1000 | 1001 | =cut 1002 | 1003 | use LWP::UserAgent; 1004 | 1005 | sub query { 1006 | my ( $self, $method, $path, $data, $raw ) = @_; 1007 | 1008 | croak("Joplin::API: Unsupported query path: $path") 1009 | unless $path =~ m;^/(?:notes|folders|tags|resources|ping)(?:/|$);; 1010 | 1011 | my $ua = $self->{ua} ||= LWP::UserAgent->new( timeout => 10 ); 1012 | my $pp = $self->{pp} ||= JSON->new->utf8; 1013 | 1014 | $path = $self->{server} . $path; 1015 | $path .= "?token=" . $self->{apikey} unless $path eq "/ping"; 1016 | $path =~ s'\?fields=body\?token='?fields=body&token='; 1017 | 1018 | warn( uc($method), " $path" ) if $self->{debug}; 1019 | 1020 | my $res; 1021 | if ( $method eq "get" ) { 1022 | croak("Joplin::API: $method query doesn't take data") if $data; 1023 | $res = $ua->$method($path); 1024 | if ( $raw ) { 1025 | return undef unless $res->is_success; 1026 | return $res->decoded_content; 1027 | } 1028 | } 1029 | elsif ( $method eq "delete" ) { 1030 | croak("Joplin::API: $method query doesn't take data") if $data; 1031 | $res = $ua->$method($path); 1032 | return 1 if $res->is_success; 1033 | } 1034 | elsif ( $method eq "put" || $method eq "post" ) { 1035 | croak("Joplin::API: $method query requires data") unless $data; 1036 | warn(Dumper($data)) if $self->{debug}; 1037 | $res = $ua->$method( $path, Content => $pp->encode($data) ); 1038 | } 1039 | elsif ( $method eq "post+" ) { 1040 | croak("Joplin::API: $method query requires data") unless $data; 1041 | $res = $ua->post( $path, 1042 | Content_Type => 'form-data', 1043 | Content => [ data => [ $data->{data} ], 1044 | props => $pp->encode($data->{props}), 1045 | ] ); 1046 | } 1047 | else { 1048 | croak("Joplin::API: Unsupported query method: $method"); 1049 | } 1050 | 1051 | warn($res->decoded_content) if $self->{debug}; 1052 | unless ( $res->is_success ) { 1053 | return 1 if $res->status_line =~ /500 OK/; 1054 | croak( "Joplin::API: " . $res->status_line ) 1055 | } 1056 | return $pp->decode($res->decoded_content); 1057 | } 1058 | 1059 | sub find_selected { 1060 | my ( $self, $pat, $list ) = @_; 1061 | $list = $list->() if UNIVERSAL::isa($list, 'CODE'); 1062 | 1063 | unless ( !$pat || ref($pat) eq "Regexp" ) { 1064 | $pat = qr/^$pat$/i; # case insens 1065 | } 1066 | 1067 | my @res; 1068 | foreach my $item ( @$list ) { 1069 | push( @res, { %$item } ) if !$pat || $item->{title} =~ $pat; 1070 | } 1071 | 1072 | return wantarray ? @res : \@res; 1073 | } 1074 | 1075 | =head1 LICENSE 1076 | 1077 | Copyright (C) 2019, Johan Vromans 1078 | 1079 | This module is free software. You can redistribute it and/or 1080 | modify it under the terms of the Artistic License 2.0. 1081 | 1082 | This program is distributed in the hope that it will be useful, 1083 | but without any warranty; without even the implied warranty of 1084 | merchantability or fitness for a particular purpose. 1085 | 1086 | =cut 1087 | 1088 | 1; 1089 | -------------------------------------------------------------------------------- /lib/Joplin/Base.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | package Joplin::Base; 8 | 9 | use Joplin::API; 10 | use Carp; 11 | use overload '""' => 'title'; 12 | 13 | # _wrap takes the supplied hash and wraps it in a new object. 14 | # 15 | # The api parameter is mandatory if this method is invoked from a 16 | # class. 17 | 18 | sub _wrap { 19 | my ( $pkg, $init, $api ) = @_; 20 | if ( ref($pkg) ) { 21 | $api = $pkg->api; 22 | $pkg = ref($pkg); 23 | } 24 | bless { %$init, _api => $api }, $pkg; 25 | } 26 | 27 | # Returns the low-level Joplin::API object. 28 | 29 | sub api :lvalue { 30 | $_[0]->{_api}; 31 | } 32 | 33 | # Checks if the Joplin API server can be reached. 34 | 35 | sub ping { 36 | $_[0]->api->ping; 37 | } 38 | 39 | # Converts a timestamp readable ISO-8601 format. 40 | # Note that joplin maintains times in milliseconds. 41 | 42 | sub iso8601date { 43 | my ( $self, $time ) = @_; 44 | $time = @_ >= 2 ? $time/1000 : time; 45 | my @tm = localtime($time); 46 | sprintf( "%04d-%02d-%02d %02d:%02d:%02d", 47 | 1900+$tm[5], 1+$tm[4], @tm[3,2,1,0] ); 48 | } 49 | 50 | ################ Property Setter/Getters ################ 51 | 52 | # Gets the value of a readonly property. 53 | 54 | sub _get_property { 55 | my ( $self, $name ) = @_; 56 | if ( @_ >= 3 ) { 57 | croak("Joplin: Property '$name' is read-only"); 58 | } 59 | $self->{$name}; 60 | } 61 | 62 | # Gets the lvalue of a property. 63 | # With additional argument: modifies the property. 64 | 65 | sub _set_get_property :lvalue { 66 | my ( $self, $name ) = splice( @_, 0, 2 ); 67 | if ( @_ == 1 ) { 68 | $self->{$name} = $_[0]; 69 | } 70 | $self->{$name}; 71 | } 72 | 73 | # Sets up the property handlers for readonly and readwrite properties. 74 | 75 | sub _set_property_handlers { 76 | my ( $pkg ) = @_; 77 | ( my $type = lc $pkg ) =~ s/^.*:://; 78 | 79 | no strict 'refs'; 80 | foreach ( @{ Joplin::API->properties( $type, "rw" ) } ) { 81 | my $attr = $_; # lexical for closure 82 | *{$pkg.'::'.$_} = sub :lvalue { 83 | splice( @_, 1, 0, $attr ); 84 | goto &_set_get_property; 85 | }; 86 | } 87 | foreach ( @{ Joplin::API->properties( $type, "ro" ) } ) { 88 | my $attr = $_; # lexical for closure 89 | *{$pkg.'::'.$_} = sub { 90 | splice( @_, 1, 0, $attr ); 91 | goto &_get_property; 92 | }; 93 | } 94 | *{$pkg.'::'.'properties'} = sub { 95 | my ( $self, $what ) = @_; 96 | my @res = @{ Joplin::API->properties( $type, $what ) }; 97 | wantarray ? @res : \@res; 98 | }; 99 | } 100 | 101 | 1; 102 | -------------------------------------------------------------------------------- /lib/Joplin/Folder.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | =head1 Joplin::Folder 8 | 9 | Joplin::Folder - Class for Joplin folders 10 | 11 | =head1 SYNOPSIS 12 | 13 | # Connect to Joplin. 14 | $root = Joplin->connect( ... ); 15 | 16 | # Find folder with name "Project". Assume there is only one. 17 | $prj = $root->find_folders("Project")->[0]; 18 | 19 | $ Find the notes in this project that have "january" in the title. 20 | $notes = $prj->find_notes(qr/january/i); 21 | 22 | =head1 DESCRIPTION 23 | 24 | This class provides methods to deal with Joplin folders. 25 | 26 | =cut 27 | 28 | package Joplin::Folder; 29 | 30 | use Carp; 31 | 32 | use parent qw(Joplin::Base); 33 | 34 | use Joplin::Note; 35 | use Joplin::Tag; 36 | 37 | =head1 METHODS 38 | 39 | =head2 find_notes 40 | 41 | Finds notes by name or pattern in this folder. 42 | 43 | @res = $folder->find_notes($pattern); 44 | $res = $folder->find_notes($pattern); 45 | 46 | The optional argument C<$pattern> must be a string or a pattern. If a 47 | string, it performs a case insensitive search on the name of the note. 48 | A pattern can be used for more complex matches. If the pattern is 49 | omitted, all results are returned. 50 | 51 | Returns a (possibly empty) array of Joplin::Note objects. 52 | 53 | With a second, non-false argument, the search includes subfolders. 54 | 55 | =cut 56 | 57 | sub find_notes { 58 | my ( $self, $pat, $recurse ) = @_; 59 | 60 | my $res; 61 | if ( $recurse ) { 62 | foreach ( @{ $self->api->get_folders_recursive( $self->id ) } ) { 63 | push( @$res, 64 | @{ $self->api->find_folder_notes( $_->{id}, $pat ) } ); 65 | } 66 | } 67 | else { 68 | $res = $self->api->find_folder_notes( $self->id, $pat ); 69 | } 70 | 71 | $res = [ map { Joplin::Note->_wrap( $_, $self->api ) } @$res ]; 72 | return wantarray ? @$res : $res; 73 | } 74 | 75 | =head2 find_folders 76 | 77 | Finds folders by name or pattern in this folder. 78 | 79 | @res = $folder->find_folder($pattern); 80 | $res = $folder->find_folder($pattern); 81 | 82 | The optional argument C<$pattern> must be a string or a pattern. If a 83 | string, it performs a case insensitive search on the name of the note. 84 | A pattern can be used for more complex matches. If the pattern is 85 | omitted, all results are returned. 86 | 87 | Returns a (possibly empty) array of Joplin::Folder objects. 88 | 89 | With a second, non-false argument, the search includes subfolders. 90 | 91 | =cut 92 | 93 | sub find_folders { 94 | my ( $self, $pat, $recurse ) = @_; 95 | 96 | unless ( !$pat || ref($pat) eq "Regexp" ) { 97 | $pat = qr/^$pat$/i; # case insens 98 | } 99 | 100 | my @res; 101 | my $list = $self->api->get_folders_recursive( $self->id ); 102 | if ( $recurse ) { 103 | shift(@$list); 104 | } 105 | else { 106 | $list = [ grep { $_->{parent_id} eq $self->id } @$list ]; 107 | } 108 | foreach my $f ( @$list ) { 109 | next if $pat && $f->{title} !~ $pat; 110 | push( @res, Joplin::Folder->_wrap( $f, $self->api ) ); 111 | } 112 | 113 | return wantarray ? @res : \@res; 114 | } 115 | 116 | =name2 create_folder 117 | 118 | Creates a new (sub)folder with the given name and optional properties. 119 | 120 | $new = $folder->create_folder("A SubFolder"); 121 | 122 | Returns a Joplin::Folder object for the new folder. 123 | 124 | =cut 125 | 126 | sub create_folder { 127 | my ( $self, $title, %args ) = @_; 128 | $args{parent_id} //= $self->id; 129 | Joplin::Folder->_wrap( $self->api->create_folder( $title, %args ), 130 | $self->api ); 131 | } 132 | 133 | =name2 create_note 134 | 135 | Creates a new note with the given name and optional properties. 136 | 137 | $new = $folder->create_note("Title", "Content goes *here*"); 138 | 139 | Returns a Joplin::Note object for the new note. 140 | 141 | =cut 142 | 143 | sub create_note { 144 | my ( $self, $title, $content, %args ) = @_; 145 | Joplin::Note->_wrap( $self->api->create_note( $title, $content, $self->id, %args ), 146 | $self->api ); 147 | } 148 | 149 | =name2 delete 150 | 151 | Deletes the current folder. 152 | 153 | $folder->delete; 154 | 155 | Returns true if successful. 156 | 157 | =cut 158 | 159 | sub delete { 160 | my ( $self ) = @_; 161 | $self->api->delete_folder( $self->id ); 162 | } 163 | 164 | =head2 update 165 | 166 | Updates the properties of the folder to the server. 167 | 168 | $res = $folder->update; 169 | 170 | Returns the folder object with updated properties. 171 | 172 | =cut 173 | 174 | sub update { 175 | my ( $self ) = @_; 176 | my $current = $self->api->get_folder( $self->id ); 177 | my $new = { %$self }; 178 | delete $new->{_api}; 179 | my $data = {}; 180 | foreach ( keys(%$current) ) { 181 | $data->{$_} = $new->{$_} 182 | if #exists $self->properties("rw")->{$_} && 183 | defined($new->{$_}) && $new->{$_} ne $current->{$_}; 184 | delete $new->{$_}; 185 | } 186 | croak("Joplin: Unhandled properties in folder update: " . 187 | join(" ", sort keys %$new) ) if %$new; 188 | my $res = $self->api->update_folder( $self->id, %$data ); 189 | @$self{keys(%$res)} = values(%$res); 190 | $self; 191 | } 192 | 193 | =head2 refresh 194 | 195 | Updates the folder to the server properties. 196 | 197 | $res = $folder->refresh 198 | 199 | Returns the folder object with refreshed properties. 200 | 201 | =cut 202 | 203 | sub refresh { 204 | my ( $self ) = @_; 205 | my $new = $self->api->get_folder( $self->id ); 206 | @$self{keys(%$new)} = values(%$new); 207 | $self; 208 | } 209 | 210 | =head2 is_root 211 | 212 | Tests if this folder is the root folder. 213 | 214 | $status = $folder->is_root; 215 | 216 | =cut 217 | 218 | sub is_root { shift->isa('Joplin::Root') } 219 | 220 | ################ Initialisation ################ 221 | 222 | __PACKAGE__->_set_property_handlers; 223 | 224 | 1; 225 | -------------------------------------------------------------------------------- /lib/Joplin/Note.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use Carp; 7 | 8 | package Joplin::Note; 9 | 10 | use parent qw(Joplin::Base); 11 | 12 | use Joplin::Tag; 13 | 14 | =head2 update 15 | 16 | Updates the properties of the note to the server. 17 | 18 | $res = $note->update; 19 | 20 | Returns the note object with updated properties. 21 | 22 | =cut 23 | 24 | sub update { 25 | my ( $self ) = @_; 26 | my $current = $self->api->get_note( $self->id, "full" ); 27 | my $new = { %$self }; 28 | delete $new->{_api}; 29 | my $data = {}; 30 | foreach ( keys(%$current) ) { 31 | warn("update: undef $_") unless defined $current->{$_}; 32 | $data->{$_} = $new->{$_} 33 | if #exists $self->properties("rw")->{$_} && 34 | defined($new->{$_}) && $new->{$_} ne $current->{$_}; 35 | delete $new->{$_}; 36 | } 37 | croak("Joplin: Unhandled properties in note update: " . 38 | join(" ", sort keys %$new) ) if %$new; 39 | my $res = $self->api->update_note( $self->id, %$data ); 40 | @$self{keys(%$res)} = values(%$res); 41 | $self; 42 | } 43 | 44 | =head2 refresh 45 | 46 | Updates the note to the server properties. 47 | 48 | $res = $note->refresh 49 | 50 | Returns the note object with refreshed properties. 51 | 52 | =cut 53 | 54 | sub refresh { 55 | my ( $self ) = @_; 56 | my $new = $self->api->get_note( $self->id ); 57 | @$self{keys(%$new)} = values(%$new); 58 | $self; 59 | } 60 | 61 | =name2 delete 62 | 63 | Deletes the current note. 64 | 65 | $note->delete; 66 | 67 | Returns true if successful. 68 | 69 | =cut 70 | 71 | sub delete { 72 | my ( $self ) = @_; 73 | $self->api->delete_note( $self->id ); 74 | } 75 | 76 | =name2 export 77 | 78 | =cut 79 | 80 | sub export { 81 | my ( $self, $filename ) = @_; 82 | open( my $fd, '>:utf8', $filename ) 83 | or croak("Export: $filename [$!]"); 84 | print $fd $self->{body_html} || $self->{body}; 85 | close($fd); 86 | } 87 | 88 | =name2 add_tag 89 | 90 | Adds a tag to the note. 91 | 92 | $tag = $note->add_tag("my tag"); 93 | 94 | Returns a Joplin::Tag object. 95 | 96 | =cut 97 | 98 | sub add_tag { 99 | my ( $self, $title, %args ) = @_; 100 | 101 | my $tag; 102 | 103 | unless ( ref($title) eq "Joplin::Tag" ) { 104 | $tag = $self->api->find_tags($title)->[0] 105 | // $self->api->create_tag($title, %args); 106 | $tag = Joplin::Tag->_wrap($tag); 107 | } 108 | 109 | $self->api->create_tag_note( $tag->id, $self->id ); 110 | return $tag; 111 | } 112 | 113 | =name2 delete_tag 114 | 115 | Removes a tag from the note. 116 | 117 | $tag = $note->delete_tag("my tag"); 118 | 119 | Returns true upon success. 120 | 121 | =cut 122 | 123 | sub delete_tag { 124 | my ( $self, $tag ) = @_; 125 | 126 | unless ( ref($tag) eq "Joplin::Tag" ) { 127 | $tag = $self->api->find_tags($tag)->[0]; 128 | return 1 unless defined $tag; 129 | $tag = Joplin::Tag->_wrap($tag); 130 | } 131 | 132 | $self->api->delete_tag_note( $tag->id, $self->id ); 133 | } 134 | 135 | =name2 folder 136 | 137 | Finds the parent forder of the note. 138 | 139 | $folder = $note->folder; 140 | 141 | Returns a Joplin::Folder object. 142 | 143 | =cut 144 | 145 | sub folder { 146 | my ( $self ) = @_; 147 | 148 | if ( $self->parent_id eq '' ) { 149 | return Joplin::Folder->_wrap( { id => '' }, $self->api ); 150 | } 151 | Joplin::Folder->_wrap( $self->api->get_folder( $self->parent_id ), 152 | $self->api ); 153 | } 154 | 155 | ################ Initialisation ################ 156 | 157 | __PACKAGE__->_set_property_handlers; 158 | 159 | 1; 160 | -------------------------------------------------------------------------------- /lib/Joplin/Resource.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use Carp; 7 | 8 | package Joplin::Resource; 9 | 10 | use parent qw(Joplin::Base); 11 | 12 | # To attach a resource to a note, first create the resource with POST 13 | # /resources, then get the ID from there and simply add the resource 14 | # to the body of the note with the syntax 15 | # [](:/12345678123456781234567812345678). 16 | 17 | ################ Initialisation ################ 18 | 19 | __PACKAGE__->_set_property_handlers; 20 | 21 | 1; 22 | -------------------------------------------------------------------------------- /lib/Joplin/Root.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | package Joplin::Root; 8 | 9 | use Carp; 10 | 11 | use parent qw(Joplin::Folder); 12 | 13 | use Joplin::Note; 14 | use Joplin::Tag; 15 | 16 | =head1 METHODS 17 | 18 | =head2 new 19 | 20 | =cut 21 | 22 | sub new { 23 | my ( $pkg, %init ) = @_; 24 | my $self = bless { id => '', parent_id => '', 25 | _api => Joplin::API->new(%init) }, $pkg; 26 | return $self; 27 | } 28 | 29 | 30 | =head2 find_notes 31 | 32 | Finds notes by name or pattern. 33 | 34 | @res = $folder->find_notes($pattern); 35 | $res = $folder->find_notes($pattern); 36 | 37 | The optional argument C<$pattern> must be a string or a pattern. If a 38 | string, it performs a case insensitive search on the name of the note. 39 | A pattern can be used for more complex matches. If the pattern is 40 | omitted, all results are returned. 41 | 42 | Returns a (possibly empty) array of Joplin::Note objects. 43 | 44 | With a second, non-false argument, the search includes subfolders. 45 | 46 | =cut 47 | 48 | sub find_notes { 49 | my ( $self, $pat, $recurse ) = @_; 50 | 51 | unless ( !$pat || ref($pat) eq "Regexp" ) { 52 | $pat = qr/^$pat$/i; # case insens 53 | } 54 | 55 | my @res; 56 | foreach ( @{ $self->api->get_notes } ) { 57 | next unless $recurse || $_->{parent_id} eq ''; 58 | next if $pat && $_->{title} !~ $pat; 59 | push( @res, Joplin::Note->_wrap( $_, $self->api ) ); 60 | } 61 | 62 | return wantarray ? @res : \@res; 63 | } 64 | 65 | =head2 find_folders 66 | 67 | Finds folders by name or pattern. 68 | 69 | @res = $folder->find_folder($pattern); 70 | $res = $folder->find_folder($pattern); 71 | 72 | The optional argument C<$pattern> must be a string or a pattern. If a 73 | string, it performs a case insensitive search on the name of the note. 74 | A pattern can be used for more complex matches. If the pattern is 75 | omitted, all results are returned. 76 | 77 | Returns a (possibly empty) array of Joplin::Folder objects. 78 | 79 | With a second, non-false argument, the search includes subfolders. 80 | 81 | =cut 82 | 83 | sub find_folders { 84 | my ( $self, $pat, $recurse ) = @_; 85 | 86 | unless ( !$pat || ref($pat) eq "Regexp" ) { 87 | $pat = qr/^$pat$/i; # case insens 88 | } 89 | 90 | my @res; 91 | my $list = $recurse 92 | ? $self->api->get_folders_recursive 93 | : $self->api->get_folders; 94 | foreach my $f ( @$list ) { 95 | next if $pat && $f->{title} !~ $pat; 96 | push( @res, Joplin::Folder->_wrap( $f, $self->api ) ); 97 | } 98 | 99 | return wantarray ? @res : \@res; 100 | } 101 | 102 | =head2 find_tags 103 | 104 | Finds matching tags. 105 | 106 | @res = $root->find_tags("my tag"); 107 | 108 | Returns a possible empty) array of Joplin::Tag objects. 109 | 110 | =cut 111 | 112 | sub find_tags { 113 | my ( $self, $pat ) = @_; 114 | 115 | my @res = map { Joplin::Tag->_wrap( $_, $self->api ) } 116 | @{ $self->api->find_tags($pat) }; 117 | wantarray ? @res : \@res; 118 | } 119 | 120 | ################ Inhibits ################ 121 | 122 | sub delete { 123 | croak("Joplin: Cannot delete the root folder!"); 124 | } 125 | 126 | sub update { 127 | croak("Joplin: Cannot update the root folder!"); 128 | } 129 | 130 | sub refresh { 131 | croak("Joplin: Cannot refresh the root folder!"); 132 | } 133 | 134 | ################ Initialisation ################ 135 | 136 | __PACKAGE__->_set_property_handlers; 137 | 138 | 1; 139 | -------------------------------------------------------------------------------- /lib/Joplin/Tag.pm: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use Carp; 7 | 8 | package Joplin::Tag; 9 | 10 | use parent qw(Joplin::Base); 11 | 12 | use Joplin::Note; 13 | 14 | =head1 METHODS 15 | 16 | =head2 create 17 | 18 | Creates a new tag. 19 | 20 | $tag = Joplin::Tag->create("my tag"); 21 | 22 | Returns a Joplin::Tag object. 23 | 24 | =cut 25 | 26 | sub create { 27 | my ( $pkg, $title, %args ) = @_; 28 | my $res = do { ... }; 29 | } 30 | 31 | =head2 find_notes 32 | 33 | Finds notes by name or pattern having this tag. 34 | 35 | @res = $tag->find_notes($pattern); 36 | $res = $tag->find_notes($pattern); 37 | 38 | The optional argument C<$pattern> must be a string or a pattern. If a 39 | string, it performs a case insensitive search on the name of the note. 40 | A pattern can be used for more complex matches. If the pattern is 41 | omitted, all results are returned. 42 | 43 | Returns a (possibly empty) array of Joplin::Note objects. 44 | 45 | =cut 46 | 47 | sub find_notes { 48 | my ( $self, $pat ) = @_; 49 | my @res = map { Joplin::Note->_wrap( $_, $self->api ) } 50 | @{ $self->api->find_selected( $pat, 51 | $self->api->get_tag_notes($self->id) 52 | ) }; 53 | wantarray ? @res : \@res; 54 | } 55 | 56 | =name2 delete 57 | 58 | Deletes the current tag. 59 | 60 | $tag->delete 61 | 62 | Returns true if successful. 63 | 64 | =cut 65 | 66 | sub delete { 67 | my ( $self ) = @_; 68 | $self->api->delete_tag( $self->id ); 69 | } 70 | 71 | 72 | ################ Initialisation ################ 73 | 74 | __PACKAGE__->_set_property_handlers; 75 | 76 | 1; 77 | -------------------------------------------------------------------------------- /script/addnote.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # Add notes to Joplin using the Web Clipper API. 4 | # See https://discourse.joplin.cozic.net/t/web-clipper-is-now-available-beta-feature/154/37 5 | # NEW API, See https://joplin.cozic.net/api/ 6 | 7 | # Author : Johan Vromans 8 | # Created On : Wed Sep 5 13:44:45 2018 9 | # Last Modified By: Johan Vromans 10 | # Last Modified On: Fri Feb 1 10:31:06 2019 11 | # Update Count : 124 12 | # Status : Unknown, Use with caution! 13 | 14 | ################ Common stuff ################ 15 | 16 | use strict; 17 | use warnings; 18 | use Encode; 19 | use Joplin::API; 20 | 21 | # Package name. 22 | my $my_package = 'JoplinTools'; 23 | # Program name and version. 24 | my ($my_name, $my_version) = qw( addnote 0.04 ); 25 | 26 | ################ Command line parameters ################ 27 | 28 | use Getopt::Long 2.13; 29 | 30 | # Command line options. 31 | my $title; 32 | my $parent = "Imported Notes"; 33 | my $server = "http://127.0.0.1:41184"; 34 | my $verbose = 1; # verbose processing 35 | 36 | # Run Joplin and copy the token from the Web Clipper options page. 37 | my $token = "YOUR TOKEN GOES HERE"; 38 | 39 | # Development options (not shown with -help). 40 | my $debug = 0; # debugging 41 | my $trace = 0; # trace (show process) 42 | my $test = 0; # test mode. 43 | 44 | # Process command line options. 45 | app_options(); 46 | 47 | # Post-processing. 48 | $trace |= ($debug || $test); 49 | 50 | ################ Presets ################ 51 | 52 | use LWP::UserAgent; 53 | use JSON; 54 | use MIME::Base64; 55 | 56 | my $pp = JSON->new->utf8; 57 | my $ua = LWP::UserAgent->new; 58 | 59 | ################ The Process ################ 60 | 61 | my $author = (getpwuid($<))[6]; 62 | 63 | my $api = Joplin::API->new( server => $server, token => $token ); 64 | my $pid = $api->find_folders( qr/^$parent$/ ); 65 | die("No parent folder \"$parent\" found\n") unless $pid; 66 | $pid = $pid->[0]->{id}; 67 | 68 | my $data; 69 | my $file = $ARGV[0]; 70 | 71 | if ( defined($file) && !$title ) { 72 | ( $title = $file ) =~ s;^.*/;; 73 | } 74 | 75 | my $content = { parent_id => $pid, 76 | title => $title, 77 | defined($file) ? ( source_url => $file ) : (), 78 | author => $author, 79 | }; 80 | 81 | if ( $file =~ /\.(jpe?g|gif|png)$/ ) { # image 82 | my $mime = $1; 83 | $mime = "jpeg" if $mime eq "jpg"; 84 | $mime = "image/jpeg"; 85 | open( my $fd, '<:raw', $file ) 86 | or die("$file: $!\n"); 87 | my $data = encode_base64( do { local $/; <$fd> } ); 88 | close($fd); 89 | 90 | $content->{body} = $file; 91 | $content->{image_data_url} = "data:$mime;base64,$data"; 92 | } 93 | elsif ( $file =~ /^https?:/ ) { 94 | require LWP::UserAgent; 95 | my $ua = LWP::UserAgent->new; 96 | my $res = $ua->get($file); 97 | unless ( $res->is_success ) { 98 | die("$file: ", $res->status_line, "\n"); 99 | } 100 | my $data = $res->decoded_content; 101 | if ( $data =~ /^<(!doctype html|html)/i ) { 102 | $content->{body_html} = $data; 103 | } 104 | else { 105 | $content->{body} = $data; 106 | } 107 | $content->{base_url} = $file; 108 | } 109 | else { 110 | my $data = do { local $/; <> }; 111 | if ( $data =~ /^{body_html} = $data; 113 | } 114 | else { 115 | $content->{body} = $data; 116 | } 117 | } 118 | 119 | my $res = $api->create_note( $content->{title}, 120 | $content->{body}, 121 | $content->{parent_id}, 122 | %$content ); 123 | 124 | use DDumper; DDumper $res; 125 | 126 | ################ Subroutines ################ 127 | 128 | sub app_options { 129 | my $help = 0; # handled locally 130 | my $ident = 0; # handled locally 131 | my $man = 0; # handled locally 132 | 133 | my $pod2usage = sub { 134 | # Load Pod::Usage only if needed. 135 | require Pod::Usage; 136 | Pod::Usage->import; 137 | &pod2usage; 138 | }; 139 | 140 | # Process options. 141 | if ( @ARGV > 0 ) { 142 | GetOptions('parent=s' => sub { $parent = decode_utf8($_[1]) }, 143 | 'title=s' => sub { $title = decode_utf8($_[1]) }, 144 | 'server=s' => sub { $server = decode_utf8($_[1]) }, 145 | 'token=s' => \$token, 146 | 'ident' => \$ident, 147 | 'verbose+' => \$verbose, 148 | 'quiet' => sub { $verbose = 0 }, 149 | 'trace' => \$trace, 150 | 'help|?' => \$help, 151 | 'man' => \$man, 152 | 'debug' => \$debug) 153 | or $pod2usage->(2); 154 | } 155 | if ( $ident or $help or $man ) { 156 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 157 | } 158 | if ( $man or $help ) { 159 | $pod2usage->(1) if $help; 160 | $pod2usage->(VERBOSE => 2) if $man; 161 | } 162 | } 163 | 164 | __END__ 165 | 166 | ################ Documentation ################ 167 | 168 | =head1 NAME 169 | 170 | addnote - add a note to Joplin using the web API 171 | 172 | =head1 SYNOPSIS 173 | 174 | makenote [options] [file ...] 175 | 176 | Options: 177 | --parent=XXX note parent (defaults to "Imported Notes") 178 | --title=XXX title (optional) 179 | --server=XXX the host running the Joplin server 180 | --token=XXX Joplin server access token 181 | --ident shows identification 182 | --quiet runs quietly 183 | --help shows a brief help message and exits 184 | --man shows full documentation and exits 185 | --verbose provides more verbose information 186 | 187 | =head1 OPTIONS 188 | 189 | =over 8 190 | 191 | =item B<--parent=>I 192 | 193 | Specifies the parent for the note or folder. 194 | 195 | The argument is used for case insensitive substring search 196 | on folder titles. If it starts with a C, it is interpreted as a 197 | regular expression pattern to be matched against the folder titles. 198 | 199 | =item B<--host=>I 200 | 201 | The host where the Joplin server is running. 202 | Default is C. 203 | 204 | =item B<--token=>I 205 | 206 | Access token for Joplin. You can find it on the Web Clipper options page. 207 | 208 | =item B<--title=>I 209 | 210 | Specifies the title for the note or folder. 211 | 212 | =item B<--help> 213 | 214 | Prints a brief help message and exits. 215 | 216 | =item B<--man> 217 | 218 | Prints the manual page and exits. 219 | 220 | =item B<--ident> 221 | 222 | Prints program identification. 223 | 224 | =item B<--verbose> 225 | 226 | Provides more verbose information. 227 | 228 | =item B<--quiet> 229 | 230 | Runs quietly. 231 | 232 | =item I 233 | 234 | The input file to process, if any. For images, the file name must 235 | appear on the command line. 236 | 237 | =back 238 | 239 | =head1 DESCRIPTION 240 | 241 | B will add a note to a Joplin server running the web API. 242 | 243 | =head1 AUTHOR 244 | 245 | Johan Vromans C<< >> 246 | 247 | =head1 SUPPORT 248 | 249 | Joplin-Tools development is hosted on GitHub, repository 250 | L. 251 | 252 | Please report any bugs or feature requests to the GitHub issue tracker, 253 | L. 254 | 255 | =head1 LICENSE 256 | 257 | Copyright (C) 2018 Johan Vromans, 258 | 259 | This program is free software. You can redistribute it and/or 260 | modify it under the terms of the Artistic License 2.0. 261 | 262 | This program is distributed in the hope that it will be useful, 263 | but without any warranty; without even the implied warranty of 264 | merchantability or fitness for a particular purpose. 265 | 266 | =cut 267 | 268 | 1; 269 | -------------------------------------------------------------------------------- /script/joplinfs.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | # FUSE access to Joplin API. 4 | 5 | # Author : Johan Vromans 6 | # Created On : Tue Apr 14 21:17:43 2020 7 | # Last Modified By: Johan Vromans 8 | # Last Modified On: Thu Jun 25 14:41:20 2020 9 | # Update Count : 362 10 | # Status : Unknown, Use with caution! 11 | 12 | ################ Common stuff ################ 13 | 14 | use 5.010001; 15 | use strict; 16 | use warnings; 17 | use utf8; 18 | use Carp (); 19 | use FindBin; 20 | use lib "$FindBin::Bin/../lib"; 21 | 22 | # Package name. 23 | my $my_package = 'Sciurix'; 24 | # Program name and version. 25 | my ($my_name, $my_version) = qw( joplinfs 0.02 ); 26 | 27 | # Establish some system capabilities. 28 | system_capabilities(); 29 | 30 | ################ Command line parameters ################ 31 | 32 | use Getopt::Long 2.13; 33 | 34 | # Command line options. 35 | my $server = "http://localhost:27583"; 36 | my $apikey = "f21437609de3e09ad78dc90dd126d51eefc03bbc58a462f1f194a767a46f222c4b0c7cbfad7b7ea580e20c2f7d35bec94b639e974b1d660467840c1f46d77a9a"; 37 | my $prefix = "/joplin"; 38 | my $use_real_statfs; 39 | my $pidfile; 40 | my $logfile; 41 | my $daemon = 1; 42 | my $verbose = 1; # verbose processing 43 | 44 | # Development options (not shown with -help). 45 | my $debug = 0; # debugging 46 | my $trace = 0; # trace (show process) 47 | my $test = 0; # test mode. 48 | 49 | # Extra options for Fuse. 50 | my %extraopts = ( 'threaded' => 0, 'debug' => 0 ); 51 | 52 | # Process command line options. 53 | app_options(); 54 | 55 | # Post-processing. 56 | $prefix =~ s;/+$;;; 57 | $prefix =~ s;^/*$;/;; 58 | $trace |= ($debug || $test); 59 | 60 | ################ Presets ################ 61 | 62 | $SIG{'__WARN__'} = \&Carp::cluck if $debug; 63 | binmode( STDOUT, ':utf8' ); 64 | binmode( STDERR, ':utf8' ); 65 | 66 | ################ The Process ################ 67 | 68 | use Joplin; 69 | use Fuse; 70 | use POSIX qw( ENOTDIR ENOENT ENOSYS EEXIST EPERM EINVAL 71 | O_RDONLY O_RDWR O_WRONLY O_APPEND O_CREAT setsid ); 72 | 73 | # We need a mount point that we can read/write to. 74 | my ( $mountpoint) = @ARGV; 75 | if ( ! -d -r -w -x $mountpoint ) { 76 | # Note that there may be a joplinfs already active... 77 | die( "$mountpoint: non-existent/accessible directory\n" ); 78 | } 79 | 80 | # Connect to server. 81 | my $root = Joplin->connect( server => $server, 82 | apikey => $apikey, 83 | ); 84 | unless ( defined $root ) { 85 | die( "Cannot connect to $server\n", 86 | "Is the Joplin server running?\n" ); 87 | } 88 | $root->api->set_debug($debug); 89 | 90 | # Put ourselves in the background. 91 | daemonize() if $daemon; 92 | 93 | # Activate FUSE. 94 | Fuse::main( mountpoint => $mountpoint, 95 | callbacks(), 96 | %extraopts ); 97 | 98 | ################ Callbacks ################ 99 | 100 | # System capabilities, determined by system_capabilities(). 101 | my $has_threads = 0; 102 | my $has_Filesys__Statvfs = 0; 103 | my $use_lchown = 0; 104 | my $has_mknod = 0; 105 | 106 | use Encode; 107 | 108 | my %fs; 109 | 110 | # Map filenames to base. 111 | # Filenames start with / relative to the mount point. 112 | sub fixup { 113 | my ( $file ) = @_; 114 | $file = Encode::decode_utf8($file); 115 | Carp::cluck("Alien filename: $file") 116 | unless $file =~ s;^\Q$prefix\E(/|$);;; 117 | $file =~ s/\.md$//; 118 | return $file; 119 | } 120 | 121 | #### General callbacks. 122 | 123 | sub jfs_getattr { 124 | my ( $file ) = @_; 125 | # warn("getattr: \"$file\""); 126 | 127 | # TODO This is to avoid the fuse fs to be overrun by gvfs monitors. 128 | return -ENOSYS if $file !~ m;^\Q$prefix\E(?:/|$);; 129 | 130 | $file = fixup($file); 131 | #return -ENOENT unless $file; 132 | warn("getattr: \"$file\""); 133 | my @list = ( 0, 0, 0040755, 1, 0+$<, 0+$(, 0, 0, 134 | time, time-1000, time-2000, 512, 0 ); 135 | 136 | my $res = find($file); 137 | return $res if $res < 0; 138 | if ( $res && @$res ) { 139 | if ( $res->[0]->isa('Joplin::Note') ) { 140 | $list[ 2] = 0100644; 141 | $list[ 9] = int( $res->[0]->updated_time / 1000 ); 142 | $list[10] = int( $res->[0]->created_time 143 | // $res->[0]->user_created_time / 1000 ); 144 | if ( $file =~ /Sum$/ ) { 145 | $list[2] |= 020000; 146 | } 147 | } 148 | elsif ( $res->[0]->isa('Joplin::Folder') ) { 149 | $list[ 2] = 0040755; 150 | $list[ 7] = $res->[0]->{note_count}; 151 | } 152 | } 153 | else { 154 | return -ENOENT; 155 | } 156 | return @list; 157 | } 158 | 159 | sub jfs_getdir { 160 | my ( $dirname ) = @_; 161 | 162 | # TODO This is to avoid the fuse fs to be overrun by gvfs monitors. 163 | return -ENOENT if $dirname !~ m;^\Q$prefix\E(?:/|$);; 164 | 165 | $dirname = fixup($dirname); 166 | # TODO This is to avoid the fuse fs to be overrun by gvfs monitors. 167 | #return -ENOENT unless $dirname; 168 | warn("getdir: \"$dirname\""); 169 | 170 | my $res = find($dirname); 171 | return -ENOENT unless @$res && $res->[0]->isa('Joplin::Folder'); 172 | my $f = $res->[0]; 173 | 174 | my $r1 = $f->find_folders; 175 | my $r2 = $f->find_notes; 176 | 177 | my @files = map { $_->{title} } @$r1; 178 | push( @files, map { $_->{title} . ".md" } @$r2 ); 179 | return ( @files, 0 ); 180 | } 181 | 182 | #### Open/close callbacks. 183 | 184 | sub jfs_open { 185 | my ( $file, $mode, $fi ) = @_; 186 | $file = fixup($file); 187 | warn sprintf("open \"$file\" mode=0%o", $mode); 188 | my $res = find($file); 189 | return $res if $res < 0; 190 | return -ENOENT unless $res; 191 | 192 | my $fd = IO::Handle->new; 193 | $fs{$fd} = { id => $res->[0]->id, buf => undef, dirty => 0, 194 | mode => $mode }; 195 | 196 | # Need this to prevent reads to use the disk file size. 197 | $fi->{direct_io} = 1; 198 | 199 | return ( 0, $fd ); 200 | } 201 | 202 | sub jfs_create { 203 | my ( $file, $modes, $flags ) = @_; 204 | $file = fixup($file); 205 | warn sprintf("create \"$file\" mode=0%o", $flags); 206 | 207 | my $fd = IO::Handle->new; 208 | my @dirs = split( "/", $file ); 209 | my $leaf = pop(@dirs); 210 | my $res = find( join( "/", @dirs ) ); 211 | return $res if $res < 0; 212 | return -ENOSYS unless @$res; 213 | my $f = $res->[0]; 214 | my $n = $f->create_note( $leaf, "" ); 215 | $fs{$fd} = { id => $n->id, parent => $f, mode => $flags, 216 | title => $leaf, buf => undef, dirty => 0 }; 217 | 218 | return ( 0, $fd ); 219 | } 220 | 221 | sub jfs_flush { 222 | my ( $file, $handle ) = @_; 223 | $file = fixup($file); 224 | unless ( defined($handle) ) { 225 | warn("flush: \"$file\" not open"); 226 | return -ENOSYS; 227 | } 228 | if ( $fs{$handle}->{dirty} ) { 229 | if ( defined $fs{$handle}->{id} ) { 230 | my $n = find($file)->[0]; 231 | $n->{body} = ${$fs{$handle}->{buf}}; 232 | $n->update; 233 | } 234 | else { 235 | $fs{$handle}->{parent}->create_note( $fs{$handle}->{title}, 236 | ${$fs{$handle}->{buf}} ) 237 | } 238 | } 239 | return 0; 240 | } 241 | 242 | sub jfs_release { 243 | my ( $file, undef, $handle ) = @_; 244 | unless ( defined($handle) ) { 245 | warn("release: \"$file\" not open"); 246 | return -ENOSYS; 247 | } 248 | undef $fs{$handle}; 249 | undef( $_[2] ); 250 | return 0; 251 | } 252 | 253 | #### Read/write callbacks. 254 | 255 | sub jfs_read { 256 | my ( $file, $bufsize, $off, $handle ) = @_; 257 | return -ENOSYS unless $handle; 258 | $file = fixup($file); 259 | printf STDERR ("read: %d at %d from \"%s\"\n", 260 | $bufsize, $off, $file ); 261 | 262 | unless ( $fs{$handle}->{buf} ) { 263 | my $res = $root->api->query("get", "/notes/".$fs{$handle}->{id}."?fields=body"); 264 | ${$fs{$handle}->{buf}} = $res->{body}; 265 | } 266 | 267 | if ( $off >= 0 && $off <= length(${$fs{$handle}->{buf}}) ) { 268 | my $t = substr( ${$fs{$handle}->{buf}}, $off, $bufsize ); 269 | warn sprintf("read: return %d bytes for \"$file\"", length($t)); 270 | return $t; 271 | } 272 | return -ENOSYS; 273 | } 274 | 275 | sub jfs_write { 276 | my ( $file, $buf, $off, $handle ) = @_; 277 | unless ( defined $handle ) { 278 | warn("write: Opening $file"); 279 | return -ENOSYS; 280 | } 281 | printf STDERR ("%s: %d at %d from \"%s\"\n", 282 | $fs{$handle}->{mode} & O_APPEND ? "append" : "write", 283 | length($buf), $off, $file ); 284 | 285 | if ( $fs{$handle}->{mode} & O_APPEND && !$fs{$handle}->{buf} ) { 286 | my $res = $root->api->query("get", "/notes/".$fs{$handle}->{id}."?fields=body"); 287 | ${$fs{$handle}->{buf}} = $res->{body}; 288 | } 289 | 290 | if ( ! $fs{$handle}->{buf} ) { 291 | my $b = ""; 292 | if ( $off == 0 ) { 293 | } 294 | else { 295 | $b .= " " x $off; 296 | } 297 | $b .= $buf; 298 | $fs{$handle}->{buf} = \$b; 299 | } 300 | elsif ( $off == length(${$fs{$handle}->{buf}}) 301 | || $fs{$handle}->{mode} & O_APPEND ) { 302 | ${$fs{$handle}->{buf}} .= $buf; 303 | } 304 | else { 305 | ${$fs{$handle}->{buf}} .= " " x ( $off - length(${$fs{$handle}->{buf}})); 306 | substr( ${$fs{$handle}->{buf}}, $off, length($buf), $buf ); 307 | } 308 | $fs{$handle}->{dirty} = 1; 309 | return length($buf); 310 | } 311 | 312 | sub jfs_truncate { 313 | my ( $file, $off ) = @_; 314 | $file = fixup($file); 315 | } 316 | 317 | #### Unlink/rename. 318 | 319 | sub jfs_unlink { 320 | my ( $file ) = @_; 321 | $file = fixup($file); 322 | my $note = find($file); 323 | return $note if $note < 0; 324 | $note->[0]->delete; 325 | return 0; 326 | } 327 | 328 | sub jfs_rename { 329 | my ( $old, $new ) = @_; 330 | $old = fixup($old); 331 | $new = fixup($new); 332 | 333 | my ( $target, $newname ) = ( $1, $2 ) if $new =~ m;^(.*)/([^/]+)$;; 334 | my ( $source, $oldname ) = ( $1, $2 ) if $old =~ m;^(.*)/([^/]+)$;; 335 | 336 | if ( $target eq $source ) { # rename 337 | my $note = find($old); 338 | return $note if $note < 0; 339 | $note = $note->[0]; 340 | $note->title($newname); 341 | $note->update; 342 | return 0; 343 | } 344 | else { # move 345 | my $note = find($old); 346 | return $note if $note < 0; 347 | my $dest = find($new); 348 | return -EINVAL if UNIVERSAL::isa($note, 'Joplin::Base'); 349 | $target = $target ne '' ? find($target) : $root; 350 | return $target if $target < 0; 351 | $note = $note->[0]; 352 | $note->parent_id( $target->[0]->id ); 353 | $note->update; 354 | return 0; 355 | } 356 | } 357 | 358 | #### Create/remove folders. 359 | 360 | sub jfs_mkdir { 361 | my ( $name, $perm ) = @_; 362 | $name = fixup($name); 363 | my ( $target, $newname ) = ( $root, $name ); 364 | ( $target, $newname ) = ( $1, $2 ) if $name =~ m;^(.*)/([^/]+)$;; 365 | 366 | my $note = find($target); 367 | return $note if $note < 0; 368 | $note->[0]->create_folder($newname); 369 | return 0; 370 | } 371 | 372 | sub jfs_rmdir { 373 | my ( $name ) = @_; 374 | $name = fixup($name); 375 | my $folder = find($name); 376 | return $folder if $folder < 0; 377 | $folder->[0]->delete; 378 | return 0; 379 | } 380 | 381 | #### Tags. 382 | 383 | sub jfs_readlink { 384 | my ( $name ) = @_; 385 | warn("readlink($name)"); 386 | $name = fixup($name); 387 | my $note = find($name); 388 | return -ENOSYS; 389 | } 390 | 391 | #### Miscellaneous. 392 | 393 | sub jfs_utime { 394 | my ( $file, $atime, $mtime ) = @_; 395 | $file = fixup($file); 396 | return utime( $atime, $mtime, $file ) ? 0 : -$!; 397 | } 398 | 399 | sub callbacks { 400 | my @callbacks = qw( getattr readlink getdir create 401 | mknod mkdir unlink rmdir symlink 402 | rename link chmod chown truncate 403 | utime open release flush read write statfs ); 404 | my %callbacks; 405 | foreach ( @callbacks ) { 406 | if ( my $op = main::->can("jfs_$_") ) { 407 | $callbacks{$_} = $op; 408 | } 409 | else { 410 | my $op = $_; 411 | $callbacks{$_} = sub { 412 | warn("NYI: $op"); 413 | return -ENOSYS; 414 | }; 415 | } 416 | } 417 | 418 | %callbacks; 419 | } 420 | 421 | ################ Subroutines ################ 422 | 423 | # http://perldoc.perl.org/perlipc.html#Complete-Dissociation-of-Child-from-Parent 424 | sub daemonize { 425 | chdir("/") || die( "can't chdir to /: $!" ); 426 | open( STDIN, '<', '/dev/null' ) || die( "can't read /dev/null: $!" ); 427 | if ( $logfile ) { 428 | open( STDOUT, '>', $logfile ) || die( "can't open logfile: $!" ); 429 | } 430 | else { 431 | open( STDOUT, '>', '/dev/null') || die( "can't write to /dev/null: $!" ); 432 | } 433 | defined( my $pid = fork() ) || die( "can't fork: $!" ); 434 | exit if $pid; # non-zero now means I am the parent 435 | 436 | (setsid() != -1) || die( "Can't start a new session: $!" ); 437 | open( STDERR, '>&', \*STDOUT ) || die( "can't dup stdout: $!" ); 438 | if ( $pidfile ) { 439 | open( my $fd, '>', $pidfile ); 440 | print $fd ( $$, "\n" ); 441 | close($fd); 442 | } 443 | } 444 | 445 | sub system_capabilities { 446 | 447 | eval { 448 | require threads; 449 | require threads::shared; 450 | $has_threads = 1; 451 | }; 452 | if ( $has_threads ) { 453 | threads->import(); 454 | threads::shared->import(); 455 | } 456 | 457 | eval { 458 | require Filesys::Statvfs; 459 | $has_Filesys__Statvfs = 1; 460 | }; 461 | if ( $has_Filesys__Statvfs ) { 462 | Filesys::Statvfs->import(); 463 | } 464 | 465 | eval { 466 | require Lchown; 467 | $use_lchown = 1; 468 | }; 469 | if ( $use_lchown ) { 470 | Lchown->import(); 471 | } 472 | 473 | eval { 474 | require Unix::Mknod; 475 | $has_mknod = 1; 476 | }; 477 | if ( $has_mknod ) { 478 | Unix::Mknod->import(); 479 | } 480 | } 481 | 482 | ################ Subroutines ################ 483 | 484 | sub find { 485 | my ( $path ) = @_; 486 | 487 | return [ $root ] if $path eq ''; 488 | 489 | my @dirs = split( "/", $path ); 490 | my $leaf = pop(@dirs); 491 | 492 | my $f = $root; 493 | $path = ""; 494 | foreach my $d ( @dirs ) { 495 | my $res = $f->find_folders( qr/^\Q$d\E$/ ); 496 | return -ENOENT unless @$res; 497 | warn("Multiple results for dir \"$path$d\"") if @$res > 1; 498 | $f = $res->[0]; 499 | $path .= $d . "/"; 500 | } 501 | 502 | # Leaf can be a folder or a note. 503 | 504 | my $res = $f->find_notes (qr/^\Q$leaf\E$/ ); 505 | $res = $f->find_folders( qr/^\Q$leaf\E$/ ) unless @$res; 506 | warn("Multiple results for \"$path" . ($leaf//'') . "\"") if @$res > 1; 507 | 508 | return $res; 509 | } 510 | 511 | ################ Subroutines ################ 512 | 513 | sub app_options { 514 | my $help = 0; # handled locally 515 | my $ident = 0; # handled locally 516 | my $man = 0; # handled locally 517 | my $use_threads = 0; # handled locally 518 | 519 | my $pod2usage = sub { 520 | # Load Pod::Usage only if needed. 521 | require Pod::Usage; 522 | Pod::Usage->import; 523 | &pod2usage; 524 | }; 525 | 526 | # Process options. 527 | GetOptions( 'ident' => \$ident, 528 | 'server=s' => \$server, 529 | 'apikey=s' => \$apikey, 530 | 'prefix=s' => \$prefix, 531 | 'use-threads' => \$use_threads, 532 | 'use-real-statfs' => \$use_real_statfs, 533 | 'pidfile=s' => \$pidfile, 534 | 'logfile=s' => \$logfile, 535 | 'daemon!' => \$daemon, 536 | 'verbose+' => \$verbose, 537 | 'quiet' => sub { $verbose = 0 }, 538 | 'trace' => \$trace, 539 | 'help|?' => \$help, 540 | 'man' => \$man, 541 | 'debug' => \$debug) 542 | or $pod2usage->(2); 543 | 544 | if ( $ident or $help or $man ) { 545 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 546 | } 547 | if ( $man or $help ) { 548 | $pod2usage->(1) if $help; 549 | $pod2usage->(VERBOSE => 2) if $man; 550 | } 551 | $extraopts{threaded} = $use_threads && $has_threads; 552 | $extraopts{debug} = $debug; 553 | $pod2usage->(2) unless @ARGV == 1; 554 | } 555 | 556 | __END__ 557 | 558 | ################ Documentation ################ 559 | 560 | =head1 NAME 561 | 562 | loopback - FUSE loopback file system 563 | 564 | =head1 SYNOPSIS 565 | 566 | loopback [options] mountpoint 567 | 568 | Options: 569 | --server=XXX Jopin server 570 | --apikey=XXX Joplin server API key 571 | --prefix=XXX file system prefix (default: /joplin ) 572 | --use-threads uses threads 573 | --use-real-statfs use real stat command if possible 574 | --pidfile=XXX creates a file containing PID 575 | --logfile=XXX directs stdout/stderr to file (default /dev/null) 576 | --ident shows identification 577 | --help shows a brief help message and exits 578 | --man shows full documentation and exits 579 | --verbose provides more verbose information 580 | --quiet runs as silently as possible 581 | 582 | =head1 OPTIONS 583 | 584 | =over 8 585 | 586 | =item B<--use-threads> 587 | 588 | Uses perl threads, if available. 589 | 590 | =item B<--use-real-statfs> 591 | 592 | Uses real stat() command if possible. 593 | 594 | If not, fake values are delivered. 595 | 596 | =item B<--pidfile=>I 597 | 598 | Creates a file containing the PID of the fuse process. 599 | 600 | =item B<--logfile=>I 601 | 602 | Directs stdout/stderr to the file. 603 | 604 | By default all output is discarded. 605 | 606 | =item B<--help> 607 | 608 | Prints a brief help message and exits. 609 | 610 | =item B<--man> 611 | 612 | Prints the manual page and exits. 613 | 614 | =item B<--ident> 615 | 616 | Prints program identification. 617 | 618 | =item B<--verbose> 619 | 620 | Provides more verbose information. 621 | This option may be repeated to increase verbosity. 622 | 623 | =item B<--quiet> 624 | 625 | Suppresses all non-essential information. 626 | 627 | =item I 628 | 629 | The base directory of the real file tree. 630 | 631 | =item I 632 | 633 | FUSE mountpoint for the real file tree. 634 | 635 | =back 636 | 637 | =head1 DESCRIPTION 638 | 639 | B will perform a FUSE mount of the base directory on the 640 | given mount point. From then on, the file tree under the mount point 641 | will behave just like the original file tree. 642 | 643 | B Run B I to 644 | unmount the file system. Do not kill the fuse process. 645 | 646 | =cut 647 | 648 | -------------------------------------------------------------------------------- /script/listnotes.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # List notes, hierarchically. 4 | 5 | # Author : Johan Vromans 6 | # Created On : Fri Mar 8 09:39:46 2019 7 | # Last Modified By: Johan Vromans 8 | # Last Modified On: Sun Mar 1 15:08:12 2020 9 | # Update Count : 61 10 | # Status : Unknown, Use with caution! 11 | 12 | ################ Common stuff ################ 13 | 14 | use strict; 15 | use warnings; 16 | use utf8; 17 | use Encode; 18 | use FindBin; 19 | use lib "$FindBin::Bin/../lib"; 20 | use Joplin; 21 | 22 | # Package name. 23 | my $my_package = 'JoplinTools'; 24 | # Program name and version. 25 | my ($my_name, $my_version) = qw( listnotes 0.04 ); 26 | 27 | ################ Command line parameters ################ 28 | 29 | use Getopt::Long 2.13; 30 | 31 | # Command line options. 32 | my $title; 33 | my $server = "http://127.0.0.1:41184"; 34 | my $verbose = 1; # verbose processing 35 | my $resources = 0; # unclude resources 36 | my $unused = 0; # only unused resources 37 | my $weed = 0; # weed out 38 | 39 | # Run Joplin and copy the token from the Web Clipper options page. 40 | my $token = $ENV{JOPLIN_APIKEY} // "YOUR TOKEN GOES HERE"; 41 | 42 | # Development options (not shown with -help). 43 | my $debug = 0; # debugging 44 | my $trace = 0; # trace (show process) 45 | my $test = 0; # test mode. 46 | 47 | # Process command line options. 48 | app_options(); 49 | 50 | # Post-processing. 51 | $trace |= ($debug || $test); 52 | 53 | ################ Presets ################ 54 | 55 | binmode( STDOUT, ":utf8" ); 56 | 57 | ################ The Process ################ 58 | 59 | my $root = Joplin->connect( server => $server, apikey => $token ); 60 | 61 | my $top = [ $root ]; 62 | 63 | if ( $title ) { 64 | if ( $title =~ m;^/(.?)/?$; ) { 65 | $title = qr/$1/i; 66 | } 67 | else { 68 | $title = qr/^$title$/; 69 | } 70 | $top = $root->find_folders($title, "recursive"); 71 | die("No folders found\n") unless @$top; 72 | } 73 | 74 | getresources($root) if $resources || $weed || $unused; 75 | listnotes($_) for @$top; 76 | listunusedresources($root) if $resources || $weed || $unused; 77 | 78 | ################ Subroutines ################ 79 | 80 | my %rsc; 81 | 82 | sub getresources { 83 | my ( $top ) = @_; 84 | my $res = $top->api->get_resources; 85 | foreach ( @$res ) { 86 | $rsc{ $_->{id} } = [ 0, $_->{title}, $top->iso8601date($_->{updated_time}), $_->{size} ]; 87 | } 88 | } 89 | 90 | sub listnotes { 91 | my ( $top, $indent ) = @_; 92 | die unless $top->isa('Joplin::Folder'); 93 | $indent //= ""; 94 | 95 | my ( @all ) = 96 | sort 97 | { $a->title cmp $b->title } 98 | ( @{$top->find_folders}, 99 | @{$top->find_notes} ); 100 | 101 | foreach my $item ( @all ) { 102 | my $t = ""; 103 | $t = $item->id . " " if $verbose > 1; 104 | if ( ref($item) eq 'Joplin::Folder' ) { 105 | print( $indent, $t, trunc($item), "\n" ) unless $unused; 106 | listnotes( $item, $indent . " " ); 107 | } 108 | elsif ( ref($item) eq 'Joplin::Note' ) { 109 | print( $indent, $t, trunc($item), 110 | # " (" . length($item->body) . ")", 111 | # " (" . length($item->body_html//"") . ")", 112 | " (" . $item->iso8601date($item->updated_time) . ")\n" ) 113 | unless $unused; 114 | next unless $resources || $weed || $unused; 115 | while ( $item->body =~ m/\(\:\/([0-9a-f]{32})\)/g ) { 116 | $rsc{$1}[0]++; 117 | next unless $resources; 118 | $t = $1 . " " if $verbose > 1; 119 | print( $indent, " + ", $t, trunc($rsc{$1}[1]), 120 | " (" . $rsc{$1}[3] . ")", 121 | " (" . $rsc{$1}[2] . ")\n" ) 122 | if $resources; 123 | } 124 | } 125 | else { 126 | print("??? $item\n"); 127 | use DDumper; DDumper($item); exit; 128 | } 129 | } 130 | } 131 | 132 | sub listunusedresources { 133 | my ( $top ) = ( @_ ); 134 | my $did = 0; 135 | my $t = ""; 136 | foreach ( sort keys %rsc ) { 137 | next if $rsc{$_}[0] > 0; 138 | print( $weed ? "Removing" : "Unused", " resources\n") unless $did++; 139 | $t = $_ . " " if $verbose > 1; 140 | print( " $t", $rsc{$_}[1], "\n" ); 141 | $top->api->delete_resource($_) if $weed; 142 | } 143 | } 144 | 145 | sub trunc { 146 | my ( $t ) = @_; 147 | if ( length($t) > 42 ) { 148 | return substr( $t, 0, 41 ) . "…"; 149 | } 150 | $t; 151 | } 152 | 153 | ################ Subroutines ################ 154 | 155 | sub app_options { 156 | my $help = 0; # handled locally 157 | my $ident = 0; # handled locally 158 | my $man = 0; # handled locally 159 | 160 | my $pod2usage = sub { 161 | # Load Pod::Usage only if needed. 162 | require Pod::Usage; 163 | Pod::Usage->import; 164 | &pod2usage; 165 | }; 166 | 167 | # Process options. 168 | if ( @ARGV > 0 ) { 169 | GetOptions('title=s' => sub { $title = decode_utf8($_[1]) }, 170 | 'server=s' => sub { $server = decode_utf8($_[1]) }, 171 | 'token=s' => \$token, 172 | 'resources!' => \$resources, 173 | 'weed' => \$weed, 174 | 'unused' => \$unused, 175 | 'ident' => \$ident, 176 | 'verbose+' => \$verbose, 177 | 'quiet' => sub { $verbose = 0 }, 178 | 'trace' => \$trace, 179 | 'help|?' => \$help, 180 | 'man' => \$man, 181 | 'debug' => \$debug) 182 | or $pod2usage->(2); 183 | } 184 | if ( $ident or $help or $man ) { 185 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 186 | } 187 | if ( $man or $help ) { 188 | $pod2usage->(1) if $help; 189 | $pod2usage->(VERBOSE => 2) if $man; 190 | } 191 | } 192 | 193 | __END__ 194 | 195 | ################ Documentation ################ 196 | 197 | =head1 NAME 198 | 199 | listnotes - list note titles and resources hierarchically 200 | 201 | =head1 SYNOPSIS 202 | 203 | listnotes [options] 204 | 205 | Options: 206 | --title=XXX select starting folder(s) by title 207 | --server=XXX the host running the Joplin server 208 | --token=XXX Joplin server access token 209 | --resources includes resources 210 | --unused shows unused resources only 211 | --weed removes unused resources 212 | --ident shows identification 213 | --quiet runs quietly 214 | --help shows a brief help message and exits 215 | --man shows full documentation and exits 216 | --verbose provides more verbose information 217 | 218 | =head1 OPTIONS 219 | 220 | =over 8 221 | 222 | =item B<--server=>I 223 | 224 | The host where the Joplin server is running. 225 | Default is C. 226 | 227 | =item B<--token=>I 228 | 229 | Access token for Joplin. You can find it on the Web Clipper options page. 230 | 231 | =item B<--title=>I 232 | 233 | If specified, selects one or more folders to start listing. 234 | 235 | =item B<--resources> 236 | 237 | Shows resources used by the notes, and a summary of unused resources. 238 | 239 | =item B<--unused> 240 | 241 | Only shows a summary of unused resources. 242 | 243 | =item B<--weed> 244 | 245 | Shows a summary of unused resources and removes them. 246 | 247 | =item B<--help> 248 | 249 | Prints a brief help message and exits. 250 | 251 | =item B<--man> 252 | 253 | Prints the manual page and exits. 254 | 255 | =item B<--ident> 256 | 257 | Prints program identification. 258 | 259 | =item B<--verbose> 260 | 261 | Provides more verbose information. 262 | 263 | =item B<--quiet> 264 | 265 | Runs quietly. 266 | 267 | =back 268 | 269 | =head1 DESCRIPTION 270 | 271 | B will list titles of Joplin notes hierarchically. 272 | 273 | =head1 AUTHOR 274 | 275 | Johan Vromans C<< >> 276 | 277 | =head1 SUPPORT 278 | 279 | Joplin-Tools development is hosted on GitHub, repository 280 | L. 281 | 282 | Please report any bugs or feature requests to the GitHub issue tracker, 283 | L. 284 | 285 | =head1 LICENSE 286 | 287 | Copyright (C) 2019 Johan Vromans, 288 | 289 | This program is free software. You can redistribute it and/or 290 | modify it under the terms of the Artistic License 2.0. 291 | 292 | This program is distributed in the hope that it will be useful, 293 | but without any warranty; without even the implied warranty of 294 | merchantability or fitness for a particular purpose. 295 | 296 | =cut 297 | 298 | 1; 299 | -------------------------------------------------------------------------------- /script/listtags.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | # List tags. 4 | 5 | # Author : Johan Vromans 6 | # Created On : Mon Mar 11 13:38:34 2019 7 | # Last Modified By: Johan Vromans 8 | # Last Modified On: Mon Mar 11 14:23:08 2019 9 | # Update Count : 20 10 | # Status : Unknown, Use with caution! 11 | 12 | ################ Common stuff ################ 13 | 14 | use strict; 15 | use warnings; 16 | use Encode; 17 | use FindBin; 18 | use lib "$FindBin::Bin/../lib"; 19 | use Joplin; 20 | 21 | # Package name. 22 | my $my_package = 'JoplinTools'; 23 | # Program name and version. 24 | my ($my_name, $my_version) = qw( listtags 0.02 ); 25 | 26 | ################ Command line parameters ################ 27 | 28 | use Getopt::Long 2.13; 29 | 30 | # Command line options. 31 | my $title; 32 | my $server = "http://127.0.0.1:41184"; 33 | my $weed = 0; # remove unused tags 34 | my $showid = 0; 35 | my $verbose = 1; # verbose processing 36 | 37 | # Run Joplin and copy the token from the Web Clipper options page. 38 | my $token = $ENV{JOPLIN_APIKEY} // "YOUR TOKEN GOES HERE"; 39 | 40 | # Development options (not shown with -help). 41 | my $debug = 0; # debugging 42 | my $trace = 0; # trace (show process) 43 | my $test = 0; # test mode. 44 | 45 | # Process command line options. 46 | app_options(); 47 | 48 | # Post-processing. 49 | $trace |= ($debug || $test); 50 | 51 | ################ Presets ################ 52 | 53 | binmode( STDOUT, ":utf8" ); 54 | 55 | if ( $title ) { 56 | if ( $title =~ m;^/(.?)/?$; ) { 57 | $title = qr/$1/i; 58 | } 59 | else { 60 | $title = qr/^$title$/; 61 | } 62 | } 63 | 64 | ################ The Process ################ 65 | 66 | my $root = Joplin->connect( server => $server, apikey => $token ); 67 | 68 | $root->api->set_debug(1) if $debug; 69 | 70 | my @tags = sort { $a->title cmp $b->title } @{ $root->find_tags($title) }; 71 | 72 | listtags($_) for @tags; 73 | 74 | ################ Subroutines ################ 75 | 76 | sub listtags { 77 | my ( $tag ) = @_; 78 | die unless $tag->isa('Joplin::Tag'); 79 | 80 | my @notes = @{ $tag->find_notes }; 81 | print( $tag->id, " " ) if $showid; 82 | print( $tag->title ); 83 | if ( @notes ) { 84 | print( " (", scalar(@notes), " note", @notes == 1 ? "" : "s", ")"); 85 | } 86 | elsif ( $weed ) { 87 | eval { $tag->delete; print( " (deleted)\n") } 88 | and return; 89 | my $msg = $@; 90 | $msg =~ s/Joplin::API/delete/; 91 | $msg =~ s/ at .*$//s; 92 | print( " ($msg)"); 93 | } 94 | else { 95 | print( " (unused)"); 96 | } 97 | print("\n"); 98 | 99 | return unless $verbose > 1; 100 | 101 | @notes = sort { $a->title cmp $b->title } @notes; 102 | foreach my $n ( @notes ) { 103 | print( $n->id, " " ) if $showid; 104 | print( " ", $n->title, "\n" ); 105 | } 106 | } 107 | 108 | ################ Subroutines ################ 109 | 110 | sub app_options { 111 | my $help = 0; # handled locally 112 | my $ident = 0; # handled locally 113 | my $man = 0; # handled locally 114 | 115 | my $pod2usage = sub { 116 | # Load Pod::Usage only if needed. 117 | require Pod::Usage; 118 | Pod::Usage->import; 119 | &pod2usage; 120 | }; 121 | 122 | # Process options. 123 | if ( @ARGV > 0 ) { 124 | GetOptions('title=s' => sub { $title = decode_utf8($_[1]) }, 125 | 'server=s' => sub { $server = decode_utf8($_[1]) }, 126 | 'token=s' => \$token, 127 | 'weed' => \$weed, 128 | 'showid' => \$showid, 129 | 'ident' => \$ident, 130 | 'verbose+' => \$verbose, 131 | 'quiet' => sub { $verbose = 0 }, 132 | 'trace' => \$trace, 133 | 'help|?' => \$help, 134 | 'man' => \$man, 135 | 'debug' => \$debug) 136 | or $pod2usage->(2); 137 | } 138 | if ( $ident or $help or $man ) { 139 | print STDERR ("This is $my_package [$my_name $my_version]\n"); 140 | } 141 | if ( $man or $help ) { 142 | $pod2usage->(1) if $help; 143 | $pod2usage->(VERBOSE => 2) if $man; 144 | } 145 | } 146 | 147 | __END__ 148 | 149 | ################ Documentation ################ 150 | 151 | =head1 NAME 152 | 153 | listnotes - list note titles hierarchically 154 | 155 | =head1 SYNOPSIS 156 | 157 | exportnote [options] 158 | 159 | Options: 160 | --title=XXX selects tags by title 161 | --server=XXX the host running the Joplin server 162 | --token=XXX Joplin server access token 163 | --weed removes unused tags 164 | --ident shows identification 165 | --quiet runs quietly 166 | --help shows a brief help message and exits 167 | --man shows full documentation and exits 168 | --verbose provides more verbose information 169 | 170 | =head1 OPTIONS 171 | 172 | =over 8 173 | 174 | =item B<--server=>I 175 | 176 | The host where the Joplin server is running. 177 | Default is C. 178 | 179 | =item B<--token=>I 180 | 181 | Access token for Joplin. You can find it on the Web Clipper options page. 182 | 183 | =item B<--title=>I 184 | 185 | If specified, selects one or more folders to start listing. 186 | 187 | =item B<--weed> 188 | 189 | Removes unused tags. 190 | 191 | =item B<--help> 192 | 193 | Prints a brief help message and exits. 194 | 195 | =item B<--man> 196 | 197 | Prints the manual page and exits. 198 | 199 | =item B<--ident> 200 | 201 | Prints program identification. 202 | 203 | =item B<--verbose> 204 | 205 | Provides more verbose information. In particular, show note titles for 206 | each tag with notes. 207 | 208 | =item B<--quiet> 209 | 210 | Runs quietly. 211 | 212 | =back 213 | 214 | =head1 DESCRIPTION 215 | 216 | B will list titles of all Joplin tags, and the number of 217 | notes associated with each tag. 218 | 219 | Optionally, removes unused tags. 220 | 221 | =head1 AUTHOR 222 | 223 | Johan Vromans C<< >> 224 | 225 | =head1 SUPPORT 226 | 227 | Joplin-Tools development is hosted on GitHub, repository 228 | L. 229 | 230 | Please report any bugs or feature requests to the GitHub issue tracker, 231 | L. 232 | 233 | =head1 LICENSE 234 | 235 | Copyright (C) 2019 Johan Vromans, 236 | 237 | This program is free software. You can redistribute it and/or 238 | modify it under the terms of the Artistic License 2.0. 239 | 240 | This program is distributed in the hope that it will be useful, 241 | but without any warranty; without even the implied warranty of 242 | merchantability or fitness for a particular purpose. 243 | 244 | =cut 245 | 246 | 1; 247 | -------------------------------------------------------------------------------- /script/pp.PL: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | my $src = shift; 4 | open( my $fd, ">", $src); 5 | open( my $fi, "<", "$src.pl" ); 6 | print { $fd } ( <$fi> ); 7 | close($fd); 8 | -------------------------------------------------------------------------------- /t/00_load.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use Test::More tests => 1; 6 | require_ok( "Joplin::API" ); 7 | 8 | diag("Testing Joplin::API version $Joplin::API::VERSION with perl $^V\n"); 9 | -------------------------------------------------------------------------------- /t/01_basic.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing API setup and server connection. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More tests => 4; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | ok( $init{token}, "We have a token" ); 16 | 17 | my $api = Joplin::API->new( %init, debug => 0 ); 18 | ok( $api, "Got API instance" ); 19 | 20 | diag("Testing Joplin server " . $api->get_server . "\n"); 21 | 22 | SKIP: { 23 | my $res = $api->ping; 24 | skip "Server is not running", 2 unless $res; 25 | pass("Server is running"); 26 | is( $res, "JoplinClipperServer", "It's Joplin, jay!" ); 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /t/02_folder.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing folder creation and deletion. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | my $api = Joplin::API->new( %init, debug => 0 ); 17 | my $res; 18 | 19 | SKIP: { 20 | skip "Server is not running" unless $api->ping; 21 | 22 | $res = $api->get_folders; 23 | my $nfolders = scalar(@$res); 24 | ok( $res, "Got $nfolders folders" ); 25 | 26 | my $fname = "TestFolder$$"; 27 | my $fid = $api->create_folder($fname); 28 | ok( $fid, "Create folder $fname" ); 29 | $fid = $fid->{id}; 30 | 31 | $res = $api->get_folders; 32 | is( scalar(@$res), 1+$nfolders, "Got " . scalar(@$res) . " folders" ); 33 | 34 | $res = $api->get_folder($fid); 35 | is( $res->{id}, $fid, "Found folder" ); 36 | 37 | $res = $api->find_folders($fname); 38 | ok( $res, "Found " . scalar(@$res) . " folders" ); 39 | is( $res->[0]->{id}, $fid, "Found folder $fname" ); 40 | ok( $api->delete_folder($fid), "Delete folder $fname" ); 41 | 42 | $res = $api->get_folders; 43 | is( scalar(@$res), $nfolders, "Got " . scalar(@$res) . " folders" ); 44 | } 45 | 46 | done_testing(); 47 | -------------------------------------------------------------------------------- /t/03_note.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing note creation and deletion. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | use utf8; 10 | 11 | -d "t" && chdir("t"); 12 | 13 | # Get credentials. 14 | our %init; 15 | -s "./joplin.dat" && do "./joplin.dat"; 16 | 17 | my $api = Joplin::API->new( %init, debug => 0 ); 18 | my $res; 19 | 20 | SKIP: { 21 | skip "Server is not running" unless $api->ping; 22 | 23 | my $fname = "TestFolder$$"; 24 | my $fid = $api->create_folder($fname); 25 | $fid = $fid->{id}; 26 | 27 | my $nname = "TestNote$$"; 28 | my $nid = $api->create_note( $nname, 29 | "♫ Hi from testing ♬", 30 | $fid, 31 | ); 32 | $nid = $nid->{id}; 33 | ok( $nid, "Created note $nname" ); 34 | 35 | $res = $api->find_notes( qr/^$nname$/ ); 36 | is( $res->[0]->{id}, $nid, "Found the note" ); 37 | 38 | $res = $api->get_folder_notes($fid); 39 | is( $res->[0]->{id}, $nid, "Found the note" ); 40 | 41 | ok( $api->delete_note($nid), "Delete note $nname" ); 42 | 43 | $api->delete_folder($fid); 44 | } 45 | 46 | done_testing(); 47 | -------------------------------------------------------------------------------- /t/04_note.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing note creation with tags, and deletion of tags and note. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | my $api = Joplin::API->new( %init, debug => 0 ); 17 | my $res; 18 | 19 | SKIP: { 20 | skip "Server is not running" unless $api->ping; 21 | 22 | my $fname = "TestFolder$$"; 23 | my $fid = $api->create_folder($fname); 24 | $fid = $fid->{id}; 25 | 26 | my $nid = $api->create_note( "TestNote$$", 27 | "Hi from testing", 28 | $fid, 29 | tags => "ttag2,ttag1", 30 | ); 31 | $nid = $nid->{id}; 32 | ok( $nid, "Create note" ); 33 | 34 | my $tags = $api->get_note_tags($nid); 35 | is( @$tags, 2, "Got " . scalar(@$tags) . " tags" ); 36 | 37 | for ( @$tags ) { 38 | ok( $api->delete_tag($_->{id}), "Delete tag $_->{title}" ); 39 | } 40 | 41 | ok( $api->delete_note($nid), "Delete note" ); 42 | 43 | $api->delete_folder($fid); 44 | } 45 | 46 | done_testing(); 47 | -------------------------------------------------------------------------------- /t/05_note.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing note update. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | my $api = Joplin::API->new( %init, debug => 0 ); 17 | my $res; 18 | 19 | SKIP: { 20 | skip "Server is not running" unless $api->ping; 21 | 22 | my $fname = "TestFolder$$"; 23 | my $fid = $api->create_folder($fname); 24 | $fid = $fid->{id}; 25 | 26 | my $nname = "TestNote$$"; 27 | my $nid = $api->create_note( $nname, 28 | "Hi from testing", 29 | $fid, 30 | ); 31 | $nid = $nid->{id}; 32 | 33 | $res = $api->update_note( $nid, 34 | title => "♫ ♫ ♫", 35 | body => "Updated content", 36 | ); 37 | ok( $res, "Update note" ); 38 | is( $res->{body}, "Updated content", "Update note contents" ); 39 | 40 | $res = $api->find_notes( qr/^♫ ♫ ♫$/ ); 41 | is( $res->[0]->{id}, $nid, "Found the note" ); 42 | 43 | $res = $api->get_note($nid); 44 | is( $res->{body}, "Updated content", "Updated note contents" ); 45 | 46 | $api->delete_note($nid); 47 | $api->delete_folder($fid); 48 | } 49 | 50 | done_testing(); 51 | -------------------------------------------------------------------------------- /t/06_tags.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing CRUD on tags. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | my $api = Joplin::API->new( %init, debug => 0 ); 17 | my $res; 18 | 19 | SKIP: { 20 | skip "Server is not running" unless $api->ping; 21 | 22 | my $tname = "TestTag$$"; 23 | my $tid = $api->create_tag($tname); 24 | ok( $tid, "Create tag $tname" ); 25 | $tid = $tid->{id}; 26 | 27 | $res = $api->find_tags( qr/^$tname$/i ); 28 | ok( $res, "Found " . scalar(@$res) . " tags" ); 29 | is( $res->[0]->{id}, $tid, "Found tag $tname" ); 30 | is( $res->[0]->{title}, lc($tname), "It's $tname" ); 31 | 32 | $res = $api->update_tag( $tid, title => "XX$tname" ); 33 | ok( $res, "Updated tag" ); 34 | is( $res->{id}, $tid, "Found tag" ); 35 | 36 | $tname = "XX$tname"; 37 | $res = $api->find_tags( qr/^$tname$/i ) // []; 38 | ok( $res, "Found " . scalar(@$res) . " tags" ); 39 | is( $res->[0]->{id}, $tid, "Found tag $tname" ); 40 | is( $res->[0]->{title}, lc($tname), "It's $tname" ); 41 | 42 | ok( $api->delete_tag($tid), "Delete tag" ); 43 | } 44 | 45 | done_testing(); 46 | -------------------------------------------------------------------------------- /t/07_tags.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing adding and removing tags from notes. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin::API; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | my $api = Joplin::API->new( %init, debug => 0 ); 17 | my $res; 18 | 19 | SKIP: { 20 | skip "Server is not running" unless $api->ping; 21 | 22 | my $fname = "TestFolder$$"; 23 | my $fid = $api->create_folder($fname); 24 | $fid = $fid->{id}; 25 | my $nname = "TestNote$$"; 26 | my $nid = $api->create_note( $nname, "Hi from testing", $fid,); 27 | $nid = $nid->{id}; 28 | 29 | my $tname = "TestTag$$"; 30 | my $tid = $api->create_tag($tname); 31 | ok( $tid, "Create tag $tname" ); 32 | $tid = $tid->{id}; 33 | 34 | $res = $api->create_tag_note( $tid, $nid ); 35 | ok( $res, "Link tag to note" ); 36 | 37 | $res = $api->get_tag_notes($tid); 38 | ok( $res, "Got note for tag" ); 39 | is( $res->[0]->{id}, $nid, "Got the right note for tag" ); 40 | 41 | $res = $api->get_note_tags($nid); 42 | ok( $res, "Found " . scalar(@$res) . " tags" ); 43 | is( $res->[0]->{id}, $tid, "Found tag $tname" ); 44 | is( $res->[0]->{title}, lc($tname), "It's $tname" ); 45 | 46 | ok( $api->delete_tag_note($tid, $nid), "Unlink tag" ); 47 | ok( $api->delete_tag($tid), "Delete tag" ); 48 | 49 | # These have been tested before. 50 | $api->delete_note($nid); 51 | $api->delete_folder($fid); 52 | } 53 | 54 | done_testing(); 55 | -------------------------------------------------------------------------------- /t/50_load.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | my @mods; 7 | 8 | BEGIN { @mods = qw( Base Folder Note Resource Tag ) } 9 | 10 | use Test::More tests => 1 + @mods; 11 | 12 | require_ok( "Joplin" ); 13 | 14 | require_ok( "Joplin::$_" ) for @mods; 15 | 16 | diag("Testing Joplin version $Joplin::VERSION with perl $^V\n"); 17 | -------------------------------------------------------------------------------- /t/51_basic.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing API setup and server connection. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More tests => 4; 8 | use Joplin; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | ok( $init{token}, "We have a token" ); 16 | 17 | my $root = Joplin->connect( %init ); 18 | ok( defined $root, "Got Root Folder instance" ); 19 | 20 | diag("Testing Joplin server " . $root->api->get_server . "\n"); 21 | 22 | SKIP: { 23 | my $res = $root->ping; 24 | skip "Server is not running", 2 unless $res; 25 | pass("Server is running"); 26 | is( $res, "JoplinClipperServer", "It's Joplin, jay!" ); 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /t/52_folders.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | # Testing API setup and server connection. 4 | 5 | use strict; 6 | use warnings; 7 | use Test::More; 8 | use Joplin; 9 | 10 | -d "t" && chdir("t"); 11 | 12 | # Get credentials. 13 | our %init; 14 | -s "./joplin.dat" && do "./joplin.dat"; 15 | 16 | # Connect to server. 17 | my $root = Joplin->connect( %init ); 18 | 19 | SKIP: { 20 | my $res = $root->ping; 21 | skip "Server is not running" unless $res; 22 | 23 | my $sf = $root->create_folder("TestFolder$$"); 24 | ok( $sf, "Create test folder" ); 25 | 26 | my $ssf = $sf->create_folder("TestSubFolder$$"); 27 | ok( $sf, "Create test subfolder" ); 28 | 29 | $ssf->title("TestXXSubFolder$$"); 30 | $res = $ssf->update; 31 | ok( $res && $res->{title} eq "TestXXSubFolder$$", 32 | "Renamed subfolder" ); 33 | 34 | my $fn = $ssf->create_note( "TestNote$$", "Some *markdown* content."); 35 | ok( $fn, "Create test note in subfolder" ); 36 | 37 | ok( $fn->delete, "Delete test note" ); 38 | 39 | my $n = folders(); 40 | ok( $ssf->delete, "Delete test subfolder" ); 41 | is( $n-1, folders(), "One down, one to go" ); 42 | ok( $sf->delete, "Delete test folder" ); 43 | is( $n-2, folders(), "Two down" ); 44 | } 45 | 46 | done_testing(); 47 | 48 | sub folders { 49 | scalar( @{ $root->find_folders( undef, "recurse" ) } ); 50 | } 51 | -------------------------------------------------------------------------------- /t/57_tags.t: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | use strict; 4 | use warnings; 5 | use Test::More; 6 | use Joplin; 7 | 8 | -d "t" && chdir("t"); 9 | 10 | # Get credentials. 11 | our %init; 12 | -s "./joplin.dat" && do "./joplin.dat"; 13 | 14 | # Connect to server. 15 | my $root = Joplin->connect( %init ); 16 | 17 | SKIP: { 18 | my $res = $root->ping; 19 | skip "Server is not running" unless $res; 20 | 21 | my $sf = $root->create_folder("TestFolder$$"); 22 | my $fn = $sf->create_note( "TestNote$$", "Some *markdown* content."); 23 | 24 | my @res = $root->find_tags("TestTag$$"); 25 | is( scalar(@res), 0, "No test tag" ); 26 | 27 | my $t = $fn->add_tag("TestTag$$"); 28 | ok( $t, "Added tag" ); 29 | @res = $root->find_tags("TestTag$$"); 30 | is( scalar(@res), 1, "Got test tag" ); 31 | is( $res[0]->id, $t->id, "Verified tag" ); 32 | ok( $fn->delete_tag($t), "Delete tag from note" ); 33 | ok( $fn->delete, "Delete test note" ); 34 | ok( $sf->delete, "Delete test folder" ); 35 | } 36 | 37 | done_testing(); 38 | -------------------------------------------------------------------------------- /t/joplin.dat: -------------------------------------------------------------------------------- 1 | #! perl 2 | 3 | our %init; 4 | 5 | $init{host} = "localhost"; 6 | $init{port} = 41184; 7 | $init{token} = ""; # YOUR TOKEN HERE 8 | 9 | # This is for my convenience and won't work for you. 10 | if ( !$init{token} && eval { require ResInfo } ) { 11 | $init{host} = ResInfo::resinfo("joplin.host"); 12 | $init{port} = ResInfo::resinfo("joplin.port"); 13 | $init{token} = ResInfo::resinfo("joplin.apikey"); 14 | } 15 | 16 | --------------------------------------------------------------------------------