PageRenderTime 106ms CodeModel.GetById 18ms app.highlight 82ms RepoModel.GetById 1ms app.codeStats 0ms

/modules/mod_search/mod_search.erl

http://github.com/zotonic/zotonic
Erlang | 648 lines | 466 code | 83 blank | 99 comment | 17 complexity | 0f66ce9f9200270e2629f3b8cb654e47 MD5 | raw file
  1%% @author Marc Worrell <marc@worrell.nl>
  2%% @copyright 2009 Marc Worrell
  3%% Date: 2009-06-09
  4%% @doc Defines PostgreSQL queries for basic content searches in Zotonic.
  5%% This module needs to be split in specific PostgreSQL queries and standard SQL queries when you want to 
  6%% support other databases (like MySQL).
  7
  8%% Copyright 2009 Marc Worrell
  9%%
 10%% Licensed under the Apache License, Version 2.0 (the "License");
 11%% you may not use this file except in compliance with the License.
 12%% You may obtain a copy of the License at
 13%% 
 14%%     http://www.apache.org/licenses/LICENSE-2.0
 15%% 
 16%% Unless required by applicable law or agreed to in writing, software
 17%% distributed under the License is distributed on an "AS IS" BASIS,
 18%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 19%% See the License for the specific language governing permissions and
 20%% limitations under the License.
 21
 22-module(mod_search).
 23-author("Marc Worrell <marc@worrell.nl>").
 24-behaviour(gen_server).
 25
 26-mod_title("Search Queries").
 27-mod_description("Defines PostgreSQL queries for basic content searches in Zotonic.").
 28-mod_prio(1000).
 29
 30%% gen_server exports
 31-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
 32-export([start_link/1]).
 33
 34%% interface functions
 35-export([
 36    observe_search_query/2,
 37    observe_module_activate/2,
 38    to_tsquery/2,
 39    rank_weight/0,
 40    rank_behaviour/1,
 41    find_by_id/2,
 42    find_by_id/3
 43]).
 44
 45-include("zotonic.hrl").
 46
 47-record(state, {context, query_watches=[]}).
 48
 49
 50observe_search_query({search_query, Req, OffsetLimit}, Context) ->
 51    search(Req, OffsetLimit, Context).
 52
 53observe_module_activate(#module_activate{module=?MODULE, pid=Pid}, _Context) ->
 54    gen_server:cast(Pid, init_query_watches);
 55observe_module_activate(_, _Context) ->
 56    ok.
 57
 58
 59%%====================================================================
 60%% API
 61%%====================================================================
 62%% @spec start_link(Args) -> {ok,Pid} | ignore | {error,Error}
 63%% @doc Starts the server
 64start_link(Args) when is_list(Args) ->
 65    gen_server:start_link(?MODULE, Args, []).
 66
 67%%====================================================================
 68%% gen_server callbacks
 69%%====================================================================
 70
 71%% @spec init(Args) -> {ok, State} |
 72%%                     {ok, State, Timeout} |
 73%%                     ignore               |
 74%%                     {stop, Reason}
 75%% @doc Initiates the server.
 76init(Args) ->
 77    process_flag(trap_exit, true),
 78    {context, Context} = proplists:lookup(context, Args),
 79    lager:md([
 80        {site, z_context:site(Context)},
 81        {module, ?MODULE}
 82      ]),
 83
 84    %% Watch for changes to resources
 85    z_notifier:observe(rsc_update_done, self(), Context),
 86    z_notifier:observe(rsc_delete, self(), Context),
 87    {ok, #state{context=z_acl:sudo(z_context:new(Context))}}.
 88
 89%% @spec handle_call(Request, From, State) -> {reply, Reply, State} |
 90%%                                      {reply, Reply, State, Timeout} |
 91%%                                      {noreply, State} |
 92%%                                      {noreply, State, Timeout} |
 93%%                                      {stop, Reason, Reply, State} |
 94%%                                      {stop, Reason, State}
 95%% @doc Trap unknown calls
 96handle_call(Message, _From, State) ->
 97    {stop, {unknown_call, Message}, State}.
 98
 99%% @spec handle_cast(Msg, State) -> {noreply, State} |
100%%                                  {noreply, State, Timeout} |
101%%                                  {stop, Reason, State}
102%% @doc Casts for updates to resources
103handle_cast({#rsc_delete{id=Id, is_a=IsA}, _Ctx}, State=#state{context=Context,query_watches=Watches}) ->
104    Watches1 = case lists:member('query', IsA) of
105                   false -> Watches;
106                   true -> search_query_notify:watches_remove(Id, Watches, Context)
107               end,
108    {noreply, State#state{query_watches=Watches1}};
109
110handle_cast(init_query_watches, State) ->
111    Watches = search_query_notify:init(State#state.context),
112    {noreply, State#state{query_watches=Watches}};
113
114handle_cast({#rsc_update_done{action=delete}, _Ctx}, State) ->
115    {noreply, State};
116handle_cast({#rsc_update_done{id=Id, pre_is_a=Cats, post_is_a=Cats}, _Ctx}, State=#state{query_watches=Watches,context=Context}) ->
117    %% Update; categories have not changed.
118    Watches1 = case lists:member('query', Cats) of
119                   false -> Watches;
120                   true -> search_query_notify:watches_update(Id, Watches, Context)
121               end,
122    %% Item updated; send notifications for matched queries.
123    search_query_notify:send_notifications(Id, search_query_notify:check_rsc(Id, Watches1, Context), Context),
124    {noreply, State#state{query_watches=Watches1}};
125
126handle_cast({#rsc_update_done{id=Id, pre_is_a=CatsOld, post_is_a=CatsNew}, _Ctx}, State=#state{query_watches=Watches,context=Context}) ->
127    %% Update; categories *have* changed.
128    Watches1 = case lists:member('query', CatsOld) of
129                   true ->
130                       case lists:member('query', CatsNew) of
131                           true ->
132                               %% It still is a query; but might have changes; update watches.
133                               search_query_notify:watches_update(Id, Watches, Context);
134                           false ->
135                               %% Its no longer a query; remove from watches.
136                               search_query_notify:watches_remove(Id, Watches, Context)
137                       end;
138                   false ->
139                       case lists:member('query', CatsNew) of
140                           true ->
141                               %% It has become a query
142                               search_query_notify:watches_update(Id, Watches, Context);
143                           false ->
144                               %% It has not been a query
145                               Watches
146                       end
147               end,
148    search_query_notify:send_notifications(Id, search_query_notify:check_rsc(Id, Watches1, Context), Context),
149    {noreply, State#state{query_watches=Watches1}};
150
151%% @doc Trap unknown casts
152handle_cast(Message, State) ->
153    {stop, {unknown_cast, Message}, State}.
154
155
156
157%% @spec handle_info(Info, State) -> {noreply, State} |
158%%                                       {noreply, State, Timeout} |
159%%                                       {stop, Reason, State}
160%% @doc Handling all non call/cast messages
161handle_info(_Info, State) ->
162    {noreply, State}.
163
164%% @spec terminate(Reason, State) -> void()
165%% @doc This function is called by a gen_server when it is about to
166%% terminate. It should be the opposite of Module:init/1 and do any necessary
167%% cleaning up. When it returns, the gen_server terminates with Reason.
168%% The return value is ignored.
169terminate(_Reason, State) ->
170    Context = State#state.context,
171    z_notifier:detach(rsc_update_done, self(), Context),
172    z_notifier:detach(rsc_delete, self(), Context),
173    ok.
174
175%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
176%% @doc Convert process state when code is changed
177
178code_change(_OldVsn, State, _Extra) ->
179    {ok, State}.
180
181
182%%====================================================================
183%% support functions
184%%====================================================================
185
186search_prevnext(Type, Args, Context) ->
187    Order = fun(next) -> "ASC"; (previous) -> "DESC" end,
188    Operator = fun(next) -> " > "; (previous) -> " < " end,
189    MapField = fun("date_start") -> "pivot_date_start";
190                  ("date_end") -> "pivot_date_end";
191                  ("title") -> "pivot_title";
192                  (X) -> X end,
193    Field = z_convert:to_list(proplists:get_value(sort, Args, publication_start)),
194    Limit = z_convert:to_integer(proplists:get_value(limit, Args, 1)),
195    {id, Id} = proplists:lookup(id, Args),
196    {cat, Cat} = proplists:lookup(cat, Args),
197    FieldValue = m_rsc:p(Id, z_convert:to_atom(Field), Context),
198    #search_sql{
199                 select="r.id",
200                 from="rsc r",
201                 where="(" ++ MapField(Field) ++ " " ++ Operator(Type) ++ " $1) and r.id <> $2",
202                 tables=[{rsc, "r"}],
203                 cats=[{"r", Cat}],
204                 args=[FieldValue, z_convert:to_integer(Id), Limit],
205                 order=MapField(Field) ++ " " ++ Order(Type) ++ ", id " ++ Order(Type),
206                 limit="limit $3"
207               }.
208
209
210%% Retrieve the previous/next id(s) (on sort field, defaults to publication date) 
211search({previous, Args}, _OffsetLimit, Context) ->
212    search_prevnext(previous, Args, Context);
213search({next, Args}, _OffsetLimit, Context) ->
214    search_prevnext(next, Args, Context);
215
216search({keyword_cloud, Props}, _OffsetLimit, Context) ->
217    Cat = proplists:get_value(cat, Props),
218    KeywordCatName = proplists:get_value(keywordcat, Props, "keyword"),
219    KeywordCat = list_to_atom(KeywordCatName),    
220    KeywordPredName = proplists:get_value(keywordpred, Props, "subject"),
221    Subject = m_predicate:name_to_id_check(KeywordPredName, Context),
222    #search_sql{
223        select="kw.id as id, count(*) as count",
224        from="rsc kw, edge e, rsc r",
225        where="kw.id = e.object_id AND e.predicate_id = $1 AND e.subject_id = r.id",
226        tables=[{rsc, "kw"}, {edge, "e"}, {rsc, "r"}],
227        cats=[{"kw", KeywordCat}, {"r", Cat}],
228        args=[Subject],
229        group_by="kw.id, kw.pivot_title",
230        order="kw.pivot_title"
231       };
232
233search({archive_year, [{cat,Cat}]}, OffsetLimit, Context) ->
234    Q = #search_sql{
235      select="date_part('year', r.publication_start)::int as year, count(*) as count",
236      from="rsc r",
237      tables=[{rsc, "r"}],
238      assoc=true,
239      cats=[{"r", Cat}],
240      group_by="date_part('year', r.publication_start)",
241      order="year desc"
242     },
243    R = z_search:search_result(Q, OffsetLimit, Context),
244    Result = [ [{as_date, {{z_convert:to_integer(Y),1,1},{0,0,0}}}|Rest]
245               || Rest = [{year, Y}, {count, _}] <- R#search_result.result],
246    #search_result{result=Result};
247
248search({archive_year_month, [{cat,Cat}]}, OffsetLimit, Context) ->
249    Q = #search_sql{
250      select="date_part('year', r.publication_start)::int as year, date_part('month', r.publication_start)::int as month, count(*) as count",
251      from="rsc r",
252      tables=[{rsc, "r"}],
253      assoc=true,
254      cats=[{"r", Cat}],
255      group_by="date_part('year', r.publication_start), date_part('month', r.publication_start)",
256      order="year desc, month desc"
257     },
258    R = z_search:search_result(Q, OffsetLimit, Context),
259    Result = [ [{month_as_date, {{z_convert:to_integer(Y),z_convert:to_integer(M),1},{0,0,0}}}|Rest]
260               || Rest = [{year, Y}, {month, M}, {count, _}] <- R#search_result.result],
261    #search_result{result=z_utils:group_proplists(year, Result)};
262
263
264%% @doc Return the rsc records that have similar objects
265search({match_objects, [{id,Id}]}, _OffsetLimit, Context) ->
266	ObjectIds = m_edge:objects(Id, Context),
267	MatchTerms = [ ["zpo",integer_to_list(ObjId)] || ObjId <- ObjectIds ],
268	TsQuery = lists:flatten(z_utils:combine("|", MatchTerms)),
269	case TsQuery of
270		[] ->
271			#search_result{};
272		_ ->
273		    #search_sql{
274		        select="r.id, ts_rank(pivot_rtsv, query) AS rank",
275		        from="rsc r, to_tsquery($1) query",
276		        where=" query @@ pivot_rtsv and id <> $2",
277		        order="rank desc",
278		        args=[TsQuery, z_convert:to_integer(Id)],
279		        tables=[{rsc,"r"}]
280		    }
281	end;
282search({match_objects, [{cat,Cat},{id,Id}]}, OffsetLimit, Context) ->
283	case search({match_objects, [{id,Id}]}, OffsetLimit, Context) of
284		#search_sql{} = Search -> Search#search_sql{cats=[{"r", Cat}]};
285		Result -> Result
286	end;
287
288%% @doc Return the rsc records that have similar objects
289search({match_objects_cats, [{id,Id}]}, _OffsetLimit, Context) ->
290	IsCats = m_rsc:is_a_id(Id, Context),
291	CatTerms = [ ["zpc",integer_to_list(CatId)] || CatId <- IsCats ],
292	ObjectIds = m_edge:objects(Id, Context),
293	ObjectTerms = [ ["zpo",integer_to_list(ObjId)] || ObjId <- ObjectIds ],
294	TsQuery = lists:flatten(z_utils:combine("|", CatTerms++ObjectTerms)),
295	case TsQuery of
296		[] ->
297			#search_result{};
298		_ ->
299		    #search_sql{
300		        select="r.id, ts_rank(pivot_rtsv, query) AS rank",
301		        from="rsc r, to_tsquery($1) query",
302		        where=" query @@ pivot_rtsv and id <> $2",
303		        order="rank desc",
304		        args=[TsQuery, z_convert:to_integer(Id)],
305		        tables=[{rsc,"r"}]
306		    }
307	end;
308search({match_objects_cats, [{cat,Cat},{id,Id}]}, OffsetLimit, Context) ->
309	case search({match_objects_cats, [{id,Id}]}, OffsetLimit, Context) of
310		#search_sql{} = Search -> Search#search_sql{cats=[{"r", Cat}]};
311		Result -> Result
312	end;
313
314%% @doc Return a list of resource ids, featured ones first
315%% @spec search(SearchSpec, Range, Context) -> #search_sql{}
316search({featured, []}, OffsetLimit, Context) -> 
317   search({'query', [{sort, "-rsc.is_featured"}, {sort, "-rsc.publication_start"}]}, OffsetLimit, Context);
318
319%% @doc Return a list of resource ids inside a category, featured ones first
320%% @spec search(SearchSpec, Range, Context) -> IdList | {error, Reason}
321search({featured, [{cat, Cat}]}, OffsetLimit, Context) ->
322    search({'query', [{cat, Cat}, {sort, "-rsc.is_featured"}, {sort, "-rsc.publication_start"}]}, OffsetLimit, Context);
323
324%% @doc Return the list of resource ids, on descending id
325%% @spec search(SearchSpec, Range, Context) -> IdList | {error, Reason}
326search({all, []}, OffsetLimit, Context) ->
327    search({'query', []}, OffsetLimit, Context);
328
329%% @doc Return the list of resource ids inside a category, on descending id
330%% @spec search(SearchSpec, Range, Context) -> IdList | {error, Reason}
331search({all, [{cat, Cat}]}, OffsetLimit, Context) ->
332    search({'query', [{cat, Cat}]}, OffsetLimit, Context);
333
334%% @doc Return a list of featured resource ids inside a category having a object_id as predicate
335%% @spec search(SearchSpec, Range, Context) -> IdList | {error, Reason}
336search({featured, [{cat,Cat},{object,ObjectId},{predicate,Predicate}]}, OffsetLimit, Context) ->
337    search({'query', [{cat, Cat}, {hassubject, [ObjectId, Predicate]}]}, OffsetLimit, Context);
338
339search({published, []}, OffsetLimit, Context) ->
340    search({'query', [{sort, "-rsc.publication_start"}]}, OffsetLimit, Context);
341
342search({published, [{cat, Cat}]}, OffsetLimit, Context) ->
343    search({'query', [{cat, Cat}, {sort, "-rsc.publication_start"}]}, OffsetLimit, Context);
344
345search({latest, []}, OffsetLimit, Context) ->
346    search({'query', [{sort, "-rsc.modified"}]}, OffsetLimit, Context);
347
348search({latest, [{cat, Cat}]}, OffsetLimit, Context) ->
349    search({'query', [{cat, Cat}, {sort, "-rsc.modified"}]}, OffsetLimit, Context);
350
351search({latest, [{creator_id,CreatorId}]}, _OffsetLimit, _Context) ->
352    #search_sql{
353        select="r.id",
354        from="rsc r",
355        where="r.creator_id = $1",
356        order="r.modified desc",
357        args=[z_convert:to_integer(CreatorId)],
358        tables=[{rsc,"r"}]
359    };
360
361search({latest, [{cat, Cat}, {creator_id,CreatorId}]}, _OffsetLimit, _Context) ->
362    #search_sql{
363        select="r.id",
364        from="rsc r",
365        where="r.creator_id = $1",
366        order="r.modified desc",
367        args=[z_convert:to_integer(CreatorId)],
368        cats=[{"r", Cat}],
369        tables=[{rsc,"r"}]
370    };
371
372search({upcoming, [{cat, Cat}]}, OffsetLimit, Context) ->
373    search({'query', [{upcoming, true}, {cat, Cat}, {sort, "rsc.pivot_date_start"}]}, OffsetLimit, Context);
374
375search({finished, [{cat, Cat}]}, OffsetLimit, Context) ->
376    search({'query', [{finished, true}, {cat, Cat}, {sort, '-rsc.pivot_date_start'}]}, OffsetLimit, Context);
377
378search({autocomplete, [{text,QueryText}]}, OffsetLimit, Context) ->
379    search({autocomplete, [{cat,[]}, {text,QueryText}]}, OffsetLimit, Context);
380search({autocomplete, [{cat,Cat}, {text,QueryText}]}, _OffsetLimit, Context) ->
381    case z_string:trim(QueryText) of
382        "id:" ++ S ->
383            find_by_id(S, true, Context);
384        _ ->
385            TsQuery = to_tsquery(QueryText, Context),
386            case TsQuery of
387                A when A == undefined orelse A == [] ->
388                    #search_result{};
389                _ ->
390                    #search_sql{
391                        select="r.id, ts_rank_cd("++rank_weight()++", pivot_tsv, $1, $2) AS rank",
392                        from="rsc r",
393                        where=" $1 @@ r.pivot_tsv",
394                        order="rank desc",
395                        args=[TsQuery, rank_behaviour(Context)],
396                        cats=[{"r", Cat}],
397                        tables=[{rsc,"r"}]
398                    }
399            end
400    end;
401
402search({fulltext, [{cat,Cat},{text,QueryText}]}, OffsetLimit, Context) when Cat == undefined orelse Cat == [] orelse Cat == <<>> ->
403    search({fulltext, [{text,QueryText}]}, OffsetLimit, Context);
404
405search({fulltext, [{text,QueryText}]}, _OffsetLimit, Context) ->
406    case z_string:trim(QueryText) of
407        A when A == undefined orelse A == "" orelse A == <<>> ->
408            #search_sql{
409                select="r.id, 1 AS rank",
410                from="rsc r",
411                order="r.modified desc",
412                args=[],
413                tables=[{rsc,"r"}]
414            };
415        "id:" ++ S ->
416            find_by_id(S, true, Context);
417        _ ->
418            TsQuery = to_tsquery(QueryText, Context),
419            #search_sql{
420                select="r.id, ts_rank_cd("++rank_weight()++", pivot_tsv, $1, $2) AS rank",
421                from="rsc r",
422                where=" $1 @@ r.pivot_tsv",
423                order="rank desc",
424                args=[TsQuery, rank_behaviour(Context)],
425                tables=[{rsc,"r"}]
426            }
427    end;
428
429search({fulltext, [{cat,Cat},{text,QueryText}]}, _OffsetLimit, Context) ->
430    case z_string:trim(QueryText) of
431        A when A == undefined orelse A == "" orelse A == <<>> ->
432            #search_sql{
433                select="r.id, 1 AS rank",
434                from="rsc r",
435                order="r.modified desc",
436                cats=[{"r", Cat}],
437                tables=[{rsc,"r"}]
438            };
439        "id:" ++ S ->
440            find_by_id(S, true, Context);
441        _ ->
442            TsQuery = to_tsquery(QueryText, Context),
443            #search_sql{
444                select="r.id, ts_rank_cd("++rank_weight()++", pivot_tsv, $1, $2) AS rank",
445                from="rsc r",
446                where=" $1 @@ pivot_tsv",
447                order="rank desc",
448                args=[TsQuery, rank_behaviour(Context)],
449                cats=[{"r", Cat}],
450                tables=[{rsc,"r"}]
451            }
452    end;
453
454search({referrers, [{id,Id}]}, _OffsetLimit, _Context) ->
455    #search_sql{
456        select="o.id, e.predicate_id",
457        from="edge e join rsc o on o.id = e.subject_id",
458        where="e.object_id = $1",
459        order="e.id desc",
460        args=[z_convert:to_integer(Id)],
461        tables=[{rsc,"o"}]
462    };
463
464search({media_category_image, [{cat,Cat}]}, _OffsetLimit, _Context) ->
465    #search_sql{
466        select="m.filename",
467        from="rsc r, medium m",
468        where="m.id = r.id",
469        cats=[{"r", Cat}],
470        tables=[{rsc,"r"}, {medium, "m"}]
471    };
472
473search({media_category_depiction, [{cat,Cat}]}, _OffsetLimit, Context) ->
474    PredDepictionId = m_predicate:name_to_id_check(depiction, Context),
475    #search_sql{
476        select="m.filename",
477        from="rsc r, rsc ro, medium m, edge e",
478        where="ro.id = e.object_id and e.subject_id = r.id and e.predicate_id = $1 and ro.id = m.id",
479        tables=[{rsc,"r"}, {rsc, "ro"}, {medium, "m"}],
480        args=[PredDepictionId],
481        cats=[{"r", Cat}]
482    };
483
484
485search({media, []}, _OffsetLimit, _Context) ->
486    #search_sql{
487        select="m.*",
488        from="media m",
489        tables=[{medium, "m"}],
490        order="m.created desc",
491        args=[],
492        assoc=true
493    };
494    
495search({all_bytitle, [{cat, Cat}]}, _OffsetLimit, Context) ->
496    search_all_bytitle:search(Cat, all_bytitle, Context);
497
498search({all_bytitle_featured, [{cat, Cat}]}, _OffsetLimit, Context) ->
499    search_all_bytitle:search(Cat, all_bytitle_featured, Context);
500
501search({all_bytitle, [{cat_is, Cat}]}, _OffsetLimit, Context) ->
502    search_all_bytitle:search_cat_is(Cat, all_bytitle, Context);
503
504search({all_bytitle_featured, [{cat_is, Cat}]}, _OffsetLimit, Context) ->
505    search_all_bytitle:search_cat_is(Cat, all_bytitle_featured, Context);
506
507search({'query', Args}, _OffsetLimit, Context) ->
508    search_query:search(Args, Context);
509
510search({events, [{cat, Cat}, {'end', End}, {start, Start}]}, _OffsetLimit, _Context) ->
511    #search_sql{
512		select="r.id, r.pivot_date_start, r.pivot_date_end",
513        from="rsc r",
514        where="r.pivot_date_end >= $1 AND r.pivot_date_start <= $2",
515        args =[Start, End],
516        order="r.pivot_date_start asc",
517        cats=[{"r", Cat}],
518        tables=[{rsc,"r"}]
519    };
520
521search({events, [{'end', End}, {start, Start}]}, OffsetLimit, Context) ->
522    search({events, [{cat, event}, {'end', End}, {start, Start}]}, OffsetLimit, Context);
523
524search(_, _, _) ->
525    undefined.
526
527
528
529%% @doc Expand a search string like "hello wor" to a PostgreSQL tsquery string.
530%%      If the search string ends in a word character then a wildcard is appended
531%%      to the last search term.
532-spec to_tsquery(binary()|string(), #context{}) -> binary().
533to_tsquery(undefined, _Context) ->
534    <<>>;
535to_tsquery(Text, Context) when is_list(Text) ->
536    to_tsquery(z_convert:to_binary(Text), Context);
537to_tsquery(<<>>, _Context) ->
538    <<>>;
539to_tsquery(Text, Context) when is_binary(Text) ->
540    case to_tsquery_1(Text, Context) of
541        <<>> ->
542            % Check if the wildcard prefix was a stopword like the dutch "de"
543            case is_separator(binary:last(Text)) of
544                true ->
545                    <<>>;
546                false ->
547                    Text1 = <<(z_convert:to_binary(Text))/binary, "xcvvcx">>,
548                    TsQuery = to_tsquery_1(Text1, Context),
549                    binary:replace(TsQuery, <<"xcvvcx">>, <<>>)
550            end;
551        TsQuery ->
552            TsQuery
553    end.
554
555to_tsquery_1(Text, Context) when is_binary(Text) ->
556    Stemmer = z_pivot_rsc:stemmer_language(Context),
557    [{TsQuery, Version}] = z_db:q("select plainto_tsquery($2, $1), version()",
558                                  [z_pivot_rsc:cleanup_tsv_text(Text), Stemmer], 
559                                  Context),
560    % Version is something like "PostgreSQL 8.3.5 on i386-apple-darwin8.11.1, compiled by ..."
561    fixup_tsquery(z_convert:to_list(Stemmer), append_wildcard(Text, TsQuery, Version)).
562
563is_separator(C) when C < $0 -> true;
564is_separator(C) when C >= $0, C =< $9 -> false;
565is_separator(C) when C >= $A, C =< $Z -> false;
566is_separator(C) when C >= $a, C =< $z -> false;
567is_separator(C) when C >= 128 -> false;
568is_separator(_) -> true.
569
570append_wildcard(_Text, <<>>, _Version) ->
571    <<>>;
572append_wildcard(_Text, TsQ, Version) when Version < <<"PostgreSQL 8.4">> ->
573    TsQ;
574append_wildcard(Text, TsQ, _Version) ->
575    case is_wordchar(z_string:last_char(Text)) of
576        true -> <<TsQ/binary, ":*">>;
577        false -> TsQ
578    end.
579
580is_wordchar(C) when C >= 0, C =< 9 -> true;
581is_wordchar(C) when C >= $a, C =< $z -> true;
582is_wordchar(C) when C >= $A, C =< $Z -> true;
583is_wordchar(C) when C > 255 -> true;
584is_wordchar(_) -> false.
585
586% There are some problems with the stemming of prefixes.
587% For now we fix this up by removing the one case we found.
588%
589% to_tsquery('dutch', 'overstee') -> 'overstee'
590% to_tsquery('dutch', 'oversteek') -> 'overstek'
591fixup_tsquery(_Stemmer, <<>>) ->
592    <<>>;
593fixup_tsquery("dutch", TsQ) ->
594    iolist_to_binary(re:replace(TsQ, <<"([a-z]([aieou]))\\2':\\*">>, <<"\\1':\\*">>));
595fixup_tsquery(_Stemmer, TsQ) ->
596    TsQ.
597
598
599%% @doc Find one more more resources by id or name, when the resources exists.
600%% Input may be a single token or a comma-separated string.
601%% Search results contain a list of ids.
602-spec find_by_id(string(), #context{}) -> #search_result{}.
603find_by_id(S, Context) ->
604    find_by_id(S, false, Context).
605
606%% @doc As find_by_id/2, but when Rank is true, results contain a list of tuples: {id, 1}.
607-spec find_by_id(string(), boolean(), #context{}) -> #search_result{}.
608find_by_id(S, Rank, Context) ->
609    Ids = lists:foldl(fun(Id, Acc) ->
610        case m_rsc:exists(Id, Context) of
611            false -> Acc;
612            true -> [m_rsc:rid(Id, Context)|Acc]
613        end
614    end, [], string:tokens(S, ", ")),
615    Ids1 = lists:sort(sets:to_list(sets:from_list(Ids))),
616    Ids2 = case Rank of 
617        false -> Ids1;
618        true ->
619            lists:map(fun(Id) ->
620                {Id, 1}
621            end, Ids1)
622    end,
623    case length(Ids2) of
624        0 ->
625            #search_result{};
626        L ->
627            #search_result{
628                result=Ids2,
629                total=L
630            }
631    end.
632
633
634%% @doc The ranking behaviour for scoring words in a full text search
635%% See also: http://www.postgresql.org/docs/9.3/static/textsearch-controls.html
636-spec rank_behaviour(#context{}) -> integer().
637rank_behaviour(Context) ->
638    case m_config:get_value(mod_search, rank_behaviour, Context) of
639        Empty when Empty =:= undefined; Empty =:= <<>> -> 1 bor 4 bor 32;
640        Rank -> z_convert:to_integer(Rank)
641    end.
642
643%% @doc The weights for the ranking of the ABCD indexing categories.
644%% See also: http://www.postgresql.org/docs/9.3/static/textsearch-controls.html
645-spec rank_weight() -> string().
646rank_weight() ->
647    "'{0.05, 0.25, 0.5, 1.0}'".
648