From 2cb65e7d43ca09262e30d6e04b75f57af3d6c542 Mon Sep 17 00:00:00 2001 From: ROpdebee <15186467+ROpdebee@users.noreply.github.com> Date: Thu, 16 Jun 2022 11:44:23 +0200 Subject: [PATCH 01/23] refactor(blind votes): migrate to TypeScript --- src/mb_blind_votes/index.ts | 96 ++++++++++++++++++++++++++++++++ src/mb_blind_votes/meta.ts | 13 +++++ src/mb_blind_votes/tsconfig.json | 7 +++ 3 files changed, 116 insertions(+) create mode 100644 src/mb_blind_votes/index.ts create mode 100644 src/mb_blind_votes/meta.ts create mode 100644 src/mb_blind_votes/tsconfig.json diff --git a/src/mb_blind_votes/index.ts b/src/mb_blind_votes/index.ts new file mode 100644 index 00000000..2262bb71 --- /dev/null +++ b/src/mb_blind_votes/index.ts @@ -0,0 +1,96 @@ +import { assertNonNull } from '@lib/util/assert'; +import { onDocumentLoaded, qsa } from '@lib/util/dom'; + +function setupStyle(): void { + const style = document.createElement('style'); + style.id = 'ROpdebee_blind_votes'; + document.head.append(style); + // Names and votes + style.sheet!.insertRule(` + /* Edit pages */ + div#content:not(.unblind) div.edit-header > p.subheader > a, /* Editor */ + div#content:not(.unblind) table.vote-tally tr:nth-child(n+3), /* Vote */ + div#content:not(.unblind) table.vote-tally tr:nth-child(n+3) a, /* Voter */ + div#content:not(.unblind) table.vote-tally tr:nth-child(1) td, /* Vote tally */ + div#content:not(.unblind) div.edit-notes h3 > a:not(.date), /* Edit note author */ + + /* Edit lists */ + div.edit-list:not(.unblind) div.edit-header > p.subheader > a, /* Editor */ + div.edit-list:not(.unblind) div.edit-notes h3 > a:not(.date) /* Edit note author */ + { + color: black; + background-color: black; + }`); + // Profile images + style.sheet!.insertRule(` + /* Edit pages */ + div#content:not(.unblind) div.edit-header > p.subheader > a > img, /* Editor */ + div#content:not(.unblind) table.vote-tally th > a > img, /* Voter */ + div#content:not(.unblind) div.edit-notes h3 > a:not(.date) > img, /* Edit note author */ + div#content:not(.unblind) div.edit-notes h3 > div.voting-icon, /* Vote icon */ + + /* Edit lists */ + div.edit-list:not(.unblind) div.edit-header > p.subheader > a > img, /* Editor */ + div.edit-list:not(.unblind) div.edit-notes h3 > a:not(.date) > img, /* Edit note author */ + div.edit-list:not(.unblind) div.edit-notes h3 > div.voting-icon /* Vote icon */ + { + display: none; + }`); +} + +function onVoteSelected(evt: Event): void { + // TODO: Don't we need to verify that the target is actually selected before + // deciding to unblind? I think this is now relying on the order in which + // events arrive. + assertNonNull(evt.target); + const target = evt.target as HTMLInputElement; + target + .closest('div.edit-list') + ?.classList.add('unblind'); + // Make sure we also add .unblind to the content div on edit lists + // otherwise the CSS rules for the edit page still apply. + target + .closest('div#content') + ?.classList.add('unblind'); +} + +function onNoVoteSelected(evt: Event): void { + assertNonNull(evt.target); + const target = evt.target as HTMLInputElement; + target + .closest('div.edit-list, div#content') + ?.classList.remove('unblind'); +} + +function setupUnblindListeners(): void { + for (const voteButton of qsa('input[name^="enter-vote.vote"]:not([id$="-None"])')) { + voteButton.addEventListener('change', onVoteSelected); + } + + for (const noVoteButton of qsa('input[name^="enter-vote.vote"][id$="-None"]')) { + noVoteButton.addEventListener('change', onNoVoteSelected); + } +} + +setupStyle(); +// TODO: Why is this here? It's also on the document ready callback +setupUnblindListeners(); + +// Unblind any edits that aren't open, are your own, or on which you already voted +onDocumentLoaded(() => { + setupUnblindListeners(); + + const unblindEdits = qsa(` + div.edit-header:not(.open), + div.cancel-edit > a.negative[href*="/cancel"], + input[name^="enter-vote.vote"]:checked:not([id$="-None"])`); + + for (const unblindEdit of unblindEdits) { + unblindEdit + .closest('div.edit-list') + ?.classList.add('unblind'); + unblindEdit + .closest('div#content') + ?.classList.add('unblind'); + } +}); diff --git a/src/mb_blind_votes/meta.ts b/src/mb_blind_votes/meta.ts new file mode 100644 index 00000000..c20ff382 --- /dev/null +++ b/src/mb_blind_votes/meta.ts @@ -0,0 +1,13 @@ +import type { UserscriptMetadata } from '@lib/util/metadata'; +import { MB_EDIT_PAGE_PATHS, transformMBMatchURL } from '@lib/util/metadata'; + +const metadata: UserscriptMetadata = { + name: 'MB: Blind Votes', + description: 'Blinds editor details before your votes are cast.', + // FIXME: This should run at document-start to ensure that editor details + // don't flash onto the screen while the page is still loading. + 'run-at': 'document-end', + match: MB_EDIT_PAGE_PATHS.map((path) => transformMBMatchURL(path)), +}; + +export default metadata; diff --git a/src/mb_blind_votes/tsconfig.json b/src/mb_blind_votes/tsconfig.json new file mode 100644 index 00000000..0146fff6 --- /dev/null +++ b/src/mb_blind_votes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../configs/tsconfig.base-web.json", + "include": ["**/*"], + "references": [ + { "path": "../lib/" } + ], +} From 36983fd8976e29f2992e1c4342af7fa9daa3dbc1 Mon Sep 17 00:00:00 2001 From: ROpdebee <15186467+ROpdebee@users.noreply.github.com> Date: Thu, 16 Jun 2022 13:28:52 +0200 Subject: [PATCH 02/23] refactor(inline recs): migrate to TypeScript --- src/lib/MB/URLs.ts | 2 +- src/lib/MB/advanced-relationships.ts | 16 --- src/lib/MB/types-api.ts | 45 ++++++++ src/lib/MB/types.ts | 7 ++ src/lib/util/array.ts | 28 +++++ src/lib/util/dom.ts | 29 +++++ src/mb_qol_inline_recording_tracks/index.tsx | 101 ++++++++++++++++++ src/mb_qol_inline_recording_tracks/meta.ts | 16 +++ .../tsconfig.json | 10 ++ tests/unit/lib/util/array.test.ts | 16 ++- 10 files changed, 252 insertions(+), 18 deletions(-) delete mode 100644 src/lib/MB/advanced-relationships.ts create mode 100644 src/lib/MB/types-api.ts create mode 100644 src/mb_qol_inline_recording_tracks/index.tsx create mode 100644 src/mb_qol_inline_recording_tracks/meta.ts create mode 100644 src/mb_qol_inline_recording_tracks/tsconfig.json diff --git a/src/lib/MB/URLs.ts b/src/lib/MB/URLs.ts index 2737f660..93b87df9 100644 --- a/src/lib/MB/URLs.ts +++ b/src/lib/MB/URLs.ts @@ -1,4 +1,4 @@ -import type { ReleaseAdvRel, URLAdvRel } from './advanced-relationships'; +import type { ReleaseAdvRel, URLAdvRel } from './types-api'; interface ReleaseMetadataWithARs { relations?: Array; diff --git a/src/lib/MB/advanced-relationships.ts b/src/lib/MB/advanced-relationships.ts deleted file mode 100644 index cf04d68a..00000000 --- a/src/lib/MB/advanced-relationships.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Incomplete type definitions for ws/ API advanced relationships -export interface AdvancedRelationship { - ended: boolean; -} - -export interface URLAdvRel extends AdvancedRelationship { - url: { - resource: string; - }; -} - -export interface ReleaseAdvRel extends AdvancedRelationship { - release: { - id: string; - }; -} diff --git a/src/lib/MB/types-api.ts b/src/lib/MB/types-api.ts new file mode 100644 index 00000000..d81765b8 --- /dev/null +++ b/src/lib/MB/types-api.ts @@ -0,0 +1,45 @@ +// Incomplete type definitions for ws/ API responses. +export interface AdvancedRelationship { + ended: boolean; +} + +export interface URLAdvRel extends AdvancedRelationship { + url: { + resource: string; + }; +} + +export interface ReleaseAdvRel extends AdvancedRelationship { + release: { + id: string; + }; +} + +// TODO: Refactor this so it's not a pyramid. +export interface Recording { + id: string; + title: string; + releases: Array<{ + id: string; + title: string; + media: Array<{ + position: number; + track: Array<{ + id: string; + number: string; + title: string; + length: number; + }>; + }>; + }>; +} + +interface BaseAPIResponse { + created: string; + count: number; + offset: number; +}; + +export type APIResponse = BaseAPIResponse & { + [key in Key]: Value[]; +}; diff --git a/src/lib/MB/types.ts b/src/lib/MB/types.ts index 7119a498..81e824fe 100644 --- a/src/lib/MB/types.ts +++ b/src/lib/MB/types.ts @@ -32,5 +32,12 @@ declare global { current: ExternalLinks; }; }; + + __MB__?: { + DBDefs: { + GIT_BRANCH: string; + GIT_SHA: string; + }; + }; } } diff --git a/src/lib/util/array.ts b/src/lib/util/array.ts index a224947f..97db8c93 100644 --- a/src/lib/util/array.ts +++ b/src/lib/util/array.ts @@ -44,3 +44,31 @@ export function collatedSort(array: string[]): string[] { export function enumerate(array: T[]): Array<[T, number]> { return array.map((el, idx) => [el, idx]); } + +export function splitChunks(arr: readonly T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += chunkSize) { + chunks.push(arr.slice(i, i + chunkSize)); + } + + return chunks; +} + +/** + * Create an array wherein a given element is inserted between every two + * consecutive elements of the original array. + * + * Example: + * insertBetween([1,2,3], 0) // => [1, 0, 2, 0, 3] + * insertBetween([1], 0) // => [1] + * + * @param {readonly T1[]} arr The original array. + * @param {T2} newElement The element to insert. + * @return {(Array)} Resulting array. + */ +export function insertBetween(arr: readonly T1[], newElement: T2): Array { + return [ + ...arr.slice(0, 1), + ...arr.slice(1).flatMap((elmt) => [newElement, elmt]), + ]; +} diff --git a/src/lib/util/dom.ts b/src/lib/util/dom.ts index 18ea7c6f..c507e8b4 100644 --- a/src/lib/util/dom.ts +++ b/src/lib/util/dom.ts @@ -80,3 +80,32 @@ export function setInputValue(input: HTMLInputElement, value: string, dispatchEv input.dispatchEvent(new Event('input', { bubbles: true })); } } + +/** + * Notify when a given element has finished React hydration. + * + * If the element was already hydrated, it will run the callback immediately. + * + * @param {HTMLElement} element The element. Must be a React root that + * gets hydrated. + * @param {() => void} callback The callback. + */ +export function onReactHydrated(element: HTMLElement, callback: () => void): void { + // MBS will fire a custom `mb-hydration` event whenever a React component gets + // hydrated. We need to wait for hydration to complete before modifying the + // component, React gets mad otherwise. However, it is possible that hydration + // has already occurred, in which case a `_reactListening` attribute with a + // random suffix will be added to the element. + const alreadyHydrated = element.getAttributeNames() + .some((attrName) => attrName.startsWith('_reactListening') && element.getAttribute(attrName)); + + if (alreadyHydrated) { + callback(); + } else if (window.__MB__?.DBDefs.GIT_BRANCH === 'production' && window.__MB__.DBDefs.GIT_SHA === '923237cf73') { + // Current production version does not have this custom event yet. + // TODO: Remove this when prod is updated. + window.addEventListener('load', callback); + } else { + element.addEventListener('mb-hydration', callback); + } +} diff --git a/src/mb_qol_inline_recording_tracks/index.tsx b/src/mb_qol_inline_recording_tracks/index.tsx new file mode 100644 index 00000000..2de9d491 --- /dev/null +++ b/src/mb_qol_inline_recording_tracks/index.tsx @@ -0,0 +1,101 @@ +import pThrottle from 'p-throttle'; + +import type { APIResponse, Recording } from '@lib/MB/types-api'; +import { insertBetween, splitChunks } from '@lib/util/array'; +import { logFailure } from '@lib/util/async'; +import { onReactHydrated, qs, qsa, qsMaybe } from '@lib/util/dom'; +import { safeParseJSON } from '@lib/util/json'; + +// FIXME: These types need to be refactored in @lib/MB/types-api +type RecordingRelease = Recording['releases'][0]; +type RecordingReleaseMedia = RecordingRelease['media']; +type MediumTrack = RecordingReleaseMedia[0]['track'][0]; + + +// Throttled fetching from API so that only one request per 0.5s is made. +const throttledFetch = pThrottle({ limit: 1, interval: 500 })(fetch); + +async function loadRecordingInfo(rids: string[]): Promise> { + const query = rids.map((rid) => 'rid:' + rid).join(' OR '); + const url = document.location.origin + '/ws/2/recording?fmt=json&query=' + query; + const resp = await throttledFetch(url); + const respContent = safeParseJSON>(await resp.text(), 'Could not parse API response'); + + const perRecId = new Map(); + + respContent.recordings.forEach((rec) => { + perRecId.set(rec.id, rec); + }); + return perRecId; +} + +function getTrackIndexElement(track: MediumTrack, mediumPosition: number): HTMLElement { + return #{mediumPosition.toString()}.{track.number}; +} + +function getTrackIndexElements(media: RecordingReleaseMedia): Array { + const tracks = media.flatMap((medium) => medium.track.map((track) => { + return getTrackIndexElement(track, medium.position); + })); + + return insertBetween(tracks, ', '); +} + +function getReleaseNameElement(release: RecordingRelease): HTMLElement { + return {release.title}; +} + +function formatRow(release: RecordingRelease): Array { + // TODO: Use JSX fragments. nativejsx doesn't support those (yet) though. + return [ + getReleaseNameElement(release), + ' (', + ...getTrackIndexElements(release.media), + ')', + + ]; +} + +function insertRows(recordingTd: HTMLTableCellElement, recordingInfo: Recording): void { + const rowElements = recordingInfo.releases + .map((release) => formatRow(release)) + .map((row) =>
+
appears on:
+
{row}
+
); + + qs('div.ars', recordingTd) + .insertAdjacentElement('beforebegin',
+ {rowElements} +
); +} + +function loadAndInsert(): void { + const recAnchors = qsa('table.medium td > a[href^="/recording/"]:first-child, table.medium td > span:first-child > a[href^="/recording/"]:first-child'); + const todo = recAnchors + .map((a): [HTMLTableCellElement, string] => [a.closest('td')!, a.href.split('/recording/')[1]]) + .filter(([td]) => qsMaybe('div.ars.ROpdebee_inline_tracks', td) === null); + const chunks = splitChunks(todo, 20); + + logFailure(Promise.all(chunks.map(async (chunk) => { + const recInfo = await loadRecordingInfo(chunk.map(([, recId]) => recId)); + chunk.forEach(([td, recId]) => { + insertRows(td, recInfo.get(recId)!); + }); + }))); +} + +onReactHydrated(qs('.tracklist-and-credits'), function() { + // Callback as a function instead of a lambda here because when nativejsx + // transpiles the JSX below, it wraps it in a functions which it calls as + // `.call(this)` (to inject the outer scope's this), but that produces rollup + // warnings since `this` is not available at the top level. + // TODO: Can we edit nativejsx to instead wrap it in a lambda? Then `this` + // wouldn't need to be injected. + const button = ; + + qs('span#medium-toolbox') + .firstChild?.before(button, ' | '); +}); diff --git a/src/mb_qol_inline_recording_tracks/meta.ts b/src/mb_qol_inline_recording_tracks/meta.ts new file mode 100644 index 00000000..d7901e7b --- /dev/null +++ b/src/mb_qol_inline_recording_tracks/meta.ts @@ -0,0 +1,16 @@ +import type { UserscriptMetadata } from '@lib/util/metadata'; +import { transformMBMatchURL } from '@lib/util/metadata'; + +const metadata: UserscriptMetadata = { + // FIXME: This name isn't very descriptive. + name: 'MB: QoL: Inline all recordings\' tracks on releases', + description: 'Display all tracks and releases on which a recording appears from the release page.', + 'run-at': 'document-end', + match: transformMBMatchURL('release/*'), + exclude: [ + transformMBMatchURL('release/add'), + transformMBMatchURL('release/*/edit*'), + ], +}; + +export default metadata; diff --git a/src/mb_qol_inline_recording_tracks/tsconfig.json b/src/mb_qol_inline_recording_tracks/tsconfig.json new file mode 100644 index 00000000..366aa6e4 --- /dev/null +++ b/src/mb_qol_inline_recording_tracks/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../configs/tsconfig.base-web.json", + "include": ["**/*"], + "references": [ + { "path": "../lib/" } + ], + "compilerOptions": { + "types": ["nativejsx/types/jsx"] + } +} diff --git a/tests/unit/lib/util/array.test.ts b/tests/unit/lib/util/array.test.ts index a7e2ce31..e4c0b55f 100644 --- a/tests/unit/lib/util/array.test.ts +++ b/tests/unit/lib/util/array.test.ts @@ -1,4 +1,4 @@ -import { filterNonNull, findRight, groupBy } from '@lib/util/array'; +import { filterNonNull, findRight, groupBy, insertBetween } from '@lib/util/array'; describe('filtering null values', () => { it('retains non-null values', () => { @@ -57,3 +57,17 @@ describe('group by', () => { expect(result).toStrictEqual(expected); }); }); + +describe('insert between', () => { + it('returns empty array when given empty array', () => { + expect(insertBetween([], 0)).toStrictEqual([]); + }); + + it('does not add elements for array with one element', () => { + expect(insertBetween([1], 0)).toStrictEqual([1]); + }); + + it('adds element for array with multiple elements', () => { + expect(insertBetween([1, 2, 3], 0)).toStrictEqual([1, 0, 2, 0, 3]); + }); +}); From 1c59e35372059beda61ca5b3860f7167ee24566d Mon Sep 17 00:00:00 2001 From: ROpdebee <15186467+ROpdebee@users.noreply.github.com> Date: Thu, 16 Jun 2022 16:44:00 +0200 Subject: [PATCH 03/23] refactor(seed disamb): migrate to TypeScript, drop jQuery --- src/lib/MB/types-api.ts | 81 +++++ .../index.tsx | 283 ++++++++++++++++++ .../meta.ts | 15 + .../tsconfig.json | 10 + 4 files changed, 389 insertions(+) create mode 100644 src/mb_qol_seed_recording_disambiguation/index.tsx create mode 100644 src/mb_qol_seed_recording_disambiguation/meta.ts create mode 100644 src/mb_qol_seed_recording_disambiguation/tsconfig.json diff --git a/src/lib/MB/types-api.ts b/src/lib/MB/types-api.ts index d81765b8..566f0b85 100644 --- a/src/lib/MB/types-api.ts +++ b/src/lib/MB/types-api.ts @@ -34,6 +34,87 @@ export interface Recording { }>; } +interface BaseArea { + containment: Area[]; + primary_code: string | null; + name: string; +} + +export interface AreaCountry extends BaseArea { + typeID: 1; + country_code: string; + primary_code: string; +} + +export interface AreaSubdivision extends BaseArea { + typeID: 2; + country_code: 0; +} + +export interface AreaCity extends BaseArea { + typeID: 3; + country_code: 0; +} + +export interface AreaMunicipality extends BaseArea { + typeID: 4; + country_code: 0; +} + +export type Area = AreaCountry | AreaSubdivision | AreaCity | AreaMunicipality; + +export interface Place { + name: string; + area: Area; +} + +export interface RelationshipDate { + day: number | null; + month: number | null; + year: number | null; +} + +interface BaseRecordingRelationship { + linkTypeID: number; + attributes?: Array<{ + typeID: number; + }>; + entity0_credit: string; + + begin_date: RelationshipDate | null; + end_date: RelationshipDate | null; +} + +interface RecordingRelationshipWithTarget extends BaseRecordingRelationship { + linkTypeID: LinkTypeID; + target: TargetT; +} + +type RecordingRelationship = + RecordingRelationshipWithTarget<278, unknown /* work */> + | RecordingRelationshipWithTarget<693, Place> + | RecordingRelationshipWithTarget<698, Area> + | BaseRecordingRelationship; + +export interface ReleaseRecordingRels { + id: string; + mediums: Array<{ + position: number; + tracks: Array<{ + gid: string; + number: string; + recording: { + comment: string; + relationships: RecordingRelationship[]; + }; + }>; + }>; + + releaseGroup: { + secondaryTypeIDs?: number[]; + }; +} + interface BaseAPIResponse { created: string; count: number; diff --git a/src/mb_qol_seed_recording_disambiguation/index.tsx b/src/mb_qol_seed_recording_disambiguation/index.tsx new file mode 100644 index 00000000..dd4195f6 --- /dev/null +++ b/src/mb_qol_seed_recording_disambiguation/index.tsx @@ -0,0 +1,283 @@ +import type { Area, AreaCity, AreaCountry, AreaMunicipality, AreaSubdivision, RelationshipDate, ReleaseRecordingRels } from '@lib/MB/types-api'; +import { GMinfo } from '@lib/compat'; +import { filterNonNull } from '@lib/util/array'; +import { logFailure } from '@lib/util/async'; +import { qs, qsMaybe } from '@lib/util/dom'; + +class ConflictError extends Error {} + +// TODO: Refactor. +type Medium = ReleaseRecordingRels['mediums'][0]; +type Track = Medium['tracks'][0]; +type ReleaseGroup = ReleaseRecordingRels['releaseGroup']; +type Recording = Track['recording']; +type RecordingRelationship = Recording['relationships'][0]; + +type FilteredRelationship = ( + RelationshipT extends { linkTypeID: LinkTypeID } ? RelationshipT : never); +type RecordedAtPlaceRel = FilteredRelationship; +type RecordedInAreaRel = FilteredRelationship; + +function unicodeToAscii(s: string): string { + // Facilitate comparisons + return s + .replace(/[“”″]/g, '"') + .replace(/[‘’′]/g, "'") + .replace(/[‐‒–]/g, '-'); +} + +function getReleaseTitle(): string { + // Make sure we're only taking the first element. There could be a + // second if the release has a disambiguation comment itself. + return document.querySelector('.releaseheader > h1 bdi')!.textContent!; +} + +function getDJMixComment(): string { + return `part of “${getReleaseTitle()}” DJ‐mix`; +} + +async function getRecordingRels(relGid: string): Promise { + const resp = await fetch(`${document.location.origin}/ws/js/release/${relGid}?inc=rels recordings`); + return resp.json() as Promise; +} + +function stringifyDate(date: RelationshipDate): string { + const year = date.year ? date.year.toString().padStart(4, '0') : '????'; + const month = date.month ? date.month.toString().padStart(2, '0') : '??'; + const day = date.day ? date.day.toString().padStart(2, '0') : '??'; + + // If neither year, month, or day is set, will return '????' + return [year, month, day].join('‐') + .replace(/(?:‐\?{2}){1,2}$/, ''); // Remove -?? or -??-?? suffix. +} + +function getDateStr(rel: RecordingRelationship): string | null { + if (!rel.begin_date || !rel.end_date) return null; + + const [beginStr, endStr] = [rel.begin_date, rel.end_date].map((date) => stringifyDate(date)); + + if (beginStr === '????' || endStr === '????') return null; + + return beginStr === endStr ? beginStr : `${beginStr}–${endStr}`; +} + +function selectCommentPart(candidates: Set, partName: string): string | null { + if (candidates.size === 0) return null; + + if (candidates.size > 1) { + throw new ConflictError(`Conflicting ${partName}: ${[...candidates].join(' vs. ')}`); + } + + return candidates.values().next().value as string; +} + +function filterRels(rels: RecordingRelationship[], linkTypeID: LinkTypeID): Array> { + return rels + .filter((rel) => rel.linkTypeID === linkTypeID) as Array>; +} + +function getRecordingVenue(rels: RecordingRelationship[]): string | null { + // 693 = recorded at + const venuesFormatted = new Set(filterRels(rels, 693) + .map((placeRel) => formatRecordingVenue(placeRel))); + + return selectCommentPart(venuesFormatted, '“recorded at” ARs'); +} + +function getRecordingArea(rels: RecordingRelationship[]): string | null { + // 698 = recorded in + const areasFormatted = new Set(filterRels(rels, 698) + .map((areaRel) => formatRecordingArea(areaRel))); + + return selectCommentPart(areasFormatted, '“recorded in” ARs'); +} + +function formatRecordingVenue(placeRel: RecordedAtPlaceRel): string { + // place ARs returned by the API seem to always be in the backward direction, + // i.e. the place is the target, but entity0 remains the place. + return (placeRel.entity0_credit || placeRel.target.name) + ', ' + formatRecordingBareArea(placeRel.target.area); +} + +function formatRecordingArea(areaRel: RecordedInAreaRel): string { + return formatRecordingBareArea(areaRel.target); +} + +function formatRecordingBareArea(area: Area): string { + const areaList = [area, ...area.containment]; + let city: AreaCity | AreaMunicipality | null = null; + let country: AreaCountry | null = null; + let state: AreaSubdivision | null = null; + + // Least to most specific, retain only most specific except for states (subdivisions) + for (let i = areaList.length - 1; i >= 0; i--) { + const areaPart = areaList[i]; + switch (areaPart.typeID) { + case 1: + country = areaPart; + break; + + case 2: + state = state ?? areaPart; + break; + + case 3: + case 4: + city = areaPart; + break; + } + } + + if (!country || !['US', 'CA'].includes(country.country_code)) { + state = null; + } + + let countryName: string | null; + if (!country) countryName = null; + else if (country.primary_code === 'US') countryName = 'USA'; + else if (country.primary_code === 'GB') countryName = 'UK'; + else countryName = country.name; + + const stateName = state?.primary_code?.split('-')[1]; + // Exception for Washington D.C., it's set as a subdivision in MB, leading + // to comments for venues in DC to be "live, ...: , DC, USA" without + // the city name. + const cityName = city?.name || (stateName === 'DC' && 'Washington'); + const parts = [cityName, stateName, countryName].filter(Boolean); + return parts.join(', '); +} + +function getRecordingDate(rels: RecordingRelationship[]): string | null { + const dateStrs = new Set(filterNonNull(rels + .filter((rel) => [698, 693, 278].includes(rel.linkTypeID)) + .map((rel) => getDateStr(rel)))); + + return selectCommentPart(dateStrs, 'recording dates'); +} + +function getRecordingLiveComment(rec: Recording): string { + const rels = rec.relationships; + // Fall back on "recorded in" rels if we can't extract a place + const place = getRecordingVenue(rels) ?? getRecordingArea(rels); + + const date = getRecordingDate(rels); + + let comment = 'live'; + if (date) comment += ', ' + date; + if (place) comment += ': ' + place; + + return comment; +} + +function isLiveRecording(rec: Recording, releaseGroup: ReleaseGroup): boolean { + // 278 = recording of + const recordingRelationships = filterRels(rec.relationships, 278); + + // Consider this a live recording if there is a linked work with a live + // attribute set or if there is no linked recording but the RG has the live + // type set. If there are linked recordings but no live attributes, or + // there are no linked recordings and no live on the RG, be conservative + // and don't consider it live. + // eslint-disable-next-line unicorn/prefer-ternary -- Too complex. + if (recordingRelationships.length > 0) { + return recordingRelationships + .some((recRel) => (recRel.attributes ?? []).find((attr) => attr.typeID === 578)); + } else { + return (releaseGroup.secondaryTypeIDs ?? []).includes(6); + } +} + +function fillInput(input: HTMLInputElement | HTMLTextAreaElement, value: string): void { + input.value = value; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('input.rc')); +} + +function seedDJMix(): void { + fillInput(qs('input#all-recording-comments'), getDJMixComment()); + fillInput(qs('textarea#recording-comments-edit-note'), `${GMinfo.script.name} v${GMinfo.script.version}: Seed DJ‐mix comments`); +} + +function displayWarning(msg: string): void { + const warnList = qs('#ROpdebee_seed_comments_warnings'); + warnList.append(
  • {msg}
  • ); + warnList.closest('tr')!.style.display = ''; +} + +function createTrackLiveComment(track: Track, medium: Medium, releaseInfo: ReleaseRecordingRels): [string, string] { + const rec = track.recording; + + if (!isLiveRecording(rec, releaseInfo.releaseGroup)) { + displayWarning(`Skipping track #${medium.position}.${track.number}: Not a live recording`); + return [track.gid, rec.comment]; + } + + const existing = unicodeToAscii(rec.comment.trim()); + try { + const newComment = getRecordingLiveComment(rec); + if (existing && existing !== 'live' && existing !== unicodeToAscii(newComment)) { + // Conflicting comments, refuse to enter + throw new ConflictError(`Significant differences between old and new comments: ${existing} vs ${newComment}`); + } + return [track.gid, newComment]; + } catch (err) { + if (!(err instanceof ConflictError)) throw err; + displayWarning(`Track #${medium.position}.${track.number}: Refusing to update comment: ${err.message}`); + return [track.gid, rec.comment]; + } +} + +async function seedLive(): Promise { + const relInfo = await getRecordingRels(document.location.pathname.split('/')[2]); + const recComments = relInfo.mediums + .flatMap((medium) => medium.tracks + .map((track) => createTrackLiveComment(track, medium, relInfo))); + + const uniqueComments = [...new Set(recComments.map(([, comment]) => comment))]; + if (uniqueComments.length === 1) { + fillInput(qs('input#all-recording-comments'), uniqueComments[0]); + } else { + recComments.forEach(([trackGid, comment]) => { + fillInput(qs(`tr#${trackGid} input.recording-comment`), comment); + }); + } + + fillInput(qs('textarea#recording-comments-edit-note'), `${GMinfo.script.name} v${GMinfo.script.version}: Seed live comments`); +} + +function insertButtons(): void { + const tr = qsMaybe('table#set-recording-comments tr'); + if (tr === null) { + // Try again later, might not be loaded yet + // This will spin indefinitely if the batch recording comments script is not installed. wontfix + setTimeout(insertButtons, 500); + return; + } + + const liveButton = ; + const djButton = ; + + tr.after( + + Seed recording comments: + {liveButton} | {djButton} + , + + Warnings + +