import EventEmitter from 'event-emitter';
import { search } from './search';
import { assign } from './utils';
/**
* @module opensearch/paginator
*/
/**
* Event emitter to track the progress of paged searches.
*
* @fires PagedSearchProgressEmitter#page
* @fires PagedSearchProgressEmitter#success
* @fires PagedSearchProgressEmitter#error
*/
class PagedSearchProgressEmitter extends EventEmitter {
}
/**
* Search Progress Event
*
* @event module:opensearch/paginator~PagedSearchProgressEmitter#page
* @type {SearchResult}
*/
/**
* Search Success Event
*
* @event module:opensearch/paginator~PagedSearchProgressEmitter#success
* @type {SearchResult}
*/
/**
* Search Error Event
*
* @event module:opensearch/paginator~PagedSearchProgressEmitter#error
* @type {Error}
*/
export { PagedSearchProgressEmitter };
function isCancellable(promise) {
return promise && typeof promise.cancel === 'function' && !promise.isCancelled();
}
function combinePages(pages) {
const firstPage = pages[0];
const records = pages.reduce((rs, page) => rs.concat(page.records), []);
return {
totalResults: firstPage.totalResults,
startIndex: firstPage.startIndex,
itemsPerPage: firstPage.itemsPerPage,
records,
};
}
/**
* Class to help with paginated results of an OpenSearch service.
*/
export class OpenSearchPaginator {
/**
* @param {OpenSearchUrl} url The URL to perform all subsequent requests on.
* @param {object} parameters Search parameters.
* @param {object} [options] Additional options for the pagination
* @param {boolean} [options.useCache=true] Whether response pages shall be cached.
* @param {int} [options.preferredItemsPerPage=undefined] The preferred page size. This
* defaults to the advertised
* default of the URL.
* @param {boolean} [options.preferStartIndex=true] Whether the paging shall be done
* using the `startIndex` parameter
* (the default) or the `startPage`.
* @param {int} [options.baseOffset=0] The base index offset to apply. This option
* is useful when resuming a consecutive search.
* @param {int} [options.maxUrlLength=undefined] The maximum URL length. Forwarded to
* [search]{@link module:opensearch/search.search}.
*/
constructor(url, parameters, { useCache = true,
preferredItemsPerPage = undefined,
preferStartIndex = true,
baseOffset = 0,
maxUrlLength = undefined } = {}) {
this._url = url;
this._parameters = parameters;
this._cache = useCache ? {} : null;
this._preferredItemsPerPage = preferredItemsPerPage;
this._preferStartIndex = preferStartIndex;
this._baseOffset = baseOffset;
this._maxUrlLength = maxUrlLength;
this._serverItemsPerPage = undefined;
this._totalResults = undefined;
}
/**
* Fetch a single page of the result set. Sets the server side items per page,
* when the result is available.
* @param {int} [pageIndex=0] The index of the page to be fetched.
* @param {int} [maxCount=undefined] The maximum count of objects to be retrieved.
* @returns {Promise<SearchResult>} The search result.
* @fulfill {module:opensearch/formats~SearchResult} The search result
*/
fetchPage(pageIndex = 0, maxCount = undefined) {
// TODO: implement caching of whole pages
// if (this._cache && this._cache[pageIndex]) {
// return this._cache[pageIndex];
// }
const parameters = assign({}, this._parameters);
const pageSize = this.getActualPageSize();
if (pageSize && maxCount) {
parameters.count = Math.min(maxCount, pageSize);
} else if (pageSize) {
parameters.count = pageSize;
} else if (maxCount) {
parameters.count = maxCount;
}
if (this._preferStartIndex) {
if (typeof pageSize === 'undefined') {
parameters.startIndex = this._baseOffset + this._url.indexOffset;
} else {
parameters.startIndex = this._baseOffset + (pageSize * pageIndex) + this._url.indexOffset;
}
} else {
parameters.startPage = pageIndex + this._url.pageOffset;
}
return search(this._url, parameters, null, false, this._maxUrlLength)
.then((result) => {
this._totalResults = result.totalResults;
if (!this._serverItemsPerPage && result.itemsPerPage) {
this._serverItemsPerPage = result.itemsPerPage;
}
return result;
});
}
/**
* Fetches all pages from the URL. A probing request is sent to determine how
* many succeeding requests have to be sent.
* @returns {Promise<SearchResult[]>} The async result of all the pages in the
* search.
* @fulfill {module:opensearch/formats~SearchResult[]} The search result pages
*/
fetchAllPages() {
return this.fetchPage()
.then((firstPage) => {
const pageCount = this.getPageCount();
const requests = [firstPage];
for (let i = 1; i < pageCount; ++i) {
requests.push(this.fetchPage(i));
}
return Promise.all(requests);
});
}
/**
* Convenience method to get the records of all pages in a single result array
* @returns {Promise<SearchResult>} The records of all the pages in the search.
* @fulfill {module:opensearch/formats~SearchResult} The search result
*/
fetchAllRecords() {
return this.fetchAllPages()
.then((pages) => {
const firstPage = pages[0];
const records = pages.reduce((rs, page) => rs.concat(page.records), []);
return {
totalResults: firstPage.totalResults,
startIndex: firstPage.startIndex,
itemsPerPage: firstPage.itemsPerPage,
records,
};
});
}
/**
* Fetches the first X records of a search in a single search result.
* @param {int} maxCount The maximum number of records to fetch.
* @returns {Promise<SearchResult>} The resulting records as a promise.
* @fulfill {module:opensearch/formats~SearchResult} The search result
*/
fetchFirstRecords(maxCount) {
// Get the first page
return this.fetchPage(0, maxCount)
.then((firstPage) => {
// check if all records fit in the first page (then return this page)
if (firstPage.totalResults <= firstPage.itemsPerPage) {
// return if we already have all records
return firstPage;
}
// fetch other pages until we have the required count
const requests = [firstPage];
const usedMaxCount =
Math.min(
maxCount,
(firstPage.totalResults - firstPage.startIndex) + this._url.indexOffset
);
// determine the number of pages and issue a request for each
const numPages = Math.ceil(usedMaxCount / firstPage.itemsPerPage);
for (let i = 1; i < numPages; ++i) {
let count = firstPage.itemsPerPage;
if (firstPage.itemsPerPage * (i + 1) > usedMaxCount) {
count = usedMaxCount - (firstPage.itemsPerPage * i);
}
requests.push(this.fetchPage(i, count));
}
return Promise.all(requests)
.then(pages => combinePages(pages));
});
}
/**
* Fetches the first X records of a search in a single search result.
* Use this method when the progressive results are wished and not just a
* final result.
* @param {int} maxCount The maximum number of records to fetch.
* @param {boolean} preserveOrder Whether the results must be returned in the
* order received from the server, or the
* originally requested order.
* @returns {module:opensearch/paginator~PagedSearchProgressEmitter} The resulting
* records as a promise.
*/
searchFirstRecords(maxCount = undefined, preserveOrder = true) {
// Get the first page
const emitter = new PagedSearchProgressEmitter();
// start requesting the first page
const request = this.fetchPage(0, maxCount);
let requests = [request];
// cancel requests when issued a cancel event
emitter.on('cancel', () => {
requests.forEach((req) => {
if (isCancellable(req)) {
req.cancel();
}
});
});
let hasError = false;
const onError = (error) => {
hasError = true;
emitter.emit('error', error);
return error;
};
request
.catch(onError)
.then((firstPage) => {
if (hasError) {
throw firstPage;
}
// save the first page as a resolved promise (for later use when
// collecting results in a uniform fashion)
const newRequests = [Promise.resolve(firstPage)];
const usedMaxCount = maxCount
? Math.min(
maxCount,
(firstPage.totalResults - firstPage.startIndex) + this._url.indexOffset
) : firstPage.totalResults;
// determine the number of pages and issue a request for each
const numPages = Math.ceil(usedMaxCount / firstPage.itemsPerPage);
for (let i = 1; i < numPages; ++i) {
let count = firstPage.itemsPerPage;
if (firstPage.itemsPerPage * (i + 1) > usedMaxCount) {
count = usedMaxCount - (firstPage.itemsPerPage * i);
}
newRequests.push(this.fetchPage(i, count));
}
// save the requests in the global variable to allow cancellation/result collection
requests = newRequests;
const pages = Array(requests.length);
if (preserveOrder) {
// when the order of the the responses is important, the algorithm is
// more complex
let index = 0;
const allRequests = Array.from(requests);
const onPage = (page) => {
if (hasError) {
return;
}
pages[index] = page;
index += 1;
emitter.emit('page', page);
const promise = allRequests.shift();
if (promise) {
promise.then(onPage, onError);
} else {
emitter.emit('success', combinePages(pages)); // TODO:
}
};
allRequests.shift().then(onPage, onError);
} else {
let successCount = 0;
requests.forEach((req, index) => {
req.then((page) => {
if (hasError) {
return;
}
successCount += 1;
pages[index] = page;
if (successCount === requests.length) {
emitter.emit('success', combinePages(pages)); // TODO
}
}, onError);
});
}
});
return emitter;
}
/**
* Returns the actual page size.
* @returns {int} The computed page size.
*/
getActualPageSize() {
if (this._preferredItemsPerPage && this._serverItemsPerPage) {
return Math.min(this._preferredItemsPerPage, this._serverItemsPerPage);
} else if (this._serverItemsPerPage) {
return this._serverItemsPerPage;
} else if (this._preferredItemsPerPage) {
return this._preferredItemsPerPage;
}
const countParam = this._url.getParameter('count');
if (countParam) {
if (typeof countParam.maxExclusive !== 'undefined') {
return countParam.maxExclusive - 1;
} else if (countParam.maxInclusive) {
return countParam.maxInclusive;
}
}
return undefined;
}
/**
* Returns the computed number of pages, which is available once the first page
* was received.
* @returns {int} The number of pages.
*/
getPageCount() {
const pageSize = this.getActualPageSize();
if (!this._totalResults) {
return this._totalResults;
} else if (!pageSize) {
return undefined;
}
return Math.ceil(this._totalResults / pageSize);
}
}