From 1f37e5d6b0c6fccc9c46aabd7bd402375131d452 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 26 Jan 2026 14:07:13 -0400 Subject: data: break up content.js, flash.js, sorting-rule.js --- src/data/things/content.js | 319 --------------- src/data/things/content/CommentaryEntry.js | 20 + src/data/things/content/ContentEntry.js | 246 ++++++++++++ src/data/things/content/CreditingSourcesEntry.js | 16 + src/data/things/content/LyricsEntry.js | 43 ++ src/data/things/content/ReferencingSourcesEntry.js | 16 + src/data/things/content/index.js | 9 + src/data/things/flash.js | 443 --------------------- src/data/things/flash/Flash.js | 246 ++++++++++++ src/data/things/flash/FlashAct.js | 74 ++++ src/data/things/flash/FlashSide.js | 136 +++++++ src/data/things/flash/index.js | 3 + src/data/things/index.js | 7 +- src/data/things/sorting-rule.js | 396 ------------------ .../things/sorting-rule/DocumentSortingRule.js | 242 +++++++++++ src/data/things/sorting-rule/SortingRule.js | 86 ++++ src/data/things/sorting-rule/ThingSortingRule.js | 83 ++++ src/data/things/sorting-rule/index.js | 3 + 18 files changed, 1226 insertions(+), 1162 deletions(-) delete mode 100644 src/data/things/content.js create mode 100644 src/data/things/content/CommentaryEntry.js create mode 100644 src/data/things/content/ContentEntry.js create mode 100644 src/data/things/content/CreditingSourcesEntry.js create mode 100644 src/data/things/content/LyricsEntry.js create mode 100644 src/data/things/content/ReferencingSourcesEntry.js create mode 100644 src/data/things/content/index.js delete mode 100644 src/data/things/flash.js create mode 100644 src/data/things/flash/Flash.js create mode 100644 src/data/things/flash/FlashAct.js create mode 100644 src/data/things/flash/FlashSide.js create mode 100644 src/data/things/flash/index.js delete mode 100644 src/data/things/sorting-rule.js create mode 100644 src/data/things/sorting-rule/DocumentSortingRule.js create mode 100644 src/data/things/sorting-rule/SortingRule.js create mode 100644 src/data/things/sorting-rule/ThingSortingRule.js create mode 100644 src/data/things/sorting-rule/index.js (limited to 'src/data') diff --git a/src/data/things/content.js b/src/data/things/content.js deleted file mode 100644 index 64d03e69..00000000 --- a/src/data/things/content.js +++ /dev/null @@ -1,319 +0,0 @@ -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/content/CommentaryEntry.js b/src/data/things/content/CommentaryEntry.js new file mode 100644 index 00000000..32d69213 --- /dev/null +++ b/src/data/things/content/CommentaryEntry.js @@ -0,0 +1,20 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {hasAnnotationPart} from '#composite/things/content'; + +import {ContentEntry} from './ContentEntry.js'; + +export class CommentaryEntry extends ContentEntry { + static [Thing.wikiData] = 'commentaryData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCommentaryEntry: exposeConstant(V(true)), + + isWikiEditorCommentary: hasAnnotationPart(V('wiki editor')), + }); +} diff --git a/src/data/things/content/ContentEntry.js b/src/data/things/content/ContentEntry.js new file mode 100644 index 00000000..7dc81345 --- /dev/null +++ b/src/data/things/content/ContentEntry.js @@ -0,0 +1,246 @@ +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 { + 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'}, + }, + }; +} diff --git a/src/data/things/content/CreditingSourcesEntry.js b/src/data/things/content/CreditingSourcesEntry.js new file mode 100644 index 00000000..7331ae8c --- /dev/null +++ b/src/data/things/content/CreditingSourcesEntry.js @@ -0,0 +1,16 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {ContentEntry} from './ContentEntry.js'; + +export class CreditingSourcesEntry extends ContentEntry { + static [Thing.wikiData] = 'creditingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCreditingSourcesEntry: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/content/LyricsEntry.js b/src/data/things/content/LyricsEntry.js new file mode 100644 index 00000000..88e4464d --- /dev/null +++ b/src/data/things/content/LyricsEntry.js @@ -0,0 +1,43 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; +import {contentString} from '#composite/wiki-properties'; + +import {hasAnnotationPart} from '#composite/things/content'; + +import {ContentEntry} from './ContentEntry.js'; + +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'}, + }, + }); +} diff --git a/src/data/things/content/ReferencingSourcesEntry.js b/src/data/things/content/ReferencingSourcesEntry.js new file mode 100644 index 00000000..76fafbc2 --- /dev/null +++ b/src/data/things/content/ReferencingSourcesEntry.js @@ -0,0 +1,16 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {ContentEntry} from './ContentEntry.js'; + +export class ReferencingSourcesEntry extends ContentEntry { + static [Thing.wikiData] = 'referencingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isReferencingSourceEntry: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/content/index.js b/src/data/things/content/index.js new file mode 100644 index 00000000..aaa7f304 --- /dev/null +++ b/src/data/things/content/index.js @@ -0,0 +1,9 @@ +// Yet Another Index.js File Descending From A Folder Named Content + +export * from './ContentEntry.js'; + +export * from './CommentaryEntry.js'; +export * from './LyricsEntry.js'; + +export * from './CreditingSourcesEntry.js'; +export * from './ReferencingSourcesEntry.js'; diff --git a/src/data/things/flash.js b/src/data/things/flash.js deleted file mode 100644 index b595ec58..00000000 --- a/src/data/things/flash.js +++ /dev/null @@ -1,443 +0,0 @@ -export const FLASH_DATA_FILE = 'flashes.yaml'; - -import {input, V} from '#composite'; -import {sortFlashesChronologically} from '#sort'; -import Thing from '#thing'; -import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} - from '#validators'; - -import { - parseArtwork, - parseAdditionalNames, - parseCommentary, - parseContributors, - parseCreditingSources, - parseDate, - parseDimensions, -} from '#yaml'; - -import {withPropertyFromObject} from '#composite/data'; - -import { - exposeConstant, - exposeDependency, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import { - color, - commentatorArtists, - constitutibleArtwork, - contentString, - contributionList, - dimensions, - directory, - fileExtension, - name, - referenceList, - simpleDate, - soupyFind, - soupyReverse, - thing, - thingList, - urls, -} from '#composite/wiki-properties'; - -export class Flash extends Thing { - static [Thing.referenceType] = 'flash'; - static [Thing.wikiData] = 'flashData'; - - static [Thing.constitutibleProperties] = [ - 'coverArtwork', // from inline fields - ]; - - static [Thing.getPropertyDescriptors] = ({ - AdditionalName, - CommentaryEntry, - CreditingSourcesEntry, - FlashAct, - Track, - WikiInfo, - }) => ({ - // Update & expose - - act: thing(V(FlashAct)), - - name: name(V('Unnamed Flash')), - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - - // Flashes expose directory differently from other Things! Their - // default directory is dependent on the page number (or ID), not - // the name. - expose: { - dependencies: ['page'], - transform(directory, {page}) { - if (directory === null && page === null) return null; - else if (directory === null) return page; - else return directory; - }, - }, - }, - - page: { - flags: {update: true, expose: true}, - update: {validate: anyOf(isString, isNumber)}, - - expose: { - transform: (value) => (value === null ? null : value.toString()), - }, - }, - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withPropertyFromObject('act', V('color')), - exposeDependency('#act.color'), - ], - - date: simpleDate(), - - coverArtFileExtension: fileExtension(V('jpg')), - - coverArtDimensions: dimensions(), - - coverArtwork: - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Cover Artwork'), - - contributorContribs: contributionList({ - artistProperty: input.value('flashContributorContributions'), - }), - - featuredTracks: referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - - urls: urls(), - - additionalNames: thingList(V(AdditionalName)), - - commentary: thingList(V(CommentaryEntry)), - creditingSources: thingList(V(CreditingSourcesEntry)), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // used for withMatchingContributionPresets (indirectly by Contribution) - wikiInfo: thing(V(WikiInfo)), - - // Expose only - - isFlash: exposeConstant(V(true)), - - commentatorArtists: commentatorArtists(), - - side: [ - withPropertyFromObject('act', V('side')), - exposeDependency('#act.side'), - ], - }); - - static [Thing.getSerializeDescriptors] = ({ - serialize: S, - }) => ({ - name: S.id, - page: S.id, - directory: S.id, - date: S.id, - contributors: S.toContribRefs, - tracks: S.toRefs, - urls: S.id, - color: S.id, - }); - - static [Thing.findSpecs] = { - flash: { - referenceTypes: ['flash'], - bindTo: 'flashData', - }, - }; - - static [Thing.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'}, - 'Directory': {property: 'directory'}, - 'Page': {property: 'page'}, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, - - 'Date': { - property: 'date', - 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': { - property: 'contributorContribs', - transform: parseContributors, - }, - - '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] = ({Flash, FlashSide}) => ({ - // Update & expose - - side: thing(V(FlashSide)), - - name: name(V('Unnamed Flash Act')), - directory: directory(), - color: color(), - - listTerminology: [ - exposeUpdateValueOrContinue({ - validate: input.value(isContentString), - }), - - withPropertyFromObject('side', V('listTerminology')), - exposeDependency('#side.listTerminology'), - ], - - flashes: thingList(V(Flash)), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // Expose only - - isFlashAct: exposeConstant(V(true)), - }); - - static [Thing.findSpecs] = { - flashAct: { - referenceTypes: ['flash-act'], - bindTo: 'flashActData', - }, - }; - - static [Thing.reverseSpecs] = { - flashActsWhoseFlashesInclude: { - bindTo: 'flashActData', - - referencing: flashAct => [flashAct], - referenced: flashAct => flashAct.flashes, - }, - }; - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Act': {property: 'name'}, - 'Directory': {property: 'directory'}, - - 'Color': {property: 'color'}, - 'List Terminology': {property: 'listTerminology'}, - - 'Review Points': {ignore: true}, - }, - }; -} - -export class FlashSide extends Thing { - static [Thing.referenceType] = 'flash-side'; - static [Thing.friendlyName] = `Flash Side`; - static [Thing.wikiData] = 'flashSideData'; - - static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({ - // Update & expose - - name: name(V('Unnamed Flash Side')), - directory: directory(), - color: color(), - listTerminology: contentString(), - - acts: thingList(V(FlashAct)), - - // Update only - - find: soupyFind(), - - // Expose only - - isFlashSide: exposeConstant(V(true)), - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Side': {property: 'name'}, - 'Directory': {property: 'directory'}, - 'Color': {property: 'color'}, - 'List Terminology': {property: 'listTerminology'}, - }, - }; - - static [Thing.findSpecs] = { - flashSide: { - referenceTypes: ['flash-side'], - bindTo: 'flashSideData', - }, - }; - - static [Thing.reverseSpecs] = { - flashSidesWhoseActsInclude: { - bindTo: 'flashSideData', - - referencing: flashSide => [flashSide], - referenced: flashSide => flashSide.acts, - }, - }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {Flash, FlashAct}, - }) => ({ - title: `Process flashes file`, - file: FLASH_DATA_FILE, - - documentMode: allInOne, - documentThing: document => - ('Side' in document - ? FlashSide - : 'Act' in document - ? FlashAct - : Flash), - - connect(results) { - let thing, i; - - for (i = 0; thing = results[i]; i++) { - if (thing.isFlashSide) { - const side = thing; - const acts = []; - - for (i++; thing = results[i]; i++) { - if (thing.isFlashAct) { - const act = thing; - const flashes = []; - - for (i++; thing = results[i]; i++) { - if (thing.isFlash) { - const flash = thing; - - flash.act = act; - flashes.push(flash); - - continue; - } - - 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; - } - - side.acts = acts; - - continue; - } - - if (thing.isFlashAct) { - throw new Error(`Acts must be under a side`); - } - - if (thing.isFlash) { - throw new Error(`Flashes must be under a side and act`); - } - } - }, - - sort({flashData}) { - sortFlashesChronologically(flashData); - }, - }); -} diff --git a/src/data/things/flash/Flash.js b/src/data/things/flash/Flash.js new file mode 100644 index 00000000..1f290b3f --- /dev/null +++ b/src/data/things/flash/Flash.js @@ -0,0 +1,246 @@ +import {input, V} from '#composite'; +import Thing from '#thing'; +import {anyOf, isColor, isDirectory, isNumber, isString} + from '#validators'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + commentatorArtists, + constitutibleArtwork, + contributionList, + dimensions, + fileExtension, + name, + referenceList, + simpleDate, + soupyFind, + soupyReverse, + thing, + thingList, + urls, +} from '#composite/wiki-properties'; + +export class Flash extends Thing { + static [Thing.referenceType] = 'flash'; + static [Thing.wikiData] = 'flashData'; + + static [Thing.constitutibleProperties] = [ + 'coverArtwork', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({ + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, + FlashAct, + Track, + WikiInfo, + }) => ({ + // Update & expose + + act: thing(V(FlashAct)), + + name: name(V('Unnamed Flash')), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, + }, + }, + + page: { + flags: {update: true, expose: true}, + update: {validate: anyOf(isString, isNumber)}, + + expose: { + transform: (value) => (value === null ? null : value.toString()), + }, + }, + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withPropertyFromObject('act', V('color')), + exposeDependency('#act.color'), + ], + + date: simpleDate(), + + coverArtFileExtension: fileExtension(V('jpg')), + + coverArtDimensions: dimensions(), + + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + + contributorContribs: contributionList({ + artistProperty: input.value('flashContributorContributions'), + }), + + featuredTracks: referenceList({ + class: input.value(Track), + find: soupyFind.input('track'), + }), + + urls: urls(), + + additionalNames: thingList(V(AdditionalName)), + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for withMatchingContributionPresets (indirectly by Contribution) + wikiInfo: thing(V(WikiInfo)), + + // Expose only + + isFlash: exposeConstant(V(true)), + + commentatorArtists: commentatorArtists(), + + side: [ + withPropertyFromObject('act', V('side')), + exposeDependency('#act.side'), + ], + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, + }); + + static [Thing.findSpecs] = { + flash: { + referenceTypes: ['flash'], + bindTo: 'flashData', + }, + }; + + static [Thing.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'}, + 'Directory': {property: 'directory'}, + 'Page': {property: 'page'}, + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date': { + property: 'date', + 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': { + property: 'contributorContribs', + transform: parseContributors, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Review Points': {ignore: true}, + }, + }; + + getOwnArtworkPath(artwork) { + return [ + 'media.flashArt', + this.directory, + artwork.fileExtension, + ]; + } +} diff --git a/src/data/things/flash/FlashAct.js b/src/data/things/flash/FlashAct.js new file mode 100644 index 00000000..66d4ee1b --- /dev/null +++ b/src/data/things/flash/FlashAct.js @@ -0,0 +1,74 @@ + +import {input, V} from '#composite'; +import Thing from '#thing'; +import {isContentString} from '#validators'; + +import {withPropertyFromObject} from '#composite/data'; +import {exposeConstant, exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {color, directory, name, soupyFind, soupyReverse, thing, thingList} + from '#composite/wiki-properties'; + +export class FlashAct extends Thing { + static [Thing.referenceType] = 'flash-act'; + static [Thing.friendlyName] = `Flash Act`; + static [Thing.wikiData] = 'flashActData'; + + static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({ + // Update & expose + + side: thing(V(FlashSide)), + + name: name(V('Unnamed Flash Act')), + directory: directory(), + color: color(), + + listTerminology: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + withPropertyFromObject('side', V('listTerminology')), + exposeDependency('#side.listTerminology'), + ], + + flashes: thingList(V(Flash)), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // Expose only + + isFlashAct: exposeConstant(V(true)), + }); + + static [Thing.findSpecs] = { + flashAct: { + referenceTypes: ['flash-act'], + bindTo: 'flashActData', + }, + }; + + static [Thing.reverseSpecs] = { + flashActsWhoseFlashesInclude: { + bindTo: 'flashActData', + + referencing: flashAct => [flashAct], + referenced: flashAct => flashAct.flashes, + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Act': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + + 'Review Points': {ignore: true}, + }, + }; +} diff --git a/src/data/things/flash/FlashSide.js b/src/data/things/flash/FlashSide.js new file mode 100644 index 00000000..72782bdd --- /dev/null +++ b/src/data/things/flash/FlashSide.js @@ -0,0 +1,136 @@ +const FLASH_DATA_FILE = 'flashes.yaml'; + +import {V} from '#composite'; +import {sortFlashesChronologically} from '#sort'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {color, contentString, directory, name, soupyFind, thingList} + from '#composite/wiki-properties'; + +export class FlashSide extends Thing { + static [Thing.referenceType] = 'flash-side'; + static [Thing.friendlyName] = `Flash Side`; + static [Thing.wikiData] = 'flashSideData'; + + static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({ + // Update & expose + + name: name(V('Unnamed Flash Side')), + directory: directory(), + color: color(), + listTerminology: contentString(), + + acts: thingList(V(FlashAct)), + + // Update only + + find: soupyFind(), + + // Expose only + + isFlashSide: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Side': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + }, + }; + + static [Thing.findSpecs] = { + flashSide: { + referenceTypes: ['flash-side'], + bindTo: 'flashSideData', + }, + }; + + static [Thing.reverseSpecs] = { + flashSidesWhoseActsInclude: { + bindTo: 'flashSideData', + + referencing: flashSide => [flashSide], + referenced: flashSide => flashSide.acts, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {Flash, FlashAct}, + }) => ({ + title: `Process flashes file`, + file: FLASH_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + ('Side' in document + ? FlashSide + : 'Act' in document + ? FlashAct + : Flash), + + connect(results) { + let thing, i; + + for (i = 0; thing = results[i]; i++) { + if (thing.isFlashSide) { + const side = thing; + const acts = []; + + for (i++; thing = results[i]; i++) { + if (thing.isFlashAct) { + const act = thing; + const flashes = []; + + for (i++; thing = results[i]; i++) { + if (thing.isFlash) { + const flash = thing; + + flash.act = act; + flashes.push(flash); + + continue; + } + + 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; + } + + side.acts = acts; + + continue; + } + + if (thing.isFlashAct) { + throw new Error(`Acts must be under a side`); + } + + if (thing.isFlash) { + throw new Error(`Flashes must be under a side and act`); + } + } + }, + + sort({flashData}) { + sortFlashesChronologically(flashData); + }, + }); +} diff --git a/src/data/things/flash/index.js b/src/data/things/flash/index.js new file mode 100644 index 00000000..19b8cc34 --- /dev/null +++ b/src/data/things/flash/index.js @@ -0,0 +1,3 @@ +export * from './Flash.js'; +export * from './FlashAct.js'; +export * from './FlashSide.js'; diff --git a/src/data/things/index.js b/src/data/things/index.js index 4a3d7ad8..bf8a5a33 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -1,8 +1,11 @@ // Not actually the entry point for #things - that's init.js in this folder. export * from './album/index.js'; +export * from './content/index.js'; +export * from './flash/index.js'; export * from './group/index.js'; export * from './homepage-layout/index.js'; +export * from './sorting-rule/index.js'; export * from './AdditionalFile.js'; export * from './AdditionalName.js'; @@ -16,7 +19,3 @@ export * from './NewsEntry.js'; export * from './StaticPage.js'; export * from './Track.js'; export * from './WikiInfo.js'; - -export * from './content.js'; -export * from './flash.js'; -export * from './sorting-rule.js'; diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js deleted file mode 100644 index 101a4966..00000000 --- a/src/data/things/sorting-rule.js +++ /dev/null @@ -1,396 +0,0 @@ -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/sorting-rule/DocumentSortingRule.js b/src/data/things/sorting-rule/DocumentSortingRule.js new file mode 100644 index 00000000..0f67d8f5 --- /dev/null +++ b/src/data/things/sorting-rule/DocumentSortingRule.js @@ -0,0 +1,242 @@ +import {readFile, writeFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {V} from '#composite'; +import {chunkByProperties, compareArrays} from '#sugar'; +import Thing from '#thing'; +import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators'; + +import { + documentModes, + flattenThingLayoutToDocumentOrder, + getThingLayoutForFilename, + reorderDocumentsInYAMLSourceText, +} from '#yaml'; + +import {exposeConstant} from '#composite/control-flow'; + +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; +} + +import {ThingSortingRule} from './ThingSortingRule.js'; + +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/sorting-rule/SortingRule.js b/src/data/things/sorting-rule/SortingRule.js new file mode 100644 index 00000000..4ce9d97a --- /dev/null +++ b/src/data/things/sorting-rule/SortingRule.js @@ -0,0 +1,86 @@ +const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; + +import {V} from '#composite'; +import {unique} from '#sugar'; +import Thing from '#thing'; +import {isStringNonEmpty} from '#validators'; + +import {exposeConstant} from '#composite/control-flow'; +import {flag} from '#composite/wiki-properties'; + +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}); + } + } +} diff --git a/src/data/things/sorting-rule/ThingSortingRule.js b/src/data/things/sorting-rule/ThingSortingRule.js new file mode 100644 index 00000000..b5cc76dc --- /dev/null +++ b/src/data/things/sorting-rule/ThingSortingRule.js @@ -0,0 +1,83 @@ +import {V} from '#composite'; +import Thing from '#thing'; +import {isStringNonEmpty, strictArrayOf} from '#validators'; + +import { + compareCaseLessSensitive, + sortByDate, + sortByDirectory, + sortByName, +} from '#sort'; + +import {exposeConstant} from '#composite/control-flow'; + +import {SortingRule} from './SortingRule.js'; + +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; + } +} diff --git a/src/data/things/sorting-rule/index.js b/src/data/things/sorting-rule/index.js new file mode 100644 index 00000000..7b83bd44 --- /dev/null +++ b/src/data/things/sorting-rule/index.js @@ -0,0 +1,3 @@ +export * from './SortingRule.js'; +export * from './ThingSortingRule.js'; +export * from './DocumentSortingRule.js'; -- cgit 1.3.0-6-gf8a5