File Coverage

blib/lib/Articulate/Storage/Local.pm
Criterion Covered Total %
statement 99 145 68.2
branch 24 60 40.0
condition 5 10 50.0
subroutine 21 26 80.7
pod 10 12 83.3
total 159 253 62.8


line stmt bran cond sub pod time code
1             package Articulate::Storage::Local;
2 6     6   2669 use strict;
  6         13  
  6         176  
3 6     6   25 use warnings;
  6         11  
  6         116  
4              
5 6     6   20 use Moo;
  6         7  
  6         22  
6             with 'Articulate::Role::Component';
7             with 'Articulate::Role::Storage';
8 6     6   1463 use Articulate::Syntax qw(hash_merge);
  6         15  
  6         51  
9              
10 6     6   1526 use File::Path;
  6         8  
  6         400  
11 6     6   3143 use IO::All;
  6         52919  
  6         48  
12 6     6   1175 use YAML;
  6         7292  
  6         382  
13 6     6   38 use Articulate::Syntax;
  6         10  
  6         62  
14 6     6   9071 use Scalar::Util qw(blessed);
  6         15  
  6         298  
15 6     6   904 use FindBin;
  6         1219  
  6         10009  
16              
17             =head1 NAME
18              
19             Articulate::Content::Local - store your content locally
20              
21             =cut
22              
23             =head1 DESCRIPTION
24              
25             This content storage interface works by placing content and metadata in
26             a folder structure.
27              
28             For a given location, metadata is stored in C, content in
29             C.
30              
31             Set C in your config to specify where to place the
32             content.
33              
34             Caching is not implemented: get_content_cached simpy calls get_content.
35              
36             =cut
37              
38             =head1 METHODS
39              
40             =cut
41              
42             has content_base => (
43             is => 'rw',
44             lazy => 1, # because depends on app
45             default => sub {
46             my $self = shift;
47             return (
48             undef
49              
50             # $self->framework->appdir
51             // $FindBin::Bin
52             ) . '/content/';
53             },
54             coerce => sub {
55             my $content_base = shift;
56             unless ( -d $content_base ) {
57             File::Path::make_path $content_base;
58             throw_error( Internal => 'Could not initialise content base' )
59             unless ( -d $content_base );
60             }
61             return $content_base;
62             },
63             );
64              
65             sub ensure_exists { # internal method
66 10     10 0 98 my $self = shift;
67 10   50     31 my $true_location_full = shift // return undef;
68 10         16 my $true_location = $true_location_full;
69 10         100 $true_location =~ s~[^/]+\.[^/]+$~~; #:5.12 doesn't have s///r
70 10 100       257 unless ( -d $true_location ) {
71 3         1440 File::Path::make_path $true_location;
72             }
73 10 50       170 return -d $true_location
74             ? $true_location_full
75             : throw_error( 'Internal' => 'Could not create directory for location' );
76             }
77              
78             sub true_location {
79 69     69 0 100 my $self = shift;
80 69         1653 return $self->content_base . shift;
81             }
82              
83             =head3 get_item
84              
85             $storage->get_item( 'zone/public/article/hello-world' )
86              
87             Retrieves the metadata for the content at that location.
88              
89             =cut
90              
91             sub get_item {
92 2     2 1 409 my $self = shift;
93 2         7 my $location = shift->location;
94 2 50       7 throw_error Internal => "Bad location $location"
95             unless $self->navigation->valid_location($location);
96 2 50       8 throw_error NotFound => "No content at $location"
97             unless $self->item_exists($location);
98 2         63 my $item = $self->construction->construct( { location => $location } );
99 2         8 $item->meta( $self->get_meta($item) );
100 2         1242 $item->content( $self->get_content($item) );
101 2         15 return $item;
102             }
103              
104             =head3 get_meta
105              
106             $storage->get_meta( 'zone/public/article/hello-world' )
107              
108             Retrieves the metadata for the content at that location.
109              
110             =cut
111              
112             sub get_meta {
113 5     5 1 23 my $self = shift;
114 5         9 my $item = shift;
115 5         75 my $location = $item->location;
116 5 50       37 throw_error Internal => "Bad location $location"
117             unless $self->navigation->valid_location($location);
118 5 100       25 throw_error NotFound => "No content at $location"
119             unless $self->item_exists($location);
120 3         141 my $fn = $self->true_location( $location . '/meta.yml' );
121 3 50       363 return YAML::LoadFile($fn) if -e $fn;
122 0         0 return {};
123             }
124              
125             =head3 set_meta
126              
127             $storage->set_meta( 'zone/public/article/hello-world', {...} )
128              
129             Sets the metadata for the content at that location.
130              
131             =cut
132              
133             sub set_meta {
134             my $self = shift;
135             my $item = shift;
136             my $location = $item->location;
137             throw_error Internal => "Bad location " . $location
138             unless $self->navigation->valid_location($location);
139             throw_error NotFound => "No content at $location"
140             unless $self->item_exists($location);
141             my $fn =
142             $self->ensure_exists( $self->true_location( $location . '/meta.yml' ) );
143             YAML::DumpFile( $fn, $item->meta );
144             return $item;
145             }
146              
147             =head3 patch_meta
148              
149             $storage->patch_meta( 'zone/public/article/hello-world', {...} )
150              
151             Alters the metadata for the content at that location. Existing keys are
152             retained.
153              
154             CURRENTLY this affects top-level keys only, but a descent algorigthm is
155             planned.
156              
157             =cut
158              
159             sub patch_meta {
160 0     0 1 0 my $self = shift;
161 0         0 my $item = shift;
162 0         0 my $location = $item->location;
163 0 0       0 throw_error Internal => "Bad location " . $location
164             unless $self->navigation->valid_location($location);
165 0 0       0 throw_error NotFound => "No content at $location"
166             unless $self->item_exists($location);
167 0         0 my $fn =
168             $self->ensure_exists( $self->true_location( $location . '/meta.yml' ) );
169 0         0 my $old_data = {};
170 0 0       0 $old_data = YAML::LoadFile($fn) if -e $fn;
171 0         0 return YAML::DumpFile( $fn, hash_merge( $old_data, $item->meta ) );
172             }
173              
174             =head3 get_settings
175              
176             $storage->get_settings('zone/public/article/hello-world')
177              
178             Retrieves the settings for the content at that location.
179              
180             =cut
181              
182             sub get_settings {
183 0     0 1 0 my $self = shift;
184 0         0 my $item = shift;
185 0         0 my $location = $item->location;
186 0 0       0 throw_error Internal => "Bad location $location"
187             unless $self->navigation->valid_location($location);
188 0 0       0 throw_error NotFound => "No content at $location"
189             unless $self->item_exists($location);
190 0         0 my $fn = $self->true_location( $location . '/settings.yml' );
191 0 0       0 return YAML::LoadFile($fn) if -e $fn;
192 0         0 return {};
193             }
194              
195             =head3 set_settings
196              
197             $storage->set_settings('zone/public/article/hello-world', $amended_settings)
198              
199             Retrieves the settings for the content at that location.
200              
201             =cut
202              
203             sub set_settings {
204 0     0 1 0 my $self = shift;
205 0         0 my $location = shift->location;
206 0         0 my $settings = shift;
207 0 0       0 throw_error Internal => "Bad location $location"
208             unless $self->navigation->valid_location($location);
209 0 0       0 throw_error NotFound => "No content at $location"
210             unless $self->item_exists($location);
211 0         0 my $fn =
212             $self->ensure_exists( $self->true_location( $location . '/settings.yml' ) );
213 0         0 YAML::DumpFile( $fn, $settings );
214 0         0 return $settings;
215             }
216              
217             =head3 get_settings_complete
218              
219             $storage->get_settings_complete('zone/public/article/hello-world')
220              
221             Retrieves the settings for the content at that location.
222              
223             =cut
224              
225             sub get_settings_complete {
226 0     0 1 0 my $self = shift;
227 0         0 my $location = shift->location;
228 0 0       0 throw_error Internal => "Bad location $location"
229             unless $self->navigation->valid_location($location);
230 0         0 my @paths = split /\//, $location;
231 0         0 my $current_path = $self->true_location('') . '/';
232 0         0 my $settings = {};
233 0         0 foreach my $p (@paths) {
234 0         0 my $fn = $current_path . 'settings.yml';
235 0         0 my $lvl_settings = {};
236 0 0       0 $lvl_settings = YAML::LoadFile($fn) if -e $fn;
237 0         0 $settings = hash_merge( $settings, $lvl_settings );
238             }
239 0         0 return $settings;
240             }
241              
242             =head3 get_content
243              
244             $storage->get_content('zone/public/article/hello-world')
245              
246             Retrieves the content at that location.
247              
248             =cut
249              
250             sub get_content {
251 10     10 1 31 my $self = shift;
252 10         217 my $location = shift->location;
253 10 50       89 throw_error Internal => "Bad location $location"
254             unless $self->navigation->valid_location($location);
255 10 100       45 throw_error NotFound => "No content at $location"
256             unless $self->item_exists($location);
257 8         357 my $fn = $self->true_location( $location . '/content.blob' );
258 8 50       397 open my $fh, '<', $fn
259             or throw_error Internal => "Cannot open file $fn to read";
260 8         207 return '' . ( join '', <$fh> );
261             }
262              
263             =head3 set_content
264              
265             $storage->set_content('zone/public/article/hello-world', $blob);
266              
267             Places content at that location.
268              
269             =cut
270              
271             sub _is_upload {
272 5     5   7 my $content = shift;
273 5   33     29 return ( blessed $content and $content->isa('Articulate::File') )
274             ; # todo: have this wrapped by an articulate class which interfaces with the FrameworkAdapter
275             }
276              
277             sub _write_data {
278 5     5   9 my ( $content, $fn ) = @_;
279 5 50       508 open my $fh, '>', $fn
280             or throw_error Internal => "Cannot open file $fn to write";
281 5         56 print $fh $content;
282 5         342 close $fh;
283             }
284              
285             sub _copy_upload {
286 0     0   0 my ( $content, $fn ) = @_;
287 0         0 my $content_fh = $content->io;
288 0         0 local $/;
289 0 0       0 open my $fh, '>', $fn
290             or throw_error Internal => "Cannot open file $fn to write";
291 0         0 binmode $fh, ':raw';
292 0         0 print $fn while <$content_fh>;
293 0         0 close $fh;
294             }
295              
296             sub _write_content {
297 5     5   11 my ( $content, $fn ) = @_;
298 5   50     17 $content //= '';
299 5 50       19 if ( _is_upload($content) ) {
300 0         0 _copy_upload( $content, $fn );
301             }
302             else {
303 5         16 _write_data( $content, $fn );
304             }
305             }
306              
307             sub set_content {
308             my $self = shift;
309             my $item = shift;
310             my $location = $item->location;
311             throw_error Internal => "Bad location $location"
312             unless $self->navigation->valid_location($location);
313             throw_error NotFound => "No content at $location"
314             unless $self->item_exists($location);
315             my $fn =
316             $self->ensure_exists( $self->true_location( $location . '/content.blob' ) );
317             _write_content( $item->content, $fn );
318             return $location;
319             }
320              
321             =head3 create_item
322              
323             $storage->create_item('zone/public/article/hello-world', $meta, $blob);
324              
325             Places meta and content at that location.
326              
327             =cut
328              
329             sub create_item {
330 5     5 1 4169 my $self = shift;
331 5         11 my $item = shift;
332 5         134 my $location = $item->location;
333 5 50       47 throw_error Internal => "Bad location " . $location
334             unless $self->navigation->valid_location($location);
335 5 100       89 throw_error AlreadyExists => "Cannot create: item already exists at "
336             . $location
337             if $self->item_exists($location);
338             {
339 4         160 my $fn =
  4         18  
340             $self->ensure_exists(
341             $self->true_location( $location . '/content.blob' ) );
342 4         45 _write_content( $item->content, $fn );
343             }
344             {
345 4         9 my $fn =
  4         19  
346             $self->ensure_exists( $self->true_location( $location . '/meta.yml' ) );
347 4         33 YAML::DumpFile( $fn, $item->meta );
348             }
349 4         22082 $item->content( $self->get_content($item) );
350 4         21 return $item;
351             }
352              
353             =head3 item_exists
354              
355             if ($storage->item_exists( 'zone/public/article/hello-world')) {
356             ...
357             }
358              
359             Determines if the item has been created (only the C is
360             tested).
361              
362             =cut
363              
364             sub item_exists {
365 44     44 1 1095 my $self = shift;
366 44         127 my $location = shift->location;
367 44 50       124 throw_error Internal => "Bad location $location"
368             unless $self->navigation->valid_location($location);
369 44         132 return -e $self->true_location( $location . '/meta.yml' );
370             }
371              
372             =head3 list_items
373              
374             $storage->list_items ('/zone/public'); # 'hello-world', 'second-item' )
375              
376             Returns a list of items in the.
377              
378             =cut
379              
380             sub list_items {
381 2     2 1 50 my $self = shift;
382 2         6 my $location = shift->location;
383              
384             # throw_error Internal => "Bad location $location" unless $self->navigation->valid_location( $location ); # actually, no, because /zone fails but /zone/foo passes
385 2         5 my $true_location = $self->true_location($location);
386 2         4 my @contents;
387 2 50       39 return @contents unless -d $true_location;
388 2 50       49 opendir( my $dh, $true_location )
389             or throw_error NotFound => ( 'Could not open ' . $true_location );
390 2         58 while ( my $fn = readdir $dh ) {
391 6         143 my $child_dn = $true_location . '/' . $fn;
392 6 50       70 next unless -d $child_dn;
393 6 100 66     17 push @contents, $fn
394             if $self->navigation->valid_location( $location . '/' . $fn )
395             and $self->item_exists( new_location $location. '/' . $fn );
396             }
397 2         101 return @contents;
398             }
399              
400             =head3 empty_all_content
401              
402             $storage->empty_all_content;
403              
404             Removes all content. This is totally irreversible, unless you took a
405             backup!
406              
407             =cut
408              
409             sub empty_all_content {
410             my $self = shift;
411             my $true_location = $self->content_base;
412              
413             throw_error Internal => "Won't empty all content, this looks too dangerous"
414             if ( -d "$true_location/.git"
415             or -f "$true_location/Makefile.PL" );
416              
417             File::Path::remove_tree( $self->content_base, { keep_root => 1 } );
418             }
419              
420             =head3 delete_item
421              
422             $storage->delete_item ('/zone/public');
423              
424             Deletes the item and all its descendants.
425              
426             =cut
427              
428             sub delete_item {
429             my $self = shift;
430             my $location = shift->location;
431              
432             throw_error Internal => "Use empty_all_content instead to delete the root"
433             if "$location" eq '/';
434             throw_error Internal => "Bad location $location"
435             unless $self->navigation->valid_location($location);
436             throw_error NotFound => "No content at $location"
437             unless $self->item_exists($location);
438              
439             my $true_location = $self->true_location($location);
440             File::Path::remove_tree($true_location);
441             }
442              
443             =head1 SEE ALSO
444              
445             =over
446              
447             =item * L
448              
449             =item * L
450              
451             =back
452              
453             =cut
454              
455             1;