diff options
Diffstat (limited to 'src/data/things')
| -rw-r--r-- | src/data/things/additional-file.js | 54 | ||||
| -rw-r--r-- | src/data/things/additional-name.js | 31 | ||||
| -rw-r--r-- | src/data/things/album.js | 1080 | ||||
| -rw-r--r-- | src/data/things/art-tag.js | 185 | ||||
| -rw-r--r-- | src/data/things/artist.js | 364 | ||||
| -rw-r--r-- | src/data/things/artwork.js | 422 | ||||
| -rw-r--r-- | src/data/things/content.js | 319 | ||||
| -rw-r--r-- | src/data/things/contribution.js | 303 | ||||
| -rw-r--r-- | src/data/things/flash.js | 329 | ||||
| -rw-r--r-- | src/data/things/group.js | 234 | ||||
| -rw-r--r-- | src/data/things/homepage-layout.js | 320 | ||||
| -rw-r--r-- | src/data/things/index.js | 108 | ||||
| -rw-r--r-- | src/data/things/language.js | 484 | ||||
| -rw-r--r-- | src/data/things/news-entry.js | 9 | ||||
| -rw-r--r-- | src/data/things/sorting-rule.js | 396 | ||||
| -rw-r--r-- | src/data/things/static-page.js | 20 | ||||
| -rw-r--r-- | src/data/things/track.js | 1257 | ||||
| -rw-r--r-- | src/data/things/wiki-info.js | 123 |
18 files changed, 4844 insertions, 1194 deletions
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js new file mode 100644 index 00000000..b15f62e0 --- /dev/null +++ b/src/data/things/additional-file.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/additional-name.js b/src/data/things/additional-name.js new file mode 100644 index 00000000..99f3ee46 --- /dev/null +++ b/src/data/things/additional-name.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/album.js b/src/data/things/album.js index e9f55b2c..31d94ef1 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -3,187 +3,409 @@ export const DATA_ALBUM_DIRECTORY = 'album'; import * as path from 'node:path'; import {inspect} from 'node:util'; -import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; -import {input} from '#composite'; -import find from '#find'; +import {input, V} from '#composite'; import {traverse} from '#node-utils'; import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {accumulateSum, empty} from '#sugar'; +import {empty} from '#sugar'; import Thing from '#thing'; -import {isColor, isDate, validateWikiData} from '#validators'; -import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions} - from '#yaml'; - -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; -import {exitWithoutContribs, withDirectory, withResolvedReference} + +import { + is, + isBoolean, + isColor, + isContributionList, + isDate, + isDirectory, + isNumber, +} from '#validators'; + +import { + parseAdditionalFiles, + parseAdditionalNames, + parseAnnotatedReferences, + parseArtwork, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, + parseWallpaperParts, +} from '#yaml'; + +import {withRecontextualizedContributionList, withResolvedContribs} from '#composite/wiki-data'; import { - additionalFiles, - commentary, + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withFlattenedList, + withLengthOfList, + withNearbyItemFromList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { color, commentatorArtists, - contribsPresent, + constitutibleArtwork, + constitutibleArtworkList, + contentString, contributionList, dimensions, directory, fileExtension, flag, + hasArtwork, name, + referencedArtworkList, referenceList, simpleDate, simpleString, - singleReference, + soupyFind, + soupyReverse, + thing, + thingList, urls, + wallpaperParts, wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; -import {withAlbum} from '#composite/things/track-section'; - export class Album extends Thing { static [Thing.referenceType] = 'album'; + static [Thing.wikiData] = 'albumData'; + + static [Thing.constitutibleProperties] = [ + 'coverArtworks', + 'wallpaperArtwork', + 'bannerArtwork', + ]; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, ArtTag, - Artist, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, Group, - Track, TrackSection, + WikiInfo, }) => ({ - // Update & expose + // > Update & expose - Internal relationships - name: name('Unnamed Album'), - color: color(), + trackSections: thingList(V(TrackSection)), + + // > Update & expose - Identifying metadata + + name: name(V('Unnamed Album')), directory: directory(), - urls: urls(), - alwaysReferenceTracksByDirectory: flag(false), + directorySuffix: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDirectory), + }), + + exposeDependency('directory'), + ], + + alwaysReferenceByDirectory: flag(V(false)), + alwaysReferenceTracksByDirectory: flag(V(false)), + suffixTrackDirectories: flag(V(false)), + + style: [ + exposeUpdateValueOrContinue({ + validate: input.value(is(...[ + 'album', + 'single', + ])), + }), + + exposeConstant(V('album')), + ], bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), + additionalNames: thingList(V(AdditionalName)), + date: simpleDate(), - trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), + // > Update & expose - Credits and contributors + + artistContribs: contributionList({ + artistProperty: input.value('albumArtistContributions'), + }), + + trackArtistText: contentString(), + + trackArtistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', + }), + + exposeDependencyOrContinue('#trackArtistContribs', V('empty')), + + withRecontextualizedContributionList('artistContribs', { + artistProperty: input.value('albumTrackArtistContributions'), + }), + + exposeDependency('#artistContribs'), + ], + + // > Update & expose - General configuration + + countTracksInArtistTotals: flag(V(true)), + + showAlbumInTracksWithoutArtists: flag(V(false)), + + hasTrackNumbers: flag(V(true)), + isListedOnHomepage: flag(V(true)), + isListedInGalleries: flag(V(true)), + + hideDuration: flag(V(false)), + + // > Update & expose - General metadata + + color: color(), + + urls: urls(), + + // > Update & expose - Artworks + + coverArtworks: [ + exitWithoutDependency('hasCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + ], + + coverArtistContribs: contributionList({ + date: 'coverArtDate', + artistProperty: input.value('albumCoverArtistContributions'), + }), + coverArtDate: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), + exitWithoutDependency('hasCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), exposeUpdateValueOrContinue({ validate: input.value(isDate), }), - exposeDependency({dependency: 'date'}), + exposeDependency('date'), ], coverArtFileExtension: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - fileExtension('jpg'), + exitWithoutDependency('hasCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + fileExtension(V('jpg')), ], - trackCoverArtFileExtension: fileExtension('jpg'), + coverArtDimensions: [ + exitWithoutDependency('hasCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), - wallpaperFileExtension: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - fileExtension('jpg'), + dimensions(), ], - bannerFileExtension: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - fileExtension('jpg'), + artTags: [ + exitWithoutDependency('hasCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), ], - wallpaperStyle: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - simpleString(), + referencedArtworks: [ + exitWithoutDependency('hasCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referencedArtworkList(), ], - bannerStyle: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - simpleString(), + trackCoverArtistContribs: contributionList({ + // May be null, indicating cover art was added for tracks on the date + // each track specifies, or else the track's own release date. + date: 'trackArtDate', + + // This is the "correct" value, but it gets overwritten - with the same + // value - regardless. + artistProperty: input.value('trackCoverArtistContributions'), + }), + + trackArtDate: simpleDate(), + + trackCoverArtFileExtension: fileExtension(V('jpg')), + + trackDimensions: dimensions(), + + wallpaperArtwork: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), ], - coverArtDimensions: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - dimensions(), + wallpaperArtistContribs: contributionList({ + date: 'coverArtDate', + artistProperty: input.value('albumWallpaperArtistContributions'), + }), + + wallpaperFileExtension: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + fileExtension(V('jpg')), ], - bannerDimensions: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - dimensions(), + wallpaperStyle: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + simpleString(), ], - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), + wallpaperParts: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value([]), + mode: input.value('falsy'), + }), - commentary: commentary(), - additionalFiles: additionalFiles(), + wallpaperParts(), + ], - trackSections: referenceList({ - referenceType: input.value('unqualified-track-section'), - data: 'ownTrackSectionData', - find: input.value(find.unqualifiedTrackSection), - }), + bannerArtwork: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), + }), - artistContribs: contributionList(), - coverArtistContribs: contributionList(), - trackCoverArtistContribs: contributionList(), - wallpaperArtistContribs: contributionList(), - bannerArtistContribs: contributionList(), + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], - groups: referenceList({ - class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + bannerArtistContribs: contributionList({ + date: 'coverArtDate', + artistProperty: input.value('albumBannerArtistContributions'), }), - artTags: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), + bannerFileExtension: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), - referenceList({ - class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + fileExtension(V('jpg')), + ], + + bannerDimensions: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), + + dimensions(), ], - // Update only + bannerStyle: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), + }), - artistData: wikiData({ - class: input.value(Artist), - }), + simpleString(), + ], - artTagData: wikiData({ - class: input.value(ArtTag), - }), + // > Update & expose - Groups - groupData: wikiData({ + groups: referenceList({ class: input.value(Group), + find: soupyFind.input('group'), }), - ownTrackSectionData: wikiData({ - class: input.value(TrackSection), - }), + // > Update & expose - Content entries - // Expose only + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + + // > Update & expose - Additional files + + additionalFiles: thingList(V(AdditionalFile)), + + // > 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 + + isAlbum: exposeConstant(V(true)), commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), - hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), - hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), + hasCoverArt: hasArtwork({ + contribs: '_coverArtistContribs', + artworks: '_coverArtworks', + }), + + hasWallpaperArt: hasArtwork({ + contribs: '_wallpaperArtistContribs', + artwork: '_wallpaperArtwork', + }), + + hasBannerArt: hasArtwork({ + contribs: '_bannerArtistContribs', + artwork: '_bannerArtwork', + }), tracks: [ - withTracks(), - exposeDependency({dependency: '#tracks'}), + exitWithoutDependency('trackSections', V([])), + + withPropertyFromList('trackSections', V('tracks')), + withFlattenedList('#trackSections.tracks'), + exposeDependency('#flattenedList'), ], }); @@ -229,19 +451,127 @@ export class Album extends Thing { static [Thing.findSpecs] = { album: { - referenceTypes: ['album', 'album-commentary', 'album-gallery'], + referenceTypes: [ + 'album', + 'album-commentary', + 'album-gallery', + ], + + bindTo: 'albumData', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumSinglesOnly: { + referencing: ['album'], + + bindTo: 'albumData', + + incldue: album => + album.style === 'single', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumWithArtwork: { + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + + bindTo: 'albumData', + + include: album => + album.hasCoverArt, + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork, {Artwork, Album}) => + artwork instanceof Artwork && + artwork.thing instanceof Album && + artwork === artwork.thing.coverArtworks[0], + + getMatchableNames: ({thing: album}) => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + + getMatchableDirectories: ({thing: album}) => + [album.directory], + }, + }; + + static [Thing.reverseSpecs] = { + albumsWhoseArtworksFeature: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.artTags, + }, + + albumsWhoseGroupsInclude: { bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.groups, + }, + + albumArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'artistContribs'), + + albumTrackArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'trackArtistContribs'), + + albumCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), + + albumWallpaperArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}), + + albumBannerArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}), + + albumsWithCommentaryBy: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.commentatorArtists, }, }; static [Thing.yamlDocumentSpec] = { fields: { + // Identifying metadata + 'Album': {property: 'name'}, 'Directory': {property: 'directory'}, - - 'Always Reference Tracks By Directory': { - property: 'alwaysReferenceTracksByDirectory', - }, + 'Directory Suffix': {property: 'directorySuffix'}, + 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'}, + 'Style': {property: 'style'}, 'Bandcamp Album ID': { property: 'bandcampAlbumIdentifier', @@ -253,38 +583,131 @@ export class Album extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + 'Date': { property: 'date', transform: parseDate, }, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + // Credits and contributors + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Track Artist Text': { + property: 'trackArtistText', + }, + + 'Track Artists': { + property: 'trackArtistContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Show Album In Tracks Without Artists': { + property: 'showAlbumInTracksWithoutArtists', + }, 'Has Track Numbers': {property: 'hasTrackNumbers'}, 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + 'Hide Duration': {property: 'hideDuration'}, + + // General metadata + + 'Color': {property: 'color'}, + + 'URLs': {property: 'urls'}, + + // Artworks + // (Note - this YAML section is deliberately ordered differently + // than the corresponding property descriptors.) + + 'Cover Artwork': { + property: 'coverArtworks', + transform: + parseArtwork({ + thingProperty: 'coverArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'albumCoverArtistContributions', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + }), + }, + + 'Banner Artwork': { + property: 'bannerArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'bannerArtwork', + dimensionsFromThingProperty: 'bannerDimensions', + fileExtensionFromThingProperty: 'bannerFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'bannerArtistContribs', + artistContribsArtistProperty: 'albumBannerArtistContributions', + }), + }, + + 'Wallpaper Artwork': { + property: 'wallpaperArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'wallpaperArtwork', + dimensionsFromThingProperty: null, + fileExtensionFromThingProperty: 'wallpaperFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'wallpaperArtistContribs', + artistContribsArtistProperty: 'albumWallpaperArtistContributions', + }), + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + 'Cover Art Date': { property: 'coverArtDate', transform: parseDate, }, - 'Default Track Cover Art Date': { - property: 'trackArtDate', - transform: parseDate, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, }, - 'Date Added': { - property: 'dateAddedToWiki', - transform: parseDate, + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, }, - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, + }, - 'Cover Art Dimensions': { - property: 'coverArtDimensions', + 'Default Track Dimensions': { + property: 'trackDimensions', transform: parseDimensions, }, @@ -294,55 +717,96 @@ export class Album extends Thing { }, 'Wallpaper Style': {property: 'wallpaperStyle'}, - 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + + 'Wallpaper Parts': { + property: 'wallpaperParts', + transform: parseWallpaperParts, + }, 'Banner Artists': { property: 'bannerArtistContribs', transform: parseContributors, }, - 'Banner Style': {property: 'bannerStyle'}, - 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Banner Dimensions': { property: 'bannerDimensions', transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, + 'Banner Style': {property: 'bannerStyle'}, - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, + + 'Art Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, }, - 'Franchises': {ignore: true}, + // Groups - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Groups': {property: 'groups'}, + + // Content entries + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Default Track Cover Artists': { - property: 'trackCoverArtistContribs', - transform: parseContributors, + // Additional files + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, }, - 'Groups': {property: 'groups'}, - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, + + invalidFieldCombinations: [ + {message: `Move commentary on singles to the track`, fields: [ + ['Style', 'single'], + 'Commentary', + ]}, + + {message: `Move crediting sources on singles to the track`, fields: [ + ['Style', 'single'], + 'Crediting Sources', + ]}, + + {message: `Move additional names on singles to the track`, fields: [ + ['Style', 'single'], + 'Additional Names', + ]}, + + {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [ + 'Wallpaper Parts', + 'Wallpaper Style', + ]}, + + {message: `Wallpaper file extensions are specified on asset, per part`, fields: [ + 'Wallpaper Parts', + 'Wallpaper File Extension', + ]}, + ], }; static [Thing.getYamlLoadingSpec] = ({ documentModes: {headerAndEntries}, - thingConstructors: {Album, Track, TrackSectionHelper}, + thingConstructors: {Album, Track}, }) => ({ title: `Process album files`, @@ -359,74 +823,48 @@ export class Album extends Thing { ? TrackSection : Track), - save(results) { - const albumData = []; - const trackSectionData = []; - const trackData = []; - - for (const {header: album, entries} of results) { - const trackSections = []; - - let currentTrackSection = new TrackSection(); - let currentTrackSectionTracks = []; + connect({header: album, entries}) { + const trackSections = []; - Object.assign(currentTrackSection, { - name: `Default Track Section`, - isDefaultTrackSection: true, - }); + let currentTrackSection = new TrackSection(); + let currentTrackSectionTracks = []; - const albumRef = Thing.getReference(album); + Object.assign(currentTrackSection, { + name: `Default Track Section`, + isDefaultTrackSection: true, + }); - const closeCurrentTrackSection = () => { - if ( - currentTrackSection.isDefaultTrackSection && - empty(currentTrackSectionTracks) - ) { - return; - } - - currentTrackSection.tracks = - currentTrackSectionTracks - .map(track => Thing.getReference(track)); - - currentTrackSection.ownTrackData = - currentTrackSectionTracks; - - currentTrackSection.ownAlbumData = - [album]; - - trackSections.push(currentTrackSection); - trackSectionData.push(currentTrackSection); - }; + const closeCurrentTrackSection = () => { + if ( + currentTrackSection.isDefaultTrackSection && + empty(currentTrackSectionTracks) + ) { + return; + } - for (const entry of entries) { - if (entry instanceof TrackSection) { - closeCurrentTrackSection(); - currentTrackSection = entry; - currentTrackSectionTracks = []; - continue; - } + currentTrackSection.tracks = currentTrackSectionTracks; + currentTrackSection.album = album; - currentTrackSectionTracks.push(entry); - trackData.push(entry); + trackSections.push(currentTrackSection); + }; - entry.dataSourceAlbum = albumRef; + for (const entry of entries) { + if (entry instanceof TrackSection) { + closeCurrentTrackSection(); + currentTrackSection = entry; + currentTrackSectionTracks = []; + continue; } - closeCurrentTrackSection(); - - albumData.push(album); + entry.album = album; + entry.trackSection = currentTrackSection; - album.trackSections = - trackSections - .map(trackSection => - `unqualified-track-section:` + - trackSection.unqualifiedDirectory); - - album.ownTrackSectionData = trackSections; + currentTrackSectionTracks.push(entry); } - return {albumData, trackSectionData, trackData}; + closeCurrentTrackSection(); + + album.trackSections = trackSections; }, sort({albumData, trackData}) { @@ -434,124 +872,217 @@ export class Album extends Thing { sortAlbumsTracksChronologically(trackData); }, }); + + getOwnAdditionalFilePath(_file, filename) { + return [ + 'media.albumAdditionalFile', + this.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (artwork === this.bannerArtwork) { + return [ + 'media.albumBanner', + this.directory, + artwork.fileExtension, + ]; + } + + if (artwork === this.wallpaperArtwork) { + if (!empty(this.wallpaperParts)) { + return null; + } + + return [ + 'media.albumWallpaper', + this.directory, + artwork.fileExtension, + ]; + } + + // TODO: using trackCover here is obviously, badly wrong + // but we ought to refactor banners and wallpapers similarly + // (i.e. depend on those intrinsic artwork paths rather than + // accessing media.{albumBanner,albumWallpaper} from content + // or other code directly) + return [ + 'media.trackCover', + this.directory, + + (artwork.unqualifiedDirectory + ? 'cover-' + artwork.unqualifiedDirectory + : 'cover'), + + artwork.fileExtension, + ]; + } + + // As of writing, albums don't even have a `duration` property... + // so this function will never be called... but the message stands... + countOwnContributionInDurationTotals(_contrib) { + return false; + } } export class TrackSection extends Thing { static [Thing.friendlyName] = `Track Section`; static [Thing.referenceType] = `track-section`; + static [Thing.wikiData] = 'trackSectionData'; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Track}) => ({ // Update & expose - name: name('Unnamed Track Section'), + album: thing(V(Album)), + + name: name(V('Unnamed Track Section')), unqualifiedDirectory: directory(), + directorySuffix: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDirectory), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('directorySuffix'), + }), + + exposeDependency({dependency: '#album.directorySuffix'}), + ], + + suffixTrackDirectories: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('suffixTrackDirectories'), + }), + + exposeDependency({dependency: '#album.suffixTrackDirectories'}), + ], + color: [ exposeUpdateValueOrContinue({ validate: input.value(isColor), }), - withAlbum(), - withPropertyFromObject({ - object: '#album', + object: 'album', property: input.value('color'), }), exposeDependency({dependency: '#album.color'}), ], + hasTrackNumbers: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('album', V('hasTrackNumbers')), + exposeDependency('#album.hasTrackNumbers'), + ], + + startCountingFrom: [ + exposeUpdateValueOrContinue({ + validate: input.value(isNumber), + }), + + withPropertyFromObject('album', V('hasTrackNumbers')), + exitWithoutDependency('#album.hasTrackNumbers', V(1), V('falsy')), + + withPropertyFromObject('album', V('trackSections')), + + withNearbyItemFromList({ + list: '#album.trackSections', + item: input.myself(), + offset: input.value(-1), + }).outputs({ + '#nearbyItem': '#previousTrackSection', + }), + + exitWithoutDependency('#previousTrackSection', V(1)), + + withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')), + exposeDependency('#previousTrackSection.continueCountingFrom'), + ], + dateOriginallyReleased: simpleDate(), - isDefaultTrackSection: flag(false), + countTracksInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('countTracksInArtistTotals'), + }), - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), ], - tracks: referenceList({ - class: input.value(Track), - data: 'ownTrackData', - find: input.value(find.track), - }), + isDefaultTrackSection: flag(V(false)), - // Update only + description: contentString(), - ownAlbumData: wikiData({ - class: input.value(Album), - }), + tracks: thingList(V(Track)), - ownTrackData: wikiData({ - class: input.value(Track), - }), + // Update only + + reverse: soupyReverse(), // Expose only - directory: [ - withAlbum(), + isTrackSection: [ + exposeConstant({ + value: input.value(true), + }), + ], + directory: [ exitWithoutDependency({ - dependency: '#album', + dependency: 'album', }), withPropertyFromObject({ - object: '#album', + object: 'album', property: input.value('directory'), }), - withDirectory({ - directory: 'unqualifiedDirectory', - }).outputs({ - '#directory': '#unqualifiedDirectory', - }), - { - dependencies: ['#album.directory', '#unqualifiedDirectory'], + dependencies: ['#album.directory', 'unqualifiedDirectory'], compute: ({ ['#album.directory']: albumDirectory, - ['#unqualifiedDirectory']: unqualifiedDirectory, + ['unqualifiedDirectory']: unqualifiedDirectory, }) => albumDirectory + '/' + unqualifiedDirectory, }, ], - startIndex: [ - withAlbum(), - - withPropertyFromObject({ - object: '#album', - property: input.value('trackSections'), - }), + continueCountingFrom: [ + withPropertyFromObject('album', V('hasTrackNumbers')), + exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')), { - dependencies: ['#album.trackSections', input.myself()], - compute: (continuation, { - ['#album.trackSections']: trackSections, - [input.myself()]: myself, - }) => continuation({ - ['#index']: - trackSections.indexOf(myself), - }), + dependencies: ['hasTrackNumbers', 'startCountingFrom'], + compute: (continuation, {hasTrackNumbers, startCountingFrom}) => + (hasTrackNumbers + ? continuation() + : continuation.exit(startCountingFrom)), }, - exitWithoutDependency({ - dependency: '#index', - mode: input.value('index'), - value: input.value(0), - }), + withLengthOfList('tracks'), { - dependencies: ['#album.trackSections', '#index'], - compute: ({ - ['#album.trackSections']: trackSections, - ['#index']: index, - }) => - accumulateSum( - trackSections - .slice(0, index) - .map(section => section.tracks.length)), + dependencies: ['startCountingFrom', '#tracks.length'], + compute: ({startCountingFrom, '#tracks.length': tracks}) => + startCountingFrom + tracks, }, ], }); @@ -573,12 +1104,21 @@ export class TrackSection extends Thing { static [Thing.yamlDocumentSpec] = { fields: { 'Section': {property: 'name'}, + 'Directory Suffix': {property: 'directorySuffix'}, + 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Color': {property: 'color'}, + 'Has Track Numbers': {property: 'hasTrackNumbers'}, + 'Start Counting From': {property: 'startCountingFrom'}, 'Date Originally Released': { property: 'dateOriginallyReleased', transform: parseDate, }, + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Description': {property: 'description'}, }, }; @@ -587,40 +1127,38 @@ export class TrackSection extends Thing { parts.push(Thing.prototype[inspect.custom].apply(this)); - if (depth >= 0) { + if (depth >= 0) showAlbum: { let album = null; try { album = this.album; - } catch {} + } catch { + break showAlbum; + } let first = null; try { - first = this.startIndex; + first = this.tracks.at(0).trackNumber; } catch {} - let length = null; + let last = null; try { - length = this.tracks.length; + last = this.tracks.at(-1).trackNumber; } catch {} - album ??= CacheableObject.getUpdateValue(this, 'ownAlbumData')?.[0]; + const albumName = album.name; + const albumIndex = album.trackSections.indexOf(this); - if (album) { - const albumName = album.name; - const albumIndex = album.trackSections.indexOf(this); + const num = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); - const num = - (albumIndex === -1 - ? 'indeterminate position' - : `#${albumIndex + 1}`); + const range = + (albumIndex >= 0 && first !== null && last !== null + ? `: ${first}-${last}` + : ''); - const range = - (albumIndex >= 0 && first !== null && length !== null - ? `: ${first + 1}-${first + length + 1}` - : ''); - - parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`); - } + parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`); } return parts.join(''); diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 3149b310..f4fedf49 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,31 +1,51 @@ +export const DATA_ART_TAGS_DIRECTORY = 'art-tags'; export const ART_TAG_DATA_FILE = 'tags.yaml'; -import {input} from '#composite'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +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 {exposeUpdateValueOrContinue} from '#composite/control-flow'; +import { + exitWithoutDependency, + exposeConstant, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { + annotatedReferenceList, color, + contentString, directory, flag, + referenceList, + reverseReferenceList, name, - wikiData, + 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] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose - name: name('Unnamed Art Tag'), + name: name(V('Unnamed Art Tag')), directory: directory(), color: color(), - isContentWarning: flag(false), + isContentWarning: flag(V(false)), + extraReadingURLs: urls(), nameShort: [ exposeUpdateValueOrContinue({ @@ -39,30 +59,96 @@ export class ArtTag extends Thing { }, ], - // Update only + additionalNames: thingList(V(AdditionalName)), - albumData: wikiData({ - class: input.value(Album), + description: contentString(), + + directDescendantArtTags: referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), }), - trackData: wikiData({ - class: input.value(Track), + 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 - taggedInThings: { - flags: {expose: true}, + isArtTag: exposeConstant(V(true)), - expose: { - dependencies: ['this', 'albumData', 'trackData'], - compute: ({this: artTag, albumData, trackData}) => - sortAlbumsTracksChronologically( - [...albumData, ...trackData] - .filter(({artTags}) => artTags.includes(artTag)), - {getDate: thing => thing.coverArtDate ?? thing.date}), + descriptionShort: [ + exitWithoutDependency('description', { + value: input.value(null), + mode: input.value('falsy'), + }), + + { + dependencies: ['description'], + compute: ({description}) => + description.split('<hr class="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] = { @@ -70,10 +156,19 @@ export class ArtTag extends Thing { referenceTypes: ['tag'], bindTo: 'artTagData', - getMatchableNames: tag => - (tag.isContentWarning - ? [`cw: ${tag.name}`] - : [tag.name]), + getMatchableNames: artTag => + (artTag.isContentWarning + ? [`cw: ${artTag.name}`] + : [artTag.name]), + }, + }; + + static [Thing.reverseSpecs] = { + artTagsWhichDirectlyAncestor: { + bindTo: 'artTagData', + + referencing: artTag => [artTag], + referenced: artTag => artTag.directDescendantArtTags, }, }; @@ -82,23 +177,51 @@ export class ArtTag extends Thing { '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: {allInOne}, + documentModes: {allTogether}, thingConstructors: {ArtTag}, }) => ({ title: `Process art tags file`, - file: ART_TAG_DATA_FILE, - documentMode: allInOne, - documentThing: ArtTag, + files: dataPath => + Promise.allSettled([ + readFile(path.join(dataPath, ART_TAG_DATA_FILE)) + .then(() => [ART_TAG_DATA_FILE]), - save: (results) => ({artTagData: results}), + 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 index 841d652f..a2ed0b74 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -4,227 +4,202 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; -import {input} from '#composite'; -import find from '#find'; -import {sortAlphabetically} from '#sort'; -import {stitchArrays, unique} from '#sugar'; +import {input, V} from '#composite'; import Thing from '#thing'; -import {isName, validateArrayItems} from '#validators'; -import {getKebabCase} from '#wiki-data'; +import {parseArtistAliases, parseArtwork} from '#yaml'; -import {withReverseContributionList} from '#composite/wiki-data'; +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, - reverseContributionList, reverseReferenceList, - singleReference, + soupyFind, + soupyReverse, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Artist extends Thing { static [Thing.referenceType] = 'artist'; - static [Thing.wikiDataArray] = 'artistData'; + static [Thing.wikiData] = 'artistData'; + + static [Thing.constitutibleProperties] = [ + 'avatarArtwork', // from inline fields + ]; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: name('Unnamed Artist'), + name: name(V('Unnamed Artist')), directory: directory(), urls: urls(), contextNotes: contentString(), - hasAvatar: flag(false), - avatarFileExtension: fileExtension('jpg'), + hasAvatar: flag(V(false)), + avatarFileExtension: fileExtension(V('jpg')), - aliasNames: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isName)}, - expose: {transform: (names) => names ?? []}, - }, + avatarArtwork: [ + exitWithoutDependency('hasAvatar', { + value: input.value(null), + mode: input.value('falsy'), + }), - isAlias: flag(), + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Avatar Artwork'), + ], - aliasedArtist: singleReference({ - class: input.value(Artist), - find: input.value(find.artist), - data: 'artistData', - }), + isAlias: flag(V(false)), + artistAliases: thingList(V(Artist)), + aliasedArtist: thing(V(Artist)), // Update only - albumData: wikiData({ - class: input.value(Album), - }), + find: soupyFind(), + reverse: soupyReverse(), - artistData: wikiData({ - class: input.value(Artist), - }), + // Expose only - flashData: wikiData({ - class: input.value(Flash), - }), + isArtist: exposeConstant(V(true)), - trackData: wikiData({ - class: input.value(Track), + trackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }), - // Expose only - - tracksAsArtist: reverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + trackContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }), - tracksAsContributor: reverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + trackCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), }), - tracksAsCoverArtist: reverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), + tracksAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('tracksWithCommentaryBy'), }), - tracksAsAny: [ - withReverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsArtist', - }), - - withReverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsContributor', - }), - - withReverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsCoverArtist', - }), + albumArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumArtistContributionsBy'), + }), - { - dependencies: [ - '#tracksAsArtist', - '#tracksAsContributor', - '#tracksAsCoverArtist', - ], + albumTrackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumTrackArtistContributionsBy'), + }), - compute: ({ - ['#tracksAsArtist']: tracksAsArtist, - ['#tracksAsContributor']: tracksAsContributor, - ['#tracksAsCoverArtist']: tracksAsCoverArtist, - }) => - unique([ - ...tracksAsArtist, - ...tracksAsContributor, - ...tracksAsCoverArtist, - ]), - }, - ], + albumCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), + }), - tracksAsCommentator: reverseReferenceList({ - data: 'trackData', - list: input.value('commentatorArtists'), + albumWallpaperArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), }), - albumsAsAlbumArtist: reverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), + albumBannerArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), }), - albumsAsCoverArtist: reverseContributionList({ - data: 'albumData', - list: input.value('coverArtistContribs'), + albumsAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('albumsWithCommentaryBy'), }), - albumsAsWallpaperArtist: reverseContributionList({ - data: 'albumData', - list: input.value('wallpaperArtistContribs'), + flashContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('flashContributorContributionsBy'), }), - albumsAsBannerArtist: reverseContributionList({ - data: 'albumData', - list: input.value('bannerArtistContribs'), + flashesAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('flashesWithCommentaryBy'), }), - albumsAsAny: [ - withReverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsArtist', - }), + closelyLinkedGroups: reverseReferenceList({ + reverse: soupyReverse.input('groupsCloselyLinkedTo'), + }), - withReverseContributionList({ - data: 'albumData', - list: input.value('coverArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsCoverArtist', - }), + musicContributions: [ + { + dependencies: [ + 'trackArtistContributions', + 'trackContributorContributions', + ], - withReverseContributionList({ - data: 'albumData', - list: input.value('wallpaperArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsWallpaperArtist', - }), + compute: (continuation, { + trackArtistContributions, + trackContributorContributions, + }) => continuation({ + ['#contributions']: [ + ...trackArtistContributions, + ...trackContributorContributions, + ], + }), + }, - withReverseContributionList({ - data: 'albumData', - list: input.value('bannerArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsBannerArtist', - }), + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortAlbumsTracksChronologically), + }, + ], + artworkContributions: [ { dependencies: [ - '#albumsAsArtist', - '#albumsAsCoverArtist', - '#albumsAsWallpaperArtist', - '#albumsAsBannerArtist', + 'trackCoverArtistContributions', + 'albumCoverArtistContributions', + 'albumWallpaperArtistContributions', + 'albumBannerArtistContributions', ], - compute: ({ - ['#albumsAsArtist']: albumsAsArtist, - ['#albumsAsCoverArtist']: albumsAsCoverArtist, - ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist, - ['#albumsAsBannerArtist']: albumsAsBannerArtist, - }) => - unique([ - ...albumsAsArtist, - ...albumsAsCoverArtist, - ...albumsAsWallpaperArtist, - ...albumsAsBannerArtist, - ]), + compute: (continuation, { + trackCoverArtistContributions, + albumCoverArtistContributions, + albumWallpaperArtistContributions, + albumBannerArtistContributions, + }) => continuation({ + ['#contributions']: [ + ...trackCoverArtistContributions, + ...albumCoverArtistContributions, + ...albumWallpaperArtistContributions, + ...albumBannerArtistContributions, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortArtworksChronologically), }, ], - albumsAsCommentator: reverseReferenceList({ - data: 'albumData', - list: input.value('commentatorArtists'), - }), + totalDuration: [ + withPropertyFromList('musicContributions', V('thing')), + withPropertyFromList('#musicContributions.thing', V('isMainRelease')), - flashesAsContributor: reverseContributionList({ - data: 'flashData', - list: input.value('contributorContribs'), - }), + withFilteredList('musicContributions', '#musicContributions.thing.isMainRelease') + .outputs({'#filteredList': '#mainReleaseContributions'}), - flashesAsCommentator: reverseReferenceList({ - data: 'flashData', - list: input.value('commentatorArtists'), - }), + withContributionListSums('#mainReleaseContributions'), + exposeDependency('#contributionListDuration'), + ], }); static [Thing.getSerializeDescriptors] = ({ @@ -238,20 +213,8 @@ export class Artist extends Thing { hasAvatar: S.id, avatarFileExtension: S.id, - aliasNames: S.id, - - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, tracksAsCommentator: S.toRefs, - - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, albumsAsCommentator: S.toRefs, - - flashesAsContributor: S.toRefs, }); static [Thing.findSpecs] = { @@ -280,17 +243,9 @@ export class Artist extends Thing { // in the original's alias list. This is honestly a bit awkward, but it // avoids artist aliases conflicting with each other when checking for // duplicate directories. - for (const aliasName of originalArtist.aliasNames) { - // These are trouble. We should be accessing aliases' directories - // directly, but artists currently don't expose a reverse reference - // list for aliases. (This is pending a cleanup of "reverse reference" - // behavior in general.) It doesn't actually cause problems *here* - // because alias directories are computed from their names 100% of the - // time, but that *is* an assumption this code makes. - if (aliasName === artist.name) continue; - if (artist.directory === getKebabCase(aliasName)) { - return []; - } + 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 @@ -316,10 +271,24 @@ export class Artist extends Thing { '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: 'aliasNames'}, + 'Aliases': { + property: 'artistAliases', + transform: parseArtistAliases, + }, 'Dead URLs': {ignore: true}, @@ -337,33 +306,6 @@ export class Artist extends Thing { documentMode: allInOne, documentThing: Artist, - save(results) { - const artists = results; - - const artistRefs = - artists.map(artist => Thing.getReference(artist)); - - const artistAliasNames = - artists.map(artist => artist.aliasNames); - - const artistAliases = - stitchArrays({ - originalArtistRef: artistRefs, - aliasNames: artistAliasNames, - }).flatMap(({originalArtistRef, aliasNames}) => - aliasNames.map(name => { - const alias = new Artist(); - alias.name = name; - alias.isAlias = true; - alias.aliasedArtist = originalArtistRef; - return alias; - })); - - const artistData = [...artists, ...artistAliases]; - - return {artistData}; - }, - sort({artistData}) { sortAlphabetically(artistData); }, @@ -380,7 +322,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } @@ -389,4 +331,12 @@ export class Artist extends Thing { 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/content.js b/src/data/things/content.js new file mode 100644 index 00000000..64d03e69 --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,319 @@ +import {input, V} from '#composite'; +import {transposeArrays} from '#sugar'; +import Thing from '#thing'; +import {is, isDate, validateReferenceList} from '#validators'; +import {parseDate} from '#yaml'; + +import {withFilteredList, withMappedList, withPropertyFromList} + from '#composite/data'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {contentString, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + hasAnnotationPart, + withAnnotationPartNodeLists, + withExpressedOrImplicitArtistReferences, + withWebArchiveDate, +} from '#composite/things/content'; + +export class ContentEntry extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + artists: [ + withExpressedOrImplicitArtistReferences({ + from: input.updateValue({ + validate: validateReferenceList('artist'), + }), + }), + + exitWithoutDependency('#artistReferences', V([])), + + withResolvedReferenceList({ + list: '#artistReferences', + find: soupyFind.input('artist'), + }), + + exposeDependency('#resolvedReferenceList'), + ], + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exitWithoutDependency('_accessDate'), + + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({from: '#webArchiveDate'}), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant(V('accessed')), + ], + + date: simpleDate(), + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant(V(null)), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + + // Expose only + + isContentEntry: exposeConstant(V(true)), + + annotationParts: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstNodes']: + nodeLists.map(list => list.at(0)), + + ['#lastNodes']: + nodeLists.map(list => list.at(-1)), + }), + }, + + withPropertyFromList('#firstNodes', V('i')) + .outputs({'#firstNodes.i': '#startIndices'}), + + withPropertyFromList('#lastNodes', V('iEnd')) + .outputs({'#lastNodes.iEnd': '#endIndices'}), + + { + dependencies: [ + 'annotation', + '#startIndices', + '#endIndices', + ], + + compute: ({ + ['annotation']: annotation, + ['#startIndices']: startIndices, + ['#endIndices']: endIndices, + }) => + transposeArrays([startIndices, endIndices]) + .map(([start, end]) => + annotation.slice(start, end)), + }, + ], + + sourceText: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstPartWithExternalLink']: + nodeLists + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + exitWithoutDependency('#firstPartWithExternalLink'), + + { + dependencies: ['annotation', '#firstPartWithExternalLink'], + compute: ({ + ['annotation']: annotation, + ['#firstPartWithExternalLink']: nodes, + }) => + annotation.slice( + nodes.at(0).i, + nodes.at(-1).iEnd), + }, + ], + + sourceURLs: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstPartWithExternalLink']: + nodeLists + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + exitWithoutDependency('#firstPartWithExternalLink', V([])), + + withMappedList({ + list: '#firstPartWithExternalLink', + map: input.value(node => node.type === 'external-link'), + }).outputs({ + '#mappedList': '#externalLinkFilter', + }), + + withFilteredList({ + list: '#firstPartWithExternalLink', + filter: '#externalLinkFilter', + }), + + withMappedList({ + list: '#filteredList', + map: input.value(node => node.data.href), + }), + + exposeDependency('#mappedList'), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Artists': {property: 'artists'}, + 'Artist Text': {property: 'artistText'}, + + 'Annotation': {property: 'annotation'}, + + 'Date Kind': {property: 'dateKind'}, + 'Access Kind': {property: 'accessKind'}, + + 'Date': {property: 'date', transform: parseDate}, + 'Second Date': {property: 'secondDate', transform: parseDate}, + 'Access Date': {property: 'accessDate', transform: parseDate}, + + 'Body': {property: 'body'}, + }, + }; +} + +export class CommentaryEntry extends ContentEntry { + static [Thing.wikiData] = 'commentaryData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCommentaryEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + + isWikiEditorCommentary: hasAnnotationPart({ + part: input.value('wiki editor'), + }), + }); +} + +export class LyricsEntry extends ContentEntry { + static [Thing.wikiData] = 'lyricsData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isLyricsEntry: exposeConstant(V(true)), + + isWikiLyrics: hasAnnotationPart(V('wiki lyrics')), + helpNeeded: hasAnnotationPart(V('help needed')), + + hasSquareBracketAnnotations: [ + exitWithoutDependency('isWikiLyrics', V(false), V('falsy')), + exitWithoutDependency('body', V(false)), + + { + dependencies: ['body'], + compute: ({body}) => + /\[.*\]/m.test(body), + }, + ], + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); +} + +export class CreditingSourcesEntry extends ContentEntry { + static [Thing.wikiData] = 'creditingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCreditingSourcesEntry: exposeConstant(V(true)), + }); +} + +export class ReferencingSourcesEntry extends ContentEntry { + static [Thing.wikiData] = 'referencingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isReferencingSourceEntry: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js new file mode 100644 index 00000000..393a60b4 --- /dev/null +++ b/src/data/things/contribution.js @@ -0,0 +1,303 @@ +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: [ + inheritFromContributionPresets(), + + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant(V(true)), + ], + + countInDurationTotals: [ + inheritFromContributionPresets(), + + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + 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)), + + 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/flash.js b/src/data/things/flash.js index ceed79f7..b595ec58 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,48 +1,69 @@ export const FLASH_DATA_FILE = 'flashes.yaml'; -import {input} from '#composite'; -import find from '#find'; -import {empty} from '#sugar'; +import {input, V} from '#composite'; import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseDate, parseContributors} from '#yaml'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; import { exposeConstant, exposeDependency, - exposeDependencyOrContinue, exposeUpdateValueOrContinue, } from '#composite/control-flow'; import { color, - commentary, commentatorArtists, + constitutibleArtwork, contentString, contributionList, + dimensions, directory, fileExtension, name, referenceList, simpleDate, + soupyFind, + soupyReverse, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; -import {withFlashAct} from '#composite/things/flash'; -import {withFlashSide} from '#composite/things/flash-act'; - export class Flash extends Thing { static [Thing.referenceType] = 'flash'; - - static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ + static [Thing.wikiData] = 'flashData'; + + static [Thing.constitutibleProperties] = [ + 'coverArtwork', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({ + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, + FlashAct, + Track, + WikiInfo, + }) => ({ // Update & expose - name: name('Unnamed Flash'), + act: thing(V(FlashAct)), + + name: name(V('Unnamed Flash')), directory: { flags: {update: true, expose: true}, @@ -75,64 +96,53 @@ export class Flash extends Thing { validate: input.value(isColor), }), - withFlashAct(), - - withPropertyFromObject({ - object: '#flashAct', - property: input.value('color'), - }), - - exposeDependency({dependency: '#flashAct.color'}), + withPropertyFromObject('act', V('color')), + exposeDependency('#act.color'), ], date: simpleDate(), - coverArtFileExtension: fileExtension('jpg'), + coverArtFileExtension: fileExtension(V('jpg')), + + coverArtDimensions: dimensions(), + + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), - contributorContribs: contributionList(), + contributorContribs: contributionList({ + artistProperty: input.value('flashContributorContributions'), + }), featuredTracks: referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), }), urls: urls(), - commentary: commentary(), + additionalNames: thingList(V(AdditionalName)), + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), // Update only - artistData: wikiData({ - class: input.value(Artist), - }), + find: soupyFind(), + reverse: soupyReverse(), - trackData: wikiData({ - class: input.value(Track), - }), - - flashActData: wikiData({ - class: input.value(FlashAct), - }), + // used for withMatchingContributionPresets (indirectly by Contribution) + wikiInfo: thing(V(WikiInfo)), // Expose only - commentatorArtists: commentatorArtists(), + isFlash: exposeConstant(V(true)), - act: [ - withFlashAct(), - exposeDependency({dependency: '#flashAct'}), - ], + commentatorArtists: commentatorArtists(), side: [ - withFlashAct(), - - withPropertyFromObject({ - object: '#flashAct', - property: input.value('side'), - }), - - exposeDependency({dependency: '#flashAct.side'}), + withPropertyFromObject('act', V('side')), + exposeDependency('#act.side'), ], }); @@ -156,6 +166,25 @@ export class Flash extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashesWhichFeature: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.featuredTracks, + }, + + flashContributorContributionsBy: + soupyReverse.contributionsBy('flashData', 'contributorContribs'), + + flashesWithCommentaryBy: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.commentatorArtists, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Flash': {property: 'name'}, @@ -169,8 +198,29 @@ export class Flash extends Thing { transform: parseDate, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Cover Artwork': { + property: 'coverArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'coverArtwork', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dimensionsFromThingProperty: 'coverArtDimensions', + }), + }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + 'Featured Tracks': {property: 'featuredTracks'}, 'Contributors': { @@ -178,21 +228,40 @@ export class Flash extends Thing { transform: parseContributors, }, - 'Commentary': {property: 'commentary'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Review Points': {ignore: true}, }, }; + + getOwnArtworkPath(artwork) { + return [ + 'media.flashArt', + this.directory, + artwork.fileExtension, + ]; + } } export class FlashAct extends Thing { static [Thing.referenceType] = 'flash-act'; static [Thing.friendlyName] = `Flash Act`; + static [Thing.wikiData] = 'flashActData'; - static [Thing.getPropertyDescriptors] = () => ({ + static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({ // Update & expose - name: name('Unnamed Flash Act'), + side: thing(V(FlashSide)), + + name: name(V('Unnamed Flash Act')), directory: directory(), color: color(), @@ -201,44 +270,20 @@ export class FlashAct extends Thing { validate: input.value(isContentString), }), - withFlashSide(), - - withPropertyFromObject({ - object: '#flashSide', - property: input.value('listTerminology'), - }), - - exposeDependencyOrContinue({ - dependency: '#flashSide.listTerminology', - }), - - exposeConstant({ - value: input.value(null), - }), + withPropertyFromObject('side', V('listTerminology')), + exposeDependency('#side.listTerminology'), ], - flashes: referenceList({ - class: input.value(Flash), - find: input.value(find.flash), - data: 'flashData', - }), + flashes: thingList(V(Flash)), // Update only - flashData: wikiData({ - class: input.value(Flash), - }), - - flashSideData: wikiData({ - class: input.value(FlashSide), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only - side: [ - withFlashSide(), - exposeDependency({dependency: '#flashSide'}), - ], + isFlashAct: exposeConstant(V(true)), }); static [Thing.findSpecs] = { @@ -248,6 +293,15 @@ export class FlashAct extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashActsWhoseFlashesInclude: { + bindTo: 'flashActData', + + referencing: flashAct => [flashAct], + referenced: flashAct => flashAct.flashes, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Act': {property: 'name'}, @@ -264,26 +318,25 @@ export class FlashAct extends Thing { export class FlashSide extends Thing { static [Thing.referenceType] = 'flash-side'; static [Thing.friendlyName] = `Flash Side`; + static [Thing.wikiData] = 'flashSideData'; - static [Thing.getPropertyDescriptors] = () => ({ + static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({ // Update & expose - name: name('Unnamed Flash Side'), + name: name(V('Unnamed Flash Side')), directory: directory(), color: color(), listTerminology: contentString(), - acts: referenceList({ - class: input.value(FlashAct), - find: input.value(find.flashAct), - data: 'flashActData', - }), + acts: thingList(V(FlashAct)), // Update only - flashActData: wikiData({ - class: input.value(FlashAct), - }), + find: soupyFind(), + + // Expose only + + isFlashSide: exposeConstant(V(true)), }); static [Thing.yamlDocumentSpec] = { @@ -302,6 +355,15 @@ export class FlashSide extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashSidesWhoseActsInclude: { + bindTo: 'flashSideData', + + referencing: flashSide => [flashSide], + referenced: flashSide => flashSide.acts, + }, + }; + static [Thing.getYamlLoadingSpec] = ({ documentModes: {allInOne}, thingConstructors: {Flash, FlashAct}, @@ -317,50 +379,61 @@ export class FlashSide extends Thing { ? FlashAct : Flash), - save(results) { - // JavaScript likes you. + connect(results) { + let thing, i; - if (!empty(results) && !(results[0] instanceof FlashSide)) { - throw new Error(`Expected a side at top of flash data file`); - } + for (i = 0; thing = results[i]; i++) { + if (thing.isFlashSide) { + const side = thing; + const acts = []; - let index = 0; - let thing; - for (; thing = results[index]; index++) { - const flashSide = thing; - const flashActRefs = []; + for (i++; thing = results[i]; i++) { + if (thing.isFlashAct) { + const act = thing; + const flashes = []; - if (results[index + 1] instanceof Flash) { - throw new Error(`Expected an act to immediately follow a side`); - } + for (i++; thing = results[i]; i++) { + if (thing.isFlash) { + const flash = thing; + + flash.act = act; + flashes.push(flash); + + continue; + } - for ( - index++; - (thing = results[index]) && thing instanceof FlashAct; - index++ - ) { - const flashAct = thing; - const flashRefs = []; - for ( - index++; - (thing = results[index]) && thing instanceof Flash; - index++ - ) { - flashRefs.push(Thing.getReference(thing)); + i--; + break; + } + + act.side = side; + act.flashes = flashes; + acts.push(act); + + continue; + } + + if (thing.isFlash) { + throw new Error(`Flashes must be under an act`); + } + + i--; + break; } - index--; - flashAct.flashes = flashRefs; - flashActRefs.push(Thing.getReference(flashAct)); + + side.acts = acts; + + continue; } - index--; - flashSide.acts = flashActRefs; - } - const flashData = results.filter(x => x instanceof Flash); - const flashActData = results.filter(x => x instanceof FlashAct); - const flashSideData = results.filter(x => x instanceof FlashSide); + if (thing.isFlashAct) { + throw new Error(`Acts must be under a side`); + } - return {flashData, flashActData, flashSideData}; + if (thing.isFlash) { + throw new Error(`Flashes must be under a side and act`); + } + } }, sort({flashData}) { diff --git a/src/data/things/group.js b/src/data/things/group.js index 0dbbbb7f..cc5f4cb8 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,50 +1,94 @@ export const GROUP_DATA_FILE = 'groups.yaml'; -import {input} from '#composite'; -import find from '#find'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; import Thing from '#thing'; +import {is, isBoolean} from '#validators'; +import {parseAnnotatedReferences, parseSerieses} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; +import {withUniqueReferencingThing} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { + annotatedReferenceList, color, contentString, directory, + flag, name, referenceList, + soupyFind, + soupyReverse, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { static [Thing.referenceType] = 'group'; + static [Thing.wikiData] = 'groupData'; - static [Thing.getPropertyDescriptors] = ({Album}) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({ // Update & expose - name: name('Unnamed Group'), + name: name(V('Unnamed Group')), directory: directory(), + excludeFromGalleryTabs: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withUniqueReferencingThing({ + reverse: soupyReverse.input('groupCategoriesWhichInclude'), + }).outputs({ + '#uniqueReferencingThing': '#category', + }), + + withPropertyFromObject('#category', V('excludeGroupsFromGalleryTabs')), + exposeDependencyOrContinue('#category.excludeGroupsFromGalleryTabs'), + + exposeConstant(V(false)), + ], + + divideAlbumsByStyle: flag(V(false)), + description: contentString(), urls: urls(), + closelyLinkedArtists: annotatedReferenceList({ + class: input.value(Artist), + find: soupyFind.input('artist'), + + reference: input.value('artist'), + thing: input.value('artist'), + }), + featuredAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), - // Update only + serieses: thingList(V(Series)), - albumData: wikiData({ - class: input.value(Album), - }), + // Update only - groupCategoryData: wikiData({ - class: input.value(GroupCategory), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only + isGroup: exposeConstant(V(true)), + descriptionShort: { flags: {expose: true}, @@ -61,9 +105,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'albumData'], - compute: ({this: group, albumData}) => - albumData?.filter((album) => album.groups.includes(group)) ?? [], + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => + reverse.albumsWhoseGroupsInclude(group), }, }, @@ -71,9 +115,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?.color, }, }, @@ -82,9 +126,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) ?? + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?? null, }, }, @@ -97,15 +141,52 @@ export class Group extends Thing { }, }; + static [Thing.reverseSpecs] = { + groupsCloselyLinkedTo: { + bindTo: 'groupData', + + referencing: group => + group.closelyLinkedArtists + .map(({artist, ...referenceDetails}) => ({ + group, + artist, + referenceDetails, + })), + + referenced: ({artist}) => [artist], + + tidy: ({group, referenceDetails}) => + ({group, ...referenceDetails}), + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Group': {property: 'name'}, 'Directory': {property: 'directory'}, + + 'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'}, + 'Divide Albums By Style': {property: 'divideAlbumsByStyle'}, + 'Description': {property: 'description'}, 'URLs': {property: 'urls'}, + 'Closely Linked Artists': { + property: 'closelyLinkedArtists', + transform: value => + parseAnnotatedReferences(value, { + referenceField: 'Artist', + referenceProperty: 'artist', + }), + }, + 'Featured Albums': {property: 'featuredAlbums'}, + 'Series': { + property: 'serieses', + transform: parseSerieses, + }, + 'Review Points': {ignore: true}, }, }; @@ -123,7 +204,7 @@ export class Group extends Thing { ? GroupCategory : Group), - save(results) { + connect(results) { let groupCategory; let groupRefs = []; @@ -147,11 +228,6 @@ export class Group extends Thing { if (groupCategory) { Object.assign(groupCategory, {groups: groupRefs}); } - - const groupData = results.filter(x => x instanceof Group); - const groupCategoryData = results.filter(x => x instanceof GroupCategory); - - return {groupData, groupCategoryData}; }, // Groups aren't sorted at all, always preserving the order in the data @@ -163,32 +239,120 @@ export class Group extends Thing { export class GroupCategory extends Thing { static [Thing.referenceType] = 'group-category'; static [Thing.friendlyName] = `Group Category`; + static [Thing.wikiData] = 'groupCategoryData'; static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: name('Unnamed Group Category'), + name: name(V('Unnamed Group Category')), directory: directory(), + excludeGroupsFromGalleryTabs: flag(V(false)), + color: color(), groups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), // Update only - groupData: wikiData({ - class: input.value(Group), - }), + find: soupyFind(), + + // Expose only + + isGroupCategory: exposeConstant(V(true)), }); + static [Thing.reverseSpecs] = { + groupCategoriesWhichInclude: { + bindTo: 'groupCategoryData', + + referencing: groupCategory => [groupCategory], + referenced: groupCategory => groupCategory.groups, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Category': {property: 'name'}, + 'Color': {property: 'color'}, + + 'Exclude Groups From Gallery Tabs': { + property: 'excludeGroupsFromGalleryTabs', + }, + }, + }; +} + +export class Series extends Thing { + static [Thing.wikiData] = 'seriesData'; + + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name(V('Unnamed Series')), + + showAlbumArtists: { + flags: {update: true, expose: true}, + update: { + validate: + is('all', 'differing', 'none'), + }, + }, + + description: contentString(), + + group: thing(V(Group)), + + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Update only + + find: soupyFind(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + + 'Description': {property: 'description'}, + + 'Show Album Artists': {property: 'showAlbumArtists'}, + + 'Albums': {property: 'albums'}, }, }; + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (depth >= 0) showGroup: { + let group = null; + try { + group = this.group; + } catch { + break showGroup; + } + + const groupName = group.name; + const groupIndex = group.serieses.indexOf(this); + + const num = + (groupIndex === -1 + ? 'indeterminate position' + : `#${groupIndex + 1}`); + + parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`); + } + + return parts.join(''); + } } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 00d6aef5..c4dc2812 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,8 +1,11 @@ export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; -import {input} from '#composite'; -import find from '#find'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; import Thing from '#thing'; +import {empty} from '#sugar'; import { anyOf, @@ -11,19 +14,28 @@ import { isString, isStringNonEmpty, validateArrayItems, - validateInstanceOf, validateReference, } from '#validators'; -import {exposeDependency} from '#composite/control-flow'; +import {exposeConstant, exposeDependency} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; -import {color, contentString, name, referenceList, wikiData} - from '#composite/wiki-properties'; + +import { + color, + contentString, + name, + referenceList, + soupyFind, + thing, + thingList, +} from '#composite/wiki-properties'; export class HomepageLayout extends Thing { static [Thing.friendlyName] = `Homepage Layout`; + static [Thing.wikiData] = 'homepageLayout'; + static [Thing.oneInstancePerWiki] = true; - static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose sidebarContent: contentString(), @@ -31,15 +43,14 @@ export class HomepageLayout extends Thing { navbarLinks: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isStringNonEmpty)}, + expose: {transform: value => value ?? []}, }, - rows: { - flags: {update: true, expose: true}, + sections: thingList(V(HomepageLayoutSection)), - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), - }, - }, + // Expose only + + isHomepageLayout: exposeConstant(V(true)), }); static [Thing.yamlDocumentSpec] = { @@ -50,85 +61,220 @@ export class HomepageLayout extends Thing { 'Navbar Links': {property: 'navbarLinks'}, }, }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: { + HomepageLayout, + HomepageLayoutSection, + }, + }) => ({ + title: `Process homepage layout file`, + file: HOMEPAGE_LAYOUT_DATA_FILE, + + documentMode: allInOne, + documentThing: document => { + if (document['Homepage']) { + return HomepageLayout; + } + + if (document['Section']) { + return HomepageLayoutSection; + } + + if (document['Row']) { + switch (document['Row']) { + case 'actions': + return HomepageLayoutActionsRow; + case 'album carousel': + return HomepageLayoutAlbumCarouselRow; + case 'album grid': + return HomepageLayoutAlbumGridRow; + default: + throw new TypeError(`Unrecognized row type ${document['Row']}`); + } + } + + return null; + }, + + connect(results) { + if (!empty(results) && !(results[0] instanceof HomepageLayout)) { + throw new Error(`Expected 'Homepage' document at top of homepage layout file`); + } + + const homepageLayout = results[0]; + const sections = []; + + let currentSection = null; + let currentSectionRows = []; + + const closeCurrentSection = () => { + if (currentSection) { + for (const row of currentSectionRows) { + row.section = currentSection; + } + + currentSection.rows = currentSectionRows; + sections.push(currentSection); + + currentSection = null; + currentSectionRows = []; + } + }; + + for (const entry of results.slice(1)) { + if (entry instanceof HomepageLayout) { + throw new Error(`Expected only one 'Homepage' document in total`); + } else if (entry instanceof HomepageLayoutSection) { + closeCurrentSection(); + currentSection = entry; + } else if (entry instanceof HomepageLayoutRow) { + if (currentSection) { + currentSectionRows.push(entry); + } else { + throw new Error(`Expected a 'Section' document to add following rows into`); + } + } + } + + closeCurrentSection(); + + homepageLayout.sections = sections; + }, + }); +} + +export class HomepageLayoutSection extends Thing { + static [Thing.friendlyName] = `Homepage Section`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + // Update & expose + + name: name(V(`Unnamed Homepage Section`)), + + color: color(), + + rows: thingList(V(HomepageLayoutRow)), + + // Expose only + + isHomepageLayoutSection: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + }, + }; } export class HomepageLayoutRow extends Thing { static [Thing.friendlyName] = `Homepage Row`; - static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose - name: name('Unnamed Homepage Row'), + section: thing(V(HomepageLayoutSection)), + + // Update only + + find: soupyFind(), + + // Expose only + + isHomepageLayoutRow: exposeConstant(V(true)), type: { - flags: {update: true, expose: true}, + flags: {expose: true}, - update: { - validate() { + expose: { + compute() { throw new Error(`'type' property validator must be overridden`); }, }, }, + }); - color: color(), + static [Thing.yamlDocumentSpec] = { + fields: { + 'Row': {ignore: true}, + }, + }; - // Update only + [inspect.custom](depth) { + const parts = []; - // These wiki data arrays aren't necessarily used by every subclass, but - // to the convenience of providing these, the superclass accepts all wiki - // data arrays depended upon by any subclass. + parts.push(Thing.prototype[inspect.custom].apply(this)); - albumData: wikiData({ - class: input.value(Album), - }), + if (depth >= 0 && this.section) { + const sectionName = this.section.name; + const index = this.section.rows.indexOf(this); + const rowNum = + (index === -1 + ? 'indeterminate position' + : `#${index + 1}`); + parts.push(` (${colors.yellow(rowNum)} in ${colors.green(sectionName)})`); + } - groupData: wikiData({ - class: input.value(Group), - }), + return parts.join(''); + } +} + +export class HomepageLayoutActionsRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Actions Row`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, + + // Expose only + + isHomepageLayoutActionsRow: exposeConstant(V(true)), + type: exposeConstant(V('actions')), }); static [Thing.yamlDocumentSpec] = { fields: { - 'Row': {property: 'name'}, - 'Color': {property: 'color'}, - 'Type': {property: 'type'}, + 'Actions': {property: 'actionLinks'}, }, }; } -export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.friendlyName] = `Homepage Albums Row`; - - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ - ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), +export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Carousel Row`; + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ // Update & expose - type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } - - return true; - }, - }, - }, + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), - displayStyle: { - flags: {update: true, expose: true}, + // Expose only - update: { - validate: is('grid', 'carousel'), - }, + isHomepageLayoutAlbumCarouselRow: exposeConstant(V(true)), + type: exposeConstant(V('album carousel')), + }); - expose: { - transform: (displayStyle) => - displayStyle ?? 'grid', - }, + static [Thing.yamlDocumentSpec] = { + fields: { + 'Albums': {property: 'albums'}, }, + }; +} + +export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Grid Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + // Update & expose sourceGroup: [ { @@ -151,17 +297,15 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { withResolvedReference({ ref: input.updateValue(), - data: 'groupData', - find: input.value(find.group), + find: soupyFind.input('group'), }), - exposeDependency({dependency: '#resolvedReference'}), + exposeDependency('#resolvedReference'), ], sourceAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), countAlbumsFromGroup: { @@ -169,55 +313,17 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { update: {validate: isCountingNumber}, }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)}, - }, + // Expose only + + isHomepageLayoutAlbumGridRow: exposeConstant(V(true)), + type: exposeConstant(V('album grid')), }); - static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { + static [Thing.yamlDocumentSpec] = { fields: { - 'Display Style': {property: 'displayStyle'}, 'Group': {property: 'sourceGroup'}, 'Count': {property: 'countAlbumsFromGroup'}, 'Albums': {property: 'sourceAlbums'}, - 'Actions': {property: 'actionLinks'}, }, - }); - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {headerAndEntries}, // Kludge, see below - thingConstructors: { - HomepageLayout, - HomepageLayoutAlbumsRow, - }, - }) => ({ - title: `Process homepage layout file`, - - // Kludge: This benefits from the same headerAndEntries style messaging as - // albums and tracks (for example), but that document mode is designed to - // support multiple files, and only one is actually getting processed here. - files: [HOMEPAGE_LAYOUT_DATA_FILE], - - documentMode: headerAndEntries, - headerDocumentThing: HomepageLayout, - entryDocumentThing: document => { - switch (document['Type']) { - case 'albums': - return HomepageLayoutAlbumsRow; - default: - throw new TypeError(`No processDocument function for row type ${document['Type']}!`); - } - }, - - save(results) { - if (!results[0]) { - return; - } - - const {header: homepageLayout, entries: rows} = results[0]; - Object.assign(homepageLayout, {rows}); - return {homepageLayout}; - }, - }); + }; } diff --git a/src/data/things/index.js b/src/data/things/index.js index 4f87f492..09765fd2 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -6,29 +6,42 @@ import CacheableObject from '#cacheable-object'; import {logError} from '#cli'; import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; +import {empty} from '#sugar'; import Thing from '#thing'; +import * as additionalFileClasses from './additional-file.js'; +import * as additionalNameClasses from './additional-name.js'; import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; import * as artistClasses from './artist.js'; +import * as artworkClasses from './artwork.js'; +import * as contentClasses from './content.js'; +import * as contributionClasses from './contribution.js'; import * as flashClasses from './flash.js'; import * as groupClasses from './group.js'; import * as homepageLayoutClasses from './homepage-layout.js'; import * as languageClasses from './language.js'; import * as newsEntryClasses from './news-entry.js'; +import * as sortingRuleClasses from './sorting-rule.js'; import * as staticPageClasses from './static-page.js'; import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; const allClassLists = { + 'additional-file.js': additionalFileClasses, + 'additional-name.js': additionalNameClasses, 'album.js': albumClasses, 'art-tag.js': artTagClasses, 'artist.js': artistClasses, + 'artwork.js': artworkClasses, + 'content.js': contentClasses, + 'contribution.js': contributionClasses, 'flash.js': flashClasses, 'group.js': groupClasses, 'homepage-layout.js': homepageLayoutClasses, 'language.js': languageClasses, 'news-entry.js': newsEntryClasses, + 'sorting-rule.js': sortingRuleClasses, 'static-page.js': staticPageClasses, 'track.js': trackClasses, 'wiki-info.js': wikiInfoClasses, @@ -45,6 +58,7 @@ const __dirname = path.dirname( function niceShowAggregate(error, ...opts) { showAggregate(error, { pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), + showClasses: false, ...opts, }); } @@ -77,13 +91,39 @@ function errorDuplicateClassNames() { } function flattenClassLists() { + let remaining = []; for (const classes of Object.values(allClassLists)) { - for (const [name, constructor] of Object.entries(classes)) { + for (const constructor of Object.values(classes)) { if (typeof constructor !== 'function') continue; if (!(constructor.prototype instanceof Thing)) continue; - allClasses[name] = constructor; + remaining.push(constructor); } } + + let sorted = []; + while (true) { + if (sorted[0]) { + const superclass = Object.getPrototypeOf(sorted[0]); + if (superclass !== Thing) { + if (sorted.includes(superclass)) { + sorted.unshift(...sorted.splice(sorted.indexOf(superclass), 1)); + } else { + sorted.unshift(superclass); + } + continue; + } + } + + if (!empty(remaining)) { + sorted.unshift(remaining.shift()); + } else { + break; + } + } + + for (const constructor of sorted) { + allClasses[constructor.name] = constructor; + } } function descriptorAggregateHelper({ @@ -113,6 +153,15 @@ function descriptorAggregateHelper({ } catch (error) { niceShowAggregate(error); showFailedClasses(failedClasses); + + /* + if (error.errors) { + for (const sub of error.errors) { + console.error(sub); + } + } + */ + return false; } } @@ -142,10 +191,10 @@ function evaluatePropertyDescriptors() { } } - constructor[CacheableObject.propertyDescriptors] = { - ...constructor[CacheableObject.propertyDescriptors] ?? {}, - ...results, - }; + constructor[CacheableObject.propertyDescriptors] = + Object.create(constructor[CacheableObject.propertyDescriptors] ?? null); + + Object.assign(constructor[CacheableObject.propertyDescriptors], results); }, showFailedClasses(failedClasses) { @@ -175,16 +224,49 @@ function evaluateSerializeDescriptors() { }); } -if (!errorDuplicateClassNames()) - process.exit(1); +function finalizeYamlDocumentSpecs() { + return descriptorAggregateHelper({ + message: `Errors finalizing Thing YAML document specs`, -flattenClassLists(); + op(constructor) { + const superclass = Object.getPrototypeOf(constructor); + if ( + constructor[Thing.yamlDocumentSpec] && + superclass[Thing.yamlDocumentSpec] + ) { + constructor[Thing.yamlDocumentSpec] = + Thing.extendDocumentSpec(superclass, constructor[Thing.yamlDocumentSpec]); + } + }, + + showFailedClasses(failedClasses) { + logError`Failed to finalize YAML document specs for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +function finalizeCacheableObjectPrototypes() { + return descriptorAggregateHelper({ + message: `Errors finalizing Thing class prototypes`, + + op(constructor) { + constructor.finalizeCacheableObjectPrototype(); + }, -if (!evaluatePropertyDescriptors()) - process.exit(1); + showFailedClasses(failedClasses) { + logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +if (!errorDuplicateClassNames()) process.exit(1); + +flattenClassLists(); -if (!evaluateSerializeDescriptors()) - process.exit(1); +if (!evaluatePropertyDescriptors()) process.exit(1); +if (!evaluateSerializeDescriptors()) process.exit(1); +if (!finalizeYamlDocumentSpecs()) process.exit(1); +if (!finalizeCacheableObjectPrototypes()) process.exit(1); Object.assign(allClasses, {Thing}); diff --git a/src/data/things/language.js b/src/data/things/language.js index dbe1ff3d..7f3f43de 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,24 +1,25 @@ -import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; import {withAggregate} from '#aggregate'; -import CacheableObject from '#cacheable-object'; import {logWarn} from '#cli'; +import {input, V} from '#composite'; import * as html from '#html'; -import {empty} from '#sugar'; -import {isLanguageCode} from '#validators'; +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, - isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; -import {externalFunction, flag, name} from '#composite/wiki-properties'; - -export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; +import {exitWithoutDependency, exposeConstant} + from '#composite/control-flow'; +import {flag, name} from '#composite/wiki-properties'; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ @@ -34,7 +35,7 @@ export class Language extends Thing { // Human-readable name. This should be the language's own native name, not // localized to any other language. - name: name(`Unnamed 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 @@ -56,20 +57,29 @@ export class Language extends Thing { // with languages that are currently in development and not ready for // formal release, or which are just kept hidden as "experimental zones" // for wiki development or content testing. - hidden: flag(false), + hidden: flag(V(false)), // Mapping of translation keys to values (strings). Generally, don't // access this object directly - use methods instead. - strings: { - flags: {update: true, expose: true}, - update: {validate: (t) => typeof t === 'object'}, + strings: [ + { + dependencies: [ + input.updateValue({validate: isObject}), + 'inheritedStrings', + ], + + compute: (continuation, { + [input.updateValue()]: strings, + ['inheritedStrings']: inheritedStrings, + }) => + (strings && inheritedStrings + ? continuation() + : strings ?? inheritedStrings), + }, - expose: { + { dependencies: ['inheritedStrings', 'code'], transform(strings, {inheritedStrings, code}) { - if (!strings && !inheritedStrings) return null; - if (!inheritedStrings) return strings; - const validStrings = { ...inheritedStrings, ...strings, @@ -98,6 +108,7 @@ export class Language extends Thing { logWarn`- Missing options: ${missingOptionNames.join(', ')}`; if (!empty(misplacedOptionNames)) logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + validStrings[key] = inheritedStrings[key]; } } @@ -105,7 +116,7 @@ export class Language extends Thing { return validStrings; }, }, - }, + ], // May be provided to specify "default" strings, generally (but not // necessarily) inherited from another Language object. @@ -114,26 +125,22 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, - // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to util/external-links.js for info. - externalLinkSpec: { - flags: {update: true, expose: true}, - update: {validate: isExternalLinkSpec}, - }, - - // Update only + // Expose only - escapeHTML: externalFunction(), + isLanguage: exposeConstant(V(true)), - // Expose only + 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}, @@ -151,19 +158,16 @@ export class Language extends Thing { }, // TODO: This currently isn't used. Is it still needed? - strings_htmlEscaped: { - flags: {expose: true}, - expose: { - dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], - compute({strings, inheritedStrings, escapeHTML}) { - if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...inheritedStrings, ...strings}; - return Object.fromEntries( - Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) - ); - }, + strings_htmlEscaped: [ + exitWithoutDependency('strings'), + + { + dependencies: ['strings'], + compute: ({strings}) => + withEntries(strings, entries => entries + .map(([key, value]) => [key, html.escape(value)])), }, - }, + ], }); static #intlHelper (constructor, opts) { @@ -184,87 +188,136 @@ export class Language extends Thing { 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 = - (hasOptions ? args.slice(0, -1) : args) - .filter(Boolean) - .join('.'); + this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); + + const template = + this.#getStringTemplateFromFormedKey(key); const options = (hasOptions ? args.at(-1) : {}); - if (!this.strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } + 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]) => [ - name - .replace(/[A-Z]/g, '_$&') - .toUpperCase(), + constantCasify(name), value, ])); const output = this.#iterateOverTemplate({ - template: this.strings[key], - + template, match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { - if (optionsMap.has(optionName)) { - let optionValue; - - // We'll only need the option's value if we're going to use it as - // part of the formed output (see below). - if (!canceledForming) { - optionValue = optionsMap.get(optionName); - } - - // But we always have to delete expected options off the provided - // option map, since the leftovers are what will be used to tell - // which are misplaced. - optionsMap.delete(optionName); + if (!optionsMap.has(optionName)) { + missingOptionNames.add(optionName); - if (canceledForming) { - return undefined; - } else { - return optionValue; - } - } else { // We don't need to continue forming the output if we've hit a // missing option name, since the end result of this formatString // call will be a thrown error, and formed output won't be needed. - missingOptionNames.add(optionName); + // Return undefined 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); }, }); @@ -272,20 +325,73 @@ export class Language extends Thing { Array.from(optionsMap.keys()); withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + const names = set => Array.from(set).join(', '); + if (!empty(missingOptionNames)) { - const names = Array.from(missingOptionNames).join(`, `); - push(new Error(`Missing options: ${names}`)); + push(new Error( + `Missing options: ${names(missingOptionNames)}`)); + } + + if (!empty(valuelessOptionNames)) { + push(new Error( + `Valueless options: ${names(valuelessOptionNames)}`)); } if (!empty(misplacedOptionNames)) { - const names = Array.from(misplacedOptionNames).join(`, `); - push(new Error(`Unexpected options: ${names}`)); + 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, @@ -316,26 +422,22 @@ export class Language extends Thing { partInProgress += template.slice(lastIndex, match.index); - // Sanitize string arguments in particular. These are taken to come from - // (raw) data and may include special characters that aren't meant to be - // rendered as HTML markup. - const sanitizedInsertion = - this.#sanitizeValueForInsertion(insertion); - - if (typeof sanitizedInsertion === 'string') { - // Join consecutive strings together. - partInProgress += sanitizedInsertion; - } else if ( - sanitizedInsertion instanceof html.Tag && - sanitizedInsertion.contentOnly - ) { - // Collapse string-only tag contents onto the current string part. - partInProgress += sanitizedInsertion.toString(); - } else { - // Push the string part in progress, then the insertion as-is. - outputParts.push(partInProgress); - outputParts.push(sanitizedInsertion); + 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; @@ -367,14 +469,9 @@ export class Language extends Thing { // html.Tag objects - gets left as-is, preserving the value exactly as it's // provided. #sanitizeValueForInsertion(value) { - const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); - if (!escapeHTML) { - throw new Error(`escapeHTML unavailable`); - } - switch (typeof value) { case 'string': - return escapeHTML(value); + return html.escape(value); case 'number': case 'boolean': @@ -416,21 +513,84 @@ export class Language extends Thing { } 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}); @@ -468,6 +628,14 @@ export class Language extends Thing { 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); @@ -528,6 +696,12 @@ export class Language extends Thing { } 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'); } @@ -561,14 +735,15 @@ export class Language extends Thing { style = 'platform', context = 'generic', } = {}) { - if (!this.externalLinkSpec) { - throw new TypeError(`externalLinkSpec unavailable`); + // Null or undefined url is blank content. + if (url === null || url === undefined) { + return html.blank(); } isExternalLinkContext(context); if (style === 'all') { - return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { language: this, context, }); @@ -577,7 +752,7 @@ export class Language extends Thing { isExternalLinkStyle(style); const result = - getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, { language: this, context, }); @@ -589,16 +764,31 @@ export class Language extends Thing { } 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 ); @@ -612,6 +802,11 @@ export class Language extends Thing { } #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 @@ -673,10 +868,22 @@ export class Language extends Thing { // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB formatFileSize(bytes) { - if (!bytes) return ''; + // 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); - if (isNaN(bytes)) return ''; + + // Non-number bytes is blank content! Wow. + if (isNaN(bytes)) { + return html.blank(); + } const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; @@ -700,10 +907,62 @@ export class Language extends Thing { 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} = {}) { + 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) @@ -715,13 +974,14 @@ const countHelper = (stringKey, optionName = stringKey) => 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'), - countCoverArts: countHelper('coverArts'), countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), + countTimesFeatured: countHelper('timesFeatured'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43d1638e..bb35d11b 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,20 +1,23 @@ 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('Unnamed News Entry'), + name: name(V('Unnamed News Entry')), directory: directory(), date: simpleDate(), @@ -22,6 +25,8 @@ export class NewsEntry extends Thing { // Expose only + isNewsEntry: exposeConstant(V(true)), + contentShort: { flags: {expose: true}, @@ -64,8 +69,6 @@ export class NewsEntry extends Thing { documentMode: allInOne, documentThing: NewsEntry, - save: (results) => ({newsData: results}), - sort({newsData}) { sortChronologically(newsData, {latestFirst: true}); }, diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js new file mode 100644 index 00000000..101a4966 --- /dev/null +++ b/src/data/things/sorting-rule.js @@ -0,0 +1,396 @@ +export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; + +import {readFile, writeFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {V} from '#composite'; +import {chunkByProperties, compareArrays, unique} from '#sugar'; +import Thing from '#thing'; +import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators'; + +import { + compareCaseLessSensitive, + sortByDate, + sortByDirectory, + sortByName, +} from '#sort'; + +import { + documentModes, + flattenThingLayoutToDocumentOrder, + getThingLayoutForFilename, + reorderDocumentsInYAMLSourceText, +} from '#yaml'; + +import {exposeConstant} from '#composite/control-flow'; +import {flag} from '#composite/wiki-properties'; + +function isSelectFollowingEntry(value) { + isObject(value); + + const {length} = Object.keys(value); + if (length !== 1) { + throw new Error(`Expected object with 1 key, got ${length}`); + } + + return true; +} + +export class SortingRule extends Thing { + static [Thing.friendlyName] = `Sorting Rule`; + static [Thing.wikiData] = 'sortingRules'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + active: flag(V(true)), + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + // Expose only + + isSortingRule: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Message': {property: 'message'}, + 'Active': {property: 'active'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {DocumentSortingRule}, + }) => ({ + title: `Process sorting rules file`, + file: SORTING_RULE_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + (document['Sort Documents'] + ? DocumentSortingRule + : null), + }); + + check(opts) { + return this.constructor.check(this, opts); + } + + apply(opts) { + return this.constructor.apply(this, opts); + } + + static check(rule, opts) { + const result = this.apply(rule, {...opts, dry: true}); + if (!result) return true; + if (!result.changed) return true; + return false; + } + + static async apply(_rule, _opts) { + throw new Error(`Not implemented`); + } + + static async* applyAll(_rules, _opts) { + throw new Error(`Not implemented`); + } + + static async* go({dataPath, wikiData, dry}) { + const rules = wikiData.sortingRules; + const constructors = unique(rules.map(rule => rule.constructor)); + + for (const constructor of constructors) { + yield* constructor.applyAll( + rules + .filter(rule => rule.active) + .filter(rule => rule.constructor === constructor), + {dataPath, wikiData, dry}); + } + } +} + +export class ThingSortingRule extends SortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + properties: { + flags: {update: true, expose: true}, + update: { + validate: strictArrayOf(isStringNonEmpty), + }, + }, + + // Expose only + + isThingSortingRule: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'By Properties': {property: 'properties'}, + }, + }; + + sort(sortable) { + if (this.properties) { + for (const property of this.properties.toReversed()) { + const get = thing => thing[property]; + const lc = property.toLowerCase(); + + if (lc.endsWith('date')) { + sortByDate(sortable, {getDate: get}); + continue; + } + + if (lc.endsWith('directory')) { + sortByDirectory(sortable, {getDirectory: get}); + continue; + } + + if (lc.endsWith('name')) { + sortByName(sortable, {getName: get}); + continue; + } + + const values = sortable.map(get); + + if (values.every(v => typeof v === 'string')) { + sortable.sort((a, b) => + compareCaseLessSensitive(get(a), get(b))); + continue; + } + + if (values.every(v => typeof v === 'number')) { + sortable.sort((a, b) => get(a) - get(b)); + continue; + } + + sortable.sort((a, b) => + (get(a).toString() < get(b).toString() + ? -1 + : get(a).toString() > get(b).toString() + ? +1 + : 0)); + } + } + + return sortable; + } +} + +export class DocumentSortingRule extends ThingSortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + // TODO: glob :plead: + filename: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + + expose: { + dependencies: ['filename'], + transform: (value, {filename}) => + value ?? + `Sort ${filename}`, + }, + }, + + selectDocumentsFollowing: { + flags: {update: true, expose: true}, + + update: { + validate: + anyOf( + isSelectFollowingEntry, + strictArrayOf(isSelectFollowingEntry)), + }, + + compute: { + transform: value => + (Array.isArray(value) + ? value + : [value]), + }, + }, + + selectDocumentsUnder: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + // Expose only + + isDocumentSortingRule: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Sort Documents': {property: 'filename'}, + 'Select Documents Following': {property: 'selectDocumentsFollowing'}, + 'Select Documents Under': {property: 'selectDocumentsUnder'}, + }, + + invalidFieldCombinations: [ + {message: `Specify only one of these`, fields: [ + 'Select Documents Following', + 'Select Documents Under', + ]}, + ], + }; + + static async apply(rule, {wikiData, dataPath, dry}) { + const oldLayout = getThingLayoutForFilename(rule.filename, wikiData); + if (!oldLayout) return null; + + const newLayout = rule.#processLayout(oldLayout); + + const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout); + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + const changed = compareArrays(oldOrder, newOrder); + + if (dry) return {changed}; + + const realPath = + path.join( + dataPath, + rule.filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + + return {changed}; + } + + static async* applyAll(rules, {wikiData, dataPath, dry}) { + rules = rules + .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en')); + + for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) { + const initialLayout = getThingLayoutForFilename(filename, wikiData); + if (!initialLayout) continue; + + let currLayout = initialLayout; + let prevLayout = initialLayout; + let anyChanged = false; + + for (const rule of chunk) { + currLayout = rule.#processLayout(currLayout); + + const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout); + const currOrder = flattenThingLayoutToDocumentOrder(currLayout); + + if (compareArrays(currOrder, prevOrder)) { + yield {rule, changed: false}; + } else { + anyChanged = true; + yield {rule, changed: true}; + } + + prevLayout = currLayout; + } + + if (!anyChanged) continue; + if (dry) continue; + + const newLayout = currLayout; + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + + const realPath = + path.join( + dataPath, + filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + } + } + + #processLayout(layout) { + const fresh = {...layout}; + + let sortable = null; + switch (fresh.documentMode) { + case documentModes.headerAndEntries: + sortable = fresh.entryThings = + fresh.entryThings.slice(); + break; + + case documentModes.allInOne: + sortable = fresh.things = + fresh.things.slice(); + break; + + default: + throw new Error(`Invalid document type for sorting`); + } + + if (this.selectDocumentsFollowing) { + for (const entry of this.selectDocumentsFollowing) { + const [field, value] = Object.entries(entry)[0]; + + const after = + sortable.findIndex(thing => + thing[Thing.yamlSourceDocument][field] === value); + + const different = + after + + sortable + .slice(after) + .findIndex(thing => + Object.hasOwn(thing[Thing.yamlSourceDocument], field) && + thing[Thing.yamlSourceDocument][field] !== value); + + const before = + (different === -1 + ? sortable.length + : different); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else if (this.selectDocumentsUnder) { + const field = this.selectDocumentsUnder; + + const indices = + Array.from(sortable.entries()) + .filter(([_index, thing]) => + Object.hasOwn(thing[Thing.yamlSourceDocument], field)) + .map(([index, _thing]) => index); + + for (const [indicesIndex, after] of indices.entries()) { + const before = + (indicesIndex === indices.length - 1 + ? sortable.length + : indices[indicesIndex + 1]); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else { + this.sort(sortable); + } + + return fresh; + } +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 03274979..999072d3 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -2,22 +2,25 @@ 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 {contentString, directory, name, simpleString} +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('Unnamed Static Page'), + name: name(V('Unnamed Static Page')), nameShort: { flags: {update: true, expose: true}, @@ -30,9 +33,16 @@ export class StaticPage extends Thing { }, directory: directory(), - content: contentString(), + stylesheet: simpleString(), script: simpleString(), + content: contentString(), + + absoluteLinks: flag(V(false)), + + // Expose only + + isStaticPage: exposeConstant(V(true)), }); static [Thing.findSpecs] = { @@ -48,6 +58,8 @@ export class StaticPage extends Thing { 'Short Name': {property: 'nameShort'}, 'Directory': {property: 'directory'}, + 'Absolute Links': {property: 'absoluteLinks'}, + 'Style': {property: 'stylesheet'}, 'Script': {property: 'script'}, 'Content': {property: 'content'}, @@ -71,8 +83,6 @@ export class StaticPage extends Thing { documentMode: onePerFile, documentThing: StaticPage, - save: (results) => ({staticPageData: results}), - sort({staticPageData}) { sortAlphabetically(staticPageData); }, diff --git a/src/data/things/track.js b/src/data/things/track.js index 725b1bb7..ab7511a8 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -2,37 +2,70 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; -import {input} from '#composite'; -import find from '#find'; +import {input, V} from '#composite'; +import {onlyItem} from '#sugar'; +import {sortByDate} from '#sort'; import Thing from '#thing'; -import {isColor, isContributionList, isDate, isFileExtension} - from '#validators'; +import {getKebabCase} 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, } from '#yaml'; -import {withPropertyFromObject} from '#composite/data'; -import {withResolvedContribs} from '#composite/wiki-data'; - import { exitWithoutDependency, + exitWithoutUpdateValue, exposeConstant, exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, + exposeWhetherDependencyAvailable, + withAvailabilityFilter, + withResultOfAvailabilityCheck, } from '#composite/control-flow'; import { - additionalFiles, - additionalNameList, - commentary, + 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, @@ -41,323 +74,848 @@ import { flag, name, referenceList, + referencedArtworkList, reverseReferenceList, simpleDate, - singleReference, simpleString, + soupyFind, + soupyReverse, + thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; import { - exitWithoutUniqueCoverArt, - inferredAdditionalNameList, - inheritFromOriginalRelease, - sharedAdditionalNameList, - trackReverseReferenceList, - withAlbum, - withAlwaysReferenceByDirectory, - withContainingTrackSection, - withHasUniqueCoverArt, - withOtherReleases, - withPropertyFromAlbum, + 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, + 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), + }), - static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ - // Update & expose + 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), + }), - name: name('Unnamed Track'), - directory: directory(), + withPropertyFromObject('album', V('alwaysReferenceTracksByDirectory')), - additionalNames: additionalNameList(), - sharedAdditionalNames: sharedAdditionalNameList(), - inferredAdditionalNames: inferredAdditionalNameList(), + // 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'), + }), - bandcampTrackIdentifier: simpleString(), - bandcampArtworkIdentifier: simpleString(), + exitWithoutDependency('_mainRelease', V(false)), + exitWithoutDependency('mainReleaseTrack', V(false)), - duration: duration(), - urls: urls(), - dateFirstReleased: simpleDate(), + withPropertyFromObject('mainReleaseTrack', V('name')), - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), + { + dependencies: ['name', '#mainReleaseTrack.name'], + compute: ({ + ['name']: name, + ['#mainReleaseTrack.name']: mainReleaseName, + }) => + getKebabCase(name) === + getKebabCase(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'])), }), - withContainingTrackSection(), + { + dependencies: ['name'], + transform: (ref, continuation, {name: ownName}) => + (ref === 'same name single' + ? continuation(ref, { + ['#albumOrTrackReference']: null, + ['#sameNameSingleReference']: ownName, + }) + : continuation(ref, { + ['#albumOrTrackReference']: ref, + ['#sameNameSingleReference']: null, + })), + }, - withPropertyFromObject({ - object: '#trackSection', - property: input.value('color'), + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('trackMainReleasesOnly'), + }).outputs({ + '#resolvedReference': '#matchingTrack', }), - exposeDependencyOrContinue({dependency: '#trackSection.color'}), + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('album'), + }).outputs({ + '#resolvedReference': '#matchingAlbum', + }), - withPropertyFromAlbum({ - property: input.value('color'), + withResolvedReference({ + ref: '#sameNameSingleReference', + find: soupyFind.input('albumSinglesOnly'), + findOptions: input.value({ + fuzz: { + capitalization: true, + kebab: true, + }, + }), + }).outputs({ + '#resolvedReference': '#sameNameSingle', }), - exposeDependency({dependency: '#album.color'}), - ], + exposeDependencyOrContinue('#sameNameSingle'), - alwaysReferenceByDirectory: [ - withAlwaysReferenceByDirectory(), - exposeDependency({dependency: '#alwaysReferenceByDirectory'}), + { + dependencies: [ + '#matchingTrack', + '#matchingAlbum', + ], + + compute: (continuation, { + ['#matchingTrack']: matchingTrack, + ['#matchingAlbum']: matchingAlbum, + }) => + (matchingTrack && matchingAlbum + ? continuation() + : matchingTrack ?? matchingAlbum + ? matchingTrack ?? matchingAlbum + : null), + }, + + withPropertyFromObject( '#matchingAlbum', V('tracks')), + + { + dependencies: [ + '#matchingAlbum.tracks', + '#matchingTrack', + ], + + compute: ({ + ['#matchingAlbum.tracks']: matchingAlbumTracks, + ['#matchingTrack']: matchingTrack, + }) => + (matchingAlbumTracks.includes(matchingTrack) + ? matchingTrack + : null), + }, ], - // Disables presenting the track as though it has its own unique artwork. - // This flag should only be used in select circumstances, i.e. to override - // an album's trackCoverArtists. This flag supercedes that property, as well - // as the track's own coverArtists. - disableUniqueCoverArt: flag(), + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), - // File extension for track's corresponding media file. This represents the - // track's unique cover artwork, if any, and does not inherit the extension - // of the album's main artwork. It does inherit trackCoverArtFileExtension, - // if present on the album. - coverArtFileExtension: [ - exitWithoutUniqueCoverArt(), + additionalNames: thingList(V(AdditionalName)), + dateFirstReleased: simpleDate(), + + // > Update & expose - Credits and contributors + + artistText: [ exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), + validate: input.value(isContentString), }), - withPropertyFromAlbum({ - property: input.value('trackCoverArtFileExtension'), + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), + ], + + artistTextInLists: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), }), - exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + exposeDependencyOrContinue('_artistText'), - exposeConstant({ - value: input.value('jpg'), - }), + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), ], - // Date of cover art release. Like coverArtFileExtension, this represents - // only the track's own unique cover artwork, if any. This exposes only as - // the track's own coverArtDate or its album's trackArtDate, so if neither - // is specified, this value is null. - coverArtDate: [ - withHasUniqueCoverArt(), - - exitWithoutDependency({ - dependency: '#hasUniqueCoverArt', - mode: input.value('falsy'), + artistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#artistContribs', }), - exposeUpdateValueOrContinue({ - validate: input.value(isDate), + 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'), }), - withPropertyFromAlbum({ - property: input.value('trackArtDate'), + withRedatedContributionList({ + list: '#album.trackArtistContribs', + date: 'date', }), - exposeDependency({dependency: '#album.trackArtDate'}), + exposeDependency('#album.trackArtistContribs'), ], - coverArtDimensions: [ - exitWithoutUniqueCoverArt(), - dimensions(), + contributorContribs: [ + inheritContributionListFromMainRelease(), + + contributionList({ + date: 'date', + artistProperty: input.value('trackContributorContributions'), + }), ], - commentary: commentary(), + // > Update & expose - General configuration - lyrics: [ - inheritFromOriginalRelease(), - contentString(), + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('trackSection', V('countTracksInArtistTotals')), + exposeDependency('#trackSection.countTracksInArtistTotals'), ], - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), + disableUniqueCoverArt: flag(V(false)), + disableDate: flag(V(false)), - originalReleaseTrack: singleReference({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', - }), + // > Update & expose - General metadata - // Internal use only - for directly identifying an album inside a track's - // util.inspect display, if it isn't indirectly available (by way of being - // included in an album's track list). - dataSourceAlbum: singleReference({ - class: input.value(Album), - find: input.value(find.album), - data: 'albumData', - }), + duration: duration(), - artistContribs: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), }), - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - }).outputs({ - '#resolvedContribs': '#artistContribs', + withPropertyFromObject('trackSection', V('color')), + exposeDependencyOrContinue('#trackSection.color'), + + withPropertyFromObject('album', V('color')), + exposeDependency('#album.color'), + ], + + needsLyrics: [ + exposeUpdateValueOrContinue({ + mode: input.value('falsy'), + validate: input.value(isBoolean), }), - exposeDependencyOrContinue({ - dependency: '#artistContribs', + exitWithoutDependency('_lyrics', { + value: input.value(false), mode: input.value('empty'), }), - withPropertyFromAlbum({ - property: input.value('artistContribs'), - }), + withPropertyFromList('_lyrics', V('helpNeeded')), - exposeDependency({dependency: '#album.artistContribs'}), + { + dependencies: ['#lyrics.helpNeeded'], + compute: ({ + ['#lyrics.helpNeeded']: helpNeeded, + }) => + helpNeeded.includes(true) + }, ], - contributorContribs: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + urls: urls(), + + // > Update & expose - Artworks + + trackArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), }), - contributionList(), + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), ], - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. coverArtistContribs: [ - exitWithoutUniqueCoverArt({ + exitWithoutDependency('hasUniqueCoverArt', { value: input.value([]), + mode: input.value('falsy'), }), withResolvedContribs({ from: input.updateValue({validate: isContributionList}), - }).outputs({ - '#resolvedContribs': '#coverArtistContribs', + date: 'coverArtDate', + thingProperty: input.value('coverArtistContribs'), + artistProperty: input.value('trackCoverArtistContributions'), }), - exposeDependencyOrContinue({ - dependency: '#coverArtistContribs', - mode: input.value('empty'), + exposeDependencyOrContinue('#resolvedContribs', V('empty')), + + withPropertyFromObject('album', V('trackCoverArtistContribs')), + + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), }), - withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: 'coverArtDate', }), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + exposeDependency('#album.trackCoverArtistContribs'), ], - referencedTracks: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + coverArtDate: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), }), - referenceList({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + exposeUpdateValueOrContinue({ + validate: input.value(isDate), }), + + withPropertyFromObject('album', V('trackArtDate')), + exposeDependencyOrContinue('#album.trackArtDate'), + + exposeDependency('date'), ], - sampledTracks: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + coverArtFileExtension: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), }), - referenceList({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + 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: [ - exitWithoutUniqueCoverArt({ + exitWithoutDependency('hasUniqueCoverArt', { value: input.value([]), + mode: input.value('falsy'), }), referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], - // Update only + referencedArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), - albumData: wikiData({ - class: input.value(Album), - }), + referencedArtworkList(), + ], - artistData: wikiData({ - class: input.value(Artist), - }), + // > Update & expose - Referenced tracks - artTagData: wikiData({ - class: input.value(ArtTag), - }), + previousProductionTracks: [ + inheritFromMainRelease(), - flashData: wikiData({ - class: input.value(Flash), - }), + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], - trackData: wikiData({ - class: input.value(Track), - }), + referencedTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], - // Expose only + sampledTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + + // > Update & expose - Additional files + + additionalFiles: thingList(V(AdditionalFile)), + sheetMusicFiles: thingList(V(AdditionalFile)), + midiProjectFiles: thingList(V(AdditionalFile)), + + // > Update & expose - Content entries + + lyrics: [ + // 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(), - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), + directorySuffix: [ + exitWithoutDependency('suffixDirectoryFromAlbum', { + value: input.value(null), + mode: input.value('falsy'), + }), + + withPropertyFromObject('trackSection', V('directorySuffix')), + exposeDependency('#trackSection.directorySuffix'), ], date: [ - exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), + { + dependencies: ['disableDate'], + compute: (continuation, {disableDate}) => + (disableDate + ? null + : continuation()), + }, - withPropertyFromAlbum({ - property: input.value('date'), - }), + exposeDependencyOrContinue('dateFirstReleased'), - exposeDependency({dependency: '#album.date'}), + 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: [ - withHasUniqueCoverArt(), - exposeDependency({dependency: '#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, + }) => { + const ownNameKebabed = getKebabCase(ownName); + + return continuation({ + ['#mapItsNameLikeName']: + name => getKebabCase(name) === ownNameKebabed, + + ['#mapItsDirectoryLikeDirectory']: + (ownDirectory + ? directory => directory === ownDirectory + : () => false), + + ['#mapItsNameLikeDirectory']: + (ownDirectory + ? name => getKebabCase(name) === ownDirectory + : () => false), + + ['#mapItsDirectoryLikeName']: + directory => directory === ownNameKebabed, + }); + }, + }, + + 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: [ - withOtherReleases(), - exposeDependency({dependency: '#otherReleases'}), + { + dependencies: [input.myself(), 'allReleases'], + compute: ({ + [input.myself()]: thisTrack, + ['allReleases']: allReleases, + }) => + allReleases.filter(track => track !== thisTrack), + }, ], - referencedByTracks: trackReverseReferenceList({ - list: input.value('referencedTracks'), + commentaryFromMainRelease: [ + exitWithoutDependency('mainReleaseTrack', V([])), + + withPropertyFromObject('mainReleaseTrack', V('commentary')), + exposeDependency('#mainReleaseTrack.commentary'), + ], + + groups: [ + withPropertyFromObject('album', V('groups')), + exposeDependency('#album.groups'), + ], + + followingProductionTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreFollowingProductionsOf'), + }), + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), }), - sampledByTracks: trackReverseReferenceList({ - list: input.value('sampledTracks'), + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), }), featuredInFlashes: reverseReferenceList({ - data: 'flashData', - list: input.value('featuredTracks'), + reverse: soupyReverse.input('flashesWhichFeature'), }), }); static [Thing.yamlDocumentSpec] = { fields: { + // Identifying metadata + 'Track': {property: 'name'}, + 'Track Text': {property: 'nameText'}, 'Directory': {property: 'directory'}, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Main Release': {property: 'mainRelease'}, 'Bandcamp Track ID': { property: 'bandcampTrackIdentifier', @@ -369,17 +927,86 @@ export class Track extends Thing { 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'}, - 'Date First Released': { - property: 'dateFirstReleased', - transform: parseDate, + // 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': { @@ -394,18 +1021,20 @@ export class Track extends Thing { transform: parseDimensions, }, - 'Has Cover Art': { - property: 'disableUniqueCoverArt', - transform: value => - (typeof value === 'boolean' - ? !value - : value), + 'Art Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, }, - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + // Referenced tracks - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, + 'Previous Productions': {property: 'previousProductionTracks'}, + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + // Additional files 'Additional Files': { property: 'additionalFiles', @@ -422,56 +1051,58 @@ export class Track extends Thing { transform: parseAdditionalFiles, }, - 'Originally Released As': {property: 'originalReleaseTrack'}, - 'Referenced Tracks': {property: 'referencedTracks'}, - 'Sampled Tracks': {property: 'sampledTracks'}, + // Content entries - 'Franchises': {ignore: true}, - 'Inherit Franchises': {ignore: true}, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, }, - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ - {message: `Rereleases inherit references from the original`, fields: [ - 'Originally Released As', - 'Referenced Tracks', + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', ]}, - {message: `Rereleases inherit samples from the original`, fields: [ - 'Originally Released As', - 'Sampled Tracks', + {message: `Secondary releases inherit references from the main one`, fields: [ + 'Main Release', + 'Referenced Tracks', ]}, - {message: `Rereleases inherit artists from the original`, fields: [ - 'Originally Released As', - 'Artists', + {message: `Secondary releases inherit samples from the main one`, fields: [ + 'Main Release', + 'Sampled Tracks', ]}, - {message: `Rereleases inherit contributors from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit contributors from the main one`, fields: [ + 'Main Release', 'Contributors', ]}, - {message: `Rereleases inherit lyrics from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit lyrics from the main one`, fields: [ + 'Main Release', 'Lyrics', ]}, @@ -492,6 +1123,7 @@ export class Track extends Thing { static [Thing.findSpecs] = { track: { referenceTypes: ['track'], + bindTo: 'trackData', getMatchableNames: track => @@ -500,12 +1132,12 @@ export class Track extends Thing { : [track.name]), }, - trackOriginalReleasesOnly: { + trackMainReleasesOnly: { referenceTypes: ['track'], bindTo: 'trackData', include: track => - !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + !CacheableObject.getUpdateValue(track, 'mainRelease'), // It's still necessary to check alwaysReferenceByDirectory here, since // it may be set manually (with `Always Reference By Directory: true`), @@ -516,32 +1148,169 @@ export class Track extends Thing { ? [] : [track.name]), }, + + 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, Track}) => + artwork instanceof Artwork && + artwork.thing instanceof Track && + 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], + }, + + tracksWhichAreFollowingProductionsOf: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.previousProductionTracks, + }, }; // 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, + ]; + } + + 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, 'originalReleaseTrack')) { - parts.unshift(`${colors.yellow('[rerelease]')} `); + if (CacheableObject.getUpdateValue(this, 'mainRelease')) { + parts.unshift(`${colors.yellow('[secrelease]')} `); } let album; if (depth >= 0) { - try { - album = this.album; - } catch (_error) { - // Computing album might crash for any reason, which we don't want to - // distract from another error we might be trying to work out at the - // moment (for which debugging might involve inspecting this track!). - } - - album ??= this.dataSourceAlbum; + album = this.album; } if (album) { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 316bd3bb..26b69ba6 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,20 +1,35 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; -import {input} from '#composite'; -import find from '#find'; +import {input, V} from '#composite'; import Thing from '#thing'; -import {isColor, isLanguageCode, isName, isURL} from '#validators'; - -import {contentString, flag, name, referenceList, wikiData} - from '#composite/wiki-properties'; +import {isBoolean, isContributionPresetList, isLanguageCode, isName} + from '#validators'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; + +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('Unnamed Wiki'), + name: name(V('Unnamed Wiki')), // Displayed in nav bar. nameShort: { @@ -27,14 +42,7 @@ export class WikiInfo extends Thing { }, }, - color: { - flags: {update: true, expose: true}, - update: {validate: isColor}, - - expose: { - transform: color => color ?? '#0088ff', - }, - }, + color: color(V('#0088ff')), // One-line description used for <meta rel="description"> tag. description: contentString(), @@ -46,46 +54,93 @@ export class WikiInfo extends Thing { update: {validate: isLanguageCode}, }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL}, - }, + canonicalBase: canonicalBase(), + canonicalMediaBase: canonicalBase(), + + wikiWallpaperFileExtension: fileExtension(V('jpg')), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), divideTrackListsByGroups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), + contributionPresets: { + flags: {update: true, expose: true}, + update: {validate: isContributionPresetList}, + }, + // Feature toggles - enableFlashesAndGames: flag(false), - enableListings: flag(false), - enableNews: flag(false), - enableArtTagUI: flag(false), - enableGroupUI: flag(false), + 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 - groupData: wikiData({ - class: input.value(Group), - }), + 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'}, - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Canonical Media Base': {property: 'canonicalMediaBase'}, + + '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, + }, }, }; @@ -98,13 +153,5 @@ export class WikiInfo extends Thing { documentMode: oneDocumentTotal, documentThing: WikiInfo, - - save(wikiInfo) { - if (!wikiInfo) { - return; - } - - return {wikiInfo}; - }, }); } |