From ca4e9b3fd53f91e1cd95c8aa20496177ec39d669 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 26 Jan 2026 13:11:03 -0400 Subject: infra: rename singleton-export thing modules --- src/data/things/AdditionalFile.js | 54 ++ src/data/things/AdditionalName.js | 31 + src/data/things/ArtTag.js | 230 ++++++ src/data/things/Artist.js | 371 ++++++++++ src/data/things/Artwork.js | 422 +++++++++++ src/data/things/Contribution.js | 314 +++++++++ src/data/things/Language.js | 990 ++++++++++++++++++++++++++ src/data/things/MusicVideo.js | 147 ++++ src/data/things/NewsEntry.js | 76 ++ src/data/things/StaticPage.js | 90 +++ src/data/things/Track.js | 1349 ++++++++++++++++++++++++++++++++++++ src/data/things/WikiInfo.js | 169 +++++ src/data/things/additional-file.js | 54 -- src/data/things/additional-name.js | 31 - src/data/things/art-tag.js | 230 ------ src/data/things/artist.js | 371 ---------- src/data/things/artwork.js | 422 ----------- src/data/things/contribution.js | 314 --------- src/data/things/index.js | 25 +- src/data/things/language.js | 990 -------------------------- src/data/things/music-video.js | 147 ---- src/data/things/news-entry.js | 76 -- src/data/things/static-page.js | 90 --- src/data/things/track.js | 1349 ------------------------------------ src/data/things/wiki-info.js | 169 ----- 25 files changed, 4256 insertions(+), 4255 deletions(-) create mode 100644 src/data/things/AdditionalFile.js create mode 100644 src/data/things/AdditionalName.js create mode 100644 src/data/things/ArtTag.js create mode 100644 src/data/things/Artist.js create mode 100644 src/data/things/Artwork.js create mode 100644 src/data/things/Contribution.js create mode 100644 src/data/things/Language.js create mode 100644 src/data/things/MusicVideo.js create mode 100644 src/data/things/NewsEntry.js create mode 100644 src/data/things/StaticPage.js create mode 100644 src/data/things/Track.js create mode 100644 src/data/things/WikiInfo.js delete mode 100644 src/data/things/additional-file.js delete mode 100644 src/data/things/additional-name.js delete mode 100644 src/data/things/art-tag.js delete mode 100644 src/data/things/artist.js delete mode 100644 src/data/things/artwork.js delete mode 100644 src/data/things/contribution.js delete mode 100644 src/data/things/language.js delete mode 100644 src/data/things/music-video.js delete mode 100644 src/data/things/news-entry.js delete mode 100644 src/data/things/static-page.js delete mode 100644 src/data/things/track.js delete mode 100644 src/data/things/wiki-info.js (limited to 'src/data') diff --git a/src/data/things/AdditionalFile.js b/src/data/things/AdditionalFile.js new file mode 100644 index 00000000..b15f62e0 --- /dev/null +++ b/src/data/things/AdditionalFile.js @@ -0,0 +1,54 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +export class AdditionalFile extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: simpleString(), + + description: contentString(), + + filenames: [ + exposeUpdateValueOrContinue({ + validate: input.value(validateArrayItems(isString)), + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Expose only + + isAdditionalFile: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Title': {property: 'title'}, + 'Description': {property: 'description'}, + 'Files': {property: 'filenames'}, + }, + }; + + get paths() { + if (!this.thing) return null; + if (!this.thing.getOwnAdditionalFilePath) return null; + + return ( + this.filenames.map(filename => + this.thing.getOwnAdditionalFilePath(this, filename))); + } +} diff --git a/src/data/things/AdditionalName.js b/src/data/things/AdditionalName.js new file mode 100644 index 00000000..99f3ee46 --- /dev/null +++ b/src/data/things/AdditionalName.js @@ -0,0 +1,31 @@ +import {input} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + + // Expose only + + isAdditionalName: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Annotation': {property: 'annotation'}, + }, + }; +} diff --git a/src/data/things/ArtTag.js b/src/data/things/ArtTag.js new file mode 100644 index 00000000..91248f77 --- /dev/null +++ b/src/data/things/ArtTag.js @@ -0,0 +1,230 @@ +const DATA_ART_TAGS_DIRECTORY = 'art-tags'; +const ART_TAG_DATA_FILE = 'tags.yaml'; + +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {input, V} from '#composite'; +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; +import Thing from '#thing'; +import {unique} from '#sugar'; +import {isName} from '#validators'; +import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; + +import { + exitWithoutDependency, + exposeConstant, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + annotatedReferenceList, + color, + contentString, + directory, + flag, + referenceList, + reverseReferenceList, + name, + soupyFind, + soupyReverse, + thingList, + urls, +} from '#composite/wiki-properties'; + +export class ArtTag extends Thing { + static [Thing.referenceType] = 'tag'; + static [Thing.friendlyName] = `Art Tag`; + static [Thing.wikiData] = 'artTagData'; + + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ + // Update & expose + + name: name(V('Unnamed Art Tag')), + directory: directory(), + color: color(), + isContentWarning: flag(V(false)), + extraReadingURLs: urls(), + + nameShort: [ + exposeUpdateValueOrContinue({ + validate: input.value(isName), + }), + + { + dependencies: ['name'], + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), + }, + ], + + additionalNames: thingList(V(AdditionalName)), + + description: contentString(), + + directDescendantArtTags: referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), + + relatedArtTags: annotatedReferenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + + reference: input.value('artTag'), + thing: input.value('artTag'), + }), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // Expose only + + isArtTag: exposeConstant(V(true)), + + descriptionShort: [ + exitWithoutDependency('description', { + value: input.value(null), + mode: input.value('falsy'), + }), + + { + dependencies: ['description'], + compute: ({description}) => + description.split('
')[0], + }, + ], + + directlyFeaturedInArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichFeature'), + }), + + indirectlyFeaturedInArtworks: [ + { + dependencies: ['allDescendantArtTags'], + compute: ({allDescendantArtTags}) => + unique( + allDescendantArtTags + .flatMap(artTag => artTag.directlyFeaturedInArtworks)), + }, + ], + + // All the art tags which descend from this one - that means its own direct + // descendants, plus all the direct and indirect descendants of each of those! + // The results aren't specially sorted, but they won't contain any duplicates + // (for example if two descendant tags both route deeper to end up including + // some of the same tags). + allDescendantArtTags: [ + { + dependencies: ['directDescendantArtTags'], + compute: ({directDescendantArtTags}) => + unique([ + ...directDescendantArtTags, + ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags), + ]), + }, + ], + + directAncestorArtTags: reverseReferenceList({ + reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), + }), + + // All the art tags which are ancestors of this one as a "baobab tree" - + // what you'd typically think of as roots are all up in the air! Since this + // really is backwards from the way that the art tag tree is written in data, + // chances are pretty good that there will be many of the exact same "leaf" + // nodes - art tags which don't themselves have any ancestors. In the actual + // data structure, each node is a Map, with keys for each ancestor and values + // for each ancestor's own baobab (thus a branching structure, just like normal + // trees in this regard). + ancestorArtTagBaobabTree: [ + { + dependencies: ['directAncestorArtTags'], + compute: ({directAncestorArtTags}) => + new Map( + directAncestorArtTags + .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])), + }, + ], + }); + + static [Thing.findSpecs] = { + artTag: { + referenceTypes: ['tag'], + bindTo: 'artTagData', + + getMatchableNames: artTag => + (artTag.isContentWarning + ? [`cw: ${artTag.name}`] + : [artTag.name]), + }, + }; + + static [Thing.reverseSpecs] = { + artTagsWhichDirectlyAncestor: { + bindTo: 'artTagData', + + referencing: artTag => [artTag], + referenced: artTag => artTag.directDescendantArtTags, + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Tag': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, + 'Description': {property: 'description'}, + 'Extra Reading URLs': {property: 'extraReadingURLs'}, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Color': {property: 'color'}, + 'Is CW': {property: 'isContentWarning'}, + + 'Direct Descendant Tags': {property: 'directDescendantArtTags'}, + + 'Related Tags': { + property: 'relatedArtTags', + transform: entries => + parseAnnotatedReferences(entries, { + referenceField: 'Tag', + referenceProperty: 'artTag', + }), + }, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allTogether}, + thingConstructors: {ArtTag}, + }) => ({ + title: `Process art tags file`, + + files: dataPath => + Promise.allSettled([ + readFile(path.join(dataPath, ART_TAG_DATA_FILE)) + .then(() => [ART_TAG_DATA_FILE]), + + traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: DATA_ART_TAGS_DIRECTORY, + }), + ]).then(results => results + .filter(({status}) => status === 'fulfilled') + .flatMap(({value}) => value)), + + documentMode: allTogether, + documentThing: ArtTag, + + sort({artTagData}) { + sortAlphabetically(artTagData); + }, + }); +} diff --git a/src/data/things/Artist.js b/src/data/things/Artist.js new file mode 100644 index 00000000..85bdc006 --- /dev/null +++ b/src/data/things/Artist.js @@ -0,0 +1,371 @@ +const ARTIST_DATA_FILE = 'artists.yaml'; + +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input, V} from '#composite'; +import Thing from '#thing'; +import {parseArtistAliases, parseArtwork} from '#yaml'; + +import { + sortAlbumsTracksChronologically, + sortArtworksChronologically, + sortAlphabetically, + sortContributionsChronologically, +} from '#sort'; + +import {exitWithoutDependency, exposeConstant, exposeDependency} + from '#composite/control-flow'; +import {withFilteredList, withPropertyFromList} from '#composite/data'; +import {withContributionListSums} from '#composite/wiki-data'; + +import { + constitutibleArtwork, + contentString, + directory, + fileExtension, + flag, + name, + reverseReferenceList, + soupyFind, + soupyReverse, + thing, + thingList, + urls, +} from '#composite/wiki-properties'; + +export class Artist extends Thing { + static [Thing.referenceType] = 'artist'; + static [Thing.wikiData] = 'artistData'; + + static [Thing.constitutibleProperties] = [ + 'avatarArtwork', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({Contribution}) => ({ + // Update & expose + + name: name(V('Unnamed Artist')), + directory: directory(), + urls: urls(), + + contextNotes: contentString(), + + hasAvatar: flag(V(false)), + avatarFileExtension: fileExtension(V('jpg')), + + avatarArtwork: [ + exitWithoutDependency('hasAvatar', { + value: input.value(null), + mode: input.value('falsy'), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Avatar Artwork'), + ], + + isAlias: flag(V(false)), + artistAliases: thingList(V(Artist)), + aliasedArtist: thing(V(Artist)), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // Expose only + + isArtist: exposeConstant(V(true)), + + mockSimpleContribution: { + flags: {expose: true}, + expose: { + dependencies: ['directory', '_find'], + compute: ({directory, _find: find}) => + Object.assign(new Contribution, { + artist: 'artist:' + directory, + + // These nulls have no effect, they're only included + // here for clarity. + date: null, + thing: null, + annotation: null, + artistProperty: null, + thingProperty: null, + + find, + }), + }, + }, + + trackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), + }), + + trackContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), + }), + + trackCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), + }), + + tracksAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('tracksWithCommentaryBy'), + }), + + albumArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumArtistContributionsBy'), + }), + + albumTrackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumTrackArtistContributionsBy'), + }), + + albumCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), + }), + + albumWallpaperArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), + }), + + albumBannerArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), + }), + + albumsAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('albumsWithCommentaryBy'), + }), + + flashContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('flashContributorContributionsBy'), + }), + + flashesAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('flashesWithCommentaryBy'), + }), + + closelyLinkedGroups: reverseReferenceList({ + reverse: soupyReverse.input('groupsCloselyLinkedTo'), + }), + + musicContributions: [ + { + dependencies: [ + 'trackArtistContributions', + 'trackContributorContributions', + ], + + compute: (continuation, { + trackArtistContributions, + trackContributorContributions, + }) => continuation({ + ['#contributions']: [ + ...trackArtistContributions, + ...trackContributorContributions, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortAlbumsTracksChronologically), + }, + ], + + artworkContributions: [ + { + dependencies: [ + 'trackCoverArtistContributions', + 'albumCoverArtistContributions', + 'albumWallpaperArtistContributions', + 'albumBannerArtistContributions', + ], + + compute: (continuation, { + trackCoverArtistContributions, + albumCoverArtistContributions, + albumWallpaperArtistContributions, + albumBannerArtistContributions, + }) => continuation({ + ['#contributions']: [ + ...trackCoverArtistContributions, + ...albumCoverArtistContributions, + ...albumWallpaperArtistContributions, + ...albumBannerArtistContributions, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortArtworksChronologically), + }, + ], + + musicVideoArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('musicVideoArtistContributionsBy'), + }), + + musicVideoContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('musicVideoContributorContributionsBy'), + }), + + totalDuration: [ + withPropertyFromList('musicContributions', V('thing')), + withPropertyFromList('#musicContributions.thing', V('isMainRelease')), + + withFilteredList('musicContributions', '#musicContributions.thing.isMainRelease') + .outputs({'#filteredList': '#mainReleaseContributions'}), + + withContributionListSums('#mainReleaseContributions'), + exposeDependency('#contributionListDuration'), + ], + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, + + hasAvatar: S.id, + avatarFileExtension: S.id, + + tracksAsCommentator: S.toRefs, + albumsAsCommentator: 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 alias of originalArtist.artistAliases) { + if (alias === artist) break; + if (alias.directory === artist.directory) 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'}, + + // note: doesn't really work as an independent field yet + 'Avatar Artwork': { + property: 'avatarArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'avatarArtwork', + fileExtensionFromThingProperty: 'avatarFileExtension', + }), + }, + + 'Has Avatar': {property: 'hasAvatar'}, + 'Avatar File Extension': {property: 'avatarFileExtension'}, + + 'Aliases': { + property: 'artistAliases', + transform: parseArtistAliases, + }, + + '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, + + 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 { + aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); + } + + parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`); + } + + return parts.join(''); + } + + getOwnArtworkPath(artwork) { + return [ + 'media.artistAvatar', + this.directory, + artwork.fileExtension, + ]; + } +} diff --git a/src/data/things/Artwork.js b/src/data/things/Artwork.js new file mode 100644 index 00000000..c1aafa8f --- /dev/null +++ b/src/data/things/Artwork.js @@ -0,0 +1,422 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; +import find from '#find'; +import Thing from '#thing'; + +import { + isContentString, + isContributionList, + isDate, + isDimensions, + isFileExtension, + optional, + validateArrayItems, + validateProperties, + validateReference, + validateReferenceList, +} from '#validators'; + +import { + parseAnnotatedReferences, + parseContributors, + parseDate, + parseDimensions, +} from '#yaml'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + flipFilter, +} from '#composite/control-flow'; + +import { + withFilteredList, + withNearbyItemFromList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + constituteFrom, + constituteOrContinue, + withRecontextualizedContributionList, + withResolvedAnnotatedReferenceList, + withResolvedContribs, + withResolvedReferenceList, +} from '#composite/wiki-data'; + +import { + contentString, + directory, + flag, + reverseReferenceList, + simpleString, + soupyFind, + soupyReverse, + thing, + wikiData, +} from '#composite/wiki-properties'; + +import {withContainingArtworkList} from '#composite/things/artwork'; + +export class Artwork extends Thing { + static [Thing.referenceType] = 'artwork'; + static [Thing.wikiData] = 'artworkData'; + + static [Thing.constitutibleProperties] = [ + // Contributions currently aren't being observed for constitution. + // 'artistContribs', // from attached artwork or thing + ]; + + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ + // Update & expose + + unqualifiedDirectory: directory({ + name: input.value(null), + }), + + thing: thing(), + thingProperty: simpleString(), + + label: simpleString(), + source: contentString(), + originDetails: contentString(), + showFilename: simpleString(), + + dateFromThingProperty: simpleString(), + + date: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + constituteFrom('thing', 'dateFromThingProperty'), + ], + + fileExtensionFromThingProperty: simpleString(), + + fileExtension: [ + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + constituteFrom('thing', 'fileExtensionFromThingProperty', { + else: input.value('jpg'), + }), + ], + + dimensionsFromThingProperty: simpleString(), + + dimensions: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDimensions), + }), + + constituteFrom('thing', 'dimensionsFromThingProperty'), + ], + + attachAbove: flag(V(false)), + + artistContribsFromThingProperty: simpleString(), + artistContribsArtistProperty: simpleString(), + + artistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: 'artistContribsArtistProperty', + }), + + exposeDependencyOrContinue('#resolvedContribs', V('empty')), + + withPropertyFromObject('attachedArtwork', V('artistContribs')), + + withRecontextualizedContributionList('#attachedArtwork.artistContribs'), + exposeDependencyOrContinue('#attachedArtwork.artistContribs'), + + exitWithoutDependency('artistContribsFromThingProperty', V([])), + + withPropertyFromObject('thing', 'artistContribsFromThingProperty') + .outputs({'#value': '#artistContribsFromThing'}), + + withRecontextualizedContributionList('#artistContribsFromThing'), + exposeDependency('#artistContribsFromThing'), + ], + + style: simpleString(), + + artTagsFromThingProperty: simpleString(), + + artTags: [ + withResolvedReferenceList({ + list: input.updateValue({ + validate: + validateReferenceList(ArtTag[Thing.referenceType]), + }), + find: soupyFind.input('artTag'), + }), + + exposeDependencyOrContinue('#resolvedReferenceList', V('empty')), + + constituteOrContinue('attachedArtwork', V('artTags'), V('empty')), + + constituteFrom('thing', 'artTagsFromThingProperty', V([])), + ], + + referencedArtworksFromThingProperty: simpleString(), + + referencedArtworks: [ + { + compute: (continuation) => continuation({ + ['#find']: + find.mixed({ + track: find.trackPrimaryArtwork, + album: find.albumPrimaryArtwork, + }), + }), + }, + + withResolvedAnnotatedReferenceList({ + list: input.updateValue({ + validate: + // TODO: It's annoying to hardcode this when it's really the + // same behavior as through annotatedReferenceList and through + // referenceListUpdateDescription, the latter of which isn't + // available outside of #composite/wiki-data internals. + validateArrayItems( + validateProperties({ + reference: validateReference(['album', 'track']), + annotation: optional(isContentString), + })), + }), + + data: '_artworkData', + find: '#find', + + thing: input.value('artwork'), + }), + + exposeDependencyOrContinue('#resolvedAnnotatedReferenceList', V('empty')), + + constituteFrom('thing', 'referencedArtworksFromThingProperty', { + else: input.value([]), + }), + ], + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworks (mixedFind) + artworkData: wikiData(V(Artwork)), + + // Expose only + + isArtwork: exposeConstant(V(true)), + + referencedByArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichReference'), + }), + + isMainArtwork: [ + withContainingArtworkList(), + exitWithoutDependency('#containingArtworkList'), + + { + dependencies: [input.myself(), '#containingArtworkList'], + compute: ({ + [input.myself()]: myself, + ['#containingArtworkList']: list, + }) => + list[0] === myself, + }, + ], + + mainArtwork: [ + withContainingArtworkList(), + exitWithoutDependency('#containingArtworkList'), + + { + dependencies: ['#containingArtworkList'], + compute: ({'#containingArtworkList': list}) => + list[0], + }, + ], + + attachedArtwork: [ + exitWithoutDependency('attachAbove', { + value: input.value(null), + mode: input.value('falsy'), + }), + + withContainingArtworkList(), + + withPropertyFromList('#containingArtworkList', V('attachAbove')), + + flipFilter('#containingArtworkList.attachAbove') + .outputs({'#containingArtworkList.attachAbove': '#filterNotAttached'}), + + withNearbyItemFromList({ + list: '#containingArtworkList', + item: input.myself(), + offset: input.value(-1), + filter: '#filterNotAttached', + }), + + exposeDependency('#nearbyItem'), + ], + + attachingArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichAttach'), + }), + + groups: [ + withPropertyFromObject('thing', V('groups')), + exposeDependencyOrContinue('#thing.groups'), + + exposeConstant(V([])), + ], + + contentWarningArtTags: [ + withPropertyFromList('artTags', V('isContentWarning')), + withFilteredList('artTags', '#artTags.isContentWarning'), + exposeDependency('#filteredList'), + ], + + contentWarnings: [ + withPropertyFromList('contentWarningArtTags', V('name')), + exposeDependency('#contentWarningArtTags.name'), + ], + + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Directory': {property: 'unqualifiedDirectory'}, + 'File Extension': {property: 'fileExtension'}, + + 'Dimensions': { + property: 'dimensions', + transform: parseDimensions, + }, + + 'Label': {property: 'label'}, + 'Source': {property: 'source'}, + 'Origin Details': {property: 'originDetails'}, + 'Show Filename': {property: 'showFilename'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Attach Above': {property: 'attachAbove'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Style': {property: 'style'}, + + 'Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, + }, + }, + }; + + static [Thing.reverseSpecs] = { + artworksWhichReference: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + referencingArtwork.referencedArtworks + .map(({artwork: referencedArtwork, ...referenceDetails}) => ({ + referencingArtwork, + referencedArtwork, + referenceDetails, + })), + + referenced: ({referencedArtwork}) => [referencedArtwork], + + tidy: ({referencingArtwork, referenceDetails}) => ({ + artwork: referencingArtwork, + ...referenceDetails, + }), + + date: ({artwork}) => artwork.date, + }, + + artworksWhichAttach: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + (referencingArtwork.attachAbove + ? [referencingArtwork] + : []), + + referenced: referencingArtwork => + [referencingArtwork.attachedArtwork], + }, + + artworksWhichFeature: { + bindTo: 'artworkData', + + referencing: artwork => [artwork], + referenced: artwork => artwork.artTags, + }, + }; + + get path() { + if (!this.thing) return null; + if (!this.thing.getOwnArtworkPath) return null; + + return this.thing.getOwnArtworkPath(this); + } + + countOwnContributionInContributionTotals(contrib) { + if (this.attachAbove) { + return false; + } + + if (contrib.annotation?.startsWith('edits for wiki')) { + return false; + } + + return true; + } + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + parts.push(` for ${inspect(this.thing, newOptions)}`); + } else { + parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + return parts.join(''); + } +} diff --git a/src/data/things/Contribution.js b/src/data/things/Contribution.js new file mode 100644 index 00000000..4048709b --- /dev/null +++ b/src/data/things/Contribution.js @@ -0,0 +1,314 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input, V} from '#composite'; +import {empty} from '#sugar'; +import Thing from '#thing'; +import {isBoolean, isStringNonEmpty, isThing} from '#validators'; + +import {simpleDate, singleReference, soupyFind} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withFilteredList, + withNearbyItemFromList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + inheritFromContributionPresets, + withContainingReverseContributionList, + withContributionContext, +} from '#composite/things/contribution'; + +export class Contribution extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: { + flags: {update: true, expose: true}, + update: {validate: isThing}, + }, + + thingProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + artistProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + date: simpleDate(), + + artist: singleReference({ + find: soupyFind.input('artist'), + }), + + annotation: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + countInContributionTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + inheritFromContributionPresets(), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant(V(true)), + ], + + countInDurationTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + inheritFromContributionPresets(), + + withPropertyFromObject('thing', V('duration')), + exitWithoutDependency('#thing.duration', { + value: input.value(false), + mode: input.value('falsy'), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant(V(true)), + ], + + // Update only + + find: soupyFind(), + + // Expose only + + isContribution: exposeConstant(V(true)), + + annotationParts: { + flags: {expose: true}, + expose: { + dependencies: ['annotation'], + compute: ({annotation}) => + (annotation + ? annotation.split(',').map(part => part.trim()) + : []), + }, + }, + + context: [ + withContributionContext(), + + { + dependencies: [ + '#contributionTarget', + '#contributionProperty', + ], + + compute: ({ + ['#contributionTarget']: target, + ['#contributionProperty']: property, + }) => ({ + target, + property, + }), + }, + ], + + matchingPresets: [ + withPropertyFromObject('thing', { + property: input.value('wikiInfo'), + internal: input.value(true), + }), + + exitWithoutDependency('#thing.wikiInfo', V([])), + + withPropertyFromObject('#thing.wikiInfo', V('contributionPresets')) + .outputs({'#thing.wikiInfo.contributionPresets': '#contributionPresets'}), + + exitWithoutDependency('#contributionPresets', V([]), V('empty')), + + withContributionContext(), + + // TODO: implementing this with compositional filters would be fun + { + dependencies: [ + '#contributionPresets', + '#contributionTarget', + '#contributionProperty', + 'annotation', + ], + + compute: ({ + ['#contributionPresets']: presets, + ['#contributionTarget']: target, + ['#contributionProperty']: property, + ['annotation']: annotation, + }) => + presets.filter(preset => + preset.context[0] === target && + preset.context.slice(1).includes(property) && + // For now, only match if the annotation is a complete match. + // Partial matches (e.g. because the contribution includes "two" + // annotations, separated by commas) don't count. + preset.annotation === annotation), + }, + ], + + // All the contributions from the list which includes this contribution. + // Note that this list contains not only other contributions by the same + // artist, but also this very contribution. It doesn't mix contributions + // exposed on different properties. + associatedContributions: [ + exitWithoutDependency('thing', V([])), + exitWithoutDependency('thingProperty', V([])), + + withPropertyFromObject('thing', 'thingProperty') + .outputs({'#value': '#contributions'}), + + withPropertyFromList('#contributions', V('annotation')), + + { + dependencies: ['#contributions.annotation', 'annotation'], + compute: (continuation, { + ['#contributions.annotation']: contributionAnnotations, + ['annotation']: annotation, + }) => continuation({ + ['#likeContributionsFilter']: + contributionAnnotations.map(mappingAnnotation => + (annotation?.startsWith(`edits for wiki`) + ? mappingAnnotation?.startsWith(`edits for wiki`) + : !mappingAnnotation?.startsWith(`edits for wiki`))), + }), + }, + + withFilteredList('#contributions', '#likeContributionsFilter') + .outputs({'#filteredList': '#contributions'}), + + exposeDependency('#contributions'), + ], + + previousBySameArtist: [ + withContainingReverseContributionList() + .outputs({'#containingReverseContributionList': '#list'}), + + exitWithoutDependency('#list'), + + withNearbyItemFromList('#list', input.myself(), V(-1)), + exposeDependency('#nearbyItem'), + ], + + nextBySameArtist: [ + withContainingReverseContributionList() + .outputs({'#containingReverseContributionList': '#list'}), + + exitWithoutDependency('#list'), + + withNearbyItemFromList('#list', input.myself(), V(+1)), + exposeDependency('#nearbyItem'), + ], + + groups: [ + withPropertyFromObject('thing', V('groups')), + exposeDependencyOrContinue('#thing.groups'), + + exposeConstant(V([])), + ], + }); + + [inspect.custom](depth, options, inspect) { + const parts = []; + const accentParts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.annotation) { + accentParts.push(colors.green(`"${this.annotation}"`)); + } + + if (this.date) { + accentParts.push(colors.yellow(this.date.toLocaleDateString())); + } + + let artistRef; + if (depth >= 0) { + let artist; + try { + artist = this.artist; + } catch { + // Computing artist might crash for any reason - don't distract from + // other errors as a result of inspecting this contribution. + } + + if (artist) { + artistRef = + colors.blue(Thing.getReference(artist)); + } + } else { + artistRef = + colors.green(CacheableObject.getUpdateValue(this, 'artist')); + } + + if (artistRef) { + accentParts.push(`by ${artistRef}`); + } + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + accentParts.push(`to ${inspect(this.thing, newOptions)}`); + } else { + accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + if (!empty(accentParts)) { + parts.push(` (${accentParts.join(', ')})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/Language.js b/src/data/things/Language.js new file mode 100644 index 00000000..7f3f43de --- /dev/null +++ b/src/data/things/Language.js @@ -0,0 +1,990 @@ +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; + +import {withAggregate} from '#aggregate'; +import {logWarn} from '#cli'; +import {input, V} from '#composite'; +import * as html from '#html'; +import {accumulateSum, empty, withEntries} from '#sugar'; +import {isLanguageCode, isObject} from '#validators'; +import Thing from '#thing'; +import {languageOptionRegex} from '#wiki-data'; + +import { + externalLinkSpec, + getExternalLinkStringOfStyleFromDescriptors, + getExternalLinkStringsFromDescriptors, + isExternalLinkContext, + isExternalLinkStyle, +} from '#external-links'; + +import {exitWithoutDependency, exposeConstant} + from '#composite/control-flow'; +import {flag, name} from '#composite/wiki-properties'; + +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(V(`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 + // 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(V(false)), + + // Mapping of translation keys to values (strings). Generally, don't + // access this object directly - use methods instead. + strings: [ + { + dependencies: [ + input.updateValue({validate: isObject}), + 'inheritedStrings', + ], + + compute: (continuation, { + [input.updateValue()]: strings, + ['inheritedStrings']: inheritedStrings, + }) => + (strings && inheritedStrings + ? continuation() + : strings ?? inheritedStrings), + }, + + { + dependencies: ['inheritedStrings', 'code'], + transform(strings, {inheritedStrings, code}) { + 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'}, + }, + + // Expose only + + isLanguage: exposeConstant(V(true)), + + onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))), + + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), + intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}), + 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'}), + intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}), + + 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: [ + exitWithoutDependency('strings'), + + { + dependencies: ['strings'], + compute: ({strings}) => + withEntries(strings, entries => entries + .map(([key, value]) => [key, html.escape(value)])), + }, + ], + }); + + 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); + } + + $order(...args) { + return this.orderStringOptions(...args); + } + + assertIntlAvailable(property) { + if (!this[property]) { + throw new Error(`Intl API ${property} unavailable`); + } + } + + countWords(text) { + this.assertIntlAvailable('intl_wordSegmenter'); + + const string = html.resolve(text, {normalize: 'plain'}); + const segments = this.intl_wordSegmenter.segment(string); + + return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0); + } + + getUnitForm(value) { + this.assertIntlAvailable('intl_pluralCardinal'); + return this.intl_pluralCardinal.select(value); + } + + formatString(...args) { + if (typeof args.at(-1) === 'function') { + throw new Error(`Passed function - did you mean language.encapsulate() instead?`); + } + + const hasOptions = + typeof args.at(-1) === 'object' && + args.at(-1) !== null; + + const key = + this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); + + const template = + this.#getStringTemplateFromFormedKey(key); + + const options = + (hasOptions + ? args.at(-1) + : {}); + + const constantCasify = name => + name + .replace(/[A-Z]/g, '_$&') + .toUpperCase(); + + // These will be filled up as we iterate over the template, slotting in + // each option (if it's present). + const missingOptionNames = new Set(); + + // These will also be filled. It's a bit different of an error, indicating + // a provided option was *expected,* but its value was null, undefined, or + // blank HTML content. + const valuelessOptionNames = new Set(); + + // These *might* be missing, and if they are, that's OK!! Instead of adding + // to the valueless set above, we'll just mark to return a blank for the + // whole string. + const expectedValuelessOptionNames = + new Set( + (options[this.onlyIfOptions] ?? []) + .map(constantCasify)); + + let seenExpectedValuelessOption = false; + + const isValueless = + value => + value === null || + value === undefined || + html.isBlank(value); + + // 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]) => [ + constantCasify(name), + value, + ])); + + const output = this.#iterateOverTemplate({ + template, + match: languageOptionRegex, + + insert: ({name: optionName}, canceledForming) => { + if (!optionsMap.has(optionName)) { + missingOptionNames.add(optionName); + + // 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. + // Return undefined to mark canceledForming for the following + // iterations (and exit early out of this iteration). + return undefined; + } + + // Even if we're not actually forming the output anymore, we'll still + // have to access this option's value to check if it is invalid. + const optionValue = optionsMap.get(optionName); + + // 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 - information you want even (or doubly so) if we've + // already stopped forming the output thanks to missing options. + optionsMap.delete(optionName); + + // Just like if an option is missing, a valueless option cancels + // forming the rest of the output. + if (isValueless(optionValue)) { + // It's also an error, *except* if this option is one of the ones + // that we're indicated to *expect* might be valueless! In that case, + // we still need to stop forming the string (and mark a separate flag + // so that we return a blank), but it's not an error. + if (expectedValuelessOptionNames.has(optionName)) { + seenExpectedValuelessOption = true; + } else { + valuelessOptionNames.add(optionName); + } + + return undefined; + } + + if (canceledForming) { + return undefined; + } + + return this.sanitize(optionValue); + }, + }); + + const misplacedOptionNames = + Array.from(optionsMap.keys()); + + withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + const names = set => Array.from(set).join(', '); + + if (!empty(missingOptionNames)) { + push(new Error( + `Missing options: ${names(missingOptionNames)}`)); + } + + if (!empty(valuelessOptionNames)) { + push(new Error( + `Valueless options: ${names(valuelessOptionNames)}`)); + } + + if (!empty(misplacedOptionNames)) { + push(new Error( + `Unexpected options: ${names(misplacedOptionNames)}`)); + } + }); + + // If an option was valueless as marked to expect, then that indicates + // the whole string should be treated as blank content. + if (seenExpectedValuelessOption) { + return html.blank(); + } + + return output; + } + + orderStringOptions(...args) { + let slice = null, at = null, parts = null; + if (args.length >= 2 && typeof args.at(-1) === 'number') { + if (args.length >= 3 && typeof args.at(-2) === 'number') { + slice = [args.at(-2), args.at(-1)]; + parts = args.slice(0, -2); + } else { + at = args.at(-1); + parts = args.slice(0, -1); + } + } else { + parts = args; + } + + const template = this.getStringTemplate(...parts); + const matches = Array.from(template.matchAll(languageOptionRegex)); + const options = matches.map(({groups}) => groups.name); + + if (slice !== null) return options.slice(...slice); + if (at !== null) return options.at(at); + return options; + } + + getStringTemplate(...args) { + const key = this.#joinKeyParts(args); + return this.#getStringTemplateFromFormedKey(key); + } + + #getStringTemplateFromFormedKey(key) { + if (!this.strings) { + throw new Error(`Strings unavailable`); + } + + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } + + return this.strings[key]; + } + + #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); + + const insertionItems = html.smush(insertion).content; + if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { + // Push the insertion exactly as it is, rather than manipulating. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertion); + partInProgress = ''; + } else for (const insertionItem of insertionItems) { + if (typeof insertionItem === 'string') { + // Join consecutive strings together. + partInProgress += insertionItem; + } else { + // Push the string part in progress, then the insertion as-is. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertionItem); + 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) { + switch (typeof value) { + case 'string': + return html.escape(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) { + // Null or undefined date is blank content. + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_date'); + return this.intl_date.format(date); + } + + formatDateRange(startDate, endDate) { + // formatDateRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } + + this.assertIntlAvailable('intl_date'); + return this.intl_date.formatRange(startDate, endDate); + } + + formatYear(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.format(date); + } + + formatMonthDay(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateMonthDay'); + return this.intl_dateMonthDay.format(date); + } + + formatYearRange(startDate, endDate) { + // formatYearRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.formatRange(startDate, endDate); + } + + formatDateDuration({ + years: numYears = 0, + months: numMonths = 0, + days: numDays = 0, + approximate = false, + }) { + // Give up if any of years, months, or days is null or undefined. + // These default to zero, so something's gone pretty badly wrong to + // pass in all or partial missing values. + if ( + numYears === undefined || numYears === null || + numMonths === undefined || numMonths === null || + numDays === undefined || numDays === null + ) { + throw new Error(`Expected values or default zero for years, months, and days`); + } + + 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, + } = {}) { + // Give up if current and/or reference date is null or undefined. + if ( + currentDate === undefined || currentDate === null || + referenceDate === undefined || referenceDate === null + ) { + throw new Error(`Expected values for currentDate and referenceDate`); + } + + 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} = {}) { + // Null or undefined duration is blank content. + if (secTotal === null || secTotal === undefined) { + return html.blank(); + } + + // Zero duration is a "missing" string. + 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', + } = {}) { + // Null or undefined url is blank content. + if (url === null || url === undefined) { + return html.blank(); + } + + isExternalLinkContext(context); + + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { + language: this, + context, + }); + } + + isExternalLinkStyle(style); + + const result = + getExternalLinkStringOfStyleFromDescriptors(url, style, 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) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_pluralOrdinal'); + return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); + } + + formatNumber(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_number'); + return this.intl_number.format(value); + } + + formatWordCount(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + + 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) { + // Empty lists, null, and undefined are blank content. + if (empty(array) || array === null || array === undefined) { + return html.blank(); + } + + // 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_(?[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) { + // Null or undefined bytes is blank content. + if (bytes === null || bytes === undefined) { + return html.blank(); + } + + // Zero bytes is blank content. + if (bytes === 0) { + return html.blank(); + } + + bytes = parseInt(bytes); + + // Non-number bytes is blank content! Wow. + if (isNaN(bytes)) { + return html.blank(); + } + + 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}); + } + } + + typicallyLowerCase(string) { + // Utter nonsense implementation, so this only works on strings, + // not actual HTML content, and may rudely disrespect *intentful* + // capitalization of whatever goes into it. + + if (typeof string !== 'string') return string; + if (string.length <= 1) return string; + if (/^\S+?[A-Z]/.test(string)) return string; + + return string[0].toLowerCase() + string.slice(1); + } + + // Utility function to quickly provide a useful string key + // (generally a prefix) to stuff nested beneath it. + encapsulate(...args) { + const fn = + (typeof args.at(-1) === 'function' + ? args.at(-1) + : null); + + const parts = + (fn + ? args.slice(0, -1) + : args); + + const capsule = + this.#joinKeyParts(parts); + + if (fn) { + return fn(capsule); + } else { + return capsule; + } + } + + #joinKeyParts(parts) { + return parts.filter(Boolean).join('.'); + } +} + +const countHelper = (stringKey, optionName = stringKey) => + function(value, { + unit = false, + blankIfZero = false, + } = {}) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + + // Zero is blank content, if that option is set. + if (value === 0 && blankIfZero) { + return html.blank(); + } + + 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'), + countArtTags: countHelper('artTags', 'tags'), + countArtworks: countHelper('artworks'), + countCommentaryEntries: countHelper('commentaryEntries', 'entries'), + countContributions: countHelper('contributions'), + countDays: countHelper('days'), + countFlashes: countHelper('flashes'), + countMonths: countHelper('months'), + countTimesFeatured: countHelper('timesFeatured'), + countTimesReferenced: countHelper('timesReferenced'), + countTimesUsed: countHelper('timesUsed'), + countTracks: countHelper('tracks'), + countWeeks: countHelper('weeks'), + countYears: countHelper('years'), +}); diff --git a/src/data/things/MusicVideo.js b/src/data/things/MusicVideo.js new file mode 100644 index 00000000..20f201cc --- /dev/null +++ b/src/data/things/MusicVideo.js @@ -0,0 +1,147 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; +import find from '#find'; +import Thing from '#thing'; +import {is, isDate, isStringNonEmpty, isURL} from '#validators'; +import {parseContributors, parseDate} from '#yaml'; + +import {constituteFrom} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + contributionList, + dimensions, + directory, + fileExtension, + soupyFind, + soupyReverse, + thing, + urls, +} from '#composite/wiki-properties'; + +export class MusicVideo extends Thing { + static [Thing.referenceType] = 'music-video'; + static [Thing.wikiData] = 'musicVideoData'; + + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ + // Update & expose + + thing: thing(), + + label: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + expose: {transform: value => value ?? 'Music video'}, + }, + + labelStyle: { + flags: {update: true, expose: true}, + update: { + validate: + is('label', 'title'), + }, + }, + + unqualifiedDirectory: directory({name: 'label'}), + + date: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + constituteFrom('thing', V('date')), + ], + + url: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, + + coverArtFileExtension: fileExtension(V('jpg')), + coverArtDimensions: dimensions(), + + artistContribs: contributionList({ + artistProperty: input.value('musicVideoArtistContributions'), + }), + + contributorContribs: contributionList({ + artistProperty: input.value('musicVideoContributorContributions'), + }), + + // Update only + + find: soupyFind(), + + // Expose only + + isMusicVideo: exposeConstant(V(true)), + + dateIsSpecified: [ + withResultOfAvailabilityCheck('_date'), + exposeDependency('#availability'), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Label': {property: 'label'}, + 'Label Style': {property: 'labelStyle'}, + 'Directory': {property: 'unqualifiedDirectory'}, + 'Date': {property: 'date', transform: parseDate}, + 'URL': {property: 'url'}, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Cover Art Dimensions': {property: 'coverArtDimensions'}, + + 'Artists': {property: 'artistContribs', transform: parseContributors}, + 'Contributors': {property: 'contributorContribs', transform: parseContributors}, + }, + }; + + static [Thing.reverseSpecs] = { + musicVideoArtistContributionsBy: + soupyReverse.contributionsBy('musicVideoData', 'artistContribs'), + + musicVideoContributorContributionsBy: + soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'), + }; + + get path() { + if (!this.thing) return null; + if (!this.thing.getOwnMusicVideoCoverPath) return null; + + return this.thing.getOwnMusicVideoCoverPath(this); + } + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + parts.push(` for ${inspect(this.thing, newOptions)}`); + } else { + parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + return parts.join(''); + } +} diff --git a/src/data/things/NewsEntry.js b/src/data/things/NewsEntry.js new file mode 100644 index 00000000..65fd125b --- /dev/null +++ b/src/data/things/NewsEntry.js @@ -0,0 +1,76 @@ +const NEWS_DATA_FILE = 'news.yaml'; + +import {V} from '#composite'; +import {sortChronologically} from '#sort'; +import Thing from '#thing'; +import {parseDate} from '#yaml'; + +import {exposeConstant} from '#composite/control-flow'; +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.wikiData] = 'newsData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name(V('Unnamed News Entry')), + directory: directory(), + date: simpleDate(), + + content: contentString(), + + // Expose only + + isNewsEntry: exposeConstant(V(true)), + + contentShort: { + flags: {expose: true}, + + expose: { + dependencies: ['content'], + + compute: ({content}) => content.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, + + sort({newsData}) { + sortChronologically(newsData, {latestFirst: true}); + }, + }); +} diff --git a/src/data/things/StaticPage.js b/src/data/things/StaticPage.js new file mode 100644 index 00000000..daa77a7e --- /dev/null +++ b/src/data/things/StaticPage.js @@ -0,0 +1,90 @@ +const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; + +import * as path from 'node:path'; + +import {V} from '#composite'; +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; +import Thing from '#thing'; +import {isName} from '#validators'; + +import {exposeConstant} from '#composite/control-flow'; +import {contentString, directory, flag, name, simpleString} + from '#composite/wiki-properties'; + +export class StaticPage extends Thing { + static [Thing.referenceType] = 'static'; + static [Thing.friendlyName] = `Static Page`; + static [Thing.wikiData] = 'staticPageData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: name(V('Unnamed Static Page')), + + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, + }, + }, + + directory: directory(), + + stylesheet: simpleString(), + script: simpleString(), + content: contentString(), + + absoluteLinks: flag(V(false)), + + // Expose only + + isStaticPage: exposeConstant(V(true)), + }); + + static [Thing.findSpecs] = { + staticPage: { + referenceTypes: ['static'], + bindTo: 'staticPageData', + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, + + 'Absolute Links': {property: 'absoluteLinks'}, + + '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, + + sort({staticPageData}) { + sortAlphabetically(staticPageData); + }, + }); +} diff --git a/src/data/things/Track.js b/src/data/things/Track.js new file mode 100644 index 00000000..b4e56d82 --- /dev/null +++ b/src/data/things/Track.js @@ -0,0 +1,1349 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input, V} from '#composite'; +import find, {keyRefRegex} from '#find'; +import {onlyItem} from '#sugar'; +import {sortByDate} from '#sort'; +import Thing from '#thing'; +import {compareKebabCase} from '#wiki-data'; + +import { + isBoolean, + isColor, + isContentString, + isContributionList, + isDate, + isFileExtension, + validateReference, +} from '#validators'; + +import { + parseAdditionalFiles, + parseAdditionalNames, + parseAnnotatedReferences, + parseArtwork, + parseCommentary, + parseContributors, + parseCreditingSources, + parseReferencingSources, + parseDate, + parseDimensions, + parseDuration, + parseLyrics, + parseMusicVideos, +} from '#yaml'; + +import { + exitWithoutDependency, + exitWithoutUpdateValue, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + exposeWhetherDependencyAvailable, + withAvailabilityFilter, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + fillMissingListItems, + withFilteredList, + withFlattenedList, + withIndexInList, + withMappedList, + withPropertiesFromObject, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + withRecontextualizedContributionList, + withRedatedContributionList, + withResolvedContribs, + withResolvedReference, +} from '#composite/wiki-data'; + +import { + commentatorArtists, + constitutibleArtworkList, + contentString, + contributionList, + dimensions, + directory, + duration, + flag, + name, + referenceList, + referencedArtworkList, + reverseReferenceList, + simpleDate, + simpleString, + soupyFind, + soupyReverse, + thing, + thingList, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + inheritContributionListFromMainRelease, + inheritFromMainRelease, +} from '#composite/things/track'; + +export class Track extends Thing { + static [Thing.referenceType] = 'track'; + static [Thing.wikiData] = 'trackData'; + + static [Thing.constitutibleProperties] = [ + // Contributions currently aren't being observed for constitution. + // 'artistContribs', // from main release or album + // 'contributorContribs', // from main release + // 'coverArtistContribs', // from main release + + 'trackArtworks', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, + Album, + ArtTag, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, + LyricsEntry, + MusicVideo, + ReferencingSourcesEntry, + TrackSection, + WikiInfo, + }) => ({ + // > Update & expose - Internal relationships + + album: thing(V(Album)), + trackSection: thing(V(TrackSection)), + + // > Update & expose - Identifying metadata + + name: name(V('Unnamed Track')), + nameText: contentString(), + + directory: directory({ + suffix: 'directorySuffix', + }), + + suffixDirectoryFromAlbum: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('trackSection', V('suffixTrackDirectories')), + exposeDependency('#trackSection.suffixTrackDirectories'), + ], + + // Controls how find.track works - it'll never be matched by + // a reference just to the track's name, which means you don't + // have to always reference some *other* (much more commonly + // referenced) track by directory instead of more naturally by name. + alwaysReferenceByDirectory: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('album', V('alwaysReferenceTracksByDirectory')), + + // Falsy mode means this exposes true if the album's property is true, + // but continues if the property is false (which is also the default). + exposeDependencyOrContinue({ + dependency: '#album.alwaysReferenceTracksByDirectory', + mode: input.value('falsy'), + }), + + exitWithoutDependency('_mainRelease', V(false)), + exitWithoutDependency('mainReleaseTrack', V(false)), + + withPropertyFromObject('mainReleaseTrack', V('name')), + + { + dependencies: ['name', '#mainReleaseTrack.name'], + compute: ({ + ['name']: name, + ['#mainReleaseTrack.name']: mainReleaseName, + }) => + compareKebabCase(name, mainReleaseName), + }, + ], + + // Album or track. The exposed value is really just what's provided here, + // whether or not a matching track is found on a provided album, for + // example. When presenting or processing, read `mainReleaseTrack`. + mainRelease: [ + exitWithoutUpdateValue({ + validate: input.value( + validateReference(['album', 'track'])), + }), + + { + dependencies: ['name'], + transform: (ref, continuation, {name: ownName}) => + (ref === 'same name single' + ? continuation(ref, { + ['#albumOrTrackReference']: null, + ['#sameNameSingleReference']: ownName, + }) + : continuation(ref, { + ['#albumOrTrackReference']: ref, + ['#sameNameSingleReference']: null, + })), + }, + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('trackMainReleasesOnly'), + }).outputs({ + '#resolvedReference': '#matchingTrack', + }), + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('album'), + }).outputs({ + '#resolvedReference': '#matchingAlbum', + }), + + withResolvedReference({ + ref: '#sameNameSingleReference', + find: soupyFind.input('albumSinglesOnly'), + findOptions: input.value({ + fuzz: { + capitalization: true, + kebab: true, + }, + }), + }).outputs({ + '#resolvedReference': '#sameNameSingle', + }), + + exposeDependencyOrContinue('#sameNameSingle'), + exposeDependencyOrContinue('#matchingAlbum'), + exposeDependency('#matchingTrack'), + ], + + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), + + additionalNames: thingList(V(AdditionalName)), + + dateFirstReleased: simpleDate(), + + // > Update & expose - Credits and contributors + + artistText: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), + ], + + artistTextInLists: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + exposeDependencyOrContinue('_artistText'), + + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), + ], + + artistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue('#artistContribs', V('empty')), + + // Specifically inherit artist contributions later than artist contribs. + // Secondary releases' artists may differ from the main release. + inheritContributionListFromMainRelease(), + + withPropertyFromObject('album', V('trackArtistContribs')), + + withRecontextualizedContributionList({ + list: '#album.trackArtistContribs', + artistProperty: input.value('trackArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackArtistContribs', + date: 'date', + }), + + exposeDependency('#album.trackArtistContribs'), + ], + + contributorContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#contributorContribs', + }), + + exposeDependencyOrContinue('#contributorContribs', V('empty')), + + inheritContributionListFromMainRelease(), + + exposeConstant(V([])), + ], + + // > Update & expose - General configuration + + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('trackSection', V('countTracksInArtistTotals')), + exposeDependency('#trackSection.countTracksInArtistTotals'), + ], + + disableUniqueCoverArt: flag(V(false)), + disableDate: flag(V(false)), + + // > Update & expose - General metadata + + duration: duration(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withPropertyFromObject('trackSection', V('color')), + exposeDependencyOrContinue('#trackSection.color'), + + withPropertyFromObject('album', V('color')), + exposeDependency('#album.color'), + ], + + needsLyrics: [ + exposeUpdateValueOrContinue({ + mode: input.value('falsy'), + validate: input.value(isBoolean), + }), + + exitWithoutDependency('_lyrics', { + value: input.value(false), + mode: input.value('empty'), + }), + + withPropertyFromList('_lyrics', V('helpNeeded')), + + { + dependencies: ['#lyrics.helpNeeded'], + compute: ({ + ['#lyrics.helpNeeded']: helpNeeded, + }) => + helpNeeded.includes(true) + }, + ], + + urls: urls(), + + // > Update & expose - Artworks + + trackArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), + ], + + coverArtistContribs: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'coverArtDate', + thingProperty: input.value('coverArtistContribs'), + artistProperty: input.value('trackCoverArtistContributions'), + }), + + exposeDependencyOrContinue('#resolvedContribs', V('empty')), + + withPropertyFromObject('album', V('trackCoverArtistContribs')), + + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: 'coverArtDate', + }), + + exposeDependency('#album.trackCoverArtistContribs'), + ], + + coverArtDate: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withPropertyFromObject('album', V('trackArtDate')), + exposeDependencyOrContinue('#album.trackArtDate'), + + exposeDependency('date'), + ], + + coverArtFileExtension: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromObject('album', V('trackCoverArtFileExtension')), + exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + + exposeConstant(V('jpg')), + ], + + coverArtDimensions: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue(), + + withPropertyFromObject('album', V('trackDimensions')), + exposeDependencyOrContinue('#album.trackDimensions'), + + dimensions(), + ], + + artTags: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), + ], + + referencedArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referencedArtworkList(), + ], + + // > Update & expose - Referenced tracks + + referencedTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackReference'), + }), + ], + + sampledTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackReference'), + }), + ], + + // > Update & expose - Music videos + + musicVideos: [ + exposeUpdateValueOrContinue(), + + // TODO: Same situation as lyrics. Inherited music videos don't set + // the proper .thing property back to this track... but then, it needs + // to keep a reference to its original .thing to get its proper path, + // so maybe this is okay... + inheritFromMainRelease(), + + thingList(V(MusicVideo)), + ], + + // > Update & expose - Additional files + + additionalFiles: thingList(V(AdditionalFile)), + sheetMusicFiles: thingList(V(AdditionalFile)), + midiProjectFiles: thingList(V(AdditionalFile)), + + // > Update & expose - Content entries + + lyrics: [ + exposeUpdateValueOrContinue(), + + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... + inheritFromMainRelease(), + + thingList(V(LyricsEntry)), + ], + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + referencingSources: thingList(V(ReferencingSourcesEntry)), + + // > Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworkList (mixedFind) + artworkData: wikiData(V(Artwork)), + + // used for withMatchingContributionPresets (indirectly by Contribution) + wikiInfo: thing(V(WikiInfo)), + + // > Expose only + + isTrack: exposeConstant(V(true)), + + commentatorArtists: commentatorArtists(), + + directorySuffix: [ + exitWithoutDependency('suffixDirectoryFromAlbum', { + value: input.value(null), + mode: input.value('falsy'), + }), + + withPropertyFromObject('trackSection', V('directorySuffix')), + exposeDependency('#trackSection.directorySuffix'), + ], + + date: [ + { + dependencies: ['disableDate'], + compute: (continuation, {disableDate}) => + (disableDate + ? null + : continuation()), + }, + + exposeDependencyOrContinue('dateFirstReleased'), + + withPropertyFromObject('album', V('date')), + exposeDependency('#album.date'), + ], + + trackNumber: [ + // Zero is the fallback, not one, but in most albums the first track + // (and its intended output by this composition) will be one. + + exitWithoutDependency('trackSection', V(0)), + withPropertiesFromObject('trackSection', V(['tracks', 'startCountingFrom'])), + + withIndexInList('#trackSection.tracks', input.myself()), + exitWithoutDependency('#index', V(0), V('index')), + + { + dependencies: ['#trackSection.startCountingFrom', '#index'], + compute: ({ + ['#trackSection.startCountingFrom']: startCountingFrom, + ['#index']: index, + }) => startCountingFrom + index, + }, + ], + + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) + // + // hasUniqueCoverArt is based only around the presence of *specified* + // cover artist contributions, not whether the references to artists on those + // contributions actually resolve to anything. It completely evades interacting + // with find/replace. + hasUniqueCoverArt: [ + { + dependencies: ['disableUniqueCoverArt'], + compute: (continuation, {disableUniqueCoverArt}) => + (disableUniqueCoverArt + ? false + : continuation()), + }, + + withResultOfAvailabilityCheck({ + from: '_coverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? true + : continuation()), + }, + + withPropertyFromObject('album', { + property: input.value('trackCoverArtistContribs'), + internal: input.value(true), + }), + + withResultOfAvailabilityCheck({ + from: '#album.trackCoverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? true + : continuation()), + }, + + exitWithoutDependency('_trackArtworks', { + value: input.value(false), + mode: input.value('empty'), + }), + + withPropertyFromList('_trackArtworks', { + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems('#trackArtworks.artistContribs', V([])), + + withFlattenedList('#trackArtworks.artistContribs'), + + exposeWhetherDependencyAvailable({ + dependency: '#flattenedList', + mode: input.value('empty'), + }), + ], + + isMainRelease: + exposeWhetherDependencyAvailable({ + dependency: 'mainReleaseTrack', + negate: input.value(true), + }), + + isSecondaryRelease: + exposeWhetherDependencyAvailable({ + dependency: 'mainReleaseTrack', + }), + + mainReleaseTrack: [ + exitWithoutDependency('mainRelease'), + + withPropertyFromObject('mainRelease', V('isTrack')), + + { + dependencies: ['mainRelease', '#mainRelease.isTrack'], + compute: (continuation, { + ['mainRelease']: mainRelease, + ['#mainRelease.isTrack']: mainReleaseIsTrack, + }) => + (mainReleaseIsTrack + ? mainRelease + : continuation()), + }, + + { + dependencies: ['name', '_directory'], + compute: (continuation, { + ['name']: ownName, + ['_directory']: ownDirectory, + }) => continuation({ + ['#mapItsNameLikeName']: + itsName => compareKebabCase(itsName, ownName), + + ['#mapItsDirectoryLikeDirectory']: + (ownDirectory + ? itsDirectory => itsDirectory === ownDirectory + : () => false), + + ['#mapItsNameLikeDirectory']: + (ownDirectory + ? itsName => compareKebabCase(itsName, ownDirectory) + : () => false), + + ['#mapItsDirectoryLikeName']: + itsDirectory => compareKebabCase(itsDirectory, ownName), + }), + }, + + withPropertyFromObject('mainRelease', V('tracks')), + + withPropertyFromList('#mainRelease.tracks', { + property: input.value('mainRelease'), + internal: input.value(true), + }), + + withAvailabilityFilter({from: '#mainRelease.tracks.mainRelease'}), + + withMappedList({ + list: '#availabilityFilter', + map: input.value(item => !item), + }).outputs({ + '#mappedList': '#availabilityFilter', + }), + + withFilteredList('#mainRelease.tracks', '#availabilityFilter') + .outputs({'#filteredList': '#mainRelease.tracks'}), + + withPropertyFromList('#mainRelease.tracks', V('name')), + + withPropertyFromList('#mainRelease.tracks', { + property: input.value('directory'), + internal: input.value(true), + }), + + withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeName') + .outputs({'#mappedList': '#filterItsNameLikeName'}), + + withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeDirectory') + .outputs({'#mappedList': '#filterItsDirectoryLikeDirectory'}), + + withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeDirectory') + .outputs({'#mappedList': '#filterItsNameLikeDirectory'}), + + withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeName') + .outputs({'#mappedList': '#filterItsDirectoryLikeName'}), + + withFilteredList('#mainRelease.tracks', '#filterItsNameLikeName') + .outputs({'#filteredList': '#matchingItsNameLikeName'}), + + withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeDirectory') + .outputs({'#filteredList': '#matchingItsDirectoryLikeDirectory'}), + + withFilteredList('#mainRelease.tracks', '#filterItsNameLikeDirectory') + .outputs({'#filteredList': '#matchingItsNameLikeDirectory'}), + + withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeName') + .outputs({'#filteredList': '#matchingItsDirectoryLikeName'}), + + { + dependencies: [ + '#matchingItsNameLikeName', + '#matchingItsDirectoryLikeDirectory', + '#matchingItsNameLikeDirectory', + '#matchingItsDirectoryLikeName', + ], + + compute: (continuation, { + ['#matchingItsNameLikeName']: NLN, + ['#matchingItsDirectoryLikeDirectory']: DLD, + ['#matchingItsNameLikeDirectory']: NLD, + ['#matchingItsDirectoryLikeName']: DLN, + }) => continuation({ + ['#mainReleaseTrack']: + onlyItem(DLD) ?? + onlyItem(NLN) ?? + onlyItem(DLN) ?? + onlyItem(NLD) ?? + null, + }), + }, + + { + dependencies: ['#mainReleaseTrack', input.myself()], + compute: ({ + ['#mainReleaseTrack']: mainReleaseTrack, + [input.myself()]: thisTrack, + }) => + (mainReleaseTrack === thisTrack + ? null + : mainReleaseTrack), + }, + ], + + // Only has any value for main releases, because secondary releases + // are never secondary to *another* secondary release. + secondaryReleases: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), + }), + + allReleases: [ + { + dependencies: [ + 'mainReleaseTrack', + 'secondaryReleases', + input.myself(), + ], + + compute: (continuation, { + mainReleaseTrack, + secondaryReleases, + [input.myself()]: thisTrack, + }) => + (mainReleaseTrack + ? continuation({ + ['#mainReleaseTrack']: mainReleaseTrack, + ['#secondaryReleaseTracks']: mainReleaseTrack.secondaryReleases, + }) + : continuation({ + ['#mainReleaseTrack']: thisTrack, + ['#secondaryReleaseTracks']: secondaryReleases, + })), + }, + + { + dependencies: [ + '#mainReleaseTrack', + '#secondaryReleaseTracks', + ], + + compute: ({ + ['#mainReleaseTrack']: mainReleaseTrack, + ['#secondaryReleaseTracks']: secondaryReleaseTracks, + }) => + sortByDate([mainReleaseTrack, ...secondaryReleaseTracks]), + }, + ], + + otherReleases: [ + { + dependencies: [input.myself(), 'allReleases'], + compute: ({ + [input.myself()]: thisTrack, + ['allReleases']: allReleases, + }) => + allReleases.filter(track => track !== thisTrack), + }, + ], + + commentaryFromMainRelease: [ + exitWithoutDependency('mainReleaseTrack', V([])), + + withPropertyFromObject('mainReleaseTrack', V('commentary')), + exposeDependency('#mainReleaseTrack.commentary'), + ], + + groups: [ + withPropertyFromObject('album', V('groups')), + exposeDependency('#album.groups'), + ], + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), + }), + + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), + }), + + featuredInFlashes: reverseReferenceList({ + reverse: soupyReverse.input('flashesWhichFeature'), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + // Identifying metadata + + 'Track': {property: 'name'}, + 'Track Text': {property: 'nameText'}, + 'Directory': {property: 'directory'}, + 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Main Release': {property: 'mainRelease'}, + + 'Bandcamp Track ID': { + property: 'bandcampTrackIdentifier', + transform: String, + }, + + 'Bandcamp Artwork ID': { + property: 'bandcampArtworkIdentifier', + transform: String, + }, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + // Credits and contributors + + 'Artist Text': {property: 'artistText'}, + 'Artist Text In Lists': {property: 'artistTextInLists'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Has Date': { + property: 'disableDate', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + // General metadata + + 'Duration': { + property: 'duration', + transform: parseDuration, + }, + + 'Color': {property: 'color'}, + + 'Needs Lyrics': { + property: 'needsLyrics', + }, + + 'URLs': {property: 'urls'}, + + // Artworks + + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + + 'Art Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, + }, + + // Referenced tracks + + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + // Music videos + + 'Music Videos': { + property: 'musicVideos', + transform: parseMusicVideos, + }, + + // Additional files + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Sheet Music Files': { + property: 'sheetMusicFiles', + transform: parseAdditionalFiles, + }, + + 'MIDI Project Files': { + property: 'midiProjectFiles', + transform: parseAdditionalFiles, + }, + + // Content entries + + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, + }, + + // Shenanigans + + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, + 'Review Points': {ignore: true}, + }, + + invalidFieldCombinations: [ + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', + ]}, + + {message: `Secondary releases inherit references from the main one`, fields: [ + 'Main Release', + 'Referenced Tracks', + ]}, + + {message: `Secondary releases inherit samples from the main one`, fields: [ + 'Main Release', + 'Sampled Tracks', + ]}, + + { + 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]), + }, + + trackMainReleasesOnly: { + referenceTypes: ['track'], + bindTo: 'trackData', + + include: track => + !CacheableObject.getUpdateValue(track, 'mainRelease'), + + // 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]), + }, + + trackReference: { + referenceTypes: ['track'], + bindTo: 'trackData', + + byob(fullRef, data, opts) { + const {from} = opts; + + const acontextual = () => + find.trackMainReleasesOnly(fullRef, data, opts); + + const regexMatch = fullRef.match(keyRefRegex); + if (!regexMatch || regexMatch?.keyPart) { + // It's a reference by directory or it's malformed. + // Either way, we can't handle it here! + return acontextual(); + } + + if (!from?.isTrack) { + throw new Error( + `Expected to find starting from a track, got: ` + + inspect(from, {compact: true})); + } + + const referencingTrack = from; + const referencedName = fullRef; + + for (const track of referencingTrack.album.tracks) { + // Totally ignore alwaysReferenceByDirectory here. + // void track.alwaysReferenceByDirectory; + + if (track.name === referencedName) { + if (track.isSecondaryRelease) { + return track.mainReleaseTrack; + } else { + return track; + } + } + } + + return acontextual(); + }, + }, + + trackWithArtwork: { + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'trackData', + + include: track => + track.hasUniqueCoverArt, + + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + + trackPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork) => + artwork.isArtwork && + artwork.thing.isTrack && + artwork === artwork.thing.trackArtworks[0], + + getMatchableNames: ({thing: track}) => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + + getMatchableDirectories: ({thing: track}) => + [track.directory], + }, + }; + + static [Thing.reverseSpecs] = { + tracksWhichReference: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.referencedTracks, + }, + + tracksWhichSample: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.sampledTracks, + }, + + tracksWhoseArtworksFeature: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.artTags, + }, + + trackArtistContributionsBy: + soupyReverse.contributionsBy('trackData', 'artistContribs'), + + trackContributorContributionsBy: + soupyReverse.contributionsBy('trackData', 'contributorContribs'), + + trackCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'), + + tracksWithCommentaryBy: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.commentatorArtists, + }, + + tracksWhichAreSecondaryReleasesOf: { + bindTo: 'trackData', + + referencing: track => track.isSecondaryRelease ? [track] : [], + referenced: track => [track.mainReleaseTrack], + }, + }; + + // Track YAML loading is handled in album.js. + static [Thing.getYamlLoadingSpec] = null; + + getOwnAdditionalFilePath(_file, filename) { + if (!this.album) return null; + + return [ + 'media.albumAdditionalFile', + this.album.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (!this.album) return null; + + return [ + 'media.trackCover', + this.album.directory, + + (artwork.unqualifiedDirectory + ? this.directory + '-' + artwork.unqualifiedDirectory + : this.directory), + + artwork.fileExtension, + ]; + } + + getOwnMusicVideoCoverPath(musicVideo) { + if (!this.album) return null; + if (!musicVideo.unqualifiedDirectory) return null; + + return [ + 'media.trackCover', + this.album.directory, + this.directory + '-' + musicVideo.unqualifiedDirectory, + musicVideo.coverArtFileExtension, + ]; + } + + countOwnContributionInContributionTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + + countOwnContributionInDurationTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + + [inspect.custom](depth) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (CacheableObject.getUpdateValue(this, 'mainRelease')) { + parts.unshift(`${colors.yellow('[secrelease]')} `); + } + + let album; + + if (depth >= 0) { + album = this.album; + } + + 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/WikiInfo.js b/src/data/things/WikiInfo.js new file mode 100644 index 00000000..1d1f90e6 --- /dev/null +++ b/src/data/things/WikiInfo.js @@ -0,0 +1,169 @@ +export const WIKI_INFO_FILE = 'wiki-info.yaml'; + +import {input, V} from '#composite'; +import Thing from '#thing'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; + +import { + isBoolean, + isContributionPresetList, + isLanguageCode, + isName, + isNumber, +} from '#validators'; + +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; + +import { + canonicalBase, + color, + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; + +export class WikiInfo extends Thing { + static [Thing.friendlyName] = `Wiki Info`; + static [Thing.wikiData] = 'wikiInfo'; + static [Thing.oneInstancePerWiki] = true; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ + // Update & expose + + name: name(V('Unnamed Wiki')), + + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, + }, + }, + + color: color(V('#0088ff')), + + // One-line description used for tag. + description: contentString(), + + footerContent: contentString(), + + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + canonicalBase: canonicalBase(), + canonicalMediaBase: canonicalBase(), + + wikiWallpaperBrightness: { + flags: {update: true, expose: true}, + update: {validate: isNumber}, + }, + + wikiWallpaperFileExtension: fileExtension(V('jpg')), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), + + divideTrackListsByGroups: referenceList({ + class: input.value(Group), + find: soupyFind.input('group'), + }), + + contributionPresets: { + flags: {update: true, expose: true}, + update: {validate: isContributionPresetList}, + }, + + // Feature toggles + enableFlashesAndGames: flag(V(false)), + enableListings: flag(V(false)), + enableNews: flag(V(false)), + enableArtTagUI: flag(V(false)), + enableGroupUI: flag(V(false)), + + enableSearch: [ + exitWithoutDependency('_searchDataAvailable', { + value: input.value(false), + mode: input.value('falsy'), + }), + + flag(V(true)), + ], + + // Update only + + find: soupyFind(), + + searchDataAvailable: { + flags: {update: true}, + update: { + validate: isBoolean, + default: false, + }, + }, + + // Expose only + + isWikiInfo: exposeConstant(V(true)), + }); + + 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'}, + 'Canonical Media Base': {property: 'canonicalMediaBase'}, + + 'Wiki Wallpaper Brightness': {property: 'wikiWallpaperBrightness'}, + 'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'}, + + 'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'}, + + 'Wiki Wallpaper Parts': { + property: 'wikiWallpaperParts', + transform: parseWallpaperParts, + }, + + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, + 'Enable Listings': {property: 'enableListings'}, + 'Enable News': {property: 'enableNews'}, + 'Enable Art Tag UI': {property: 'enableArtTagUI'}, + 'Enable Group UI': {property: 'enableGroupUI'}, + + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + + 'Contribution Presets': { + property: 'contributionPresets', + transform: parseContributionPresets, + }, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {oneDocumentTotal}, + thingConstructors: {WikiInfo}, + }) => ({ + title: `Process wiki info file`, + file: WIKI_INFO_FILE, + + documentMode: oneDocumentTotal, + documentThing: WikiInfo, + }); +} diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js deleted file mode 100644 index b15f62e0..00000000 --- a/src/data/things/additional-file.js +++ /dev/null @@ -1,54 +0,0 @@ -import {input} from '#composite'; -import Thing from '#thing'; -import {isString, validateArrayItems} from '#validators'; - -import {exposeConstant, exposeUpdateValueOrContinue} - from '#composite/control-flow'; -import {contentString, simpleString, thing} from '#composite/wiki-properties'; - -export class AdditionalFile extends Thing { - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - thing: thing(), - - title: simpleString(), - - description: contentString(), - - filenames: [ - exposeUpdateValueOrContinue({ - validate: input.value(validateArrayItems(isString)), - }), - - exposeConstant({ - value: input.value([]), - }), - ], - - // Expose only - - isAdditionalFile: [ - exposeConstant({ - value: input.value(true), - }), - ], - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Title': {property: 'title'}, - 'Description': {property: 'description'}, - 'Files': {property: 'filenames'}, - }, - }; - - get paths() { - if (!this.thing) return null; - if (!this.thing.getOwnAdditionalFilePath) return null; - - return ( - this.filenames.map(filename => - this.thing.getOwnAdditionalFilePath(this, filename))); - } -} diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js deleted file mode 100644 index 99f3ee46..00000000 --- a/src/data/things/additional-name.js +++ /dev/null @@ -1,31 +0,0 @@ -import {input} from '#composite'; -import Thing from '#thing'; - -import {exposeConstant} from '#composite/control-flow'; -import {contentString, thing} from '#composite/wiki-properties'; - -export class AdditionalName extends Thing { - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - thing: thing(), - - name: contentString(), - annotation: contentString(), - - // Expose only - - isAdditionalName: [ - exposeConstant({ - value: input.value(true), - }), - ], - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Name': {property: 'name'}, - 'Annotation': {property: 'annotation'}, - }, - }; -} diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js deleted file mode 100644 index f4fedf49..00000000 --- a/src/data/things/art-tag.js +++ /dev/null @@ -1,230 +0,0 @@ -export const DATA_ART_TAGS_DIRECTORY = 'art-tags'; -export const ART_TAG_DATA_FILE = 'tags.yaml'; - -import {readFile} from 'node:fs/promises'; -import * as path from 'node:path'; - -import {input, V} from '#composite'; -import {traverse} from '#node-utils'; -import {sortAlphabetically} from '#sort'; -import Thing from '#thing'; -import {unique} from '#sugar'; -import {isName} from '#validators'; -import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; - -import { - exitWithoutDependency, - exposeConstant, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import { - annotatedReferenceList, - color, - contentString, - directory, - flag, - referenceList, - reverseReferenceList, - name, - soupyFind, - soupyReverse, - thingList, - urls, -} from '#composite/wiki-properties'; - -export class ArtTag extends Thing { - static [Thing.referenceType] = 'tag'; - static [Thing.friendlyName] = `Art Tag`; - static [Thing.wikiData] = 'artTagData'; - - static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ - // Update & expose - - name: name(V('Unnamed Art Tag')), - directory: directory(), - color: color(), - isContentWarning: flag(V(false)), - extraReadingURLs: urls(), - - nameShort: [ - exposeUpdateValueOrContinue({ - validate: input.value(isName), - }), - - { - dependencies: ['name'], - compute: ({name}) => - name.replace(/ \([^)]*?\)$/, ''), - }, - ], - - additionalNames: thingList(V(AdditionalName)), - - description: contentString(), - - directDescendantArtTags: referenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), - }), - - relatedArtTags: annotatedReferenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), - - reference: input.value('artTag'), - thing: input.value('artTag'), - }), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // Expose only - - isArtTag: exposeConstant(V(true)), - - descriptionShort: [ - exitWithoutDependency('description', { - value: input.value(null), - mode: input.value('falsy'), - }), - - { - dependencies: ['description'], - compute: ({description}) => - description.split('
')[0], - }, - ], - - directlyFeaturedInArtworks: reverseReferenceList({ - reverse: soupyReverse.input('artworksWhichFeature'), - }), - - indirectlyFeaturedInArtworks: [ - { - dependencies: ['allDescendantArtTags'], - compute: ({allDescendantArtTags}) => - unique( - allDescendantArtTags - .flatMap(artTag => artTag.directlyFeaturedInArtworks)), - }, - ], - - // All the art tags which descend from this one - that means its own direct - // descendants, plus all the direct and indirect descendants of each of those! - // The results aren't specially sorted, but they won't contain any duplicates - // (for example if two descendant tags both route deeper to end up including - // some of the same tags). - allDescendantArtTags: [ - { - dependencies: ['directDescendantArtTags'], - compute: ({directDescendantArtTags}) => - unique([ - ...directDescendantArtTags, - ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags), - ]), - }, - ], - - directAncestorArtTags: reverseReferenceList({ - reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), - }), - - // All the art tags which are ancestors of this one as a "baobab tree" - - // what you'd typically think of as roots are all up in the air! Since this - // really is backwards from the way that the art tag tree is written in data, - // chances are pretty good that there will be many of the exact same "leaf" - // nodes - art tags which don't themselves have any ancestors. In the actual - // data structure, each node is a Map, with keys for each ancestor and values - // for each ancestor's own baobab (thus a branching structure, just like normal - // trees in this regard). - ancestorArtTagBaobabTree: [ - { - dependencies: ['directAncestorArtTags'], - compute: ({directAncestorArtTags}) => - new Map( - directAncestorArtTags - .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])), - }, - ], - }); - - static [Thing.findSpecs] = { - artTag: { - referenceTypes: ['tag'], - bindTo: 'artTagData', - - getMatchableNames: artTag => - (artTag.isContentWarning - ? [`cw: ${artTag.name}`] - : [artTag.name]), - }, - }; - - static [Thing.reverseSpecs] = { - artTagsWhichDirectlyAncestor: { - bindTo: 'artTagData', - - referencing: artTag => [artTag], - referenced: artTag => artTag.directDescendantArtTags, - }, - }; - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Tag': {property: 'name'}, - 'Short Name': {property: 'nameShort'}, - 'Directory': {property: 'directory'}, - 'Description': {property: 'description'}, - 'Extra Reading URLs': {property: 'extraReadingURLs'}, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, - - 'Color': {property: 'color'}, - 'Is CW': {property: 'isContentWarning'}, - - 'Direct Descendant Tags': {property: 'directDescendantArtTags'}, - - 'Related Tags': { - property: 'relatedArtTags', - transform: entries => - parseAnnotatedReferences(entries, { - referenceField: 'Tag', - referenceProperty: 'artTag', - }), - }, - }, - }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allTogether}, - thingConstructors: {ArtTag}, - }) => ({ - title: `Process art tags file`, - - files: dataPath => - Promise.allSettled([ - readFile(path.join(dataPath, ART_TAG_DATA_FILE)) - .then(() => [ART_TAG_DATA_FILE]), - - traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), { - filterFile: name => path.extname(name) === '.yaml', - prefixPath: DATA_ART_TAGS_DIRECTORY, - }), - ]).then(results => results - .filter(({status}) => status === 'fulfilled') - .flatMap(({value}) => value)), - - documentMode: allTogether, - documentThing: ArtTag, - - sort({artTagData}) { - sortAlphabetically(artTagData); - }, - }); -} diff --git a/src/data/things/artist.js b/src/data/things/artist.js deleted file mode 100644 index 439386f8..00000000 --- a/src/data/things/artist.js +++ /dev/null @@ -1,371 +0,0 @@ -export const ARTIST_DATA_FILE = 'artists.yaml'; - -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input, V} from '#composite'; -import Thing from '#thing'; -import {parseArtistAliases, parseArtwork} from '#yaml'; - -import { - sortAlbumsTracksChronologically, - sortArtworksChronologically, - sortAlphabetically, - sortContributionsChronologically, -} from '#sort'; - -import {exitWithoutDependency, exposeConstant, exposeDependency} - from '#composite/control-flow'; -import {withFilteredList, withPropertyFromList} from '#composite/data'; -import {withContributionListSums} from '#composite/wiki-data'; - -import { - constitutibleArtwork, - contentString, - directory, - fileExtension, - flag, - name, - reverseReferenceList, - soupyFind, - soupyReverse, - thing, - thingList, - urls, -} from '#composite/wiki-properties'; - -export class Artist extends Thing { - static [Thing.referenceType] = 'artist'; - static [Thing.wikiData] = 'artistData'; - - static [Thing.constitutibleProperties] = [ - 'avatarArtwork', // from inline fields - ]; - - static [Thing.getPropertyDescriptors] = ({Contribution}) => ({ - // Update & expose - - name: name(V('Unnamed Artist')), - directory: directory(), - urls: urls(), - - contextNotes: contentString(), - - hasAvatar: flag(V(false)), - avatarFileExtension: fileExtension(V('jpg')), - - avatarArtwork: [ - exitWithoutDependency('hasAvatar', { - value: input.value(null), - mode: input.value('falsy'), - }), - - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Avatar Artwork'), - ], - - isAlias: flag(V(false)), - artistAliases: thingList(V(Artist)), - aliasedArtist: thing(V(Artist)), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // Expose only - - isArtist: exposeConstant(V(true)), - - mockSimpleContribution: { - flags: {expose: true}, - expose: { - dependencies: ['directory', '_find'], - compute: ({directory, _find: find}) => - Object.assign(new Contribution, { - artist: 'artist:' + directory, - - // These nulls have no effect, they're only included - // here for clarity. - date: null, - thing: null, - annotation: null, - artistProperty: null, - thingProperty: null, - - find, - }), - }, - }, - - trackArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('trackArtistContributionsBy'), - }), - - trackContributorContributions: reverseReferenceList({ - reverse: soupyReverse.input('trackContributorContributionsBy'), - }), - - trackCoverArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('trackCoverArtistContributionsBy'), - }), - - tracksAsCommentator: reverseReferenceList({ - reverse: soupyReverse.input('tracksWithCommentaryBy'), - }), - - albumArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('albumArtistContributionsBy'), - }), - - albumTrackArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('albumTrackArtistContributionsBy'), - }), - - albumCoverArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('albumCoverArtistContributionsBy'), - }), - - albumWallpaperArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), - }), - - albumBannerArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('albumBannerArtistContributionsBy'), - }), - - albumsAsCommentator: reverseReferenceList({ - reverse: soupyReverse.input('albumsWithCommentaryBy'), - }), - - flashContributorContributions: reverseReferenceList({ - reverse: soupyReverse.input('flashContributorContributionsBy'), - }), - - flashesAsCommentator: reverseReferenceList({ - reverse: soupyReverse.input('flashesWithCommentaryBy'), - }), - - closelyLinkedGroups: reverseReferenceList({ - reverse: soupyReverse.input('groupsCloselyLinkedTo'), - }), - - musicContributions: [ - { - dependencies: [ - 'trackArtistContributions', - 'trackContributorContributions', - ], - - compute: (continuation, { - trackArtistContributions, - trackContributorContributions, - }) => continuation({ - ['#contributions']: [ - ...trackArtistContributions, - ...trackContributorContributions, - ], - }), - }, - - { - dependencies: ['#contributions'], - compute: ({'#contributions': contributions}) => - sortContributionsChronologically( - contributions, - sortAlbumsTracksChronologically), - }, - ], - - artworkContributions: [ - { - dependencies: [ - 'trackCoverArtistContributions', - 'albumCoverArtistContributions', - 'albumWallpaperArtistContributions', - 'albumBannerArtistContributions', - ], - - compute: (continuation, { - trackCoverArtistContributions, - albumCoverArtistContributions, - albumWallpaperArtistContributions, - albumBannerArtistContributions, - }) => continuation({ - ['#contributions']: [ - ...trackCoverArtistContributions, - ...albumCoverArtistContributions, - ...albumWallpaperArtistContributions, - ...albumBannerArtistContributions, - ], - }), - }, - - { - dependencies: ['#contributions'], - compute: ({'#contributions': contributions}) => - sortContributionsChronologically( - contributions, - sortArtworksChronologically), - }, - ], - - musicVideoArtistContributions: reverseReferenceList({ - reverse: soupyReverse.input('musicVideoArtistContributionsBy'), - }), - - musicVideoContributorContributions: reverseReferenceList({ - reverse: soupyReverse.input('musicVideoContributorContributionsBy'), - }), - - totalDuration: [ - withPropertyFromList('musicContributions', V('thing')), - withPropertyFromList('#musicContributions.thing', V('isMainRelease')), - - withFilteredList('musicContributions', '#musicContributions.thing.isMainRelease') - .outputs({'#filteredList': '#mainReleaseContributions'}), - - withContributionListSums('#mainReleaseContributions'), - exposeDependency('#contributionListDuration'), - ], - }); - - static [Thing.getSerializeDescriptors] = ({ - serialize: S, - }) => ({ - name: S.id, - directory: S.id, - urls: S.id, - contextNotes: S.id, - - hasAvatar: S.id, - avatarFileExtension: S.id, - - tracksAsCommentator: S.toRefs, - albumsAsCommentator: 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 alias of originalArtist.artistAliases) { - if (alias === artist) break; - if (alias.directory === artist.directory) 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'}, - - // note: doesn't really work as an independent field yet - 'Avatar Artwork': { - property: 'avatarArtwork', - transform: - parseArtwork({ - single: true, - thingProperty: 'avatarArtwork', - fileExtensionFromThingProperty: 'avatarFileExtension', - }), - }, - - 'Has Avatar': {property: 'hasAvatar'}, - 'Avatar File Extension': {property: 'avatarFileExtension'}, - - 'Aliases': { - property: 'artistAliases', - transform: parseArtistAliases, - }, - - '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, - - 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 { - aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); - } - - parts.push(` ${colors.yellow(`[of ${aliasedArtist}]`)}`); - } - - return parts.join(''); - } - - getOwnArtworkPath(artwork) { - return [ - 'media.artistAvatar', - this.directory, - artwork.fileExtension, - ]; - } -} diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js deleted file mode 100644 index c1aafa8f..00000000 --- a/src/data/things/artwork.js +++ /dev/null @@ -1,422 +0,0 @@ -import {inspect} from 'node:util'; - -import {colors} from '#cli'; -import {input, V} from '#composite'; -import find from '#find'; -import Thing from '#thing'; - -import { - isContentString, - isContributionList, - isDate, - isDimensions, - isFileExtension, - optional, - validateArrayItems, - validateProperties, - validateReference, - validateReferenceList, -} from '#validators'; - -import { - parseAnnotatedReferences, - parseContributors, - parseDate, - parseDimensions, -} from '#yaml'; - -import { - exitWithoutDependency, - exposeConstant, - exposeDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, - flipFilter, -} from '#composite/control-flow'; - -import { - withFilteredList, - withNearbyItemFromList, - withPropertyFromList, - withPropertyFromObject, -} from '#composite/data'; - -import { - constituteFrom, - constituteOrContinue, - withRecontextualizedContributionList, - withResolvedAnnotatedReferenceList, - withResolvedContribs, - withResolvedReferenceList, -} from '#composite/wiki-data'; - -import { - contentString, - directory, - flag, - reverseReferenceList, - simpleString, - soupyFind, - soupyReverse, - thing, - wikiData, -} from '#composite/wiki-properties'; - -import {withContainingArtworkList} from '#composite/things/artwork'; - -export class Artwork extends Thing { - static [Thing.referenceType] = 'artwork'; - static [Thing.wikiData] = 'artworkData'; - - static [Thing.constitutibleProperties] = [ - // Contributions currently aren't being observed for constitution. - // 'artistContribs', // from attached artwork or thing - ]; - - static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ - // Update & expose - - unqualifiedDirectory: directory({ - name: input.value(null), - }), - - thing: thing(), - thingProperty: simpleString(), - - label: simpleString(), - source: contentString(), - originDetails: contentString(), - showFilename: simpleString(), - - dateFromThingProperty: simpleString(), - - date: [ - exposeUpdateValueOrContinue({ - validate: input.value(isDate), - }), - - constituteFrom('thing', 'dateFromThingProperty'), - ], - - fileExtensionFromThingProperty: simpleString(), - - fileExtension: [ - exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), - }), - - constituteFrom('thing', 'fileExtensionFromThingProperty', { - else: input.value('jpg'), - }), - ], - - dimensionsFromThingProperty: simpleString(), - - dimensions: [ - exposeUpdateValueOrContinue({ - validate: input.value(isDimensions), - }), - - constituteFrom('thing', 'dimensionsFromThingProperty'), - ], - - attachAbove: flag(V(false)), - - artistContribsFromThingProperty: simpleString(), - artistContribsArtistProperty: simpleString(), - - artistContribs: [ - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - date: 'date', - thingProperty: input.thisProperty(), - artistProperty: 'artistContribsArtistProperty', - }), - - exposeDependencyOrContinue('#resolvedContribs', V('empty')), - - withPropertyFromObject('attachedArtwork', V('artistContribs')), - - withRecontextualizedContributionList('#attachedArtwork.artistContribs'), - exposeDependencyOrContinue('#attachedArtwork.artistContribs'), - - exitWithoutDependency('artistContribsFromThingProperty', V([])), - - withPropertyFromObject('thing', 'artistContribsFromThingProperty') - .outputs({'#value': '#artistContribsFromThing'}), - - withRecontextualizedContributionList('#artistContribsFromThing'), - exposeDependency('#artistContribsFromThing'), - ], - - style: simpleString(), - - artTagsFromThingProperty: simpleString(), - - artTags: [ - withResolvedReferenceList({ - list: input.updateValue({ - validate: - validateReferenceList(ArtTag[Thing.referenceType]), - }), - find: soupyFind.input('artTag'), - }), - - exposeDependencyOrContinue('#resolvedReferenceList', V('empty')), - - constituteOrContinue('attachedArtwork', V('artTags'), V('empty')), - - constituteFrom('thing', 'artTagsFromThingProperty', V([])), - ], - - referencedArtworksFromThingProperty: simpleString(), - - referencedArtworks: [ - { - compute: (continuation) => continuation({ - ['#find']: - find.mixed({ - track: find.trackPrimaryArtwork, - album: find.albumPrimaryArtwork, - }), - }), - }, - - withResolvedAnnotatedReferenceList({ - list: input.updateValue({ - validate: - // TODO: It's annoying to hardcode this when it's really the - // same behavior as through annotatedReferenceList and through - // referenceListUpdateDescription, the latter of which isn't - // available outside of #composite/wiki-data internals. - validateArrayItems( - validateProperties({ - reference: validateReference(['album', 'track']), - annotation: optional(isContentString), - })), - }), - - data: '_artworkData', - find: '#find', - - thing: input.value('artwork'), - }), - - exposeDependencyOrContinue('#resolvedAnnotatedReferenceList', V('empty')), - - constituteFrom('thing', 'referencedArtworksFromThingProperty', { - else: input.value([]), - }), - ], - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // used for referencedArtworks (mixedFind) - artworkData: wikiData(V(Artwork)), - - // Expose only - - isArtwork: exposeConstant(V(true)), - - referencedByArtworks: reverseReferenceList({ - reverse: soupyReverse.input('artworksWhichReference'), - }), - - isMainArtwork: [ - withContainingArtworkList(), - exitWithoutDependency('#containingArtworkList'), - - { - dependencies: [input.myself(), '#containingArtworkList'], - compute: ({ - [input.myself()]: myself, - ['#containingArtworkList']: list, - }) => - list[0] === myself, - }, - ], - - mainArtwork: [ - withContainingArtworkList(), - exitWithoutDependency('#containingArtworkList'), - - { - dependencies: ['#containingArtworkList'], - compute: ({'#containingArtworkList': list}) => - list[0], - }, - ], - - attachedArtwork: [ - exitWithoutDependency('attachAbove', { - value: input.value(null), - mode: input.value('falsy'), - }), - - withContainingArtworkList(), - - withPropertyFromList('#containingArtworkList', V('attachAbove')), - - flipFilter('#containingArtworkList.attachAbove') - .outputs({'#containingArtworkList.attachAbove': '#filterNotAttached'}), - - withNearbyItemFromList({ - list: '#containingArtworkList', - item: input.myself(), - offset: input.value(-1), - filter: '#filterNotAttached', - }), - - exposeDependency('#nearbyItem'), - ], - - attachingArtworks: reverseReferenceList({ - reverse: soupyReverse.input('artworksWhichAttach'), - }), - - groups: [ - withPropertyFromObject('thing', V('groups')), - exposeDependencyOrContinue('#thing.groups'), - - exposeConstant(V([])), - ], - - contentWarningArtTags: [ - withPropertyFromList('artTags', V('isContentWarning')), - withFilteredList('artTags', '#artTags.isContentWarning'), - exposeDependency('#filteredList'), - ], - - contentWarnings: [ - withPropertyFromList('contentWarningArtTags', V('name')), - exposeDependency('#contentWarningArtTags.name'), - ], - - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Directory': {property: 'unqualifiedDirectory'}, - 'File Extension': {property: 'fileExtension'}, - - 'Dimensions': { - property: 'dimensions', - transform: parseDimensions, - }, - - 'Label': {property: 'label'}, - 'Source': {property: 'source'}, - 'Origin Details': {property: 'originDetails'}, - 'Show Filename': {property: 'showFilename'}, - - 'Date': { - property: 'date', - transform: parseDate, - }, - - 'Attach Above': {property: 'attachAbove'}, - - 'Artists': { - property: 'artistContribs', - transform: parseContributors, - }, - - 'Style': {property: 'style'}, - - 'Tags': {property: 'artTags'}, - - 'Referenced Artworks': { - property: 'referencedArtworks', - transform: parseAnnotatedReferences, - }, - }, - }; - - static [Thing.reverseSpecs] = { - artworksWhichReference: { - bindTo: 'artworkData', - - referencing: referencingArtwork => - referencingArtwork.referencedArtworks - .map(({artwork: referencedArtwork, ...referenceDetails}) => ({ - referencingArtwork, - referencedArtwork, - referenceDetails, - })), - - referenced: ({referencedArtwork}) => [referencedArtwork], - - tidy: ({referencingArtwork, referenceDetails}) => ({ - artwork: referencingArtwork, - ...referenceDetails, - }), - - date: ({artwork}) => artwork.date, - }, - - artworksWhichAttach: { - bindTo: 'artworkData', - - referencing: referencingArtwork => - (referencingArtwork.attachAbove - ? [referencingArtwork] - : []), - - referenced: referencingArtwork => - [referencingArtwork.attachedArtwork], - }, - - artworksWhichFeature: { - bindTo: 'artworkData', - - referencing: artwork => [artwork], - referenced: artwork => artwork.artTags, - }, - }; - - get path() { - if (!this.thing) return null; - if (!this.thing.getOwnArtworkPath) return null; - - return this.thing.getOwnArtworkPath(this); - } - - countOwnContributionInContributionTotals(contrib) { - if (this.attachAbove) { - return false; - } - - if (contrib.annotation?.startsWith('edits for wiki')) { - return false; - } - - return true; - } - - [inspect.custom](depth, options, inspect) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (this.thing) { - if (depth >= 0) { - const newOptions = { - ...options, - depth: - (options.depth === null - ? null - : options.depth - 1), - }; - - parts.push(` for ${inspect(this.thing, newOptions)}`); - } else { - parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); - } - } - - return parts.join(''); - } -} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js deleted file mode 100644 index 4048709b..00000000 --- a/src/data/things/contribution.js +++ /dev/null @@ -1,314 +0,0 @@ -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input, V} from '#composite'; -import {empty} from '#sugar'; -import Thing from '#thing'; -import {isBoolean, isStringNonEmpty, isThing} from '#validators'; - -import {simpleDate, singleReference, soupyFind} - from '#composite/wiki-properties'; - -import { - exitWithoutDependency, - exposeConstant, - exposeDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import { - withFilteredList, - withNearbyItemFromList, - withPropertyFromList, - withPropertyFromObject, -} from '#composite/data'; - -import { - inheritFromContributionPresets, - withContainingReverseContributionList, - withContributionContext, -} from '#composite/things/contribution'; - -export class Contribution extends Thing { - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - thing: { - flags: {update: true, expose: true}, - update: {validate: isThing}, - }, - - thingProperty: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - artistProperty: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - date: simpleDate(), - - artist: singleReference({ - find: soupyFind.input('artist'), - }), - - annotation: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - countInContributionTotals: [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - inheritFromContributionPresets(), - - { - dependencies: ['thing', input.myself()], - compute: (continuation, { - ['thing']: thing, - [input.myself()]: contribution, - }) => - (thing.countOwnContributionInContributionTotals?.(contribution) - ? true - : thing.countOwnContributionInContributionTotals - ? false - : continuation()), - }, - - exposeConstant(V(true)), - ], - - countInDurationTotals: [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - inheritFromContributionPresets(), - - withPropertyFromObject('thing', V('duration')), - exitWithoutDependency('#thing.duration', { - value: input.value(false), - mode: input.value('falsy'), - }), - - { - dependencies: ['thing', input.myself()], - compute: (continuation, { - ['thing']: thing, - [input.myself()]: contribution, - }) => - (thing.countOwnContributionInDurationTotals?.(contribution) - ? true - : thing.countOwnContributionInDurationTotals - ? false - : continuation()), - }, - - exposeConstant(V(true)), - ], - - // Update only - - find: soupyFind(), - - // Expose only - - isContribution: exposeConstant(V(true)), - - annotationParts: { - flags: {expose: true}, - expose: { - dependencies: ['annotation'], - compute: ({annotation}) => - (annotation - ? annotation.split(',').map(part => part.trim()) - : []), - }, - }, - - context: [ - withContributionContext(), - - { - dependencies: [ - '#contributionTarget', - '#contributionProperty', - ], - - compute: ({ - ['#contributionTarget']: target, - ['#contributionProperty']: property, - }) => ({ - target, - property, - }), - }, - ], - - matchingPresets: [ - withPropertyFromObject('thing', { - property: input.value('wikiInfo'), - internal: input.value(true), - }), - - exitWithoutDependency('#thing.wikiInfo', V([])), - - withPropertyFromObject('#thing.wikiInfo', V('contributionPresets')) - .outputs({'#thing.wikiInfo.contributionPresets': '#contributionPresets'}), - - exitWithoutDependency('#contributionPresets', V([]), V('empty')), - - withContributionContext(), - - // TODO: implementing this with compositional filters would be fun - { - dependencies: [ - '#contributionPresets', - '#contributionTarget', - '#contributionProperty', - 'annotation', - ], - - compute: ({ - ['#contributionPresets']: presets, - ['#contributionTarget']: target, - ['#contributionProperty']: property, - ['annotation']: annotation, - }) => - presets.filter(preset => - preset.context[0] === target && - preset.context.slice(1).includes(property) && - // For now, only match if the annotation is a complete match. - // Partial matches (e.g. because the contribution includes "two" - // annotations, separated by commas) don't count. - preset.annotation === annotation), - }, - ], - - // All the contributions from the list which includes this contribution. - // Note that this list contains not only other contributions by the same - // artist, but also this very contribution. It doesn't mix contributions - // exposed on different properties. - associatedContributions: [ - exitWithoutDependency('thing', V([])), - exitWithoutDependency('thingProperty', V([])), - - withPropertyFromObject('thing', 'thingProperty') - .outputs({'#value': '#contributions'}), - - withPropertyFromList('#contributions', V('annotation')), - - { - dependencies: ['#contributions.annotation', 'annotation'], - compute: (continuation, { - ['#contributions.annotation']: contributionAnnotations, - ['annotation']: annotation, - }) => continuation({ - ['#likeContributionsFilter']: - contributionAnnotations.map(mappingAnnotation => - (annotation?.startsWith(`edits for wiki`) - ? mappingAnnotation?.startsWith(`edits for wiki`) - : !mappingAnnotation?.startsWith(`edits for wiki`))), - }), - }, - - withFilteredList('#contributions', '#likeContributionsFilter') - .outputs({'#filteredList': '#contributions'}), - - exposeDependency('#contributions'), - ], - - previousBySameArtist: [ - withContainingReverseContributionList() - .outputs({'#containingReverseContributionList': '#list'}), - - exitWithoutDependency('#list'), - - withNearbyItemFromList('#list', input.myself(), V(-1)), - exposeDependency('#nearbyItem'), - ], - - nextBySameArtist: [ - withContainingReverseContributionList() - .outputs({'#containingReverseContributionList': '#list'}), - - exitWithoutDependency('#list'), - - withNearbyItemFromList('#list', input.myself(), V(+1)), - exposeDependency('#nearbyItem'), - ], - - groups: [ - withPropertyFromObject('thing', V('groups')), - exposeDependencyOrContinue('#thing.groups'), - - exposeConstant(V([])), - ], - }); - - [inspect.custom](depth, options, inspect) { - const parts = []; - const accentParts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (this.annotation) { - accentParts.push(colors.green(`"${this.annotation}"`)); - } - - if (this.date) { - accentParts.push(colors.yellow(this.date.toLocaleDateString())); - } - - let artistRef; - if (depth >= 0) { - let artist; - try { - artist = this.artist; - } catch { - // Computing artist might crash for any reason - don't distract from - // other errors as a result of inspecting this contribution. - } - - if (artist) { - artistRef = - colors.blue(Thing.getReference(artist)); - } - } else { - artistRef = - colors.green(CacheableObject.getUpdateValue(this, 'artist')); - } - - if (artistRef) { - accentParts.push(`by ${artistRef}`); - } - - if (this.thing) { - if (depth >= 0) { - const newOptions = { - ...options, - depth: - (options.depth === null - ? null - : options.depth - 1), - }; - - accentParts.push(`to ${inspect(this.thing, newOptions)}`); - } else { - accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`); - } - } - - if (!empty(accentParts)) { - parts.push(` (${accentParts.join(', ')})`); - } - - return parts.join(''); - } -} diff --git a/src/data/things/index.js b/src/data/things/index.js index bf3df9a7..766ceb44 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -1,20 +1,21 @@ // Not actually the entry point for #things - that's init.js in this folder. -export * from './additional-file.js'; -export * from './additional-name.js'; +export * from './AdditionalFile.js'; +export * from './AdditionalName.js'; +export * from './ArtTag.js'; +export * from './Artist.js'; +export * from './Artwork.js'; +export * from './Contribution.js'; +export * from './Language.js'; +export * from './MusicVideo.js'; +export * from './NewsEntry.js'; +export * from './StaticPage.js'; +export * from './Track.js'; +export * from './WikiInfo.js'; + export * from './album.js'; -export * from './art-tag.js'; -export * from './artist.js'; -export * from './artwork.js'; export * from './content.js'; -export * from './contribution.js'; export * from './flash.js'; export * from './group.js'; export * from './homepage-layout.js'; -export * from './language.js'; -export * from './music-video.js'; -export * from './news-entry.js'; export * from './sorting-rule.js'; -export * from './static-page.js'; -export * from './track.js'; -export * from './wiki-info.js'; diff --git a/src/data/things/language.js b/src/data/things/language.js deleted file mode 100644 index 7f3f43de..00000000 --- a/src/data/things/language.js +++ /dev/null @@ -1,990 +0,0 @@ -import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; - -import {withAggregate} from '#aggregate'; -import {logWarn} from '#cli'; -import {input, V} from '#composite'; -import * as html from '#html'; -import {accumulateSum, empty, withEntries} from '#sugar'; -import {isLanguageCode, isObject} from '#validators'; -import Thing from '#thing'; -import {languageOptionRegex} from '#wiki-data'; - -import { - externalLinkSpec, - getExternalLinkStringOfStyleFromDescriptors, - getExternalLinkStringsFromDescriptors, - isExternalLinkContext, - isExternalLinkStyle, -} from '#external-links'; - -import {exitWithoutDependency, exposeConstant} - from '#composite/control-flow'; -import {flag, name} from '#composite/wiki-properties'; - -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(V(`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 - // 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(V(false)), - - // Mapping of translation keys to values (strings). Generally, don't - // access this object directly - use methods instead. - strings: [ - { - dependencies: [ - input.updateValue({validate: isObject}), - 'inheritedStrings', - ], - - compute: (continuation, { - [input.updateValue()]: strings, - ['inheritedStrings']: inheritedStrings, - }) => - (strings && inheritedStrings - ? continuation() - : strings ?? inheritedStrings), - }, - - { - dependencies: ['inheritedStrings', 'code'], - transform(strings, {inheritedStrings, code}) { - 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'}, - }, - - // Expose only - - isLanguage: exposeConstant(V(true)), - - onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))), - - intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), - intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), - intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}), - 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'}), - intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}), - - 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: [ - exitWithoutDependency('strings'), - - { - dependencies: ['strings'], - compute: ({strings}) => - withEntries(strings, entries => entries - .map(([key, value]) => [key, html.escape(value)])), - }, - ], - }); - - 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); - } - - $order(...args) { - return this.orderStringOptions(...args); - } - - assertIntlAvailable(property) { - if (!this[property]) { - throw new Error(`Intl API ${property} unavailable`); - } - } - - countWords(text) { - this.assertIntlAvailable('intl_wordSegmenter'); - - const string = html.resolve(text, {normalize: 'plain'}); - const segments = this.intl_wordSegmenter.segment(string); - - return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0); - } - - getUnitForm(value) { - this.assertIntlAvailable('intl_pluralCardinal'); - return this.intl_pluralCardinal.select(value); - } - - formatString(...args) { - if (typeof args.at(-1) === 'function') { - throw new Error(`Passed function - did you mean language.encapsulate() instead?`); - } - - const hasOptions = - typeof args.at(-1) === 'object' && - args.at(-1) !== null; - - const key = - this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); - - const template = - this.#getStringTemplateFromFormedKey(key); - - const options = - (hasOptions - ? args.at(-1) - : {}); - - const constantCasify = name => - name - .replace(/[A-Z]/g, '_$&') - .toUpperCase(); - - // These will be filled up as we iterate over the template, slotting in - // each option (if it's present). - const missingOptionNames = new Set(); - - // These will also be filled. It's a bit different of an error, indicating - // a provided option was *expected,* but its value was null, undefined, or - // blank HTML content. - const valuelessOptionNames = new Set(); - - // These *might* be missing, and if they are, that's OK!! Instead of adding - // to the valueless set above, we'll just mark to return a blank for the - // whole string. - const expectedValuelessOptionNames = - new Set( - (options[this.onlyIfOptions] ?? []) - .map(constantCasify)); - - let seenExpectedValuelessOption = false; - - const isValueless = - value => - value === null || - value === undefined || - html.isBlank(value); - - // 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]) => [ - constantCasify(name), - value, - ])); - - const output = this.#iterateOverTemplate({ - template, - match: languageOptionRegex, - - insert: ({name: optionName}, canceledForming) => { - if (!optionsMap.has(optionName)) { - missingOptionNames.add(optionName); - - // 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. - // Return undefined to mark canceledForming for the following - // iterations (and exit early out of this iteration). - return undefined; - } - - // Even if we're not actually forming the output anymore, we'll still - // have to access this option's value to check if it is invalid. - const optionValue = optionsMap.get(optionName); - - // 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 - information you want even (or doubly so) if we've - // already stopped forming the output thanks to missing options. - optionsMap.delete(optionName); - - // Just like if an option is missing, a valueless option cancels - // forming the rest of the output. - if (isValueless(optionValue)) { - // It's also an error, *except* if this option is one of the ones - // that we're indicated to *expect* might be valueless! In that case, - // we still need to stop forming the string (and mark a separate flag - // so that we return a blank), but it's not an error. - if (expectedValuelessOptionNames.has(optionName)) { - seenExpectedValuelessOption = true; - } else { - valuelessOptionNames.add(optionName); - } - - return undefined; - } - - if (canceledForming) { - return undefined; - } - - return this.sanitize(optionValue); - }, - }); - - const misplacedOptionNames = - Array.from(optionsMap.keys()); - - withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { - const names = set => Array.from(set).join(', '); - - if (!empty(missingOptionNames)) { - push(new Error( - `Missing options: ${names(missingOptionNames)}`)); - } - - if (!empty(valuelessOptionNames)) { - push(new Error( - `Valueless options: ${names(valuelessOptionNames)}`)); - } - - if (!empty(misplacedOptionNames)) { - push(new Error( - `Unexpected options: ${names(misplacedOptionNames)}`)); - } - }); - - // If an option was valueless as marked to expect, then that indicates - // the whole string should be treated as blank content. - if (seenExpectedValuelessOption) { - return html.blank(); - } - - return output; - } - - orderStringOptions(...args) { - let slice = null, at = null, parts = null; - if (args.length >= 2 && typeof args.at(-1) === 'number') { - if (args.length >= 3 && typeof args.at(-2) === 'number') { - slice = [args.at(-2), args.at(-1)]; - parts = args.slice(0, -2); - } else { - at = args.at(-1); - parts = args.slice(0, -1); - } - } else { - parts = args; - } - - const template = this.getStringTemplate(...parts); - const matches = Array.from(template.matchAll(languageOptionRegex)); - const options = matches.map(({groups}) => groups.name); - - if (slice !== null) return options.slice(...slice); - if (at !== null) return options.at(at); - return options; - } - - getStringTemplate(...args) { - const key = this.#joinKeyParts(args); - return this.#getStringTemplateFromFormedKey(key); - } - - #getStringTemplateFromFormedKey(key) { - if (!this.strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - - return this.strings[key]; - } - - #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); - - const insertionItems = html.smush(insertion).content; - if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { - // Push the insertion exactly as it is, rather than manipulating. - if (partInProgress) outputParts.push(partInProgress); - outputParts.push(insertion); - partInProgress = ''; - } else for (const insertionItem of insertionItems) { - if (typeof insertionItem === 'string') { - // Join consecutive strings together. - partInProgress += insertionItem; - } else { - // Push the string part in progress, then the insertion as-is. - if (partInProgress) outputParts.push(partInProgress); - outputParts.push(insertionItem); - 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) { - switch (typeof value) { - case 'string': - return html.escape(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) { - // Null or undefined date is blank content. - if (date === null || date === undefined) { - return html.blank(); - } - - this.assertIntlAvailable('intl_date'); - return this.intl_date.format(date); - } - - formatDateRange(startDate, endDate) { - // formatDateRange expects both values to be present, but if both are null - // or both are undefined, that's just blank content. - const hasStart = startDate !== null && startDate !== undefined; - const hasEnd = endDate !== null && endDate !== undefined; - if (!hasStart && !hasEnd) { - return html.blank(); - } else if (hasStart && !hasEnd) { - throw new Error(`Expected both start and end of date range, got only start`); - } else if (!hasStart && hasEnd) { - throw new Error(`Expected both start and end of date range, got only end`); - } - - this.assertIntlAvailable('intl_date'); - return this.intl_date.formatRange(startDate, endDate); - } - - formatYear(date) { - if (date === null || date === undefined) { - return html.blank(); - } - - this.assertIntlAvailable('intl_dateYear'); - return this.intl_dateYear.format(date); - } - - formatMonthDay(date) { - if (date === null || date === undefined) { - return html.blank(); - } - - this.assertIntlAvailable('intl_dateMonthDay'); - return this.intl_dateMonthDay.format(date); - } - - formatYearRange(startDate, endDate) { - // formatYearRange expects both values to be present, but if both are null - // or both are undefined, that's just blank content. - const hasStart = startDate !== null && startDate !== undefined; - const hasEnd = endDate !== null && endDate !== undefined; - if (!hasStart && !hasEnd) { - return html.blank(); - } else if (hasStart && !hasEnd) { - throw new Error(`Expected both start and end of date range, got only start`); - } else if (!hasStart && hasEnd) { - throw new Error(`Expected both start and end of date range, got only end`); - } - - this.assertIntlAvailable('intl_dateYear'); - return this.intl_dateYear.formatRange(startDate, endDate); - } - - formatDateDuration({ - years: numYears = 0, - months: numMonths = 0, - days: numDays = 0, - approximate = false, - }) { - // Give up if any of years, months, or days is null or undefined. - // These default to zero, so something's gone pretty badly wrong to - // pass in all or partial missing values. - if ( - numYears === undefined || numYears === null || - numMonths === undefined || numMonths === null || - numDays === undefined || numDays === null - ) { - throw new Error(`Expected values or default zero for years, months, and days`); - } - - 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, - } = {}) { - // Give up if current and/or reference date is null or undefined. - if ( - currentDate === undefined || currentDate === null || - referenceDate === undefined || referenceDate === null - ) { - throw new Error(`Expected values for currentDate and referenceDate`); - } - - 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} = {}) { - // Null or undefined duration is blank content. - if (secTotal === null || secTotal === undefined) { - return html.blank(); - } - - // Zero duration is a "missing" string. - 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', - } = {}) { - // Null or undefined url is blank content. - if (url === null || url === undefined) { - return html.blank(); - } - - isExternalLinkContext(context); - - if (style === 'all') { - return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { - language: this, - context, - }); - } - - isExternalLinkStyle(style); - - const result = - getExternalLinkStringOfStyleFromDescriptors(url, style, 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) { - // Null or undefined value is blank content. - if (value === null || value === undefined) { - return html.blank(); - } - - this.assertIntlAvailable('intl_pluralOrdinal'); - return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); - } - - formatNumber(value) { - // Null or undefined value is blank content. - if (value === null || value === undefined) { - return html.blank(); - } - - this.assertIntlAvailable('intl_number'); - return this.intl_number.format(value); - } - - formatWordCount(value) { - // Null or undefined value is blank content. - if (value === null || value === undefined) { - return html.blank(); - } - - 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) { - // Empty lists, null, and undefined are blank content. - if (empty(array) || array === null || array === undefined) { - return html.blank(); - } - - // 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_(?[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) { - // Null or undefined bytes is blank content. - if (bytes === null || bytes === undefined) { - return html.blank(); - } - - // Zero bytes is blank content. - if (bytes === 0) { - return html.blank(); - } - - bytes = parseInt(bytes); - - // Non-number bytes is blank content! Wow. - if (isNaN(bytes)) { - return html.blank(); - } - - 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}); - } - } - - typicallyLowerCase(string) { - // Utter nonsense implementation, so this only works on strings, - // not actual HTML content, and may rudely disrespect *intentful* - // capitalization of whatever goes into it. - - if (typeof string !== 'string') return string; - if (string.length <= 1) return string; - if (/^\S+?[A-Z]/.test(string)) return string; - - return string[0].toLowerCase() + string.slice(1); - } - - // Utility function to quickly provide a useful string key - // (generally a prefix) to stuff nested beneath it. - encapsulate(...args) { - const fn = - (typeof args.at(-1) === 'function' - ? args.at(-1) - : null); - - const parts = - (fn - ? args.slice(0, -1) - : args); - - const capsule = - this.#joinKeyParts(parts); - - if (fn) { - return fn(capsule); - } else { - return capsule; - } - } - - #joinKeyParts(parts) { - return parts.filter(Boolean).join('.'); - } -} - -const countHelper = (stringKey, optionName = stringKey) => - function(value, { - unit = false, - blankIfZero = false, - } = {}) { - // Null or undefined value is blank content. - if (value === null || value === undefined) { - return html.blank(); - } - - // Zero is blank content, if that option is set. - if (value === 0 && blankIfZero) { - return html.blank(); - } - - 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'), - countArtTags: countHelper('artTags', 'tags'), - countArtworks: countHelper('artworks'), - countCommentaryEntries: countHelper('commentaryEntries', 'entries'), - countContributions: countHelper('contributions'), - countDays: countHelper('days'), - countFlashes: countHelper('flashes'), - countMonths: countHelper('months'), - countTimesFeatured: countHelper('timesFeatured'), - countTimesReferenced: countHelper('timesReferenced'), - countTimesUsed: countHelper('timesUsed'), - countTracks: countHelper('tracks'), - countWeeks: countHelper('weeks'), - countYears: countHelper('years'), -}); diff --git a/src/data/things/music-video.js b/src/data/things/music-video.js deleted file mode 100644 index 20f201cc..00000000 --- a/src/data/things/music-video.js +++ /dev/null @@ -1,147 +0,0 @@ -import {inspect} from 'node:util'; - -import {colors} from '#cli'; -import {input, V} from '#composite'; -import find from '#find'; -import Thing from '#thing'; -import {is, isDate, isStringNonEmpty, isURL} from '#validators'; -import {parseContributors, parseDate} from '#yaml'; - -import {constituteFrom} from '#composite/wiki-data'; - -import { - exposeConstant, - exposeDependency, - exposeUpdateValueOrContinue, - withResultOfAvailabilityCheck, -} from '#composite/control-flow'; - -import { - contributionList, - dimensions, - directory, - fileExtension, - soupyFind, - soupyReverse, - thing, - urls, -} from '#composite/wiki-properties'; - -export class MusicVideo extends Thing { - static [Thing.referenceType] = 'music-video'; - static [Thing.wikiData] = 'musicVideoData'; - - static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ - // Update & expose - - thing: thing(), - - label: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - expose: {transform: value => value ?? 'Music video'}, - }, - - labelStyle: { - flags: {update: true, expose: true}, - update: { - validate: - is('label', 'title'), - }, - }, - - unqualifiedDirectory: directory({name: 'label'}), - - date: [ - exposeUpdateValueOrContinue({ - validate: input.value(isDate), - }), - - constituteFrom('thing', V('date')), - ], - - url: { - flags: {update: true, expose: true}, - update: {validate: isURL}, - }, - - coverArtFileExtension: fileExtension(V('jpg')), - coverArtDimensions: dimensions(), - - artistContribs: contributionList({ - artistProperty: input.value('musicVideoArtistContributions'), - }), - - contributorContribs: contributionList({ - artistProperty: input.value('musicVideoContributorContributions'), - }), - - // Update only - - find: soupyFind(), - - // Expose only - - isMusicVideo: exposeConstant(V(true)), - - dateIsSpecified: [ - withResultOfAvailabilityCheck('_date'), - exposeDependency('#availability'), - ], - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Label': {property: 'label'}, - 'Label Style': {property: 'labelStyle'}, - 'Directory': {property: 'unqualifiedDirectory'}, - 'Date': {property: 'date', transform: parseDate}, - 'URL': {property: 'url'}, - - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - 'Cover Art Dimensions': {property: 'coverArtDimensions'}, - - 'Artists': {property: 'artistContribs', transform: parseContributors}, - 'Contributors': {property: 'contributorContribs', transform: parseContributors}, - }, - }; - - static [Thing.reverseSpecs] = { - musicVideoArtistContributionsBy: - soupyReverse.contributionsBy('musicVideoData', 'artistContribs'), - - musicVideoContributorContributionsBy: - soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'), - }; - - get path() { - if (!this.thing) return null; - if (!this.thing.getOwnMusicVideoCoverPath) return null; - - return this.thing.getOwnMusicVideoCoverPath(this); - } - - [inspect.custom](depth, options, inspect) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (this.thing) { - if (depth >= 0) { - const newOptions = { - ...options, - depth: - (options.depth === null - ? null - : options.depth - 1), - }; - - parts.push(` for ${inspect(this.thing, newOptions)}`); - } else { - parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); - } - } - - return parts.join(''); - } -} diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js deleted file mode 100644 index bb35d11b..00000000 --- a/src/data/things/news-entry.js +++ /dev/null @@ -1,76 +0,0 @@ -export const NEWS_DATA_FILE = 'news.yaml'; - -import {V} from '#composite'; -import {sortChronologically} from '#sort'; -import Thing from '#thing'; -import {parseDate} from '#yaml'; - -import {exposeConstant} from '#composite/control-flow'; -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.wikiData] = 'newsData'; - - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - name: name(V('Unnamed News Entry')), - directory: directory(), - date: simpleDate(), - - content: contentString(), - - // Expose only - - isNewsEntry: exposeConstant(V(true)), - - contentShort: { - flags: {expose: true}, - - expose: { - dependencies: ['content'], - - compute: ({content}) => content.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, - - sort({newsData}) { - sortChronologically(newsData, {latestFirst: true}); - }, - }); -} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js deleted file mode 100644 index 999072d3..00000000 --- a/src/data/things/static-page.js +++ /dev/null @@ -1,90 +0,0 @@ -export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; - -import * as path from 'node:path'; - -import {V} from '#composite'; -import {traverse} from '#node-utils'; -import {sortAlphabetically} from '#sort'; -import Thing from '#thing'; -import {isName} from '#validators'; - -import {exposeConstant} from '#composite/control-flow'; -import {contentString, directory, flag, name, simpleString} - from '#composite/wiki-properties'; - -export class StaticPage extends Thing { - static [Thing.referenceType] = 'static'; - static [Thing.friendlyName] = `Static Page`; - static [Thing.wikiData] = 'staticPageData'; - - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - name: name(V('Unnamed Static Page')), - - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - expose: { - dependencies: ['name'], - transform: (value, {name}) => value ?? name, - }, - }, - - directory: directory(), - - stylesheet: simpleString(), - script: simpleString(), - content: contentString(), - - absoluteLinks: flag(V(false)), - - // Expose only - - isStaticPage: exposeConstant(V(true)), - }); - - static [Thing.findSpecs] = { - staticPage: { - referenceTypes: ['static'], - bindTo: 'staticPageData', - }, - }; - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Name': {property: 'name'}, - 'Short Name': {property: 'nameShort'}, - 'Directory': {property: 'directory'}, - - 'Absolute Links': {property: 'absoluteLinks'}, - - '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, - - sort({staticPageData}) { - sortAlphabetically(staticPageData); - }, - }); -} diff --git a/src/data/things/track.js b/src/data/things/track.js deleted file mode 100644 index b4e56d82..00000000 --- a/src/data/things/track.js +++ /dev/null @@ -1,1349 +0,0 @@ -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input, V} from '#composite'; -import find, {keyRefRegex} from '#find'; -import {onlyItem} from '#sugar'; -import {sortByDate} from '#sort'; -import Thing from '#thing'; -import {compareKebabCase} from '#wiki-data'; - -import { - isBoolean, - isColor, - isContentString, - isContributionList, - isDate, - isFileExtension, - validateReference, -} from '#validators'; - -import { - parseAdditionalFiles, - parseAdditionalNames, - parseAnnotatedReferences, - parseArtwork, - parseCommentary, - parseContributors, - parseCreditingSources, - parseReferencingSources, - parseDate, - parseDimensions, - parseDuration, - parseLyrics, - parseMusicVideos, -} from '#yaml'; - -import { - exitWithoutDependency, - exitWithoutUpdateValue, - exposeConstant, - exposeDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, - exposeWhetherDependencyAvailable, - withAvailabilityFilter, - withResultOfAvailabilityCheck, -} from '#composite/control-flow'; - -import { - fillMissingListItems, - withFilteredList, - withFlattenedList, - withIndexInList, - withMappedList, - withPropertiesFromObject, - withPropertyFromList, - withPropertyFromObject, -} from '#composite/data'; - -import { - withRecontextualizedContributionList, - withRedatedContributionList, - withResolvedContribs, - withResolvedReference, -} from '#composite/wiki-data'; - -import { - commentatorArtists, - constitutibleArtworkList, - contentString, - contributionList, - dimensions, - directory, - duration, - flag, - name, - referenceList, - referencedArtworkList, - reverseReferenceList, - simpleDate, - simpleString, - soupyFind, - soupyReverse, - thing, - thingList, - urls, - wikiData, -} from '#composite/wiki-properties'; - -import { - inheritContributionListFromMainRelease, - inheritFromMainRelease, -} from '#composite/things/track'; - -export class Track extends Thing { - static [Thing.referenceType] = 'track'; - static [Thing.wikiData] = 'trackData'; - - static [Thing.constitutibleProperties] = [ - // Contributions currently aren't being observed for constitution. - // 'artistContribs', // from main release or album - // 'contributorContribs', // from main release - // 'coverArtistContribs', // from main release - - 'trackArtworks', // from inline fields - ]; - - static [Thing.getPropertyDescriptors] = ({ - AdditionalFile, - AdditionalName, - Album, - ArtTag, - Artwork, - CommentaryEntry, - CreditingSourcesEntry, - LyricsEntry, - MusicVideo, - ReferencingSourcesEntry, - TrackSection, - WikiInfo, - }) => ({ - // > Update & expose - Internal relationships - - album: thing(V(Album)), - trackSection: thing(V(TrackSection)), - - // > Update & expose - Identifying metadata - - name: name(V('Unnamed Track')), - nameText: contentString(), - - directory: directory({ - suffix: 'directorySuffix', - }), - - suffixDirectoryFromAlbum: [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - withPropertyFromObject('trackSection', V('suffixTrackDirectories')), - exposeDependency('#trackSection.suffixTrackDirectories'), - ], - - // Controls how find.track works - it'll never be matched by - // a reference just to the track's name, which means you don't - // have to always reference some *other* (much more commonly - // referenced) track by directory instead of more naturally by name. - alwaysReferenceByDirectory: [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - withPropertyFromObject('album', V('alwaysReferenceTracksByDirectory')), - - // Falsy mode means this exposes true if the album's property is true, - // but continues if the property is false (which is also the default). - exposeDependencyOrContinue({ - dependency: '#album.alwaysReferenceTracksByDirectory', - mode: input.value('falsy'), - }), - - exitWithoutDependency('_mainRelease', V(false)), - exitWithoutDependency('mainReleaseTrack', V(false)), - - withPropertyFromObject('mainReleaseTrack', V('name')), - - { - dependencies: ['name', '#mainReleaseTrack.name'], - compute: ({ - ['name']: name, - ['#mainReleaseTrack.name']: mainReleaseName, - }) => - compareKebabCase(name, mainReleaseName), - }, - ], - - // Album or track. The exposed value is really just what's provided here, - // whether or not a matching track is found on a provided album, for - // example. When presenting or processing, read `mainReleaseTrack`. - mainRelease: [ - exitWithoutUpdateValue({ - validate: input.value( - validateReference(['album', 'track'])), - }), - - { - dependencies: ['name'], - transform: (ref, continuation, {name: ownName}) => - (ref === 'same name single' - ? continuation(ref, { - ['#albumOrTrackReference']: null, - ['#sameNameSingleReference']: ownName, - }) - : continuation(ref, { - ['#albumOrTrackReference']: ref, - ['#sameNameSingleReference']: null, - })), - }, - - withResolvedReference({ - ref: '#albumOrTrackReference', - find: soupyFind.input('trackMainReleasesOnly'), - }).outputs({ - '#resolvedReference': '#matchingTrack', - }), - - withResolvedReference({ - ref: '#albumOrTrackReference', - find: soupyFind.input('album'), - }).outputs({ - '#resolvedReference': '#matchingAlbum', - }), - - withResolvedReference({ - ref: '#sameNameSingleReference', - find: soupyFind.input('albumSinglesOnly'), - findOptions: input.value({ - fuzz: { - capitalization: true, - kebab: true, - }, - }), - }).outputs({ - '#resolvedReference': '#sameNameSingle', - }), - - exposeDependencyOrContinue('#sameNameSingle'), - exposeDependencyOrContinue('#matchingAlbum'), - exposeDependency('#matchingTrack'), - ], - - bandcampTrackIdentifier: simpleString(), - bandcampArtworkIdentifier: simpleString(), - - additionalNames: thingList(V(AdditionalName)), - - dateFirstReleased: simpleDate(), - - // > Update & expose - Credits and contributors - - artistText: [ - exposeUpdateValueOrContinue({ - validate: input.value(isContentString), - }), - - withPropertyFromObject('album', V('trackArtistText')), - exposeDependency('#album.trackArtistText'), - ], - - artistTextInLists: [ - exposeUpdateValueOrContinue({ - validate: input.value(isContentString), - }), - - exposeDependencyOrContinue('_artistText'), - - withPropertyFromObject('album', V('trackArtistText')), - exposeDependency('#album.trackArtistText'), - ], - - artistContribs: [ - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - date: 'date', - thingProperty: input.thisProperty(), - artistProperty: input.value('trackArtistContributions'), - }).outputs({ - '#resolvedContribs': '#artistContribs', - }), - - exposeDependencyOrContinue('#artistContribs', V('empty')), - - // Specifically inherit artist contributions later than artist contribs. - // Secondary releases' artists may differ from the main release. - inheritContributionListFromMainRelease(), - - withPropertyFromObject('album', V('trackArtistContribs')), - - withRecontextualizedContributionList({ - list: '#album.trackArtistContribs', - artistProperty: input.value('trackArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.trackArtistContribs', - date: 'date', - }), - - exposeDependency('#album.trackArtistContribs'), - ], - - contributorContribs: [ - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - date: 'date', - thingProperty: input.thisProperty(), - artistProperty: input.value('trackArtistContributions'), - }).outputs({ - '#resolvedContribs': '#contributorContribs', - }), - - exposeDependencyOrContinue('#contributorContribs', V('empty')), - - inheritContributionListFromMainRelease(), - - exposeConstant(V([])), - ], - - // > Update & expose - General configuration - - countInArtistTotals: [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - withPropertyFromObject('trackSection', V('countTracksInArtistTotals')), - exposeDependency('#trackSection.countTracksInArtistTotals'), - ], - - disableUniqueCoverArt: flag(V(false)), - disableDate: flag(V(false)), - - // > Update & expose - General metadata - - duration: duration(), - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withPropertyFromObject('trackSection', V('color')), - exposeDependencyOrContinue('#trackSection.color'), - - withPropertyFromObject('album', V('color')), - exposeDependency('#album.color'), - ], - - needsLyrics: [ - exposeUpdateValueOrContinue({ - mode: input.value('falsy'), - validate: input.value(isBoolean), - }), - - exitWithoutDependency('_lyrics', { - value: input.value(false), - mode: input.value('empty'), - }), - - withPropertyFromList('_lyrics', V('helpNeeded')), - - { - dependencies: ['#lyrics.helpNeeded'], - compute: ({ - ['#lyrics.helpNeeded']: helpNeeded, - }) => - helpNeeded.includes(true) - }, - ], - - urls: urls(), - - // > Update & expose - Artworks - - trackArtworks: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value([]), - mode: input.value('falsy'), - }), - - constitutibleArtworkList.fromYAMLFieldSpec - .call(this, 'Track Artwork'), - ], - - coverArtistContribs: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value([]), - mode: input.value('falsy'), - }), - - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - date: 'coverArtDate', - thingProperty: input.value('coverArtistContribs'), - artistProperty: input.value('trackCoverArtistContributions'), - }), - - exposeDependencyOrContinue('#resolvedContribs', V('empty')), - - withPropertyFromObject('album', V('trackCoverArtistContribs')), - - withRecontextualizedContributionList({ - list: '#album.trackCoverArtistContribs', - artistProperty: input.value('trackCoverArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.trackCoverArtistContribs', - date: 'coverArtDate', - }), - - exposeDependency('#album.trackCoverArtistContribs'), - ], - - coverArtDate: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value(null), - mode: input.value('falsy'), - }), - - exposeUpdateValueOrContinue({ - validate: input.value(isDate), - }), - - withPropertyFromObject('album', V('trackArtDate')), - exposeDependencyOrContinue('#album.trackArtDate'), - - exposeDependency('date'), - ], - - coverArtFileExtension: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value(null), - mode: input.value('falsy'), - }), - - exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), - }), - - withPropertyFromObject('album', V('trackCoverArtFileExtension')), - exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), - - exposeConstant(V('jpg')), - ], - - coverArtDimensions: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value(null), - mode: input.value('falsy'), - }), - - exposeUpdateValueOrContinue(), - - withPropertyFromObject('album', V('trackDimensions')), - exposeDependencyOrContinue('#album.trackDimensions'), - - dimensions(), - ], - - artTags: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value([]), - mode: input.value('falsy'), - }), - - referenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), - }), - ], - - referencedArtworks: [ - exitWithoutDependency('hasUniqueCoverArt', { - value: input.value([]), - mode: input.value('falsy'), - }), - - referencedArtworkList(), - ], - - // > Update & expose - Referenced tracks - - referencedTracks: [ - inheritFromMainRelease(), - - referenceList({ - class: input.value(Track), - find: soupyFind.input('trackReference'), - }), - ], - - sampledTracks: [ - inheritFromMainRelease(), - - referenceList({ - class: input.value(Track), - find: soupyFind.input('trackReference'), - }), - ], - - // > Update & expose - Music videos - - musicVideos: [ - exposeUpdateValueOrContinue(), - - // TODO: Same situation as lyrics. Inherited music videos don't set - // the proper .thing property back to this track... but then, it needs - // to keep a reference to its original .thing to get its proper path, - // so maybe this is okay... - inheritFromMainRelease(), - - thingList(V(MusicVideo)), - ], - - // > Update & expose - Additional files - - additionalFiles: thingList(V(AdditionalFile)), - sheetMusicFiles: thingList(V(AdditionalFile)), - midiProjectFiles: thingList(V(AdditionalFile)), - - // > Update & expose - Content entries - - lyrics: [ - exposeUpdateValueOrContinue(), - - // TODO: Inherited lyrics are literally the same objects, so of course - // their .thing properties aren't going to point back to this one, and - // certainly couldn't be recontextualized... - inheritFromMainRelease(), - - thingList(V(LyricsEntry)), - ], - - commentary: thingList(V(CommentaryEntry)), - creditingSources: thingList(V(CreditingSourcesEntry)), - referencingSources: thingList(V(ReferencingSourcesEntry)), - - // > Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // used for referencedArtworkList (mixedFind) - artworkData: wikiData(V(Artwork)), - - // used for withMatchingContributionPresets (indirectly by Contribution) - wikiInfo: thing(V(WikiInfo)), - - // > Expose only - - isTrack: exposeConstant(V(true)), - - commentatorArtists: commentatorArtists(), - - directorySuffix: [ - exitWithoutDependency('suffixDirectoryFromAlbum', { - value: input.value(null), - mode: input.value('falsy'), - }), - - withPropertyFromObject('trackSection', V('directorySuffix')), - exposeDependency('#trackSection.directorySuffix'), - ], - - date: [ - { - dependencies: ['disableDate'], - compute: (continuation, {disableDate}) => - (disableDate - ? null - : continuation()), - }, - - exposeDependencyOrContinue('dateFirstReleased'), - - withPropertyFromObject('album', V('date')), - exposeDependency('#album.date'), - ], - - trackNumber: [ - // Zero is the fallback, not one, but in most albums the first track - // (and its intended output by this composition) will be one. - - exitWithoutDependency('trackSection', V(0)), - withPropertiesFromObject('trackSection', V(['tracks', 'startCountingFrom'])), - - withIndexInList('#trackSection.tracks', input.myself()), - exitWithoutDependency('#index', V(0), V('index')), - - { - dependencies: ['#trackSection.startCountingFrom', '#index'], - compute: ({ - ['#trackSection.startCountingFrom']: startCountingFrom, - ['#index']: index, - }) => startCountingFrom + index, - }, - ], - - // Whether or not the track has "unique" cover artwork - a cover which is - // specifically associated with this track in particular, rather than with - // the track's album as a whole. This is typically used to select between - // displaying the track artwork and a fallback, such as the album artwork - // or a placeholder. (This property is named hasUniqueCoverArt instead of - // the usual hasCoverArt to emphasize that it does not inherit from the - // album.) - // - // hasUniqueCoverArt is based only around the presence of *specified* - // cover artist contributions, not whether the references to artists on those - // contributions actually resolve to anything. It completely evades interacting - // with find/replace. - hasUniqueCoverArt: [ - { - dependencies: ['disableUniqueCoverArt'], - compute: (continuation, {disableUniqueCoverArt}) => - (disableUniqueCoverArt - ? false - : continuation()), - }, - - withResultOfAvailabilityCheck({ - from: '_coverArtistContribs', - mode: input.value('empty'), - }), - - { - dependencies: ['#availability'], - compute: (continuation, { - ['#availability']: availability, - }) => - (availability - ? true - : continuation()), - }, - - withPropertyFromObject('album', { - property: input.value('trackCoverArtistContribs'), - internal: input.value(true), - }), - - withResultOfAvailabilityCheck({ - from: '#album.trackCoverArtistContribs', - mode: input.value('empty'), - }), - - { - dependencies: ['#availability'], - compute: (continuation, { - ['#availability']: availability, - }) => - (availability - ? true - : continuation()), - }, - - exitWithoutDependency('_trackArtworks', { - value: input.value(false), - mode: input.value('empty'), - }), - - withPropertyFromList('_trackArtworks', { - property: input.value('artistContribs'), - internal: input.value(true), - }), - - // Since we're getting the update value for each artwork's artistContribs, - // it may not be set at all, and in that case won't be exposing as []. - fillMissingListItems('#trackArtworks.artistContribs', V([])), - - withFlattenedList('#trackArtworks.artistContribs'), - - exposeWhetherDependencyAvailable({ - dependency: '#flattenedList', - mode: input.value('empty'), - }), - ], - - isMainRelease: - exposeWhetherDependencyAvailable({ - dependency: 'mainReleaseTrack', - negate: input.value(true), - }), - - isSecondaryRelease: - exposeWhetherDependencyAvailable({ - dependency: 'mainReleaseTrack', - }), - - mainReleaseTrack: [ - exitWithoutDependency('mainRelease'), - - withPropertyFromObject('mainRelease', V('isTrack')), - - { - dependencies: ['mainRelease', '#mainRelease.isTrack'], - compute: (continuation, { - ['mainRelease']: mainRelease, - ['#mainRelease.isTrack']: mainReleaseIsTrack, - }) => - (mainReleaseIsTrack - ? mainRelease - : continuation()), - }, - - { - dependencies: ['name', '_directory'], - compute: (continuation, { - ['name']: ownName, - ['_directory']: ownDirectory, - }) => continuation({ - ['#mapItsNameLikeName']: - itsName => compareKebabCase(itsName, ownName), - - ['#mapItsDirectoryLikeDirectory']: - (ownDirectory - ? itsDirectory => itsDirectory === ownDirectory - : () => false), - - ['#mapItsNameLikeDirectory']: - (ownDirectory - ? itsName => compareKebabCase(itsName, ownDirectory) - : () => false), - - ['#mapItsDirectoryLikeName']: - itsDirectory => compareKebabCase(itsDirectory, ownName), - }), - }, - - withPropertyFromObject('mainRelease', V('tracks')), - - withPropertyFromList('#mainRelease.tracks', { - property: input.value('mainRelease'), - internal: input.value(true), - }), - - withAvailabilityFilter({from: '#mainRelease.tracks.mainRelease'}), - - withMappedList({ - list: '#availabilityFilter', - map: input.value(item => !item), - }).outputs({ - '#mappedList': '#availabilityFilter', - }), - - withFilteredList('#mainRelease.tracks', '#availabilityFilter') - .outputs({'#filteredList': '#mainRelease.tracks'}), - - withPropertyFromList('#mainRelease.tracks', V('name')), - - withPropertyFromList('#mainRelease.tracks', { - property: input.value('directory'), - internal: input.value(true), - }), - - withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeName') - .outputs({'#mappedList': '#filterItsNameLikeName'}), - - withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeDirectory') - .outputs({'#mappedList': '#filterItsDirectoryLikeDirectory'}), - - withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeDirectory') - .outputs({'#mappedList': '#filterItsNameLikeDirectory'}), - - withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeName') - .outputs({'#mappedList': '#filterItsDirectoryLikeName'}), - - withFilteredList('#mainRelease.tracks', '#filterItsNameLikeName') - .outputs({'#filteredList': '#matchingItsNameLikeName'}), - - withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeDirectory') - .outputs({'#filteredList': '#matchingItsDirectoryLikeDirectory'}), - - withFilteredList('#mainRelease.tracks', '#filterItsNameLikeDirectory') - .outputs({'#filteredList': '#matchingItsNameLikeDirectory'}), - - withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeName') - .outputs({'#filteredList': '#matchingItsDirectoryLikeName'}), - - { - dependencies: [ - '#matchingItsNameLikeName', - '#matchingItsDirectoryLikeDirectory', - '#matchingItsNameLikeDirectory', - '#matchingItsDirectoryLikeName', - ], - - compute: (continuation, { - ['#matchingItsNameLikeName']: NLN, - ['#matchingItsDirectoryLikeDirectory']: DLD, - ['#matchingItsNameLikeDirectory']: NLD, - ['#matchingItsDirectoryLikeName']: DLN, - }) => continuation({ - ['#mainReleaseTrack']: - onlyItem(DLD) ?? - onlyItem(NLN) ?? - onlyItem(DLN) ?? - onlyItem(NLD) ?? - null, - }), - }, - - { - dependencies: ['#mainReleaseTrack', input.myself()], - compute: ({ - ['#mainReleaseTrack']: mainReleaseTrack, - [input.myself()]: thisTrack, - }) => - (mainReleaseTrack === thisTrack - ? null - : mainReleaseTrack), - }, - ], - - // Only has any value for main releases, because secondary releases - // are never secondary to *another* secondary release. - secondaryReleases: reverseReferenceList({ - reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), - }), - - allReleases: [ - { - dependencies: [ - 'mainReleaseTrack', - 'secondaryReleases', - input.myself(), - ], - - compute: (continuation, { - mainReleaseTrack, - secondaryReleases, - [input.myself()]: thisTrack, - }) => - (mainReleaseTrack - ? continuation({ - ['#mainReleaseTrack']: mainReleaseTrack, - ['#secondaryReleaseTracks']: mainReleaseTrack.secondaryReleases, - }) - : continuation({ - ['#mainReleaseTrack']: thisTrack, - ['#secondaryReleaseTracks']: secondaryReleases, - })), - }, - - { - dependencies: [ - '#mainReleaseTrack', - '#secondaryReleaseTracks', - ], - - compute: ({ - ['#mainReleaseTrack']: mainReleaseTrack, - ['#secondaryReleaseTracks']: secondaryReleaseTracks, - }) => - sortByDate([mainReleaseTrack, ...secondaryReleaseTracks]), - }, - ], - - otherReleases: [ - { - dependencies: [input.myself(), 'allReleases'], - compute: ({ - [input.myself()]: thisTrack, - ['allReleases']: allReleases, - }) => - allReleases.filter(track => track !== thisTrack), - }, - ], - - commentaryFromMainRelease: [ - exitWithoutDependency('mainReleaseTrack', V([])), - - withPropertyFromObject('mainReleaseTrack', V('commentary')), - exposeDependency('#mainReleaseTrack.commentary'), - ], - - groups: [ - withPropertyFromObject('album', V('groups')), - exposeDependency('#album.groups'), - ], - - referencedByTracks: reverseReferenceList({ - reverse: soupyReverse.input('tracksWhichReference'), - }), - - sampledByTracks: reverseReferenceList({ - reverse: soupyReverse.input('tracksWhichSample'), - }), - - featuredInFlashes: reverseReferenceList({ - reverse: soupyReverse.input('flashesWhichFeature'), - }), - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - // Identifying metadata - - 'Track': {property: 'name'}, - 'Track Text': {property: 'nameText'}, - 'Directory': {property: 'directory'}, - 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Main Release': {property: 'mainRelease'}, - - 'Bandcamp Track ID': { - property: 'bandcampTrackIdentifier', - transform: String, - }, - - 'Bandcamp Artwork ID': { - property: 'bandcampArtworkIdentifier', - transform: String, - }, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, - - 'Date First Released': { - property: 'dateFirstReleased', - transform: parseDate, - }, - - // Credits and contributors - - 'Artist Text': {property: 'artistText'}, - 'Artist Text In Lists': {property: 'artistTextInLists'}, - - 'Artists': { - property: 'artistContribs', - transform: parseContributors, - }, - - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, - }, - - // General configuration - - 'Count In Artist Totals': {property: 'countInArtistTotals'}, - - 'Has Cover Art': { - property: 'disableUniqueCoverArt', - transform: value => - (typeof value === 'boolean' - ? !value - : value), - }, - - 'Has Date': { - property: 'disableDate', - transform: value => - (typeof value === 'boolean' - ? !value - : value), - }, - - // General metadata - - 'Duration': { - property: 'duration', - transform: parseDuration, - }, - - 'Color': {property: 'color'}, - - 'Needs Lyrics': { - property: 'needsLyrics', - }, - - 'URLs': {property: 'urls'}, - - // Artworks - - 'Track Artwork': { - property: 'trackArtworks', - transform: - parseArtwork({ - thingProperty: 'trackArtworks', - dimensionsFromThingProperty: 'coverArtDimensions', - fileExtensionFromThingProperty: 'coverArtFileExtension', - dateFromThingProperty: 'coverArtDate', - artTagsFromThingProperty: 'artTags', - referencedArtworksFromThingProperty: 'referencedArtworks', - artistContribsFromThingProperty: 'coverArtistContribs', - artistContribsArtistProperty: 'trackCoverArtistContributions', - }), - }, - - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, - }, - - 'Cover Art Date': { - property: 'coverArtDate', - transform: parseDate, - }, - - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - - 'Cover Art Dimensions': { - property: 'coverArtDimensions', - transform: parseDimensions, - }, - - 'Art Tags': {property: 'artTags'}, - - 'Referenced Artworks': { - property: 'referencedArtworks', - transform: parseAnnotatedReferences, - }, - - // Referenced tracks - - 'Referenced Tracks': {property: 'referencedTracks'}, - 'Sampled Tracks': {property: 'sampledTracks'}, - - // Music videos - - 'Music Videos': { - property: 'musicVideos', - transform: parseMusicVideos, - }, - - // Additional files - - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, - }, - - 'Sheet Music Files': { - property: 'sheetMusicFiles', - transform: parseAdditionalFiles, - }, - - 'MIDI Project Files': { - property: 'midiProjectFiles', - transform: parseAdditionalFiles, - }, - - // Content entries - - 'Lyrics': { - property: 'lyrics', - transform: parseLyrics, - }, - - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, - - 'Crediting Sources': { - property: 'creditingSources', - transform: parseCreditingSources, - }, - - 'Referencing Sources': { - property: 'referencingSources', - transform: parseReferencingSources, - }, - - // Shenanigans - - 'Franchises': {ignore: true}, - 'Inherit Franchises': {ignore: true}, - 'Review Points': {ignore: true}, - }, - - invalidFieldCombinations: [ - {message: `Secondary releases never count in artist totals`, fields: [ - 'Main Release', - 'Count In Artist Totals', - ]}, - - {message: `Secondary releases inherit references from the main one`, fields: [ - 'Main Release', - 'Referenced Tracks', - ]}, - - {message: `Secondary releases inherit samples from the main one`, fields: [ - 'Main Release', - 'Sampled Tracks', - ]}, - - { - 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]), - }, - - trackMainReleasesOnly: { - referenceTypes: ['track'], - bindTo: 'trackData', - - include: track => - !CacheableObject.getUpdateValue(track, 'mainRelease'), - - // 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]), - }, - - trackReference: { - referenceTypes: ['track'], - bindTo: 'trackData', - - byob(fullRef, data, opts) { - const {from} = opts; - - const acontextual = () => - find.trackMainReleasesOnly(fullRef, data, opts); - - const regexMatch = fullRef.match(keyRefRegex); - if (!regexMatch || regexMatch?.keyPart) { - // It's a reference by directory or it's malformed. - // Either way, we can't handle it here! - return acontextual(); - } - - if (!from?.isTrack) { - throw new Error( - `Expected to find starting from a track, got: ` + - inspect(from, {compact: true})); - } - - const referencingTrack = from; - const referencedName = fullRef; - - for (const track of referencingTrack.album.tracks) { - // Totally ignore alwaysReferenceByDirectory here. - // void track.alwaysReferenceByDirectory; - - if (track.name === referencedName) { - if (track.isSecondaryRelease) { - return track.mainReleaseTrack; - } else { - return track; - } - } - } - - return acontextual(); - }, - }, - - trackWithArtwork: { - referenceTypes: [ - 'track', - 'track-referencing-artworks', - 'track-referenced-artworks', - ], - - bindTo: 'trackData', - - include: track => - track.hasUniqueCoverArt, - - getMatchableNames: track => - (track.alwaysReferenceByDirectory - ? [] - : [track.name]), - }, - - trackPrimaryArtwork: { - [Thing.findThisThingOnly]: false, - - referenceTypes: [ - 'track', - 'track-referencing-artworks', - 'track-referenced-artworks', - ], - - bindTo: 'artworkData', - - include: (artwork) => - artwork.isArtwork && - artwork.thing.isTrack && - artwork === artwork.thing.trackArtworks[0], - - getMatchableNames: ({thing: track}) => - (track.alwaysReferenceByDirectory - ? [] - : [track.name]), - - getMatchableDirectories: ({thing: track}) => - [track.directory], - }, - }; - - static [Thing.reverseSpecs] = { - tracksWhichReference: { - bindTo: 'trackData', - - referencing: track => track.isMainRelease ? [track] : [], - referenced: track => track.referencedTracks, - }, - - tracksWhichSample: { - bindTo: 'trackData', - - referencing: track => track.isMainRelease ? [track] : [], - referenced: track => track.sampledTracks, - }, - - tracksWhoseArtworksFeature: { - bindTo: 'trackData', - - referencing: track => [track], - referenced: track => track.artTags, - }, - - trackArtistContributionsBy: - soupyReverse.contributionsBy('trackData', 'artistContribs'), - - trackContributorContributionsBy: - soupyReverse.contributionsBy('trackData', 'contributorContribs'), - - trackCoverArtistContributionsBy: - soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'), - - tracksWithCommentaryBy: { - bindTo: 'trackData', - - referencing: track => [track], - referenced: track => track.commentatorArtists, - }, - - tracksWhichAreSecondaryReleasesOf: { - bindTo: 'trackData', - - referencing: track => track.isSecondaryRelease ? [track] : [], - referenced: track => [track.mainReleaseTrack], - }, - }; - - // Track YAML loading is handled in album.js. - static [Thing.getYamlLoadingSpec] = null; - - getOwnAdditionalFilePath(_file, filename) { - if (!this.album) return null; - - return [ - 'media.albumAdditionalFile', - this.album.directory, - filename, - ]; - } - - getOwnArtworkPath(artwork) { - if (!this.album) return null; - - return [ - 'media.trackCover', - this.album.directory, - - (artwork.unqualifiedDirectory - ? this.directory + '-' + artwork.unqualifiedDirectory - : this.directory), - - artwork.fileExtension, - ]; - } - - getOwnMusicVideoCoverPath(musicVideo) { - if (!this.album) return null; - if (!musicVideo.unqualifiedDirectory) return null; - - return [ - 'media.trackCover', - this.album.directory, - this.directory + '-' + musicVideo.unqualifiedDirectory, - musicVideo.coverArtFileExtension, - ]; - } - - countOwnContributionInContributionTotals(_contrib) { - if (!this.countInArtistTotals) { - return false; - } - - if (this.isSecondaryRelease) { - return false; - } - - return true; - } - - countOwnContributionInDurationTotals(_contrib) { - if (!this.countInArtistTotals) { - return false; - } - - if (this.isSecondaryRelease) { - return false; - } - - return true; - } - - [inspect.custom](depth) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (CacheableObject.getUpdateValue(this, 'mainRelease')) { - parts.unshift(`${colors.yellow('[secrelease]')} `); - } - - let album; - - if (depth >= 0) { - album = this.album; - } - - 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 deleted file mode 100644 index 1d1f90e6..00000000 --- a/src/data/things/wiki-info.js +++ /dev/null @@ -1,169 +0,0 @@ -export const WIKI_INFO_FILE = 'wiki-info.yaml'; - -import {input, V} from '#composite'; -import Thing from '#thing'; -import {parseContributionPresets, parseWallpaperParts} from '#yaml'; - -import { - isBoolean, - isContributionPresetList, - isLanguageCode, - isName, - isNumber, -} from '#validators'; - -import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; - -import { - canonicalBase, - color, - contentString, - fileExtension, - flag, - name, - referenceList, - simpleString, - soupyFind, - wallpaperParts, -} from '#composite/wiki-properties'; - -export class WikiInfo extends Thing { - static [Thing.friendlyName] = `Wiki Info`; - static [Thing.wikiData] = 'wikiInfo'; - static [Thing.oneInstancePerWiki] = true; - - static [Thing.getPropertyDescriptors] = ({Group}) => ({ - // Update & expose - - name: name(V('Unnamed Wiki')), - - // Displayed in nav bar. - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - expose: { - dependencies: ['name'], - transform: (value, {name}) => value ?? name, - }, - }, - - color: color(V('#0088ff')), - - // One-line description used for tag. - description: contentString(), - - footerContent: contentString(), - - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode}, - }, - - canonicalBase: canonicalBase(), - canonicalMediaBase: canonicalBase(), - - wikiWallpaperBrightness: { - flags: {update: true, expose: true}, - update: {validate: isNumber}, - }, - - wikiWallpaperFileExtension: fileExtension(V('jpg')), - wikiWallpaperStyle: simpleString(), - wikiWallpaperParts: wallpaperParts(), - - divideTrackListsByGroups: referenceList({ - class: input.value(Group), - find: soupyFind.input('group'), - }), - - contributionPresets: { - flags: {update: true, expose: true}, - update: {validate: isContributionPresetList}, - }, - - // Feature toggles - enableFlashesAndGames: flag(V(false)), - enableListings: flag(V(false)), - enableNews: flag(V(false)), - enableArtTagUI: flag(V(false)), - enableGroupUI: flag(V(false)), - - enableSearch: [ - exitWithoutDependency('_searchDataAvailable', { - value: input.value(false), - mode: input.value('falsy'), - }), - - flag(V(true)), - ], - - // Update only - - find: soupyFind(), - - searchDataAvailable: { - flags: {update: true}, - update: { - validate: isBoolean, - default: false, - }, - }, - - // Expose only - - isWikiInfo: exposeConstant(V(true)), - }); - - 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'}, - 'Canonical Media Base': {property: 'canonicalMediaBase'}, - - 'Wiki Wallpaper Brightness': {property: 'wikiWallpaperBrightness'}, - 'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'}, - - 'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'}, - - 'Wiki Wallpaper Parts': { - property: 'wikiWallpaperParts', - transform: parseWallpaperParts, - }, - - 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, - 'Enable Listings': {property: 'enableListings'}, - 'Enable News': {property: 'enableNews'}, - 'Enable Art Tag UI': {property: 'enableArtTagUI'}, - 'Enable Group UI': {property: 'enableGroupUI'}, - - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, - - 'Contribution Presets': { - property: 'contributionPresets', - transform: parseContributionPresets, - }, - }, - }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {oneDocumentTotal}, - thingConstructors: {WikiInfo}, - }) => ({ - title: `Process wiki info file`, - file: WIKI_INFO_FILE, - - documentMode: oneDocumentTotal, - documentThing: WikiInfo, - }); -} -- cgit 1.3.0-6-gf8a5