diff --git a/build/plugin-userscript.ts b/build/plugin-userscript.ts index eb5a5b3a6..1cb07a4cd 100644 --- a/build/plugin-userscript.ts +++ b/build/plugin-userscript.ts @@ -30,7 +30,7 @@ const DEFAULT_OPTIONS = { metadataOrder: [ 'name', 'description', 'version', 'author', 'license', 'namespace', 'homepageURL', 'supportURL', 'downloadURL', 'updateURL', - 'match', 'exclude', 'require', 'resource', 'run-at', 'grant', 'connect', + 'include', 'exclude', 'require', 'resource', 'run-at', 'grant', 'connect', ], }; diff --git a/package-lock.json b/package-lock.json index 9033412d9..0b173dff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,10 @@ "@types/greasemonkey": "4.0.3", "@types/jest": "28.1.6", "@types/jest-when": "3.5.2", + "@types/jquery": "^3.5.14", + "@types/jqueryui": "^1.12.16", "@types/postcss-preset-env": "7.7.0", + "@types/resemblejs": "^4.1.0", "@types/rollup__plugin-virtual": "2.0.1", "@types/rollup-plugin-progress": "1.1.1", "@types/setup-polly-jest": "0.5.1", @@ -81,6 +84,7 @@ "jest-html-reporters": "3.0.10", "jest-when": "3.5.1", "license-checker": "25.0.1", + "moment": "^2.29.3", "nativejsx": "github:ROpdebee/nativejsx", "node-fetch": "3.2.10", "postcss": "8.4.16", @@ -3568,6 +3572,57 @@ "@types/jest": "*" } }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.1.tgz", + "integrity": "sha512-wwJbVTGFHeucr5Jw2bQ9P+VYHyLdAqedFLEkdQUVaBF/eiidDwH5OpilINq4mEfhbCjLnirt6HTTDhv1HaTIQw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.0.2", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/@types/jquery": { + "version": "3.5.14", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.14.tgz", + "integrity": "sha512-X1gtMRMbziVQkErhTQmSe2jFwwENA/Zr+PprCkF63vFq+Yt5PZ4AlKqgmeNlwgn7dhsXEK888eIW2520EpC+xg==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@types/jqueryui": { + "version": "1.12.16", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.16.tgz", + "integrity": "sha512-6huAQDpNlso9ayaUT9amBOA3kj02OCeUWs+UvDmbaJmwkHSg/HLsQOoap/D5uveN9ePwl72N45Bl+Frp5xyG1Q==", + "dev": true, + "dependencies": { + "@types/jquery": "*" + } + }, "node_modules/@types/jsdom": { "version": "16.2.15", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.15.tgz", @@ -3671,6 +3726,12 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "node_modules/@types/resemblejs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@types/resemblejs/-/resemblejs-4.1.0.tgz", + "integrity": "sha512-+MIkKy/UngDfhTnvn2yK/KSzlbtLeB5BU73qqZrzIF24+e2r8enJ4cW3UbtkstByYSDV8pbheGAqg7zT8ZZ2pA==", + "dev": true + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -3768,6 +3829,12 @@ "@types/pollyjs__core": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -10081,6 +10148,15 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz", + "integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -16994,6 +17070,32 @@ "requires": { "jest-matcher-utils": "^28.0.0", "pretty-format": "^28.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.1.tgz", + "integrity": "sha512-wwJbVTGFHeucr5Jw2bQ9P+VYHyLdAqedFLEkdQUVaBF/eiidDwH5OpilINq4mEfhbCjLnirt6HTTDhv1HaTIQw==", + "dev": true, + "requires": { + "@jest/schemas": "^28.0.2", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + } } }, "@types/jest-when": { @@ -17005,6 +17107,24 @@ "@types/jest": "*" } }, + "@types/jquery": { + "version": "3.5.14", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.14.tgz", + "integrity": "sha512-X1gtMRMbziVQkErhTQmSe2jFwwENA/Zr+PprCkF63vFq+Yt5PZ4AlKqgmeNlwgn7dhsXEK888eIW2520EpC+xg==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, + "@types/jqueryui": { + "version": "1.12.16", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.16.tgz", + "integrity": "sha512-6huAQDpNlso9ayaUT9amBOA3kj02OCeUWs+UvDmbaJmwkHSg/HLsQOoap/D5uveN9ePwl72N45Bl+Frp5xyG1Q==", + "dev": true, + "requires": { + "@types/jquery": "*" + } + }, "@types/jsdom": { "version": "16.2.15", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.15.tgz", @@ -17108,6 +17228,12 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "@types/resemblejs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@types/resemblejs/-/resemblejs-4.1.0.tgz", + "integrity": "sha512-+MIkKy/UngDfhTnvn2yK/KSzlbtLeB5BU73qqZrzIF24+e2r8enJ4cW3UbtkstByYSDV8pbheGAqg7zT8ZZ2pA==", + "dev": true + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -17197,6 +17323,12 @@ "@types/pollyjs__core": "*" } }, + "@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -21805,6 +21937,12 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true }, + "moment": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz", + "integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index d2c006117..80eb7f9a4 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,10 @@ "@types/greasemonkey": "4.0.3", "@types/jest": "28.1.6", "@types/jest-when": "3.5.2", + "@types/jquery": "^3.5.14", + "@types/jqueryui": "^1.12.16", "@types/postcss-preset-env": "7.7.0", + "@types/resemblejs": "^4.1.0", "@types/rollup__plugin-virtual": "2.0.1", "@types/rollup-plugin-progress": "1.1.1", "@types/setup-polly-jest": "0.5.1", @@ -88,6 +91,7 @@ "jest-html-reporters": "3.0.10", "jest-when": "3.5.1", "license-checker": "25.0.1", + "moment": "^2.29.3", "nativejsx": "github:ROpdebee/nativejsx", "node-fetch": "3.2.10", "postcss": "8.4.16", diff --git a/src/lib/MB/URLs.ts b/src/lib/MB/URLs.ts index 2737f660e..93b87df97 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 cf04d68a8..000000000 --- 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 000000000..caa640b4b --- /dev/null +++ b/src/lib/MB/types-api.ts @@ -0,0 +1,150 @@ +// 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 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[]; + }; +} + +export interface Release { + id: string; + packagingID: number; + statusID: number; + combined_format_name?: string; + barcode?: string; + events?: Array<{ + country?: { primary_code: string }; + date?: RelationshipDate; + }>; + labels?: Array<{ + catalogNumber?: string; + label?: { + gid: string; + name: string; + }; + }>; + mediums: Array<{ + format?: { + name: string; + }; + }>; +} + +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 3e29128a4..dfee407bc 100644 --- a/src/lib/MB/types.ts +++ b/src/lib/MB/types.ts @@ -36,5 +36,22 @@ declare global { }; }; }; + + __MB__?: { + DBDefs: { + GIT_BRANCH: string; + GIT_SHA: string; + }; + $c: { + user: { + preferences: { + datetime_format: string; + }; + }; + stash: { + current_language?: string; + }; + }; + }; } } diff --git a/src/lib/compat.ts b/src/lib/compat.ts index 60f0a280f..a548daa88 100644 --- a/src/lib/compat.ts +++ b/src/lib/compat.ts @@ -7,6 +7,9 @@ // Declare v3 GM_* APIs, but not globally. declare function GM_xmlhttpRequest(details: GM.Request): void; declare function GM_getResourceURL(resourceName: string): string; +declare function GM_getValue(name: string): TValue; +declare function GM_setValue(name: string, value: GM.Value): void; +declare function GM_deleteValue(name: string): void; declare const GM_info: typeof GM.info; function existsInGM(name: string): boolean { @@ -43,6 +46,29 @@ export function GMgetResourceUrl(resourceName: string): Promise { } } +export function GMgetValue(name: string): Promise { + // eslint-disable-next-line sonarjs/no-use-of-empty-return-value -- False positive. + return existsInGM('getValue') ? GM.getValue(name) : Promise.resolve(GM_getValue(name)); +} + +export function GMsetValue(name: string, value: GM.Value): Promise { + if (existsInGM('setValue')) { + return GM.setValue(name, value); + } else { + GM_setValue(name, value); + return Promise.resolve(); + } +} + +export function GMdeleteValue(name: string): Promise { + if (existsInGM('deleteValue')) { + return GM.deleteValue(name); + } else { + GM_deleteValue(name); + return Promise.resolve(); + } +} + export const GMinfo = existsInGM('info') ? GM.info : GM_info; /* eslint-enable no-restricted-globals */ diff --git a/src/lib/util/array.ts b/src/lib/util/array.ts index 3c0b1180f..30ad1d815 100644 --- a/src/lib/util/array.ts +++ b/src/lib/util/array.ts @@ -45,6 +45,15 @@ export function enumerate(array: readonly 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; +} + function isFactory(maybeFactory: T2 | (() => T2)): maybeFactory is () => T2 { return typeof maybeFactory === 'function'; } @@ -67,3 +76,18 @@ export function insertBetween(arr: readonly T1[], newElement: T2 | (() = ...arr.slice(1).flatMap((elmt) => [isFactory(newElement) ? newElement() : newElement, elmt]), ]; } + +/** + * Intersect two arrays: Return array with elements common to both arrays. + */ +export function intersect(arr1: readonly T[], arr2: readonly T[]): T[] { + return arr1.filter((el) => arr2.includes(el)); +} + +/** + * Take difference between two arrays: Return array with elements of first array + * that are not present in second. + */ +export function difference(arr1: readonly T[], arr2: readonly T[]): T[] { + return arr1.filter((el) => !arr2.includes(el)); +} diff --git a/src/lib/util/dom.ts b/src/lib/util/dom.ts index 61415ad03..743b7c71f 100644 --- a/src/lib/util/dom.ts +++ b/src/lib/util/dom.ts @@ -90,3 +90,35 @@ 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 = Object.keys(element) + .some((attrName) => + attrName.startsWith('_reactListening') + // @ts-expect-error custom property + && element[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. + onWindowLoaded(callback); + } else { + element.addEventListener('mb-hydration', callback); + } +} diff --git a/src/lib/util/metadata.ts b/src/lib/util/metadata.ts index 61994a859..a4a5ce27f 100644 --- a/src/lib/util/metadata.ts +++ b/src/lib/util/metadata.ts @@ -5,7 +5,7 @@ export interface UserscriptCustomMetadata { name: string; description: string; - match: readonly string[] | string; + include: readonly string[] | string; exclude?: readonly string[] | string; require?: readonly string[] | string; // https://wiki.greasespot.net/Metadata_Block#.40run-at @@ -28,17 +28,106 @@ export interface UserscriptDefaultMetadata { export type UserscriptMetadata = UserscriptCustomMetadata & Partial; export type AllUserscriptMetadata = UserscriptCustomMetadata & UserscriptDefaultMetadata & { version: string }; +// Borrowed and adapted from kellnerd +// https://github.com/kellnerd/musicbrainz-bookmarklets/blob/730ed0f96a81ef9bb239ed564f247bd68f84bee3/tools/userscriptMetadata.js#L68 +interface CreateURLRuleRegexOptions { + query?: 'mandatory' | 'forbidden' | 'allowed'; + fragment?: 'mandatory' | 'forbidden' | 'allowed'; +} + +const MB_DOMAIN_REGEX = /(\w+\.)?musicbrainz\.org/; + +function constructSuffix({ + query = 'allowed', + fragment = 'allowed', +}: CreateURLRuleRegexOptions): string { + // Short circuit for shorter regexes + if (query === 'allowed' && fragment === 'allowed') { + return '([?#]|$)'; + } + + let suffix = ''; + if (query === 'mandatory') { + suffix += String.raw`\?.+?`; + } else if (query === 'allowed') { + suffix += String.raw`(\?.+?)?`; + } + + if (fragment === 'mandatory') { + suffix += '#.+?'; + } else if (fragment === 'allowed') { + suffix += '(#.+?)?'; + } + + return suffix + '$'; +} + +export function createURLRuleRegex(domainPattern: string | RegExp, pathRegex: string | RegExp, options: CreateURLRuleRegexOptions = {}): string { + const domainRegex = typeof domainPattern !== 'string' ? domainPattern.source : domainPattern.replaceAll('.', '\\.'); + pathRegex = (typeof pathRegex === 'string') ? pathRegex : pathRegex.source; -export function transformMBMatchURL(requireString: string): string { - return `*://*.musicbrainz.org/${requireString}`; + return `/^https?://${domainRegex}/${pathRegex}${constructSuffix(options)}/`; } +function removeTemplateAndCreateRule(options: CreateURLRuleRegexOptions, pattern: string): string; +function removeTemplateAndCreateRule(options: CreateURLRuleRegexOptions, template: TemplateStringsArray, ...values: string[]): string; +function removeTemplateAndCreateRule(options: CreateURLRuleRegexOptions, pattern: string | TemplateStringsArray, ...values: string[]): string { + if (typeof pattern === 'string') { + return createURLRuleRegex(MB_DOMAIN_REGEX, pattern, options); + } else { + const fullPattern = pattern.raw[0] + pattern.raw.slice(1) + .map((lit, idx) => `${values[idx]}${lit}`) + .join(''); + return createURLRuleRegex(MB_DOMAIN_REGEX, fullPattern, options); + } +} + +// To be used with a tagged template literal +export function createMBRegex(options: CreateURLRuleRegexOptions): (pattern: string | TemplateStringsArray) => string; +export function createMBRegex(pattern: string): string; +export function createMBRegex(pattern: TemplateStringsArray, ...values: string[]): string; +export function createMBRegex(patternOrOptions: CreateURLRuleRegexOptions | string | TemplateStringsArray, ...values: string[]): ((pattern: string) => string) | ((pattern: TemplateStringsArray, ...values2: string[]) => string) | string { + // The two first branches could in practice be merged, but TypeScript struggles with that. + if (typeof patternOrOptions === 'string') { + return removeTemplateAndCreateRule({}, patternOrOptions); + } else if (Array.isArray(patternOrOptions)) { + return removeTemplateAndCreateRule({}, patternOrOptions as TemplateStringsArray, ...values); + } else { + return removeTemplateAndCreateRule.bind(undefined, patternOrOptions as CreateURLRuleRegexOptions); + } +} + +const mb = createMBRegex; +export const MBID_REGEX_PART = String.raw`[a-f\d-]{36}`; +const mbid = MBID_REGEX_PART; + +// List again -stolen- borrowed from kellnerd. Genre doesn't have an edit history. +export const MB_EDITABLE_ENTITIES = [ + 'area', + 'artist', + 'collection', + 'event', + 'instrument', + 'label', + 'place', + 'recording', + 'release', + 'release-group', + 'series', + 'work', + 'url', +]; + /** Any pages on which edits can occur */ -export const MB_EDIT_PAGE_PATHS = [ - 'edit/*', - // //edits, user//edits/open, search/edits?condition.0=..., ... - // TODO: This also matches /search/edits, on which no edits are shown. Should somehow be excluded - '*/edits*', - 'user/*/votes', - '*/open_edits', +export const MB_EDIT_DISPLAY_PAGE_PATTERNS = [ + mb`edit/\d+`, + mb`edit/(open|subscribed(_editors)?)`, + // //edits, //open_edits + mb`(${MB_EDITABLE_ENTITIES.join('|')})/${mbid}/(open_)?edits`, + // user//edits/... + mb`user/[^/]+/edits(/\w+)?`, + // user//votes + mb`user/[^/]+/votes`, + // Edit search + mb({ query: 'mandatory' })`search/edits`, ]; diff --git a/src/lib/util/xhr.ts b/src/lib/util/xhr.ts index fa1d56594..a03bffb68 100644 --- a/src/lib/util/xhr.ts +++ b/src/lib/util/xhr.ts @@ -32,10 +32,10 @@ export class HTTPResponseError extends ResponseError { public readonly statusCode: number; public readonly statusText: string; // eslint-disable-next-line no-restricted-globals - public readonly response: GM.Response; + public readonly response: GM.Response | Response; // eslint-disable-next-line no-restricted-globals - public constructor(url: string | URL, response: GM.Response, errorMessage?: string) { + public constructor(url: string | URL, response: GM.Response | Response, errorMessage?: string) { /* istanbul ignore else: Should not happen */ if (errorMessage) { super(url, errorMessage); diff --git a/src/mb_blind_votes/index.ts b/src/mb_blind_votes/index.ts new file mode 100644 index 000000000..2262bb713 --- /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 000000000..7415c415b --- /dev/null +++ b/src/mb_blind_votes/meta.ts @@ -0,0 +1,13 @@ +import type { UserscriptMetadata } from '@lib/util/metadata'; +import { MB_EDIT_DISPLAY_PAGE_PATTERNS } 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', + include: MB_EDIT_DISPLAY_PAGE_PATTERNS, +}; + +export default metadata; diff --git a/src/mb_blind_votes/tsconfig.json b/src/mb_blind_votes/tsconfig.json new file mode 100644 index 000000000..0146fff60 --- /dev/null +++ b/src/mb_blind_votes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../configs/tsconfig.base-web.json", + "include": ["**/*"], + "references": [ + { "path": "../lib/" } + ], +} diff --git a/src/mb_caa_dimensions/exports.ts b/src/mb_caa_dimensions/exports.ts index 3edd54612..c69560814 100644 --- a/src/mb_caa_dimensions/exports.ts +++ b/src/mb_caa_dimensions/exports.ts @@ -2,33 +2,25 @@ import { logFailure } from '@lib/util/async'; -import type { ImageInfo } from './ImageInfo'; +import type { Dimensions, ImageInfo } from './ImageInfo'; import type { InfoCache } from './InfoCache'; import { CAAImageWithFullSizeURL, displayInfoWhenInView } from './DisplayedImage'; import { CAAImage } from './Image'; -interface LegacyImageInfo { - url: string; - width: number; - height: number; - size?: number; - format?: string; -} +export type ROpdebee_getDimensionsWhenInView = (imgElement: HTMLImageElement) => void; +export type ROpdebee_getCAAImageInfo = (imgUrl: string) => Promise; +export type ROpdebee_getImageDimensions = (imgUrl: string) => Promise; declare global { interface Window { - ROpdebee_getDimensionsWhenInView: (imgElement: HTMLImageElement) => void; - ROpdebee_getCAAImageInfo: (imgUrl: string) => Promise; - ROpdebee_loadImageDimensions: (imgUrl: string) => Promise; + ROpdebee_getDimensionsWhenInView: ROpdebee_getDimensionsWhenInView; + ROpdebee_getCAAImageInfo: ROpdebee_getCAAImageInfo; + ROpdebee_getImageDimensions: ROpdebee_getImageDimensions; } } export function setupExports(cachePromise: Promise): void { async function getCAAImageInfo(imgUrl: string): Promise { - if (new URL(imgUrl).hostname !== 'archive.org') { - throw new Error('Unsupported URL: Need direct image URL'); - } - const cache = await cachePromise; const image = new CAAImage(imgUrl, cache); return image.getImageInfo(); @@ -41,19 +33,14 @@ export function setupExports(cachePromise: Promise): void { }), `Something went wrong when attempting to load image info for ${imgElement.src}`); } - async function loadImageDimensions(imgUrl: string): Promise { - const imageInfo = await getCAAImageInfo(imgUrl); - return { - url: imgUrl, - ...imageInfo.dimensions ?? { width: 0, height: 0 }, - size: imageInfo.size, - format: imageInfo.fileType, - }; + async function getImageDimensions(imgUrl: string): Promise { + const cache = await cachePromise; + const image = new CAAImage(imgUrl, cache); + return image.getDimensions(); } // Expose the function for use in other scripts that may load images. window.ROpdebee_getDimensionsWhenInView = getDimensionsWhenInView; - // Deprecated, use `ROpdebee_getImageInfo` instead. - window.ROpdebee_loadImageDimensions = loadImageDimensions; + window.ROpdebee_getImageDimensions = getImageDimensions; window.ROpdebee_getCAAImageInfo = getCAAImageInfo; } diff --git a/src/mb_caa_dimensions/meta.ts b/src/mb_caa_dimensions/meta.ts index 6e6d3e47c..33e698c80 100644 --- a/src/mb_caa_dimensions/meta.ts +++ b/src/mb_caa_dimensions/meta.ts @@ -1,19 +1,17 @@ import type { UserscriptMetadata } from '@lib/util/metadata'; -import { MB_EDIT_PAGE_PATHS, transformMBMatchURL } from '@lib/util/metadata'; +import { createMBRegex as mb, MB_EDIT_DISPLAY_PAGE_PATTERNS, MBID_REGEX_PART as mbid } from '@lib/util/metadata'; const metadata: UserscriptMetadata = { name: 'MB: Display CAA image dimensions', description: 'Displays the dimensions and size of images in the cover art archive.', 'run-at': 'document-start', - match: [ - 'release/*', - 'release-group/*', - ...MB_EDIT_PAGE_PATHS, - ].map((path) => transformMBMatchURL(path)), + include: [ + ...MB_EDIT_DISPLAY_PAGE_PATTERNS, + mb`release(-group)?/${mbid}(/.+?)?`, + ], exclude: [ - transformMBMatchURL('release/*/edit'), - transformMBMatchURL('release/*/edit-relationships'), - transformMBMatchURL('release-group/*/edit'), + mb`release(-group)?/${mbid}/edit`, + mb`release-group/${mbid}/edit-relationships`, ], }; diff --git a/src/mb_enhanced_cover_art_uploads/meta.ts b/src/mb_enhanced_cover_art_uploads/meta.ts index 77081e40f..02e010e5c 100644 --- a/src/mb_enhanced_cover_art_uploads/meta.ts +++ b/src/mb_enhanced_cover_art_uploads/meta.ts @@ -1,23 +1,17 @@ import type { UserscriptMetadata } from '@lib/util/metadata'; -import { transformMBMatchURL } from '@lib/util/metadata'; - -const mbMatchedUrls = [ - 'release/*/add-cover-art', - 'release/*/add-cover-art?*', - 'release/*/cover-art', -].map((url) => transformMBMatchURL(url)); +import { createMBRegex as mb, createURLRuleRegex, MBID_REGEX_PART as mbid } from '@lib/util/metadata'; const metadata: UserscriptMetadata = { name: 'MB: Enhanced Cover Art Uploads', description: 'Enhance the cover art uploader! Upload directly from a URL, automatically import covers from Discogs/Spotify/Apple Music/..., automatically retrieve the largest version, and more!', 'run-at': 'document-end', - match: [ - ...mbMatchedUrls, - '*://atisket.pulsewidth.org.uk/*', - '*://etc.marlonob.info/atisket/*', - '*://vgmdb.net/album/*', + include: [ + mb`release/${mbid}/add-cover-art`, + mb`release/${mbid}/cover-art`, + createURLRuleRegex('atisket.pulsewidth.org.uk', /.*?/, { query: 'mandatory' }), + createURLRuleRegex('etc.marlonob.info', /atisket\/.*?/, { query: 'mandatory' }), + createURLRuleRegex('vgmdb.net', /album\/\d+/), ], - exclude: ['*://atisket.pulsewidth.org.uk/'], grant: [ 'GM.xmlhttpRequest', // Used for some favicons diff --git a/src/mb_enhanced_cover_art_uploads/providers/archive.ts b/src/mb_enhanced_cover_art_uploads/providers/archive.ts index 82f2f4f79..4c701952e 100644 --- a/src/mb_enhanced_cover_art_uploads/providers/archive.ts +++ b/src/mb_enhanced_cover_art_uploads/providers/archive.ts @@ -10,12 +10,20 @@ import { gmxhr } from '@lib/util/xhr'; import type { CoverArt } from '../types'; import { CoverArtProvider } from './base'; -interface CAAIndex { +// TODO: This should probably be put in lib. +export interface CAAIndex { images: Array<{ comment: string; types: string[]; id: string | number; // Used to be string in the past, hasn't been applied retroactively yet, see CAA-129 image: string; + thumbnails: { + '250': string; + '500': string; + '1200'?: string; + small: string; + large: string; + }; }>; } diff --git a/src/mb_multi_external_links/meta.ts b/src/mb_multi_external_links/meta.ts index a06466f52..e22adbd0b 100644 --- a/src/mb_multi_external_links/meta.ts +++ b/src/mb_multi_external_links/meta.ts @@ -1,19 +1,14 @@ import type { UserscriptMetadata } from '@lib/util/metadata'; -import { transformMBMatchURL } from '@lib/util/metadata'; +import { createMBRegex as mb, MB_EDITABLE_ENTITIES, MBID_REGEX_PART as mbid } from '@lib/util/metadata'; const metadata: UserscriptMetadata = { name: 'MB: QoL: Paste multiple external links at once', description: 'Enables pasting multiple links, separated by whitespace, into the external link editor.', 'run-at': 'document-end', - match: [ - '*/edit', - '*/edit?*', - 'release/*/edit-relationships*', - '*/add', - '*/add?*', - '*/create', - '*/create?*', - ].map((path) => transformMBMatchURL(path)), + include: [ + mb`(${MB_EDITABLE_ENTITIES.join('|')})/(add|create|${mbid}/edit)`, + mb`release/${mbid}/edit-relationships`, + ], }; export default metadata; 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 000000000..feae45c52 --- /dev/null +++ b/src/mb_qol_inline_recording_tracks/index.tsx @@ -0,0 +1,105 @@ +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}
+
); + + // Insert before the first ARs element, or just at the end if no such element + // exists. The latter can happen is the user is displaying credits at the + // bottom of the page and does not have jesus2099's INLINE STUFF script. + recordingTd.insertBefore( +
+ {rowElements} +
, + qsMaybe('div.ars', recordingTd)); +} + +function loadAndInsert(): void { + const recAnchors = qsa('table.medium td > a[href^="/recording/"]:first-of-type, table.medium td > span:first-child > a[href^="/recording/"]:first-of-type'); + 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 000000000..f4d5c530f --- /dev/null +++ b/src/mb_qol_inline_recording_tracks/meta.ts @@ -0,0 +1,12 @@ +import type { UserscriptMetadata } from '@lib/util/metadata'; +import { createMBRegex as mb, MBID_REGEX_PART as mbid } 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', + include: mb`release/${mbid}`, +}; + +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 000000000..366aa6e4c --- /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/src/mb_qol_seed_recording_disambiguation/index.tsx b/src/mb_qol_seed_recording_disambiguation/index.tsx new file mode 100644 index 000000000..768ac2cb1 --- /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[id="${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 + +