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/site7.css) | 646 | ||||
-rw-r--r-- | src/static/js/client.js (renamed from src/static/client4.js) | 1557 | ||||
-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 (renamed from src/static/xhr-util.js) | 0 | ||||
-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, 2738 insertions, 124 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/site7.css b/src/static/css/site.css index c23acffb..80801c85 100644 --- a/src/static/site7.css +++ b/src/static/css/site.css @@ -172,6 +172,10 @@ body::before { flex-grow: 1; } +.sidebar-column.initially-hidden { + display: none; +} + .sidebar-multiple { display: flex; flex-direction: column; @@ -226,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; @@ -255,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); @@ -394,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); } @@ -432,6 +471,283 @@ 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-label { + width: calc(100% - 4px); + padding: 2px 4px; + margin: 2px 2px 3px 2px; + box-sizing: border-box; + + display: flex; + flex-direction: row; + + background: transparent; + border: 1px solid var(--dim-color); + border-radius: 3px; +} + +.wiki-search-label::before { + display: inline-block; + padding-left: 3px; + padding-right: 3px; + margin-right: 3px; + width: 1.8em; + text-align: center; + content: '\1f50d\fe0e'; +} + +.wiki-search-input { + background: transparent; + border: transparent; + color: inherit; + flex-grow: 1; +} + +.wiki-search-input::-webkit-search-cancel-button { + filter: grayscale(1) invert(1); +} + +.wiki-search-label.disabled { + opacity: 0.6; +} + +.wiki-search-label.disabled, +.wiki-search-input[disabled] { + cursor: not-allowed; +} + +.wiki-search-label:not(.disabled):hover, +.wiki-search-label:focus-within { + background: var(--light-ghost-color); +} + +.wiki-search-label:focus-within { + border-color: var(--primary-color); +} + +.wiki-search-label:focus-within::before { + opacity: 0.7; +} + +.wiki-search-input:focus { + border: none; + outline: none; +} + +.wiki-search-input::placeholder { + color: var(--primary-color); + font-style: oblique; +} + +.wiki-search-input:focus::placeholder { + color: var(--dim-color); +} + +.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); +} + #content { overflow-wrap: anywhere; } @@ -476,6 +792,7 @@ a:not([href]):hover { .external-link.indicate-external::after { content: '\00a0➚'; + font-style: normal; } .external-link.indicate-external:hover::after { @@ -500,11 +817,6 @@ a:not([href]):hover { content: "\0020/\0020"; } -#header .chronology .heading, -#header .chronology .buttons { - white-space: nowrap; -} - #secondary-nav { text-align: center; } @@ -580,7 +892,7 @@ li:not(:first-child:last-child) .tooltip, 0 -2px 4px -2px var(--primary-color) inset; } -.icons-tooltip { +.contribution-tooltip { padding: 3px 6px 6px 6px; left: -34px; } @@ -601,7 +913,7 @@ li:not(:first-child:last-child) .tooltip, margin-right: -120px; } -.icons-tooltip .tooltip-content { +.contribution-tooltip .tooltip-content { padding: 6px 2px 2px 2px; -webkit-user-select: none; @@ -612,42 +924,122 @@ li:not(:first-child:last-child) .tooltip, display: grid; grid-template-columns: - [icon-start] auto [icon-end domain-start] auto [domain-end]; + [icon-start] 26px [icon-end handle-start] auto [handle-end platform-start] auto [platform-end]; } -.icons-tooltip .icon { +.contribution-tooltip .external-link { + display: grid; + grid-column-start: icon-start; + grid-column-end: handle-end; + grid-template-columns: subgrid; + + height: 1.4em; +} + +.contribution-tooltip .chronology-link { + display: grid; + grid-column-start: icon-start; + grid-column-end: handle-end; + grid-template-columns: subgrid; + + height: 1.2em; +} + +.contribution-tooltip .external-icon, +.contribution-tooltip .chronology-symbol { grid-column-start: icon-start; grid-column-end: icon-end; } -.icons-tooltip .icon-platform { +.contribution-tooltip .external-icon svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.contribution-tooltip .chronology-symbol { + text-align: center; +} + +.contribution-tooltip .external-handle, +.contribution-tooltip .chronology-text { + grid-column-start: handle-start; + grid-column-end: handle-end; + + width: max-content; + max-width: 200px; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.contribution-tooltip .external-handle { + padding-right: 8px; +} + +.contribution-tooltip .chronology-text { + padding-right: 6px; +} + +.contribution-tooltip .chronology-text, +.contribution-tooltip .chronology-info { + font-size: 0.85em; +} + +.contribution-tooltip .tooltip-divider { + grid-column-start: icon-start; + grid-column-end: platform-end; + + border-top: 1px dotted var(--primary-color); + margin-top: 3px; + margin-bottom: 4px; +} + +.contribution-tooltip .external-platform, +.contribution-tooltip .chronology-info { display: none; - grid-column-start: domain-start; - grid-column-end: domain-end; + grid-column-start: platform-start; + grid-column-end: platform-end; - --icon-platform-opacity: 0.8; - padding-right: 4px; + --external-platform-opacity: 0.8; opacity: 0.8; + padding-right: 4px; + + white-space: nowrap; } -.icons-tooltip.show-info .icon-platform { +.contribution-tooltip.show-info .external-platform, +.contribution-tooltip.show-info .chronology-info { display: inline; - animation: icon-platform 0.2s forwards linear; + animation: external-platform 0.2s forwards linear; } -@keyframes icon-platform { +@keyframes external-platform { from { opacity: 0; } to { - opacity: var(--icon-platform-opacity); + opacity: var(--external-platform-opacity); } } -.icons-tooltip .icon:hover + .icon-platform { - --icon-platform-opacity: 1; +.contribution-tooltip .external-link:hover, +.contribution-tooltip .chronology-link:hover { + filter: brightness(1.4); + text-decoration: none; +} + +.contribution-tooltip .external-link:hover .external-handle, +.contribution-tooltip .chronology-link:hover .chronology-text { + text-decoration: underline; +} + +.contribution-tooltip .external-link:hover + .external-platform, +.contribution-tooltip .chronology-link:hover + .chronology-info { + --external-platform-opacity: 1; text-decoration: underline; text-decoration-color: #ffffffaa; } @@ -663,27 +1055,15 @@ li:not(:first-child:last-child) .tooltip, padding: 3px 4.5px; } -.icons { - font-style: normal; - white-space: nowrap; -} - -.icons a:hover { - filter: brightness(1.4); -} - -.icons a { - padding: 0 3px; -} - -.icon { +.external-icon { display: inline-block; + padding: 0 3px; width: 24px; height: 1em; position: relative; } -.icon > svg { +.external-icon svg { width: 24px; height: 24px; top: -0.25em; @@ -691,23 +1071,6 @@ li:not(:first-child:last-child) .tooltip, fill: var(--primary-color); } -.icon.has-text { - display: block; - width: unset; - height: 1.4em; -} - -.icon.has-text > svg { - width: 18px; - height: 18px; - top: -0.1em; -} - -.icon.has-text > .icon-text { - margin-left: 24px; - padding-right: 8px; -} - .rerelease, .other-group-accent { opacity: 0.7; @@ -722,6 +1085,10 @@ li:not(:first-child:last-child) .tooltip, color: var(--page-primary-color); } +progress { + accent-color: var(--primary-color); +} + .content-columns { columns: 2; } @@ -803,6 +1170,10 @@ ul.image-details li { content: " \00b7 "; } +#artist-commentary.first-entry-is-dated { + clear: right; +} + .commentary-entry-heading { margin-left: 15px; padding-left: 5px; @@ -815,6 +1186,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%; @@ -846,6 +1230,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; } @@ -921,11 +1316,15 @@ html[data-url-key="localized.home"] #content h1 { .quick-info { text-align: center; + padding-left: calc(var(--responsive-padding-ratio) * 100%); + padding-right: calc(var(--responsive-padding-ratio) * 100%); + line-height: 1.25em; } ul.quick-info { list-style: none; padding-left: 0; + padding-right: 0; } ul.quick-info li { @@ -937,10 +1336,65 @@ ul.quick-info li:not(:last-child)::after { font-weight: 800; } -.carousel-container + .quick-info { +.carousel-container + .quick-info, +.carousel-container + .quick-description { margin-top: 25px; } +.quick-description:not(.has-external-links-only) { + --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06); + margin-left: auto; + margin-right: auto; + padding-left: calc(0.40 * var(--clamped-padding-ratio) * 100%); + padding-right: calc(0.40 * var(--clamped-padding-ratio) * 100%); + max-width: 500px; + + padding-top: 0.25em; + padding-bottom: 0.75em; + border-left: 1px solid var(--dim-color); + border-right: 1px solid var(--dim-color); + line-height: 1.25em; +} + +.quick-description.has-external-links-only { + padding-left: 12%; + padding-right: 12%; +} + +.quick-description.has-content-only { + padding-bottom: 0.5em; +} + +.quick-description p { + text-align: center; +} + +.quick-description .description-content.long hr ~ p { + text-align: left; +} + +.quick-description > .description-content :first-child { + margin-top: 0; +} + +.quick-description > .quick-description-actions, +.quick-description.has-content-only .description-content :last-child { + margin-bottom: 0; +} + +.quick-description:not(.collapsed) .description-content.short, +.quick-description:not(.collapsed) .quick-description-actions.when-collapsed, +.quick-description:not(.expanded) .description-content.long, +.quick-description:not(.expanded) .quick-description-actions.when-expanded { + display: none; +} + +.quick-description .quick-description-actions .expand-link, +.quick-description .quick-description-actions .collapse-link { + text-decoration: underline; + text-decoration-style: dotted; +} + #intro-menu { margin: 24px 0; padding: 10px; @@ -997,8 +1451,23 @@ 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 { + --responsive-padding-ratio: 0.10; +} + main.long-content { - --long-content-padding-ratio: 0.10; + --long-content-padding-ratio: var(--responsive-padding-ratio); } main.long-content .main-content-container, @@ -1013,6 +1482,9 @@ dl dt { } dl dt { + /* Heads up, this affects the measurement + * for dl dt which are .content-heading! + */ margin-bottom: 2px; } @@ -1348,7 +1820,6 @@ img.pixelate, .pixelate img { font-size: 1.6em; opacity: 0.8; - background-image: url("warning.svg"); } .reveal-interaction { @@ -1839,6 +2310,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; } @@ -2032,40 +2510,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; @@ -2101,6 +2579,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; @@ -2252,7 +2731,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; } } @@ -2269,8 +2749,9 @@ 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 { - --long-content-padding-ratio: 0.06; + #page-container.showing-sidebar-left main, + #page-container.showing-sidebar-right main { + --responsive-padding-ratio: 0.06; } } @@ -2301,12 +2782,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; } @@ -2333,6 +2814,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content } .sidebar-column { + position: static !important; max-width: unset !important; flex-basis: unset !important; margin-right: 0 !important; @@ -2356,8 +2838,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content columns: 1; } - main.long-content { - --long-content-padding-ratio: 0.02; + main { + --responsive-padding-ratio: 0.02; } #cover-art-container { diff --git a/src/static/client4.js b/src/static/js/client.js index 729836b5..21c3911a 100644 --- a/src/static/client4.js +++ b/src/static/js/client.js @@ -5,8 +5,18 @@ // 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); @@ -25,7 +35,7 @@ function initInfo(infoKey, description) { for (const obj of [ object, object.state, - object.setting, + object.settings, object.event, ]) { if (!obj) continue; @@ -33,35 +43,117 @@ function initInfo(infoKey, description) { } if (object.session) { - const sessionDefaults = object.session; + const sessionSpecs = object.session; object.session = {}; - for (const [key, defaultValue] of Object.entries(sessionDefaults)) { + 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 { - return sessionStorage.getItem(storageKey) ?? defaultValue; + value = sessionStorage.getItem(storageKey) ?? defaultValue; } catch (error) { if (error instanceof DOMException) { - return fallbackValue; + 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 { - sessionStorage.setItem(storageKey, value); + operation(); } catch (error) { - if (error instanceof DOMException) { - fallbackValue = value; - } else { + if (!(error instanceof DOMException)) { throw error; } } @@ -144,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) => { @@ -176,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. @@ -210,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); } } @@ -1045,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', { @@ -1116,6 +1271,11 @@ const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', { // from causing the current tooltip to be hidden. currentTouchIdentifiers: new Set(), touchIdentifiersBanishedByScrolling: new Set(), + + // This is a two-item array that tracks the direction we've already + // dynamically placed the current tooltip. If we *reposition* the tooltip + // (because its dimensions changed), we'll try to follow this anchor first. + dynamicTooltipAnchorDirection: null, }, event: { @@ -1576,6 +1736,8 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; + state.dynamicTooltipAnchorDirection = null; + // Set this for one tick of the event cycle. state.tooltipWasJustHidden = true; setTimeout(() => { @@ -1603,6 +1765,11 @@ function showTooltipFromHoverable(hoverable) { positionTooltipFromHoverableWithBrains(hoverable); + // After a tooltip is shown, if we *didn't* specify an anchor, + // assume it was shown in its default position - generally presented + // as down and to the right. Successive repositioning will base on this. + state.dynamicTooltipAnchorDirection ??= ['down', 'right']; + cssProp(tooltip, 'display', 'block'); tooltip.inert = false; @@ -1637,10 +1804,23 @@ function peekTooltipClientRect(tooltip) { } } +function repositionCurrentTooltip() { + const {state} = hoverableTooltipInfo; + const {currentlyActiveHoverable} = state; + + if (!currentlyActiveHoverable) { + throw new Error(`No hoverable active to reposition tooltip from`); + } + + positionTooltipFromHoverableWithBrains(currentlyActiveHoverable); +} + function positionTooltipFromHoverableWithBrains(hoverable) { const {state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); + const anchorDirection = state.dynamicTooltipAnchorDirection; + // Reset before doing anything else. We're going to adapt to // its natural placement, adjusted by CSS, which otherwise // could be obscured by a placement we've previously provided. @@ -1662,23 +1842,42 @@ function positionTooltipFromHoverableWithBrains(hoverable) { return; } - let selectedRect = null; - for (let i = 0; i < numBaselineRects; i++) { - selectedRect = opportunities.right.down[i]; - if (selectedRect) break; + const tryDirection = (dir1, dir2, i) => { + selectedRect = opportunities[dir1][dir2][i]; + return !!selectedRect; + }; - selectedRect = opportunities.left.down[i]; - if (selectedRect) break; + let selectedRect = null; + selectRect: { + if (anchorDirection) { + for (let i = 0; i < numBaselineRects; i++) { + if (tryDirection(...anchorDirection, i)) { + break selectRect; + } + } + } - selectedRect = opportunities.right.up[i]; - if (selectedRect) break; + for (let i = 0; i < numBaselineRects; i++) { + for (const [dir1, dir2] of [ + ['right', 'down'], + ['left', 'down'], + ['right', 'up'], + ['left', 'up'], + ['down', 'right'], + ['down', 'left'], + ['up', 'right'], + ['up', 'left'], + ]) { + if (tryDirection(dir1, dir2, i)) { + state.dynamicTooltipAnchorDirection = [dir1, dir2]; + break selectRect; + } + } + } - selectedRect = opportunities.left.up[i]; - if (selectedRect) break; + selectedRect = baselineRect; } - selectedRect ??= baselineRect; - positionTooltip(tooltip, selectedRect.x, selectedRect.y); } @@ -1774,18 +1973,18 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { const neededVerticalOverlap = 30; const neededHorizontalOverlap = 30; + const upTopDown = + WikiRect.beneath( + hoverableRect.top + neededVerticalOverlap - tooltipRect.height); + + const downBottomUp = + WikiRect.above( + hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); + // Please don't ask us to make this but horizontal? const prepareVerticalOrientationRects = (regionRects) => { const orientations = {}; - const upTopDown = - WikiRect.beneath( - hoverableRect.top + neededVerticalOverlap - tooltipRect.height); - - const downBottomUp = - WikiRect.above( - hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); - const orientHorizontally = (rect, i) => { if (!rect) return null; @@ -1841,9 +2040,67 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { return orientations; }; + const rightRightLeft = + WikiRect.leftOf( + hoverableRect.left - neededHorizontalOverlap + tooltipRect.width); + + const leftLeftRight = + WikiRect.rightOf( + hoverableRect.left + neededHorizontalOverlap - tooltipRect.width); + + // Oops. + const prepareHorizontalOrientationRects = (regionRects) => { + const orientations = {}; + + const orientVertically = (rect, i) => { + if (!rect) return null; + + const regionRect = regionRects[i]; + + if (regionRect.height > 0) { + return rect; + } else { + return WikiRect.fromRect({ + x: rect.x, + y: regionRect.bottom - tooltipRect.height, + width: rect.width, + height: rect.height, + }); + } + }; + + orientations.left = + regionRects + .map(rect => rect?.intersectionWith(leftLeftRight)) + .map(orientVertically) + .map(keepIfFits); + + orientations.right = + regionRects + .map(rect => rect?.intersectionWith(rightRightLeft)) + .map(rect => + (rect + ? rect.intersectionWith(WikiRect.fromRect({ + x: rect.right - tooltipRect.width, + y: rect.y, + width: rect.width, + height: tooltipRect.height, + })) + : null)) + .map(orientVertically) + .map(keepIfFits); + + // No analogous center because we don't actually use + // center alignment... + + return orientations; + }; + const orientationRects = { left: prepareVerticalOrientationRects(regionRects.left), right: prepareVerticalOrientationRects(regionRects.right), + down: prepareHorizontalOrientationRects(regionRects.bottom), + up: prepareHorizontalOrientationRects(regionRects.top), }; return { @@ -2551,9 +2808,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') { @@ -3099,7 +3357,7 @@ function getArtistExternalLinkTooltipPageReferences() { const info = artistExternalLinkTooltipInfo; info.tooltips = - Array.from(document.getElementsByClassName('icons-tooltip')); + Array.from(document.getElementsByClassName('contribution-tooltip')); info.tooltipRows = info.tooltips.map(tooltip => @@ -3243,6 +3501,8 @@ function showArtistExternalLinkTooltipInfo() { for (const tooltip of info.tooltips) { tooltip.classList.add('show-info'); } + + repositionCurrentTooltip(); } function hideArtistExternalLinkTooltipInfo() { @@ -3260,6 +3520,1219 @@ clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences); clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners); clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners); +// Quick description -------------------------------------- + +const quickDescriptionInfo = initInfo('quickDescriptionInfo', { + quickDescriptionContainers: null, + + quickDescriptionsAreExpandable: null, + + expandDescriptionLinks: null, + collapseDescriptionLinks: null, +}); + +function getQuickDescriptionReferences() { + const info = quickDescriptionInfo; + + info.quickDescriptionContainers = + Array.from(document.querySelectorAll('#content .quick-description')); + + info.quickDescriptionsAreExpandable = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions.when-expanded')); + + info.expandDescriptionLinks = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions .expand-link')); + + info.collapseDescriptionLinks = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions .collapse-link')); +} + +function addQuickDescriptionListeners() { + const info = quickDescriptionInfo; + + for (const { + isExpandable, + container, + expandLink, + collapseLink, + } of stitchArrays({ + isExpandable: info.quickDescriptionsAreExpandable, + container: info.quickDescriptionContainers, + expandLink: info.expandDescriptionLinks, + collapseLink: info.collapseDescriptionLinks, + })) { + if (!isExpandable) continue; + + expandLink.addEventListener('click', event => { + event.preventDefault(); + container.classList.add('expanded'); + container.classList.remove('collapsed'); + }); + + collapseLink.addEventListener('click', event => { + event.preventDefault(); + container.classList.add('collapsed'); + container.classList.remove('expanded'); + }); + } +} + +clientSteps.getPageReferences.push(getQuickDescriptionReferences); +clientSteps.addPageListeners.push(addQuickDescriptionListeners); + +// 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, + searchLabel: 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.searchLabel = + info.searchBox.querySelector('.wiki-search-label'); + + 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.searchLabel.classList.add('disabled'); + 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 +4949,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/xhr-util.js b/src/static/js/xhr-util.js index 8a43072c..8a43072c 100644 --- a/src/static/xhr-util.js +++ b/src/static/js/xhr-util.js 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`. |