diff options
Diffstat (limited to 'src/data/things')
-rw-r--r-- | src/data/things/album.js | 434 | ||||
-rw-r--r-- | src/data/things/art-tag.js | 107 | ||||
-rw-r--r-- | src/data/things/artist.js | 392 | ||||
-rw-r--r-- | src/data/things/flash.js | 370 | ||||
-rw-r--r-- | src/data/things/group.js | 194 | ||||
-rw-r--r-- | src/data/things/homepage-layout.js | 223 | ||||
-rw-r--r-- | src/data/things/index.js | 188 | ||||
-rw-r--r-- | src/data/things/language.js | 730 | ||||
-rw-r--r-- | src/data/things/news-entry.js | 73 | ||||
-rw-r--r-- | src/data/things/static-page.js | 80 | ||||
-rw-r--r-- | src/data/things/track.js | 566 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 110 |
12 files changed, 3467 insertions, 0 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js new file mode 100644 index 0000000..40cd463 --- /dev/null +++ b/src/data/things/album.js @@ -0,0 +1,434 @@ +export const DATA_ALBUM_DIRECTORY = 'album'; + +import * as path from 'node:path'; + +import {input} from '#composite'; +import find from '#find'; +import {traverse} from '#node-utils'; +import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; +import {empty} from '#sugar'; +import Thing from '#thing'; +import {isDate} from '#validators'; +import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions} + from '#yaml'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {exitWithoutContribs} from '#composite/wiki-data'; + +import { + additionalFiles, + commentary, + color, + commentatorArtists, + contribsPresent, + contributionList, + dimensions, + directory, + fileExtension, + flag, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import {withTracks, withTrackSections} from '#composite/things/album'; + +export class Album extends Thing { + static [Thing.referenceType] = 'album'; + + static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ + // Update & expose + + name: name('Unnamed Album'), + color: color(), + directory: directory(), + urls: urls(), + + bandcampAlbumIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), + + date: simpleDate(), + trackArtDate: simpleDate(), + dateAddedToWiki: simpleDate(), + + coverArtDate: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + exposeDependency({dependency: 'date'}), + ], + + coverArtFileExtension: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + fileExtension('jpg'), + ], + + trackCoverArtFileExtension: fileExtension('jpg'), + + wallpaperFileExtension: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + fileExtension('jpg'), + ], + + bannerFileExtension: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + simpleString(), + ], + + bannerStyle: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + simpleString(), + ], + + coverArtDimensions: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + dimensions(), + ], + + bannerDimensions: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + dimensions(), + ], + + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + commentary: commentary(), + additionalFiles: additionalFiles(), + + trackSections: [ + withTrackSections(), + exposeDependency({dependency: '#trackSections'}), + ], + + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), + + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), + + artTags: [ + exitWithoutContribs({ + contribs: 'coverArtistContribs', + value: input.value([]), + }), + + referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), + ], + + // Update only + + artistData: wikiData({ + class: input.value(Artist), + }), + + artTagData: wikiData({ + class: input.value(ArtTag), + }), + + groupData: wikiData({ + class: input.value(Group), + }), + + // Only the tracks which belong to this album. + // Necessary for computing the track list, so provide this statically + // or keep it updated. + ownTrackData: wikiData({ + class: input.value(Track), + }), + + // Expose only + + commentatorArtists: commentatorArtists(), + + hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), + hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), + + tracks: [ + withTracks(), + exposeDependency({dependency: '#tracks'}), + ], + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + color: S.id, + directory: S.id, + urls: S.id, + + date: S.id, + coverArtDate: S.id, + trackArtDate: S.id, + dateAddedToWiki: S.id, + + artistContribs: S.toContribRefs, + coverArtistContribs: S.toContribRefs, + trackCoverArtistContribs: S.toContribRefs, + wallpaperArtistContribs: S.toContribRefs, + bannerArtistContribs: S.toContribRefs, + + coverArtFileExtension: S.id, + trackCoverArtFileExtension: S.id, + wallpaperStyle: S.id, + wallpaperFileExtension: S.id, + bannerStyle: S.id, + bannerFileExtension: S.id, + bannerDimensions: S.id, + + hasTrackArt: S.id, + isListedOnHomepage: S.id, + + commentary: S.toCommentaryRefs, + + additionalFiles: S.id, + + tracks: S.toRefs, + groups: S.toRefs, + artTags: S.toRefs, + commentatorArtists: S.toRefs, + }); + + static [Thing.findSpecs] = { + album: { + referenceTypes: ['album', 'album-commentary', 'album-gallery'], + bindTo: 'albumData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Album': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Bandcamp Album ID': { + property: 'bandcampAlbumIdentifier', + transform: String, + }, + + 'Bandcamp Artwork ID': { + property: 'bandcampArtworkIdentifier', + transform: String, + }, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Has Track Numbers': {property: 'hasTrackNumbers'}, + 'Listed on Homepage': {property: 'isListedOnHomepage'}, + 'Listed in Galleries': {property: 'isListedInGalleries'}, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, + }, + + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + + 'Wallpaper Artists': { + property: 'wallpaperArtistContribs', + transform: parseContributors, + }, + + 'Wallpaper Style': {property: 'wallpaperStyle'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + + 'Banner Artists': { + property: 'bannerArtistContribs', + transform: parseContributors, + }, + + 'Banner Style': {property: 'bannerStyle'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, + + 'Banner Dimensions': { + property: 'bannerDimensions', + transform: parseDimensions, + }, + + 'Commentary': {property: 'commentary'}, + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Franchises': {ignore: true}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, + }, + + 'Groups': {property: 'groups'}, + 'Art Tags': {property: 'artTags'}, + + 'Review Points': {ignore: true}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {headerAndEntries}, + thingConstructors: {Album, Track, TrackSectionHelper}, + }) => ({ + title: `Process album files`, + + files: dataPath => + traverse(path.join(dataPath, DATA_ALBUM_DIRECTORY), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: DATA_ALBUM_DIRECTORY, + }), + + documentMode: headerAndEntries, + headerDocumentThing: Album, + entryDocumentThing: document => + ('Section' in document + ? TrackSectionHelper + : Track), + + save(results) { + const albumData = []; + const trackData = []; + + for (const {header: album, entries} of results) { + // We can't mutate an array once it's set as a property value, + // so prepare the track sections that will show up in a track list + // all the way before actually applying them. (It's okay to mutate + // an individual section before applying it, since those are just + // generic objects; they aren't Things in and of themselves.) + const trackSections = []; + const ownTrackData = []; + + let currentTrackSection = { + name: `Default Track Section`, + isDefaultTrackSection: true, + tracks: [], + }; + + const albumRef = Thing.getReference(album); + + const closeCurrentTrackSection = () => { + if (!empty(currentTrackSection.tracks)) { + trackSections.push(currentTrackSection); + } + }; + + for (const entry of entries) { + if (entry instanceof TrackSectionHelper) { + closeCurrentTrackSection(); + + currentTrackSection = { + name: entry.name, + color: entry.color, + dateOriginallyReleased: entry.dateOriginallyReleased, + isDefaultTrackSection: false, + tracks: [], + }; + + continue; + } + + trackData.push(entry); + + entry.dataSourceAlbum = albumRef; + + ownTrackData.push(entry); + currentTrackSection.tracks.push(Thing.getReference(entry)); + } + + closeCurrentTrackSection(); + + albumData.push(album); + + album.trackSections = trackSections; + album.ownTrackData = ownTrackData; + } + + return {albumData, trackData}; + }, + + sort({albumData, trackData}) { + sortChronologically(albumData); + sortAlbumsTracksChronologically(trackData); + }, + }); +} + +export class TrackSectionHelper extends Thing { + static [Thing.friendlyName] = `Track Section`; + + static [Thing.getPropertyDescriptors] = () => ({ + name: name('Unnamed Track Section'), + color: color(), + dateOriginallyReleased: simpleDate(), + isDefaultTrackGroup: flag(false), + }) + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + + 'Date Originally Released': { + property: 'dateOriginallyReleased', + transform: parseDate, + }, + }, + }; +} diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js new file mode 100644 index 0000000..3149b31 --- /dev/null +++ b/src/data/things/art-tag.js @@ -0,0 +1,107 @@ +export const ART_TAG_DATA_FILE = 'tags.yaml'; + +import {input} from '#composite'; +import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import Thing from '#thing'; +import {isName} from '#validators'; + +import {exposeUpdateValueOrContinue} from '#composite/control-flow'; + +import { + color, + directory, + flag, + name, + wikiData, +} from '#composite/wiki-properties'; + +export class ArtTag extends Thing { + static [Thing.referenceType] = 'tag'; + static [Thing.friendlyName] = `Art Tag`; + + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + // Update & expose + + name: name('Unnamed Art Tag'), + directory: directory(), + color: color(), + isContentWarning: flag(false), + + nameShort: [ + exposeUpdateValueOrContinue({ + validate: input.value(isName), + }), + + { + dependencies: ['name'], + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), + }, + ], + + // Update only + + albumData: wikiData({ + class: input.value(Album), + }), + + trackData: wikiData({ + class: input.value(Track), + }), + + // Expose only + + taggedInThings: { + flags: {expose: true}, + + expose: { + dependencies: ['this', 'albumData', 'trackData'], + compute: ({this: artTag, albumData, trackData}) => + sortAlbumsTracksChronologically( + [...albumData, ...trackData] + .filter(({artTags}) => artTags.includes(artTag)), + {getDate: thing => thing.coverArtDate ?? thing.date}), + }, + }, + }); + + static [Thing.findSpecs] = { + artTag: { + referenceTypes: ['tag'], + bindTo: 'artTagData', + + getMatchableNames: tag => + (tag.isContentWarning + ? [`cw: ${tag.name}`] + : [tag.name]), + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Tag': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, + + 'Color': {property: 'color'}, + 'Is CW': {property: 'isContentWarning'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {ArtTag}, + }) => ({ + title: `Process art tags file`, + file: ART_TAG_DATA_FILE, + + documentMode: allInOne, + documentThing: ArtTag, + + save: (results) => ({artTagData: results}), + + sort({artTagData}) { + sortAlphabetically(artTagData); + }, + }); +} diff --git a/src/data/things/artist.js b/src/data/things/artist.js new file mode 100644 index 0000000..841d652 --- /dev/null +++ b/src/data/things/artist.js @@ -0,0 +1,392 @@ +export const ARTIST_DATA_FILE = 'artists.yaml'; + +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input} from '#composite'; +import find from '#find'; +import {sortAlphabetically} from '#sort'; +import {stitchArrays, unique} from '#sugar'; +import Thing from '#thing'; +import {isName, validateArrayItems} from '#validators'; +import {getKebabCase} from '#wiki-data'; + +import {withReverseContributionList} from '#composite/wiki-data'; + +import { + contentString, + directory, + fileExtension, + flag, + name, + reverseContributionList, + reverseReferenceList, + singleReference, + urls, + wikiData, +} from '#composite/wiki-properties'; + +export class Artist extends Thing { + static [Thing.referenceType] = 'artist'; + static [Thing.wikiDataArray] = 'artistData'; + + static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ + // Update & expose + + name: name('Unnamed Artist'), + directory: directory(), + urls: urls(), + + contextNotes: contentString(), + + hasAvatar: flag(false), + avatarFileExtension: fileExtension('jpg'), + + aliasNames: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isName)}, + expose: {transform: (names) => names ?? []}, + }, + + isAlias: flag(), + + aliasedArtist: singleReference({ + class: input.value(Artist), + find: input.value(find.artist), + data: 'artistData', + }), + + // Update only + + albumData: wikiData({ + class: input.value(Album), + }), + + artistData: wikiData({ + class: input.value(Artist), + }), + + flashData: wikiData({ + class: input.value(Flash), + }), + + trackData: wikiData({ + class: input.value(Track), + }), + + // Expose only + + tracksAsArtist: reverseContributionList({ + data: 'trackData', + list: input.value('artistContribs'), + }), + + tracksAsContributor: reverseContributionList({ + data: 'trackData', + list: input.value('contributorContribs'), + }), + + tracksAsCoverArtist: reverseContributionList({ + data: 'trackData', + list: input.value('coverArtistContribs'), + }), + + tracksAsAny: [ + withReverseContributionList({ + data: 'trackData', + list: input.value('artistContribs'), + }).outputs({ + '#reverseContributionList': '#tracksAsArtist', + }), + + withReverseContributionList({ + data: 'trackData', + list: input.value('contributorContribs'), + }).outputs({ + '#reverseContributionList': '#tracksAsContributor', + }), + + withReverseContributionList({ + data: 'trackData', + list: input.value('coverArtistContribs'), + }).outputs({ + '#reverseContributionList': '#tracksAsCoverArtist', + }), + + { + dependencies: [ + '#tracksAsArtist', + '#tracksAsContributor', + '#tracksAsCoverArtist', + ], + + compute: ({ + ['#tracksAsArtist']: tracksAsArtist, + ['#tracksAsContributor']: tracksAsContributor, + ['#tracksAsCoverArtist']: tracksAsCoverArtist, + }) => + unique([ + ...tracksAsArtist, + ...tracksAsContributor, + ...tracksAsCoverArtist, + ]), + }, + ], + + tracksAsCommentator: reverseReferenceList({ + data: 'trackData', + list: input.value('commentatorArtists'), + }), + + albumsAsAlbumArtist: reverseContributionList({ + data: 'albumData', + list: input.value('artistContribs'), + }), + + albumsAsCoverArtist: reverseContributionList({ + data: 'albumData', + list: input.value('coverArtistContribs'), + }), + + albumsAsWallpaperArtist: reverseContributionList({ + data: 'albumData', + list: input.value('wallpaperArtistContribs'), + }), + + albumsAsBannerArtist: reverseContributionList({ + data: 'albumData', + list: input.value('bannerArtistContribs'), + }), + + albumsAsAny: [ + withReverseContributionList({ + data: 'albumData', + list: input.value('artistContribs'), + }).outputs({ + '#reverseContributionList': '#albumsAsArtist', + }), + + withReverseContributionList({ + data: 'albumData', + list: input.value('coverArtistContribs'), + }).outputs({ + '#reverseContributionList': '#albumsAsCoverArtist', + }), + + withReverseContributionList({ + data: 'albumData', + list: input.value('wallpaperArtistContribs'), + }).outputs({ + '#reverseContributionList': '#albumsAsWallpaperArtist', + }), + + withReverseContributionList({ + data: 'albumData', + list: input.value('bannerArtistContribs'), + }).outputs({ + '#reverseContributionList': '#albumsAsBannerArtist', + }), + + { + dependencies: [ + '#albumsAsArtist', + '#albumsAsCoverArtist', + '#albumsAsWallpaperArtist', + '#albumsAsBannerArtist', + ], + + compute: ({ + ['#albumsAsArtist']: albumsAsArtist, + ['#albumsAsCoverArtist']: albumsAsCoverArtist, + ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist, + ['#albumsAsBannerArtist']: albumsAsBannerArtist, + }) => + unique([ + ...albumsAsArtist, + ...albumsAsCoverArtist, + ...albumsAsWallpaperArtist, + ...albumsAsBannerArtist, + ]), + }, + ], + + albumsAsCommentator: reverseReferenceList({ + data: 'albumData', + list: input.value('commentatorArtists'), + }), + + flashesAsContributor: reverseContributionList({ + data: 'flashData', + list: input.value('contributorContribs'), + }), + + flashesAsCommentator: reverseReferenceList({ + data: 'flashData', + list: input.value('commentatorArtists'), + }), + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, + + hasAvatar: S.id, + avatarFileExtension: S.id, + + aliasNames: S.id, + + tracksAsArtist: S.toRefs, + tracksAsContributor: S.toRefs, + tracksAsCoverArtist: S.toRefs, + tracksAsCommentator: S.toRefs, + + albumsAsAlbumArtist: S.toRefs, + albumsAsCoverArtist: S.toRefs, + albumsAsWallpaperArtist: S.toRefs, + albumsAsBannerArtist: S.toRefs, + albumsAsCommentator: S.toRefs, + + flashesAsContributor: S.toRefs, + }); + + static [Thing.findSpecs] = { + artist: { + referenceTypes: ['artist', 'artist-gallery'], + bindTo: 'artistData', + + include: artist => !artist.isAlias, + }, + + artistAlias: { + referenceTypes: ['artist', 'artist-gallery'], + bindTo: 'artistData', + + include: artist => artist.isAlias, + + getMatchableDirectories(artist) { + const originalArtist = artist.aliasedArtist; + + // Aliases never match by the same directory as the original. + if (artist.directory === originalArtist.directory) { + return []; + } + + // Aliases never match by the same directory as some *previous* alias + // in the original's alias list. This is honestly a bit awkward, but it + // avoids artist aliases conflicting with each other when checking for + // duplicate directories. + for (const aliasName of originalArtist.aliasNames) { + // These are trouble. We should be accessing aliases' directories + // directly, but artists currently don't expose a reverse reference + // list for aliases. (This is pending a cleanup of "reverse reference" + // behavior in general.) It doesn't actually cause problems *here* + // because alias directories are computed from their names 100% of the + // time, but that *is* an assumption this code makes. + if (aliasName === artist.name) continue; + if (artist.directory === getKebabCase(aliasName)) { + return []; + } + } + + // And, aliases never return just a blank string. This part is pretty + // spooky because it doesn't handle two differently named aliases, on + // different artists, who have names that are similar *apart* from a + // character that's shortened. But that's also so fundamentally scary + // that we can't support it properly with existing code, anyway - we + // would need to be able to specifically set a directory *on an alias,* + // which currently can't be done in YAML data files. + if (artist.directory === '') { + return []; + } + + return [artist.directory]; + }, + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Artist': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'URLs': {property: 'urls'}, + 'Context Notes': {property: 'contextNotes'}, + + 'Has Avatar': {property: 'hasAvatar'}, + 'Avatar File Extension': {property: 'avatarFileExtension'}, + + 'Aliases': {property: 'aliasNames'}, + + 'Dead URLs': {ignore: true}, + + 'Review Points': {ignore: true}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {Artist}, + }) => ({ + title: `Process artists file`, + file: ARTIST_DATA_FILE, + + documentMode: allInOne, + documentThing: Artist, + + save(results) { + const artists = results; + + const artistRefs = + artists.map(artist => Thing.getReference(artist)); + + const artistAliasNames = + artists.map(artist => artist.aliasNames); + + const artistAliases = + stitchArrays({ + originalArtistRef: artistRefs, + aliasNames: artistAliasNames, + }).flatMap(({originalArtistRef, aliasNames}) => + aliasNames.map(name => { + const alias = new Artist(); + alias.name = name; + alias.isAlias = true; + alias.aliasedArtist = originalArtistRef; + return alias; + })); + + const artistData = [...artists, ...artistAliases]; + + return {artistData}; + }, + + sort({artistData}) { + sortAlphabetically(artistData); + }, + }); + + [inspect.custom]() { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (CacheableObject.getUpdateValue(this, 'isAlias')) { + parts.unshift(`${colors.yellow('[alias]')} `); + + let aliasedArtist; + try { + aliasedArtist = this.aliasedArtist.name; + } catch (_error) { + aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); + } + + parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/flash.js b/src/data/things/flash.js new file mode 100644 index 0000000..ceed79f --- /dev/null +++ b/src/data/things/flash.js @@ -0,0 +1,370 @@ +export const FLASH_DATA_FILE = 'flashes.yaml'; + +import {input} from '#composite'; +import find from '#find'; +import {empty} from '#sugar'; +import {sortFlashesChronologically} from '#sort'; +import Thing from '#thing'; +import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} + from '#validators'; +import {parseDate, parseContributors} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + color, + commentary, + commentatorArtists, + contentString, + contributionList, + directory, + fileExtension, + name, + referenceList, + simpleDate, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import {withFlashAct} from '#composite/things/flash'; +import {withFlashSide} from '#composite/things/flash-act'; + +export class Flash extends Thing { + static [Thing.referenceType] = 'flash'; + + static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ + // Update & expose + + name: name('Unnamed Flash'), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, + }, + }, + + page: { + flags: {update: true, expose: true}, + update: {validate: anyOf(isString, isNumber)}, + + expose: { + transform: (value) => (value === null ? null : value.toString()), + }, + }, + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withFlashAct(), + + withPropertyFromObject({ + object: '#flashAct', + property: input.value('color'), + }), + + exposeDependency({dependency: '#flashAct.color'}), + ], + + date: simpleDate(), + + coverArtFileExtension: fileExtension('jpg'), + + contributorContribs: contributionList(), + + featuredTracks: referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + + urls: urls(), + + commentary: commentary(), + + // Update only + + artistData: wikiData({ + class: input.value(Artist), + }), + + trackData: wikiData({ + class: input.value(Track), + }), + + flashActData: wikiData({ + class: input.value(FlashAct), + }), + + // Expose only + + commentatorArtists: commentatorArtists(), + + act: [ + withFlashAct(), + exposeDependency({dependency: '#flashAct'}), + ], + + side: [ + withFlashAct(), + + withPropertyFromObject({ + object: '#flashAct', + property: input.value('side'), + }), + + exposeDependency({dependency: '#flashAct.side'}), + ], + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, + }); + + static [Thing.findSpecs] = { + flash: { + referenceTypes: ['flash'], + bindTo: 'flashData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Flash': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Page': {property: 'page'}, + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + + 'Featured Tracks': {property: 'featuredTracks'}, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + 'Commentary': {property: 'commentary'}, + + 'Review Points': {ignore: true}, + }, + }; +} + +export class FlashAct extends Thing { + static [Thing.referenceType] = 'flash-act'; + static [Thing.friendlyName] = `Flash Act`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name('Unnamed Flash Act'), + directory: directory(), + color: color(), + + listTerminology: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + withFlashSide(), + + withPropertyFromObject({ + object: '#flashSide', + property: input.value('listTerminology'), + }), + + exposeDependencyOrContinue({ + dependency: '#flashSide.listTerminology', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + flashes: referenceList({ + class: input.value(Flash), + find: input.value(find.flash), + data: 'flashData', + }), + + // Update only + + flashData: wikiData({ + class: input.value(Flash), + }), + + flashSideData: wikiData({ + class: input.value(FlashSide), + }), + + // Expose only + + side: [ + withFlashSide(), + exposeDependency({dependency: '#flashSide'}), + ], + }); + + static [Thing.findSpecs] = { + flashAct: { + referenceTypes: ['flash-act'], + bindTo: 'flashActData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Act': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + + 'Review Points': {ignore: true}, + }, + }; +} + +export class FlashSide extends Thing { + static [Thing.referenceType] = 'flash-side'; + static [Thing.friendlyName] = `Flash Side`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name('Unnamed Flash Side'), + directory: directory(), + color: color(), + listTerminology: contentString(), + + acts: referenceList({ + class: input.value(FlashAct), + find: input.value(find.flashAct), + data: 'flashActData', + }), + + // Update only + + flashActData: wikiData({ + class: input.value(FlashAct), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Side': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + }, + }; + + static [Thing.findSpecs] = { + flashSide: { + referenceTypes: ['flash-side'], + bindTo: 'flashSideData', + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {Flash, FlashAct}, + }) => ({ + title: `Process flashes file`, + file: FLASH_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + ('Side' in document + ? FlashSide + : 'Act' in document + ? FlashAct + : Flash), + + save(results) { + // JavaScript likes you. + + if (!empty(results) && !(results[0] instanceof FlashSide)) { + throw new Error(`Expected a side at top of flash data file`); + } + + let index = 0; + let thing; + for (; thing = results[index]; index++) { + const flashSide = thing; + const flashActRefs = []; + + if (results[index + 1] instanceof Flash) { + throw new Error(`Expected an act to immediately follow a side`); + } + + for ( + index++; + (thing = results[index]) && thing instanceof FlashAct; + index++ + ) { + const flashAct = thing; + const flashRefs = []; + for ( + index++; + (thing = results[index]) && thing instanceof Flash; + index++ + ) { + flashRefs.push(Thing.getReference(thing)); + } + index--; + flashAct.flashes = flashRefs; + flashActRefs.push(Thing.getReference(flashAct)); + } + index--; + flashSide.acts = flashActRefs; + } + + const flashData = results.filter(x => x instanceof Flash); + const flashActData = results.filter(x => x instanceof FlashAct); + const flashSideData = results.filter(x => x instanceof FlashSide); + + return {flashData, flashActData, flashSideData}; + }, + + sort({flashData}) { + sortFlashesChronologically(flashData); + }, + }); +} diff --git a/src/data/things/group.js b/src/data/things/group.js new file mode 100644 index 0000000..0dbbbb7 --- /dev/null +++ b/src/data/things/group.js @@ -0,0 +1,194 @@ +export const GROUP_DATA_FILE = 'groups.yaml'; + +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; + +import { + color, + contentString, + directory, + name, + referenceList, + urls, + wikiData, +} from '#composite/wiki-properties'; + +export class Group extends Thing { + static [Thing.referenceType] = 'group'; + + static [Thing.getPropertyDescriptors] = ({Album}) => ({ + // Update & expose + + name: name('Unnamed Group'), + directory: directory(), + + description: contentString(), + + urls: urls(), + + featuredAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), + + // Update only + + albumData: wikiData({ + class: input.value(Album), + }), + + groupCategoryData: wikiData({ + class: input.value(GroupCategory), + }), + + // Expose only + + descriptionShort: { + flags: {expose: true}, + + expose: { + dependencies: ['description'], + compute: ({description}) => + (description + ? description.split('<hr class="split">')[0] + : null), + }, + }, + + albums: { + flags: {expose: true}, + + expose: { + dependencies: ['this', 'albumData'], + compute: ({this: group, albumData}) => + albumData?.filter((album) => album.groups.includes(group)) ?? [], + }, + }, + + color: { + flags: {expose: true}, + + expose: { + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => + groupCategoryData.find((category) => category.groups.includes(group)) + ?.color, + }, + }, + + category: { + flags: {expose: true}, + + expose: { + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => + groupCategoryData.find((category) => category.groups.includes(group)) ?? + null, + }, + }, + }); + + static [Thing.findSpecs] = { + group: { + referenceTypes: ['group', 'group-gallery'], + bindTo: 'groupData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Group': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Description': {property: 'description'}, + 'URLs': {property: 'urls'}, + + 'Featured Albums': {property: 'featuredAlbums'}, + + 'Review Points': {ignore: true}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {Group, GroupCategory}, + }) => ({ + title: `Process groups file`, + file: GROUP_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + ('Category' in document + ? GroupCategory + : Group), + + save(results) { + let groupCategory; + let groupRefs = []; + + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } + + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, {groups: groupRefs}); + } + + groupCategory = thing; + groupRefs = []; + } else { + groupRefs.push(Thing.getReference(thing)); + } + } + + if (groupCategory) { + Object.assign(groupCategory, {groups: groupRefs}); + } + + const groupData = results.filter(x => x instanceof Group); + const groupCategoryData = results.filter(x => x instanceof GroupCategory); + + return {groupData, groupCategoryData}; + }, + + // Groups aren't sorted at all, always preserving the order in the data + // file as-is. + sort: null, + }); +} + +export class GroupCategory extends Thing { + static [Thing.referenceType] = 'group-category'; + static [Thing.friendlyName] = `Group Category`; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ + // Update & expose + + name: name('Unnamed Group Category'), + directory: directory(), + + color: color(), + + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), + + // Update only + + groupData: wikiData({ + class: input.value(Group), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Category': {property: 'name'}, + 'Color': {property: 'color'}, + }, + }; +} diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js new file mode 100644 index 0000000..00d6aef --- /dev/null +++ b/src/data/things/homepage-layout.js @@ -0,0 +1,223 @@ +export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; + +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; + +import { + anyOf, + is, + isCountingNumber, + isString, + isStringNonEmpty, + validateArrayItems, + validateInstanceOf, + validateReference, +} from '#validators'; + +import {exposeDependency} from '#composite/control-flow'; +import {withResolvedReference} from '#composite/wiki-data'; +import {color, contentString, name, referenceList, wikiData} + from '#composite/wiki-properties'; + +export class HomepageLayout extends Thing { + static [Thing.friendlyName] = `Homepage Layout`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + // Update & expose + + sidebarContent: contentString(), + + navbarLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isStringNonEmpty)}, + }, + + rows: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), + }, + }, + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Homepage': {ignore: true}, + + 'Sidebar Content': {property: 'sidebarContent'}, + 'Navbar Links': {property: 'navbarLinks'}, + }, + }; +} + +export class HomepageLayoutRow extends Thing { + static [Thing.friendlyName] = `Homepage Row`; + + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name('Unnamed Homepage Row'), + + type: { + flags: {update: true, expose: true}, + + update: { + validate() { + throw new Error(`'type' property validator must be overridden`); + }, + }, + }, + + color: color(), + + // Update only + + // These wiki data arrays aren't necessarily used by every subclass, but + // to the convenience of providing these, the superclass accepts all wiki + // data arrays depended upon by any subclass. + + albumData: wikiData({ + class: input.value(Album), + }), + + groupData: wikiData({ + class: input.value(Group), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Row': {property: 'name'}, + 'Color': {property: 'color'}, + 'Type': {property: 'type'}, + }, + }; +} + +export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Albums Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), + + // Update & expose + + type: { + flags: {update: true, expose: true}, + update: { + validate(value) { + if (value !== 'albums') { + throw new TypeError(`Expected 'albums'`); + } + + return true; + }, + }, + }, + + displayStyle: { + flags: {update: true, expose: true}, + + update: { + validate: is('grid', 'carousel'), + }, + + expose: { + transform: (displayStyle) => + displayStyle ?? 'grid', + }, + }, + + sourceGroup: [ + { + flags: {expose: true, update: true, compose: true}, + + update: { + validate: + anyOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }, + + expose: { + transform: (value, continuation) => + (value === 'new-releases' || value === 'new-additions' + ? value + : continuation(value)), + }, + }, + + withResolvedReference({ + ref: input.updateValue(), + data: 'groupData', + find: input.value(find.group), + }), + + exposeDependency({dependency: '#resolvedReference'}), + ], + + sourceAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), + + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { + fields: { + 'Display Style': {property: 'displayStyle'}, + 'Group': {property: 'sourceGroup'}, + 'Count': {property: 'countAlbumsFromGroup'}, + 'Albums': {property: 'sourceAlbums'}, + 'Actions': {property: 'actionLinks'}, + }, + }); + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {headerAndEntries}, // Kludge, see below + thingConstructors: { + HomepageLayout, + HomepageLayoutAlbumsRow, + }, + }) => ({ + title: `Process homepage layout file`, + + // Kludge: This benefits from the same headerAndEntries style messaging as + // albums and tracks (for example), but that document mode is designed to + // support multiple files, and only one is actually getting processed here. + files: [HOMEPAGE_LAYOUT_DATA_FILE], + + documentMode: headerAndEntries, + headerDocumentThing: HomepageLayout, + entryDocumentThing: document => { + switch (document['Type']) { + case 'albums': + return HomepageLayoutAlbumsRow; + default: + throw new TypeError(`No processDocument function for row type ${document['Type']}!`); + } + }, + + save(results) { + if (!results[0]) { + return; + } + + const {header: homepageLayout, entries: rows} = results[0]; + Object.assign(homepageLayout, {rows}); + return {homepageLayout}; + }, + }); +} diff --git a/src/data/things/index.js b/src/data/things/index.js new file mode 100644 index 0000000..3bf8409 --- /dev/null +++ b/src/data/things/index.js @@ -0,0 +1,188 @@ +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {openAggregate, showAggregate} from '#aggregate'; +import {logError} from '#cli'; +import {compositeFrom} from '#composite'; +import * as serialize from '#serialize'; + +import Thing from '#thing'; + +import * as albumClasses from './album.js'; +import * as artTagClasses from './art-tag.js'; +import * as artistClasses from './artist.js'; +import * as flashClasses from './flash.js'; +import * as groupClasses from './group.js'; +import * as homepageLayoutClasses from './homepage-layout.js'; +import * as languageClasses from './language.js'; +import * as newsEntryClasses from './news-entry.js'; +import * as staticPageClasses from './static-page.js'; +import * as trackClasses from './track.js'; +import * as wikiInfoClasses from './wiki-info.js'; + +const allClassLists = { + 'album.js': albumClasses, + 'art-tag.js': artTagClasses, + 'artist.js': artistClasses, + 'flash.js': flashClasses, + 'group.js': groupClasses, + 'homepage-layout.js': homepageLayoutClasses, + 'language.js': languageClasses, + 'news-entry.js': newsEntryClasses, + 'static-page.js': staticPageClasses, + 'track.js': trackClasses, + 'wiki-info.js': wikiInfoClasses, +}; + +let allClasses = Object.create(null); + +// src/data/things/index.js -> src/ +const __dirname = path.dirname( + path.resolve( + fileURLToPath(import.meta.url), + '../..')); + +function niceShowAggregate(error, ...opts) { + showAggregate(error, { + pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), + ...opts, + }); +} + +function errorDuplicateClassNames() { + const locationDict = Object.create(null); + + for (const [location, classes] of Object.entries(allClassLists)) { + for (const className of Object.keys(classes)) { + if (className in locationDict) { + locationDict[className].push(location); + } else { + locationDict[className] = [location]; + } + } + } + + let success = true; + + for (const [className, locations] of Object.entries(locationDict)) { + if (locations.length === 1) { + continue; + } + + logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`; + success = false; + } + + return success; +} + +function flattenClassLists() { + for (const classes of Object.values(allClassLists)) { + for (const [name, constructor] of Object.entries(classes)) { + if (typeof constructor !== 'function') continue; + if (!(constructor.prototype instanceof Thing)) continue; + allClasses[name] = constructor; + } + } +} + +function descriptorAggregateHelper({ + showFailedClasses, + message, + op, +}) { + const failureSymbol = Symbol(); + const aggregate = openAggregate({ + message, + returnOnFail: failureSymbol, + }); + + const failedClasses = []; + + for (const [name, constructor] of Object.entries(allClasses)) { + const result = aggregate.call(op, constructor); + + if (result === failureSymbol) { + failedClasses.push(name); + } + } + + try { + aggregate.close(); + return true; + } catch (error) { + niceShowAggregate(error); + showFailedClasses(failedClasses); + return false; + } +} + +function evaluatePropertyDescriptors() { + const opts = {...allClasses}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class property descriptors`, + + op(constructor) { + if (!constructor[Thing.getPropertyDescriptors]) { + throw new Error(`Missing [Thing.getPropertyDescriptors] function`); + } + + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom({ + annotation: `${constructor.name}.${key}`, + compose: false, + steps: value, + }); + } else if (value.toResolvedComposition) { + results[key] = compositeFrom(value.toResolvedComposition()); + } + } + + constructor.propertyDescriptors = results; + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +function evaluateSerializeDescriptors() { + const opts = {...allClasses, serialize}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class serialize descriptors`, + + op(constructor) { + if (!constructor[Thing.getSerializeDescriptors]) { + return; + } + + constructor[serialize.serializeDescriptors] = + constructor[Thing.getSerializeDescriptors](opts); + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +if (!errorDuplicateClassNames()) + process.exit(1); + +flattenClassLists(); + +if (!evaluatePropertyDescriptors()) + process.exit(1); + +if (!evaluateSerializeDescriptors()) + process.exit(1); + +Object.assign(allClasses, {Thing}); + +export default allClasses; diff --git a/src/data/things/language.js b/src/data/things/language.js new file mode 100644 index 0000000..dbe1ff3 --- /dev/null +++ b/src/data/things/language.js @@ -0,0 +1,730 @@ +import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; + +import {withAggregate} from '#aggregate'; +import CacheableObject from '#cacheable-object'; +import {logWarn} from '#cli'; +import * as html from '#html'; +import {empty} from '#sugar'; +import {isLanguageCode} from '#validators'; +import Thing from '#thing'; + +import { + getExternalLinkStringOfStyleFromDescriptors, + getExternalLinkStringsFromDescriptors, + isExternalLinkContext, + isExternalLinkSpec, + isExternalLinkStyle, +} from '#external-links'; + +import {externalFunction, flag, name} from '#composite/wiki-properties'; + +export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; + +export class Language extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + // General language code. This is used to identify the language distinctly + // from other languages (similar to how "Directory" operates in many data + // objects). + code: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + // Human-readable name. This should be the language's own native name, not + // localized to any other language. + name: name(`Unnamed Language`), + + // Language code specific to JavaScript's Internationalization (Intl) API. + // Usually this will be the same as the language's general code, but it + // may be overridden to provide Intl constructors an alternative value. + intlCode: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + expose: { + dependencies: ['code'], + transform: (intlCode, {code}) => intlCode ?? code, + }, + }, + + // Flag which represents whether or not to hide a language from general + // access. If a language is hidden, its portion of the website will still + // be built (with all strings localized to the language), but it won't be + // included in controls for switching languages or the <link rel=alternate> + // tags used for search engine optimization. This flag is intended for use + // with languages that are currently in development and not ready for + // formal release, or which are just kept hidden as "experimental zones" + // for wiki development or content testing. + hidden: flag(false), + + // Mapping of translation keys to values (strings). Generally, don't + // access this object directly - use methods instead. + strings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + + expose: { + dependencies: ['inheritedStrings', 'code'], + transform(strings, {inheritedStrings, code}) { + if (!strings && !inheritedStrings) return null; + if (!inheritedStrings) return strings; + + const validStrings = { + ...inheritedStrings, + ...strings, + }; + + const optionsFromTemplate = template => + Array.from(template.matchAll(languageOptionRegex)) + .map(({groups}) => groups.name); + + for (const [key, providedTemplate] of Object.entries(strings)) { + const inheritedTemplate = inheritedStrings[key]; + if (!inheritedTemplate) continue; + + const providedOptions = optionsFromTemplate(providedTemplate); + const inheritedOptions = optionsFromTemplate(inheritedTemplate); + + const missingOptionNames = + inheritedOptions.filter(name => !providedOptions.includes(name)); + + const misplacedOptionNames = + providedOptions.filter(name => !inheritedOptions.includes(name)); + + if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) { + logWarn`Not using ${code ?? '(no code)'} string ${key}:`; + if (!empty(missingOptionNames)) + logWarn`- Missing options: ${missingOptionNames.join(', ')}`; + if (!empty(misplacedOptionNames)) + logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + validStrings[key] = inheritedStrings[key]; + } + } + + return validStrings; + }, + }, + }, + + // May be provided to specify "default" strings, generally (but not + // necessarily) inherited from another Language object. + inheritedStrings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + }, + + // List of descriptors for providing to external link utilities when using + // language.formatExternalLink - refer to util/external-links.js for info. + externalLinkSpec: { + flags: {update: true, expose: true}, + update: {validate: isExternalLinkSpec}, + }, + + // Update only + + escapeHTML: externalFunction(), + + // Expose only + + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_number: this.#intlHelper(Intl.NumberFormat), + intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), + intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), + intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}), + intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}), + intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}), + + validKeys: { + flags: {expose: true}, + + expose: { + dependencies: ['strings', 'inheritedStrings'], + compute: ({strings, inheritedStrings}) => + Array.from( + new Set([ + ...Object.keys(inheritedStrings ?? {}), + ...Object.keys(strings ?? {}), + ]) + ), + }, + }, + + // TODO: This currently isn't used. Is it still needed? + strings_htmlEscaped: { + flags: {expose: true}, + expose: { + dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], + compute({strings, inheritedStrings, escapeHTML}) { + if (!(strings || inheritedStrings) || !escapeHTML) return null; + const allStrings = {...inheritedStrings, ...strings}; + return Object.fromEntries( + Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) + ); + }, + }, + }, + }); + + static #intlHelper (constructor, opts) { + return { + flags: {expose: true}, + expose: { + dependencies: ['code', 'intlCode'], + compute: ({code, intlCode}) => { + const constructCode = intlCode ?? code; + if (!constructCode) return null; + return Reflect.construct(constructor, [constructCode, opts]); + }, + }, + }; + } + + $(...args) { + return this.formatString(...args); + } + + assertIntlAvailable(property) { + if (!this[property]) { + throw new Error(`Intl API ${property} unavailable`); + } + } + + getUnitForm(value) { + this.assertIntlAvailable('intl_pluralCardinal'); + return this.intl_pluralCardinal.select(value); + } + + formatString(...args) { + const hasOptions = + typeof args.at(-1) === 'object' && + args.at(-1) !== null; + + const key = + (hasOptions ? args.slice(0, -1) : args) + .filter(Boolean) + .join('.'); + + const options = + (hasOptions + ? args.at(-1) + : {}); + + if (!this.strings) { + throw new Error(`Strings unavailable`); + } + + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } + + // These will be filled up as we iterate over the template, slotting in + // each option (if it's present). + const missingOptionNames = new Set(); + + // And this will have entries deleted as they're encountered in the + // template. Leftover entries are misplaced. + const optionsMap = + new Map( + Object.entries(options).map(([name, value]) => [ + name + .replace(/[A-Z]/g, '_$&') + .toUpperCase(), + value, + ])); + + const output = this.#iterateOverTemplate({ + template: this.strings[key], + + match: languageOptionRegex, + + insert: ({name: optionName}, canceledForming) => { + if (optionsMap.has(optionName)) { + let optionValue; + + // We'll only need the option's value if we're going to use it as + // part of the formed output (see below). + if (!canceledForming) { + optionValue = optionsMap.get(optionName); + } + + // But we always have to delete expected options off the provided + // option map, since the leftovers are what will be used to tell + // which are misplaced. + optionsMap.delete(optionName); + + if (canceledForming) { + return undefined; + } else { + return optionValue; + } + } else { + // We don't need to continue forming the output if we've hit a + // missing option name, since the end result of this formatString + // call will be a thrown error, and formed output won't be needed. + missingOptionNames.add(optionName); + return undefined; + } + }, + }); + + const misplacedOptionNames = + Array.from(optionsMap.keys()); + + withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + if (!empty(missingOptionNames)) { + const names = Array.from(missingOptionNames).join(`, `); + push(new Error(`Missing options: ${names}`)); + } + + if (!empty(misplacedOptionNames)) { + const names = Array.from(misplacedOptionNames).join(`, `); + push(new Error(`Unexpected options: ${names}`)); + } + }); + + return output; + } + + #iterateOverTemplate({ + template, + match: regexp, + insert: insertFn, + }) { + const outputParts = []; + + let canceledForming = false; + + let lastIndex = 0; + let partInProgress = ''; + + for (const match of template.matchAll(regexp)) { + const insertion = + insertFn(match.groups, canceledForming); + + if (insertion === undefined) { + canceledForming = true; + } + + // Don't proceed with forming logic if the insertion function has + // indicated that's not needed anymore - but continue iterating over + // the rest of the template's matches, so other iteration logic (with + // side effects) gets to process everything. + if (canceledForming) { + continue; + } + + partInProgress += template.slice(lastIndex, match.index); + + // Sanitize string arguments in particular. These are taken to come from + // (raw) data and may include special characters that aren't meant to be + // rendered as HTML markup. + const sanitizedInsertion = + this.#sanitizeValueForInsertion(insertion); + + if (typeof sanitizedInsertion === 'string') { + // Join consecutive strings together. + partInProgress += sanitizedInsertion; + } else if ( + sanitizedInsertion instanceof html.Tag && + sanitizedInsertion.contentOnly + ) { + // Collapse string-only tag contents onto the current string part. + partInProgress += sanitizedInsertion.toString(); + } else { + // Push the string part in progress, then the insertion as-is. + outputParts.push(partInProgress); + outputParts.push(sanitizedInsertion); + partInProgress = ''; + } + + lastIndex = match.index + match[0].length; + } + + if (canceledForming) { + return undefined; + } + + // Tack onto the final partInProgress, which may still have a value by this + // point, if the final inserted value was a string. (Otherwise, it'll just + // be equal to the remaining template text.) + if (lastIndex < template.length) { + partInProgress += template.slice(lastIndex); + } + + if (partInProgress) { + outputParts.push(partInProgress); + } + + return this.#wrapSanitized(outputParts); + } + + // Processes a value so that it's suitable to be inserted into a template. + // For strings, this escapes HTML special characters, displaying them as-are + // instead of representing HTML markup. For numbers and booleans, this turns + // them into string values, so they never accidentally get caught as falsy + // by #html stringification. Everything else - most importantly including + // html.Tag objects - gets left as-is, preserving the value exactly as it's + // provided. + #sanitizeValueForInsertion(value) { + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); + if (!escapeHTML) { + throw new Error(`escapeHTML unavailable`); + } + + switch (typeof value) { + case 'string': + return escapeHTML(value); + + case 'number': + case 'boolean': + return value.toString(); + + default: + return value; + } + } + + // Wraps the output of a formatting function in a no-name-nor-attributes + // HTML tag, which will indicate to other calls to formatString that this + // content is a string *that may contain HTML* and doesn't need to + // sanitized any further. It'll still .toString() to just the string + // contents, if needed. + #wrapSanitized(content) { + return html.tags(content, { + [html.blessAttributes]: true, + [html.joinChildren]: '', + [html.noEdgeWhitespace]: true, + }); + } + + // Similar to the above internal methods, but this one is public. + // It should be used when embedding content that may not have previously + // been sanitized directly into an HTML tag or template's contents. + // The templating engine usually handles this on its own, as does passing + // a value (sanitized or not) directly for inserting into formatting + // functions, but if you used a custom slot validation function (for example, + // {validate: v => v.isHTML} instead of {type: 'string'} / {type: 'html'}) + // and are embedding the contents of the slot as a direct child of another + // tag, you should manually sanitize those contents with this function. + sanitize(value) { + if (typeof value === 'string') { + return this.#wrapSanitized(this.#sanitizeValueForInsertion(value)); + } else { + return value; + } + } + + formatDate(date) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.format(date); + } + + formatDateRange(startDate, endDate) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.formatRange(startDate, endDate); + } + + formatDateDuration({ + years: numYears = 0, + months: numMonths = 0, + days: numDays = 0, + approximate = false, + }) { + let basis; + + const years = this.countYears(numYears, {unit: true}); + const months = this.countMonths(numMonths, {unit: true}); + const days = this.countDays(numDays, {unit: true}); + + if (numYears && numMonths && numDays) + basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days}); + else if (numYears && numMonths) + basis = this.formatString('count.dateDuration.yearsMonths', {years, months}); + else if (numYears && numDays) + basis = this.formatString('count.dateDuration.yearsDays', {years, days}); + else if (numYears) + basis = this.formatString('count.dateDuration.years', {years}); + else if (numMonths && numDays) + basis = this.formatString('count.dateDuration.monthsDays', {months, days}); + else if (numMonths) + basis = this.formatString('count.dateDuration.months', {months}); + else if (numDays) + basis = this.formatString('count.dateDuration.days', {days}); + else + return this.formatString('count.dateDuration.zero'); + + if (approximate) { + return this.formatString('count.dateDuration.approximate', { + duration: basis, + }); + } else { + return basis; + } + } + + formatRelativeDate(currentDate, referenceDate, { + considerRoundingDays = false, + approximate = true, + absolute = true, + } = {}) { + const currentInstant = toTemporalInstant.apply(currentDate); + const referenceInstant = toTemporalInstant.apply(referenceDate); + + const comparison = + Temporal.Instant.compare(currentInstant, referenceInstant); + + if (comparison === 0) { + return this.formatString('count.dateDuration.same'); + } + + const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC'); + const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC'); + + const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ); + const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ); + + const {years, months, days} = + laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: + (considerRoundingDays + ? (laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: 'day', + }).years + ? 'month' + : 'day') + : 'day'), + roundingMode: 'halfCeil', + }); + + const duration = + this.formatDateDuration({ + years, months, days, + approximate: false, + }); + + const relative = + this.formatString( + 'count.dateDuration', + (approximate && (years || months || days) + ? (comparison === -1 + ? 'approximateEarlier' + : 'approximateLater') + : (comparison === -1 + ? 'earlier' + : 'later')), + {duration}); + + if (absolute) { + return this.formatString('count.dateDuration.relativeAbsolute', { + relative, + absolute: this.formatDate(currentDate), + }); + } else { + return relative; + } + } + + formatDuration(secTotal, {approximate = false, unit = false} = {}) { + if (secTotal === 0) { + return this.formatString('count.duration.missing'); + } + + const hour = Math.floor(secTotal / 3600); + const min = Math.floor((secTotal - hour * 3600) / 60); + const sec = Math.floor(secTotal - hour * 3600 - min * 60); + + const pad = (val) => val.toString().padStart(2, '0'); + + const stringSubkey = unit ? '.withUnit' : ''; + + const duration = + hour > 0 + ? this.formatString('count.duration.hours' + stringSubkey, { + hours: hour, + minutes: pad(min), + seconds: pad(sec), + }) + : this.formatString('count.duration.minutes' + stringSubkey, { + minutes: min, + seconds: pad(sec), + }); + + return approximate + ? this.formatString('count.duration.approximate', {duration}) + : duration; + } + + formatExternalLink(url, { + style = 'platform', + context = 'generic', + } = {}) { + if (!this.externalLinkSpec) { + throw new TypeError(`externalLinkSpec unavailable`); + } + + isExternalLinkContext(context); + + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + language: this, + context, + }); + } + + isExternalLinkStyle(style); + + const result = + getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + language: this, + context, + }); + + // It's possible for there to not actually be any string available for the + // given URL, style, and context, and we want this to be detectable via + // html.blank(). + return result ?? html.blank(); + } + + formatIndex(value) { + this.assertIntlAvailable('intl_pluralOrdinal'); + return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); + } + + formatNumber(value) { + this.assertIntlAvailable('intl_number'); + return this.intl_number.format(value); + } + + formatWordCount(value) { + const num = this.formatNumber( + value > 1000 ? Math.floor(value / 100) / 10 : value + ); + + const words = + value > 1000 + ? this.formatString('count.words.thousand', {words: num}) + : this.formatString('count.words', {words: num}); + + return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words}); + } + + #formatListHelper(array, processFn) { + // Operate on "insertion markers" instead of the actual contents of the + // array, because the process function (likely an Intl operation) is taken + // to only operate on strings. We'll insert the contents of the array back + // at these points afterwards. + + const insertionMarkers = + Array.from( + {length: array.length}, + (_item, index) => `<::insertion_${index}>`); + + // Basically the same insertion logic as in formatString. Like there, we + // can't assume that insertion markers were kept in the same order as they + // were provided, so we'll refer to the marked index. But we don't need to + // worry about some of the indices *not* corresponding to a provided source + // item, like we do in formatString, so that cuts out a lot of the + // validation logic. + + return this.#iterateOverTemplate({ + template: processFn(insertionMarkers), + + match: /<::insertion_(?<index>[0-9]+)>/g, + + insert: ({index: markerIndex}) => { + return array[markerIndex]; + }, + }); + } + + // Conjunction list: A, B, and C + formatConjunctionList(array) { + this.assertIntlAvailable('intl_listConjunction'); + return this.#formatListHelper( + array, + array => this.intl_listConjunction.format(array)); + } + + // Disjunction lists: A, B, or C + formatDisjunctionList(array) { + this.assertIntlAvailable('intl_listDisjunction'); + return this.#formatListHelper( + array, + array => this.intl_listDisjunction.format(array)); + } + + // Unit lists: A, B, C + formatUnitList(array) { + this.assertIntlAvailable('intl_listUnit'); + return this.#formatListHelper( + array, + array => this.intl_listUnit.format(array)); + } + + // Lists without separator: A B C + formatListWithoutSeparator(array) { + return this.#formatListHelper( + array, + array => array.join(' ')); + } + + // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB + formatFileSize(bytes) { + if (!bytes) return ''; + + bytes = parseInt(bytes); + if (isNaN(bytes)) return ''; + + const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; + + if (bytes >= 10 ** 12) { + return this.formatString('count.fileSize.terabytes', { + terabytes: round(12), + }); + } else if (bytes >= 10 ** 9) { + return this.formatString('count.fileSize.gigabytes', { + gigabytes: round(9), + }); + } else if (bytes >= 10 ** 6) { + return this.formatString('count.fileSize.megabytes', { + megabytes: round(6), + }); + } else if (bytes >= 10 ** 3) { + return this.formatString('count.fileSize.kilobytes', { + kilobytes: round(3), + }); + } else { + return this.formatString('count.fileSize.bytes', {bytes}); + } + } +} + +const countHelper = (stringKey, optionName = stringKey) => + function(value, {unit = false} = {}) { + return this.formatString( + unit + ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) + : `count.${stringKey}`, + {[optionName]: this.formatNumber(value)}); + }; + +// TODO: These are hard-coded. Is there a better way? +Object.assign(Language.prototype, { + countAdditionalFiles: countHelper('additionalFiles', 'files'), + countAlbums: countHelper('albums'), + countArtworks: countHelper('artworks'), + countCommentaryEntries: countHelper('commentaryEntries', 'entries'), + countContributions: countHelper('contributions'), + countCoverArts: countHelper('coverArts'), + countDays: countHelper('days'), + countFlashes: countHelper('flashes'), + countMonths: countHelper('months'), + countTimesReferenced: countHelper('timesReferenced'), + countTimesUsed: countHelper('timesUsed'), + countTracks: countHelper('tracks'), + countWeeks: countHelper('weeks'), + countYears: countHelper('years'), +}); diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js new file mode 100644 index 0000000..43d1638 --- /dev/null +++ b/src/data/things/news-entry.js @@ -0,0 +1,73 @@ +export const NEWS_DATA_FILE = 'news.yaml'; + +import {sortChronologically} from '#sort'; +import Thing from '#thing'; +import {parseDate} from '#yaml'; + +import {contentString, directory, name, simpleDate} + from '#composite/wiki-properties'; + +export class NewsEntry extends Thing { + static [Thing.referenceType] = 'news-entry'; + static [Thing.friendlyName] = `News Entry`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name('Unnamed News Entry'), + directory: directory(), + date: simpleDate(), + + content: contentString(), + + // Expose only + + contentShort: { + flags: {expose: true}, + + expose: { + dependencies: ['content'], + + compute: ({content}) => content.split('<hr class="split">')[0], + }, + }, + }); + + static [Thing.findSpecs] = { + newsEntry: { + referenceTypes: ['news-entry'], + bindTo: 'newsData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Content': {property: 'content'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {NewsEntry}, + }) => ({ + title: `Process news data file`, + file: NEWS_DATA_FILE, + + documentMode: allInOne, + documentThing: NewsEntry, + + save: (results) => ({newsData: results}), + + sort({newsData}) { + sortChronologically(newsData, {latestFirst: true}); + }, + }); +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js new file mode 100644 index 0000000..0327497 --- /dev/null +++ b/src/data/things/static-page.js @@ -0,0 +1,80 @@ +export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; + +import * as path from 'node:path'; + +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; +import Thing from '#thing'; +import {isName} from '#validators'; + +import {contentString, directory, name, simpleString} + from '#composite/wiki-properties'; + +export class StaticPage extends Thing { + static [Thing.referenceType] = 'static'; + static [Thing.friendlyName] = `Static Page`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name('Unnamed Static Page'), + + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, + }, + }, + + directory: directory(), + content: contentString(), + stylesheet: simpleString(), + script: simpleString(), + }); + + static [Thing.findSpecs] = { + staticPage: { + referenceTypes: ['static'], + bindTo: 'staticPageData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, + + 'Style': {property: 'stylesheet'}, + 'Script': {property: 'script'}, + 'Content': {property: 'content'}, + + 'Review Points': {ignore: true}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {onePerFile}, + thingConstructors: {StaticPage}, + }) => ({ + title: `Process static page files`, + + files: dataPath => + traverse(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: DATA_STATIC_PAGE_DIRECTORY, + }), + + documentMode: onePerFile, + documentThing: StaticPage, + + save: (results) => ({staticPageData: results}), + + sort({staticPageData}) { + sortAlphabetically(staticPageData); + }, + }); +} diff --git a/src/data/things/track.js b/src/data/things/track.js new file mode 100644 index 0000000..cc49fc2 --- /dev/null +++ b/src/data/things/track.js @@ -0,0 +1,566 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; +import {isColor, isContributionList, isDate, isFileExtension} + from '#validators'; + +import { + parseAdditionalFiles, + parseAdditionalNames, + parseContributors, + parseDate, + parseDimensions, + parseDuration, +} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedContribs} from '#composite/wiki-data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + additionalFiles, + additionalNameList, + commentary, + commentatorArtists, + contentString, + contributionList, + dimensions, + directory, + duration, + flag, + name, + referenceList, + reverseReferenceList, + simpleDate, + singleReference, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + exitWithoutUniqueCoverArt, + inferredAdditionalNameList, + inheritFromOriginalRelease, + sharedAdditionalNameList, + trackReverseReferenceList, + withAlbum, + withAlwaysReferenceByDirectory, + withContainingTrackSection, + withHasUniqueCoverArt, + withOtherReleases, + withPropertyFromAlbum, +} from '#composite/things/track'; + +export class Track extends Thing { + static [Thing.referenceType] = 'track'; + + static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ + // Update & expose + + name: name('Unnamed Track'), + directory: directory(), + + additionalNames: additionalNameList(), + sharedAdditionalNames: sharedAdditionalNameList(), + inferredAdditionalNames: inferredAdditionalNameList(), + + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), + + duration: duration(), + urls: urls(), + dateFirstReleased: simpleDate(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('color'), + }), + + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + + withPropertyFromAlbum({ + property: input.value('color'), + }), + + exposeDependency({dependency: '#album.color'}), + ], + + alwaysReferenceByDirectory: [ + withAlwaysReferenceByDirectory(), + exposeDependency({dependency: '#alwaysReferenceByDirectory'}), + ], + + // Disables presenting the track as though it has its own unique artwork. + // This flag should only be used in select circumstances, i.e. to override + // an album's trackCoverArtists. This flag supercedes that property, as well + // as the track's own coverArtists. + disableUniqueCoverArt: flag(), + + // File extension for track's corresponding media file. This represents the + // track's unique cover artwork, if any, and does not inherit the extension + // of the album's main artwork. It does inherit trackCoverArtFileExtension, + // if present on the album. + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtFileExtension'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: input.value('jpg'), + }), + ], + + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: [ + withHasUniqueCoverArt(), + + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withPropertyFromAlbum({ + property: input.value('trackArtDate'), + }), + + exposeDependency({dependency: '#album.trackArtDate'}), + ], + + coverArtDimensions: [ + exitWithoutUniqueCoverArt(), + dimensions(), + ], + + commentary: commentary(), + + lyrics: [ + inheritFromOriginalRelease({ + property: input.value('lyrics'), + }), + + contentString(), + ], + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), + + originalReleaseTrack: singleReference({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + + // Internal use only - for directly identifying an album inside a track's + // util.inspect display, if it isn't indirectly available (by way of being + // included in an album's track list). + dataSourceAlbum: singleReference({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), + + artistContribs: [ + inheritFromOriginalRelease({ + property: input.value('artistContribs'), + notFoundValue: input.value([]), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#artistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('artistContribs'), + }), + + exposeDependency({dependency: '#album.artistContribs'}), + ], + + contributorContribs: [ + inheritFromOriginalRelease({ + property: input.value('contributorContribs'), + notFoundValue: input.value([]), + }), + + contributionList(), + ], + + // Cover artists aren't inherited from the original release, since it + // typically varies by release and isn't defined by the musical qualities + // of the track. + coverArtistContribs: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#coverArtistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + ], + + referencedTracks: [ + inheritFromOriginalRelease({ + property: input.value('referencedTracks'), + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + sampledTracks: [ + inheritFromOriginalRelease({ + property: input.value('sampledTracks'), + notFoundValue: input.value([]), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + artTags: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), + ], + + // Update only + + albumData: wikiData({ + class: input.value(Album), + }), + + artistData: wikiData({ + class: input.value(Artist), + }), + + artTagData: wikiData({ + class: input.value(ArtTag), + }), + + flashData: wikiData({ + class: input.value(Flash), + }), + + trackData: wikiData({ + class: input.value(Track), + }), + + // Expose only + + commentatorArtists: commentatorArtists(), + + album: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], + + date: [ + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), + + withPropertyFromAlbum({ + property: input.value('date'), + }), + + exposeDependency({dependency: '#album.date'}), + ], + + hasUniqueCoverArt: [ + withHasUniqueCoverArt(), + exposeDependency({dependency: '#hasUniqueCoverArt'}), + ], + + otherReleases: [ + withOtherReleases(), + exposeDependency({dependency: '#otherReleases'}), + ], + + referencedByTracks: trackReverseReferenceList({ + list: input.value('referencedTracks'), + }), + + sampledByTracks: trackReverseReferenceList({ + list: input.value('sampledTracks'), + }), + + featuredInFlashes: reverseReferenceList({ + data: 'flashData', + list: input.value('featuredTracks'), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Track': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Bandcamp Track ID': { + property: 'bandcampTrackIdentifier', + transform: String, + }, + + 'Bandcamp Artwork ID': { + property: 'bandcampArtworkIdentifier', + transform: String, + }, + + 'Duration': { + property: 'duration', + transform: parseDuration, + }, + + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + + 'Lyrics': {property: 'lyrics'}, + 'Commentary': {property: 'commentary'}, + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Sheet Music Files': { + property: 'sheetMusicFiles', + transform: parseAdditionalFiles, + }, + + 'MIDI Project Files': { + property: 'midiProjectFiles', + transform: parseAdditionalFiles, + }, + + 'Originally Released As': {property: 'originalReleaseTrack'}, + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Art Tags': {property: 'artTags'}, + + 'Review Points': {ignore: true}, + }, + + invalidFieldCombinations: [ + {message: `Rereleases inherit references from the original`, fields: [ + 'Originally Released As', + 'Referenced Tracks', + ]}, + + {message: `Rereleases inherit samples from the original`, fields: [ + 'Originally Released As', + 'Sampled Tracks', + ]}, + + {message: `Rereleases inherit artists from the original`, fields: [ + 'Originally Released As', + 'Artists', + ]}, + + {message: `Rereleases inherit contributors from the original`, fields: [ + 'Originally Released As', + 'Contributors', + ]}, + + {message: `Rereleases inherit lyrics from the original`, fields: [ + 'Originally Released As', + 'Lyrics', + ]}, + + { + message: ({'Has Cover Art': hasCoverArt}) => + (hasCoverArt + ? `"Has Cover Art: true" is inferred from cover artist credits` + : `Tracks without cover art must not have cover artist credits`), + + fields: [ + 'Has Cover Art', + 'Cover Artists', + ], + }, + ], + }; + + static [Thing.findSpecs] = { + track: { + referenceTypes: ['track'], + bindTo: 'trackData', + + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + + trackOriginalReleasesOnly: { + referenceTypes: ['track'], + bindTo: 'trackData', + + include: track => + !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + + // It's still necessary to check alwaysReferenceByDirectory here, since + // it may be set manually (with `Always Reference By Directory: true`), + // and these shouldn't be matched by name (as per usual). + // See the definition for that property for more information. + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + }; + + // Track YAML loading is handled in album.js. + static [Thing.getYamlLoadingSpec] = null; + + [inspect.custom](depth) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { + parts.unshift(`${colors.yellow('[rerelease]')} `); + } + + let album; + + if (depth >= 0) { + try { + album = this.album; + } catch (_error) { + // Computing album might crash for any reason, which we don't want to + // distract from another error we might be trying to work out at the + // moment (for which debugging might involve inspecting this track!). + } + + album ??= this.dataSourceAlbum; + } + + if (album) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); + parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js new file mode 100644 index 0000000..316bd3b --- /dev/null +++ b/src/data/things/wiki-info.js @@ -0,0 +1,110 @@ +export const WIKI_INFO_FILE = 'wiki-info.yaml'; + +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; +import {isColor, isLanguageCode, isName, isURL} from '#validators'; + +import {contentString, flag, name, referenceList, wikiData} + from '#composite/wiki-properties'; + +export class WikiInfo extends Thing { + static [Thing.friendlyName] = `Wiki Info`; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ + // Update & expose + + name: name('Unnamed Wiki'), + + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, + }, + }, + + color: { + flags: {update: true, expose: true}, + update: {validate: isColor}, + + expose: { + transform: color => color ?? '#0088ff', + }, + }, + + // One-line description used for <meta rel="description"> tag. + description: contentString(), + + footerContent: contentString(), + + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + canonicalBase: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, + + divideTrackListsByGroups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), + + // Feature toggles + enableFlashesAndGames: flag(false), + enableListings: flag(false), + enableNews: flag(false), + enableArtTagUI: flag(false), + enableGroupUI: flag(false), + + // Update only + + groupData: wikiData({ + class: input.value(Group), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, + 'Enable Listings': {property: 'enableListings'}, + 'Enable News': {property: 'enableNews'}, + 'Enable Art Tag UI': {property: 'enableArtTagUI'}, + 'Enable Group UI': {property: 'enableGroupUI'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {oneDocumentTotal}, + thingConstructors: {WikiInfo}, + }) => ({ + title: `Process wiki info file`, + file: WIKI_INFO_FILE, + + documentMode: oneDocumentTotal, + documentThing: WikiInfo, + + save(wikiInfo) { + if (!wikiInfo) { + return; + } + + return {wikiInfo}; + }, + }); +} |