Skip to content

Commit cc4282c

Browse files
authored
feat(web): Open all "Link external IDs" links
Adds a button to open all links to add external IDs for artists, labels, and recordings. Since opening multiple tabs/windows is blocked by default in most browsers, a warning is shown if the tabs were blocked informing the user that they need to allow pop-ups for the site. The button to open all links is only shown when more than one link is available. Some margin around the group of links is added when there are multiple links and the button is shown. Other changes: - 300ms delay after each opened tab to avoid ws/js rate limit errors - New semantic class 'action' which uses the same styles as a 'message' - Test cases for the extracted `getEditUrlToSeedExternalLinks` function
1 parent 7289e99 commit cc4282c

File tree

9 files changed

+444
-74
lines changed

9 files changed

+444
-74
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ snaps/
1010

1111
# Temporary files
1212
local/
13+
14+
# Coverage directory
15+
coverage/

musicbrainz/edit_link.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { type EntityWithUrlRels, getEditUrlToSeedExternalLinks } from './edit_link.ts';
2+
import type { EntityId, LinkType, ResolvableEntity } from '@/harmonizer/types.ts';
3+
import type { EntityType } from '@kellnerd/musicbrainz/data/entity';
4+
import { describe, it } from '@std/testing/bdd';
5+
import { assertInstanceOf } from 'std/assert/assert_instance_of.ts';
6+
import { assertEquals } from 'std/assert/assert_equals.ts';
7+
import type { MetadataProvider } from '@/providers/base.ts';
8+
import type { ProviderRegistry } from '@/providers/registry.ts';
9+
10+
describe('getEditUrlToSeedExternalLinks', () => {
11+
const entityType: EntityType = 'artist';
12+
const sourceEntityUrl = new URL('https://source.com/entity/1');
13+
14+
const mockProviders = {
15+
findByName: (_name: string) => ({
16+
constructUrl: (entity: EntityId) => new URL(`https://example.com/${entity.id}`),
17+
getLinkTypesForEntity: (_entity: EntityId): LinkType[] => ['paid download', 'free streaming'],
18+
} as unknown as MetadataProvider),
19+
} as unknown as ProviderRegistry;
20+
21+
it('returns null if entity has no externalIds', () => {
22+
const entity: ResolvableEntity = { mbid: 'mbid-1', externalIds: [] };
23+
assertEquals(
24+
getEditUrlToSeedExternalLinks({ entity, entityType, sourceEntityUrl, providers: mockProviders }),
25+
null,
26+
);
27+
});
28+
29+
it('returns null if entity has no mbid', () => {
30+
const entity: ResolvableEntity = { externalIds: [{ type: '', provider: 'test', id: '1' }] };
31+
assertEquals(
32+
getEditUrlToSeedExternalLinks({ entity, entityType, sourceEntityUrl, providers: mockProviders }),
33+
null,
34+
);
35+
});
36+
37+
it('returns null if all external links already exist', () => {
38+
const entity: ResolvableEntity = {
39+
mbid: 'mbid-2',
40+
externalIds: [{ type: '', provider: 'test', id: '1' }],
41+
};
42+
const entityCache: EntityWithUrlRels[] = [{
43+
id: 'mbid-2',
44+
relations: [{ url: { resource: 'https://example.com/1' } }],
45+
}];
46+
assertEquals(
47+
getEditUrlToSeedExternalLinks({
48+
entity,
49+
entityType,
50+
sourceEntityUrl,
51+
entityCache,
52+
providers: mockProviders,
53+
}),
54+
null,
55+
);
56+
});
57+
58+
it('returns a URL with correct search params for new links', () => {
59+
const entity: ResolvableEntity = {
60+
mbid: 'mbid-3',
61+
externalIds: [{ type: '', provider: 'test', id: '2', linkTypes: ['free download'] }],
62+
};
63+
const entityCache: EntityWithUrlRels[] = [{
64+
id: 'mbid-3',
65+
relations: [],
66+
}];
67+
const url = getEditUrlToSeedExternalLinks({
68+
entity,
69+
entityType,
70+
sourceEntityUrl,
71+
entityCache,
72+
providers: mockProviders,
73+
});
74+
assertInstanceOf(url, URL);
75+
assertEquals(
76+
url!.origin,
77+
'https://musicbrainz.org',
78+
);
79+
assertEquals(
80+
url!.pathname,
81+
'/artist/mbid-3/edit',
82+
);
83+
const searchParams = url!.searchParams;
84+
assertEquals(searchParams.get('edit-artist.url.0.text'), 'https://example.com/2');
85+
assertEquals(searchParams.get('edit-artist.url.0.link_type_id'), '177');
86+
assertEquals(searchParams.get('edit-artist.url.1.text'), null);
87+
assertEquals(searchParams.get('edit-artist.url.1.link_type_id'), null);
88+
assertEquals(
89+
searchParams.get('edit-artist.edit_note'),
90+
'Matched artist while importing https://source.com/entity/1 with Harmony',
91+
);
92+
});
93+
94+
it('handles missing entityCache gracefully', () => {
95+
const entity: ResolvableEntity = {
96+
mbid: 'mbid-4',
97+
externalIds: [{ type: '', provider: 'test', id: '3', linkTypes: ['free download'] }],
98+
};
99+
const url = getEditUrlToSeedExternalLinks({ entity, entityType, sourceEntityUrl, providers: mockProviders });
100+
assertInstanceOf(url, URL);
101+
const searchParams = url!.searchParams;
102+
assertEquals(searchParams.get('edit-artist.url.0.text'), 'https://example.com/3');
103+
assertEquals(searchParams.get('edit-artist.url.0.link_type_id'), '177'); // free download
104+
assertEquals(searchParams.get('edit-artist.url.1.text'), null);
105+
assertEquals(searchParams.get('edit-artist.url.1.link_type_id'), null);
106+
});
107+
108+
it('defaults to provider link types if none are specified', () => {
109+
const entity: ResolvableEntity = {
110+
mbid: 'mbid-6',
111+
externalIds: [{ type: '', provider: 'test', id: '5' }],
112+
};
113+
const entityCache: EntityWithUrlRels[] = [{
114+
id: 'mbid-6',
115+
relations: [],
116+
}];
117+
const url = getEditUrlToSeedExternalLinks({
118+
entity,
119+
entityType,
120+
sourceEntityUrl,
121+
entityCache,
122+
providers: mockProviders,
123+
});
124+
assertInstanceOf(url, URL);
125+
const searchParams = url!.searchParams;
126+
assertEquals(searchParams.get('edit-artist.url.0.text'), 'https://example.com/5');
127+
assertEquals(searchParams.get('edit-artist.url.0.link_type_id'), '176'); // paid download
128+
assertEquals(searchParams.get('edit-artist.url.1.text'), 'https://example.com/5');
129+
assertEquals(searchParams.get('edit-artist.url.1.link_type_id'), '194'); // free streaming
130+
assertEquals(searchParams.get('edit-artist.url.2.text'), null);
131+
assertEquals(searchParams.get('edit-artist.url.2.link_type_id'), null);
132+
});
133+
134+
it('includes external links with empty link types', () => {
135+
const entity: ResolvableEntity = {
136+
mbid: 'mbid-7',
137+
externalIds: [{ type: '', provider: 'test', id: '6', linkTypes: [] }],
138+
};
139+
const entityCache: EntityWithUrlRels[] = [{
140+
id: 'mbid-7',
141+
relations: [],
142+
}];
143+
const url = getEditUrlToSeedExternalLinks({
144+
entity,
145+
entityType,
146+
sourceEntityUrl,
147+
entityCache,
148+
providers: mockProviders,
149+
});
150+
assertInstanceOf(url, URL);
151+
const searchParams = url!.searchParams;
152+
assertEquals(searchParams.get('edit-artist.url.0.text'), 'https://example.com/6');
153+
assertEquals(searchParams.get('edit-artist.url.0.link_type_id'), null); // No link type
154+
assertEquals(searchParams.get('edit-artist.url.1.text'), null);
155+
assertEquals(searchParams.get('edit-artist.url.1.link_type_id'), null);
156+
});
157+
158+
it('returns null if no new external links after filtering', () => {
159+
const entity: ResolvableEntity = {
160+
mbid: 'mbid-5',
161+
externalIds: [{ type: '', provider: 'test', id: '4' }],
162+
};
163+
const entityCache: EntityWithUrlRels[] = [{
164+
id: 'mbid-5',
165+
relations: [{ url: { resource: 'https://example.com/4' } }],
166+
}];
167+
assertEquals(
168+
getEditUrlToSeedExternalLinks({
169+
entity,
170+
entityType,
171+
sourceEntityUrl,
172+
entityCache,
173+
providers: mockProviders,
174+
}),
175+
null,
176+
);
177+
});
178+
});

musicbrainz/edit_link.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { join } from 'std/url/join.ts';
2+
import { flatten } from 'utils/object/flatten.js';
3+
import { musicbrainzTargetServer } from '@/config.ts';
4+
import { convertLinkType } from '@/musicbrainz/seeding.ts';
5+
import type { EntityType } from '@kellnerd/musicbrainz/data/entity';
6+
import type { EntityWithMbid } from '@kellnerd/musicbrainz/api-types';
7+
import type { ExternalLink, ResolvableEntity } from '@/harmonizer/types.ts';
8+
import type { ProviderRegistry } from '@/providers/registry.ts';
9+
10+
// TODO: incomplete type, expose a suitable type from @kellnerd/musicbrainz?
11+
// interface $EntityWithUrlRels extends EntityWithMbid, WithRels<'url-rels'> {}
12+
// type EntityWithUrlRels = WithIncludes<$EntityWithUrlRels, 'url-rels'>
13+
export interface EntityWithUrlRels extends EntityWithMbid {
14+
relations: Array<{
15+
url: {
16+
resource: string;
17+
};
18+
}>;
19+
}
20+
21+
export function getEditUrlToSeedExternalLinks(
22+
{ entity, entityType, sourceEntityUrl, entityCache, providers }: {
23+
entity: ResolvableEntity;
24+
entityType: EntityType;
25+
sourceEntityUrl: URL;
26+
entityCache?: EntityWithUrlRels[];
27+
/**
28+
* Registry of available metadata providers to construct external URLs and get link types.
29+
* Sent as a parameter to allow easier testing/mocking.
30+
*/
31+
providers: ProviderRegistry;
32+
},
33+
): URL | null {
34+
if (!entity.externalIds?.length || !entity.mbid) return null;
35+
36+
// Get the entity from the cache and check which links already exist in MB.
37+
const mbEntity = entityCache?.find((e) => e.id === entity.mbid);
38+
const existingLinks = new Set(mbEntity?.relations.map((urlRel) => urlRel.url.resource));
39+
40+
// Convert external IDs into links and discard those which already exist.
41+
const externalLinks: ExternalLink[] = entity.externalIds.map((externalId) => {
42+
const provider = providers.findByName(externalId.provider)!;
43+
return {
44+
url: provider.constructUrl(externalId).href,
45+
types: externalId.linkTypes ?? provider.getLinkTypesForEntity(externalId),
46+
};
47+
}).filter((link) => !existingLinks.has(link.url));
48+
49+
if (!externalLinks.length) return null;
50+
51+
// Construct link to seed the MB entity editor.
52+
const mbEditLink = join(musicbrainzTargetServer, entityType, entity.mbid, 'edit');
53+
mbEditLink.search = new URLSearchParams(flatten({
54+
[`edit-${entityType}`]: {
55+
url: externalLinks.flatMap((link) =>
56+
link.types?.length
57+
? link.types.map((type) => ({
58+
text: link.url,
59+
link_type_id: convertLinkType(entityType, type, new URL(link.url)),
60+
}))
61+
: ({ text: link.url })
62+
),
63+
edit_note: `Matched ${entityType} while importing ${sourceEntityUrl} with Harmony`,
64+
},
65+
})).toString();
66+
67+
return mbEditLink;
68+
}

server/components/LinkWithMusicBrainz.tsx

Lines changed: 47 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,64 @@
11
import { LinkedEntity } from './LinkedEntity.tsx';
22
import { SpriteIcon } from './SpriteIcon.tsx';
3+
import { OpenAllLinks } from '@/server/islands/OpenAllLinks.tsx';
34

4-
import { musicbrainzTargetServer } from '@/config.ts';
5-
import type { ExternalLink, ResolvableEntity } from '@/harmonizer/types.ts';
6-
import { convertLinkType } from '@/musicbrainz/seeding.ts';
7-
import { providers } from '@/providers/mod.ts';
8-
import type { EntityWithMbid } from '@kellnerd/musicbrainz/api-types';
95
import type { EntityType } from '@kellnerd/musicbrainz/data/entity';
10-
import { join } from 'std/url/join.ts';
11-
import { flatten } from 'utils/object/flatten.js';
12-
13-
// TODO: incomplete type, expose a suitable type from @kellnerd/musicbrainz?
14-
// interface $EntityWithUrlRels extends EntityWithMbid, WithRels<'url-rels'> {}
15-
// type EntityWithUrlRels = WithIncludes<$EntityWithUrlRels, 'url-rels'>
16-
export interface EntityWithUrlRels extends EntityWithMbid {
17-
relations: Array<{
18-
url: {
19-
resource: string;
20-
};
21-
}>;
22-
}
6+
import { type EntityWithUrlRels, getEditUrlToSeedExternalLinks } from '@/musicbrainz/edit_link.ts';
7+
import type { ResolvableEntity } from '@/harmonizer/types.ts';
8+
import { isDefined } from '@/utils/predicate.ts';
9+
import { providers } from '@/providers/mod.ts';
2310

24-
export function LinkWithMusicBrainz({ entity, entityType, sourceEntityUrl, entityCache }: {
25-
entity: ResolvableEntity;
11+
/**
12+
* Renders a list of MusicBrainz edit links to add external links, based on the provided entities and entity type.
13+
* If multiple entities are present, a button to open all MusicBrainz edit links at once is shown.
14+
*/
15+
export function LinkWithMusicBrainz({ entities, entityType, sourceEntityUrl, entityCache }: {
16+
entities: ResolvableEntity[];
2617
entityType: EntityType;
2718
sourceEntityUrl: URL;
2819
entityCache?: EntityWithUrlRels[];
2920
}) {
30-
if (!entity.externalIds?.length || !entity.mbid) return null;
31-
32-
// Get the entity from the cache and check which links already exist in MB.
33-
const mbEntity = entityCache?.find((e) => e.id === entity.mbid);
34-
const existingLinks = new Set(mbEntity?.relations.map((urlRel) => urlRel.url.resource));
21+
// No entities or no source entity URL to link from, nothing to render.
22+
if (!sourceEntityUrl) return null;
3523

36-
// Convert external IDs into links and discard those which already exist.
37-
const externalLinks: ExternalLink[] = entity.externalIds.map((externalId) => {
38-
const provider = providers.findByName(externalId.provider)!;
39-
return {
40-
url: provider.constructUrl(externalId).href,
41-
types: externalId.linkTypes ?? provider.getLinkTypesForEntity(externalId),
42-
};
43-
}).filter((link) => !existingLinks.has(link.url));
24+
const entitiesWithMbEditLinks = entities.map((entity) => {
25+
const mbEditLink = getEditUrlToSeedExternalLinks({ entity, entityType, sourceEntityUrl, entityCache, providers });
26+
if (mbEditLink) {
27+
return { entity, mbEditLink };
28+
}
29+
}).filter(isDefined);
4430

45-
if (!externalLinks.length) return null;
31+
if (entitiesWithMbEditLinks.length === 0) return null;
4632

47-
// Construct link to seed the MB entity editor.
48-
const mbEditLink = join(musicbrainzTargetServer, entityType, entity.mbid, 'edit');
49-
mbEditLink.search = new URLSearchParams(flatten({
50-
[`edit-${entityType}`]: {
51-
url: externalLinks.flatMap((link) =>
52-
link.types?.length
53-
? link.types.map((type) => ({
54-
text: link.url,
55-
link_type_id: convertLinkType(entityType, type, new URL(link.url)),
56-
}))
57-
: ({ text: link.url })
58-
),
59-
edit_note: `Matched ${entityType} while importing ${sourceEntityUrl} with Harmony`,
60-
},
61-
})).toString();
33+
const actions = entitiesWithMbEditLinks.map(({ entity, mbEditLink }) => (
34+
<LinkWithMusicBrainzAction
35+
mbEditLink={mbEditLink}
36+
entity={entity}
37+
entityType={entityType}
38+
/>
39+
));
40+
if (actions.length > 1) {
41+
return (
42+
<div class='action-group'>
43+
<OpenAllLinks
44+
links={entitiesWithMbEditLinks.map(({ mbEditLink }) => mbEditLink.href)}
45+
linkType={entityType}
46+
/>
47+
{actions}
48+
</div>
49+
);
50+
} else {
51+
return actions[0];
52+
}
53+
}
6254

55+
function LinkWithMusicBrainzAction({ mbEditLink, entity, entityType }: {
56+
mbEditLink: URL;
57+
entity: ResolvableEntity;
58+
entityType: EntityType;
59+
}) {
6360
return (
64-
<div class='message'>
61+
<div class='action'>
6562
<SpriteIcon name='link' />
6663
<div>
6764
<p>

server/fresh.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as $release from './routes/release.tsx';
1111
import * as $release_actions from './routes/release/actions.tsx';
1212
import * as $settings from './routes/settings.tsx';
1313
import * as $CopyButton from './islands/CopyButton.tsx';
14+
import * as $OpenAllLinks from './islands/OpenAllLinks.tsx';
1415
import * as $PersistentInput from './islands/PersistentInput.tsx';
1516
import * as $RegionList from './islands/RegionList.tsx';
1617
import * as $ReleaseSeeder from './islands/ReleaseSeeder.tsx';
@@ -29,6 +30,7 @@ const manifest = {
2930
},
3031
islands: {
3132
'./islands/CopyButton.tsx': $CopyButton,
33+
'./islands/OpenAllLinks.tsx': $OpenAllLinks,
3234
'./islands/PersistentInput.tsx': $PersistentInput,
3335
'./islands/RegionList.tsx': $RegionList,
3436
'./islands/ReleaseSeeder.tsx': $ReleaseSeeder,

0 commit comments

Comments
 (0)