mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
More work on CS FTS
This commit is contained in:
parent
7fb6e0971d
commit
a784e359a2
@ -2,6 +2,9 @@
|
|||||||
# vim:fileencoding=utf-8
|
# vim:fileencoding=utf-8
|
||||||
# License: GPL v3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPL v3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from calibre.ebooks.metadata import authors_to_string
|
||||||
from calibre.srv.errors import (
|
from calibre.srv.errors import (
|
||||||
HTTPBadRequest, HTTPPreconditionRequired, HTTPUnprocessableEntity,
|
HTTPBadRequest, HTTPPreconditionRequired, HTTPUnprocessableEntity,
|
||||||
)
|
)
|
||||||
@ -22,7 +25,7 @@ def fts_search(ctx, rd):
|
|||||||
raise HTTPPreconditionRequired('Full text searching is not enabled on this library')
|
raise HTTPPreconditionRequired('Full text searching is not enabled on this library')
|
||||||
metadata_cache = {}
|
metadata_cache = {}
|
||||||
l, t = db.fts_indexing_progress()[:2]
|
l, t = db.fts_indexing_progress()[:2]
|
||||||
ans = {'metadata': metadata_cache, 'left': l, 'total': t}
|
ans = {'metadata': metadata_cache, 'indexing_status': {'left': l, 'total': t}}
|
||||||
|
|
||||||
use_stemming = rd.query.get('use_stemming', 'y') == 'y'
|
use_stemming = rd.query.get('use_stemming', 'y') == 'y'
|
||||||
query = rd.query.get('query' '')
|
query = rd.query.get('query' '')
|
||||||
@ -38,7 +41,7 @@ def fts_search(ctx, rd):
|
|||||||
bid = result['book_id']
|
bid = result['book_id']
|
||||||
if bid not in metadata_cache:
|
if bid not in metadata_cache:
|
||||||
with db.safe_read_lock:
|
with db.safe_read_lock:
|
||||||
metadata_cache[bid] = {'title': db._field_for('title', bid), 'authors': db._field_for('authors', bid)}
|
metadata_cache[bid] = {'title': db._field_for('title', bid), 'authors': authors_to_string(db._field_for('authors', bid))}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
from calibre.db import FTSQueryError
|
from calibre.db import FTSQueryError
|
||||||
@ -49,3 +52,49 @@ def fts_search(ctx, rd):
|
|||||||
except FTSQueryError as e:
|
except FTSQueryError as e:
|
||||||
raise HTTPUnprocessableEntity(str(e))
|
raise HTTPUnprocessableEntity(str(e))
|
||||||
return ans
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
@endpoint('/fts/snippets/{book_ids}', postprocess=json)
|
||||||
|
def fts_snippets(ctx, rd, book_ids):
|
||||||
|
'''
|
||||||
|
Perform the specified full text query and return the results with snippets restricted to the specified book ids.
|
||||||
|
|
||||||
|
Optional: ?query=<search query>&library_id=<default library>&use_stemming=<y or n>
|
||||||
|
&query_id=arbitrary&snippet_size=32&highlight_start=\x1c&highlight_end=\x1e
|
||||||
|
'''
|
||||||
|
db = get_library_data(ctx, rd)[0]
|
||||||
|
if not db.is_fts_enabled():
|
||||||
|
raise HTTPPreconditionRequired('Full text searching is not enabled on this library')
|
||||||
|
|
||||||
|
use_stemming = rd.query.get('use_stemming', 'y') == 'y'
|
||||||
|
query = rd.query.get('query' '')
|
||||||
|
if not query:
|
||||||
|
raise HTTPBadRequest('No search query specified')
|
||||||
|
try:
|
||||||
|
bids = frozenset(map(int, book_ids.split(',')))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPBadRequest('Invalid list of book ids')
|
||||||
|
try:
|
||||||
|
ssz = int(rd.query.get('snippet_size', 32))
|
||||||
|
except Exception:
|
||||||
|
raise HTTPBadRequest('Invalid snippet size')
|
||||||
|
snippets = {bid:{} for bid in bids}
|
||||||
|
ans = {}
|
||||||
|
qid = rd.query.get('query_id')
|
||||||
|
if qid:
|
||||||
|
ans['query_id'] = qid
|
||||||
|
from calibre.db import FTSQueryError
|
||||||
|
sanitize_pat = re.compile(r'\s+')
|
||||||
|
try:
|
||||||
|
for x in db.fts_search(
|
||||||
|
query, use_stemming=use_stemming, return_text=True,
|
||||||
|
highlight_start=rd.query.get('highlight_start', '\x1c'), highlight_end=rd.query.get('highlight_end', '\x1e'),
|
||||||
|
restrict_to_book_ids=bids, snippet_size=ssz,
|
||||||
|
):
|
||||||
|
r = snippets[x['book_id']]
|
||||||
|
q = sanitize_pat.sub('', x['text'])
|
||||||
|
r.setdefault(q, {'formats': [], 'text': x['text'],})['formats'].append(x['format'])
|
||||||
|
except FTSQueryError as e:
|
||||||
|
raise HTTPUnprocessableEntity(str(e))
|
||||||
|
ans['snippets'] = {bid: tuple(v.values()) for bid, v in snippets.items()}
|
||||||
|
return ans
|
||||||
|
@ -5,10 +5,13 @@ from __python__ import bound_methods, hash_literals
|
|||||||
from elementmaker import E
|
from elementmaker import E
|
||||||
|
|
||||||
from ajax import ajax
|
from ajax import ajax
|
||||||
from book_list.globals import get_session_data, get_current_query
|
from book_list.cover_grid import THUMBNAIL_MAX_HEIGHT, THUMBNAIL_MAX_WIDTH
|
||||||
|
from book_list.globals import get_current_query, get_session_data
|
||||||
from book_list.router import back, push_state
|
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 book_list.views import create_image
|
||||||
|
from book_list.library_data import current_library_id
|
||||||
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 _
|
||||||
@ -26,6 +29,7 @@ add_extra_css(def():
|
|||||||
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'
|
||||||
style += f'{sel} p' + ' { margin: 0}\n'
|
style += f'{sel} p' + ' { margin: 0}\n'
|
||||||
|
style += '.fts-highlight-match { font-style: italic; font-weight: bold }\n'
|
||||||
return style
|
return style
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -41,7 +45,9 @@ def showing_search_panel():
|
|||||||
def make_new_fts_query(q):
|
def make_new_fts_query(q):
|
||||||
nonlocal current_fts_query, query_id_counter
|
nonlocal current_fts_query, query_id_counter
|
||||||
query_id_counter += 1
|
query_id_counter += 1
|
||||||
current_fts_query = {'query_id': query_id_counter}
|
current_fts_query = {'query_id': query_id_counter + ''}
|
||||||
|
if current_library_id():
|
||||||
|
current_fts_query.library_id = current_library_id()
|
||||||
Object.assign(current_fts_query, q)
|
Object.assign(current_fts_query, q)
|
||||||
xhr = ajax('fts/search', on_initial_fts_fetched, query=current_fts_query, bypass_cache=True)
|
xhr = ajax('fts/search', on_initial_fts_fetched, query=current_fts_query, bypass_cache=True)
|
||||||
xhr.send()
|
xhr.send()
|
||||||
@ -59,7 +65,7 @@ def on_initial_fts_fetched(end_type, xhr, ev):
|
|||||||
results = JSON.parse(xhr.responseText)
|
results = JSON.parse(xhr.responseText)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
return error_dialog(_('Server error'), _('Failed to parse search response from server.'), 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:
|
if results.query_id + '' is not current_fts_query.query_id:
|
||||||
return
|
return
|
||||||
current_fts_query.results = results
|
current_fts_query.results = results
|
||||||
show_initial_results()
|
show_initial_results()
|
||||||
@ -167,31 +173,153 @@ def apply_search_panel_state():
|
|||||||
show_initial_results()
|
show_initial_results()
|
||||||
|
|
||||||
|
|
||||||
|
def book_result_tile(book_id, title, authors):
|
||||||
|
tile_height, img_max_width = '16ex', '12ex'
|
||||||
|
img = create_image(book_id, THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, def():pass;)
|
||||||
|
img.style.maxHeight = tile_height
|
||||||
|
img.style.maxWidth = img_max_width
|
||||||
|
tooltip = (title) + ' ' + _('by') + ' ' + (authors)
|
||||||
|
img.alt = _('Cover of') + ' ' + tooltip
|
||||||
|
return E.div(
|
||||||
|
title=tooltip,
|
||||||
|
data_book_id=book_id + '', data_snippets_needed='1',
|
||||||
|
style=f'cursor: pointer; margin-bottom: 1ex; display:flex; height: {tile_height}; max-height: {tile_height}; width: 100%; align-items: stretch',
|
||||||
|
E.div(
|
||||||
|
style=f'margin-right: 1ex; width: {img_max_width}',
|
||||||
|
img
|
||||||
|
),
|
||||||
|
|
||||||
|
E.div(
|
||||||
|
style=f'display:flex; flex-direction: column; height: 100%; overflow-y: auto',
|
||||||
|
E.div(E.span(style='font-size: small; font-style: italic; opacity: 0.5;', _('loading'), '…'), class_='snippets_container'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def on_snippets_fetched(end_type, xhr, ev):
|
||||||
|
if end_type is 'abort' or not showing_search_panel():
|
||||||
|
return
|
||||||
|
if end_type is not 'load':
|
||||||
|
return error_dialog(_('Failed to search'), _('The search failed. Click "Show details" for more information.'), xhr.error_html)
|
||||||
|
container = component('results')
|
||||||
|
if not container:
|
||||||
|
return
|
||||||
|
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
|
||||||
|
if not current_fts_query.results.snippets:
|
||||||
|
current_fts_query.results.snippets = {}
|
||||||
|
Object.assign(current_fts_query.results.snippets, results.snippets)
|
||||||
|
show_snippets(results.snippets)
|
||||||
|
fetch_snippets()
|
||||||
|
|
||||||
|
|
||||||
|
def render_text(parent, text):
|
||||||
|
in_highlighted = False
|
||||||
|
while text.length > 0:
|
||||||
|
q = '\x1e' if in_highlighted else '\x1c'
|
||||||
|
idx = text.indexOf(q)
|
||||||
|
if idx < 0:
|
||||||
|
idx = text.length
|
||||||
|
chunk = text[:idx]
|
||||||
|
text = text[idx+1:]
|
||||||
|
if in_highlighted:
|
||||||
|
parent.append(E.span(class_='fts-highlight-match', chunk))
|
||||||
|
in_highlighted = False
|
||||||
|
else:
|
||||||
|
parent.append(E.span(chunk))
|
||||||
|
in_highlighted = True
|
||||||
|
|
||||||
|
|
||||||
|
def show_snippets(snippets):
|
||||||
|
container = component('results')
|
||||||
|
for book_id in Object.keys(snippets):
|
||||||
|
c = container.querySelector(f'[data-book-id="{book_id}"]')
|
||||||
|
v'delete c.dataset.snippetsNeeded'
|
||||||
|
s = c.querySelector('.snippets_container')
|
||||||
|
clear(s)
|
||||||
|
for x in snippets[book_id]:
|
||||||
|
f = ' '.join(x.formats)
|
||||||
|
e = E.div(E.code(
|
||||||
|
style='border: solid 1px currentColor; border-radius: 6px; padding: 0 4px; font-size: smaller',
|
||||||
|
data_formats=f, f)
|
||||||
|
)
|
||||||
|
e.appendChild(E.span(' '))
|
||||||
|
render_text(e, x.text)
|
||||||
|
s.appendChild(e)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_snippets():
|
||||||
|
container = component('results')
|
||||||
|
if not container:
|
||||||
|
return
|
||||||
|
ids = v'[]'
|
||||||
|
for x in container.querySelectorAll('[data-snippets-needed="1"]'):
|
||||||
|
book_id = int(x.dataset.bookId)
|
||||||
|
ids.push(book_id)
|
||||||
|
if ids.length > 1:
|
||||||
|
break
|
||||||
|
if ids.length < 1:
|
||||||
|
return
|
||||||
|
ids = ','.join(ids)
|
||||||
|
q = {}
|
||||||
|
Object.assign(q, current_fts_query)
|
||||||
|
q.results = v'undefined'
|
||||||
|
xhr = ajax(f'fts/snippets/{ids}', on_snippets_fetched, query=q, bypass_cache=True)
|
||||||
|
xhr.send()
|
||||||
|
|
||||||
|
|
||||||
def show_initial_results():
|
def show_initial_results():
|
||||||
container = component('results')
|
container = component('results')
|
||||||
if not container:
|
if not container:
|
||||||
return
|
return
|
||||||
clear(container)
|
clear(container)
|
||||||
results = current_fts_query.results
|
results = current_fts_query.results
|
||||||
results
|
left, total = results.indexing_status['left'], results.indexing_status['total']
|
||||||
|
if left > 0:
|
||||||
|
pc = int(((total-left) / total) * 100)
|
||||||
|
container.appendChild(E.div(
|
||||||
|
style='margin-top: 0.5ex',
|
||||||
|
E.span(_('WARNING:'), style='color: red; font-weight: bold'), '\xa0',
|
||||||
|
_('Indexing of library only {}% complete, search results may be incomplete.').format(pc)
|
||||||
|
))
|
||||||
|
rc = E.div(style='margin-top: 0.5ex')
|
||||||
|
container.appendChild(rc)
|
||||||
|
mm = results.metadata
|
||||||
|
seen = {}
|
||||||
|
for r in results.results:
|
||||||
|
bid = r['book_id']
|
||||||
|
m = mm[bid]
|
||||||
|
if not seen[bid]:
|
||||||
|
rc.appendChild(book_result_tile(bid, m['title'], m['authors']))
|
||||||
|
seen[bid] = rc.lastChild
|
||||||
|
rc.appendChild(E.hr())
|
||||||
|
if results.results.length < 1:
|
||||||
|
rc.appendChild(E.div(_('No matches found')))
|
||||||
|
fetch_snippets()
|
||||||
|
|
||||||
|
|
||||||
def show_panel(visible, hidden):
|
def show_panel(visible):
|
||||||
c = component(visible)
|
c = component(visible)
|
||||||
if c:
|
if c:
|
||||||
|
x = c.parentNode.firstChild
|
||||||
|
while x:
|
||||||
|
if x.nodeType is 1 and x is not c:
|
||||||
|
x.style.display = 'none'
|
||||||
|
x = x.nextSibling
|
||||||
c.style.display = 'block'
|
c.style.display = 'block'
|
||||||
c = component(hidden)
|
|
||||||
if c:
|
|
||||||
c.style.display = 'none'
|
|
||||||
|
|
||||||
|
|
||||||
def show_search_panel():
|
def show_search_panel():
|
||||||
show_panel('search', 'index')
|
show_panel('search')
|
||||||
apply_search_panel_state()
|
apply_search_panel_state()
|
||||||
|
|
||||||
|
|
||||||
def show_index_panel():
|
def show_index_panel():
|
||||||
show_panel('index', 'search')
|
show_panel('index')
|
||||||
|
|
||||||
|
|
||||||
def init(container_id):
|
def init(container_id):
|
||||||
|
@ -203,11 +203,12 @@ def field_names_for(field, proceed):
|
|||||||
|
|
||||||
|
|
||||||
def thumbnail_url(book_id, width, height):
|
def thumbnail_url(book_id, width, height):
|
||||||
return absolute_path(
|
query = f'sz={Math.ceil(width * window.devicePixelRatio)}x{Math.ceil(height * window.devicePixelRatio)}'
|
||||||
'get/thumb/{}/{}?sz={}x{}'.format(
|
prefix = f'get/thumb/{book_id}'
|
||||||
book_id, loaded_books_query().library_id,
|
lid = loaded_books_query().library_id or current_library_id()
|
||||||
Math.ceil(width * window.devicePixelRatio), Math.ceil(height * window.devicePixelRatio)
|
if lid:
|
||||||
))
|
prefix += f'/{lid}'
|
||||||
|
return absolute_path(f'{prefix}?{query}')
|
||||||
|
|
||||||
|
|
||||||
def cover_url(book_id):
|
def cover_url(book_id):
|
||||||
@ -269,7 +270,7 @@ class ThumbnailCache:
|
|||||||
# Cache to prevent browser from issuing HTTP requests when thumbnails pages
|
# Cache to prevent browser from issuing HTTP requests when thumbnails pages
|
||||||
# are destroyed/rebuilt.
|
# are destroyed/rebuilt.
|
||||||
|
|
||||||
def __init__(self, size=250):
|
def __init__(self, size=256):
|
||||||
self.cache = LRUCache(size)
|
self.cache = LRUCache(size)
|
||||||
|
|
||||||
def get(self, book_id, width, height, callback):
|
def get(self, book_id, width, height, callback):
|
||||||
@ -281,7 +282,7 @@ class ThumbnailCache:
|
|||||||
img.onerror = self.load_finished.bind(None, item, 'error')
|
img.onerror = self.load_finished.bind(None, item, 'error')
|
||||||
img.onload = self.load_finished.bind(None, item, 'load')
|
img.onload = self.load_finished.bind(None, item, 'load')
|
||||||
img.onabort = self.load_finished.bind(None, item, 'abort')
|
img.onabort = self.load_finished.bind(None, item, 'abort')
|
||||||
img.dataset.bookId = str(book_id)
|
img.dataset.bookId = book_id + ''
|
||||||
img.src = url
|
img.src = url
|
||||||
self.cache.set(url, item)
|
self.cache.set(url, item)
|
||||||
return img
|
return img
|
||||||
|
Loading…
x
Reference in New Issue
Block a user