diff options
Diffstat (limited to 'src/static')
-rw-r--r-- | src/static/css/site-basic.css (renamed from src/static/site-basic.css) | 0 | ||||
-rw-r--r-- | src/static/css/site.css (renamed from src/static/site6.css) | 420 | ||||
-rw-r--r-- | src/static/js/client.js (renamed from src/static/client3.js) | 1584 | ||||
-rw-r--r-- | src/static/js/lazy-loading.js (renamed from src/static/lazy-loading.js) | 0 | ||||
-rw-r--r-- | src/static/js/module-import-shims.js | 27 | ||||
-rw-r--r-- | src/static/js/search-worker.js | 621 | ||||
-rw-r--r-- | src/static/js/xhr-util.js | 64 | ||||
-rw-r--r-- | src/static/misc/icons.svg (renamed from src/static/icons.svg) | 0 | ||||
-rw-r--r-- | src/static/misc/warning.svg (renamed from src/static/warning.svg) | 0 | ||||
-rw-r--r-- | src/static/shared-util/README.md | 11 |
10 files changed, 2602 insertions, 125 deletions
diff --git a/src/static/site-basic.css b/src/static/css/site-basic.css index 586f37b5..586f37b5 100644 --- a/src/static/site-basic.css +++ b/src/static/css/site-basic.css diff --git a/src/static/site6.css b/src/static/css/site.css index 73721956..e297993c 100644 --- a/src/static/site6.css +++ b/src/static/css/site.css @@ -32,7 +32,9 @@ /* Layout - Common */ body { - margin: 10px; + position: relative; + margin: 0; + padding: 10px; overflow-y: scroll; } @@ -41,8 +43,8 @@ body::before { position: fixed; top: 0; left: 0; - width: 100%; - height: 100%; + width: 100vw; + height: 100vh; z-index: -1; /* NB: these are 100 LVW, "largest view width", etc. @@ -56,7 +58,7 @@ body::before { #page-container { max-width: 1100px; - margin: 10px auto 50px; + margin: 0 auto 40px; padding: 15px 0; } @@ -170,6 +172,10 @@ body::before { flex-grow: 1; } +.sidebar-column.initially-hidden { + display: none; +} + .sidebar-multiple { display: flex; flex-direction: column; @@ -224,7 +230,7 @@ body { } body::before { - background-image: url("../media/bg.jpg"); + background-image: url("../../media/bg.jpg"); background-position: center; background-size: cover; opacity: 0.5; @@ -253,6 +259,11 @@ body::before { font-weight: 800; } +#page-container:not(.showing-sidebar-left) #skippers .skipper[data-for=sidebar-left], +#page-container:not(.showing-sidebar-right) #skippers .skipper[data-for=sidebar-right] { + display: none; +} + #banner { background: black; background-color: var(--dim-color); @@ -392,6 +403,36 @@ summary > span:hover { text-decoration-color: var(--primary-color); } +summary > span:hover a { + text-decoration: none !important; +} + +summary > span:hover:has(a:hover), +summary > span:hover:has(a.nested-hover), +summary.has-nested-hover > span { + text-decoration: none !important; +} + +summary > span:hover:has(a:hover) a, +summary > span:hover:has(a.nested-hover) a, +summary.has-nested-hover > span a { + text-decoration: underline !important; +} + +summary.underline-white > span:hover { + text-decoration-color: white; +} + +/* This link isn't supposed to be underlined *at all* + * when the summary (and not link) is hovered, but + * for some reason Safari is still applying its colored + * and dotted(!) underline. Get around the apparent + * effect by just making it white. + */ +summary.underline-white > span:hover a:not(:hover) { + text-decoration-color: white; +} + summary .group-name { color: var(--primary-color); } @@ -430,6 +471,242 @@ summary .group-name { font-weight: normal; } +.sidebar-column.search-showing-results { + position: sticky; + top: 5px; + align-self: flex-start !important; /* pls */ +} + +.wiki-search-sidebar-box { + padding: 1px 0 0 0; + + z-index: 100; + max-height: calc(100vh - 20px); + + display: flex; + flex-direction: column; + + background-color: #000000c0; + + -webkit-backdrop-filter: + brightness(1.2) blur(4px); + + backdrop-filter: + brightness(1.2) blur(4px); +} + +.wiki-search-sidebar-box.showing-results { + box-shadow: + 0 4px 16px -8px var(--primary-color), + 0 10px 6px var(--bg-black-color), + 0 6px 4px #00000040; +} + +/* This is to say, any sidebar that's *not* + * the first sidebar after the search box. + */ +.wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar { + margin-top: 5px; +} + +.wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) { + opacity: 0.8; + filter: brightness(0.7); +} + +.wiki-search-input { + width: calc(100% - 4px); + padding: 2px 4px; + margin: 2px 2px 3px 2px; + box-sizing: border-box; + + background: transparent; + border: 1px solid var(--dim-color); + border-radius: 3px; + color: inherit; +} + +.wiki-search-input[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +.wiki-search-sidebar-box hr { + border-color: var(--primary-color); + border-style: none none dotted none; + margin-top: 3px; + margin-bottom: 3px; +} + +.wiki-search-progress-container { + padding: 2px 6px 4px 6px; + display: flex; + flex-direction: row; +} + +.wiki-search-progress-label { + font-size: 0.9em; + font-style: oblique; + cursor: default; + margin-right: 1ch; +} + +.wiki-search-progress-bar { + flex-grow: 1; +} + +.wiki-search-failed-container { + padding: 2px 3px 4px 6px; +} + +.wiki-search-failed-container p { + margin: 0; +} + +.wiki-search-results-container { + margin-bottom: 0; + padding: 2px; +} + +.wiki-search-no-results { + font-size: 0.9em; + padding: 2px 3px 4px 6px; + cursor: default; +} + +.wiki-search-result { + position: relative; + display: flex; + padding: 4px 3px 4px 6px; +} + +.wiki-search-result:hover { + text-decoration: none !important; +} + +.wiki-search-result::before { + content: ''; + position: absolute; + top: -2px; + bottom: -2px; + left: 0; + right: 0; + + border: 1.5px solid var(--primary-color); + border-radius: 4px; + display: none; +} + +.wiki-search-result.current-result { + background: var(--light-ghost-color); + border-top: 1px solid var(--dim-color); + border-bottom: 1px solid var(--dim-color); +} + +.wiki-search-result:hover::before { + display: block; + background: var(--light-ghost-color); +} + +.wiki-search-result.current-result:hover { + background: none; + border-color: transparent; +} + +.wiki-search-result.current-result:hover .wiki-search-current-result-text { + filter: saturate(0.8) brightness(1.4); +} + +.wiki-search-result-text-area { + align-self: center; + flex-grow: 1; + min-width: 0; + overflow-wrap: break-word; + padding-bottom: 2px; +} + +.wiki-search-result-name { + margin-right: 0.25em; +} + +.wiki-search-result:hover .wiki-search-result-name { + text-decoration: underline; +} + +.wiki-search-current-result-text, +.wiki-search-result-kind { + font-style: oblique; + opacity: 0.9; + display: inline-block; +} + +.wiki-search-result-image-container { + align-self: flex-start; + flex-shrink: 0; + margin-right: 6px; + border-radius: 2px; + overflow: hidden; + + background-color: var(--deep-color); + border: 2px solid var(--deep-color); +} + +.wiki-search-results:not(:has(.wiki-search-result-image)) .wiki-search-result-image-container { + display: none; +} + +.wiki-search-result-image, +.wiki-search-result-image-placeholder { + display: block; + width: 1.8em; + height: 1.8em; + aspect-ratio: 1 / 1; + border-radius: 1.5px; +} + +.wiki-search-result-image-placeholder { + background-color: #0004; + box-shadow: 0 1px 3px -1px #0008 inset; +} + +.wiki-search-result-image.has-warning { + filter: blur(2px) brightness(0.8); +} + +.wiki-search-end-search-line { + text-align: center; + margin-top: 6px; + margin-bottom: 2px; +} + +.wiki-search-end-search-line a { + display: inline-block; + font-style: oblique; + opacity: 0.9; + padding: 3px 6px 4px 6px; + border-radius: 4px; + border: 1.5px solid transparent; +} + +.wiki-search-end-search-line a:hover { + opacity: 1; + background: var(--light-ghost-color); + border-color: var(--deep-color); +} + +.wiki-search-input:focus { + border-color: var(--primary-color); +} + +.wiki-search-input::placeholder { + color: var(--primary-color); + font-style: oblique; +} + +.wiki-search-input:focus::placeholder { + color: var(--dim-color); +} + #content { overflow-wrap: anywhere; } @@ -474,6 +751,7 @@ a:not([href]):hover { .external-link.indicate-external::after { content: '\00a0➚'; + font-style: normal; } .external-link.indicate-external:hover::after { @@ -488,13 +766,13 @@ a:not([href]):hover { display: inline-block; } -.nav-links-index .nav-link.has-divider::before, -.nav-links-groups .nav-link.has-divider::before { +.nav-links-index .nav-link:not(:first-child)::before, +.nav-links-groups .nav-link:not(:first-child)::before { content: "\0020\00b7\0020"; font-weight: 800; } -.nav-links-hierarchical .nav-link.has-divider::before { +.nav-links-hierarchical .nav-link:not(:first-child)::before { content: "\0020/\0020"; } @@ -503,6 +781,19 @@ a:not([href]):hover { white-space: nowrap; } +#header .scoped-chronology { + display: none; +} + +#header .scoped-chronology-switcher .switcher-link { + text-decoration: underline; + text-decoration-style: dotted; +} + +#header .scoped-chronology-switcher > div { + margin-left: 20px; +} + #secondary-nav { text-align: center; } @@ -720,6 +1011,10 @@ li:not(:first-child:last-child) .tooltip, color: var(--page-primary-color); } +progress { + accent-color: var(--primary-color); +} + .content-columns { columns: 2; } @@ -801,6 +1096,10 @@ ul.image-details li { content: " \00b7 "; } +#artist-commentary.first-entry-is-dated { + clear: right; +} + .commentary-entry-heading { margin-left: 15px; padding-left: 5px; @@ -813,6 +1112,19 @@ ul.image-details li { font-style: oblique; } +.commentary-entry-heading time { + float: right; + padding-left: 0.5ch; + padding-right: 0.25ch; + margin-left: 0.75ch; + border-left: 1px dotted transparent; + transition: border-left-color 0.15s; +} + +.commentary-entry-heading time:hover { + border-left-color: white; +} + .commentary-art { float: right; width: 30%; @@ -844,6 +1156,17 @@ ul.image-details li { margin-bottom: 1.5em; } +a.align-center, img.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +center { + margin-top: 1em; + margin-bottom: 1em; +} + .content-image { display: inline-block !important; } @@ -995,6 +1318,17 @@ p code { margin-bottom: 0; } +#content blockquote h2 { + font-size: 1em; + font-weight: 800; +} + +#content blockquote h3 { + font-size: 1em; + font-weight: normal; + font-style: oblique; +} + main.long-content { --long-content-padding-ratio: 0.10; } @@ -1011,6 +1345,9 @@ dl dt { } dl dt { + /* Heads up, this affects the measurement + * for dl dt which are .content-heading! + */ margin-bottom: 2px; } @@ -1346,7 +1683,6 @@ img.pixelate, .pixelate img { font-size: 1.6em; opacity: 0.8; - background-image: url("warning.svg"); } .reveal-interaction { @@ -1837,6 +2173,13 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } +dl dt.content-heading { + /* Basic margin-bottom for dt is 2px, + * so just subtract 3px from that. + */ + margin-bottom: -1px; +} + h3.content-heading { clear: both; } @@ -2030,40 +2373,40 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r /* Sticky sidebar */ -.sidebar-column.sidebar.sticky-column, -.sidebar-column.sidebar.sticky-last, -.sidebar-multiple.sticky-last > .sidebar:last-child, -.sidebar-multiple.sticky-column { - position: sticky; - top: 10px; -} - -.sidebar-multiple.sticky-last { +.sidebar-column:not(.sticky-column) { align-self: stretch; } -.sidebar-multiple.sticky-column { +.sidebar-column.sticky-column { + position: sticky; + top: 10px; align-self: flex-start; + max-height: calc(100vh - 20px); + display: flex; + flex-direction: column; } -.sidebar-column.sidebar.sticky-column { - max-height: calc(100vh - 20px); - align-self: start; - padding-bottom: 0; - box-sizing: border-box; - flex-basis: 275px; - padding-top: 0; +.sidebar-multiple.sticky-column .sidebar:last-child { + flex-shrink: 1; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--dark-color); +} + +.wiki-search-sidebar-box .wiki-search-results-container { overflow-y: scroll; scrollbar-width: thin; scrollbar-color: var(--dark-color); } -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { +.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar, +.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar { background: var(--dark-color); width: 12px; } -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { +.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar-thumb, +.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar-thumb { transition: background 0.2s; background: rgba(255, 255, 255, 0.2); border: 3px solid transparent; @@ -2099,6 +2442,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r left: 0; right: 0; bottom: 0; + z-index: 4000; background: rgba(0, 0, 0, 0.8); color: white; @@ -2250,7 +2594,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Layout - Wide (most computers) */ @media (min-width: 900px) { - #page-container:not(.has-zero-sidebars) #secondary-nav { + #page-container.showing-sidebar-left #secondary-nav, + #page-container.showing-sidebar-left #secondary-nav { display: none; } } @@ -2267,7 +2612,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content * don't apply the similar layout change of widening the long-content area * if this page doesn't have a sidebar to hide in the first place. */ - #page-container:not(.has-zero-sidebars) main.long-content { + #page-container.showing-sidebar-left main.long-content, + #page-container.showing-sidebar-right main.long-content { --long-content-padding-ratio: 0.06; } } @@ -2299,12 +2645,12 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) { + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) { flex-basis: 23%; margin: 15px; } - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) { + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) { flex-basis: 18%; margin: 10px; } @@ -2313,7 +2659,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Layout - Medium or Thin */ @media (max-width: 899.98px) { - .sidebar-column:not(.no-hide) { + .sidebar.collapsible, + .sidebar-column.all-boxes-collapsible { display: none; } @@ -2321,15 +2668,16 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content display: block; } - .layout-columns.vertical-when-thin { + .layout-columns { flex-direction: column; } - .layout-columns.vertical-when-thin > *:not(:last-child) { + .layout-columns > *:not(:last-child) { margin-bottom: 10px; } - .sidebar-column.no-hide { + .sidebar-column { + position: static !important; max-width: unset !important; flex-basis: unset !important; margin-right: 0 !important; diff --git a/src/static/client3.js b/src/static/js/client.js index 7d6544a0..68b1a013 100644 --- a/src/static/client3.js +++ b/src/static/js/client.js @@ -5,8 +5,19 @@ // that cannot 8e done at static-site compile time, 8y its fundamentally // ephemeral nature. -import {accumulateSum, empty, filterMultipleArrays, stitchArrays} - from '../util/sugar.js'; +import {getColors} from '../shared-util/colors.js'; + +import { + accumulateSum, + atOffset, + empty, + filterMultipleArrays, + promiseWithResolvers, + stitchArrays, + withEntries, +} from '../shared-util/sugar.js'; + +import {fetchWithProgress} from './xhr-util.js'; const clientInfo = window.hsmusicClientInfo = Object.create(null); @@ -18,20 +29,142 @@ const clientSteps = { addPageListeners: [], }; -function initInfo(key, description) { +function initInfo(infoKey, description) { const object = {...description}; for (const obj of [ object, object.state, - object.setting, + object.settings, object.event, ]) { if (!obj) continue; Object.preventExtensions(obj); } - clientInfo[key] = object; + if (object.session) { + const sessionSpecs = object.session; + + object.session = {}; + + for (const [key, spec] of Object.entries(sessionSpecs)) { + const hasSpec = + typeof spec === 'object' && spec !== null; + + const defaultValue = + (hasSpec + ? spec.default ?? null + : spec); + + let formatRead = value => value; + let formatWrite = value => value; + if (hasSpec && spec.type) { + switch (spec.type) { + case 'number': + formatRead = parseFloat; + formatWrite = String; + break; + + case 'boolean': + formatRead = Boolean; + formatWrite = String; + break; + + case 'string': + formatRead = String; + formatWrite = String; + break; + + case 'json': + formatRead = JSON.parse; + formatWrite = JSON.stringify; + break; + + default: + throw new Error(`Unknown type for session storage spec "${spec.type}"`); + } + } + + let getMaxLength = + (!hasSpec + ? () => Infinity + : typeof spec.maxLength === 'function' + ? (object.settings + ? () => spec.maxLength(object.settings) + : () => spec.maxLength()) + : () => spec.maxLength); + + const storageKey = `hsmusic.${infoKey}.${key}`; + + let fallbackValue = defaultValue; + + Object.defineProperty(object.session, key, { + get: () => { + let value; + try { + value = sessionStorage.getItem(storageKey) ?? defaultValue; + } catch (error) { + if (error instanceof DOMException) { + value = fallbackValue; + } else { + throw error; + } + } + + if (value === null) { + return null; + } + + return formatRead(value); + }, + + set: (value) => { + if (value !== null && value !== '') { + value = formatWrite(value); + } + + if (value === null) { + value = ''; + } + + const maxLength = getMaxLength(); + if (value.length > maxLength) { + console.warn( + `Requested to set session storage ${storageKey} ` + + `beyond maximum length ${maxLength}, ` + + `ignoring this value.`); + console.trace(); + return; + } + + let operation; + if (value === '') { + fallbackValue = null; + operation = () => { + sessionStorage.removeItem(storageKey); + }; + } else { + fallbackValue = value; + operation = () => { + sessionStorage.setItem(storageKey, value); + }; + } + + try { + operation(); + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + } + }, + }); + } + + Object.preventExtensions(object.session); + } + + clientInfo[infoKey] = object; return object; } @@ -103,6 +236,18 @@ function cssProp(el, ...args) { } } +function templateContent(el) { + if (el === null) { + return null; + } + + if (el?.nodeName !== 'TEMPLATE') { + throw new Error(`Expected a <template> element`); + } + + return el.content.cloneNode(true); +} + // Curry-style, so multiple points can more conveniently be tested at once. function pointIsOverAnyOf(elements) { return (clientX, clientY) => { @@ -135,9 +280,12 @@ function getVisuallyContainingElement(child) { const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); */ -const openAlbum = (d) => rebase(`album/${d}`); -const openTrack = (d) => rebase(`track/${d}`); -const openArtist = (d) => rebase(`artist/${d}`); +const openAlbum = d => rebase(`album/${d}`); +const openArtTag = d => rebase(`tag/${d}`); +const openArtist = d => rebase(`artist/${d}`); +const openFlash = d => rebase(`flash/${d}`); +const openGroup = d => rebase(`group/${d}`); +const openTrack = d => rebase(`track/${d}`); // TODO: This should also use urlSpec. @@ -169,8 +317,8 @@ function dispatchInternalEvent(event, eventName, ...args) { try { results.push(listener(...args)); } catch (error) { - console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); - console.debug(error); + console.error(`Uncaught error in listener for ${infoName}.${eventName}`); + console.error(error); results.push(undefined); } } @@ -1004,6 +1152,54 @@ if ( }); } +// Links nested in summaries ------------------------------ + +const summaryNestedLinksInfo = initInfo('summaryNestedLinksInfo', { + summaries: null, + links: null, +}); + +function getSummaryNestedLinksReferences() { + const info = summaryNestedLinksInfo; + + info.summaries = + Array.from(document.getElementsByTagName('summary')); + + info.links = + info.summaries + .map(summary => + Array.from(summary.getElementsByTagName('a'))); + + filterMultipleArrays( + info.summaries, + info.links, + (_summary, links) => !empty(links)); +} + +function addSummaryNestedLinksPageListeners() { + const info = summaryNestedLinksInfo; + + for (const {summary, links} of stitchArrays({ + summary: info.summaries, + links: info.links, + })) { + for (const link of links) { + link.addEventListener('mouseover', () => { + link.classList.add('nested-hover'); + summary.classList.add('has-nested-hover'); + }); + + link.addEventListener('mouseout', () => { + link.classList.remove('nested-hover'); + summary.classList.remove('has-nested-hover'); + }); + } + } +} + +clientSteps.getPageReferences.push(getSummaryNestedLinksReferences); +clientSteps.getPageReferences.push(addSummaryNestedLinksPageListeners); + // Tooltip-style hover (infrastructure) ------------------- const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', { @@ -2510,9 +2706,10 @@ function updateStickySubheadingContent(index) { } const textContainer = - closestHeading.querySelector('.content-heading-main-title') - // Just for compatibility with older builds of the site. - ?? closestHeading; + templateContent( + closestHeading.querySelector('.content-heading-sticky-title')) ?? + closestHeading.querySelector('.content-heading-main-title') ?? + closestHeading; for (const child of textContainer.childNodes) { if (child.tagName === 'A') { @@ -2613,7 +2810,7 @@ function addImageOverlayClickHandlers() { } } -function handleImageLinkClicked(evt) { +async function handleImageLinkClicked(evt) { if (evt.metaKey || evt.shiftKey || evt.altKey) { return; } @@ -2680,26 +2877,46 @@ function handleImageLinkClicked(evt) { mainImage.addEventListener('load', handleMainImageLoaded); mainImage.addEventListener('error', handleMainImageErrored); - container.style.setProperty('--download-progress', '0%'); - loadImage(mainSrc, progress => { - container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); - }).then( - blobUrl => { - mainImage.src = blobUrl; - container.style.setProperty('--download-progress', '100%'); - }, - handleMainImageErrored); + const showProgress = amount => { + cssProp(container, '--download-progress', `${amount * 100}%`); + }; + + showProgress(0.00); + + const response = + await fetchWithProgress(mainSrc, progress => { + if (progress === -1) { + // TODO: Indeterminate response progress cue + showProgress(0.00); + } else { + showProgress(0.20 + 0.80 * progress); + } + }); + + if (!response.status.toString().startsWith('2')) { + handleMainImageErrored(); + return; + } + + const blob = await response.blob(); + const blobSrc = URL.createObjectURL(blob); + + mainImage.src = blobSrc; + showProgress(1.00); function handleMainImageLoaded() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); container.classList.add('loaded'); + removeEventListeners(); } function handleMainImageErrored() { + container.classList.add('errored'); + removeEventListeners(); + } + + function removeEventListeners() { mainImage.removeEventListener('load', handleMainImageLoaded); mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('errored'); } } @@ -2799,67 +3016,6 @@ function updateFileSizeInformation(fileSize) { addImageOverlayClickHandlers(); -/** - * Credits: Parziphal, Feb 13, 2017 - * https://stackoverflow.com/a/42196770 - * - * Loads an image with progress callback. - * - * The `onprogress` callback will be called by XMLHttpRequest's onprogress - * event, and will receive the loading progress ratio as an whole number. - * However, if it's not possible to compute the progress ratio, `onprogress` - * will be called only once passing -1 as progress value. This is useful to, - * for example, change the progress animation to an undefined animation. - * - * @param {string} imageUrl The image to load - * @param {Function} onprogress - * @return {Promise} - */ -function loadImage(imageUrl, onprogress) { - return new Promise((resolve, reject) => { - var xhr = new XMLHttpRequest(); - var notifiedNotComputable = false; - - xhr.open('GET', imageUrl, true); - xhr.responseType = 'arraybuffer'; - - xhr.onprogress = function(ev) { - if (ev.lengthComputable) { - onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); - } else { - if (!notifiedNotComputable) { - notifiedNotComputable = true; - onprogress(-1); - } - } - } - - xhr.onloadend = function() { - if (!xhr.status.toString().match(/^2/)) { - reject(xhr); - } else { - if (!notifiedNotComputable) { - onprogress(100); - } - - var options = {} - var headers = xhr.getAllResponseHeaders(); - var m = headers.match(/^Content-Type:\s*(.*?)$/mi); - - if (m && m[1]) { - options.type = m[1]; - } - - var blob = new Blob([this.response], options); - - resolve(window.URL.createObjectURL(blob)); - } - } - - xhr.send(); - }); -} - // "Additional names" box --------------------------------- const additionalNamesBoxInfo = initInfo('additionalNamesBox', { @@ -2950,6 +3106,114 @@ clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences); clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners); clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners); +// Scoped chronology links -------------------------------- + +const scopedChronologyLinksInfo = initInfo('scopedChronologyLinksInfo', { + switcher: null, + containers: null, + switcherLinks: null, + modes: null, + + session: { + selectedMode: 'wiki', + }, +}); + +function getScopedChronologyLinksReferences() { + const info = scopedChronologyLinksInfo; + + info.switcher = + document.querySelector('.scoped-chronology-switcher'); + + if (!info.switcher) { + return; + } + + info.containers = + Array.from(info.switcher.querySelectorAll(':scope > div')); + + info.switcherLinks = + Array.from(info.switcher.querySelectorAll('.switcher-link')); + + info.modes = + info.containers + .map(container => + Array.from(container.classList) + .find(className => className.startsWith('scope-')) + .slice('scope-'.length)); +} + +function addScopedChronologyLinksPageHandlers() { + const info = scopedChronologyLinksInfo; + const {session} = scopedChronologyLinksInfo; + + if (!info.switcher) { + return; + } + + for (const [index, { + container: currentContainer, + switcherLink: currentSwitcherLink, + }] of stitchArrays({ + container: info.containers, + switcherLink: info.switcherLinks, + }).entries()) { + const nextContainer = + atOffset(info.containers, index, +1, {wrap: true}); + + const nextSwitcherLink = + atOffset(info.switcherLinks, index, +1, {wrap: true}); + + const nextMode = + atOffset(info.modes, index, +1, {wrap: true}); + + currentSwitcherLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + cssProp(currentContainer, 'display', 'none'); + cssProp(currentSwitcherLink, 'display', 'none'); + cssProp(nextContainer, 'display', 'block'); + cssProp(nextSwitcherLink, 'display', 'inline'); + + session.selectedMode = nextMode; + }); + } +} + +function mutateScopedChronologyLinksContent() { + const info = scopedChronologyLinksInfo; + + if (!info.switcher) { + return; + } + + const {selectedMode} = info.session; + + if (info.modes.includes(selectedMode)) { + const selectedIndex = info.modes.indexOf(selectedMode); + + for (const [index, { + container, + switcherLink, + }] of stitchArrays({ + container: info.containers, + switcherLink: info.switcherLinks, + }).entries()) { + if (index === selectedIndex) { + cssProp(container, 'display', 'block'); + cssProp(switcherLink, 'display', 'inline'); + } else { + cssProp(container, 'display', 'none'); + cssProp(switcherLink, 'display', 'none'); + } + } + } +} + +clientSteps.getPageReferences.push(getScopedChronologyLinksReferences); +clientSteps.mutatePageContent.push(mutateScopedChronologyLinksContent); +clientSteps.addPageListeners.push(addScopedChronologyLinksPageHandlers); + // Group contributions table ------------------------------ // TODO: Update to clientSteps style. @@ -3260,6 +3524,1148 @@ clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences); clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners); clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners); +// Internal search functionality -------------------------- + +const wikiSearchInfo = initInfo('wikiSearchInfo', { + state: { + worker: null, + + workerReadyPromise: null, + workerReadyPromiseResolvers: null, + + workerActionCounter: 0, + workerActionPromiseResolverMap: new Map(), + + downloads: Object.create(null), + }, + + event: { + whenWorkerAlive: [], + whenWorkerReady: [], + whenWorkerFailsToInitialize: [], + whenWorkerHasRuntimeError: [], + + whenDownloadBegins: [], + whenDownloadsBegin: [], + whenDownloadProgresses: [], + whenDownloadEnds: [], + }, +}); + +async function initializeSearchWorker() { + const {state} = wikiSearchInfo; + + if (state.worker) { + return await state.workerReadyPromise; + } + + state.worker = + new Worker( + import.meta.resolve('./search-worker.js'), + {type: 'module'}); + + state.worker.onmessage = handleSearchWorkerMessage; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerReadyPromiseResolvers = {resolve, reject}; + + return await (state.workerReadyPromise = promise); +} + +function handleSearchWorkerMessage(message) { + switch (message.data.kind) { + case 'status': + handleSearchWorkerStatusMessage(message); + break; + + case 'result': + handleSearchWorkerResultMessage(message); + break; + + case 'download-begun': + handleSearchWorkerDownloadBegunMessage(message); + break; + + case 'download-progress': + handleSearchWorkerDownloadProgressMessage(message); + break; + + case 'download-complete': + handleSearchWorkerDownloadCompleteMessage(message); + break; + + default: + console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`); + break; + } +} + +function handleSearchWorkerStatusMessage(message) { + const {state, event} = wikiSearchInfo; + + switch (message.data.status) { + case 'alive': + console.debug(`Search worker is alive, but not yet ready.`); + dispatchInternalEvent(event, 'whenWorkerAlive'); + break; + + case 'ready': + console.debug(`Search worker has loaded corpuses and is ready.`); + state.workerReadyPromiseResolvers.resolve(state.worker); + dispatchInternalEvent(event, 'whenWorkerReady'); + break; + + case 'setup-error': + console.debug(`Search worker failed to initialize.`); + state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker')); + dispatchInternalEvent(event, 'whenWorkerFailsToInitialize'); + break; + + case 'runtime-error': + console.debug(`Search worker had an uncaught runtime error.`); + dispatchInternalEvent(event, 'whenWorkerHasRuntimeError'); + break; + + default: + console.warn(`Unknown status "${message.data.status}" <- from search worker`); + break; + } +} + +function handleSearchWorkerResultMessage(message) { + const {state} = wikiSearchInfo; + const {id} = message.data; + + if (!id) { + console.warn(`Result without id <- from search worker:`, message.data); + return; + } + + if (!state.workerActionPromiseResolverMap.has(id)) { + console.warn(`Runaway result id <- from search worker:`, message.data); + return; + } + + const {resolve, reject} = + state.workerActionPromiseResolverMap.get(id); + + switch (message.data.status) { + case 'resolve': + resolve(message.data.value); + break; + + case 'reject': + reject(message.data.value); + break; + + default: + console.warn(`Unknown result status "${message.data.status}" <- from search worker`); + return; + } + + state.workerActionPromiseResolverMap.delete(id); +} + +function handleSearchWorkerDownloadBegunMessage(message) { + const {event} = wikiSearchInfo; + const {context: contextKey, keys} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey, true); + + for (const key of keys) { + context[key] = 0.00; + + dispatchInternalEvent(event, 'whenDownloadBegins', { + context: contextKey, + key, + }); + } + + dispatchInternalEvent(event, 'whenDownloadsBegin', { + context: contextKey, + keys, + }); +} + +function handleSearchWorkerDownloadProgressMessage(message) { + const {event} = wikiSearchInfo; + const {context: contextKey, key, progress} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = progress; + + dispatchInternalEvent(event, 'whenDownloadProgresses', { + context: contextKey, + key, + progress, + }); +} + +function handleSearchWorkerDownloadCompleteMessage(message) { + const {event} = wikiSearchInfo; + const {context: contextKey, key} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = 1.00; + + dispatchInternalEvent(event, 'whenDownloadEnds', { + context: contextKey, + key, + }); +} + +function getSearchWorkerDownloadContext(context, initialize = false) { + const {state} = wikiSearchInfo; + + if (context in state.downloads) { + return state.downloads[context]; + } + + if (!initialize) { + return null; + } + + return state.downloads[context] = Object.create(null); +} + +async function postSearchWorkerAction(action, options) { + const {state} = wikiSearchInfo; + + const worker = await initializeSearchWorker(); + const id = ++state.workerActionCounter; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerActionPromiseResolverMap.set(id, {resolve, reject}); + + worker.postMessage({ + kind: 'action', + action: action, + id, + options, + }); + + return await promise; +} + +async function searchAll(query, options = {}) { + return await postSearchWorkerAction('search', { + query, + options, + }); +} + +// Sidebar search box ------------------------------------- + +const sidebarSearchInfo = initInfo('sidebarSearchInfo', { + pageContainer: null, + + searchSidebarColumn: null, + searchBox: null, + searchInput: null, + + progressRule: null, + progressContainer: null, + progressLabel: null, + progressBar: null, + + failedRule: null, + failedContainer: null, + + resultsRule: null, + resultsContainer: null, + results: null, + + endSearchRule: null, + endSearchLine: null, + endSearchLink: null, + + preparingString: null, + loadingDataString: null, + searchingString: null, + failedString: null, + + noResultsString: null, + currentResultString: null, + endSearchString: null, + + albumResultKindString: null, + artistResultKindString: null, + groupResultKindString: null, + tagResultKindString: null, + + state: { + sidebarColumnShownForSearch: null, + + tidiedSidebar: null, + collapsedDetailsForTidiness: null, + + workerStatus: null, + searchStage: null, + + stoppedTypingTimeout: null, + stoppedScrollingTimeout: null, + + indexDownloadStatuses: Object.create(null), + }, + + session: { + activeQuery: { + type: 'string', + }, + + activeQueryResults: { + type: 'json', + maxLength: settings => settings.maxActiveResultsStorage, + }, + + repeatQueryOnReload: { + type: 'boolean', + default: false, + }, + + resultsScrollOffset: { + type: 'number', + }, + }, + + settings: { + stoppedTypingDelay: 800, + stoppedScrollingDelay: 200, + + maxActiveResultsStorage: 100000, + }, +}); + +function getSidebarSearchReferences() { + const info = sidebarSearchInfo; + + info.pageContainer = + document.getElementById('page-container'); + + info.searchBox = + document.querySelector('.wiki-search-sidebar-box'); + + if (!info.searchBox) { + return; + } + + info.searchInput = + info.searchBox.querySelector('.wiki-search-input'); + + info.searchSidebarColumn = + info.searchBox.closest('.sidebar-column'); + + const findString = classPart => + info.searchBox.querySelector(`.wiki-search-${classPart}-string`); + + info.preparingString = + findString('preparing'); + + info.loadingDataString = + findString('loading-data'); + + info.searchingString = + findString('searching'); + + info.failedString = + findString('failed'); + + info.noResultsString = + findString('no-results'); + + info.currentResultString = + findString('current-result'); + + info.endSearchString = + findString('end-search'); + + info.albumResultKindString = + findString('album-result-kind'); + + info.artistResultKindString = + findString('artist-result-kind'); + + info.groupResultKindString = + findString('group-result-kind'); + + info.tagResultKindString = + findString('tag-result-kind'); +} + +function addSidebarSearchInternalListeners() { + const info = sidebarSearchInfo; + + if (!info.searchBox) return; + + wikiSearchInfo.event.whenWorkerAlive.push( + trackSidebarSearchWorkerAlive, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerReady.push( + trackSidebarSearchWorkerReady, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerFailsToInitialize.push( + trackSidebarSearchWorkerFailsToInitialize, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerHasRuntimeError.push( + trackSidebarSearchWorkerHasRuntimeError, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadsBegin.push( + trackSidebarSearchDownloadsBegin, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadProgresses.push( + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadEnds.push( + trackSidebarSearchDownloadEnds, + updateSidebarSearchStatus); +} + +function mutateSidebarSearchContent() { + const info = sidebarSearchInfo; + + if (!info.searchBox) return; + + // Progress section + + info.progressRule = + document.createElement('hr'); + + info.progressContainer = + document.createElement('div'); + + info.progressContainer.classList.add('wiki-search-progress-container'); + + cssProp(info.progressRule, 'display', 'none'); + cssProp(info.progressContainer, 'display', 'none'); + + info.progressLabel = + document.createElement('label'); + + info.progressLabel.classList.add('wiki-search-progress-label'); + info.progressLabel.htmlFor = 'wiki-search-progress-bar'; + + info.progressBar = + document.createElement('progress'); + + info.progressBar.classList.add('wiki-search-progress-bar'); + info.progressBar.id = 'wiki-search-progress-bar'; + + info.progressContainer.appendChild(info.progressLabel); + info.progressContainer.appendChild(info.progressBar); + + info.searchBox.appendChild(info.progressRule); + info.searchBox.appendChild(info.progressContainer); + + // Search failed section + + info.failedRule = + document.createElement('hr'); + + info.failedContainer = + document.createElement('div'); + + info.failedContainer.classList.add('wiki-search-failed-container'); + + { + const p = document.createElement('p'); + p.appendChild(templateContent(info.failedString)); + info.failedContainer.appendChild(p); + } + + cssProp(info.failedRule, 'display', 'none'); + cssProp(info.failedContainer, 'display', 'none'); + + info.searchBox.appendChild(info.failedRule); + info.searchBox.appendChild(info.failedContainer); + + // Results section + + info.resultsRule = + document.createElement('hr'); + + info.resultsContainer = + document.createElement('div'); + + info.resultsContainer.classList.add('wiki-search-results-container'); + + cssProp(info.resultsRule, 'display', 'none'); + cssProp(info.resultsContainer, 'display', 'none'); + + info.results = + document.createElement('div'); + + info.results.classList.add('wiki-search-results'); + + info.resultsContainer.appendChild(info.results); + + info.searchBox.appendChild(info.resultsRule); + info.searchBox.appendChild(info.resultsContainer); + + // End search section + + info.endSearchRule = + document.createElement('hr'); + + info.endSearchLine = + document.createElement('p'); + + info.endSearchLink = + document.createElement('a'); + + { + const p = info.endSearchLine; + const a = info.endSearchLink; + p.classList.add('wiki-search-end-search-line'); + a.setAttribute('href', '#'); + a.appendChild(templateContent(info.endSearchString)); + p.appendChild(a); + } + + cssProp(info.endSearchRule, 'display', 'none'); + cssProp(info.endSearchLine, 'display', 'none'); + + info.searchBox.appendChild(info.endSearchRule); + info.searchBox.appendChild(info.endSearchLine); +} + +function addSidebarSearchListeners() { + const info = sidebarSearchInfo; + + if (!info.searchInput) return; + + info.searchInput.addEventListener('change', domEvent => { + if (info.searchInput.value) { + activateSidebarSearch(info.searchInput.value); + } + }); + + info.searchInput.addEventListener('input', domEvent => { + const {settings, state} = info; + + if (!info.searchInput.value) { + clearSidebarSearch(); + return; + } + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + } + + state.stoppedTypingTimeout = + setTimeout(() => { + activateSidebarSearch(info.searchInput.value); + }, settings.stoppedTypingDelay); + }); + + info.endSearchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + clearSidebarSearch(); + possiblyHideSearchSidebarColumn(); + restoreSidebarSearchColumn(); + }); + + info.resultsContainer.addEventListener('scroll', () => { + const {settings, state} = info; + + if (state.stoppedScrollingTimeout) { + clearTimeout(state.stoppedScrollingTimeout); + } + + state.stoppedScrollingTimeout = + setTimeout(() => { + saveSidebarSearchResultsScrollOffset(); + }, settings.stoppedScrollingDelay); + }); +} + +function initializeSidebarSearchState() { + const info = sidebarSearchInfo; + const {session} = info; + + if (!info.searchInput) return; + + if (session.activeQuery) { + info.searchInput.value = session.activeQuery; + if (session.repeatQueryOnReload) { + activateSidebarSearch(session.activeQuery); + } else if (session.activeQueryResults) { + showSidebarSearchResults(session.activeQueryResults); + } + } +} + +function trackSidebarSearchWorkerAlive() { + const {state} = sidebarSearchInfo; + + state.workerStatus = 'alive'; +} + +function trackSidebarSearchWorkerReady() { + const {state} = sidebarSearchInfo; + + state.workerStatus = 'ready'; + state.searchStage = 'searching'; +} + +function trackSidebarSearchWorkerFailsToInitialize() { + const {state} = sidebarSearchInfo; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchWorkerHasRuntimeError() { + const {state} = sidebarSearchInfo; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchDownloadsBegin(event) { + const {state} = sidebarSearchInfo; + + if (event.context === 'search-indexes') { + for (const key of event.keys) { + state.indexDownloadStatuses[key] = 'active'; + } + } +} + +function trackSidebarSearchDownloadEnds(event) { + const {state} = sidebarSearchInfo; + + if (event.context === 'search-indexes') { + state.indexDownloadStatuses[event.key] = 'complete'; + + const statuses = Object.values(state.indexDownloadStatuses); + if (statuses.every(status => status === 'complete')) { + for (const key of Object.keys(state.indexDownloadStatuses)) { + delete state.indexDownloadStatuses[key]; + } + } + } +} + +async function activateSidebarSearch(query) { + const {session, settings, state} = sidebarSearchInfo; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + state.searchStage = + (state.workerStatus === 'ready' + ? 'searching' + : 'preparing'); + updateSidebarSearchStatus(); + + let results; + try { + results = await searchAll(query, {enrich: true}); + } catch (error) { + console.error(`There was an error performing a sidebar search:`); + console.error(error); + showSidebarSearchFailed(); + return; + } + + state.searchStage = 'complete'; + updateSidebarSearchStatus(); + + session.activeQuery = query; + session.activeQueryResults = results; + session.resultsScrollOffset = 0; + + showSidebarSearchResults(results); +} + +function clearSidebarSearch() { + const info = sidebarSearchInfo; + const {session, state} = info; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + info.searchBox.classList.remove('showing-results'); + info.searchSidebarColumn.classList.remove('search-showing-results'); + + info.searchInput.value = ''; + + state.searchStage = null; + + session.activeQuery = null; + session.activeQueryResults = null; + session.resultsScrollOffset = null; + + hideSidebarSearchResults(); +} + +function updateSidebarSearchStatus() { + const info = sidebarSearchInfo; + const {state} = info; + + if (state.searchStage === 'failed') { + hideSidebarSearchResults(); + showSidebarSearchFailed(); + + return; + } + + const searchIndexDownloads = + getSearchWorkerDownloadContext('search-indexes'); + + const downloadProgressValues = + Object.values(searchIndexDownloads ?? {}); + + if (downloadProgressValues.some(v => v < 1.00)) { + const total = Object.keys(state.indexDownloadStatuses).length; + const sum = accumulateSum(downloadProgressValues); + showSidebarSearchProgress( + sum / total, + templateContent(info.loadingDataString)); + + return; + } + + if (state.searchStage === 'preparing') { + showSidebarSearchProgress( + null, + templateContent(info.preparingString)); + + return; + } + + if (state.searchStage === 'searching') { + showSidebarSearchProgress( + null, + templateContent(info.searchingString)); + + return; + } + + hideSidebarSearchProgress(); +} + +function showSidebarSearchProgress(progress, label) { + const info = sidebarSearchInfo; + + cssProp(info.progressRule, 'display', null); + cssProp(info.progressContainer, 'display', null); + + if (progress === null) { + info.progressBar.removeAttribute('value'); + } else { + info.progressBar.value = progress; + } + + while (info.progressLabel.firstChild) { + info.progressLabel.firstChild.remove(); + } + + info.progressLabel.appendChild(label); +} + +function hideSidebarSearchProgress() { + const info = sidebarSearchInfo; + + cssProp(info.progressRule, 'display', 'none'); + cssProp(info.progressContainer, 'display', 'none'); +} + +function showSidebarSearchFailed() { + const info = sidebarSearchInfo; + const {state} = info; + + hideSidebarSearchProgress(); + hideSidebarSearchResults(); + + cssProp(info.failedRule, 'display', null); + cssProp(info.failedContainer, 'display', null); + + info.searchInput.disabled = true; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } +} + +function showSidebarSearchResults(results) { + const info = sidebarSearchInfo; + + console.debug(`Showing search results:`, results); + + showSearchSidebarColumn(); + + const flatResults = + Object.entries(results) + .filter(([index]) => index === 'generic') + .flatMap(([index, results]) => results + .flatMap(({doc, id}) => ({ + index, + reference: id ?? null, + referenceType: (id ? id.split(':')[0] : null), + directory: (id ? id.split(':')[1] : null), + data: doc, + }))); + + info.searchBox.classList.add('showing-results'); + info.searchSidebarColumn.classList.add('search-showing-results'); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.resultsRule, 'display', 'block'); + cssProp(info.resultsContainer, 'display', 'block'); + + if (empty(flatResults)) { + const p = document.createElement('p'); + p.classList.add('wiki-search-no-results'); + p.appendChild(templateContent(info.noResultsString)); + info.results.appendChild(p); + } + + for (const result of flatResults) { + const el = generateSidebarSearchResult(result); + if (!el) continue; + + info.results.appendChild(el); + } + + if (!empty(flatResults)) { + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); + + tidySidebarSearchColumn(); + } + + restoreSidebarSearchResultsScrollOffset(); +} + +function generateSidebarSearchResult(result) { + const info = sidebarSearchInfo; + + const preparedSlots = { + color: + result.data.color ?? null, + + name: + result.data.name ?? result.data.primaryName ?? null, + + imageSource: + getSearchResultImageSource(result), + }; + + switch (result.referenceType) { + case 'album': { + preparedSlots.href = + openAlbum(result.directory); + + preparedSlots.kindString = + info.albumResultKindString; + + break; + } + + case 'artist': { + preparedSlots.href = + openArtist(result.directory); + + preparedSlots.kindString = + info.artistResultKindString; + + break; + } + + case 'group': { + preparedSlots.href = + openGroup(result.directory); + + preparedSlots.kindString = + info.groupResultKindString; + + break; + } + + case 'flash': { + preparedSlots.href = + openFlash(result.directory); + + break; + } + + case 'tag': { + preparedSlots.href = + openArtTag(result.directory); + + preparedSlots.kindString = + info.tagResultKindString; + + break; + } + + case 'track': { + preparedSlots.href = + openTrack(result.directory); + + break; + } + + default: + return null; + } + + return generateSidebarSearchResultTemplate(preparedSlots); +} + +function getSearchResultImageSource(result) { + const {artwork} = result.data; + if (!artwork) return null; + + return ( + rebase( + artwork.replace('<>', result.directory), + 'rebaseThumb')); +} + +function generateSidebarSearchResultTemplate(slots) { + const info = sidebarSearchInfo; + + const link = document.createElement('a'); + link.classList.add('wiki-search-result'); + + if (slots.href) { + link.setAttribute('href', slots.href); + } + + if (slots.color) { + cssProp(link, '--primary-color', slots.color); + + try { + const colors = getColors(slots.color, {chroma}); + cssProp(link, '--light-ghost-color', colors.lightGhost); + cssProp(link, '--deep-color', colors.deep); + } catch (error) { + console.warn(error); + } + } + + const imgContainer = document.createElement('span'); + imgContainer.classList.add('wiki-search-result-image-container'); + + if (slots.imageSource) { + const img = document.createElement('img'); + img.classList.add('wiki-search-result-image'); + img.setAttribute('src', slots.imageSource); + imgContainer.appendChild(img); + if (slots.imageSource.endsWith('.mini.jpg')) { + img.classList.add('has-warning'); + } + } else { + const placeholder = document.createElement('span'); + placeholder.classList.add('wiki-search-result-image-placeholder'); + imgContainer.appendChild(placeholder); + } + + link.appendChild(imgContainer); + + const text = document.createElement('span'); + text.classList.add('wiki-search-result-text-area'); + + if (slots.name) { + const span = document.createElement('span'); + span.classList.add('wiki-search-result-name'); + span.appendChild(document.createTextNode(slots.name)); + text.appendChild(span); + } + + let accentSpan = null; + + if (link.href) { + const here = location.href.replace(/\/$/, ''); + const there = link.href.replace(/\/$/, ''); + if (here === there) { + link.classList.add('current-result'); + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-current-result-text'); + accentSpan.appendChild(templateContent(info.currentResultString)); + } + } + + if (!accentSpan && slots.kindString) { + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-result-kind'); + accentSpan.appendChild(templateContent(slots.kindString)); + } + + if (accentSpan) { + text.appendChild(document.createTextNode(' ')); + text.appendChild(accentSpan); + } + + link.appendChild(text); + + link.addEventListener('click', () => { + saveSidebarSearchResultsScrollOffset(); + }); + + return link; +} + +function hideSidebarSearchResults() { + const info = sidebarSearchInfo; + + cssProp(info.resultsRule, 'display', 'none'); + cssProp(info.resultsContainer, 'display', 'none'); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.endSearchRule, 'display', 'none'); + cssProp(info.endSearchLine, 'display', 'none'); +} + +function saveSidebarSearchResultsScrollOffset() { + const info = sidebarSearchInfo; + const {session} = info; + + session.resultsScrollOffset = info.resultsContainer.scrollTop; +} + +function restoreSidebarSearchResultsScrollOffset() { + const info = sidebarSearchInfo; + const {session} = info; + + if (session.resultsScrollOffset) { + info.resultsContainer.scrollTop = session.resultsScrollOffset; + } +} + +function showSearchSidebarColumn() { + const info = sidebarSearchInfo; + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!info.searchSidebarColumn.classList.contains('initially-hidden')) { + return; + } + + info.searchSidebarColumn.classList.remove('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.add('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.add('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = true; +} + +function possiblyHideSearchSidebarColumn() { + const info = sidebarSearchInfo; + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!state.sidebarColumnShownForSearch) { + return; + } + + info.searchSidebarColumn.classList.add('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.remove('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.remove('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = null; +} + +// This should be called after results are shown, since it checks the +// elements added to understand the current search state. +function tidySidebarSearchColumn() { + const info = sidebarSearchInfo; + const {state} = info; + + // Don't *re-tidy* the sidebar if we've already tidied it to display + // some results. This flag will get cleared if the search is dismissed + // altogether (and the pre-tidy state is restored). + if (state.tidiedSidebar) { + return; + } + + const here = location.href.replace(/\/$/, ''); + const currentPageIsResult = + Array.from(info.results.querySelectorAll('a')) + .some(link => { + const there = link.href.replace(/\/$/, ''); + return here === there; + }); + + // Don't tidy the sidebar if you've navigated to some other page than + // what's in the current result list. + if (!currentPageIsResult) { + return; + } + + state.tidiedSidebar = true; + state.collapsedDetailsForTidiness = []; + + for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) { + if (box === info.searchBox) { + continue; + } + + for (const details of box.getElementsByTagName('details')) { + if (details.open) { + details.removeAttribute('open'); + state.collapsedDetailsForTidiness.push(details); + } + } + } +} + +function restoreSidebarSearchColumn() { + const {state} = sidebarSearchInfo; + + if (!state.tidiedSidebar) { + return; + } + + for (const details of state.collapsedDetailsForTidiness) { + details.setAttribute('open', ''); + } + + state.collapsedDetailsForTidiness = []; + state.tidiedSidebar = null; +} + +clientSteps.getPageReferences.push(getSidebarSearchReferences); +clientSteps.addInternalListeners.push(addSidebarSearchInternalListeners); +clientSteps.mutatePageContent.push(mutateSidebarSearchContent); +clientSteps.addPageListeners.push(addSidebarSearchListeners); +clientSteps.initializeState.push(initializeSidebarSearchState); + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', { @@ -3476,8 +4882,8 @@ for (const [key, steps] of Object.entries(clientSteps)) { try { step(); } catch (error) { - console.warn(`During ${key}, failed to run ${step.name}`); - console.debug(error); + console.error(`During ${key}, failed to run ${step.name}`); + console.error(error); } } } diff --git a/src/static/lazy-loading.js b/src/static/js/lazy-loading.js index 1df56f08..1df56f08 100644 --- a/src/static/lazy-loading.js +++ b/src/static/js/lazy-loading.js diff --git a/src/static/js/module-import-shims.js b/src/static/js/module-import-shims.js new file mode 100644 index 00000000..e7e1e0cc --- /dev/null +++ b/src/static/js/module-import-shims.js @@ -0,0 +1,27 @@ +export const loadDependency = { + async fromWindow(modulePath) { + globalThis.window = {}; + + await import(modulePath); + + const exports = globalThis.window; + + delete globalThis.window; + + return exports; + }, + + async fromModuleExports(modulePath) { + globalThis.exports = {}; + globalThis.module = {exports: globalThis.exports}; + + await import(modulePath); + + const exports = globalThis.exports; + + delete globalThis.module; + delete globalThis.exports; + + return exports; + }, +}; diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js new file mode 100644 index 00000000..8d987a74 --- /dev/null +++ b/src/static/js/search-worker.js @@ -0,0 +1,621 @@ +/* eslint-env worker */ + +import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js'; + +import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js'; + +import { + empty, + groupArray, + promiseWithResolvers, + stitchArrays, + unique, + withEntries, +} from '../shared-util/sugar.js'; + +import {loadDependency} from './module-import-shims.js'; +import {fetchWithProgress} from './xhr-util.js'; + +// Will be loaded from dependencies. +let decompress; +let unpack; + +let idb; + +let status = null; +let indexes = null; + +onmessage = handleWindowMessage; +onerror = handleRuntimeError; +onunhandledrejection = handleRuntimeError; +postStatus('alive'); + +Promise.all([ + loadDependencies(), + loadDatabase(), +]).then(main) + .then( + () => { + postStatus('ready'); + }, + error => { + console.error(`Search worker setup error:`, error); + postStatus('setup-error'); + }); + +async function loadDependencies() { + const {compressJSON} = + await loadDependency.fromWindow('../lib/compress-json/bundle.min.js'); + + const msgpackr = + await loadDependency.fromModuleExports('../lib/msgpackr/index.js'); + + ({decompress} = compressJSON); + ({unpack} = msgpackr); +} + +async function promisifyIDBRequest(request) { + const {promise, resolve, reject} = promiseWithResolvers(); + + request.addEventListener('success', () => resolve(request.result)); + request.addEventListener('error', () => reject(request.error)); + + return promise; +} + +async function* iterateIDBObjectStore(store, query) { + const request = + store.openCursor(query); + + let promise, resolve, reject; + let cursor; + + request.onsuccess = () => { + cursor = request.result; + if (cursor) { + resolve({done: false, value: [cursor.key, cursor.value]}); + } else { + resolve({done: true}); + } + }; + + request.onerror = () => { + reject(request.error); + }; + + do { + ({promise, resolve, reject} = promiseWithResolvers()); + + const result = await promise; + + if (result.done) { + return; + } + + yield result.value; + + cursor.continue(); + } while (true); +} + +async function loadCachedIndexFromIDB() { + if (!idb) return null; + + const transaction = + idb.transaction(['indexes'], 'readwrite'); + + const store = + transaction.objectStore('indexes'); + + const result = {}; + + for await (const [key, object] of iterateIDBObjectStore(store)) { + result[key] = object; + } + + return result; +} + +async function loadDatabase() { + const request = + globalThis.indexedDB.open('hsmusicSearchDatabase', 4); + + request.addEventListener('upgradeneeded', () => { + const idb = request.result; + + idb.createObjectStore('indexes', { + keyPath: 'key', + }); + }); + + try { + idb = await promisifyIDBRequest(request); + } catch (error) { + console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`); + console.warn(request.error); + idb = null; + } +} + +function rebase(path) { + return `/search-data/` + path; +} + +async function prepareIndexData() { + return Promise.all([ + fetch(rebase('index.json')) + .then(resp => resp.json()), + + loadCachedIndexFromIDB(), + ]).then( + ([indexData, idbIndexData]) => + ({indexData, idbIndexData})); +} + +function fetchIndexes(keysNeedingFetch) { + if (!empty(keysNeedingFetch)) { + postMessage({ + kind: 'download-begun', + context: 'search-indexes', + keys: keysNeedingFetch, + }); + } + + return ( + keysNeedingFetch.map(key => + fetchWithProgress( + rebase(key + '.json.msgpack'), + progress => { + postMessage({ + kind: 'download-progress', + context: 'search-indexes', + progress: progress / 1.00, + key, + }); + }).then(response => { + postMessage({ + kind: 'download-complete', + context: 'search-indexes', + key, + }); + + return response; + }))); +} + +async function main() { + const prepareIndexDataPromise = prepareIndexData(); + + indexes = + withEntries(searchSpec, entries => entries + .map(([key, descriptor]) => [ + key, + makeSearchIndex(descriptor, {FlexSearch}), + ])); + + const {indexData, idbIndexData} = await prepareIndexDataPromise; + + const keysNeedingFetch = + (idbIndexData + ? Object.keys(indexData) + .filter(key => + indexData[key].md5 !== + idbIndexData[key]?.md5) + : Object.keys(indexData)); + + const keysFromCache = + Object.keys(indexData) + .filter(key => !keysNeedingFetch.includes(key)) + + const cacheArrayBufferPromises = + keysFromCache + .map(key => idbIndexData[key]) + .map(({cachedBinarySource}) => + cachedBinarySource.arrayBuffer()); + + const fetchPromises = + fetchIndexes(keysNeedingFetch); + + const fetchBlobPromises = + fetchPromises + .map(promise => promise + .then(response => response.blob())); + + const fetchArrayBufferPromises = + fetchBlobPromises + .map(promise => promise + .then(blob => blob.arrayBuffer())); + + function arrayBufferToJSON(data) { + data = new Uint8Array(data); + data = unpack(data); + data = decompress(data); + return data; + } + + function importIndexes(keys, jsons) { + stitchArrays({key: keys, json: jsons}) + .forEach(({key, json}) => { + importIndex(key, json); + }); + } + + if (idb) { + console.debug(`Reusing indexes from search cache:`, keysFromCache); + console.debug(`Fetching indexes anew:`, keysNeedingFetch); + } + + await Promise.all([ + async () => { + const cacheArrayBuffers = + await Promise.all(cacheArrayBufferPromises); + + const cacheJSONs = + cacheArrayBuffers + .map(arrayBufferToJSON); + + importIndexes(keysFromCache, cacheJSONs); + }, + + async () => { + const fetchArrayBuffers = + await Promise.all(fetchArrayBufferPromises); + + const fetchJSONs = + fetchArrayBuffers + .map(arrayBufferToJSON); + + importIndexes(keysNeedingFetch, fetchJSONs); + }, + + async () => { + if (!idb) return; + + const fetchBlobs = + await Promise.all(fetchBlobPromises); + + const transaction = + idb.transaction(['indexes'], 'readwrite'); + + const store = + transaction.objectStore('indexes'); + + for (const {key, blob} of stitchArrays({ + key: keysNeedingFetch, + blob: fetchBlobs, + })) { + const value = { + key, + md5: indexData[key].md5, + cachedBinarySource: blob, + }; + + try { + await promisifyIDBRequest(store.put(value)); + } catch (error) { + console.warn(`Error saving ${key} to internal search cache:`, value); + console.warn(error); + continue; + } + } + }, + ].map(fn => fn())); +} + +function importIndex(indexKey, indexData) { + // If this fails, it's because an outdated index was cached. + // TODO: If this fails, try again once with a cache busting url. + for (const [key, value] of Object.entries(indexData)) { + indexes[indexKey].import(key, JSON.stringify(value)); + } +} + +function handleRuntimeError() { + postStatus('runtime-error'); +} + +function handleWindowMessage(message) { + switch (message.data.kind) { + case 'action': + handleWindowActionMessage(message); + break; + + default: + console.warn(`Unknown message kind -> to search worker:`, message.data); + break; + } +} + +async function handleWindowActionMessage(message) { + const {id} = message.data; + + if (!id) { + console.warn(`Action without id -> to search worker:`, message.data); + return; + } + + if (status !== 'ready') { + return postActionResult(id, 'reject', 'not ready'); + } + + let value; + + switch (message.data.action) { + case 'search': + value = await performSearchAction(message.data.options); + break; + + default: + console.warn(`Unknown action "${message.data.action}" -> to search worker:`, message.data); + return postActionResult(id, 'reject', 'unknown action'); + } + + await postActionResult(id, 'resolve', value); +} + +function postStatus(newStatus) { + status = newStatus; + postMessage({ + kind: 'status', + status: newStatus, + }); +} + +function postActionResult(id, status, value) { + postMessage({ + kind: 'result', + id, + status, + value, + }); +} + +function performSearchAction({query, options}) { + const {generic, ...otherIndexes} = indexes; + + const genericResults = + queryGenericIndex(generic, query, options); + + const otherResults = + withEntries(otherIndexes, entries => entries + .map(([indexName, index]) => [ + indexName, + index.search(query, options), + ])); + + return { + generic: genericResults, + ...otherResults, + }; +} + +function queryGenericIndex(index, query, options) { + const interestingFieldCombinations = [ + ['primaryName', 'parentName', 'groups'], + ['primaryName', 'parentName'], + ['primaryName', 'groups', 'contributors'], + ['primaryName', 'groups', 'artTags'], + ['primaryName', 'groups'], + ['primaryName', 'contributors'], + ['primaryName', 'artTags'], + ['parentName', 'groups', 'artTags'], + ['parentName', 'artTags'], + ['groups', 'contributors'], + ['groups', 'artTags'], + + // This prevents just matching *everything* tagged "john" if you + // only search "john", but it actually supports matching more than + // *two* tags at once: "john rose lowas" works! This is thanks to + // flexsearch matching multiple field values in a single query. + ['artTags', 'artTags'], + + ['contributors', 'parentName'], + ['contributors', 'groups'], + ['primaryName', 'contributors'], + ['primaryName'], + ]; + + const interestingFields = + unique(interestingFieldCombinations.flat()); + + const {genericTerms, queriedKind} = + processTerms(query); + + const particles = + particulate(genericTerms); + + const groupedParticles = + groupArray(particles, ({length}) => length); + + const queriesBy = keys => + (groupedParticles.get(keys.length) ?? []) + .flatMap(permutations) + .map(values => values.map(({terms}) => terms.join(' '))) + .map(values => + stitchArrays({ + field: keys, + query: values, + })); + + const boilerplate = queryBoilerplate(index); + + const particleResults = + Object.fromEntries( + interestingFields.map(field => [ + field, + Object.fromEntries( + particles.flat() + .map(({terms}) => terms.join(' ')) + .map(query => [ + query, + new Set( + boilerplate + .query(query, { + ...options, + field, + limit: Infinity, + }) + .fieldResults[field]), + ])), + ])); + + const results = new Set(); + + for (const interestingFieldCombination of interestingFieldCombinations) { + for (const query of queriesBy(interestingFieldCombination)) { + const idToMatchingFieldsMap = new Map(); + for (const {field, query: fieldQuery} of query) { + for (const id of particleResults[field][fieldQuery]) { + if (idToMatchingFieldsMap.has(id)) { + idToMatchingFieldsMap.get(id).push(field); + } else { + idToMatchingFieldsMap.set(id, [field]); + } + } + } + + const commonAcrossFields = + Array.from(idToMatchingFieldsMap.entries()) + .filter(([id, matchingFields]) => + matchingFields.length === interestingFieldCombination.length) + .map(([id]) => id); + + for (const result of commonAcrossFields) { + results.add(result); + } + } + } + + const constituted = + boilerplate.constitute(results); + + const constitutedAndFiltered = + constituted + .filter(({id}) => + (queriedKind + ? id.split(':')[0] === queriedKind + : true)); + + return constitutedAndFiltered; +} + +function processTerms(query) { + const kindTermSpec = [ + {kind: 'album', terms: ['album']}, + {kind: 'artist', terms: ['artist']}, + {kind: 'flash', terms: ['flash']}, + {kind: 'group', terms: ['group']}, + {kind: 'tag', terms: ['art tag', 'tag']}, + {kind: 'track', terms: ['track']}, + ]; + + const genericTerms = []; + let queriedKind = null; + + const termRegexp = + new RegExp( + String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` + + String.raw`|\S+`, + 'gi'); + + for (const match of query.matchAll(termRegexp)) { + const {groups} = match; + + if (groups.kind && !queriedKind) { + queriedKind = + kindTermSpec + .find(({terms}) => terms.includes(groups.kind.toLowerCase())) + .kind; + + continue; + } + + genericTerms.push(match[0]); + } + + return {genericTerms, queriedKind}; +} + +function particulate(terms) { + if (empty(terms)) return []; + + const results = []; + + for (let slice = 1; slice <= 2; slice++) { + if (slice === terms.length) { + break; + } + + const front = terms.slice(0, slice); + const back = terms.slice(slice); + + results.push(... + particulate(back) + .map(result => [ + {terms: front}, + ...result + ])); + } + + results.push([{terms}]); + + return results; +} + +// This function doesn't even come close to "performant", +// but it only operates on small data here. +function permutations(array) { + switch (array.length) { + case 0: + return []; + + case 1: + return [array]; + + default: + return array.flatMap((item, index) => { + const behind = array.slice(0, index); + const ahead = array.slice(index + 1); + return ( + permutations([...behind, ...ahead]) + .map(rest => [item, ...rest])); + }); + } +} + +function queryBoilerplate(index) { + const idToDoc = {}; + + return { + idToDoc, + + constitute: (ids) => + Array.from(ids) + .map(id => ({id, doc: idToDoc[id]})), + + query: (query, options) => { + const rawResults = + index.search(query, options); + + const fieldResults = + Object.fromEntries( + rawResults + .map(({field, result}) => [ + field, + result.map(result => + (typeof result === 'string' + ? result + : result.id)), + ])); + + Object.assign( + idToDoc, + Object.fromEntries( + rawResults + .flatMap(({result}) => result) + .map(({id, doc}) => [id, doc]))); + + return {rawResults, fieldResults}; + }, + }; +} diff --git a/src/static/js/xhr-util.js b/src/static/js/xhr-util.js new file mode 100644 index 00000000..8a43072c --- /dev/null +++ b/src/static/js/xhr-util.js @@ -0,0 +1,64 @@ +/* eslint-env browser */ + +/** + * This fetch function is adapted from a `loadImage` function + * credited to Parziphal, Feb 13, 2017. + * https://stackoverflow.com/a/42196770 + * + * The callback is generally run with the loading progress as a decimal 0-1. + * However, if it's not possible to compute the progress ration (which might + * only become apparent after a progress amount *has* been sent!), + * the callback will be run with the value -1. + * + * The return promise resolves to a manually instantiated Response object + * which generally behaves the same as a normal fetch response; access headers, + * text, blob, arrayBuffer as usual. Accordingly, non-200 responses do *not* + * reject the prmoise, so be sure to check the response status yourself. + */ +export function fetchWithProgress(url, progressCallback) { + return new Promise(resolve => { + const xhr = new XMLHttpRequest(); + let notifiedNotComputable = false; + + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + + xhr.onprogress = event => { + if (notifiedNotComputable) { + return; + } + + if (!event.lengthComputable) { + notifiedNotComputable = true; + progressCallback(-1); + return; + } + + progressCallback(event.loaded / event.total); + }; + + xhr.onloadend = () => { + const body = xhr.response; + + const options = { + status: xhr.status, + headers: + parseResponseHeaders(xhr.getAllResponseHeaders()), + }; + + resolve(new Response(body, options)); + }; + + xhr.send(); + }); + + function parseResponseHeaders(headers) { + return ( + Object.fromEntries( + headers + .trim() + .split(/[\r\n]+/) + .map(line => line.match(/(.+?):\s*(.+)/)) + .map(match => [match[1], match[2]]))); + } +} diff --git a/src/static/icons.svg b/src/static/misc/icons.svg index 8c9a80a9..8c9a80a9 100644 --- a/src/static/icons.svg +++ b/src/static/misc/icons.svg diff --git a/src/static/warning.svg b/src/static/misc/warning.svg index 92e55778..92e55778 100644 --- a/src/static/warning.svg +++ b/src/static/misc/warning.svg diff --git a/src/static/shared-util/README.md b/src/static/shared-util/README.md new file mode 100644 index 00000000..d21c0e6b --- /dev/null +++ b/src/static/shared-util/README.md @@ -0,0 +1,11 @@ +# `src/static/shared-util` + +Module imports under `src/static/js` may appear to be pointing to files that aren't at quite the right place. For example, the import: + + import {empty} from '../shared-util/sugar.js'; + +...is reading a file that doesn't exist here, under `shared-util`. This isn't an error! + +This folder (`src/shared-util`) does not actually exist in a build of the website; instead, the folder `src/util` is symlinked in its place. So, all files under `src/util` are actually available at (e.g.) `/static/shared-util/` online. + +The above import would actually import from the bindings in `src/util/sugar.js`. |