diff --git a/pywb/static/query.js b/pywb/static/query.js new file mode 100644 index 00000000..cae02cae --- /dev/null +++ b/pywb/static/query.js @@ -0,0 +1,1086 @@ +var colon = ':'; +var usingWorkerStrat = + typeof window.Worker === 'function' && + typeof window.fetch === 'function' && + typeof window.TextDecoder === 'function' && + typeof window.ReadableStream === 'function'; + +var first = 1; + +/** + * Class responsible for rendering the results of the query. If the query is considered regular + * (no filter or match type query modifiers) the calender view is rendered otherwise the advanced search is rendered + * @param {Object} init - Initialization info for rendering the results of the query + */ +function RenderCalendar(init) { + if (!(this instanceof RenderCalendar)) return new RenderCalendar(init); + this.prefix = init.prefix; + this.cdxURL = this.prefix + 'cdx'; + this.staticPrefix = init.staticPrefix; + this.queryInfo = { + calView: true, + complete: false, + cdxQueryURL: null, + url: null, + hasFilter: false, + searchParams: { + present: false, + includeInURL: false + } + }; + // references to the DOM structure elements that contain the to be rendered markup + this.containers = { + numCaptures: null, + updatesSpinner: null, + yearsListDiv: null, + monthsListDiv: null, + daysListDiv: null, + advancedResultsList: null, + countTextNode: null, + versionsTextNode: null + }; + // ids of the to be created dom elements that are important to rendering the query results + this.domStructureIds = { + queryInfoId: 'display-query-type-info', + capturesMount: 'captures', + numCaptures: 'num-captures', + updatesSpinner: 'still-updating-spinner' + }; + // memoized last active year month day tab information + this.lastActiveYMD = {}; + this.lastActiveDaysTab = null; + // regular expressing for checking the URL when no query params are present + this.starQueries = { + regularStr: '/*/', + dtFromTo: /\/([^-]+)-([^/]+)\/(.+)/i, + dtFrom: /\/([^/]+)\/(.+)/i + }; + // regex for extracting the filter constraints and filter mods to human explanation + this.filterRE = /filter([^a-z]+)([a-z]+):(.+)/i; + this.filterMods = { + '=': 'Contains', + '==': 'Matches Exactly', + '=~': 'Matches Regex', + '=!': 'Does Not Contains', + '=!=': 'Is Not', + '=!~': 'Does Not Begins With' + }; + this.text = init.text; + this.versionString = null; +} + +/** + * Initializes the rendering process and checks the our locations URL to determine which + * result view to render (calendar or advanced) + * @return {void} + */ +RenderCalendar.prototype.init = function() { + // strip the prefix from our locations URL leaving us with /dt-info/url or /*?... + var queryPart = location.href.substring(this.prefix.length - 1); + + // check for from the search interface + if (queryPart.indexOf('/*?url=') === 0) { + // the query info came from the collections query interface and since we are + // mimicking the cdx api here we need to parse our locations URL using the + // WHATWG URL Standard since location.search is un-helpful here + var searchURL = new URL(location.href); + this.queryInfo.url = searchURL.searchParams.get('url'); + var haveMatchType = searchURL.searchParams.has('matchType'); + var haveFilter = searchURL.searchParams.has('filter'); + if (!haveMatchType) { + // be extra sure the user did not input a URL that includes the match type + this.determineViewFromQInfoURL(); + } else { + this.queryInfo.searchParams.matchType = searchURL.searchParams.get( + 'matchType' + ); + } + if (this.queryInfo.calView) { + this.queryInfo.calView = !(haveMatchType || haveFilter); + } + this.queryInfo.searchParams.from = searchURL.searchParams.get('from'); + this.queryInfo.searchParams.to = searchURL.searchParams.get('to'); + this.queryInfo.searchParams.present = true; + this.queryInfo.rawSearch = location.search; + this.queryInfo.hasFilter = haveFilter; + return this.makeCDXRequest(); + } + + // check for /*/URL + if (queryPart.indexOf(this.starQueries.regularStr) === 0) { + this.queryInfo.url = queryPart.substring( + queryPart.indexOf(this.starQueries.regularStr) + + this.starQueries.regularStr.length + ); + this.determineViewFromQInfoURL(); + return this.makeCDXRequest(); + } + + // check for /fromDT*-toDT*/url* + var maybeDTStarQ = this.maybeExtractDTStarQuery(queryPart); + if (maybeDTStarQ) { + this.queryInfo.searchParams.present = true; + this.queryInfo.searchParams.includeInURL = true; + this.queryInfo.searchParams.from = maybeDTStarQ.from; + this.queryInfo.searchParams.to = maybeDTStarQ.to; + this.queryInfo.url = maybeDTStarQ.url; + this.determineViewFromQInfoURL(); + return this.makeCDXRequest(); + } +}; + +/** + * Determines the match type from the query URL if it includes the shortcuts + * @return {void} + */ +RenderCalendar.prototype.determineViewFromQInfoURL = function() { + var purl; + var domainMatch = + this.queryInfo.url.charAt(this.queryInfo.url.length - 1) === '*'; + + try { + // if parsing the query url via the WHATWG URL class fails (no scheme) + // we are probably not rendering the date calendar + purl = new URL(this.queryInfo.url); + } catch (e) {} + + if (!purl) { + // since purl is null we know we do not hav a valid URL and need to check + // for the match type cases and if one is present update queryInfo + var prefixMatch = this.queryInfo.url.substring(0, 2) === '*.'; + return this.updateMatchType(prefixMatch, domainMatch); + } + + if (purl.search) { + // the URL we are querying with has some search params + // in this case we know we can not check for match type domain because + // http://example.com?it=* is valid and the * here is for the search param + return; + } + + // there are no search params and may have http://example.com[*|/*] + // indicating we are operating under match type domain + return this.updateMatchType(null, domainMatch); +}; + +/** + * Updates the queryInfo's searchParams property to reflect if CDX query is + * using a match type depending on the supplied T/falsy mPrefix and mDomain values + * @param {?boolean} prefixMatch - T/falsy indicating if the match type prefix condition is satisfied + * @param {?boolean} domainMatch - T/falsy indicating if the match type domain condition is satisfied + * @return {void} + */ +RenderCalendar.prototype.updateMatchType = function(prefixMatch, domainMatch) { + // if mPrefix && mDomain something weird is up and we got *.xyz.com[*|/*] + // default to prefix just to be safe since we know we got that for sure + if ((prefixMatch && domainMatch) || prefixMatch) { + this.queryInfo.searchParams.present = true; + this.queryInfo.searchParams.matchType = 'prefix'; + this.queryInfo.calView = false; + return; + } + if (domainMatch) { + this.queryInfo.searchParams.present = true; + this.queryInfo.searchParams.matchType = 'domain'; + this.queryInfo.calView = false; + } +}; + +/** + * Extracts the datetime information and url from the query part of our + * locations URL if it exists otherwise returns null + * @param {string} queryPart + * @return {?{from: string, to?: string, url: string}} + */ +RenderCalendar.prototype.maybeExtractDTStarQuery = function(queryPart) { + // check /from-to/url first + var match = this.starQueries.dtFromTo.exec(queryPart); + if (match) return { from: match[1], to: match[2], url: match[3] }; + // lastly check /from/url first + match = this.starQueries.dtFrom.exec(queryPart); + if (match) return { from: match[1], url: match[2] }; + return null; +}; + +/** + * Constructs and returns the URL for the CDX query. Also updates the + * queryInfo's cdxQueryURL property with the constructed URL. + * @return {string} + */ +RenderCalendar.prototype.makeCDXQueryURL = function() { + var queryURL; + if (this.queryInfo.rawSearch) { + queryURL = this.cdxURL + this.queryInfo.rawSearch + '&output=json'; + } else { + queryURL = + this.cdxURL + + '?output=json&url=' + + // ensure that any query params in the search URL are not considered + // apart of the full query URL + encodeURIComponent(this.queryInfo.url); + } + var querySearchParams = this.queryInfo.searchParams; + if (querySearchParams.present && querySearchParams.includeInURL) { + if (querySearchParams.from) { + queryURL += '&from=' + querySearchParams.from.trim(); + } + if (querySearchParams.to) { + queryURL += '&to=' + querySearchParams.to.trim(); + } + } + this.queryInfo.cdxQueryURL = queryURL; + return queryURL; +}; + +/** + * Performs the query by requesting the CDX information from the cdx server. + * How the results are received from the CDX API is done by of two strategies: using a web worker or in the main thread. + * This is due to the fact that the query results can be LARGE and extracting the results in the main thread is not + * preformat and will lock up the browser. The determination for if we can use the web worker strategy is + * by checking if the browser has the symbols Worker, fetch, TextDecoder, and ReadableStream defined and are functions. + * If we can use the web worker strategy a worker is created and updates the result view as it sends us the queries + * cdx info. Otherwise the query is executed in the main thread and the results are both parsed and rendered in the + * main thread. + * @return {void} + */ +RenderCalendar.prototype.makeCDXRequest = function() { + // if we are rendering the calendar view (regular result view) we need memoizedYMDT to be an object otherwise nothing + var memoizedYMDT = this.queryInfo.calView ? {} : null; + var renderCal = this; + // initialized the dom structure + this.createContainers(); + if (usingWorkerStrat) { + // execute the query and render the results using the query worker + var queryWorker = new window.Worker(this.staticPrefix + '/queryWorker.js'); + var cdxRecordMsg = 'cdxRecord'; + var done = 'finished'; + var months = this.text.months; + + queryWorker.onmessage = function(msg) { + var data = msg.data; + var terminate = false; + if (data.type === cdxRecordMsg) { + data.timeInfo.month = months[data.timeInfo.month]; + + // render the results sent to us from the worker + renderCal.displayedCountStr( + data.recordCount, + data.recordCountFormatted + ); + if (renderCal.queryInfo.calView) { + renderCal.renderDateCalPart( + memoizedYMDT, + data.record, + data.timeInfo, + data.recordCount === first + ); + } else { + renderCal.renderAdvancedSearchPart(data.record); + } + } else if (data.type === done) { + // the worker has consumed the entirety of the response body + terminate = true; + // if there were no results we need to inform the user + renderCal.displayedCountStr( + data.recordCount, + data.recordCountFormatted + ); + } + if (terminate) { + queryWorker.terminate(); + var spinner = document.getElementById( + renderCal.domStructureIds.updatesSpinner + ); + if (spinner && spinner.parentNode) { + spinner.parentNode.removeChild(spinner); + } + } + }; + queryWorker.postMessage({ + type: 'query', + queryURL: this.makeCDXQueryURL() + }); + return; + } + // main thread processing + $.ajax(this.makeCDXQueryURL(), { + dataType: 'text', + success: function(data) { + var cdxLines = data ? data.trim().split('\n') : []; + if (cdxLines.length === 0) { + renderCal.displayedCountStr(0, '0'); + return; + } + var numCdxEntries = cdxLines.length; + renderCal.displayedCountStr( + numCdxEntries, + numCdxEntries.toLocaleString() + ); + for (var i = 0; i < numCdxEntries; ++i) { + var cdxObj = JSON.parse(cdxLines[i]); + if (renderCal.queryInfo.calView) { + renderCal.renderDateCalPart( + memoizedYMDT, + cdxObj, + renderCal.makeTimeInfo(cdxObj), + i === 0 + ); + } else { + renderCal.renderAdvancedSearchPart(cdxObj); + } + } + var spinner = document.getElementById( + renderCal.domStructureIds.updatesSpinner + ); + if (spinner && spinner.parentNode) { + spinner.parentNode.removeChild(spinner); + } + } + }); +}; + +/** + * Creates the DOM structure required for rendering the query results based on if we are rendering the + * calendar view or the advanced query view and populates the containers object + */ +RenderCalendar.prototype.createContainers = function() { + var queryResults = document.getElementById( + this.domStructureIds.capturesMount + ); + var queryInfo = document.getElementById(this.domStructureIds.queryInfoId); + var renderCal = this; + if (this.queryInfo.calView) { + // create regulars count structure + this.createAndAddElementTo(queryInfo, { + tag: 'h3', + children: [ + { + tag: 'i', + className: 'fas fa-spinner fa-pulse mr-2', + id: this.domStructureIds.updatesSpinner + }, + { + tag: 'b', + child: { + tag: 'textNode', + value: '', + ref: function(refToElem) { + renderCal.containers.countTextNode = refToElem; + } + } + }, + { tag: 'textNode', value: ' ' }, + { + tag: 'textNode', + value: '', + ref: function(refToElem) { + renderCal.containers.versionsTextNode = refToElem; + } + }, + { tag: 'b', innerText: ' ' + this.queryInfo.url } + ] + }); + // create the row that will hold the results of the regular query + this.createAndAddElementTo(queryResults, { + tag: 'div', + className: 'row q-row', + children: [ + // years column and initial display structure + { + tag: 'div', + className: 'col-12 col-sm-2 pr-1 pl-1 h-100', + child: { + tag: 'div', + className: 'list-group h-100 auto-overflow', + id: 'year-tab-list', + attributes: { role: 'tablist' }, + ref: function(refToElem) { + renderCal.containers.yearsListDiv = refToElem; + } + } + }, + // months initial structure + { + tag: 'div', + className: 'col-12 mt-2 col-sm-2 mt-sm-0 pr-1 pl-1 h-100', + child: { + tag: 'div', + className: 'tab-content h-100', + id: 'year-month-tab-list', + ref: function(refToElem) { + renderCal.containers.monthsListDiv = refToElem; + } + } + }, + // days initial structure + { + tag: 'div', + className: 'col-12 mt-3 mb-3 pr-1 mt-sm-0 mb-sm-0 pr-sm-0 col-sm-8 pl-1 h-100', + child: { + tag: 'div', + className: 'tab-content h-100', + id: 'year-month-day-tab-list', + ref: function(refToElem) { + renderCal.containers.daysListDiv = refToElem; + } + } + } + ] + }); + this.containers.updatesSpinner = document.getElementById( + this.domStructureIds.updatesSpinner + ); + return; + } + // create the advanced results query info DOM structure + var forString = ' for '; + var forElems; + + if (this.queryInfo.searchParams.matchType) { + forString = ' ' + this.text.matching + ' '; + forElems = [ + { tag: 'b', innerText: this.queryInfo.url }, + { tag: 'textNode', value: ' ' + this.text.by + ' ' }, + { tag: 'b', innerText: this.text.types[this.queryInfo.searchParams.matchType] } + ]; + } else { + forElems = [{ tag: 'b', innerText: this.queryInfo.url }]; + } + this.createAndAddElementTo(queryInfo, { + tag: 'div', + className: 'col-12', + child: { + tag: 'h3', + className: 'text-center', + children: [ + { + tag: 'i', + className: 'fas fa-spinner fa-pulse mr-2', + id: this.domStructureIds.updatesSpinner + }, + { + tag: 'b', + child: { + tag: 'textNode', + value: '', + ref: function(refToElem) { + renderCal.containers.countTextNode = refToElem; + } + } + }, + { tag: 'textNode', value: ' ' }, + { + tag: 'textNode', + value: '', + ref: function(refToElem) { + renderCal.containers.versionsTextNode = refToElem; + } + }, + { tag: 'textNode', value: forString } + ].concat(forElems) + } + }); + // next we will display only the URL or the URL + match type or the URL + date range + // if the executed query contained filters display the humanized version of them + + if (this.queryInfo.searchParams.from || this.queryInfo.searchParams.to) { + this.createAndAddElementTo(queryInfo, { + tag: 'div', + className: 'col-6 align-self-center', + child: { + tag: 'p', + className: 'lead text-center', + child: { tag: 'textNode', value: ' ' + this.niceDateRange() } + } + }); + } + + if (this.queryInfo.hasFilter) { + this.createAndAddElementTo(queryInfo, { + tag: 'div', + className: 'col-6', + children: [ + { + tag: 'p', + className: 'text-center mb-0 mt-1', + innerText: 'Filtering by' + }, + { + tag: 'ul', + className: 'list-group auto-overflow', + style: 'max-height: 150px', + children: this.niceFilterDisplay() + } + ] + }); + } + // create the advanced results DOM structure + this.containers.advancedResultsList = this.createAndAddElementTo( + queryResults, + { + tag: 'div', + className: 'row q-row', + child: { + tag: 'ul', + className: 'list-group h-100 auto-overflow w-100' + } + } + ).firstElementChild; +}; + +/** + * Updates the calendar view with the supplied cdx information + * @param {Object} memoizedYMDT - Object containing the counts for the captures + * @param {Object} cdxObj - CDX object for this capture + * @param {Object} timeInfo - Object containing the date time information for this capture + * @param {boolean} active - Should we add the active classes to the markup rendered here + */ +RenderCalendar.prototype.renderDateCalPart = function( + memoizedYMDT, + cdxObj, + timeInfo, + active +) { + if (memoizedYMDT[timeInfo.year] == null) { + // we have not seen this year month day before + // create the year month day structure (occurs once per result year) + + var timeVal = {}; + timeVal[timeInfo.time] = 1; + + var dayVal = {}; + dayVal[timeInfo.day] = timeVal; + + var monthVal = {}; + monthVal[timeInfo.month] = dayVal; + + memoizedYMDT[timeInfo.year] = monthVal; + + this.addRegYearListItem(timeInfo, active); + this.addRegYearMonthListItem(timeInfo, active); + return this.addRegYearMonthDayListItem(cdxObj, timeInfo, 1, active); + } else if (memoizedYMDT[timeInfo.year][timeInfo.month] == null) { + // we have seen the year before but not the month and day + // create the month day structure (occurs for every new month encountered for an existing year) + var timeVal = {}; + timeVal[timeInfo.time] = 1; + + var dayVal = {}; + dayVal[timeInfo.day] = timeVal; + + memoizedYMDT[timeInfo.year][timeInfo.month] = dayVal; + + this.addRegYearMonthListItem(timeInfo, active); + return this.addRegYearMonthDayListItem(cdxObj, timeInfo, 1, active); + } + // in cases 1 & 2 a new day at time entry is added otherwise only the captures count is updated (case 3) + var count = 1; + var month = memoizedYMDT[timeInfo.year][timeInfo.month]; + if (month[timeInfo.day] == null) { + // never seen this day (case 1) + var timeVal = {}; + timeVal[timeInfo.time] = count; + + month[timeInfo.day] = timeVal; + } else if (month[timeInfo.day][timeInfo.time] == null) { + // we have seen this day before but not at this time (case 2) + month[timeInfo.day][timeInfo.time] = count; + } else { + // we have seen this day at time before so just increment the captures count (case 3) + count = month[timeInfo.day][timeInfo.time] + 1; + month[timeInfo.day][timeInfo.time] = count; + } + this.addRegYearMonthDayListItem(cdxObj, timeInfo, count, active); +}; + +/** + * Updates the advanced view with the supplied cdx information + * @param {Object} cdxObj - CDX object for this capture + */ +RenderCalendar.prototype.renderAdvancedSearchPart = function(cdxObj) { + // display the URL of the result + var displayedInfo = [ + { + tag: 'small', + innerText: this.text.dateTime + this.tsToDate(cdxObj.timestamp) + } + ]; + // display additional information about the result under the URL + if (cdxObj.mime) { + displayedInfo.push({ + tag: 'small', + innerText: this.text.mimeType + cdxObj.mime + }); + } + if (cdxObj.status) { + displayedInfo.push({ + tag: 'small', + innerText: this.text.httpStatus + cdxObj.status + }); + } + displayedInfo.push({ + tag: 'a', + attributes: { + href: this.prefix + '*' + '/' + cdxObj.url, + target: '_blank' + }, + child: { tag: 'small', innerText: this.text.viewAllCaptures } + }); + this.createAndAddElementTo(this.containers.advancedResultsList, { + tag: 'li', + className: 'list-group-item flex-column align-items-start w-100', + children: [ + { + tag: 'a', + className: 'w-100 text-small long-text', + attributes: { + href: this.prefix + cdxObj.timestamp + '/' + cdxObj.url, + target: '_blank' + }, + innerText: cdxObj.url + }, + { + tag: 'div', + className: 'pt-1 d-flex w-100 justify-content-between', + children: displayedInfo + } + ] + }); +}; + +/** + * Creates and adds a year entry for the calendar view + * @param {Object} timeInfo - Object containing the date time information for this capture + * @param {boolean} active - Should we add the active classes to the markup rendered here + */ +RenderCalendar.prototype.addRegYearListItem = function(timeInfo, active) { + // adds an year to div[id=year-tab-list] + var renderCal = this; + var year = timeInfo.year; + this.createAndAddElementTo(this.containers.yearsListDiv, { + tag: 'a', + className: + 'list-group-item list-group-item-action' + (active ? ' active' : ''), + innerText: year, + attributes: { role: 'tab', href: '#' + this.displayMonthsId(year) }, + dataset: { toggle: 'list' }, + events: { + // add a click listener to ensure we display the correct year month days combination + // when choosing a new year or month + click: function() { + renderCal.ensureCorrectActive(year); + } + } + }); +}; + +/** + * Adds a years months to it's month list. If the years month list was not previously created it is created. + * @param {Object} timeInfo - Object containing the date time information for this capture + * @param {boolean} active - Should we add the active classes to the markup rendered here + */ +RenderCalendar.prototype.addRegYearMonthListItem = function(timeInfo, active) { + var yeaMonthsId = this.displayMonthsId(timeInfo.year); // _year-Display-Month + var yearMonthListId = this.displayMonthsListId(timeInfo.year); // _year-Month-Display-List + var yml = document.getElementById(yearMonthListId); + if (yml == null) { + /* + we have not created this years month list before before + so we must create a tab pane for it and month list + + div[id=_year-Display-Months, role=tabpanel].h-100.tab-pane + div[id=_year-Display-Months-List, role=tablist].list-group.h-100.auto-overflow + */ + yml = this.createAndAddElementTo(this.containers.monthsListDiv, { + tag: 'div', + className: 'h-100 tab-pane' + (active ? ' active show' : ''), + id: yeaMonthsId, + attributes: { role: 'tabpanel' }, + child: { + tag: 'div', + className: 'list-group h-100 auto-overflow', + id: yearMonthListId, + attributes: { role: 'tablist' } + } + }).firstElementChild; + } + var renderCal = this; + var showDaysId = this.displayYearMonthDaysId(timeInfo.year, timeInfo.month); + var year = timeInfo.year; + // adds an years month to div[id=_year-Display-Months-List] + this.createAndAddElementTo(yml, { + tag: 'a', + className: + 'list-group-item list-group-item-action' + (active ? ' active' : ''), + innerText: timeInfo.month, + attributes: { + role: 'tab', + href: '#' + showDaysId // _year-month-Display-Days + }, + events: { + click: function() { + renderCal.lastActiveYMD[year] = { + month: yeaMonthsId, + days: showDaysId + }; + renderCal.lastActiveDaysTab = document.getElementById(showDaysId); + } + }, + dataset: { toggle: 'list' } + }); +}; + +/** + * Updates the number of captures for a day at time for a year month. If the years month month day at time list + * was not previously created it is created. If the day was not present in it's years months days list it is created. + * @param {Object} cdxObj - CDX object for this capture + * @param {Object} timeInfo - Object containing the date time information for this capture + * @param {number} numCaptures - How many captures do we have currently for this URL + * @param {boolean} active - Should we add the active classes to the markup rendered here + */ +RenderCalendar.prototype.addRegYearMonthDayListItem = function( + cdxObj, + timeInfo, + numCaptures, + active +) { + var yearMonthDaysListId = this.displayYearMonthDaysListId( + timeInfo.year, + timeInfo.month + ); // _year-month-Display-Days-List + var ymlDL = document.getElementById(yearMonthDaysListId); + if (ymlDL == null) { + /* + we have not created this years months day tab and list before before + so we must create a tab pane for it and days list + + div[id=_year-month-Display-Days, role=tabpanel].h-100.tab-pane.active.show + ul[id=_year-month-Display-Days-List].list-group.h-100.auto-overflow + */ + ymlDL = this.createAndAddElementTo(this.containers.daysListDiv, { + tag: 'div', + className: 'h-100 tab-pane' + (active ? ' active show' : ''), + id: this.displayYearMonthDaysId(timeInfo.year, timeInfo.month), + attributes: { role: 'tabpanel' }, + child: { + tag: 'ul', + className: 'list-group h-100 auto-overflow', + id: yearMonthDaysListId + } + }).firstElementChild; + } + // retrieve the existing days num captures display element + var existingDayId = 'count_' + cdxObj.timestamp; + var existingDayCount = document.getElementById(existingDayId); + if (existingDayCount == null) { + /* + it does not exist so we have not displayed this day before + + li.list-group-item + a[href="replay url"] + span[id=count_ts].badge.badge-info.badge-pill.float-right + */ + const options = { + dateStyle: 'long', + timeStyle: 'medium', + }; + var dateTimeString = this.tsToDate(cdxObj.timestamp, false, options); + this.createAndAddElementTo(ymlDL, { + tag: 'li', + className: 'list-group-item', + children: [ + { + tag: 'a', + attributes: { + href: this.prefix + cdxObj.timestamp + '/' + cdxObj.url, + target: '_blank' + }, + innerText: dateTimeString + }, + { + tag: 'span', + id: existingDayId, + className: 'badge badge-info badge-pill float-right', + ref: function(refToElem) { + existingDayCount = refToElem; + } + } + ] + }); + if (active) { + // populate the lastActives with the selected correct year month days combination + var daysID = this.displayYearMonthDaysId(timeInfo.year, timeInfo.month); + this.lastActiveYMD[timeInfo.year] = { + month: this.displayMonthsId(timeInfo.year), + days: daysID + }; + this.lastActiveDaysTab = document.getElementById(daysID); + } + } + existingDayCount.innerText = numCaptures + ''; +}; + +/** + * Ensures that we display the correct year month days combination when switching year's. + * Updates the lastActiveDaysTab property. + * @param {string} year - The year to be shown + */ +RenderCalendar.prototype.ensureCorrectActive = function(year) { + // un-activates currently active year month day display tab + if (this.lastActiveDaysTab != null) { + this.lastActiveDaysTab.classList.remove('active', 'show'); + } + // activates the last year month day display tab for the current year chosen + if (this.lastActiveYMD[year] && this.lastActiveYMD[year].days) { + this.lastActiveDaysTab = document.getElementById( + this.lastActiveYMD[year].days + ); + this.lastActiveDaysTab.classList.add('active', 'show'); + } +}; + +/** + * Creates a DOM element(s) based on the supplied creation options. + * Creation options: + * - tag: The name of the tag to be created (supplied to doc.createElement) or 'textNode' to create a text + * node using doc.createTextNode [required] + * - value: A string used as the contents for the to be created text node (only used if tag == 'textNode') + * - id: An string used to set the `id` of the element + * - className: An string of CSS class names used to set the elements `className` property + * - style: An string containing style definition(s) used to set the elements `style` property + * - innerText: An string used to set the elements `innerText` property + * - innerHTML: An string used to set the elements `innerHTML` property + * - attributes: An object containing string key value pairs that will be used to set the elements attributes + * using element.setAttribute(key, value) + * - dataset: An object containing string key value pairs that will be used to set the elements attributes + * using element.dataset[key] = value + * - events: An object containing string keys and function value pairs that will be used to add event listeners + * on the element using addEventListener.addEventListener(key, value) + * - child: An object (same properties as the creationOptions parameter for this function) that represent a child + * element of the element created using this function, added via element.appendChild once created + * - children: An array of objects (same properties as the creationOptions parameter for this function) that + * represent a child element of the element created using this function, added via element.appendChild once created + * - ref: An function that if supplied will be called with the created element as the only argument + * @param {Object} creationOptions - Options for new element creation + * @return {HTMLElement|Node} - The newly created element + */ +RenderCalendar.prototype.createElement = function(creationOptions) { + if (creationOptions.tag === 'textNode') { + var tn = document.createTextNode(creationOptions.value); + if (creationOptions.ref) { + creationOptions.ref(tn); + } + return tn; + } + var elem = document.createElement(creationOptions.tag); + if (creationOptions.id) { + elem.id = creationOptions.id; + } + if (creationOptions.className) { + elem.className = creationOptions.className; + } + if (creationOptions.style) { + elem.style = creationOptions.style; + } + if (creationOptions.innerText) { + elem.innerText = creationOptions.innerText; + } + if (creationOptions.innerHTML) { + elem.innerHTML = creationOptions.innerHTML; + } + if (creationOptions.attributes) { + var atts = creationOptions.attributes; + for (var attributeName in atts) { + elem.setAttribute(attributeName, atts[attributeName]); + } + } + if (creationOptions.dataset) { + var dataset = creationOptions.dataset; + for (var dataName in dataset) { + elem.dataset[dataName] = dataset[dataName]; + } + } + if (creationOptions.events) { + var events = creationOptions.events; + for (var eventName in events) { + elem.addEventListener(eventName, events[eventName]); + } + } + if (creationOptions.child) { + elem.appendChild(this.createElement(creationOptions.child)); + } + if (creationOptions.children) { + var kids = creationOptions.children; + for (var i = 0; i < kids.length; i++) { + elem.appendChild(this.createElement(kids[i])); + } + } + if (creationOptions.ref) { + creationOptions.ref(elem); + } + return elem; +}; + +/** + * Adds the newly created element to the supplied existing element. + * @param {Node} addTo - The DOM node the newly created element will be added to via addTo.appendChild + * @param {Object} creationOptions - Options for creating the new element. + * See the description of {@link createElement} for more information. + * @return {HTMLElement|Node} - The newly created element + */ +RenderCalendar.prototype.createAndAddElementTo = function( + addTo, + creationOptions +) { + var created = this.createElement(creationOptions); + addTo.appendChild(created); + return created; +}; + +/** + * Returns element creation options for the humanized filtering options of the advanced view + * @returns {Array} + */ +RenderCalendar.prototype.niceFilterDisplay = function() { + var filterList = []; + var qSplit = location.search.split('&'); + for (var i = 0; i < qSplit.length; i++) { + var match = this.filterRE.exec(qSplit[i]); + if (match) { + filterList.push({ + tag: 'li', + className: 'list-group-item', + innerText: match[2] + ' ' + this.filterMods[match[1]] + ' ' + match[3] + }); + } + } + return filterList; +}; + +/** + * Returns a humanized representation of an advanced queries from or to constraints + * @returns {string} + */ +RenderCalendar.prototype.niceDateRange = function() { + var from = this.queryInfo.searchParams.from; + var to = this.queryInfo.searchParams.to; + if (from && to) { + return 'From ' + from + ' to ' + to; + } else if (from) { + return 'From ' + from + ' until ' + 'present'; + } + return 'From earliest until ' + to; +}; + +/** + * Returns the id for a years months tab + * @param {string} year - The year part for the id + * @returns {string} + */ +RenderCalendar.prototype.displayMonthsId = function(year) { + return '_' + year + '-Display-Month'; +}; + +/** + * Returns the id for displaying a years months list within a years month tab + * @param {string} year - The year part for the id + * @returns {string} + */ +RenderCalendar.prototype.displayMonthsListId = function(year) { + return '_' + year + '-Month-Display-List'; +}; + +/** + * Returns the id for a year month days tab + * @param {string} year - The year part for the id + * @param {string} month - The month part for the id + * @returns {string} + */ +RenderCalendar.prototype.displayYearMonthDaysId = function(year, month) { + return '_' + year + '-' + month + '-Display-Days'; +}; + +/** + * Returns the id for displaying a year month days list within a year month days tab + * @param {string} year - The year part for the id + * @param {string} month - The month part for the id + * @returns {string} + */ +RenderCalendar.prototype.displayYearMonthDaysListId = function(year, month) { + return '_' + year + '-' + month + '-Display-Days-List'; +}; + +/** + * Converts the supplied timestamp to either a local data string or a gmt string (if is_gmt is true) + * @param {string} ts - The timestamp to be converted to a string + * @param {boolean} [is_gmt] - Should the timestamp be converted to a gmt string + * @param {Object} [options] - String formatting options + * @returns {string} + */ +RenderCalendar.prototype.tsToDate = function(ts, is_gmt, options) { + if (ts.length < 14) return ts; + var datestr = + ts.substring(0, 4) + + '-' + + ts.substring(4, 6) + + '-' + + ts.substring(6, 8) + + 'T' + + ts.substring(8, 10) + + colon + + ts.substring(10, 12) + + colon + + ts.substring(12, 14) + + '-00:00'; + + var date = new Date(datestr); + return is_gmt ? date.toUTCString() : date.toLocaleString(document.documentElement.lang, options); +}; + +/** + * Extracts the time information for a cdx object's timestamp + * @param {Object} cdxObj - The cdx object to have its timestamp information extracted from + * @returns {{month: string, year: string, time: string, day: string}} + */ +RenderCalendar.prototype.makeTimeInfo = function(cdxObj) { + var ts = cdxObj.timestamp; + var day = ts.substring(6, 8); + return { + year: ts.substring(0, 4), + month: this.text.months[ts.substring(4, 6)], + day: day.charAt(0) === '0' ? day.charAt(1) : day, + time: + ts.substring(8, 10) + + colon + + ts.substring(10, 12) + + colon + + ts.substring(12, 14) + }; +}; + +/** + * Updates the displayed number of query results. + * + * The version(s) text that is displayed only needs to be updated a maximum of two times. + * When count (1) === first (1) we are displaying the first or only result and need the + * singular version text. Otherwise we are displaying result zero or n and need the + * plural version text. After these two version text updates we are needlessly modifying + * the element displaying the versions text with the same version. + * @param {number} count - The number of query results + * @param {string} countFormatted - The number of query results formatted for display + * @returns {string} + */ +RenderCalendar.prototype.displayedCountStr = function(count, countFormatted) { + this.containers.countTextNode.data = countFormatted; + if (count === first) { + this.containers.versionsTextNode.data = this.queryInfo.calView + ? this.text.version + : this.text.result; + } else if (!this.versionString) { + // case count = 0 or first nth record + this.versionString = this.queryInfo.calView + ? this.text.versions + : this.text.results; + this.containers.versionsTextNode.data = this.versionString; + } +}; diff --git a/pywb/templates/query.html b/pywb/templates/query.html index c0f7c53e..d9482836 100644 --- a/pywb/templates/query.html +++ b/pywb/templates/query.html @@ -7,9 +7,19 @@ {% block head %} {{ super() }} +{% if "filter=" in env['QUERY_STRING'] or "matchType=" in env['QUERY_STRING'] %} + + + + + +{% else %} + +{% endif %} + {% include 'vue_loc.html' %} {% endblock %} @@ -17,8 +27,17 @@ {% block body %} -
+{% if "filter=" in env['QUERY_STRING'] or "matchType=" in env['QUERY_STRING'] %} +
+
+

{{ _('Search Results') }}

+
+
+
+
+
+
+{% else %} + +
+ + + +{% endif %} + {% endblock %}