Commit d95b36f3 authored by Robert Knight's avatar Robert Knight

Modernize `SearchClient` code and improve docs

Make the code easier to follow and update by modernizing it to ES6+ and
linking to relevant Hypothesis API documentation.

 - Replace `self` with arrow-functions + `this`
 - Replace promise chains with async/await
 - Document the type of the `query` param to search requests
 - Add links to relevant Hypothesis API documentation
parent cd65d211
...@@ -2,6 +2,8 @@ import { TinyEmitter } from 'tiny-emitter'; ...@@ -2,6 +2,8 @@ import { TinyEmitter } from 'tiny-emitter';
/** /**
* @typedef {import('../types/api').Annotation} Annotation * @typedef {import('../types/api').Annotation} Annotation
* @typedef {import('../types/api').SearchQuery} SearchQuery
* @typedef {import('../types/api').SearchResult} SearchResult
*/ */
/** /**
...@@ -10,13 +12,15 @@ import { TinyEmitter } from 'tiny-emitter'; ...@@ -10,13 +12,15 @@ import { TinyEmitter } from 'tiny-emitter';
*/ */
/** /**
* Client for the Hypothesis search API. * Client for the Hypothesis search API [1]
* *
* SearchClient handles paging through results, canceling search etc. * SearchClient handles paging through results, canceling search etc.
*
* [1] https://h.readthedocs.io/en/latest/api-reference/#tag/annotations/paths/~1search/get
*/ */
export default class SearchClient extends TinyEmitter { export default class SearchClient extends TinyEmitter {
/** /**
* @param {Object} searchFn - Function for querying the search API * @param {(query: SearchQuery) => Promise<SearchResult>} searchFn - Function for querying the search API
* @param {Object} options * @param {Object} options
* @param {number} [options.chunkSize] - page size/number of annotations * @param {number} [options.chunkSize] - page size/number of annotations
* per batch * per batch
...@@ -62,81 +66,84 @@ export default class SearchClient extends TinyEmitter { ...@@ -62,81 +66,84 @@ export default class SearchClient extends TinyEmitter {
this._resultCount = null; this._resultCount = null;
} }
_getBatch(query, offset) { /**
const searchQuery = Object.assign( * Fetch a batch of annotations starting from `offset`.
{ *
limit: this._chunkSize, * @param {SearchQuery} query
offset: offset, * @param {number} offset
sort: this._sortBy, */
order: this._sortOrder, async _getBatch(query, offset) {
_separate_replies: this._separateReplies, const searchQuery = {
}, limit: this._chunkSize,
query offset: offset,
); sort: this._sortBy,
order: this._sortOrder,
_separate_replies: this._separateReplies,
const self = this; ...query,
this._searchFn(searchQuery) };
.then(function (results) {
if (self._canceled) {
return;
}
// For now, abort loading of annotations if `maxResults` is set and the try {
// number of annotations in the results set exceeds that value. const results = await this._searchFn(searchQuery);
// if (this._canceled) {
// NB: We can’t currently, reliably load a subset of a group’s return;
// annotations, as replies are mixed in with top-level annotations—when }
// `separateReplies` is false, which it is in most or all cases—so we’d
// end up with partially-loaded threads.
//
// This change has no effect on loading annotations in the SidebarView,
// where the `maxResults` option is not used.
//
// TODO: Implement pagination
if (self._maxResults && results.total > self._maxResults) {
self.emit(
'error',
new Error('Results size exceeds maximum allowed annotations')
);
self.emit('end');
return;
}
const chunk = results.rows.concat(results.replies || []); // For now, abort loading of annotations if `maxResults` is set and the
if (self._resultCount === null) { // number of annotations in the results set exceeds that value.
// Emit the result count (total) on first encountering it //
self._resultCount = results.total; // NB: We can’t currently, reliably load a subset of a group’s
self.emit('resultCount', self._resultCount); // annotations, as replies are mixed in with top-level annotations—when
} // `separateReplies` is false, which it is in most or all cases—so we’d
if (self._incremental) { // end up with partially-loaded threads.
self.emit('results', chunk); //
} else { // This change has no effect on loading annotations in the SidebarView,
self._results = self._results.concat(chunk); // where the `maxResults` option is not used.
} //
// TODO: Implement pagination
if (this._maxResults && results.total > this._maxResults) {
this.emit(
'error',
new Error('Results size exceeds maximum allowed annotations')
);
this.emit('end');
return;
}
// Check if there are additional pages of results to fetch. In addition to const chunk = results.rows.concat(results.replies || []);
// checking the `total` figure from the server, we also require that at if (this._resultCount === null) {
// least one result was returned in the current page, otherwise we would // Emit the result count (total) on first encountering it
// end up repeating the same query for the next page. If the server's this._resultCount = results.total;
// `total` count is incorrect for any reason, that will lead to the client this.emit('resultCount', this._resultCount);
// polling the server indefinitely. }
const nextOffset = offset + results.rows.length; if (this._incremental) {
if (results.total > nextOffset && chunk.length > 0) { this.emit('results', chunk);
self._getBatch(query, nextOffset); } else {
} else { this._results = this._results.concat(chunk);
if (!self._incremental) { }
self.emit('results', self._results);
} // Check if there are additional pages of results to fetch. In addition to
self.emit('end'); // checking the `total` figure from the server, we also require that at
} // least one result was returned in the current page, otherwise we would
}) // end up repeating the same query for the next page. If the server's
.catch(function (err) { // `total` count is incorrect for any reason, that will lead to the client
if (self._canceled) { // polling the server indefinitely.
return; const nextOffset = offset + results.rows.length;
if (results.total > nextOffset && chunk.length > 0) {
this._getBatch(query, nextOffset);
} else {
if (!this._incremental) {
this.emit('results', this._results);
} }
self.emit('error', err); this.emit('end');
self.emit('end'); }
}); } catch (err) {
if (this._canceled) {
return;
}
this.emit('error', err);
this.emit('end');
}
} }
/** /**
...@@ -148,6 +155,8 @@ export default class SearchClient extends TinyEmitter { ...@@ -148,6 +155,8 @@ export default class SearchClient extends TinyEmitter {
* *
* Emits an 'error' event if the search fails. * Emits an 'error' event if the search fails.
* Emits an 'end' event once the search completes. * Emits an 'end' event once the search completes.
*
* @param {SearchQuery} query
*/ */
get(query) { get(query) {
this._results = []; this._results = [];
......
...@@ -136,5 +136,34 @@ ...@@ -136,5 +136,34 @@
* @prop {boolean} canLeave * @prop {boolean} canLeave
*/ */
/**
* Query parameters for an `/api/search` API call.
*
* This type currently includes params that we've actually used.
*
* See https://h.readthedocs.io/en/latest/api-reference/#tag/annotations/paths/~1search/get
* for the complete list and usage of each.
*
* @typedef SearchQuery
* @prop {string[]} [uri]
* @prop {string} [group]
* @prop {string} [references]
* @prop {number} [offset]
* @prop {number} [limit]
* @prop {string} [order]
* @prop {string} [sort]
*/
/**
* Response to an `/api/search` API call.
*
* See https://h.readthedocs.io/en/latest/api-reference/#tag/annotations/paths/~1search/get
*
* @typedef SearchResult
* @prop {number} total
* @prop {Annotation[]} rows
* @prop {Annotation[]} [replies]
*/
// Make TypeScript treat this file as a module. // Make TypeScript treat this file as a module.
export const unused = {}; export const unused = {};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment