diff options
Diffstat (limited to 'src/data/things')
-rw-r--r-- | src/data/things/additional-file.js | 47 | ||||
-rw-r--r-- | src/data/things/additional-name.js | 21 | ||||
-rw-r--r-- | src/data/things/album.js | 362 | ||||
-rw-r--r-- | src/data/things/art-tag.js | 35 | ||||
-rw-r--r-- | src/data/things/artist.js | 45 | ||||
-rw-r--r-- | src/data/things/artwork.js | 510 | ||||
-rw-r--r-- | src/data/things/content.js | 205 | ||||
-rw-r--r-- | src/data/things/contribution.js | 82 | ||||
-rw-r--r-- | src/data/things/flash.js | 82 | ||||
-rw-r--r-- | src/data/things/group.js | 89 | ||||
-rw-r--r-- | src/data/things/homepage-layout.js | 3 | ||||
-rw-r--r-- | src/data/things/index.js | 8 | ||||
-rw-r--r-- | src/data/things/language.js | 55 | ||||
-rw-r--r-- | src/data/things/sorting-rule.js | 1 | ||||
-rw-r--r-- | src/data/things/track.js | 305 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 37 |
16 files changed, 1636 insertions, 251 deletions
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js new file mode 100644 index 00000000..398d0af5 --- /dev/null +++ b/src/data/things/additional-file.js @@ -0,0 +1,47 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; + +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([]), + }), + ], + }); + + 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..4c23f291 --- /dev/null +++ b/src/data/things/additional-name.js @@ -0,0 +1,21 @@ +import Thing from '#thing'; + +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + }); + + 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 5aba75f7..5132b962 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -3,11 +3,12 @@ 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 {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, isDirectory, isNumber} from '#validators'; @@ -15,7 +16,10 @@ import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseWallpaperParts, @@ -29,11 +33,10 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate} from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, color, commentatorArtists, + constitutibleArtwork, + constitutibleArtworkList, contentString, contribsPresent, contributionList, @@ -44,7 +47,6 @@ import { name, referencedArtworkList, referenceList, - reverseReferenceList, simpleDate, simpleString, soupyFind, @@ -56,7 +58,7 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; +import {withHasCoverArt, withTracks} from '#composite/things/album'; import {withAlbum, withContinueCountingFrom, withStartCountingFrom} from '#composite/things/track-section'; @@ -64,9 +66,13 @@ export class Album extends Thing { static [Thing.referenceType] = 'album'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, ArtTag, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, Group, - Track, TrackSection, WikiInfo, }) => ({ @@ -91,10 +97,14 @@ export class Album extends Thing { alwaysReferenceTracksByDirectory: flag(false), suffixTrackDirectories: flag(false), + countTracksInArtistTotals: flag(true), + color: color(), urls: urls(), - additionalNames: additionalNameList(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -104,16 +114,10 @@ export class Album extends Thing { dateAddedToWiki: simpleDate(), coverArtDate: [ - // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~ - // TODO: OK so it's because tracks don't *store* dates just like that. - // Really instead of fallback being a flag, it should be a date value, - // if this option is worth existing at all. withCoverArtDate({ from: input.updateValue({ validate: isDate, }), - - fallback: input.value(true), }), exposeDependency({dependency: '#coverArtDate'}), @@ -142,7 +146,11 @@ export class Album extends Thing { ], wallpaperParts: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + exitWithoutContribs({ + contribs: 'wallpaperArtistContribs', + value: input.value([]), + }), + wallpaperParts(), ], @@ -163,13 +171,56 @@ export class Album extends Thing { dimensions(), ], + wallpaperArtwork: [ + exitWithoutDependency({ + dependency: 'wallpaperArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), + ], + + bannerArtwork: [ + exitWithoutDependency({ + dependency: 'bannerArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], + + coverArtworks: [ + withHasCoverArt(), + + exitWithoutDependency({ + dependency: '#hasCoverArt', + mode: input.value('falsy'), + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + ], + hasTrackNumbers: flag(true), isListedOnHomepage: flag(true), isListedInGalleries: flag(true), - commentary: commentary(), - creditSources: commentary(), - additionalFiles: additionalFiles(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), trackSections: thingList({ class: input.value(TrackSection), @@ -181,9 +232,7 @@ export class Album extends Thing { }), coverArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -202,9 +251,7 @@ export class Album extends Thing { }), wallpaperArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -213,9 +260,7 @@ export class Album extends Thing { ], bannerArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -246,20 +291,7 @@ export class Album extends Thing { value: input.value([]), }), - { - dependencies: ['coverArtDate', 'date'], - compute: (continuation, { - coverArtDate, - date, - }) => continuation({ - ['#date']: - coverArtDate ?? date, - }), - }, - - referencedArtworkList({ - date: '#date', - }), + referencedArtworkList(), ], // Update only @@ -268,13 +300,8 @@ export class Album extends Thing { reverse: soupyReverse(), // used for referencedArtworkList (mixedFind) - albumData: wikiData({ - class: input.value(Album), - }), - - // used for referencedArtworkList (mixedFind) - trackData: wikiData({ - class: input.value(Track), + artworkData: wikiData({ + class: input.value(Artwork), }), // used for withMatchingContributionPresets (indirectly by Contribution) @@ -286,7 +313,11 @@ export class Album extends Thing { commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasCoverArt: [ + withHasCoverArt(), + exposeDependency({dependency: '#hasCoverArt'}), + ], + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), @@ -294,17 +325,6 @@ export class Album extends Thing { withTracks(), exposeDependency({dependency: '#tracks'}), ], - - referencedByArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), - - reverseReferenceList({ - reverse: soupyReverse.input('artworksWhichReference'), - }), - ], }); static [Thing.getSerializeDescriptors] = ({ @@ -380,6 +400,31 @@ export class Album extends Thing { ? [] : [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] = { @@ -415,13 +460,13 @@ export class Album extends Thing { soupyReverse.contributionsBy('albumData', 'artistContribs'), albumCoverArtistContributionsBy: - soupyReverse.contributionsBy('albumData', 'coverArtistContribs'), + soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), albumWallpaperArtistContributionsBy: - soupyReverse.contributionsBy('albumData', 'wallpaperArtistContribs'), + soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}), albumBannerArtistContributionsBy: - soupyReverse.contributionsBy('albumData', 'bannerArtistContribs'), + soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}), albumsWithCommentaryBy: { bindTo: 'albumData', @@ -459,6 +504,8 @@ export class Album extends Thing { transform: String, }, + 'Count Tracks In Artist Totals': {property: 'countInArtistTotals'}, + 'Date': { property: 'date', transform: parseDate, @@ -471,6 +518,49 @@ export class Album extends Thing { 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + '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 Art Date': { property: 'coverArtDate', transform: parseDate, @@ -504,9 +594,10 @@ export class Album extends Thing { transform: parseContributors, }, - 'Wallpaper Style': {property: 'wallpaperStyle'}, 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Wallpaper Style': {property: 'wallpaperStyle'}, + 'Wallpaper Parts': { property: 'wallpaperParts', transform: parseWallpaperParts, @@ -525,8 +616,15 @@ export class Album extends Thing { transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -598,6 +696,12 @@ export class Album extends Thing { const trackSectionData = []; const trackData = []; + const artworkData = []; + const commentaryData = []; + const creditingSourceData = []; + const referencingSourceData = []; + const lyricsData = []; + for (const {header: album, entries} of results) { const trackSections = []; @@ -609,8 +713,6 @@ export class Album extends Thing { isDefaultTrackSection: true, }); - const albumRef = Thing.getReference(album); - const closeCurrentTrackSection = () => { if ( currentTrackSection.isDefaultTrackSection && @@ -637,17 +739,53 @@ export class Album extends Thing { currentTrackSectionTracks.push(entry); trackData.push(entry); - entry.dataSourceAlbum = albumRef; + // Set the track's album before accessing its list of artworks. + // The existence of its artwork objects may depend on access to + // its album's 'Default Track Cover Artists'. + entry.album = album; + + artworkData.push(...entry.trackArtworks); + commentaryData.push(...entry.commentary); + creditingSourceData.push(...entry.creditingSources); + referencingSourceData.push(...entry.referencingSources); + + // TODO: As exposed, Track.lyrics tries to inherit from the main + // release, which is impossible before the data's been linked. + // We just use the update value here. But it's icky! + lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []); } closeCurrentTrackSection(); albumData.push(album); + artworkData.push(...album.coverArtworks); + + if (album.bannerArtwork) { + artworkData.push(album.bannerArtwork); + } + + if (album.wallpaperArtwork) { + artworkData.push(album.wallpaperArtwork); + } + + commentaryData.push(...album.commentary); + creditingSourceData.push(...album.creditingSources); + album.trackSections = trackSections; } - return {albumData, trackSectionData, trackData}; + return { + albumData, + trackSectionData, + trackData, + + artworkData, + commentaryData, + creditingSourceData, + referencingSourceData, + lyricsData, + }; }, sort({albumData, trackData}) { @@ -655,13 +793,65 @@ 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.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Track}) => ({ // Update & expose name: name('Unnamed Track Section'), @@ -790,11 +980,13 @@ 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 { @@ -806,22 +998,20 @@ export class TrackSection extends Thing { last = this.tracks.at(-1).trackNumber; } catch {} - if (album) { - const albumName = album.name; - const albumIndex = album.trackSections.indexOf(this); + 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 && last !== null + ? `: ${first}-${last}` + : ''); - 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 c88fcdc2..518f616b 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,8 +1,7 @@ export const ART_TAG_DATA_FILE = 'tags.yaml'; import {input} from '#composite'; -import find from '#find'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {unique} from '#sugar'; import {isName} from '#validators'; @@ -12,7 +11,6 @@ import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; import { - additionalNameList, annotatedReferenceList, color, contentString, @@ -23,8 +21,8 @@ import { name, soupyFind, soupyReverse, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withAllDescendantArtTags, withAncestorArtTagBaobabTree} @@ -34,7 +32,7 @@ export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose name: name('Unnamed Art Tag'), @@ -55,7 +53,9 @@ export class ArtTag extends Thing { }, ], - additionalNames: additionalNameList(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), description: contentString(), @@ -68,8 +68,6 @@ export class ArtTag extends Thing { class: input.value(ArtTag), find: soupyFind.input('artTag'), - date: input.value(null), - reference: input.value('artTag'), thing: input.value('artTag'), }), @@ -94,22 +92,11 @@ export class ArtTag extends Thing { }, ], - directlyTaggedInThings: { - flags: {expose: true}, - - expose: { - dependencies: ['this', 'reverse'], - compute: ({this: artTag, reverse}) => - sortAlbumsTracksChronologically( - [ - ...reverse.albumsWhoseArtworksFeature(artTag), - ...reverse.tracksWhoseArtworksFeature(artTag), - ], - {getDate: thing => thing.coverArtDate ?? thing.date}), - }, - }, + directlyFeaturedInArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichFeature'), + }), - indirectlyTaggedInThings: [ + indirectlyFeaturedInArtworks: [ withAllDescendantArtTags(), { @@ -117,7 +104,7 @@ export class ArtTag extends Thing { compute: ({'#allDescendantArtTags': allDescendantArtTags}) => unique( allDescendantArtTags - .flatMap(artTag => artTag.directlyTaggedInThings)), + .flatMap(artTag => artTag.directlyFeaturedInArtworks)), }, ], diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 7ed99a8e..5b67051c 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -10,8 +10,12 @@ import {stitchArrays} from '#sugar'; import Thing from '#thing'; import {isName, validateArrayItems} from '#validators'; import {getKebabCase} from '#wiki-data'; +import {parseArtwork} from '#yaml'; + +import {exitWithoutDependency} from '#composite/control-flow'; import { + constitutibleArtwork, contentString, directory, fileExtension, @@ -22,7 +26,6 @@ import { soupyFind, soupyReverse, urls, - wikiData, } from '#composite/wiki-properties'; import {artistTotalDuration} from '#composite/things/artist'; @@ -31,7 +34,7 @@ export class Artist extends Thing { static [Thing.referenceType] = 'artist'; static [Thing.wikiDataArray] = 'artistData'; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: name('Unnamed Artist'), @@ -43,6 +46,16 @@ export class Artist extends Thing { hasAvatar: flag(false), avatarFileExtension: fileExtension('jpg'), + avatarArtwork: [ + exitWithoutDependency({ + dependency: 'hasAvatar', + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Avatar Artwork'), + ], + aliasNames: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isName)}, @@ -193,6 +206,17 @@ 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'}, @@ -238,7 +262,12 @@ export class Artist extends Thing { const artistData = [...artists, ...artistAliases]; - return {artistData}; + const artworkData = + artistData + .filter(artist => artist.hasAvatar) + .map(artist => artist.avatarArtwork); + + return {artistData, artworkData}; }, sort({artistData}) { @@ -257,7 +286,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } @@ -266,4 +295,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..57c293ca --- /dev/null +++ b/src/data/things/artwork.js @@ -0,0 +1,510 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input} 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 {withPropertyFromObject} from '#composite/data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withRecontextualizedContributionList, + withResolvedAnnotatedReferenceList, + withResolvedContribs, + withResolvedReferenceList, +} from '#composite/wiki-data'; + +import { + contentString, + directory, + flag, + reverseReferenceList, + simpleString, + soupyFind, + soupyReverse, + thing, + wikiData, +} from '#composite/wiki-properties'; + +import { + withAttachedArtwork, + withContainingArtworkList, + withContribsFromAttachedArtwork, + withPropertyFromAttachedArtwork, + withDate, +} from '#composite/things/artwork'; + +export class Artwork extends Thing { + static [Thing.referenceType] = 'artwork'; + + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ + // Update & expose + + unqualifiedDirectory: directory({ + name: input.value(null), + }), + + thing: thing(), + thingProperty: simpleString(), + + label: simpleString(), + source: contentString(), + originDetails: contentString(), + + dateFromThingProperty: simpleString(), + + date: [ + withDate({ + from: input.updateValue({validate: isDate}), + }), + + exposeDependency({dependency: '#date'}), + ], + + fileExtensionFromThingProperty: simpleString(), + + fileExtension: [ + { + compute: (continuation) => continuation({ + ['#default']: 'jpg', + }), + }, + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + exitWithoutDependency({ + dependency: 'thing', + value: '#default', + }), + + exitWithoutDependency({ + dependency: 'fileExtensionFromThingProperty', + value: '#default', + }), + + withPropertyFromObject({ + object: 'thing', + property: 'fileExtensionFromThingProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#value', + }), + + exposeDependency({ + dependency: '#default', + }), + ], + + dimensionsFromThingProperty: simpleString(), + + dimensions: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDimensions), + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'dimensionsFromThingProperty', + }).outputs({ + ['#value']: '#dimensionsFromThing', + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + exposeDependencyOrContinue({ + dependency: '#dimensionsFromThing', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + attachAbove: flag(false), + + artistContribsFromThingProperty: simpleString(), + artistContribsArtistProperty: simpleString(), + + artistContribs: [ + withDate(), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: '#date', + thingProperty: input.thisProperty(), + artistProperty: 'artistContribsArtistProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedContribs', + mode: input.value('empty'), + }), + + withContribsFromAttachedArtwork(), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artistContribs', + }), + + exitWithoutDependency({ + dependency: 'artistContribsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artistContribsFromThingProperty', + }).outputs({ + ['#value']: '#artistContribs', + }), + + withRecontextualizedContributionList({ + list: '#artistContribs', + }), + + exposeDependency({ + dependency: '#artistContribs', + }), + ], + + artTagsFromThingProperty: simpleString(), + + artTags: [ + withResolvedReferenceList({ + list: input.updateValue({ + validate: + validateReferenceList(ArtTag[Thing.referenceType]), + }), + + find: soupyFind.input('artTag'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedReferenceList', + mode: input.value('empty'), + }), + + withPropertyFromAttachedArtwork({ + property: input.value('artTags'), + }), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artTags', + }), + + exitWithoutDependency({ + dependency: 'artTagsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artTagsFromThingProperty', + }).outputs({ + ['#value']: '#artTags', + }), + + exposeDependencyOrContinue({ + dependency: '#artTags', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + 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({ + dependency: '#resolvedAnnotatedReferenceList', + mode: input.value('empty'), + }), + + exitWithoutDependency({ + dependency: 'referencedArtworksFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'referencedArtworksFromThingProperty', + }).outputs({ + ['#value']: '#referencedArtworks', + }), + + exposeDependencyOrContinue({ + dependency: '#referencedArtworks', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworks (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), + }), + + // Expose only + + referencedByArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichReference'), + }), + + isMainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: [input.myself(), '#containingArtworkList'], + compute: ({ + [input.myself()]: myself, + ['#containingArtworkList']: list, + }) => + list[0] === myself, + }, + ], + + mainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: ['#containingArtworkList'], + compute: ({'#containingArtworkList': list}) => + list[0], + }, + ], + + attachedArtwork: [ + withAttachedArtwork(), + + exposeDependency({ + dependency: '#attachedArtwork', + }), + ], + + attachingArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichAttach'), + }), + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + }); + + 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'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Attach Above': {property: 'attachAbove'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + '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..ca41ccaa --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,205 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {is, isDate} from '#validators'; +import {parseDate} from '#yaml'; + +import {contentString, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + contentArtists, + hasAnnotationPart, + withAnnotationParts, + withHasAnnotationPart, + withSourceText, + withSourceURLs, + withWebArchiveDate, +} from '#composite/things/content'; + +export class ContentEntry extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + artists: contentArtists(), + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exitWithoutDependency({ + dependency: 'accessDate', + }), + + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({ + from: '#webArchiveDate', + }), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant({ + value: input.value('accessed'), + }), + ], + + date: simpleDate(), + + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + + // Expose only + + annotationParts: [ + withAnnotationParts({ + mode: input.value('strings'), + }), + + exposeDependency({dependency: '#annotationParts'}), + ], + + sourceText: [ + withSourceText(), + exposeDependency({dependency: '#sourceText'}), + ], + + sourceURLs: [ + withSourceURLs(), + exposeDependency({dependency: '#sourceURLs'}), + ], + }); + + 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.getPropertyDescriptors] = () => ({ + // Expose only + + isWikiEditorCommentary: hasAnnotationPart({ + part: input.value('wiki editor'), + }), + }); +} + +export class LyricsEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isWikiLyrics: hasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + hasSquareBracketAnnotations: [ + withHasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + exitWithoutDependency({ + dependency: '#hasAnnotationPart', + mode: input.value('falsy'), + value: input.value(false), + }), + + exitWithoutDependency({ + dependency: 'body', + value: input.value(false), + }), + + { + dependencies: ['body'], + compute: ({body}) => + /\[.*\]/m.test(body), + }, + ], + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); +} + +export class CreditingSourcesEntry extends ContentEntry {} + +export class ReferencingSourcesEntry extends ContentEntry {} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js index c92fafb4..90e8eb79 100644 --- a/src/data/things/contribution.js +++ b/src/data/things/contribution.js @@ -5,12 +5,20 @@ import {colors} from '#cli'; import {input} from '#composite'; import {empty} from '#sugar'; import Thing from '#thing'; -import {isStringNonEmpty, isThing, validateReference} from '#validators'; +import {isBoolean, isStringNonEmpty, isThing, validateReference} + from '#validators'; -import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; import {flag, simpleDate, soupyFind} from '#composite/wiki-properties'; import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { withFilteredList, withNearbyItemFromList, withPropertyFromList, @@ -70,7 +78,26 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + 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({ + value: input.value(true), + }), ], countInDurationTotals: [ @@ -78,7 +105,37 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'thing', + property: input.value('duration'), + }), + + exitWithoutDependency({ + dependency: '#thing.duration', + mode: input.value('falsy'), + value: input.value(false), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], // Update only @@ -238,6 +295,21 @@ export class Contribution extends Thing { dependency: '#nearbyItem', }), ], + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], }); [inspect.custom](depth, options, inspect) { @@ -259,7 +331,7 @@ export class Contribution extends Thing { let artist; try { artist = this.artist; - } catch (_error) { + } catch { // Computing artist might crash for any reason - don't distract from // other errors as a result of inspecting this contribution. } diff --git a/src/data/things/flash.js b/src/data/things/flash.js index fe1d17ff..160221f0 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -6,8 +6,16 @@ import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseAdditionalNames, parseContributors, parseDate, parseDimensions} - from '#yaml'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -19,10 +27,9 @@ import { } from '#composite/control-flow'; import { - additionalNameList, color, - commentary, commentatorArtists, + constitutibleArtwork, contentString, contributionList, dimensions, @@ -34,8 +41,8 @@ import { soupyFind, soupyReverse, thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withFlashAct} from '#composite/things/flash'; @@ -45,8 +52,10 @@ export class Flash extends Thing { static [Thing.referenceType] = 'flash'; static [Thing.getPropertyDescriptors] = ({ + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, Track, - FlashAct, WikiInfo, }) => ({ // Update & expose @@ -100,6 +109,10 @@ export class Flash extends Thing { coverArtDimensions: dimensions(), + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + contributorContribs: contributionList({ date: 'date', artistProperty: input.value('flashContributorContributions'), @@ -112,10 +125,17 @@ export class Flash extends Thing { urls: urls(), - additionalNames: additionalNameList(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), + + commentary: thingList({ + class: input.value(CommentaryEntry), + }), - commentary: commentary(), - creditSources: commentary(), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), // Update only @@ -205,6 +225,17 @@ export class Flash extends Thing { 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': { @@ -219,12 +250,27 @@ export class Flash extends Thing { transform: parseContributors, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + '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 { @@ -411,7 +457,19 @@ export class FlashSide extends Thing { const flashActData = results.filter(x => x instanceof FlashAct); const flashSideData = results.filter(x => x instanceof FlashSide); - return {flashData, flashActData, flashSideData}; + const artworkData = flashData.map(flash => flash.coverArtwork); + const commentaryData = flashData.flatMap(flash => flash.commentary); + const creditingSourceData = flashData.flatMap(flash => flash.creditingSources); + + return { + flashData, + flashActData, + flashSideData, + + artworkData, + commentaryData, + creditingSourceData, + }; }, sort({flashData}) { diff --git a/src/data/things/group.js b/src/data/things/group.js index ed3c59bb..0262a3a5 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,7 +1,11 @@ export const GROUP_DATA_FILE = 'groups.yaml'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; import {input} from '#composite'; import Thing from '#thing'; +import {is} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; import { @@ -11,16 +15,16 @@ import { directory, name, referenceList, - seriesList, soupyFind, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({ // Update & expose name: name('Unnamed Group'), @@ -34,8 +38,6 @@ export class Group extends Thing { class: input.value(Artist), find: soupyFind.input('artist'), - date: input.value(null), - reference: input.value('artist'), thing: input.value('artist'), }), @@ -45,8 +47,8 @@ export class Group extends Thing { find: soupyFind.input('album'), }), - serieses: seriesList({ - group: input.myself(), + serieses: thingList({ + class: input.value(Series), }), // Update only @@ -194,8 +196,9 @@ export class Group extends Thing { const groupData = results.filter(x => x instanceof Group); const groupCategoryData = results.filter(x => x instanceof GroupCategory); + const seriesData = groupData.flatMap(group => group.serieses); - return {groupData, groupCategoryData}; + return {groupData, groupCategoryData, seriesData}; }, // Groups aren't sorted at all, always preserving the order in the data @@ -242,3 +245,73 @@ export class GroupCategory extends Thing { }, }; } + +export class Series extends Thing { + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name('Unnamed Series'), + + showAlbumArtists: { + flags: {update: true, expose: true}, + update: { + validate: + is('all', 'differing', 'none'), + }, + }, + + description: contentString(), + + group: thing({ + class: input.value(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 82bad2d3..3a11c287 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -63,7 +63,6 @@ export class HomepageLayout extends Thing { thingConstructors: { HomepageLayout, HomepageLayoutSection, - HomepageLayoutAlbumsRow, }, }) => ({ title: `Process homepage layout file`, @@ -250,7 +249,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow { export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { static [Thing.friendlyName] = `Homepage Album Carousel Row`; - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose diff --git a/src/data/things/index.js b/src/data/things/index.js index 17471f31..11307b50 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -9,9 +9,13 @@ import * as serialize from '#serialize'; import {withEntries} 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'; @@ -24,9 +28,13 @@ 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, diff --git a/src/data/things/language.js b/src/data/things/language.js index a3f861bd..e3689643 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -135,6 +135,7 @@ export class Language extends Thing { }, intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), @@ -488,22 +489,44 @@ export class Language extends Thing { // or both are undefined, that's just blank content. const hasStart = startDate !== null && startDate !== undefined; const hasEnd = endDate !== null && endDate !== undefined; - if (!hasStart || !hasEnd) { - if (startDate === endDate) { - return html.blank(); - } else if (hasStart) { - throw new Error(`Expected both start and end of date range, got only start`); - } else if (hasEnd) { - throw new Error(`Expected both start and end of date range, got only end`); - } else { - throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); - } + 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); + } + + 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, @@ -842,6 +865,18 @@ export class Language extends Thing { } } + 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) { diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js index b169a541..ccc4ad89 100644 --- a/src/data/things/sorting-rule.js +++ b/src/data/things/sorting-rule.js @@ -3,7 +3,6 @@ export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; import {readFile, writeFile} from 'node:fs/promises'; import * as path from 'node:path'; -import {input} from '#composite'; import {chunkByProperties, compareArrays, unique} from '#sugar'; import Thing from '#thing'; import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators'; diff --git a/src/data/things/track.js b/src/data/things/track.js index 69953d33..8b9420c7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -11,10 +11,15 @@ import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, + parseReferencingSources, parseDate, parseDimensions, parseDuration, + parseLyrics, } from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -34,11 +39,8 @@ import { } from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, commentatorArtists, - contentString, + constitutibleArtworkList, contributionList, dimensions, directory, @@ -54,6 +56,7 @@ import { soupyFind, soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; @@ -62,10 +65,10 @@ import { exitWithoutUniqueCoverArt, inheritContributionListFromMainRelease, inheritFromMainRelease, - withAlbum, withAllReleases, withAlwaysReferenceByDirectory, withContainingTrackSection, + withCoverArtistContribs, withDate, withDirectorySuffix, withHasUniqueCoverArt, @@ -81,10 +84,15 @@ export class Track extends Thing { static [Thing.referenceType] = 'track'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, Album, ArtTag, - Flash, - TrackSection, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, + LyricsEntry, + ReferencingSourcesEntry, WikiInfo, }) => ({ // Update & expose @@ -121,7 +129,13 @@ export class Track extends Thing { }) ], - additionalNames: additionalNameList(), + album: thing({ + class: input.value(Album), + }), + + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampTrackIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -156,6 +170,18 @@ export class Track extends Thing { exposeDependency({dependency: '#alwaysReferenceByDirectory'}), ], + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromAlbum({ + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + // 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 @@ -197,6 +223,8 @@ export class Track extends Thing { coverArtDimensions: [ exitWithoutUniqueCoverArt(), + exposeUpdateValueOrContinue(), + withPropertyFromAlbum({ property: input.value('trackDimensions'), }), @@ -206,31 +234,46 @@ export class Track extends Thing { dimensions(), ], - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + referencingSources: thingList({ + class: input.value(ReferencingSourcesEntry), + }), 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(), - contentString(), + + thingList({ + class: input.value(LyricsEntry), + }), ], - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), + + sheetMusicFiles: thingList({ + class: input.value(AdditionalFile), + }), + + midiProjectFiles: thingList({ + class: input.value(AdditionalFile), + }), mainReleaseTrack: singleReference({ class: input.value(Track), find: soupyFind.input('track'), }), - // 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: soupyFind.input('album'), - }), - artistContribs: [ inheritContributionListFromMainRelease(), @@ -279,43 +322,13 @@ export class Track extends Thing { ], coverArtistContribs: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - withTrackArtDate({ - fallback: input.value(true), - }), - - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - thingProperty: input.thisProperty(), - artistProperty: input.value('trackCoverArtistContributions'), - date: '#trackArtDate', - }).outputs({ - '#resolvedContribs': '#coverArtistContribs', - }), - - exposeDependencyOrContinue({ - dependency: '#coverArtistContribs', - mode: input.value('empty'), - }), - - withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), - }), - - withRecontextualizedContributionList({ - list: '#album.trackCoverArtistContribs', - artistProperty: input.value('trackCoverArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.trackCoverArtistContribs', - date: '#trackArtDate', + withCoverArtistContribs({ + from: input.updateValue({ + validate: isContributionList, + }), }), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + exposeDependency({dependency: '#coverArtistContribs'}), ], referencedTracks: [ @@ -340,6 +353,15 @@ export class Track extends Thing { }), ], + trackArtworks: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), + ], + artTags: [ exitWithoutUniqueCoverArt({ value: input.value([]), @@ -356,13 +378,7 @@ export class Track extends Thing { value: input.value([]), }), - withTrackArtDate({ - fallback: input.value(true), - }), - - referencedArtworkList({ - date: '#trackArtDate', - }), + referencedArtworkList(), ], // Update only @@ -371,11 +387,11 @@ export class Track extends Thing { reverse: soupyReverse(), // used for referencedArtworkList (mixedFind) - albumData: wikiData({ - class: input.value(Album), + artworkData: wikiData({ + class: input.value(Artwork), }), - // used for referencedArtworkList (mixedFind) + // used for withAlwaysReferenceByDirectory (for some reason) trackData: wikiData({ class: input.value(Track), }), @@ -389,11 +405,6 @@ export class Track extends Thing { commentatorArtists: commentatorArtists(), - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), - ], - date: [ withDate(), exposeDependency({dependency: '#date'}), @@ -442,6 +453,16 @@ export class Track extends Thing { exposeDependency({dependency: '#otherReleases'}), ], + groups: [ + withPropertyFromAlbum({ + property: input.value('groups'), + }), + + exposeDependency({ + dependency: '#album.groups', + }), + ], + referencedByTracks: reverseReferenceList({ reverse: soupyReverse.input('tracksWhichReference'), }), @@ -453,16 +474,6 @@ export class Track extends Thing { featuredInFlashes: reverseReferenceList({ reverse: soupyReverse.input('flashesWhichFeature'), }), - - referencedByArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - reverseReferenceList({ - reverse: soupyReverse.input('artworksWhichReference'), - }), - ], }); static [Thing.yamlDocumentSpec] = { @@ -486,6 +497,8 @@ export class Track extends Thing { transform: String, }, + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + 'Duration': { property: 'duration', transform: parseDuration, @@ -521,9 +534,25 @@ export class Track extends Thing { 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -567,12 +596,32 @@ export class Track extends Thing { transform: parseContributors, }, + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + 'Art Tags': {property: 'artTags'}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', + ]}, + {message: `Secondary releases inherit references from the main one`, fields: [ 'Main Release', 'Referenced Tracks', @@ -658,6 +707,31 @@ export class Track extends Thing { ? [] : [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] = { @@ -689,7 +763,7 @@ export class Track extends Thing { soupyReverse.contributionsBy('trackData', 'contributorContribs'), trackCoverArtistContributionsBy: - soupyReverse.contributionsBy('trackData', 'coverArtistContribs'), + soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'), tracksWithCommentaryBy: { bindTo: 'trackData', @@ -709,6 +783,55 @@ export class Track extends Thing { // 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 = []; @@ -721,15 +844,7 @@ export class Track extends Thing { 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 590598be..f97f9027 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -2,7 +2,7 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; import Thing from '#thing'; -import {parseContributionPresets} from '#yaml'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; import { isBoolean, @@ -14,8 +14,17 @@ import { } from '#validators'; import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, soupyFind} - from '#composite/wiki-properties'; + +import { + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -68,6 +77,10 @@ export class WikiInfo extends Thing { }, }, + wikiWallpaperFileExtension: fileExtension('jpg'), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), + divideTrackListsByGroups: referenceList({ class: input.value(Group), find: soupyFind.input('group'), @@ -112,18 +125,34 @@ export class WikiInfo extends Thing { 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'}, + + '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, |