File Coverage

blib/lib/Dancer/SearchApp.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package Dancer::SearchApp;
2 3     3   127049 use strict;
  3         13  
  3         147  
3 3     3   12 use File::Basename 'basename';
  3         6  
  3         236  
4 3     3   2124 use Dancer;
  3         575509  
  3         16  
5 3     3   230335 use Search::Elasticsearch::Async;
  0            
  0            
6             use URI::Escape 'uri_unescape';
7             use URI::file;
8             #use Search::Elasticsearch::TestServer;
9              
10             use Dancer::SearchApp::Defaults 'get_defaults';
11              
12             use Dancer::SearchApp::Entry;
13             use Dancer::SearchApp::HTMLSnippet;
14              
15             use vars qw($VERSION $es %indices);
16             $VERSION = '0.06';
17              
18             =head1 NAME
19              
20             Dancer::SearchApp - A simple local search engine
21              
22             =head1 SYNOPSIS
23              
24             =head1 QUICKSTART
25              
26             Also see L.
27              
28             cpanm --look Dancer::SearchApp
29            
30             # Install prerequisites
31             cpanm --installdeps .
32              
33             # Install Elasticsearch https://www.elastic.co/downloads/elasticsearch
34             # Start Elasticsearch
35             # Install Apache Tika from https://tika.apache.org/download.html into jar/
36              
37             # Launch the web frontend
38             plackup --host 127.0.0.1 -p 8080 -Ilib -a bin\app.pl
39              
40             # Edit filesystem configuration
41             cat >>fs-import.yml
42             fs:
43             directories:
44             - folder: "C:\\Users\\Corion\\Projekte\\App-StarTraders"
45             recurse: true
46             exclude:
47             - ".git"
48             - folder: "t\\documents"
49             recurse: true
50              
51             # Collect some content
52             perl -Ilib -w bin/index-filesystem.pl -f
53              
54             # Search in your browser
55              
56             =head1 CONFIGURATION
57              
58             Configuration happens through config.yml
59              
60             elastic_search:
61             home: "./elasticsearch-2.1.1/"
62             index: "dancer-searchapp"
63              
64             The Elasticsearch instance to used can also be passed in C<%ENV>
65             as C.
66              
67             =cut
68              
69             use Data::Dumper;
70             $Data::Dumper::Sortkeys = 1;
71              
72             my $config = get_defaults(
73             env => \%ENV,
74             config => config(),
75             #defaults => \%
76             names => [
77             ['elastic_search/index' => 'elastic_search/index' => 'SEARCHAPP_ES_INDEX', 'searchapp'],
78             ['elastic_search/nodes' => 'elastic_search/nodes' => 'SEARCHAPP_ES_NODES', 'localhost:9200'],
79             ],
80             );
81              
82             # A small helper subroutine that adds some API headers that result in
83             # the API not being interpretable as pages to be displayed by a browser
84             sub add_api_headers {
85             header 'Content-Disposition' => 'attachment; filename="1.txt"';
86             header 'X-Content-Type-Options' => 'nosniff';
87             };
88              
89             sub search {
90             if( ! $es ) {
91             my $nodes = $config->{elastic_search}->{nodes};
92             $nodes = [split /,/, $nodes] # our config system doesn't provide for lists...
93             unless ref $nodes;
94             $es = Search::Elasticsearch->new(
95             nodes => $nodes,
96             );
97             };
98            
99             $es
100             };
101              
102             $Template::Stash::PRIVATE = $Template::Stash::PRIVATE = 1;
103              
104             get '/' => sub {
105             # Later, separate out the code paths between
106             # search and index page only, to serve the index
107             # page as a static file
108            
109             my $statistics;
110             my $results;
111            
112             my $from = params->{'from'} || 0;
113             $from =~ s!\D!!g;
114             my $size = params->{'size'} || 25;
115             $size =~ s!\D!!g;
116             my $search_term = params->{'q'};
117            
118             if( defined $search_term) {
119            
120             #warning "Reading ES indices\n";
121             %indices = %{ search->indices->get({index => ['*']}) };
122             #warning $_ for sort keys %indices;
123              
124             my @restrict_type;
125             my $type;
126             if( $type = params->{'type'} and $type =~ m!([a-z0-9+-]+)/[a-z0-9+-]+!i) {
127             #warn "Filtering for '$type'";
128             @restrict_type = (filter => { term => { mime_type => $type }});
129             };
130            
131             my $sanitized_search_term = $search_term;
132             # Escape colons, as they're special in search queries...
133             $sanitized_search_term =~ s!([:\\])!\\$1!g;
134            
135             # Move this to an async query, later
136             my $index = $config->{elastic_search}->{index};
137             $results = search->search(
138             # Wir suchen in allen Sprachindices
139             index => [ grep { /^\Q$index\E/ } sort keys %indices ],
140             body => {
141             from => $from,
142             size => $size,
143             query => {
144             # multi_match => { ... grep for the non-autocomplete stuff, and include the boosters
145             bool => {
146             must => {
147             query_string => {
148             query => $search_term,
149             fields => ['title','folder','content', 'author'] #'creation_date']
150             },
151             },
152             @restrict_type,
153             },
154             },
155             sort => {
156             _score => { order => 'desc' },
157             },
158             "highlight" => {
159             "pre_tags" => '',
160             "post_tags" => '',
161             "fields" => {
162             # we want the whole content so we can strip it down
163             # ourselves:
164             "content" => {"number_of_fragments" => 0},
165             #"content" => {}
166             }
167             }
168             }
169             );
170            
171             #warn Dumper $results->{hits};
172             } else {
173             # Update the statistics
174             #$statistics = search->search(
175             # search_type => 'count',
176             # index => config->{index},
177             # body => {
178             # query => {
179             # match_all => {}
180             # }
181             # }
182             #);
183             #warn Dumper $statistics;
184             };
185            
186             if( $results ) {
187             for( @{ $results->{ hits }->{hits} } ) {
188             $_->{source} = Dancer::SearchApp::Entry->from_es( $_ );
189             for my $key ( qw( id index type )) {
190             $_->{$key} = $_->{"_$key"}; # thanks, Template::Toolkit
191             };
192            
193             };
194             };
195            
196             if( $results and exists params->{lucky}) {
197             my $first = $results->{ hits }->{hits}->[0];
198            
199             if( $first ) {
200             my( $index, $type, $id ) = @{$first}{qw(index type id)};
201             warn "Redirecting/reproxying first document";
202             if( $type eq 'http' ) {
203             return
204             redirect $id
205             } else {
206             my $doc = $first->{source};
207             my $local = URI::file->new( $id )->file;
208             return
209             reproxy( $doc, $local, 'Inline',
210             index => $index,
211             type => $type,
212             );
213             }
214             };
215             } else {
216              
217             if( $results and $results->{hits} and $results->{hits}->{hits} and $results->{hits}->{hits}->[0]->{highlight}) {
218             # Rework the result snippets to show only the highlighted stuff, together
219             # with the appropriate page number if available
220             for my $document (@{ $results->{hits}->{hits} }) {
221             my $html = $document->{highlight}->{content}->[0];
222             my @show = Dancer::SearchApp::HTMLSnippet->extract_highlights(
223             html => $html,
224             max_length => 300,
225             );
226              
227             # Find the PDF page numbers from Tika
228             for my $s (@show) {
229             $s->{page} = () = (substr($html,0,$s->{start}) =~ /
230             };
231              
232             $document->{highlight}->{content} =
233             [map {
234             +{ snippet => substr( $html, $_->{start}, $_->{length} ),
235             page => $_->{page},
236             }
237             } @show
238             ];
239             };
240             };
241            
242             template 'index', {
243             results => ($results ? $results->{hits} : undef ),
244             params => {
245             q => $search_term,
246             from => $from,
247             size => $size,
248             },
249             };
250             };
251             };
252              
253             # Show (cached) elements
254             get '/cache/:index/:type/:id' => sub {
255             my $index = params->{index};
256             my $type = params->{type};
257             my $id = uri_unescape( params->{id} );
258             my $document = retrieve($index,$type,$id);
259             #warn $document->basic_mime_type;
260            
261             $document->{type} = $type;
262             $document->{index} = $index;
263            
264             if( $document ) {
265             return template 'view_document', {
266             result => $document,
267             backlink => scalar( request->referer ),
268             # we should also save the page offset...
269             }
270             } else {
271             status 404;
272             return <
273             That file does (not) exist anymore in the index.
274             SORRY
275             # We could delete that item from the index here...
276             # or schedule reindexing of the resource?
277             }
278             };
279              
280             # Reproxy elements from disk
281             sub reproxy {
282             my( $document, $local, $disposition, %options ) = @_;
283            
284             # Now, if the file exists both in the index and locally, let's reproxy the content
285             if( $document and -f $local) {
286             status 200;
287             content_type( $document->mime_type );
288             header( "Content-Disposition" => sprintf '%s; filename="%s"', $disposition, basename $local);
289             my $abs = File::Spec->rel2abs( $local, '.' );
290             open my $fh, '<', $local
291             or die "Couldn't read local file '$local': $!";
292             binmode $fh;
293             local $/;
294             <$fh>
295            
296             } else {
297             status 404; # sorry
298             return <
299             That file does (not) exist anymore or is currently unreachable
300             for this webserver. We'll need to implement
301             cleaning up the index from dead items.
302             SORRY
303             # We could delete that item from the index here...
304             # Or schedule reindexing of the resource?
305             }
306             };
307              
308             sub retrieve {
309             my( $index, $type, $id ) = @_;
310             my $document;
311             if( eval {
312             $document = search->get(index => $index, type => $type, id => $id);
313             1
314             }) {
315             my $res = Dancer::SearchApp::Entry->from_es($document);
316             return $res
317             } else {
318             warn "$@";
319             };
320             # Not found in the Elasticsearch index
321             return undef
322             }
323              
324             get '/open/:index/:type/:id' => sub {
325             my $index = params->{index};
326             my $type = params->{type};
327             my $id = uri_unescape params->{id};
328             my $document = retrieve($index,$type,$id);
329             if( $type eq 'http' ) {
330             return
331             redirect $id
332             } else {
333             my $local = URI::file->new( $id )->file;
334             return
335             reproxy( $document, $local, 'Attachment',
336             index => $index,
337             type => $type,
338             );
339             }
340             };
341              
342             get '/inline/:index/:type/:id' => sub {
343             my $index = params->{index};
344             my $type = params->{type};
345             my $id = uri_unescape params->{id};
346             my $document = retrieve($index,$type,$id);
347            
348             my $local;
349             if( 'http' eq $type ) {
350             $document->content
351             } else {
352             $local = URI::file->new( $id )->file;
353             };
354            
355             reproxy( $document, $local, 'Inline',
356             index => $index,
357             type => $type,
358             );
359            
360             };
361              
362             # This is likely a really bad design choice which I will regret later.
363             # Most likely, manually encoding to JSON would be the saner approach
364             # instead of globally setting a serializer for all routes.
365             set 'serializer' => 'JSON';
366              
367             get '/suggest/:query.json' => sub {
368             my( $q ) = params->{query};
369             #warn "Completing '$q'";
370            
371             return [] unless $q and $q =~ /\S/;
372            
373             # Strip leading/trailing whitespace, Justin Case
374             $q =~ s!^\s+!!;
375             $q =~ s!\s+$!!;
376              
377             # Reinitialize indices
378             # Some day, we could cache that/not refresh them all the time
379             %indices = %{ search->indices->get({index => ['*']}) };
380              
381             my @restrict_type;
382             my $type;
383             if( $type = params->{'type'} and $type =~ m!([a-z0-9+-]+)/[a-z0-9+-]+!i) {
384             #warn "Filtering for '$type'";
385             @restrict_type = (filter => { term => { mime_type => $type }});
386             };
387            
388             # This should be centralized
389             # This is "did you mean X"
390             #my @fields = ('title','content', 'author');
391            
392             # Query all suggestive fields at once:
393             #my %suggest_query = map {;
394             # "my_suggestions_$_" => {
395             # phrase => {
396             # field => "$_.autocomplete",
397             # #field => "$_",
398             # #text => $q,
399             # }
400             # }
401             #} @fields;
402            
403             my @fields = ('title_suggest');
404            
405             # Query all suggestive fields at once:
406             my %suggest_query = map {;
407             "my_completions_$_" => {
408             phrase => {
409             field => "title_suggest",
410             #field => "$_",
411             #text => $q,
412             }
413             }
414             } @fields;
415              
416             #warn Dumper \%suggest_query;
417            
418             # Move this to an async query, later
419             my $index = $config->{elastic_search}->{index};
420             my $results = search->suggest(
421             index => [ grep { /^\Q$index\E/ } sort keys %indices ],
422             body => {
423             foo => {
424             text => $q,
425             completion => {
426             field => 'title_suggest',
427             "fuzzy" => { "fuzziness" => 2 }, # edit distance of 2
428             }
429             }
430             #%suggest_query
431             }
432             );
433            
434             #warn Dumper $results;
435            
436             my %suggestions;
437             my @res = map {; +{
438             tokens => [split //, $_->{text}],
439             value => $_->{text},
440             url => $_->{_source}->{url},
441             } }
442             sort { $b->{_score} <=> $a->{_score} || $b cmp $a } # sort by score+asciibetically descending
443             map { $_->{options} ? @{ $_->{options} } : () } # unwrap again
444             map { @$_ } # unwrap
445             grep { ref $_ eq 'ARRAY' } values %$results
446             ;
447            
448             add_api_headers;
449             return \@res;
450             };
451              
452             true;
453              
454             __END__