File Coverage

lib/File/Information/Link.pm
Criterion Covered Total %
statement 50 146 34.2
branch 5 60 8.3
condition 3 95 3.1
subroutine 14 20 70.0
pod 4 4 100.0
total 76 325 23.3


line stmt bran cond sub pod time code
1             # Copyright (c) 2024-2025 Philipp Schafft <lion@cpan.org>
2              
3             # licensed under Artistic License 2.0 (see LICENSE file)
4              
5             # ABSTRACT: generic module for extracting information from filesystems
6              
7              
8             package File::Information::Link;
9              
10 3     3   29 use v5.16;
  3         8  
11 3     3   10 use strict;
  3         3  
  3         49  
12 3     3   7 use warnings;
  3         2  
  3         130  
13              
14 3     3   11 use parent 'File::Information::Base';
  3         3  
  3         20  
15              
16 3     3   276 use Carp;
  3         6  
  3         317  
17 3     3   49 use Fcntl qw(O_RDONLY O_NOFOLLOW SEEK_SET);
  3         6  
  3         228  
18 3     3   49 use File::Spec;
  3         5  
  3         79  
19 3     3   15 use File::Basename ();
  3         5  
  3         102  
20              
21 3     3   13 use Data::Identifier v0.08;
  3         52  
  3         44  
22 3     3   2387 use Data::Identifier::Generate v0.08;
  3         22125  
  3         547  
23              
24 3     3   4128 use File::Information::Inode;
  3         17  
  3         391  
25 3     3   10669 use File::Information::Deep;
  3         18  
  3         10722  
26              
27             our $VERSION = v0.16;
28              
29             my $HAVE_XML_SIMPLE = eval {require XML::Simple; 1;};
30             my $HAVE_URI_FILE = eval {require URI::file; 1;};
31             my $HAVE_DIGEST = eval {require Digest; 1;};
32              
33             my %_dot_comments_rating = (
34             0 => '06813a68-06f2-5d42-b230-28445e5f5dc1',
35             1 => '4b31eb8c-546a-578b-83bb-e5d6e6a53263',
36             2 => 'bb986cde-9f2e-5c1d-9f56-cb3fa019077d',
37             3 => 'c7ea5002-eed0-58f6-9707-edfd673c6e02',
38             4 => 'a0e425a4-a447-5b54-bafc-46ea54eb9d55',
39             5 => '14c1ebe1-9901-534d-b837-ea22cba1adfe',
40             );
41              
42             my %_properties = (
43             link_basename => {loader => \&_load_basename},
44             link_basename_clean => {loader => \&_load_basename},
45             link_basename_boring=> {loader => \&_load_basename, rawtype => 'bool'},
46             link_dotfile => {loader => \&_load_basename, rawtype => 'bool'},
47             );
48              
49             if ($HAVE_XML_SIMPLE) {
50             $_properties{'dotcomments_'.$_} = {loader => \&_load_dotcomments, dotcomments_key => $_} foreach qw(version note place time_v2_0 time_v3_0 keywords caption rating categories);
51             $_properties{dotcomments_time_v2_0}{rawtype} = 'unixts';
52             }
53              
54             if ($HAVE_URI_FILE && $HAVE_DIGEST) {
55             $_properties{link_thumbnail} = {loader => \&_load_thumbnail, rawtype => 'filename'};
56             }
57              
58             {
59             while (my ($key, $value) = each %_dot_comments_rating) {
60             Data::Identifier->new(uuid => $value, displayname => $key)->register;
61             }
62             }
63              
64             sub _new {
65 2     2   11 my ($pkg, %opts) = @_;
66 2         19 my $self = $pkg->SUPER::_new(%opts, properties => \%_properties);
67              
68 2 50       7 croak 'No path is given' unless defined $self->{path};
69              
70 2         9 return $self;
71             }
72              
73              
74             #@returns File::Information::Inode
75             sub inode {
76 8     8 1 946 my ($self) = @_;
77              
78 8 100       25 unless (exists $self->{inode}) {
79 2         5 my $fh;
80 2         4 my $mode = 0;
81              
82 2 50       7 if ($self->{symlinks} eq 'nofollow') {
    0          
83 2         6 $mode |= O_NOFOLLOW;
84             } elsif ($self->{symlinks} eq 'opportunistic-nofollow') {
85 0         0 eval { $mode |= O_NOFOLLOW };
  0         0  
86             }
87              
88 2   50     29 $self->{inode} //= undef; # force the key to exist
89              
90 2 50 33     132 sysopen($fh, $self->{path}, O_RDONLY|$mode) or opendir($fh, $self->{path}) or die $!;
91             $self->{inode} = File::Information::Inode->_new(
92 2         8 (map {$_ => $self->{$_}} qw(instance path)),
  4         32  
93             handle => $fh,
94             );
95             }
96              
97 8   33     34 return $self->{inode} // croak 'No Inode';
98             }
99              
100              
101             #@returns File::Information::Deep
102             sub deep {
103 0     0 1   my ($self, %opts) = @_;
104 0 0         return $self->{deep} if defined $self->{deep};
105 0 0         return $opts{default} if exists $opts{no_defaults};
106 0           return $self->{deep} = File::Information::Deep->_new(instance => $self->instance, path => $self->{path}, parent => $self);
107             }
108              
109              
110             #@returns File::Information::Filesystem
111             sub filesystem {
112 0     0 1   my ($self, @args) = @_;
113 0   0       return $self->{filesystem} //= $self->inode->filesystem(@args);
114             }
115              
116              
117             sub tagpool {
118 0     0 1   my ($self, @args) = @_;
119 0           return $self->inode->tagpool(@args);
120             }
121              
122             sub _load_dotcomments {
123 0     0     my ($self, $key) = @_;
124 0 0         unless ($self->{_loaded_dotcomments}) {
125 0           my $info = $self->{properties}{$key};
126 0   0       my $pv = ($self->{properties_values} //= {})->{current} //= {};
      0        
127 0           my ($volume, $directories, $file) = File::Spec->splitpath($self->{path});
128 0           my $comments_file = File::Spec->catfile($volume, $directories, '.comments', $file.'.xml');
129 0           my $xml;
130              
131 0           $self->{_loaded_dotcomments} = 1;
132              
133 0 0         return unless -f $comments_file;
134              
135 0 0         croak 'Not supported, requires XML::Simple' unless $HAVE_XML_SIMPLE;
136              
137 0           eval {
138 0           my $magic;
139             my $fh;
140              
141 0 0         open($fh, '<', $comments_file) or die $!;
142 0           binmode($fh);
143              
144 0           read($fh, $magic, 2);
145 0           seek($fh, 0, SEEK_SET);
146              
147 0 0         if ($magic eq "\x1f\x8b") {
148 0           binmode($fh, ':gzip');
149             }
150              
151 0           $xml = XML::Simple::XMLin($fh);
152             };
153              
154 0 0         croak 'No valid .comments/ XML at: '.$comments_file unless defined $xml;
155              
156 0           foreach my $key (qw(version note place caption keywords)) {
157 0   0       my $value = $xml->{$key} // $xml->{ucfirst $key};
158              
159 0 0 0       if (defined($value) && !ref($value) && length($value)) {
      0        
160 0           $pv->{'dotcomments_'.$key} = {raw => $value};
161             }
162             }
163              
164             {
165 0   0       my $value = $xml->{time} // $xml->{Time};
166 0 0         if (defined($value)) {
167 0 0         if ($xml->{version} eq '2.0') {
    0          
168 0 0 0       if (!ref($value) && $value =~ /^[0-9][1-9]+$/ && int($value)) {
      0        
169 0           $pv->{dotcomments_time_v2_0} = {raw => int($value)};
170             }
171             } elsif ($xml->{version} eq '3.0') {
172 0 0 0       if (ref($value) && defined($value->{value}) && !ref($value->{value}) && $value->{value} =~ /^[0-9]{4}:[0-9]{2}:[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/) {
      0        
      0        
173 0           $pv->{dotcomments_time_v3_0} = {raw => $value->{value}};
174             }
175             }
176             }
177             }
178              
179             {
180 0   0       my $value = $xml->{rating} // $xml->{Rating};
  0            
181 0 0 0       if (defined($value) && ref($value) && defined($value->{value}) && !ref($value->{value}) && $value->{value} =~ /^[1-5]$/) {
      0        
      0        
      0        
182 0           $pv->{dotcomments_rating} = {raw => int($value->{value})};
183             $pv->{dotcomments_rating}{uuid} = $_dot_comments_rating{$value->{value}} if defined $_dot_comments_rating{$value->{value}}
184 0 0         }
185             }
186              
187             {
188 0   0       my $value = $xml->{categories} // $xml->{Categories};
  0            
  0            
189 0           my @list;
190              
191 0 0 0       if (defined($value) && ref($value) && defined($value->{category}) && ref($value->{category})) {
      0        
      0        
192 0           $value = $value->{category};
193 0 0         if (ref($value)) {
194 0           foreach my $entry (@{$value}) {
  0            
195 0 0 0       if (ref($entry) && defined($entry->{value}) && length($entry->{value})) {
      0        
196 0           push(@list, {raw => $entry->{value}});
197             }
198             }
199             }
200             }
201              
202 0 0 0       if (defined($pv->{dotcomments_keywords}) && defined($pv->{dotcomments_keywords}{raw})) {
203 0           push(@list, map {{raw => $_}} grep {length} split(/\s*,\s*/, $pv->{dotcomments_keywords}{raw}));
  0            
  0            
204             }
205              
206 0           foreach my $entry (@list) {
207             $entry->{'Data::Identifier'} = Data::Identifier::Generate->generic(
208             displayname => $entry->{raw},
209             request => $entry->{raw},
210 0           style => 'name-based',
211             namespace => 'eb239013-7556-4091-959f-4d78ca826757',
212             );
213             }
214              
215 0           $pv->{dotcomments_categories} = \@list;
216             }
217             }
218             }
219              
220             sub _load_basename {
221 0     0     my ($self) = @_;
222 0           my $basename = File::Basename::basename($self->{path});
223 0   0       my $pv = ($self->{properties_values} //= {})->{current} //= {};
      0        
224 0           my $boring_extension = $self->instance->{boring_extension};
225 0           my $boring;
226              
227 0           $pv->{link_basename} = {raw => $basename};
228 0           $pv->{link_dotfile} = {raw => !!($basename =~ /^\./)};
229              
230 0   0       $boring ||= $basename =~ /thumb/i;
231 0   0       $boring ||= $basename =~ /[\~\#]$/;
232              
233 0 0         if ($basename =~ /\.([^\.]+)$/) {
234 0   0       $boring ||= $boring_extension->{fc($1)};
235             }
236              
237 0           $pv->{link_basename_boring} = {raw => $boring};
238              
239 0           $basename =~ s/(.)(?:\.tar)?\.[^\.]+$/$1/;
240 0           $basename =~ s/^[a-z]+\.[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\.(.)/$1/;
241              
242 0           $pv->{link_basename_clean} = {raw => $basename};
243             }
244              
245             sub _load_thumbnail {
246 0     0     my ($self) = @_;
247 0 0         unless ($self->{_loaded_thumbnail}) {
248 0           $self->{_loaded_thumbnail} = 1;
249              
250 0           eval {
251 0           my $instance = $self->instance;
252 0   0       my $pv = ($self->{properties_values} //= {})->{current} //= {};
      0        
253 0           my $uri = URI::file->new_abs($self->{path});
254 0           my $digest = Digest->new('MD5')->add($uri)->hexdigest;
255 0           my $mtime = $self->inode->get('st_mtime', default => undef);
256              
257 0 0         return unless defined $mtime;
258              
259 0           foreach my $size (qw(normal large x-large xx-large)) {
260 0           my $file = $instance->_path(XDG_CACHE_HOME => file => thumbnails => $size => $digest.'.png');
261 0           my @stat = stat($file);
262 0 0         if (scalar(@stat)) {
263 0 0         if ($mtime < $stat[9]) {
264 0           $pv->{link_thumbnail} = {raw => $file};
265 0           return;
266             }
267             }
268             }
269             };
270             }
271             }
272              
273             1;
274              
275             __END__
276              
277             =pod
278              
279             =encoding UTF-8
280              
281             =head1 NAME
282              
283             File::Information::Link - generic module for extracting information from filesystems
284              
285             =head1 VERSION
286              
287             version v0.16
288              
289             =head1 SYNOPSIS
290              
291             use File::Information;
292              
293             my File::Information $instance = File::Information->new(%config);
294              
295             my File::Information::Link $link = $instance->for_link($path);
296              
297             B<Note:> This package inherits from L<File::Information::Base>.
298              
299             This module represents a hardlink on a filesystem. A hardlink is is basically a name for an inode.
300             Each inode can have zero or more hardlinks. (The exact limits are subject to filesystem limitations.)
301             See also L<File::Information::Inode>.
302              
303             =head1 METHODS
304              
305             =head2 inode
306              
307             my File::Information::Inode $inode = $link->inode;
308              
309             Provide the inode object for the current link.
310              
311             =head2 deep
312              
313             my File::Information::Deep $deep = $link->deep;
314              
315             Provides a deep inspection object. This allows access to data internal to the file.
316              
317             See also
318             L<File::Information::Deep>.
319              
320             =head2 filesystem
321              
322             my File::Information::Filesystem $filesystem = $link->filesystem;
323              
324             Proxy for L<File::Information::Inode/filesystem>.
325              
326             =head2 tagpool
327              
328             my File::Information::Tagpool $tagpool = $link->tagpool;
329             # or:
330             my @tagpool = $link->tagpool;
331              
332             Proxy for L<File::Information::Inode/tagpool>.
333              
334             =head1 AUTHOR
335              
336             Philipp Schafft <lion@cpan.org>
337              
338             =head1 COPYRIGHT AND LICENSE
339              
340             This software is Copyright (c) 2024-2025 by Philipp Schafft <lion@cpan.org>.
341              
342             This is free software, licensed under:
343              
344             The Artistic License 2.0 (GPL Compatible)
345              
346             =cut