From ead9bdc9fc1e9cc62a26e59f6880a13aa864f931 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 6 May 2021 14:56:18 -0300 Subject: break up utility file, get build for sure working still Much Work Yet Ahead but this is good progress!! also the site is in a working state afaict and thats a kinda nice milestone lmbo --- src/gen-thumbs.js | 306 +++ src/static/client.js | 415 +++ src/static/icons.svg | 11 + src/static/lazy-loading.js | 51 + src/static/site-basic.css | 19 + src/static/site.css | 872 ++++++ src/strings-default.json | 305 +++ src/upd8.js | 6395 ++++++++++++++++++++++++++++++++++++++++++++ src/util/cli.js | 210 ++ src/util/colors.js | 47 + src/util/html.js | 92 + src/util/link.js | 67 + src/util/node-utils.js | 27 + src/util/sugar.js | 70 + src/util/urls.js | 102 + src/util/wiki-data.js | 126 + 16 files changed, 9115 insertions(+) create mode 100644 src/gen-thumbs.js create mode 100644 src/static/client.js create mode 100644 src/static/icons.svg create mode 100644 src/static/lazy-loading.js create mode 100644 src/static/site-basic.css create mode 100644 src/static/site.css create mode 100644 src/strings-default.json create mode 100755 src/upd8.js create mode 100644 src/util/cli.js create mode 100644 src/util/colors.js create mode 100644 src/util/html.js create mode 100644 src/util/link.js create mode 100644 src/util/node-utils.js create mode 100644 src/util/sugar.js create mode 100644 src/util/urls.js create mode 100644 src/util/wiki-data.js (limited to 'src') diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js new file mode 100644 index 0000000..d636d2f --- /dev/null +++ b/src/gen-thumbs.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +// Ok, so the d8te is 3 March 2021, and the music wiki was initially released +// on 15 November 2019. That is 474 days or 11376 hours. In my opinion, and +// pro8a8ly the opinions of at least one other person, that is WAY TOO LONG +// to go without media thum8nails!!!! So that's what this file is here to do. +// +// This program takes a path to the media folder (via --media or the environ. +// varia8le HSMUSIC_MEDIA), traverses su8directories to locate image files, +// and gener8tes lower-resolution/file-size versions of all that are new or +// have 8een modified since the last run. We use a JSON-format cache of MD5s +// for each file to perform this comparision; we gener8te files (using ffmpeg) +// in "medium" and "small" sizes adjacent to the existing PNG for easy and +// versatile access in site gener8tion code. +// +// So for example, on the very first run, you might have a media folder which +// looks something like this: +// +// media/ +// album-art/ +// one-year-older/ +// cover.jpg +// firefly-cloud.jpg +// october.jpg +// ... +// flash-art/ +// 413.jpg +// ... +// bg.jpg +// ... +// +// After running gen-thumbs.js with the path to that folder passed, you'd end +// up with something like this: +// +// media/ +// album-art/ +// one-year-older/ +// cover.jpg +// cover.medium.jpg +// cover.small.jpg +// firefly-cloud.jpg +// firefly-cloud.medium.jpg +// firefly-cloud.small.jpg +// october.jpg +// october.medium.jpg +// october.small.jpg +// ... +// flash-art/ +// 413.jpg +// 413.medium.jpg +// 413.small.jpg +// ... +// bg.jpg +// bg.medium.jpg +// bg.small.jpg +// thumbs-cache.json +// ... +// +// (Do note that while 8oth JPG and PNG are supported, gener8ted files will +// always 8e in JPG format and file extension. GIFs are skipped since there +// aren't any super gr8 ways to make those more efficient!) +// +// And then in gener8tion code, you'd reference the medium/small or original +// version of each file, as decided is appropriate. Here are some guidelines: +// +// - Small: Grid tiles on the homepage and in galleries. +// - Medium: Cover art on individual al8um and track pages, etc. +// - Original: Only linked to, not embedded. +// +// The traversal code is indiscrimin8te: there are no special cases to, say, +// not gener8te thum8nails for the bg.jpg file (since those would generally go +// unused). This is just to make the code more porta8le and sta8le, long-term, +// since it avoids a lot of otherwise implic8ted maintenance. + +'use strict'; + +const CACHE_FILE = 'thumbnail-cache.json'; +const WARNING_DELAY_TIME = 10000; + +import { spawn } from 'child_process'; +import { createHash } from 'crypto'; +import * as path from 'path'; + +import { + readdir, + readFile, + writeFile +} from 'fs/promises'; // Whatcha know! Nice. + +import { + createReadStream +} from 'fs'; // Still gotta import from 8oth tho, for createReadStream. + +import { + logError, + logInfo, + logWarn, + parseOptions, + progressPromiseAll +} from './util/cli.js'; + +import { + promisifyProcess, +} from './util/node-utils.js'; + +import { + delay, + queue, +} from './util/sugar.js'; + +function traverse(startDirPath, { + filterFile = () => true, + filterDir = () => true +} = {}) { + const recursive = (names, subDirPath) => Promise + .all(names.map(name => readdir(path.join(startDirPath, subDirPath, name)).then( + names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [], + err => filterFile(name) ? [path.join(subDirPath, name)] : []))) + .then(pathArrays => pathArrays.flatMap(x => x)); + + return readdir(startDirPath) + .then(names => recursive(names, '')); +} + +function readFileMD5(filePath) { + return new Promise((resolve, reject) => { + const md5 = createHash('md5'); + const stream = createReadStream(filePath); + stream.on('data', data => md5.update(data)); + stream.on('end', data => resolve(md5.digest('hex'))); + stream.on('error', err => reject(err)); + }); +} + +function generateImageThumbnails(filePath) { + const dirname = path.dirname(filePath); + const extname = path.extname(filePath); + const basename = path.basename(filePath, extname); + const output = name => path.join(dirname, basename + name + '.jpg'); + + const convert = (name, {size, quality}) => spawn('convert', [ + '-strip', + '-resize', `${size}x${size}>`, + '-interlace', 'Plane', + '-quality', `${quality}%`, + filePath, + output(name) + ]); + + return Promise.all([ + promisifyProcess(convert('.medium', {size: 400, quality: 95}), false), + promisifyProcess(convert('.small', {size: 250, quality: 85}), false) + ]); + + return new Promise((resolve, reject) => { + if (Math.random() < 0.2) { + reject(new Error(`Them's the 8r8ks, kiddo!`)); + } else { + resolve(); + } + }); +} + +export default async function genThumbs(mediaPath, { + queueSize = 0, + quiet = false +} = {}) { + if (!mediaPath) { + throw new Error('Expected mediaPath to be passed'); + } + + const quietInfo = (quiet + ? () => null + : logInfo); + + const filterFile = name => { + // TODO: Why is this not working???????? + // thumbnail-cache.json is 8eing passed through, for some reason. + + const ext = path.extname(name); + if (ext !== '.jpg' && ext !== '.png') return false; + + const rest = path.basename(name, ext); + if (rest.endsWith('.medium') || rest.endsWith('.small')) return false; + + return true; + }; + + const filterDir = name => { + if (name === '.git') return false; + return true; + }; + + let cache, firstRun = false, failedReadingCache = false; + try { + cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE))); + quietInfo`Cache file successfully read.`; + } catch (error) { + cache = {}; + if (error.code === 'ENOENT') { + firstRun = true; + } else { + failedReadingCache = true; + logWarn`Malformed or unreadable cache file: ${error}`; + logWarn`You may want to cancel and investigate this!`; + logWarn`All-new thumbnails and cache will be generated for this run.`; + await delay(WARNING_DELAY_TIME); + } + } + + try { + await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache)); + quietInfo`Writing to cache file appears to be working.`; + } catch (error) { + logWarn`Test of cache file writing failed: ${error}`; + if (cache) { + logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`; + } else if (firstRun) { + logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`; + } else { + logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`; + } + logWarn`You may want to cancel and investigate this!`; + await delay(WARNING_DELAY_TIME); + } + + const imagePaths = await traverse(mediaPath, {filterFile, filterDir}); + + const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue( + imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then( + md5 => [imagePath, md5], + error => [imagePath, {error}] + )), + queueSize + )); + + { + let error = false; + for (const entry of imageToMD5Entries) { + if (entry[1].error) { + logError`Failed to read ${entry[0]}: ${entry[1].error}`; + error = true; + } + } + if (error) { + logError`Failed to read at least one image file!`; + logError`This implies a thumbnail probably won't be generatable.`; + logError`So, exiting early.`; + return false; + } else { + quietInfo`All image files successfully read.`; + } + } + + // Technically we could pro8a8ly mut8te the cache varia8le in-place? + // 8ut that seems kinda iffy. + const updatedCache = Object.assign({}, cache); + + const entriesToGenerate = imageToMD5Entries + .filter(([filePath, md5]) => md5 !== cache[filePath]); + + if (entriesToGenerate.length === 0) { + logInfo`All image thumbnails are already up-to-date - nice!`; + return true; + } + + const failed = []; + const succeeded = []; + const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`; + + // This is actually sort of a lie, 8ecause we aren't doing synchronicity. + // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll, + // 'cuz the progress indic8tor is very cool and good. + await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) => + () => generateImageThumbnails(path.join(mediaPath, filePath)).then( + () => { + updatedCache[filePath] = md5; + succeeded.push(filePath); + }, + error => { + failed.push([filePath, error]); + } + ) + ))); + + if (failed.length > 0) { + for (const [path, error] of failed) { + logError`Thumbnails failed to generate for ${path} - ${error}`; + } + logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`; + logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`; + } else { + logInfo`Generated all (updated) thumbnails successfully!`; + } + + try { + await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache)); + quietInfo`Updated cache file successfully written!`; + } catch (error) { + logWarn`Failed to write updated cache file: ${error}`; + logWarn`Any newly (re)generated thumbnails will be regenerated next run.`; + logWarn`Sorry about that!`; + } + + return true; +} diff --git a/src/static/client.js b/src/static/client.js new file mode 100644 index 0000000..c12ff35 --- /dev/null +++ b/src/static/client.js @@ -0,0 +1,415 @@ +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. +// +// Upd8: As of 04/02/2021, it's now used for info cards too! Nice. + +import { + getColors +} from '../util/colors.js'; + +let albumData, artistData, flashData; +let officialAlbumData, fandomAlbumData, artistNames; + +let ready = false; + +// Localiz8tion nonsense ---------------------------------- + +const language = document.documentElement.getAttribute('lang'); + +let list; +if ( + typeof Intl === 'object' && + typeof Intl.ListFormat === 'function' +) { + const getFormat = type => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit') + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = array => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock + }; +} + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, key) { + return getComputedStyle(el).getPropertyValue(key).trim(); +} + +function getRefDirectory(ref) { + return ref.split(':')[1]; +} + +function getAlbum(el) { + const directory = cssProp(el, '--album-directory'); + return albumData.find(album => album.directory === directory); +} + +function getFlash(el) { + const directory = cssProp(el, '--flash-directory'); + return flashData.find(flash => flash.directory === directory); +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. +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 openFlash = d => rebase(`flash/${d}`); + +function getTrackListAndIndex() { + const album = getAlbum(document.body); + const directory = cssProp(document.body, '--track-directory'); + if (!directory && !album) return {}; + if (!directory) return {list: album.tracks}; + const trackIndex = album.tracks.findIndex(track => track.directory === directory); + return {list: album.tracks, index: trackIndex}; +} + +function openRandomTrack() { + const { list } = getTrackListAndIndex(); + if (!list) return; + return openTrack(pick(list)); +} + +function getFlashListAndIndex() { + const list = flashData.filter(flash => !flash.act8r8k) + const flash = getFlash(document.body); + if (!flash) return {list}; + const flashIndex = list.indexOf(flash); + return {list, index: flashIndex}; +} + +// TODO: This should also use urlSpec. +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')) + .then(res => res.json()); +} + +// JS-based links ----------------------------------------- + +for (const a of document.body.querySelectorAll('[data-random]')) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } + + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + switch (a.dataset.random) { + case 'album': return a.href = openAlbum(pick(albumData).directory); + case 'album-in-fandom': return a.href = openAlbum(pick(fandomAlbumData).directory); + case 'album-in-official': return a.href = openAlbum(pick(officialAlbumData).directory); + case 'track': return a.href = openTrack(getRefDirectory(pick(albumData.map(a => a.tracks).reduce((a, b) => a.concat(b), [])))); + case 'track-in-album': return a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); + case 'track-in-fandom': return a.href = openTrack(getRefDirectory(pick(fandomAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); + case 'track-in-official': return a.href = openTrack(getRefDirectory(pick(officialAlbumData.reduce((acc, album) => acc.concat(album.tracks), [])))); + case 'artist': return a.href = openArtist(pick(artistData).directory); + case 'artist-more-than-one-contrib': return a.href = openArtist(pick(artistData.filter(artist => C.getArtistNumContributions(artist) > 1)).directory); + } + }); +} + +const next = document.getElementById('next-button'); +const previous = document.getElementById('previous-button'); +const random = document.getElementById('random-button'); + +const prependTitle = (el, prepend) => { + const existing = el.getAttribute('title'); + if (existing) { + el.setAttribute('title', prepend + ' ' + existing); + } else { + el.setAttribute('title', prepend); + } +}; + +if (next) prependTitle(next, '(Shift+N)'); +if (previous) prependTitle(previous, '(Shift+P)'); +if (random) prependTitle(random, '(Shift+R)'); + +document.addEventListener('keypress', event => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + if (next) next.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + if (previous) previous.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + if (random && ready) random.click(); + } + } +}); + +for (const reveal of document.querySelectorAll('.reveal')) { + reveal.addEventListener('click', event => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + } + }); +} + +const elements1 = document.getElementsByClassName('js-hide-once-data'); +const elements2 = document.getElementsByClassName('js-show-once-data'); + +for (const element of elements1) element.style.display = 'block'; + +fetch(rebase('data.json', 'rebaseShared')).then(data => data.json()).then(data => { + albumData = data.albumData; + artistData = data.artistData; + flashData = data.flashData; + + officialAlbumData = albumData.filter(album => album.groups.includes('group:official')); + fandomAlbumData = albumData.filter(album => !album.groups.includes('group:official')); + artistNames = artistData.filter(artist => !artist.alias).map(artist => artist.name); + + for (const element of elements1) element.style.display = 'none'; + for (const element of elements2) element.style.display = 'block'; + + ready = true; +}); + +// Data & info card --------------------------------------- + +const NORMAL_HOVER_INFO_DELAY = 750; +const FAST_HOVER_INFO_DELAY = 250; +const END_FAST_HOVER_DELAY = 500; +const HIDE_HOVER_DELAY = 250; + +let fastHover = false; +let endFastHoverTimeout = null; + +function colorLink(a, color) { + if (color) { + const { primary, dim } = getColors(color); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map(el => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then(data => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements('conjunction', data.artists.map(({ artist }) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + })); + + const coverArtistParagraph = container.querySelector('.info-card-cover-artists'); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements('conjunction', data.coverArtists.map(({ artist }) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + })); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [ containerNoReveal, containerReveal ] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal') + ]; + + const [ containerShow, containerHide ] = (data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]); + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide + }; +})(); + +function makeInfoCardLinkHandlers(type) { + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout(() => { + fastHover = true; + infoCard.show(type, evt.target); + }, fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; + + infoCard.cancelHide(); + }, + + mouseleave(evt) { + clearTimeout(hoverTimeout); + + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } + + infoCard.readyHide(); + } + }; +} + +const infoCardLinkHandlers = { + track: makeInfoCardLinkHandlers('track') +}; + +function addInfoCardLinkHandlers(type) { + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [ eventName, handler ] of Object.entries(infoCardLinkHandlers[type])) { + a.addEventListener(eventName, handler); + } + } +} + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} diff --git a/src/static/icons.svg b/src/static/icons.svg new file mode 100644 index 0000000..1e4351b --- /dev/null +++ b/src/static/icons.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js new file mode 100644 index 0000000..a403d7c --- /dev/null +++ b/src/static/lazy-loading.js @@ -0,0 +1,51 @@ +// Lazy loading! Roll your own. Woot. +// This file includes a 8unch of fall8acks and stuff like that, and is written +// with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser +// with JS ena8led. (If it's disa8led, there are gener8ted