More work on the FTS search page

This commit is contained in:
Kovid Goyal 2022-12-08 21:03:56 +05:30
parent 4b873f1941
commit cd1c550cde
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
4 changed files with 195 additions and 14 deletions

View File

@ -50,6 +50,24 @@ class HTTPBadRequest(HTTPSimpleResponse):
HTTPSimpleResponse.__init__(self, http_client.BAD_REQUEST, message, close_connection) HTTPSimpleResponse.__init__(self, http_client.BAD_REQUEST, message, close_connection)
class HTTPFailedDependency(HTTPSimpleResponse):
def __init__(self, message, close_connection=False):
HTTPSimpleResponse.__init__(self, http_client.FAILED_DEPENDENCY, message, close_connection)
class HTTPPreconditionRequired(HTTPSimpleResponse):
def __init__(self, message, close_connection=False):
HTTPSimpleResponse.__init__(self, http_client.PRECONDITION_REQUIRED, message, close_connection)
class HTTPUnprocessableEntity(HTTPSimpleResponse):
def __init__(self, message, close_connection=False):
HTTPSimpleResponse.__init__(self, http_client.UNPROCESSABLE_ENTITY, message, close_connection)
class HTTPForbidden(HTTPSimpleResponse): class HTTPForbidden(HTTPSimpleResponse):
def __init__(self, http_message='', close_connection=True, log=None): def __init__(self, http_message='', close_connection=True, log=None):

51
src/calibre/srv/fts.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
from calibre.srv.errors import (
HTTPBadRequest, HTTPPreconditionRequired, HTTPUnprocessableEntity,
)
from calibre.srv.routes import endpoint, json
from calibre.srv.utils import get_library_data
@endpoint('/fts/search', postprocess=json)
def fts_search(ctx, rd):
'''
Perform the specified full text query.
Optional: ?query=<search query>&library_id=<default library>&use_stemming=<y or n>&query_id=arbitrary
'''
db = get_library_data(ctx, rd)[0]
if not db.is_fts_enabled():
raise HTTPPreconditionRequired('Full text searching is not enabled on this library')
metadata_cache = {}
l, t = db.fts_indexing_progress()[:2]
ans = {'metadata': metadata_cache, 'left': l, 'total': t}
use_stemming = rd.query.get('use_stemming', 'y') == 'y'
query = rd.query.get('query' '')
if not query:
raise HTTPBadRequest('No search query specified')
qid = rd.query.get('query_id')
if qid:
ans['query_id'] = qid
def add_metadata(result):
result.pop('id', None)
result.pop('text', None)
bid = result['book_id']
if bid not in metadata_cache:
with db.safe_read_lock:
metadata_cache[bid] = {'title': db._field_for('title', bid), 'authors': db._field_for('authors', bid)}
return result
from calibre.db import FTSQueryError
try:
ans['results'] = tuple(db.fts_search(
query, use_stemming=use_stemming, return_text=False, result_type=lambda x: x, process_each_result=add_metadata,
))
except FTSQueryError as e:
raise HTTPUnprocessableEntity(str(e))
return ans

View File

@ -182,7 +182,7 @@ class Context:
return old[1] return old[1]
SRV_MODULES = ('ajax', 'books', 'cdb', 'code', 'content', 'legacy', 'opds', 'users_api', 'convert') SRV_MODULES = ('ajax', 'books', 'cdb', 'code', 'content', 'legacy', 'opds', 'users_api', 'convert', 'fts')
class Handler: class Handler:

View File

@ -4,20 +4,24 @@ from __python__ import bound_methods, hash_literals
from elementmaker import E from elementmaker import E
from book_list.globals import get_session_data from ajax import ajax
from book_list.router import back from book_list.globals import get_session_data, get_current_query
from book_list.router import back, push_state
from book_list.top_bar import create_top_bar from book_list.top_bar import create_top_bar
from book_list.ui import set_panel_handler from book_list.ui import set_panel_handler
from complete import create_search_bar from complete import create_search_bar
from dom import add_extra_css, clear, set_css from dom import add_extra_css, clear, set_css
from gettext import gettext as _ from gettext import gettext as _
from widgets import create_button from modals import error_dialog
from widgets import create_button, create_spinner
overall_container_id = '' overall_container_id = ''
current_fts_query = {}
query_id_counter = 0
add_extra_css(def(): add_extra_css(def():
sel = '.fts-help-display ' sel = '.fts-help-display '
style = f'{sel} ' + '{ margin-left: 1em; padding-top: 0.5ex }\n' style = f'{sel} ' + '{ padding-top: 0.5ex }\n'
style += f'{sel} div' + ' { margin-top: 0.5ex }\n' style += f'{sel} div' + ' { margin-top: 0.5ex }\n'
style += f'{sel} .h' + ' { font-weight: bold; padding-bottom: 0.25ex }\n' style += f'{sel} .h' + ' { font-weight: bold; padding-bottom: 0.25ex }\n'
style += f'{sel} .bq' + ' { margin-left: 1em; margin-top: 0.5ex; margin-bottom: 0.5ex; font-style: italic }\n' style += f'{sel} .bq' + ' { margin-left: 1em; margin-top: 0.5ex; margin-bottom: 0.5ex; font-style: italic }\n'
@ -26,11 +30,53 @@ add_extra_css(def():
) )
def component(name): def component(name):
return document.getElementById(overall_container_id).querySelector(f'[data-component="{name}"]') return document.getElementById(overall_container_id)?.querySelector(f'[data-component="{name}"]')
def showing_search_panel():
c = component('search')
return bool(c and c.style.display is 'block')
def make_new_fts_query(q):
nonlocal current_fts_query, query_id_counter
query_id_counter += 1
current_fts_query = {'query_id': query_id_counter}
Object.assign(current_fts_query, q)
xhr = ajax('fts/search', on_initial_fts_fetched, query=current_fts_query, bypass_cache=True)
xhr.send()
def on_initial_fts_fetched(end_type, xhr, ev):
if end_type is 'abort' or not showing_search_panel():
return
if end_type is not 'load':
if xhr.status is 428:
# TODO: Implement FTS not enabled
pass
return error_dialog(_('Failed to search'), _('The search failed. Click "Show details" for more information.'), xhr.error_html)
try:
results = JSON.parse(xhr.responseText)
except Exception as err:
return error_dialog(_('Server error'), _('Failed to parse search response from server.'), err + '')
if results.query_id is not current_fts_query.query_id:
return
current_fts_query.results = results
show_initial_results()
def execute_search_interactive(): def execute_search_interactive():
pass nonlocal current_fts_query
query = component('query').querySelector('input').value
if not query or query.length < 1:
error_dialog(_('No search query specified'), _('A search word/phrase must be specified before attempting to search'))
return
q = get_current_query()
q.fts_panel = 'search'
q.fts_query = query
q.fts_use_stemming = 'y' if component('related_words').checked else 'n'
current_fts_query = {}
push_state(q, replace=True)
def build_search_page(): def build_search_page():
@ -40,23 +86,44 @@ def build_search_page():
search_button = create_button(_('Search'), icon='search', tooltip=_('Do the search')) search_button = create_button(_('Search'), icon='search', tooltip=_('Do the search'))
search_bar = create_search_bar(execute_search_interactive, 'search-books-fts', tooltip=_('Search for books'), placeholder=_('Enter words to search for'), button=search_button) search_bar = create_search_bar(execute_search_interactive, 'search-books-fts', tooltip=_('Search for books'), placeholder=_('Enter words to search for'), button=search_button)
set_css(search_bar, flex_grow='10', margin_right='0.5em') set_css(search_bar, flex_grow='10', margin_right='0.5em')
search_bar.dataset.component = 'query'
container.appendChild(E.div(style="display: flex; width: 100%;", search_bar, search_button)) container.appendChild(E.div(style="display: flex; width: 100%;", search_bar, search_button))
sd = get_session_data() sd = get_session_data()
related_words = E.label(E.input( related_words = E.label(E.input(
type="checkbox", data_component="related_words", checked=bool(sd.get('fts_related_words'))), type="checkbox", data_component="related_words", checked=bool(sd.get('fts_related_words'))),
onchange=def(): onchange=def():
get_session_data().set('fts_related_words', bool(component('related_words').checked)) sd = get_session_data()
rw = bool(component('related_words').checked)
if rw is not sd.get('fts_related_words'):
get_session_data().set('fts_related_words', rw)
, '' + _('Match on related words'), , '' + _('Match on related words'),
title=_( title=_(
'With this option searching for words will also match on any related words (supported in several languages). For' 'With this option searching for words will also match on any related words (supported in several languages). For'
' example, in the English language: {0} matches {1} and {2} as well').format( ' example, in the English language: {0} matches {1} and {2} as well').format(
'correction', 'correcting', 'corrected') 'correction', 'correcting', 'corrected')
) )
container.appendChild(E.div(style="text-align:left; padding-top: 1ex", related_words)) container.appendChild(E.div(style="padding-top: 1ex; border-bottom: solid 1px currentColor; width: 100%", related_words))
# Search help
container.appendChild(E.div(data_component='results'))
def show_search_help(): def clear_to_waiting_for_results(msg):
container = component('results') container = component('results')
if not container:
return
clear(container)
msg = msg or (_('Searching, please wait') + '…')
container.appendChild(E.div(
style='margin: auto; width: 100%; text-align: center; margin-top: 4ex',
create_spinner(), '\xa0' + msg
))
def clear_to_help():
container = component('results')
if not container:
return
clear(container) clear(container)
container.appendChild(E.div(class_='fts-help-display')) container.appendChild(E.div(class_='fts-help-display'))
container = container.firstChild container = container.firstChild
@ -85,19 +152,64 @@ def show_search_help():
a.classList.add('blue-link') a.classList.add('blue-link')
def apply_search_panel_state():
q = get_current_query()
ftsq = {'query': q.fts_query, 'use_stemming': q.fts_use_stemming}
component('query').querySelector('input').value = ftsq.query
component('related_words').checked = ftsq.use_stemming is 'y'
if not ftsq.query:
clear_to_help()
return
if current_fts_query.query is not ftsq.query or current_fts_query.use_stemming is not ftsq.use_stemming:
make_new_fts_query(ftsq)
clear_to_waiting_for_results(_('Searching for {}, please wait…').format(ftsq.query))
return
show_initial_results()
def show_initial_results():
container = component('results')
if not container:
return
clear(container)
results = current_fts_query.results
results
def show_panel(visible, hidden):
c = component(visible)
if c:
c.style.display = 'block'
c = component(hidden)
if c:
c.style.display = 'none'
def show_search_panel():
show_panel('search', 'index')
apply_search_panel_state()
def show_index_panel():
show_panel('index', 'search')
def init(container_id): def init(container_id):
nonlocal overall_container_id nonlocal overall_container_id
overall_container_id = container_id overall_container_id = container_id
container = document.getElementById(container_id) container = document.getElementById(container_id)
create_top_bar(container, title=_('Search text of books'), action=back, icon='close') create_top_bar(container, title=_('Search text of books'), action=back, icon='close')
container.appendChild(E.div(data_component='indexing')) container.appendChild(E.div(data_component='index'))
container.appendChild(E.div( container.appendChild(E.div(
data_component='search', data_component='search',
style="text-align:center; padding:1ex 1em; border-bottom: solid 1px currentColor; margin-top: 0.5ex; padding-bottom: 1.5ex; margin-bottom: 0.5ex" style="padding:1ex 1em; margin-top: 0.5ex;"
)) ))
container.appendChild(E.div(data_component='results'))
build_search_page() build_search_page()
show_search_help() q = get_current_query()
if not q.fts_panel or q.fts_panel is 'search':
show_search_panel()
elif q.fts_panel is 'index':
show_index_panel()
set_panel_handler('fts', init) set_panel_handler('fts', init)