diff --git a/.circleci/config.yml b/.circleci/config.yml index 59ef091a748..f623dd56dc8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,6 +18,8 @@ jobs: - attach_workspace: at: &home /home/musicbrainz - run: | + chown -R musicbrainz:musicbrainz . + chown -R postgres:postgres /home/musicbrainz/pgdata rm /etc/service/chrome/down && sv start chrome export JUNIT_OUTPUT_FILE=junit_output/js.xml sudo -E -H -u musicbrainz carton exec -- prove \ @@ -35,6 +37,8 @@ jobs: - attach_workspace: at: *home - run: | + chown -R musicbrainz:musicbrainz . + chown -R postgres:postgres /home/musicbrainz/pgdata # As noted in docker/musicbrainz-tests/DBDefs.pm, CircleCI # sets NO_PROXY=127.0.0.1,localhost in every container, so # the Selenium proxy doesn't work unless we make requests @@ -77,6 +81,8 @@ jobs: - attach_workspace: at: *home - run: | + chown -R musicbrainz:musicbrainz . + chown -R postgres:postgres /home/musicbrainz/pgdata echo '127.0.0.1 mbtest' >> /etc/hosts ./docker/musicbrainz-tests/install_pg_extensions.sh rm /etc/service/{postgresql,redis,template-renderer,website}/down && sv start postgresql redis template-renderer website @@ -108,9 +114,9 @@ jobs: - v1-source- - checkout - run: | - git submodule sync - git submodule update --init chown -R musicbrainz:musicbrainz . + sudo -E -H -u musicbrainz git submodule sync + sudo -E -H -u musicbrainz git submodule update --init # The checkout step configures git to skip gc, so we run it # here to reduce .git's size before saving it to cache. sudo -E -H -u musicbrainz git gc @@ -128,13 +134,14 @@ jobs: - run: | cd /home/musicbrainz if [[ ! -d mmd-schema ]]; then - git clone https://github.com/metabrainz/mmd-schema.git + sudo -E -H -u musicbrainz git clone https://github.com/metabrainz/mmd-schema.git cd mmd-schema else + chown -R musicbrainz:musicbrainz mmd-schema cd mmd-schema - git fetch origin + sudo -E -H -u musicbrainz git fetch origin fi - git checkout $MMD_SCHEMA_TAG + sudo -E -H -u musicbrainz git checkout $MMD_SCHEMA_TAG cd ../musicbrainz-server - save_cache: key: v1-mmd-schema-{{ .Environment.MMD_SCHEMA_TAG }} @@ -145,7 +152,9 @@ jobs: keys: - v2-node-{{ checksum "yarn.lock" }} - v2-node- - - run: sudo -E -H -u musicbrainz yarn + - run: | + chown -R musicbrainz:musicbrainz . + sudo -E -H -u musicbrainz yarn - save_cache: key: v2-node-{{ checksum "yarn.lock" }} paths: @@ -156,6 +165,7 @@ jobs: - v3-carton-{{ checksum "cpanfile.snapshot" }} - v3-carton- - run: | + chown -R musicbrainz:musicbrainz /home/musicbrainz/{carton-local,vendor} sudo -E -H -u musicbrainz carton install --cached --deployment sudo -E -H -u musicbrainz carton bundle - save_cache: diff --git a/INSTALL.md b/INSTALL.md index 4f47f25f94a..73dea018e25 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -170,6 +170,7 @@ Below outlines how to setup MusicBrainz server with local::lib. libicu-dev \ liblocal-lib-perl \ libpq-dev \ + libxml2 \ libxml2-dev \ cpanminus diff --git a/cpanfile b/cpanfile index 0e5d1171676..2d2fd989133 100644 --- a/cpanfile +++ b/cpanfile @@ -98,6 +98,7 @@ requires 'Text::WikiFormat' => '0.81'; requires 'Throwable' => '0.200009'; requires 'Unicode::ICU::Collator' => '0.002'; requires 'URI' => '1.69'; +requires 'XML::LibXML' => '1.70'; requires 'XML::Parser::Lite' => '0.719'; requires 'XML::RSS::Parser::Lite' => '0.10'; requires 'XML::Simple' => '2.20'; diff --git a/docker/templates/Dockerfile.sitemaps.m4 b/docker/templates/Dockerfile.sitemaps.m4 index b42bb96c49d..fa3417da2ac 100644 --- a/docker/templates/Dockerfile.sitemaps.m4 +++ b/docker/templates/Dockerfile.sitemaps.m4 @@ -1,7 +1,5 @@ m4_include(`server_base.m4')m4_dnl -RUN apt_install(`libxml2') - copy_common_mbs_files COPY docker/musicbrainz-sitemaps/consul-template-sitemaps.conf /etc/ diff --git a/docker/templates/macros.m4 b/docker/templates/macros.m4 index a7df44ae16c..f1ffb3c7a4e 100644 --- a/docker/templates/macros.m4 +++ b/docker/templates/macros.m4 @@ -67,6 +67,7 @@ libexpat1 m4_dnl libicu55 m4_dnl libpq5 m4_dnl libssl1.0.0 m4_dnl +libxml2 m4_dnl perl m4_dnl postgresql-client-9.5 m4_dnl postgresql-server-dev-9.5') diff --git a/entities.json b/entities.json index ce8b75b0dba..9591e6f7497 100644 --- a/entities.json +++ b/entities.json @@ -509,7 +509,8 @@ "id": 23 }, "collections": true, - "custom_tabs": ["discids", "cover-art"], + "cover_art": true, + "custom_tabs": ["discids"], "disambiguation": true, "edit_table": true, "last_updated_column": true, diff --git a/flow-typed/npm/lodash_v4.x.x.js b/flow-typed/npm/lodash_v4.x.x.js index 9d64214dea0..0eabf30011d 100644 --- a/flow-typed/npm/lodash_v4.x.x.js +++ b/flow-typed/npm/lodash_v4.x.x.js @@ -1,5 +1,5 @@ -// flow-typed signature: a9b75804169260d49cda34b56dcfabe1 -// flow-typed version: e9dac1347c/lodash_v4.x.x/flow_>=v0.63.x +// flow-typed signature: 978ec408ef4dc26808325571d235d3ca +// flow-typed version: 9f33da9d84/lodash_v4.x.x/flow_>=v0.63.x declare module "lodash" { declare type Path = $ReadOnlyArray | string | number; @@ -265,7 +265,7 @@ declare module "lodash" { ): -1; // alias of _.head first(array: ?$ReadOnlyArray): T; - flatten(array?: ?Array | X>): Array; + flatten(array?: ?$ReadOnlyArray<$ReadOnlyArray | X>): Array; flattenDeep(array?: ?(any[])): Array; flattenDepth(array?: ?(any[]), depth?: ?number): any[]; fromPairs(pairs?: ?Array<[A, B]>): { [key: A]: B }; @@ -391,8 +391,8 @@ declare module "lodash" { iteratee?: ?ValueOnlyIteratee ): Array; tail(array?: ?Array): Array; - take(array?: ?Array, n?: ?number): Array; - takeRight(array?: ?Array, n?: ?number): Array; + take(array?: ?$ReadOnlyArray, n?: ?number): Array; + takeRight(array?: ?$ReadOnlyArray, n?: ?number): Array; takeRightWhile(array?: ?Array, predicate?: ?Predicate): Array; takeWhile(array?: ?Array, predicate?: ?Predicate): Array; union(...arrays?: Array<$ReadOnlyArray>): Array; @@ -486,15 +486,15 @@ declare module "lodash" { a4: Array, comparator?: Comparator ): Array; - zip(a1?: ?(A[]), a2?: ?(B[])): Array<[A, B]>; - zip(a1: A[], a2: B[], a3: C[]): Array<[A, B, C]>; - zip(a1: A[], a2: B[], a3: C[], a4: D[]): Array<[A, B, C, D]>; + zip(a1?: ?($ReadOnlyArray), a2?: ?($ReadOnlyArray)): Array<[A, B]>; + zip(a1: $ReadOnlyArray, a2: $ReadOnlyArray, a3: $ReadOnlyArray): Array<[A, B, C]>; + zip(a1: $ReadOnlyArray, a2: $ReadOnlyArray, a3: $ReadOnlyArray, a4: $ReadOnlyArray): Array<[A, B, C, D]>; zip( - a1: A[], - a2: B[], - a3: C[], - a4: D[], - a5: E[] + a1: $ReadOnlyArray, + a2: $ReadOnlyArray, + a3: $ReadOnlyArray, + a4: $ReadOnlyArray, + a5: $ReadOnlyArray ): Array<[A, B, C, D, E]>; zipObject(props: Array, values?: ?Array): { [key: K]: V }; @@ -675,7 +675,7 @@ declare module "lodash" { iteratees?: ?$ReadOnlyArray> | ?string, orders?: ?$ReadOnlyArray<"asc" | "desc"> | ?string ): Array; - orderBy( + orderBy( object: T, iteratees?: $ReadOnlyArray> | string, orders?: $ReadOnlyArray<"asc" | "desc"> | string @@ -1890,10 +1890,10 @@ declare module "lodash/fp" { sortedUniq(array: Array): Array; sortedUniqBy(iteratee: ValueOnlyIteratee, array: Array): Array; tail(array: Array): Array; - take(n: number): (array: Array) => Array; - take(n: number, array: Array): Array; - takeRight(n: number): (array: Array) => Array; - takeRight(n: number, array: Array): Array; + take(n: number): (array: $ReadOnlyArray) => Array; + take(n: number, array: $ReadOnlyArray): Array; + takeRight(n: number): (array: $ReadOnlyArray) => Array; + takeRight(n: number, array: $ReadOnlyArray): Array; takeLast(n: number): (array: Array) => Array; takeLast(n: number, array: Array): Array; takeRightWhile(predicate: Predicate): (array: Array) => Array; diff --git a/lib/DBDefs/Default.pm b/lib/DBDefs/Default.pm index 03e45bd3f03..e506e03fa3a 100644 --- a/lib/DBDefs/Default.pm +++ b/lib/DBDefs/Default.pm @@ -440,6 +440,11 @@ sub USE_SELENIUM_HEADER { 0 } sub WIKIMEDIA_COMMONS_IMAGES_ENABLED { 1 } +# On release browse endpoints in the webservice, we limit the number of +# releases returned such that the total number of tracks doesn't exceed this +# number. +sub WS_TRACK_LIMIT { 500 } + ################################################################################ # Profiling ################################################################################ diff --git a/lib/MusicBrainz/Script/JSONDump/Constants.pm b/lib/MusicBrainz/Script/JSONDump/Constants.pm index c913f7970e2..7cfb593ed01 100644 --- a/lib/MusicBrainz/Script/JSONDump/Constants.pm +++ b/lib/MusicBrainz/Script/JSONDump/Constants.pm @@ -12,41 +12,43 @@ our @EXPORT_OK = qw( %DUMPED_ENTITY_TYPES ); -my @inc_rels = map { ($_ =~ s/_/-/gr) . '-rels' } @RELATABLE_ENTITIES; +my %inc_rels = ( + relations => [map { ($_ =~ s/_/-/gr) . '-rels' } @RELATABLE_ENTITIES], +); Readonly our %DUMPED_ENTITY_TYPES => ( area => { - inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres )], %inc_rels), }, artist => { - inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres )], %inc_rels), }, event => { - inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres )], %inc_rels), }, instrument => { - inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres )], %inc_rels), }, label => { - inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres )], %inc_rels), }, place => { - inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres )], %inc_rels), }, recording => { - inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits isrcs ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits isrcs ratings tags genres )], %inc_rels), }, release => { - inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits discids isrcs labels media recording-level-rels recordings release-groups tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits discids isrcs labels media recording-level-rels recordings release-groups tags genres )], %inc_rels), }, release_group => { - inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation artists artist-credits ratings tags genres )], %inc_rels), }, series => { - inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation tags genres )], %inc_rels), }, work => { - inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres ), @inc_rels]), + inc => WebServiceInc->new(inc => [qw( aliases annotation ratings tags genres )], %inc_rels), }, ); diff --git a/lib/MusicBrainz/Server.pm b/lib/MusicBrainz/Server.pm index dcce752111c..ef9f435e4fa 100644 --- a/lib/MusicBrainz/Server.pm +++ b/lib/MusicBrainz/Server.pm @@ -448,7 +448,7 @@ around 'finalize_error' => sub { $c->res->{status} = $c->stash->{status}; } else { $c->res->{body} = 'clear'; - $c->view('Default')->process($c); + $c->view->process($c); $c->res->{body} = encode('utf-8', $c->res->{body}); $c->res->{status} = 503 if $timed_out; @@ -505,6 +505,7 @@ sub TO_JSON { number_of_revisions own_collections release_artwork + release_artwork_count server_details server_languages subscribed diff --git a/lib/MusicBrainz/Server/Controller/Ajax.pm b/lib/MusicBrainz/Server/Controller/Ajax.pm index 9523a0a80c6..c81d6cec198 100644 --- a/lib/MusicBrainz/Server/Controller/Ajax.pm +++ b/lib/MusicBrainz/Server/Controller/Ajax.pm @@ -1,6 +1,7 @@ package MusicBrainz::Server::Controller::Ajax; BEGIN { use Moose; extends 'Catalyst::Controller' }; +use JSON qw( encode_json ); use MusicBrainz::Server::FilterUtils qw( create_artist_release_groups_form create_artist_releases_form @@ -11,27 +12,30 @@ sub filter_artist_release_groups_form : Local { my ($self, $c) = @_; my $artist_id = $c->req->query_params->{artist_id}; - create_artist_release_groups_form($c, $artist_id); + my $form = create_artist_release_groups_form($c, $artist_id); - $c->stash(template => 'components/filter-form.tt'); + $c->res->body(encode_json($form->TO_JSON)); + $c->res->content_type('application/json; charset=utf-8'); } sub filter_artist_releases_form : Local { my ($self, $c) = @_; my $artist_id = $c->req->query_params->{artist_id}; - create_artist_releases_form($c, $artist_id); + my $form = create_artist_releases_form($c, $artist_id); - $c->stash(template => 'components/filter-form.tt'); + $c->res->body(encode_json($form->TO_JSON)); + $c->res->content_type('application/json; charset=utf-8'); } sub filter_artist_recordings_form : Local { my ($self, $c) = @_; my $artist_id = $c->req->query_params->{artist_id}; - create_artist_recordings_form($c, $artist_id); + my $form = create_artist_recordings_form($c, $artist_id); - $c->stash(template => 'components/filter-form.tt'); + $c->res->body(encode_json($form->TO_JSON)); + $c->res->content_type('application/json; charset=utf-8'); } 1; diff --git a/lib/MusicBrainz/Server/Controller/Artist.pm b/lib/MusicBrainz/Server/Controller/Artist.pm index 712592c7e4e..1894ad81c06 100644 --- a/lib/MusicBrainz/Server/Controller/Artist.pm +++ b/lib/MusicBrainz/Server/Controller/Artist.pm @@ -45,7 +45,10 @@ with 'MusicBrainz::Server::Controller::Role::Collection' => { use Data::Page; use HTTP::Status qw( :constants ); -use MusicBrainz::Server::Data::Utils qw( is_special_artist ); +use MusicBrainz::Server::Data::Utils qw( + boolean_to_json + is_special_artist +); use MusicBrainz::Server::Constants qw( $DARTIST_ID $EDITOR_MODBOT @@ -174,13 +177,12 @@ sub show : PathPart('') Chained('load') my %filter = %{ $self->process_filter($c, sub { return create_artist_release_groups_form($c, $artist->id); }) }; + my $has_filter = %filter ? 1 : 0; - if (%filter) { - $c->stash( has_filter => 1 ); - } - - my $show_va = $c->req->query_params->{va}; - my $show_all = $c->req->query_params->{all}; + my $want_va_only = $c->req->query_params->{va}; + my $want_all_statuses = $c->req->query_params->{all}; + my $including_all_statuses; + my $showing_va_only; my $make_attempt = sub { my ($all, $va) = @_; @@ -192,7 +194,11 @@ sub show : PathPart('') Chained('load') # Attempt from official non-va, to all non-va, to official va, to all va; # filter out any attempt that contradicts a preference from a query param - my @attempts = grep { ($_->[0] || !$show_all) && ($_->[1] || !$show_va) } ([0,0], [1,0], [0,1], [1,1]); + my @attempts = grep { + ($_->[0] || !$want_all_statuses) && + ($_->[1] || !$want_va_only) + } ([0,0], [1,0], [0,1], [1,1]); + for my $attempt (@attempts) { my $all = $attempt->[0]; my $va = $attempt->[1]; @@ -200,16 +206,14 @@ sub show : PathPart('') Chained('load') # If filtering, only make one attempt # otherwise, attempt until we find RGs or exhaust the possibilities if (scalar @$release_groups || %filter) { - $c->stash( - including_all => $all, - including_va => $va - ); + $including_all_statuses = $all; + $showing_va_only = $va; last; } } # If there is no expressed preference (va, filter) and no RGs, find recordings - if (!$show_va && !%filter && scalar @$release_groups == 0) { + if (!$showing_va_only && !%filter && scalar @$release_groups == 0) { $recordings = $self->_load_paged($c, sub { $c->model('Recording')->find_standalone($artist->id, shift, shift); }); @@ -221,12 +225,6 @@ sub show : PathPart('') Chained('load') } } - $c->stash( - show_va => $show_va, - show_all => $show_all, - template => 'artist/index.tt' - ); - if ($c->user_exists) { $c->model('ReleaseGroup')->rating->load_user_ratings($c->user->id, @$release_groups); } @@ -237,15 +235,13 @@ sub show : PathPart('') Chained('load') $c->stash( recordings => $recordings, recordings_jsonld => {items => $recordings}, - release_groups => $release_groups, release_groups_jsonld => {items => $release_groups}, - show_artists => scalar grep { - $_->artist_credit->name ne $artist->name - } @$release_groups, ); my $coll = $c->get_collator(); my @identities; + my $legal_name_artist_aliases; + my $legal_name_aliases; my ($legal_name) = map { $_->target } grep { $_->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD } grep { $_->link->type->gid eq 'dd9886f2-1dfe-4270-97db-283f6839a666' } @{ $artist->relationships }; @@ -262,6 +258,7 @@ sub show : PathPart('') Chained('load') grep { !($_->ended) } grep { ($_->type_name // "") eq 'Legal name' } @$aliases; $c->stash( legal_name_artist_aliases => \@aliases ); + $legal_name_artist_aliases = \@aliases; push(@identities, $legal_name); } else { my $aliases = $c->model('Artist')->alias->find_by_entity_id($artist->id); @@ -272,17 +269,41 @@ sub show : PathPart('') Chained('load') grep { !($_->ended) } grep { ($_->type_name // "") eq 'Legal name' } @$aliases; $c->stash( legal_name_aliases => \@aliases ); + $legal_name_aliases = \@aliases; } - $legal_name //= $artist; my @other_identities = sort_by { $coll->getSortKey($_->name) } grep { $_->id != $artist->id } uniq map { $_->target } grep { $_->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_FORWARD } - grep { $_->link->type->gid eq 'dd9886f2-1dfe-4270-97db-283f6839a666' } @{ $legal_name->relationships }; + grep { $_->link->type->gid eq 'dd9886f2-1dfe-4270-97db-283f6839a666' } + @{ ($legal_name // $artist)->relationships }; push(@identities, @other_identities); $c->stash(other_identities => \@other_identities, identities => \@identities); + + $c->stash( + current_view => 'Node', + component_path => 'artist/ArtistIndex', + component_props => { + ajaxFilterFormUrl => $c->uri_for_action('/ajax/filter_artist_release_groups_form', { artist_id => $artist->id }), + artist => $artist, + filterForm => $c->stash->{filter_form}, + hasFilter => boolean_to_json($has_filter), + includingAllStatuses => $including_all_statuses, + legalName => $legal_name, + legalNameAliases => $legal_name_aliases, + legalNameArtistAliases => $legal_name_artist_aliases, + numberOfRevisions => $c->stash->{number_of_revisions}, + otherIdentities => \@other_identities, + recordings => $recordings, + releaseGroups => $release_groups, + showingVariousArtistsOnly => $showing_va_only, + wantAllStatuses => boolean_to_json($want_all_statuses), + wantVariousArtistsOnly => boolean_to_json($want_va_only), + wikipediaExtract => $c->stash->{wikipedia_extract}, + }, + ); } sub relationships : Chained('load') PathPart('relationships') { @@ -335,22 +356,25 @@ sub recordings : Chained('load') my $artist = $c->stash->{artist}; my $recordings; + my $standalone_only; + my $video_only; my %filter = %{ $self->process_filter($c, sub { return create_artist_recordings_form($c, $artist->id); }) }; + my $has_filter = %filter ? 1 : 0; if ($c->req->query_params->{standalone}) { $recordings = $self->_load_paged($c, sub { $c->model('Recording')->find_standalone($artist->id, shift, shift); }); - $c->stash( standalone_only => 1 ); + $standalone_only = 1; } elsif ($c->req->query_params->{video}) { $recordings = $self->_load_paged($c, sub { $c->model('Recording')->find_video($artist->id, shift, shift); }); - $c->stash( video_only => 1 ); + $video_only = 1; } else { $recordings = $self->_load_paged($c, sub { @@ -364,17 +388,24 @@ sub recordings : Chained('load') $c->model('Recording')->rating->load_user_ratings($c->user->id, @$recordings); } - $c->stash( template => 'artist/recordings.tt' ); - $c->model('ISRC')->load_for_recordings(@$recordings); $c->model('ArtistCredit')->load(@$recordings); $c->stash( recordings => $recordings, recordings_jsonld => {items => $recordings}, - show_artists => scalar(grep { - $_->artist_credit->name ne $artist->name - } @$recordings), + current_view => 'Node', + component_path => 'artist/ArtistRecordings', + component_props => { + ajaxFilterFormUrl => $c->uri_for_action('/ajax/filter_artist_recordings_form', { artist_id => $artist->id }), + artist => $artist, + filterForm => $c->stash->{filter_form}, + hasFilter => boolean_to_json($has_filter), + pager => serialize_pager($c->stash->{pager}), + recordings => $recordings, + standaloneOnly => boolean_to_json($standalone_only), + videoOnly => boolean_to_json($video_only), + }, ); } @@ -419,16 +450,18 @@ sub releases : Chained('load') my $artist = $c->stash->{artist}; my $releases; + my $showing_va_only; my %filter = %{ $self->process_filter($c, sub { return create_artist_releases_form($c, $artist->id); }) }; + my $has_filter = %filter ? 1 : 0; my $method = 'find_by_artist'; - my $show_va = $c->req->query_params->{va}; - if ($show_va) { + my $want_va_only = $c->req->query_params->{va} ? 1 : 0; + if ($want_va_only) { $method = 'find_by_track_artist'; - $c->stash( show_va => 1 ); + $showing_va_only = 1; } $releases = $self->_load_paged($c, sub { @@ -436,25 +469,29 @@ sub releases : Chained('load') }); my $pager = $c->stash->{pager}; - if (!$show_va && $pager->total_entries == 0) { + if (!$want_va_only && $pager->total_entries == 0) { $releases = $self->_load_paged($c, sub { $c->model('Release')->find_by_track_artist($c->stash->{artist}->id, shift, shift, filter => \%filter); }); - $c->stash( - va_only => 1, - show_va => 1 - ); + $want_va_only = 1; + $showing_va_only = 1; } - $c->stash( template => 'artist/releases.tt' ); - $c->model('ArtistCredit')->load(@$releases); $c->model('Release')->load_related_info(@$releases); $c->stash( - releases => $releases, - show_artists => scalar grep { - $_->artist_credit->name ne $artist->name - } @$releases, + current_view => 'Node', + component_path => 'artist/ArtistReleases', + component_props => { + ajaxFilterFormUrl => $c->uri_for_action('/ajax/filter_artist_releases_form', { artist_id => $artist->id }), + artist => $artist, + filterForm => $c->stash->{filter_form}, + hasFilter => boolean_to_json($has_filter), + pager => serialize_pager($pager), + releases => $releases, + showingVariousArtistsOnly => boolean_to_json($showing_va_only), + wantVariousArtistsOnly => boolean_to_json($want_va_only), + }, ); } diff --git a/lib/MusicBrainz/Server/Controller/Recording.pm b/lib/MusicBrainz/Server/Controller/Recording.pm index c35076c6ed0..61ba0028547 100644 --- a/lib/MusicBrainz/Server/Controller/Recording.pm +++ b/lib/MusicBrainz/Server/Controller/Recording.pm @@ -122,7 +122,15 @@ sub show : Chained('load') PathPart('') { ); } -sub fingerprints : Chained('load') PathPart('fingerprints') { } +sub fingerprints : Chained('load') PathPart('fingerprints') { + my ($self, $c) = @_; + + $c->stash( + component_path => 'recording/RecordingFingerprints', + component_props => { recording => $c->stash->{recording} }, + current_view => 'Node', + ); +} # Stuff that has the sidebar and needs collection info after [qw( show collections details tags aliases fingerprints )] => sub { diff --git a/lib/MusicBrainz/Server/Controller/Release.pm b/lib/MusicBrainz/Server/Controller/Release.pm index 4298d766716..f7e6bcbed34 100644 --- a/lib/MusicBrainz/Server/Controller/Release.pm +++ b/lib/MusicBrainz/Server/Controller/Release.pm @@ -88,6 +88,9 @@ after 'load' => sub { my $artwork = $c->model('Artwork')->find_front_cover_by_release($release); $c->stash->{release_artwork} = $artwork->[0]; + my $artwork_count = $c->model('Artwork')->find_count_by_release($release->id); + $c->stash->{release_artwork_count} = $artwork_count; + # We need to load more artist credits in 'show' if ($c->action->name ne 'show') { $c->model('ArtistCredit')->load($release); diff --git a/lib/MusicBrainz/Server/Controller/Role/ETags.pm b/lib/MusicBrainz/Server/Controller/Role/ETags.pm index dedb210430b..5177eecb380 100644 --- a/lib/MusicBrainz/Server/Controller/Role/ETags.pm +++ b/lib/MusicBrainz/Server/Controller/Role/ETags.pm @@ -10,6 +10,8 @@ after end => sub { my $body = $c->response->body; if ($body) { + $body = ${$body->string_ref} + if ref($body) eq 'IO::String'; utf8::encode($body) if utf8::is_utf8($body); $c->response->headers->etag(md5_hex($body)); diff --git a/lib/MusicBrainz/Server/Controller/Role/Merge.pm b/lib/MusicBrainz/Server/Controller/Role/Merge.pm index 88b48c62e79..412ad1d480f 100644 --- a/lib/MusicBrainz/Server/Controller/Role/Merge.pm +++ b/lib/MusicBrainz/Server/Controller/Role/Merge.pm @@ -86,7 +86,7 @@ role { my ($self, $c) = @_; delete $c->session->{merger}; $c->res->redirect( - $c->req->query_params->{returnto} || $c->uri_for('/')); + $c->req->query_params->{returnto} || $c->req->referer || $c->uri_for('/')); $c->detach; }; @@ -126,9 +126,13 @@ role { $c->model($merger->type)->get_by_ids($merger->all_entities) }; - $c->detach - unless $merger->ready_to_merge; - + unless ($merger->ready_to_merge) { + $c->response->redirect( + $c->req->referer || + $c->uri_for_action('/')); + $c->detach; + } + my $check_form = $c->form(form => 'Merge'); if ($check_form->submitted_and_valid($c->req->params)) { # Ensure that we use the entities that appeared on the page and the right type, diff --git a/lib/MusicBrainz/Server/Controller/WS/2/DiscID.pm b/lib/MusicBrainz/Server/Controller/WS/2/DiscID.pm index 774f9b69eaf..f2b7e6add80 100644 --- a/lib/MusicBrainz/Server/Controller/WS/2/DiscID.pm +++ b/lib/MusicBrainz/Server/Controller/WS/2/DiscID.pm @@ -16,7 +16,7 @@ my $ws_defs = Data::OptList::mkopt([ inc => [ qw(artists labels recordings release-groups artist-credits tags user-tags genres user-genres tags user-tags genres user-genres aliases puids isrcs _relations cdstubs ) ], - optional => [ qw( fmt ) ], + optional => [ qw( fmt limit offset ) ], } ]); @@ -44,6 +44,9 @@ sub discid : Chained('root') PathPart('discid') { if ref($toc); } + my ($limit, $offset) = $self->_limit_and_offset($c); + $limit = 25 if $limit > 25; + $c->stash->{inc}->media(1); $c->stash->{inc}->discids(1); @@ -56,16 +59,17 @@ sub discid : Chained('root') PathPart('discid') { my $opts = $stash->store($cdtoc); - my @releases = $c->model('Release')->find_by_medium( - map { $_->medium_id } @mediumcdtocs + my ($releases, $hits) = $c->model('Release')->find_by_medium( + [map { $_->medium_id } @mediumcdtocs], + $limit, $offset ); - $opts->{releases} = $self->make_list(\@releases); + $opts->{releases} = $self->make_list($releases, $hits, $offset); - $c->controller('WS::2::Release')->release_toplevel($c, $stash, \@releases); + $c->controller('WS::2::Release')->release_toplevel($c, $stash, $releases); $c->res->content_type($c->stash->{serializer}->mime_type . '; charset=utf-8'); - $c->res->body($c->stash->{serializer}->serialize('discid', $cdtoc, $c->stash->{inc}, $stash)); + $c->res->body($c->stash->{serializer}->serialize('disc', $cdtoc, $c->stash->{inc}, $stash)); return; } @@ -85,31 +89,33 @@ sub discid : Chained('root') PathPart('discid') { } if (my $toc = $c->req->query_params->{toc}) { - my $results = $c->model('DurationLookup')->lookup($toc, DURATION_LOOKUP_RANGE); + my $all_formats = 0; + if (exists $c->req->query_params->{"media-format"} && + $c->req->query_params->{'media-format'} eq "all") { + $all_formats = 1; + } + + my ($results, $hits) = $c->model('DurationLookup')->lookup( + $toc, DURATION_LOOKUP_RANGE, $all_formats, $limit, $offset); if (!defined($results)) { $self->_error($c, l('Invalid TOC')); } my $inc = $c->stash->{inc}; + my @release_ids = map { $_->{release} } @{$results}; + my $releases = $c->model('Release')->get_by_ids(@release_ids); + my @releases = map { $releases->{$_} } @release_ids; + my $release_list = $self->make_list(\@releases, $hits, $offset); - $c->model('MediumFormat')->load(map { $_->medium } @$results); - - my @mediums = map { $_->medium } @$results; - unless (exists $c->req->query_params->{"media-format"} && $c->req->query_params->{'media-format'} eq "all") { - @mediums = grep { $_->may_have_discids } @mediums; - } - $c->model('Release')->load(@mediums); - - my @releases = map { $_->release } @mediums; - $c->controller('WS::2::Release')->release_toplevel($c, $stash, \@releases); + $self->limit_releases_by_tracks($c, $release_list->{items}) + if $inc->recordings; + $c->controller('WS::2::Release')->release_toplevel($c, $stash, $release_list->{items}); $c->res->content_type($c->stash->{serializer}->mime_type . '; charset=utf-8'); $c->res->body($c->stash->{serializer}->serialize( 'release_list', - { - items => \@releases - }, - $c->stash->{inc}, $stash + $release_list, + $inc, $stash )); return; diff --git a/lib/MusicBrainz/Server/Controller/WS/2/Rating.pm b/lib/MusicBrainz/Server/Controller/WS/2/Rating.pm index cd5fc2028ec..fed4b37cb10 100644 --- a/lib/MusicBrainz/Server/Controller/WS/2/Rating.pm +++ b/lib/MusicBrainz/Server/Controller/WS/2/Rating.pm @@ -85,7 +85,7 @@ sub rating_lookup : Chained('root') PathPart('rating') Args(0) $stash->store($entity)->{user_ratings} = $entity->user_rating; $c->res->content_type($c->stash->{serializer}->mime_type . '; charset=utf-8'); - $c->res->body($c->stash->{serializer}->serialize('rating', $entity, $c->stash->{inc}, $stash)); + $c->res->body($c->stash->{serializer}->serialize('user-rating', $entity, $c->stash->{inc}, $stash)); } __PACKAGE__->meta->make_immutable; diff --git a/lib/MusicBrainz/Server/Controller/WS/2/Release.pm b/lib/MusicBrainz/Server/Controller/WS/2/Release.pm index b19e065cdaa..a747bbd00d1 100644 --- a/lib/MusicBrainz/Server/Controller/WS/2/Release.pm +++ b/lib/MusicBrainz/Server/Controller/WS/2/Release.pm @@ -8,6 +8,7 @@ use MusicBrainz::Server::Constants qw( $ACCESS_SCOPE_SUBMIT_BARCODE $EDIT_RELEASE_EDIT_BARCODES ); +use List::AllUtils qw( uniq ); use List::UtilsBy qw( partition_by uniq_by ); use MusicBrainz::Server::WebService::XML::XPath; use MusicBrainz::Server::Validation qw( is_guid is_valid_ean ); @@ -26,7 +27,7 @@ my $ws_defs = Data::OptList::mkopt([ recording release-group track collection) ], inc => [ qw(aliases artist-credits labels recordings discids - tags user-tags genres user-genres + tags user-tags genres user-genres ratings user-ratings release-groups media _relations annotation) ], optional => [ qw(fmt limit offset) ], }, @@ -90,16 +91,11 @@ sub release_toplevel { } my @rels_entities = @releases; + my @ac_entities; if ($inc->artists) { - $c->model('ArtistCredit')->load(@releases); - - my @acns = map { $_->artist_credit->all_names } @releases; - $c->model('Artist')->load(@acns); - - my @artists = map { $_->artist } @acns; - $self->linked_artists($c, $stash, \@artists); + push @ac_entities, @releases; } if ($inc->labels) @@ -136,16 +132,14 @@ sub release_toplevel { $c->model('Track')->load_for_mediums(@mediums); my @tracks = map { $_->all_tracks } @mediums; - if ($inc->artist_credits) { - $c->model('ArtistCredit')->load(@tracks); - my @acns = map { $_->artist_credit->all_names } @tracks; - $c->model('Artist')->load(@acns); - $self->linked_artists($c, $stash, [uniq_by { $_->id } map { $_->artist } @acns]); - } - my @recordings = $c->model('Recording')->load(@tracks); $c->model('Recording')->load_meta(@recordings); + my $inc_artist_credits = $inc->artist_credits; + if ($inc_artist_credits) { + push @ac_entities, @tracks, @recordings; + } + # The maximum number of recordings to try to get relationships for, if # inc=recording-level-rels is specified. Certain releases with an enourmous # number of recordings (audiobooks) will almost always timeout, driving up @@ -157,7 +151,11 @@ sub release_toplevel { push @rels_entities, @recordings; } + # Disable artist-credits during linked_recordings, since we load + # them below together with the tracks and release. + $inc->artist_credits(0) if $inc_artist_credits; $self->linked_recordings($c, $stash, \@recordings); + $inc->artist_credits(1) if $inc_artist_credits; } if ($inc->collections || $inc->user_collections) { @@ -190,6 +188,18 @@ sub release_toplevel { } } + if (@ac_entities) { + $c->model('ArtistCredit')->load(@ac_entities); + + my @acns = map { $_->all_names } + uniq map { $_->artist_credit } @ac_entities; + + $c->model('Artist')->load(@acns); + + my @artists = uniq map { $_->artist } @acns; + $self->linked_artists($c, $stash, \@artists); + } + $self->load_relationships($c, $stash, @rels_entities); } @@ -264,6 +274,9 @@ sub release_browse : Private my $stash = WebServiceStash->new; + $self->limit_releases_by_tracks($c, $releases->{items}) + if $c->stash->{inc}->recordings; + $self->release_toplevel($c, $stash, $releases->{items}); $c->res->content_type($c->stash->{serializer}->mime_type . '; charset=utf-8'); diff --git a/lib/MusicBrainz/Server/Controller/WS/2/Tag.pm b/lib/MusicBrainz/Server/Controller/WS/2/Tag.pm index af5d8795154..d852524ac89 100644 --- a/lib/MusicBrainz/Server/Controller/WS/2/Tag.pm +++ b/lib/MusicBrainz/Server/Controller/WS/2/Tag.pm @@ -43,7 +43,7 @@ sub tag_lookup : Private $stash->store($entity)->{user_tags} = \@tags; $c->res->content_type($c->stash->{serializer}->mime_type . '; charset=utf-8'); - $c->res->body($c->stash->{serializer}->serialize('tag-list', $entity, $c->stash->{inc}, $stash)); + $c->res->body($c->stash->{serializer}->serialize('user-tag-list', $entity, $c->stash->{inc}, $stash)); } diff --git a/lib/MusicBrainz/Server/Controller/WS/js/Edit.pm b/lib/MusicBrainz/Server/Controller/WS/js/Edit.pm index fee019cb401..e0bdf9d6eb1 100644 --- a/lib/MusicBrainz/Server/Controller/WS/js/Edit.pm +++ b/lib/MusicBrainz/Server/Controller/WS/js/Edit.pm @@ -37,6 +37,7 @@ use MusicBrainz::Server::Data::Utils qw( split_relationship_by_attributes sanitize trim + trim_comment non_empty ); use MusicBrainz::Server::Renderer qw( render_component ); @@ -216,7 +217,7 @@ sub process_entity { } if ($data->{comment}) { - trim_string($data, 'comment'); + $data->{comment} = trim_comment($data->{comment}); # MBS-7963 $data->{comment} = substr($data->{comment}, 0, 255); } diff --git a/lib/MusicBrainz/Server/Controller/Work.pm b/lib/MusicBrainz/Server/Controller/Work.pm index 6ef55e86e3e..33640eed61e 100644 --- a/lib/MusicBrainz/Server/Controller/Work.pm +++ b/lib/MusicBrainz/Server/Controller/Work.pm @@ -69,7 +69,17 @@ sub show : PathPart('') Chained('load') $c->model('Work')->load_writers($c->stash->{work}); - $c->stash->{template} = 'work/index.tt'; + my %props = ( + numberOfRevisions => $c->stash->{number_of_revisions}, + wikipediaExtract => $c->stash->{wikipedia_extract}, + work => $c->stash->{work}, + ); + + $c->stash( + component_path => 'work/WorkIndex', + component_props => \%props, + current_view => 'Node', + ); } # Stuff that has the side bar and thus needs to display collection information diff --git a/lib/MusicBrainz/Server/ControllerBase/WS/2.pm b/lib/MusicBrainz/Server/ControllerBase/WS/2.pm index 9accc624525..0c422242a8c 100644 --- a/lib/MusicBrainz/Server/ControllerBase/WS/2.pm +++ b/lib/MusicBrainz/Server/ControllerBase/WS/2.pm @@ -12,6 +12,7 @@ use MusicBrainz::Server::WebService::JSONSerializer; use MusicBrainz::Server::WebService::XMLSerializer; use Readonly; use Scalar::Util qw( looks_like_number ); +use List::Util qw( sum ); use List::UtilsBy qw( partition_by ); use Try::Tiny; @@ -106,6 +107,7 @@ sub method_not_allowed : Private { sub begin : Private { my ($self, $c) = @_; + $c->stash->{current_view} = 'WS'; $c->stash->{serializer} = $self->get_serialization($c); } @@ -309,6 +311,33 @@ sub make_list }; } +=head2 limit_releases_by_tracks + +Truncates a list of releases such that the entire list doesn't contain more +than C tracks in total (but returns at least one +release). The idea is to limit browse queries that contain an excessive number +of tracks when C is specified. + +Note: This mutates the passed-in array reference C<$releases>. + +=cut + +sub limit_releases_by_tracks { + my ($self, $c, $releases) = @_; + + my $track_count = 0; + my $release_count = 0; + + for my $release (@{$releases}) { + $c->model('Medium')->load_for_releases($release); + $track_count += sum map { $_->track_count } $release->all_mediums; + last if $track_count > DBDefs->WS_TRACK_LIMIT && $release_count > 0; + $release_count++; + } + + @$releases = @$releases[0 .. ($release_count - 1)]; +} + sub linked_artists { my ($self, $c, $stash, $artists) = @_; diff --git a/lib/MusicBrainz/Server/ControllerBase/WS/js.pm b/lib/MusicBrainz/Server/ControllerBase/WS/js.pm index fb7ca075549..8e74f4770bf 100644 --- a/lib/MusicBrainz/Server/ControllerBase/WS/js.pm +++ b/lib/MusicBrainz/Server/ControllerBase/WS/js.pm @@ -34,6 +34,7 @@ sub output_error : Private sub begin : Private { my ($self, $c) = @_; + $c->stash->{current_view} = 'WS'; $c->stash->{serializer} = MusicBrainz::Server::WebService::JSONSerializer->new; } diff --git a/lib/MusicBrainz/Server/Data/Artwork.pm b/lib/MusicBrainz/Server/Data/Artwork.pm index d84bee5fdde..79881900e1e 100644 --- a/lib/MusicBrainz/Server/Data/Artwork.pm +++ b/lib/MusicBrainz/Server/Data/Artwork.pm @@ -145,6 +145,18 @@ sub find_front_cover_by_release return \@artwork; } +sub find_count_by_release +{ + my ($self, $release_id) = @_; + + return unless $release_id; # nothing to do + my $query = "SELECT count(*) + FROM cover_art_archive.index_listing + WHERE cover_art_archive.index_listing.release = ?"; + + return $self->sql->select_single_value($query, $release_id); +} + sub load_for_release_groups { my ($self, @release_groups) = @_; diff --git a/lib/MusicBrainz/Server/Data/CoverArt.pm b/lib/MusicBrainz/Server/Data/CoverArt.pm index f72a8a33ad8..f7fd4115135 100644 --- a/lib/MusicBrainz/Server/Data/CoverArt.pm +++ b/lib/MusicBrainz/Server/Data/CoverArt.pm @@ -143,11 +143,16 @@ sub find_outdated_releases $row->{c_last_updated} ? (last_updated => $row->{c_last_updated}) : () ) ); + my $url = $self->c->model('URL')->_new_from_row($row); $release->add_relationship( Relationship->new( entity0 => $release, - entity1 => $self->c->model('URL')->_new_from_row($row), + entity1 => $url, + source => $release, + target => $url, + source_type => 'release', + target_type => 'url', link => Link->new( type => LinkType->new( name => $row->{link_type} ) ) diff --git a/lib/MusicBrainz/Server/Data/DurationLookup.pm b/lib/MusicBrainz/Server/Data/DurationLookup.pm index 28a6bfe69a0..7aad897ee5f 100644 --- a/lib/MusicBrainz/Server/Data/DurationLookup.pm +++ b/lib/MusicBrainz/Server/Data/DurationLookup.pm @@ -1,4 +1,5 @@ package MusicBrainz::Server::Data::DurationLookup; +use JSON::XS qw( decode_json ); use Moose; use namespace::autoclean -also => [qw( _parse_toc )]; use Readonly; @@ -7,6 +8,7 @@ use MusicBrainz::Server::Entity::Medium; use MusicBrainz::Server::Constants qw( $MAX_POSTGRES_INT ); with 'MusicBrainz::Server::Data::Role::Sql'; +with 'MusicBrainz::Server::Data::Role::QueryToList'; Readonly our $DIMENSIONS => 6; @@ -47,12 +49,15 @@ sub _parse_toc sub lookup { - my ($self, $toc, $fuzzy) = @_; + my ($self, $toc, $fuzzy, $all_formats, $limit, $offset) = @_; $toc =~ tr/+/ /; my %toc_info = _parse_toc($toc); return undef unless scalar(%toc_info); + $limit //= 25; + $offset //= 0; + my @offsets = @{$toc_info{trackoffsets}}; push @offsets, $toc_info{leadoutoffset}; @@ -67,38 +72,31 @@ sub lookup my $dur_string = "'{" . join(",", @durations) . "}'"; - my $list = $self->sql->select_list_of_hashes( - "SELECT medium_index.medium AS medium, - cube_distance(toc, create_cube_from_durations($dur_string)) AS distance, - release, - position, - format, - name, - edits_pending + $self->query_to_list_limited( + "SELECT release, + min(cube_distance(toc, create_cube_from_durations($dur_string))) AS min_distance, + json_agg(json_build_object( + 'medium', medium_index.medium, + 'distance', cube_distance(toc, create_cube_from_durations($dur_string)), + 'position', position + ) ORDER BY position) AS results FROM medium_index - JOIN medium m ON medium_index.medium = m.id + JOIN medium m ON medium_index.medium = m.id " . + ($all_formats ? '' : ' LEFT JOIN medium_format mf ON m.format = mf.id ') . " WHERE track_count_matches_cdtoc(m, ?) - AND toc <@ create_bounding_cube($dur_string, ?) - ORDER BY distance - LIMIT 25", $toc_info{tracks}, $fuzzy); - - my @results; - foreach my $item (@{$list}) - { - my $result = MusicBrainz::Server::Entity::DurationLookupResult->new(); - $result->distance(int($item->{distance})); - $result->medium_id($item->{medium}); - my $medium = MusicBrainz::Server::Entity::Medium->new(); - $medium->id($item->{medium}); - $medium->release_id($item->{release}); - $medium->position($item->{position}); - $medium->format_id($item->{format}) if $item->{format}; - $medium->name($item->{name} or ''); - $medium->edits_pending($item->{edits_pending}); - $result->medium($medium); - push @results, $result; - } - return \@results; + AND toc <@ create_bounding_cube($dur_string, ?) " . + ($all_formats ? '' : ' AND (m.format IS NULL OR mf.has_discids) ') . " + GROUP BY release + ORDER BY min_distance, release + LIMIT 25", + [$toc_info{tracks}, $fuzzy], $limit, $offset, sub { + my ($model, $row) = @_; + return { + release => $row->{release}, + min_distance => $row->{min_distance}, + results => decode_json($row->{results}), + }; + }); } sub update diff --git a/lib/MusicBrainz/Server/Data/Medium.pm b/lib/MusicBrainz/Server/Data/Medium.pm index 700a74b9c19..37e6a9f1a92 100644 --- a/lib/MusicBrainz/Server/Data/Medium.pm +++ b/lib/MusicBrainz/Server/Data/Medium.pm @@ -63,10 +63,13 @@ sub load sub load_for_releases { my ($self, @releases) = @_; + + @releases = grep { !$_->mediums_loaded } @releases; + return unless @releases; + my %id_to_release = object_to_ids(@releases); my @ids = keys %id_to_release; - return unless @ids; # nothing to do my $query = "SELECT " . $self->_columns . " FROM " . $self->_table . " @@ -81,6 +84,8 @@ sub load_for_releases weaken($medium->{release}); # XXX HACK! } } + + $_->mediums_loaded(1) for @releases; } sub update diff --git a/lib/MusicBrainz/Server/Data/Relationship.pm b/lib/MusicBrainz/Server/Data/Relationship.pm index 87f66b461ce..49448bcce5d 100644 --- a/lib/MusicBrainz/Server/Data/Relationship.pm +++ b/lib/MusicBrainz/Server/Data/Relationship.pm @@ -26,6 +26,7 @@ use MusicBrainz::Server::Data::Utils qw( ); use MusicBrainz::Server::Constants qw( $PART_OF_AREA_LINK_TYPE + %ENTITIES %ENTITIES_WITH_RELATIONSHIP_CREDITS @RELATABLE_ENTITIES entities_with @@ -64,15 +65,22 @@ sub _new_from_row my $weaken; if (defined $obj) { + $info{source} = $obj; + $info{source_type} = $obj->entity_type; + if ($matching_entity_type == 0 && $entity0 == $obj->id) { $weaken = 'entity0'; $info{entity0} = $obj; $info{direction} = $MusicBrainz::Server::Entity::Relationship::DIRECTION_FORWARD; + $info{source_credit} = $info{entity0_credit}; + $info{target_credit} = $info{entity1_credit}; } elsif ($matching_entity_type == 1 && $entity1 == $obj->id) { $weaken = 'entity1'; $info{entity1} = $obj; $info{direction} = $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD; + $info{source_credit} = $info{entity1_credit}; + $info{target_credit} = $info{entity0_credit}; } else { carp "Neither relationship end-point matched the object."; @@ -165,11 +173,20 @@ sub _load JOIN link l ON link = l.id JOIN link_type lt ON lt.id = l.link_type"; - my $order = 'l.begin_date_year, l.begin_date_month, l.begin_date_day, + my $order = 'lt.name, link_order, + l.begin_date_year, l.begin_date_month, l.begin_date_day, l.end_date_year, l.end_date_month, l.end_date_day, l.ended'; - $order .= $target eq 'url' ? ', url' : ", musicbrainz_collate(${target}.name)"; + if ($ENTITIES{$target}{sort_name}) { + $order .= ", musicbrainz_collate(${target}.sort_name)"; + } elsif ($target eq 'url') { + $order .= ', url'; + } else { + $order .= ", musicbrainz_collate(${target}.name)"; + } + + $order .= ', lt.child_order'; $query = "SELECT $select JOIN $target ON $target_id = ${target}.id @@ -227,12 +244,28 @@ sub load_entities if ($rel->entity0_id && !defined($rel->entity0)) { my $type = $rel->link->type->entity0_type; my $obj = $data_by_type{$type}->{$rel->entity0_id}; - $rel->entity0($obj) if defined($obj); + + if (defined $obj) { + $rel->entity0($obj); + + if ($rel->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD) { + $rel->target($obj); + $rel->target_type($obj->entity_type); + } + } } if ($rel->entity1_id && !defined($rel->entity1)) { my $type = $rel->link->type->entity1_type; my $obj = $data_by_type{$type}->{$rel->entity1_id}; - $rel->entity1($obj) if defined($obj); + + if (defined $obj) { + $rel->entity1($obj); + + if ($rel->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_FORWARD) { + $rel->target($obj); + $rel->target_type($obj->entity_type); + } + } } } diff --git a/lib/MusicBrainz/Server/Data/Release.pm b/lib/MusicBrainz/Server/Data/Release.pm index 963784532e3..6fa506e3db6 100644 --- a/lib/MusicBrainz/Server/Data/Release.pm +++ b/lib/MusicBrainz/Server/Data/Release.pm @@ -302,17 +302,24 @@ sub find_by_release_group SELECT * FROM ( SELECT DISTINCT ON (release.id) " . $self->_columns . ", - date_year, date_month, date_day, area.name AS country_name + date_year, date_month, date_day, area.name AS country_name, + rl.catalog_numbers AS catalog_numbers FROM " . $self->_table . " " . join(' ', @$extra_joins) . " LEFT JOIN release_event ON release_event.release = release.id LEFT JOIN area ON area.id = release_event.country + LEFT JOIN ( + SELECT + array_agg(catalog_number ORDER BY catalog_number) AS catalog_numbers, release + FROM release_label + GROUP BY release + ) rl ON release.id = rl.release WHERE " . join(" AND ", @$conditions) . " ORDER BY release.id, date_year, date_month, date_day, - country_name, barcode + rl.catalog_numbers, country_name, barcode ) s ORDER BY date_year, date_month, date_day, - country_name, barcode + catalog_numbers, country_name, barcode "; $self->query_to_list_limited($query, $params, $limit, $offset); @@ -609,16 +616,17 @@ sub load_with_medium_for_recording } sub find_by_medium { - my ($self, @medium_ids) = @_; + my ($self, $medium_ids, $limit, $offset) = @_; my $query = 'SELECT ' . $self->_columns . ' FROM ' . $self->_table . ' WHERE release.id IN ( SELECT release FROM medium WHERE medium.id = any(?) - )'; + )' . + ' ORDER BY release.id'; - $self->query_to_list($query, [\@medium_ids]); + $self->query_to_list_limited($query, [$medium_ids], $limit, $offset); } sub _order_by { diff --git a/lib/MusicBrainz/Server/Data/Role/EntityCache.pm b/lib/MusicBrainz/Server/Data/Role/EntityCache.pm index 13d6ac99183..4f7eb29bbd4 100644 --- a/lib/MusicBrainz/Server/Data/Role/EntityCache.pm +++ b/lib/MusicBrainz/Server/Data/Role/EntityCache.pm @@ -2,12 +2,15 @@ package MusicBrainz::Server::Data::Role::EntityCache; use DBDefs; use Moose::Role; -use List::MoreUtils qw( uniq ); +use List::MoreUtils qw( natatime uniq ); use MusicBrainz::Server::Constants qw( %ENTITIES ); -use Scalar::Util qw( looks_like_number ); +use MusicBrainz::Server::Validation qw( is_database_row_id ); +use Readonly; requires '_type'; +Readonly our $MAX_CACHE_ENTRIES => 500; + sub _cache_id { my ($self) = @_; @@ -64,17 +67,27 @@ sub _create_cache_entries { my $cache_id = $self->_cache_id; my $cache_prefix = $self->_type . ':'; my @entries; - for my $id (keys %{$data}) { + my @ids = keys %{$data}; + + if (scalar(@ids) > $MAX_CACHE_ENTRIES) { + @ids = @ids[0..$MAX_CACHE_ENTRIES]; + } + + my $it = natatime 100, @ids; + while (my @next_ids = $it->()) { # MBS-7241 - my $got_lock = $self->c->sql->select_single_value( - 'SELECT pg_try_advisory_xact_lock(?, ?)', + my $locks = $self->c->sql->select_list_of_hashes( + 'SELECT id, pg_try_advisory_xact_lock(?, id) AS got_lock ' . + ' FROM unnest(?::integer[]) AS id', $cache_id, - $id, + \@next_ids, ); - if ($got_lock) { - push @entries, [$cache_prefix . $id, $data->{$id}, DBDefs->ENTITY_CACHE_TTL]; - } + push @entries, map { + my $id = $_->{id}; + [$cache_prefix . $id, $data->{$id}, DBDefs->ENTITY_CACHE_TTL] + } grep { $_->{got_lock} } @$locks; } + @entries; } @@ -92,16 +105,18 @@ sub _delete_from_cache { return unless @ids; my $cache_id = $self->_cache_id; - my $cache_prefix = $self->_type . ':'; - my @keys; - for my $id (@ids) { - if (looks_like_number($id)) { - # MBS-7241 - $self->c->sql->do('SELECT pg_advisory_xact_lock(?, ?)', $cache_id, $id); - } - push @keys, $cache_prefix . $id; - } + # MBS-7241 + my @row_ids = grep { is_database_row_id($_) } @ids; + $self->c->sql->do( + 'SELECT pg_advisory_xact_lock(?, id) ' . + ' FROM unnest(?::integer[]) AS id', + $cache_id, + \@row_ids, + ) if @row_ids; + + my $cache_prefix = $self->_type . ':'; + my @keys = map { $cache_prefix . $_ } @ids; my $cache = $self->c->cache($self->_type); my $method = @keys > 1 ? 'delete_multi' : 'delete'; diff --git a/lib/MusicBrainz/Server/Data/Search.pm b/lib/MusicBrainz/Server/Data/Search.pm index 4655be8c2a9..4dd8e405391 100644 --- a/lib/MusicBrainz/Server/Data/Search.pm +++ b/lib/MusicBrainz/Server/Data/Search.pm @@ -609,6 +609,8 @@ sub schema_fixup push @relationships, MusicBrainz::Server::Entity::Relationship->new( entity1 => $entity, + target => $entity, + target_type => $entity->entity_type, link => MusicBrainz::Server::Entity::Link->new( type => MusicBrainz::Server::Entity::LinkType->new( entity1_type => $entity_type, diff --git a/lib/MusicBrainz/Server/Data/Statistics.pm b/lib/MusicBrainz/Server/Data/Statistics.pm index ebf389bb402..c3237b5b260 100644 --- a/lib/MusicBrainz/Server/Data/Statistics.pm +++ b/lib/MusicBrainz/Server/Data/Statistics.pm @@ -929,7 +929,11 @@ my %stats = ( ON l.area = ac.descendant AND ac.parent IN (SELECT area FROM country_area) FULL OUTER JOIN iso_3166_1 iso - ON iso.area = COALESCE(ac.parent, l.area) + ON iso.area = COALESCE( + (SELECT area FROM country_area WHERE area = ac.descendant), + ac.parent, + l.area + ) GROUP BY iso.code }, @containment_query_args); @@ -1369,7 +1373,11 @@ my %stats = ( ON a.area = ac.descendant AND ac.parent IN (SELECT area FROM country_area) FULL OUTER JOIN iso_3166_1 iso - ON iso.area = COALESCE(ac.parent, a.area) + ON iso.area = COALESCE( + (SELECT area FROM country_area WHERE area = ac.descendant), + ac.parent, + a.area + ) GROUP BY iso.code }, @containment_query_args); diff --git a/lib/MusicBrainz/Server/Data/Utils.pm b/lib/MusicBrainz/Server/Data/Utils.pm index 5d00634692e..5b34e1c1d09 100644 --- a/lib/MusicBrainz/Server/Data/Utils.pm +++ b/lib/MusicBrainz/Server/Data/Utils.pm @@ -70,6 +70,7 @@ our @EXPORT_OK = qw( sanitize take_while trim + trim_comment type_to_model split_relationship_by_attributes ); @@ -137,7 +138,7 @@ sub load_subobjects my $attr_id = $attr_obj . '_id'; my @objs_with_id; for my $obj (@objs) { - next unless $obj->meta->find_attribute_by_name($attr_id); + next unless $obj->can($attr_id); my $id = $obj->$attr_id; if (defined $id) { push @ids, $id; @@ -341,6 +342,14 @@ sub trim { return $t; } +sub trim_comment { + my $t = shift; + + $t =~ s/^\s*\(([^()]+)\)\s*$/$1/; + + return trim($t); +} + sub remove_direction_marks { my $t = shift; diff --git a/lib/MusicBrainz/Server/Edit/Annotation/Edit.pm b/lib/MusicBrainz/Server/Edit/Annotation/Edit.pm index 4c41e308ee2..fecdd212a8f 100644 --- a/lib/MusicBrainz/Server/Edit/Annotation/Edit.pm +++ b/lib/MusicBrainz/Server/Edit/Annotation/Edit.pm @@ -7,6 +7,7 @@ use MooseX::Types::Structured qw( Dict ); use MusicBrainz::Server::Data::Utils qw( model_to_type ); use MusicBrainz::Server::Edit::Types qw( Nullable NullableOnPreview ); use MusicBrainz::Server::Filters qw( format_wikitext ); +use JSON::XS; parameter model => ( isa => 'Str', required => 1 ); parameter edit_type => ( isa => 'Int', required => 1 ); @@ -39,7 +40,7 @@ role { ); has annotation_id => ( - isa => 'Int', + isa => 'Maybe[Int]', is => 'rw', ); @@ -64,7 +65,6 @@ role { my $data = { changelog => $self->data->{changelog}, - annotation_id => $self->annotation_id, text => $self->data->{text}, html => format_wikitext($self->data->{text}), entity_type => $entity_type, @@ -78,16 +78,27 @@ role { return $data; }; - method insert => sub { + method accept => sub { my $self = shift; my $model = $self->_annotation_model; + my $latest_annotation = $model->get_latest($self->data->{entity}{id}); + + if ($latest_annotation && $latest_annotation->{creation_date} > $self->{created_time} ) { + MusicBrainz::Server::Edit::Exceptions::FailedDependency + ->throw('The annotation has changed since this edit was entered.'); + } + my $id = $model->edit({ entity_id => $self->data->{entity}{id}, text => $self->data->{text}, changelog => $self->data->{changelog}, editor_id => $self->data->{editor_id} }); + + # We add the annotation id to the raw edit data for reference $self->annotation_id($id); + my $json = JSON::XS->new; + $self->c->sql->update_row('edit_data', { data => $json->encode($self->to_hash) }, { edit => $self->id }); }; method initialize => sub { diff --git a/lib/MusicBrainz/Server/Edit/Historic/Relationship.pm b/lib/MusicBrainz/Server/Edit/Historic/Relationship.pm index 9a52230c15c..07ffc7941b6 100644 --- a/lib/MusicBrainz/Server/Edit/Historic/Relationship.pm +++ b/lib/MusicBrainz/Server/Edit/Historic/Relationship.pm @@ -216,12 +216,18 @@ sub _display_relationships { my $model1 = type_to_model( $_->{entity1_type} ); my $entity0_id = $_->{entity0_id}; my $entity1_id = $_->{entity1_id}; + my $entity0 = $loaded->{ $model0 }{ $entity0_id } || + $self->c->model($model0)->_entity_class->new( name => $_->{entity0_name}); + my $entity1 = $loaded->{ $model1 }{ $entity1_id } || + $self->c->model($model1)->_entity_class->new( name => $_->{entity1_name}); Relationship->new( - entity0 => $loaded->{ $model0 }{ $entity0_id } || - $self->c->model($model0)->_entity_class->new( name => $_->{entity0_name}), - entity1 => $loaded->{ $model1 }{ $entity1_id } || - $self->c->model($model1)->_entity_class->new( name => $_->{entity1_name}), + entity0 => $entity0, + entity1 => $entity1, + source => $entity0, + target => $entity1, + source_type => $entity0->entity_type, + target_type => $entity1->entity_type, link => Link->new( begin_date => PartialDate->new($data->{begin_date}), end_date => PartialDate->new($data->{end_date}), diff --git a/lib/MusicBrainz/Server/Edit/Relationship/Create.pm b/lib/MusicBrainz/Server/Edit/Relationship/Create.pm index 2b7dc97807c..7ae9f370fec 100644 --- a/lib/MusicBrainz/Server/Edit/Relationship/Create.pm +++ b/lib/MusicBrainz/Server/Edit/Relationship/Create.pm @@ -166,8 +166,20 @@ sub foreign_keys sub build_display_data { my ($self, $loaded) = @_; - my $model0 = type_to_model($self->data->{type0}); - my $model1 = type_to_model($self->data->{type1}); + my $type0 = $self->data->{type0}; + my $type1 = $self->data->{type1}; + my $model0 = type_to_model($type0); + my $model1 = type_to_model($type1); + my $entity0 = $loaded->{$model0}{gid_or_id($self->data->{entity0})} || + $self->c->model($model0)->_entity_class->new( + name => $self->data->{entity0}{name} + ); + my $entity1 = $loaded->{$model1}{gid_or_id($self->data->{entity1})} || + $self->c->model($model1)->_entity_class->new( + name => $self->data->{entity1}{name} + ); + my $entity0_credit = $self->data->{entity0_credit} // ''; + my $entity1_credit = $self->data->{entity1_credit} // ''; return { relationship => Relationship->new( @@ -193,16 +205,16 @@ sub build_display_data } @{ $self->data->{attributes} } ], ), - entity0 => $loaded->{$model0}{gid_or_id($self->data->{entity0})} || - $self->c->model($model0)->_entity_class->new( - name => $self->data->{entity0}{name} - ), - entity1 => $loaded->{$model1}{gid_or_id($self->data->{entity1})} || - $self->c->model($model1)->_entity_class->new( - name => $self->data->{entity1}{name} - ), - entity0_credit => $self->data->{entity0_credit} // '', - entity1_credit => $self->data->{entity1_credit} // '', + entity0 => $entity0, + entity1 => $entity1, + entity0_credit => $entity0_credit, + entity1_credit => $entity1_credit, + source => $entity0, + target => $entity1, + source_type => $type0, + target_type => $type1, + source_credit => $entity0_credit, + target_credit => $entity1_credit, link_order => $self->data->{link_order} // 0, ), unknown_attributes => scalar( diff --git a/lib/MusicBrainz/Server/Edit/Relationship/Delete.pm b/lib/MusicBrainz/Server/Edit/Relationship/Delete.pm index 2dd5a01544e..2a37ecb16c4 100644 --- a/lib/MusicBrainz/Server/Edit/Relationship/Delete.pm +++ b/lib/MusicBrainz/Server/Edit/Relationship/Delete.pm @@ -114,33 +114,45 @@ sub build_display_data ]; my $link_type = $relationship->{link}{type}; + my $entity0_type = $link_type->{entity0_type}; + my $entity1_type = $link_type->{entity1_type}; my $link = MusicBrainz::Server::Entity::Link->new( begin_date => MusicBrainz::Server::Entity::PartialDate->new_from_row($relationship->{link}{begin_date}), end_date => MusicBrainz::Server::Entity::PartialDate->new_from_row($relationship->{link}{end_date}), ended => $relationship->{link}{ended}, type => $loaded->{LinkType}{$link_type->{id}} // MusicBrainz::Server::Entity::LinkType->new( $link_type->{id} ? (id => $link_type->{id}) : (), - entity0_type => $link_type->{entity0_type}, - entity1_type => $link_type->{entity1_type}, + entity0_type => $entity0_type, + entity1_type => $entity1_type, long_link_phrase => $link_type->{long_link_phrase} // '', ), attributes => $attrs ); - my $entity0 = $relationship->{entity0}; - my $entity1 = $relationship->{entity1}; + my $entity0_data = $relationship->{entity0}; + my $entity1_data = $relationship->{entity1}; + my $entity0 = $loaded->{ $self->model0 }->{gid_or_id($entity0_data)} || + $self->c->model($self->model0)->_entity_class->new( + name => $entity0_data->{name} + ); + my $entity1 = $loaded->{ $self->model1 }->{gid_or_id($entity1_data)} || + $self->c->model($self->model1)->_entity_class->new( + name => $entity1_data->{name} + ); + my $entity0_credit = $relationship->{entity0_credit} // ''; + my $entity1_credit = $relationship->{entity1_credit} // ''; my %relationship_opts = ( - entity0 => $loaded->{ $self->model0 }->{gid_or_id($entity0)} || - $self->c->model($self->model0)->_entity_class->new( - name => $entity0->{name} - ), - entity1 => $loaded->{ $self->model1 }->{gid_or_id($entity1)} || - $self->c->model($self->model1)->_entity_class->new( - name => $entity1->{name} - ), - entity0_credit => $relationship->{entity0_credit} // '', - entity1_credit => $relationship->{entity1_credit} // '', + entity0 => $entity0, + entity1 => $entity1, + entity0_credit => $entity0_credit, + entity1_credit => $entity1_credit, + source => $entity0, + target => $entity1, + source_type => $entity0_type, + target_type => $entity1_type, + source_credit => $entity0_credit, + target_credit => $entity1_credit, link => $link ); if ($relationship->{phrase}) { diff --git a/lib/MusicBrainz/Server/Edit/Relationship/Edit.pm b/lib/MusicBrainz/Server/Edit/Relationship/Edit.pm index 76cb9d3eced..7f9c123798d 100644 --- a/lib/MusicBrainz/Server/Edit/Relationship/Edit.pm +++ b/lib/MusicBrainz/Server/Edit/Relationship/Edit.pm @@ -148,8 +148,10 @@ sub _build_relationship { my ($self, $loaded, $data, $change) = @_; my $link = $data->{link}; - my $model0 = type_to_model($data->{type0}); - my $model1 = type_to_model($data->{type1}); + my $type0 = $data->{type0}; + my $type1 = $data->{type1}; + my $model0 = type_to_model($type0); + my $model1 = type_to_model($type1); my $begin = defined $change->{begin_date} ? $change->{begin_date} : $link->{begin_date}; my $end = defined $change->{end_date} ? $change->{end_date} : $link->{end_date}; @@ -164,19 +166,41 @@ sub _build_relationship { my $entity0_id = gid_or_id($entity0) // 0; my $entity1_id = gid_or_id($entity1) // 0; + $entity0 = $loaded->{$model0}{$entity0_id} || + $self->c->model($model0)->_entity_class->new( + defined $entity0->{id} ? (id => $entity0->{id}) : (), + name => $entity0->{name}, + ); + $entity1 = $loaded->{$model1}{$entity1_id} || + $self->c->model($model1)->_entity_class->new( + defined $entity1->{id} ? (id => $entity1->{id}) : (), + name => $entity1->{name}, + ); + my $entity0_credit = $change->{entity0_credit} // ''; + my $entity1_credit = $change->{entity1_credit} // ''; + return Relationship->new( + id => $data->{relationship_id}, link => Link->new( - type => $loaded->{LinkType}{ $lt->{id} } || LinkType->new( $lt ), + type => $loaded->{LinkType}{ $lt->{id} } || + LinkType->new( + %{$lt}, + entity0_type => $data->{type0}, + entity1_type => $data->{type1}, + ), + type_id => $lt->{id}, begin_date => PartialDate->new_from_row( $begin ), end_date => PartialDate->new_from_row( $end ), ended => $ended, attributes => [ map { - my $attr = $loaded->{LinkAttributeType}{ $_->{type}{id} }; + my $type_id = $_->{type}{id}; + my $attr = $loaded->{LinkAttributeType}{$type_id}; if ($attr) { MusicBrainz::Server::Entity::LinkAttribute->new( type => $attr, + type_id => $type_id, credited_as => $_->{credited_as}, text_value => $_->{text_value}, ); @@ -187,12 +211,18 @@ sub _build_relationship { } @$attributes ], ), - entity0 => $loaded->{$model0}{ $entity0_id } || - $self->c->model($model0)->_entity_class->new( name => $entity0->{name} ), - entity1 => $loaded->{$model1}{ $entity1_id } || - $self->c->model($model1)->_entity_class->new( name => $entity1->{name} ), - entity0_credit => $change->{entity0_credit} // '', - entity1_credit => $change->{entity1_credit} // '', + entity0 => $entity0, + entity1 => $entity1, + entity0_credit => $entity0_credit, + entity1_credit => $entity1_credit, + defined $entity0->{id} ? (entity0_id => $entity0->{id}) : (), + defined $entity1->{id} ? (entity1_id => $entity1->{id}) : (), + source => $entity0, + target => $entity1, + source_type => $type0, + target_type => $type1, + source_credit => $entity0_credit, + target_credit => $entity1_credit, ); } diff --git a/lib/MusicBrainz/Server/Edit/Relationship/EditLinkType.pm b/lib/MusicBrainz/Server/Edit/Relationship/EditLinkType.pm index 2717d635699..a0909609684 100644 --- a/lib/MusicBrainz/Server/Edit/Relationship/EditLinkType.pm +++ b/lib/MusicBrainz/Server/Edit/Relationship/EditLinkType.pm @@ -158,14 +158,20 @@ sub build_display_data { } qw( entity0_type entity1_type ); my $rel = $_->{relationship}; + my $entity0 = $class0->new($rel->{entity0}); + my $entity1 = $class1->new($rel->{entity1}); MusicBrainz::Server::Entity::ExampleRelationship->new( published => $_->{published}, name => $_->{name}, relationship => MusicBrainz::Server::Entity::Relationship->new( id => $rel->{id}, - entity0 => $class0->new($rel->{entity0}), - entity1 => $class1->new($rel->{entity1}), + entity0 => $entity0, + entity1 => $entity1, + source => $entity0, + target => $entity1, + source_type => $entity0->entity_type, + target_type => $entity1->entity_type, verbose_phrase => $rel->{verbose_phrase}, link => MusicBrainz::Server::Entity::Link->new( diff --git a/lib/MusicBrainz/Server/Edit/Relationship/Reorder.pm b/lib/MusicBrainz/Server/Edit/Relationship/Reorder.pm index 5f6e711addd..bbd9fbe1d57 100644 --- a/lib/MusicBrainz/Server/Edit/Relationship/Reorder.pm +++ b/lib/MusicBrainz/Server/Edit/Relationship/Reorder.pm @@ -100,6 +100,11 @@ sub _build_relationship { my $model0 = type_to_model($lt->{entity0_type}); my $model1 = type_to_model($lt->{entity1_type}); + my $entity0 = $loaded->{$model0}{ $data->{entity0}{id} } || + $self->c->model($model0)->_entity_class->new(name => $data->{entity0}{name}); + my $entity1 = $loaded->{$model1}{ $data->{entity1}{id} } || + $self->c->model($model1)->_entity_class->new(name => $data->{entity1}{name}); + return Relationship->new( link => Link->new( type => $loaded->{LinkType}{$lt->{id}} || LinkType->new($lt), @@ -122,10 +127,12 @@ sub _build_relationship { } @{ $data->{attributes} } ], ), - entity0 => $loaded->{$model0}{ $data->{entity0}{id} } || - $self->c->model($model0)->_entity_class->new(name => $data->{entity0}{name}), - entity1 => $loaded->{$model1}{ $data->{entity1}{id} } || - $self->c->model($model1)->_entity_class->new(name => $data->{entity1}{name}), + entity0 => $entity0, + entity1 => $entity1, + source => $entity0, + target => $entity1, + source_type => $entity0->entity_type, + target_type => $entity1->entity_type, ); } diff --git a/lib/MusicBrainz/Server/Edit/Release/Edit.pm b/lib/MusicBrainz/Server/Edit/Release/Edit.pm index 45da38c03d5..328c4124721 100644 --- a/lib/MusicBrainz/Server/Edit/Release/Edit.pm +++ b/lib/MusicBrainz/Server/Edit/Release/Edit.pm @@ -179,50 +179,13 @@ sub build_display_data ) }; - my $inflated_events = { + $data->{events} = { map { $_ => [ map { $inflate_event->($_) } @{ $self->data->{$_}{events} } ] } qw( old new ) }; - - my $by_country = sub { map { $_->country_id => $_ } @{ shift() } }; - - my %old_by_country = $by_country->($inflated_events->{old}); - my %new_by_country = $by_country->($inflated_events->{new}); - - # Attempt to correlate release event changes over old/new release - # where the country is unchanged - my %by_country = map { - $_ => { - old => $old_by_country{$_}, - new => $new_by_country{$_} - } - } - grep { - exists $old_by_country{$_} && $new_by_country{$_} - } - map { $_->country_id } values %old_by_country; - - # Take all remaining release events that can't be correlated - my $filter_unmatched = sub { - my ($filter_input, $filter_against) = @_; - return [ - grep { !exists $filter_against->{$_->country_id} } - values %$filter_input - ]; - }; - - my %changed_countries = ( - old => $filter_unmatched->(\%old_by_country, \%new_by_country), - new => $filter_unmatched->(\%new_by_country, \%old_by_country), - ); - - $data->{events} = { - unchanged_countries => \%by_country, - changed_countries => \%changed_countries - }; } $data->{release} = $loaded->{Release}{ $self->data->{entity}{id} } diff --git a/lib/MusicBrainz/Server/Entity/ArtistCredit.pm b/lib/MusicBrainz/Server/Entity/ArtistCredit.pm index d0843c92e69..cf84391507a 100644 --- a/lib/MusicBrainz/Server/Entity/ArtistCredit.pm +++ b/lib/MusicBrainz/Server/Entity/ArtistCredit.pm @@ -1,6 +1,7 @@ package MusicBrainz::Server::Entity::ArtistCredit; use Moose; +use Scalar::Util qw( refaddr ); use MusicBrainz::Server::Entity::Types; use aliased 'MusicBrainz::Server::Entity::Artist'; use aliased 'MusicBrainz::Server::Entity::ArtistCreditName'; @@ -35,17 +36,22 @@ has 'artist_count' => ( sub is_equal { my ($a, $b) = @_; + return 0 unless (defined($a) && defined($b)) && - (ref($a) eq ref($b)) && - ($a->name_count == $b->name_count); + (ref($a) eq ref($b)); + + # refaddr is needed since == is overloaded + return 1 if refaddr($a) == refaddr($b); + + return 0 unless $a->name_count == $b->name_count; for my $i (0 .. ($a->name_count - 1)) { my ($an, $bn) = ($a->names->[$i], $b->names->[$i]); return 0 unless ($an->name eq $bn->name) && (($an->join_phrase || '') eq ($bn->join_phrase || '')) && - ($an->artist_id == $bn->artist_id); + (($an->artist_id || 0) == ($bn->artist_id || 0)); } return 1; diff --git a/lib/MusicBrainz/Server/Entity/Relationship.pm b/lib/MusicBrainz/Server/Entity/Relationship.pm index 0145b178352..695ead7c3e1 100644 --- a/lib/MusicBrainz/Server/Entity/Relationship.pm +++ b/lib/MusicBrainz/Server/Entity/Relationship.pm @@ -101,107 +101,36 @@ sub entity_is_orderable { return 0; } -sub _source_target_prop { - my ($self, %opts) = @_; - - my $prop_base = exists $opts{prop_base} ? $opts{prop_base} : $self; - my $prop_suffix = $opts{prop_suffix}; - my $prop; - if ($opts{is_target}) { - $prop = ($self->direction == $DIRECTION_FORWARD) ? 'entity1' : 'entity0'; - } else { - $prop = ($self->direction == $DIRECTION_FORWARD) ? 'entity0' : 'entity1'; - } - $prop = $prop . '_' . $prop_suffix if $prop_suffix; - return $prop_base->$prop; -} - has source => ( - is => 'ro', + is => 'rw', isa => 'Linkable', - lazy => 1, - builder => '_build_source', ); -sub _build_source { - return shift->_source_target_prop(); -} - has source_type => ( - is => 'ro', + is => 'rw', isa => 'Str', - lazy => 1, - builder => '_build_source_type', ); -sub _build_source_type { - my ($self) = @_; - return $self->_source_target_prop(prop_suffix => 'type', prop_base => $self->link->type); -} - has source_credit => ( - is => 'ro', + is => 'rw', isa => 'Str', - lazy => 1, - builder => '_build_source_credit', ); -sub _build_source_credit { - my ($self) = @_; - return $self->_source_target_prop(prop_suffix => 'credit') // ''; -} - -sub source_key { - my ($self) = @_; - return ($self->source_type eq 'url') - ? $self->source->url - : $self->source->gid; -} - has target => ( - is => 'ro', + is => 'rw', isa => 'Linkable', - lazy => 1, - builder => '_build_target', ); -sub _build_target { - my ($self) = @_; - return $self->_source_target_prop(is_target => 1); -} - has target_type => ( - is => 'ro', + is => 'rw', isa => 'Str', - lazy => 1, - builder => '_build_target_type', ); -sub _build_target_type { - my ($self) = @_; - return $self->_source_target_prop(is_target => 1, prop_suffix => 'type', prop_base => $self->link->type); -} - has target_credit => ( - is => 'ro', + is => 'rw', isa => 'Str', - lazy => 1, - builder => '_build_target_credit', ); -sub _build_target_credit { - my ($self) = @_; - return $self->_source_target_prop(is_target => 1, prop_suffix => 'credit') // ''; -} - -sub target_key -{ - my ($self) = @_; - return ($self->target_type eq 'url') - ? $self->target->url - : $self->target->gid; -} - sub phrase { my ($self) = @_; @@ -348,6 +277,8 @@ around TO_JSON => sub { ended => boolean_to_json($link->ended), entity0_credit => $self->entity0_credit, entity1_credit => $self->entity1_credit, + entity0_id => $self->entity0_id, + entity1_id => $self->entity1_id, id => $self->id + 0, linkOrder => $self->link_order + 0, linkTypeID => $link->type_id + 0, @@ -359,6 +290,9 @@ around TO_JSON => sub { $json->{end_date} = $link->end_date->is_empty ? undef : partial_date_to_hash($link->end_date); $json->{direction} = 'backward' if $self->direction == $DIRECTION_BACKWARD; + my $source = $self->source; + $self->link_entity($source->entity_type, $source->id, $source); + $self->link_entity('link_type', $link->type_id, $link->type); for my $ltat ($link->type->all_attributes) { diff --git a/lib/MusicBrainz/Server/Entity/Release.pm b/lib/MusicBrainz/Server/Entity/Release.pm index 9c7eafcdd1e..54f69436319 100644 --- a/lib/MusicBrainz/Server/Entity/Release.pm +++ b/lib/MusicBrainz/Server/Entity/Release.pm @@ -143,6 +143,12 @@ has 'mediums' => ( } ); +has 'mediums_loaded' => ( + is => 'rw', + isa => 'Bool', + default => 0, +); + has events => ( is => 'rw', isa => 'ArrayRef[ReleaseEvent]', diff --git a/lib/MusicBrainz/Server/FilterUtils.pm b/lib/MusicBrainz/Server/FilterUtils.pm index 1464a4efa2c..4d190a24243 100644 --- a/lib/MusicBrainz/Server/FilterUtils.pm +++ b/lib/MusicBrainz/Server/FilterUtils.pm @@ -13,38 +13,36 @@ sub create_artist_release_groups_form { my ($c, $artist_id) = @_; my %form_args = ( + entity_type => 'release_group', types => [ $c->model('ReleaseGroupType')->get_all ], ); $form_args{artist_credits} = $c->model('ReleaseGroup')->find_artist_credits_by_artist($artist_id); - $c->stash(filter_submit_text => l('Filter release groups')); return $c->form(filter_form => 'Filter::ReleaseGroup', %form_args); } sub create_artist_releases_form { my ($c, $artist_id) = @_; - my %form_args = ( ); + my %form_args = (entity_type => 'release'); $form_args{artist_credits} = $c->model('Release')->find_artist_credits_by_artist($artist_id); - $c->stash(filter_submit_text => l('Filter releases')); - return $c->form(filter_form => 'Filter::Recording', %form_args); + return $c->form(filter_form => 'Filter::Generic', %form_args); } sub create_artist_recordings_form { my ($c, $artist_id) = @_; - my %form_args = ( ); + my %form_args = (entity_type => 'recording'); $form_args{artist_credits} = $c->model('Recording')->find_artist_credits_by_artist($artist_id); - $c->stash(filter_submit_text => l('Filter recordings')); - return $c->form(filter_form => 'Filter::Recording', %form_args); + return $c->form(filter_form => 'Filter::Generic', %form_args); } 1; diff --git a/lib/MusicBrainz/Server/Form/Field/Comment.pm b/lib/MusicBrainz/Server/Form/Field/Comment.pm index 0a0335645d3..4632ac16391 100644 --- a/lib/MusicBrainz/Server/Form/Field/Comment.pm +++ b/lib/MusicBrainz/Server/Form/Field/Comment.pm @@ -1,9 +1,16 @@ package MusicBrainz::Server::Form::Field::Comment; -use Moose; +use HTML::FormHandler::Moose; +use MusicBrainz::Server::Data::Utils; -extends 'MusicBrainz::Server::Form::Field::Text'; +extends 'HTML::FormHandler::Field::Text'; has '+maxlength' => ( default => 255 ); has '+not_nullable' => ( default => 1 ); +apply ([ + { + transform => sub { MusicBrainz::Server::Data::Utils::trim_comment(shift) } + } +]); + 1; diff --git a/lib/MusicBrainz/Server/Form/Filter/Recording.pm b/lib/MusicBrainz/Server/Form/Filter/Generic.pm similarity index 60% rename from lib/MusicBrainz/Server/Form/Filter/Recording.pm rename to lib/MusicBrainz/Server/Form/Filter/Generic.pm index 9d22bc73d30..e0404a0ca18 100644 --- a/lib/MusicBrainz/Server/Form/Filter/Recording.pm +++ b/lib/MusicBrainz/Server/Form/Filter/Generic.pm @@ -1,9 +1,15 @@ -package MusicBrainz::Server::Form::Filter::Recording; +package MusicBrainz::Server::Form::Filter::Generic; use HTML::FormHandler::Moose; extends 'MusicBrainz::Server::Form'; has '+name' => ( default => 'filter' ); +has 'entity_type' => ( + isa => 'Str', + is => 'ro', + required => 1, +); + has 'artist_credits' => ( isa => 'ArrayRef[ArtistCredit]', is => 'ro', @@ -28,10 +34,19 @@ sub filter_field_names { sub options_artist_credit_id { my ($self, $field) = @_; return [ - map { $_->id => $_->name } + map +{ value => $_->id, label => $_->name }, @{ $self->artist_credits } ]; } +around TO_JSON => sub { + my ($orig, $self) = @_; + + my $json = $self->$orig; + $json->{entity_type} = $self->entity_type; + $json->{options_artist_credit_id} = $self->options_artist_credit_id; + return $json; +}; + 1; diff --git a/lib/MusicBrainz/Server/Form/Filter/ReleaseGroup.pm b/lib/MusicBrainz/Server/Form/Filter/ReleaseGroup.pm index 3a6f4071051..8edb3fb16ae 100644 --- a/lib/MusicBrainz/Server/Form/Filter/ReleaseGroup.pm +++ b/lib/MusicBrainz/Server/Form/Filter/ReleaseGroup.pm @@ -1,14 +1,6 @@ package MusicBrainz::Server::Form::Filter::ReleaseGroup; use HTML::FormHandler::Moose; -extends 'MusicBrainz::Server::Form'; - -has '+name' => ( default => 'filter' ); - -has 'artist_credits' => ( - isa => 'ArrayRef[ArtistCredit]', - is => 'ro', - required => 1, -); +extends 'MusicBrainz::Server::Form::Filter::Generic'; has 'types' => ( isa => 'ArrayRef[ReleaseGroupType]', @@ -16,40 +8,29 @@ has 'types' => ( required => 1, ); -has_field 'name' => ( - type => '+MusicBrainz::Server::Form::Field::Text', -); - -has_field 'artist_credit_id' => ( - type => 'Select', -); - has_field 'type_id' => ( type => 'Select', ); -has_field 'cancel' => ( type => 'Submit' ); -has_field 'submit' => ( type => 'Submit' ); - sub filter_field_names { return qw/ name artist_credit_id type_id /; } -sub options_artist_credit_id { - my ($self, $field) = @_; - return [ - map { $_->id => $_->name } - @{ $self->artist_credits } - ]; -} - sub options_type_id { my ($self, $field) = @_; return [ - map { $_->id => $_->name } + map +{ value => $_->id, label => $_->name }, @{ $self->types } ]; } +around TO_JSON => sub { + my ($orig, $self) = @_; + + my $json = $self->$orig; + $json->{options_type_id} = $self->options_type_id; + return $json; +}; + 1; diff --git a/lib/MusicBrainz/Server/Form/Role/EditNote.pm b/lib/MusicBrainz/Server/Form/Role/EditNote.pm index 475b6890d03..58531462080 100644 --- a/lib/MusicBrainz/Server/Form/Role/EditNote.pm +++ b/lib/MusicBrainz/Server/Form/Role/EditNote.pm @@ -2,6 +2,7 @@ package MusicBrainz::Server::Form::Role::EditNote; use HTML::FormHandler::Moose::Role; use MusicBrainz::Server::Translation qw( l ); +use MusicBrainz::Server::Validation qw( is_valid_edit_note ); has 'requires_edit_note' => ( is => 'ro', default => 0 ); @@ -18,9 +19,13 @@ has_field 'make_votable' => ( after validate => sub { my $self = shift; - if ($self->requires_edit_note && (!defined $self->field('edit_note')->value || $self->field('edit_note')->value eq '')) { + if ($self->requires_edit_note && (!defined $self->field('edit_note')->value)) { $self->field('edit_note')->add_error(l('You must provide an edit note')); } + + if ($self->requires_edit_note && (!is_valid_edit_note($self->field('edit_note')->value))) { + $self->field('edit_note')->add_error(l('Your edit note seems to have no actual content. Please provide a note that will be helpful to your fellow editors!')); + } }; 1; diff --git a/lib/MusicBrainz/Server/Plugin/Diff.pm b/lib/MusicBrainz/Server/Plugin/Diff.pm index 659e0bec049..eb929fc1210 100644 --- a/lib/MusicBrainz/Server/Plugin/Diff.pm +++ b/lib/MusicBrainz/Server/Plugin/Diff.pm @@ -10,7 +10,6 @@ use base 'Template::Plugin'; use Algorithm::Diff qw( sdiff traverse_sequences ); use Carp qw( confess ); use HTML::Tiny; -use HTML::TreeBuilder; use HTML::Entities qw( decode_entities ); use Scalar::Util qw( blessed ); use MusicBrainz::Server::Translation qw( l ); @@ -50,24 +49,6 @@ sub diff_side { return $self->_render_side_diff(1, $filter, $split, @diffs); } -sub diff_html_side { - my ($self, $old, $new, $filter) = @_; - - my $old_root = HTML::TreeBuilder->new_from_content(''.($old // '').''); - my @old_tokens = map { - _html_token($_) - } $old_root->content_array_ref->[1]->content_list; - - my $new_root = HTML::TreeBuilder->new_from_content(''.($new // '').''); - my @new_tokens = map { - _html_token($_) - } $new_root->content_array_ref->[1]->content_list; - - my @diffs = sdiff(\@old_tokens, \@new_tokens); - - return $self->_render_side_diff(0, $filter, '\s+', @diffs); -} - sub _html_token { my ($item) = @_; return blessed($item) ? $item->as_HTML : _split_text($item, '\s+'); @@ -128,7 +109,7 @@ sub _render_side_diff { my $text = $_->{str}; $text = encode_entities($text) if $escape_output; - $h->span({ class => $class }, $text) + $class ? $h->span({ class => $class }, $text) : $text } @stack ) } diff --git a/lib/MusicBrainz/Server/Report/DuplicateEvents.pm b/lib/MusicBrainz/Server/Report/DuplicateEvents.pm index 556bb0cc725..ecf4249bd70 100644 --- a/lib/MusicBrainz/Server/Report/DuplicateEvents.pm +++ b/lib/MusicBrainz/Server/Report/DuplicateEvents.pm @@ -24,7 +24,19 @@ SELECT event.id AS event_id, AND duplicates.begin_date_month = event.begin_date_month AND duplicates.begin_date_day = event.begin_date_day AND l_event_place.entity1 = duplicates.entity1 - )"; + ) + WHERE EXISTS ( + SELECT TRUE + FROM event e2 + JOIN l_event_place lep2 ON e2.id = lep2.entity0 + JOIN place p2 ON p2.id = lep2.entity1 + WHERE e2.begin_date_year = event.begin_date_year + AND e2.begin_date_month = event.begin_date_month + AND e2.begin_date_day = event.begin_date_day + AND lep2.entity1 = duplicates.entity1 + AND e2.comment = '' + ) +"; } __PACKAGE__->meta->make_immutable; diff --git a/lib/MusicBrainz/Server/Test.pm b/lib/MusicBrainz/Server/Test.pm index 5e006b80648..d841108944a 100644 --- a/lib/MusicBrainz/Server/Test.pm +++ b/lib/MusicBrainz/Server/Test.pm @@ -389,7 +389,7 @@ sub _build_ws_test_json { $mech->clear_credentials; } - $Test->plan(tests => 2); + $Test->plan(tests => 2 + ($opts->{extra_plan} // 0)); $mech->get($end_point . $url, 'fetching'); if ($opts->{response_code}) { @@ -399,6 +399,9 @@ sub _build_ws_test_json { } cmp_deeply(decode_json($mech->content), $expected); + + my $cb = $opts->{content_cb}; + $cb->($mech->content) if $cb; }); }; } diff --git a/lib/MusicBrainz/Server/Validation.pm b/lib/MusicBrainz/Server/Validation.pm index 33ad84f2a89..18547c8364e 100644 --- a/lib/MusicBrainz/Server/Validation.pm +++ b/lib/MusicBrainz/Server/Validation.pm @@ -56,6 +56,7 @@ require Exporter; is_valid_iso_3166_2 is_valid_iso_3166_3 is_valid_partial_date + is_valid_edit_note encode_entities normalise_strings is_nat @@ -310,6 +311,20 @@ sub is_valid_partial_date return 1; } +sub is_valid_edit_note +{ + my $edit_note = shift; + + # An edit note with only spaces and / or punctuation is useless + return 0 if $edit_note =~ /^[[:space:][:punct:]]+$/; + + # An edit note with just one ASCII character is useless + # A one-character Japanese note (for example) might be useful, so limited to ASCII + return 0 if $edit_note =~ /^[[:ascii:]]$/; + + return 1; +} + ################################################################################ # Our own Mason "escape" handler ################################################################################ diff --git a/lib/MusicBrainz/Server/View/Base.pm b/lib/MusicBrainz/Server/View/Base.pm index a4311781b35..6696969dfe1 100644 --- a/lib/MusicBrainz/Server/View/Base.pm +++ b/lib/MusicBrainz/Server/View/Base.pm @@ -4,10 +4,6 @@ use strict; use warnings; use base 'Catalyst::View'; -use Date::Calc qw( Today_and_Now Add_Delta_DHMS Date_to_Time ); -use DBDefs; -use Digest::MD5 qw( md5_hex ); -use feature 'state'; use IO::Socket::UNIX; use MusicBrainz::Server::Data::Utils qw( boolean_to_json ); use MusicBrainz::Server::Renderer qw( send_to_renderer ); @@ -53,33 +49,9 @@ sub _post_process { $socket->shutdown(2); $socket->close; - return 1 unless DBDefs->USE_ETAGS; - - my $method = $c->request->method; - if ($method ne 'GET' and $method ne 'HEAD' or $c->stash->{nocache}) { - # disable caching explicitely - return 1; - } - - my $body = $c->response->body; - if ($body) { - utf8::encode($body) if utf8::is_utf8($body); - $c->response->headers->etag(md5_hex($body)); - - # MBS-7061: Prevent network providers/proxies from stripping HTML - # comments, which are used heavily by knockout.js. - $c->response->headers->push_header('Cache-Control' => 'no-transform'); - - if (DBDefs->REPLICATION_TYPE eq DBDefs->RT_SLAVE && !$c->res->headers->expires) { - my @today = Today_and_Now(1); - my $next_hour = Date_to_Time( - Add_Delta_DHMS($today[0], $today[1], $today[2], $today[3], 10, 0, 0, 1, 0, 0) - ); - my $this_hour = Date_to_Time($today[0], $today[1], $today[2], $today[3], 10, 0); - $c->res->headers->expires($next_hour); - $c->res->headers->last_modified($this_hour); - } - } + # MBS-7061: Prevent network providers/proxies from stripping HTML + # comments, which are used heavily by knockout.js. + $c->response->headers->push_header('Cache-Control' => 'no-transform'); return 1; } diff --git a/lib/MusicBrainz/Server/View/WS.pm b/lib/MusicBrainz/Server/View/WS.pm new file mode 100644 index 00000000000..f97826dfc67 --- /dev/null +++ b/lib/MusicBrainz/Server/View/WS.pm @@ -0,0 +1,47 @@ +package MusicBrainz::Server::View::WS; + +use strict; +use warnings; + +use base 'Catalyst::View'; +use Date::Calc qw( Today_and_Now Add_Delta_DHMS Date_to_Time ); +use DBDefs; +use Digest::MD5 qw( md5_hex ); + +sub process { + my ($self, $c) = @_; + + if (scalar @{ $c->stash->{errors} }) { + $c->res->content_type('text/plain; charset=utf-8'); + $c->res->body(join "\n", @{ $c->stash->{errors} }); + return 1; + } + + return 1 unless DBDefs->USE_ETAGS; + + my $method = $c->request->method; + if ($method ne 'GET' and $method ne 'HEAD' or $c->stash->{nocache}) { + # disable caching explicitely + return 1; + } + + my $body = $c->response->body; + if ($body) { + utf8::encode($body) if utf8::is_utf8($body); + $c->response->headers->etag(md5_hex($body)); + + if (DBDefs->REPLICATION_TYPE eq DBDefs->RT_SLAVE && !$c->res->headers->expires) { + my @today = Today_and_Now(1); + my $next_hour = Date_to_Time( + Add_Delta_DHMS($today[0], $today[1], $today[2], $today[3], 10, 0, 0, 1, 0, 0) + ); + my $this_hour = Date_to_Time($today[0], $today[1], $today[2], $today[3], 10, 0); + $c->res->headers->expires($next_hour); + $c->res->headers->last_modified($this_hour); + } + } + + return 1; +} + +1; diff --git a/lib/MusicBrainz/Server/WebService/JSONSerializer.pm b/lib/MusicBrainz/Server/WebService/JSONSerializer.pm index cabeaecd014..ca0c19bf3ee 100644 --- a/lib/MusicBrainz/Server/WebService/JSONSerializer.pm +++ b/lib/MusicBrainz/Server/WebService/JSONSerializer.pm @@ -30,10 +30,8 @@ sub serialize my $ret = { $plural => [ map { serialize_entity($_, $inc, $opts, 1) } sort_by { $_->gid } @{ $entity->{items} } ], }; - if (defined($entity->{offset}) || defined($entity->{total})) { - $ret->{$singular . '-offset'} = number($entity->{offset}); - $ret->{$singular . '-count' } = number($entity->{total}); - } + $ret->{$singular . '-offset'} = number($entity->{offset}) if defined($entity->{offset}); + $ret->{$singular . '-count' } = number($entity->{total}) if defined($entity->{total}); return encode_json($ret); } } diff --git a/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Place.pm b/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Place.pm index 79c6636aad7..c4253b478a3 100644 --- a/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Place.pm +++ b/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Place.pm @@ -15,8 +15,8 @@ sub serialize $body{area} = $entity->area ? serialize_entity($entity->area) : JSON::null; $body{coordinates} = $entity->coordinates ? { - latitude => $entity->coordinates->latitude, - longitude => $entity->coordinates->longitude, + latitude => $entity->coordinates->latitude + 0.0, + longitude => $entity->coordinates->longitude + 0.0, } : JSON::null; diff --git a/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Utils.pm b/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Utils.pm index 4d5fe75717d..071be41ca44 100644 --- a/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Utils.pm +++ b/lib/MusicBrainz/Server/WebService/Serializer/JSON/2/Utils.pm @@ -131,9 +131,7 @@ sub list_of my $list = $opts->{$type}; my $items = (ref $list eq 'HASH') ? $list->{items} : $list; - return [ - map { serialize_entity($_, $inc, $stash, $toplevel) } - sort_by { $_->gid } @$items ]; + return [map { serialize_entity($_, $inc, $stash, $toplevel) } @$items]; } sub count_of @@ -257,12 +255,7 @@ sub serialize_relationships { my @relationships = map { serialize_entity($_, $inc, $stash) } - sort_by { - join("\t", - $_->link->type->name, - (sprintf "%09d", $_->link_order // 0), - $_->target_key) - } $entity->all_relationships; + $entity->all_relationships; $into->{relations} = \@relationships; return; diff --git a/lib/MusicBrainz/Server/WebService/Serializer/JSON/LD/Place.pm b/lib/MusicBrainz/Server/WebService/Serializer/JSON/LD/Place.pm index 7a46094c808..9c4eae33666 100644 --- a/lib/MusicBrainz/Server/WebService/Serializer/JSON/LD/Place.pm +++ b/lib/MusicBrainz/Server/WebService/Serializer/JSON/LD/Place.pm @@ -16,8 +16,8 @@ around serialize => sub { $ret->{'@type'} = 'Place'; $ret->{geo} = { '@type' => 'GeoCoordinates', - latitude => $entity->coordinates->latitude, - longitude => $entity->coordinates->longitude + latitude => $entity->coordinates->latitude + 0.0, + longitude => $entity->coordinates->longitude + 0.0, } if $entity->coordinates; return $ret; diff --git a/lib/MusicBrainz/Server/WebService/WebServiceInc.pm b/lib/MusicBrainz/Server/WebService/WebServiceInc.pm index cdd6af88868..4e5baca0196 100644 --- a/lib/MusicBrainz/Server/WebService/WebServiceInc.pm +++ b/lib/MusicBrainz/Server/WebService/WebServiceInc.pm @@ -16,17 +16,11 @@ has $_ => ( recording_level_rels work_level_rels rels annotation release_events ), map { $_ . '_rels' } @RELATABLE_ENTITIES); -sub has_rels -{ - my ($self) = @_; - - for my $type (@RELATABLE_ENTITIES) { - my $meth = $type . '_rels'; - return 1 if $self->$meth; - } - - return 0; -} +has has_rels => ( + is => 'rw', + isa => 'Bool', + default => 0, +); sub get_rel_types { @@ -47,14 +41,16 @@ sub BUILD my $meta = $self->meta; my %methods = map { $_->name => $_ } $meta->get_all_attributes; + my @relations = @{$args->{relations} // []}; - if (exists $args->{relations} && $args->{relations}) + if (@relations) { - foreach my $rel (@{$args->{relations}}) + foreach my $rel (@relations) { $rel =~ tr/-/_/; $methods{$rel}->set_value($self, 1); } + $self->has_rels(1); } foreach my $arg (@{$args->{inc}}) diff --git a/lib/MusicBrainz/Server/WebService/WebServiceStash.pm b/lib/MusicBrainz/Server/WebService/WebServiceStash.pm index 841f6221d66..8ac43c70da7 100644 --- a/lib/MusicBrainz/Server/WebService/WebServiceStash.pm +++ b/lib/MusicBrainz/Server/WebService/WebServiceStash.pm @@ -13,12 +13,7 @@ sub store { my ($self, $entity) = @_; - if (! defined $self->_data->{$entity->meta->{'package'}}->{$entity->id}) - { - $self->_data->{$entity->meta->{'package'}}->{$entity->id} = {}; - } - - return $self->_data->{$entity->meta->{'package'}}->{$entity->id}; + return ($self->_data->{$entity->entity_type}{$entity->id} //= {}); } __PACKAGE__->meta->make_immutable; diff --git a/lib/MusicBrainz/Server/WebService/XMLSerializer.pm b/lib/MusicBrainz/Server/WebService/XMLSerializer.pm index 2c13713fe43..1b85602c825 100644 --- a/lib/MusicBrainz/Server/WebService/XMLSerializer.pm +++ b/lib/MusicBrainz/Server/WebService/XMLSerializer.pm @@ -1,5 +1,6 @@ package MusicBrainz::Server::WebService::XMLSerializer; +use IO::String; use Moose; use Scalar::Util 'reftype'; use Readonly; @@ -9,629 +10,654 @@ use MusicBrainz::Server::Data::Utils qw( non_empty ); use MusicBrainz::Server::WebService::Escape qw( xml_escape ); use MusicBrainz::Server::Entity::Relationship; use MusicBrainz::Server::Validation; -use MusicBrainz::XML; +use XML::LibXML; use aliased 'MusicBrainz::Server::WebService::WebServiceInc'; use aliased 'MusicBrainz::Server::WebService::WebServiceStash'; sub mime_type { 'application/xml' } sub fmt { 'xml' } -Readonly my $xml_decl_begin => ''; -Readonly my $xml_decl_end => ''; - # Dynamically scoped variables for determined what data is displayed. # Can change at runtime via 'local' shadowing our $in_relation_node = 0; our $show_aliases = 1; -sub _list_attributes -{ - my ($self, $list) = @_; +sub add_type_elem { + my ($parent_node, $name, $type) = @_; - my %attrs = ( count => $list->{total} ); + my $elem = $parent_node->addNewChild(undef, $name); + $elem->_setAttribute('id', $type->gid); + $elem->appendText($type->name); + return $elem; +} - $attrs{offset} = $list->{offset} if $list->{offset}; +sub set_list_attributes +{ + my ($list_node, $list) = @_; - return \%attrs; + $list_node->_setAttribute('count', $list->{total}); + $list_node->_setAttribute('offset', $list->{offset}) if $list->{offset}; } sub _serialize_annotation { - my ($self, $data, $gen, $entity, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $opts) = @_; if ($inc->annotation && defined $entity->latest_annotation && $entity->latest_annotation->text) { - push @$data, $gen->annotation($gen->text($entity->latest_annotation->text)); + my $annotation_node = $parent_node->addNewChild(undef, 'annotation'); + $annotation_node->appendTextChild('text', $entity->latest_annotation->text); } } sub _serialize_coordinates { - my ($self, $data, $gen, $entity, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $opts) = @_; - my @coordinates; - push @coordinates, $gen->latitude($entity->coordinates->latitude); - push @coordinates, $gen->longitude($entity->coordinates->longitude); - push @$data, $gen->coordinates(@coordinates); + my $elem = $parent_node->addNewChild(undef, 'coordinates'); + my $coordinates = $entity->coordinates; + $elem->appendTextChild('latitude', $coordinates->latitude); + $elem->appendTextChild('longitude', $coordinates->longitude); } sub _serialize_life_span { - my ($self, $data, $gen, $entity, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $opts) = @_; my $has_begin_date = !$entity->begin_date->is_empty; my $has_end_date = !$entity->end_date->is_empty; if ($has_begin_date || $has_end_date) { - my @span; - push @span, $gen->begin($entity->begin_date->format) if $has_begin_date; - push @span, $gen->end($entity->end_date->format) if $has_end_date; - push @span, $gen->ended('true') if ($entity->ended && !$entity->isa('MusicBrainz::Server::Entity::Event')); - push @$data, $gen->life_span(@span); + my $life_span_node = $parent_node->addNewChild(undef, 'life-span'); + $life_span_node->appendTextChild('begin', $entity->begin_date->format) + if $has_begin_date; + $life_span_node->appendTextChild('end', $entity->end_date->format) + if $has_end_date; + $life_span_node->appendTextChild('ended', 'true') + if ($entity->ended && !$entity->isa('MusicBrainz::Server::Entity::Event')); } } sub _serialize_text_representation { - my ($self, $data, $gen, $entity, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $opts) = @_; if ($entity->language || $entity->script) { - my @tr; - push @tr, $gen->language($entity->language->alpha_3_code) + my $tr_node = $parent_node->addNewChild(undef, 'text-representation'); + $tr_node->appendTextChild('language', $entity->language->alpha_3_code) if $entity->language; - push @tr, $gen->script($entity->script->iso_code) if $entity->script; - push @$data, $gen->text_representation(@tr); + $tr_node->appendTextChild('script', $entity->script->iso_code) + if $entity->script; } } -sub _serialize_alias +sub _serialize_alias_list { - my ($self, $data, $gen, $aliases, $inc, $opts) = @_; + my ($self, $parent_node, $aliases, $inc, $opts) = @_; - if ($show_aliases && @$aliases) - { - my %attr = ( count => scalar(@$aliases) ); - my @alias_list; - foreach my $al (sort_by { $_->name } @$aliases) - { - push @alias_list, $gen->alias({ - $al->locale ? ( locale => $al->locale ) : (), - 'sort-name' => $al->sort_name, - $al->type ? ( type => $al->type_name ) : (), - $al->type ? ( 'type-id' => $al->type->gid ) : (), - $al->primary_for_locale ? (primary => 'primary') : (), - !$al->begin_date->is_empty ? ( 'begin-date' => $al->begin_date->format ) : (), - !$al->end_date->is_empty ? ( 'end-date' => $al->end_date->format ) : () - }, $al->name); + if ($show_aliases && @$aliases) { + my $alias_list_node = $parent_node->addNewChild(undef, 'alias-list'); + $alias_list_node->_setAttribute('count', scalar(@$aliases)); + + foreach my $al (sort_by { $_->name } @$aliases) { + my $alias_node = $alias_list_node->addNewChild(undef, 'alias'); + $alias_node->_setAttribute('locale', $al->locale) if $al->locale; + $alias_node->_setAttribute('sort-name', $al->sort_name); + + if (my $type = $al->type) { + $alias_node->_setAttribute('type', $type->name); + $alias_node->_setAttribute('type-id', $type->gid); + } + + $alias_node->_setAttribute('primary', 'primary') if $al->primary_for_locale; + $alias_node->_setAttribute('begin-date', $al->begin_date->format) + unless $al->begin_date->is_empty; + $alias_node->_setAttribute('end-date', $al->end_date->format) + unless $al->end_date->is_empty; + + $alias_node->appendText($al->name); } - push @$data, $gen->alias_list(\%attr, @alias_list); } } sub _serialize_artist_list { - my ($self, $data, $gen, $list, $inc, $stash) = @_; + my ($self, $parent_node, $list, $inc, $stash) = @_; - if (@{ $list->{items} }) - { - my @list; - foreach my $artist (sort_by { $_->gid } @{ $list->{items} }) - { - $self->_serialize_artist(\@list, $gen, $artist, $inc, $stash, 1); + if (my @artists = @{ $list->{items} }) { + my $artist_list_node = $parent_node->addNewChild(undef, 'artist-list'); + foreach my $artist (@artists) { + $self->_serialize_artist($artist_list_node, $artist, $inc, $stash, 1); } - push @$data, $gen->artist_list($self->_list_attributes($list), @list); + set_list_attributes($artist_list_node, $list); } } sub _serialize_artist { - my ($self, $data, $gen, $artist, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $artist, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($artist); my $compact_display = $artist->id == $VARTIST_ID && !$toplevel; - my %attrs; - $attrs{id} = $artist->gid; - $attrs{type} = $artist->type->name if ($artist->type); - $attrs{"type-id"} = $artist->type->gid if ($artist->type); - - my @list; - push @list, $gen->name($artist->name); - push @list, $gen->sort_name($artist->sort_name) if ($artist->sort_name); - $self->_serialize_annotation(\@list, $gen, $artist, $inc, $opts) if $toplevel; - push @list, $gen->disambiguation($artist->comment) if ($artist->comment); - push @list, $gen->ipi($artist->ipi_codes->[0]->ipi) if ($artist->all_ipi_codes); - push @list, $gen->ipi_list( - map { $gen->ipi($_->ipi) } $artist->all_ipi_codes - ) if ($artist->all_ipi_codes); - - push @list, $gen->isni_list( - map { $gen->isni($_->isni) } $artist->all_isni_codes - ) if ($artist->all_isni_codes); + my $artist_node = $parent_node->addNewChild(undef, 'artist'); + $artist_node->_setAttribute('id', $artist->gid); + + my $type = $artist->type; + if ($type) { + $artist_node->_setAttribute('type', $type->name); + $artist_node->_setAttribute('type-id', $type->gid); + } + + $artist_node->appendTextChild('name', $artist->name); + $artist_node->appendTextChild('sort-name', $artist->sort_name) if $artist->sort_name; + + $self->_serialize_annotation($artist_node, $artist, $inc, $opts) if $toplevel; + + $artist_node->appendTextChild('disambiguation', $artist->comment) if $artist->comment; + $artist_node->appendTextChild('ipi', $artist->ipi_codes->[0]->ipi) if $artist->all_ipi_codes; + + if (my @ipi_codes = $artist->all_ipi_codes) { + my $ipi_list_node = $artist_node->addNewChild(undef, 'ipi-list'); + $ipi_list_node->appendTextChild('ipi', $_->ipi) for @ipi_codes; + } + + if (my @isni_codes = $artist->all_isni_codes) { + my $isni_list_node = $artist_node->addNewChild(undef, 'isni-list'); + $isni_list_node->appendTextChild('isni', $_->isni) for @isni_codes; + } if ($toplevel) { - push @list, $gen->gender({id => $artist->gender->gid}, $artist->gender->name) if ($artist->gender); - push @list, $gen->country($artist->area->country_code) if $artist->area && $artist->area->country_code; - $self->_serialize_area(\@list, $gen, $artist->area, $inc, $stash, $toplevel) if $artist->area; - $self->_serialize_begin_area(\@list, $gen, $artist->begin_area, $inc, $stash, $toplevel) if $artist->begin_area; - $self->_serialize_end_area(\@list, $gen, $artist->end_area, $inc, $stash, $toplevel) if $artist->end_area; + if (my $gender = $artist->gender) { + add_type_elem($artist_node, 'gender', $gender); + } - $self->_serialize_life_span(\@list, $gen, $artist, $inc, $opts); + if (my $area = $artist->area) { + $artist_node->appendTextChild('country', $area->country_code) if $area->country_code; + $self->_serialize_area($artist_node, $area, $inc, $stash, $toplevel); + } + + $self->_serialize_begin_area($artist_node, $artist->begin_area, $inc, $stash, $toplevel) if $artist->begin_area; + $self->_serialize_end_area($artist_node, $artist->end_area, $inc, $stash, $toplevel) if $artist->end_area; + $self->_serialize_life_span($artist_node, $artist, $inc, $opts); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($artist_node, $opts->{aliases}, $inc, $opts) if ($inc->aliases && $opts->{aliases} && !$compact_display); if ($toplevel) { - $self->_serialize_recording_list(\@list, $gen, $opts->{recordings}, $inc, $stash) + $self->_serialize_recording_list($artist_node, $opts->{recordings}, $inc, $stash) if $inc->recordings; - $self->_serialize_release_list(\@list, $gen, $opts->{releases}, $inc, $stash) + $self->_serialize_release_list($artist_node, $opts->{releases}, $inc, $stash) if $inc->releases; - $self->_serialize_release_group_list(\@list, $gen, $opts->{release_groups}, $inc, $stash) + $self->_serialize_release_group_list($artist_node, $opts->{release_groups}, $inc, $stash) if $inc->release_groups; - $self->_serialize_work_list(\@list, $gen, $opts->{works}, $inc, $stash) + $self->_serialize_work_list($artist_node, $opts->{works}, $inc, $stash) if $inc->works; } - $self->_serialize_relation_lists($artist, \@list, $gen, $artist->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts) + $self->_serialize_relation_lists($artist_node, $artist, $artist->relationships, $inc, $stash) + if $inc->has_rels; + $self->_serialize_tags_and_ratings($artist_node, $artist, $inc, $stash) if !$compact_display; - - push @$data, $gen->artist(\%attrs, @list); } sub _serialize_artist_credit { - my ($self, $data, $gen, $artist_credit, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $artist_credit, $inc, $stash, $toplevel) = @_; + + my $ac_node = $parent_node->addNewChild(undef, 'artist-credit'); - my @ac; foreach my $name (@{$artist_credit->names}) { - my %artist_attr = ( id => $name->artist->gid ); - - my %nc_attr; - $nc_attr{joinphrase} = $name->join_phrase if ($name->join_phrase); - - my @nc; - push @nc, $gen->name($name->name) if ($name->name ne $name->artist->name); - - $self->_serialize_artist(\@nc, $gen, $name->artist, $inc, $stash); - push @ac, $gen->name_credit(\%nc_attr, @nc); + my $acn_node = $ac_node->addNewChild(undef, 'name-credit'); + $acn_node->_setAttribute('joinphrase', $name->join_phrase) if $name->join_phrase; + $acn_node->appendTextChild('name', $name->name) if $name->name ne $name->artist->name; + $self->_serialize_artist($acn_node, $name->artist, $inc, $stash); } - - push @$data, $gen->artist_credit(@ac); } sub _serialize_collection { - my ($self, $data, $gen, $collection, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $collection, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($collection); + my $type = $collection->type; + my $entity_type = $collection->type->item_entity_type; - my %attrs; - $attrs{id} = $collection->gid; - $attrs{type} = $collection->type->name if ($collection->type); - $attrs{"entity-type"} = $collection->type->item_entity_type if ($collection->type); - - my @collection; - push @collection, $gen->name($collection->name); - push @collection, $gen->editor($collection->editor->name); + my $col_node = $parent_node->addNewChild(undef, 'collection'); + $col_node->_setAttribute('id', $collection->gid); + if ($type) { + $col_node->_setAttribute('type', $type->name); + $col_node->_setAttribute('entity-type', $entity_type); + } - my $entity_type = $collection->type->item_entity_type; - my $plural = $ENTITIES{$entity_type}{plural}; + $col_node->appendTextChild('name', $collection->name); + $col_node->appendTextChild('editor', $collection->editor->name); - my $ser = "_serialize_${entity_type}_list"; - my $gen_list = "${entity_type}_list"; - my $list = $opts->{$plural}; + my $props = $ENTITIES{$entity_type}; + my $list = $opts->{$props->{plural}}; if ($toplevel && defined($list->{items}) && @{ $list->{items} }) { - $self->$ser(\@collection, $gen, $list, $inc, $stash); + my $serialize = "_serialize_${entity_type}_list"; + $self->$serialize($col_node, $list, $inc, $stash); + } elsif ($collection->loaded_entity_count) { - push @collection, $gen->$gen_list({ count => $collection->entity_count }); + my $list_node = $col_node->addNewChild(undef, $props->{url} . '-list'); + $list_node->_setAttribute('count', $collection->entity_count); } - - push @$data, $gen->collection(\%attrs, @collection); } sub _serialize_collection_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'collection-list'); + set_list_attributes($list_node, $list); - my @list; foreach my $collection (@{ $list->{items} }) { - $self->_serialize_collection(\@list, $gen, $collection, $inc, $stash, $toplevel); + $self->_serialize_collection($list_node, $collection, $inc, $stash, $toplevel); } - push @$data, $gen->collection_list($self->_list_attributes($list), @list); } sub _serialize_release_group_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; - my @list; - foreach my $rg (sort_by { $_->gid } @{ $list->{items} }) + my $list_node = $parent_node->addNewChild(undef, 'release-group-list'); + set_list_attributes($list_node, $list); + + foreach my $rg (@{ $list->{items} }) { - $self->_serialize_release_group(\@list, $gen, $rg, $inc, $stash, $toplevel); + $self->_serialize_release_group($list_node, $rg, $inc, $stash, $toplevel); } - push @$data, $gen->release_group_list($self->_list_attributes($list), @list); } +my %rg_fallback_type_order = ( + Compilation => 0, + Remix => 1, + Soundtrack => 2, + Live => 3, + Spokenword => 4, + Interview => 5 +); + sub _serialize_release_group { - my ($self, $data, $gen, $release_group, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $release_group, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($release_group); + my $primary_type = $release_group->primary_type; + my @secondary_types = $release_group->all_secondary_types; - my %attr; - $attr{id} = $release_group->gid; - - if ($release_group->primary_type && $release_group->primary_type->name eq 'Album') { - my %fallback_type_order = ( - Compilation => 0, - Remix => 1, - Soundtrack => 2, - Live => 3, - Spokenword => 4, - Interview => 5 - ); + my $rg_node = $parent_node->addNewChild(undef, 'release-group'); + $rg_node->_setAttribute('id', $release_group->gid); + if ($primary_type && $primary_type->name eq 'Album') { my $fallback = - nsort_by { $fallback_type_order{$_->name} } - grep { exists $fallback_type_order{$_->name} } - $release_group->all_secondary_types; + nsort_by { $rg_fallback_type_order{$_->name} } + grep { exists $rg_fallback_type_order{$_->name} } + @secondary_types; if ($fallback) { - $attr{type} = $fallback->name; - $attr{"type-id"} = $fallback->gid; + $rg_node->_setAttribute('type', $fallback->name); + $rg_node->_setAttribute('type-id', $fallback->gid); } else { - $attr{type} = $release_group->primary_type->name; - $attr{"type-id"} = $release_group->primary_type->gid; + $rg_node->_setAttribute('type', $primary_type->name); + $rg_node->_setAttribute('type-id', $primary_type->gid); } } - elsif ($release_group->primary_type) { - $attr{type} = $release_group->primary_type->name; - $attr{"type-id"} = $release_group->primary_type->gid; + elsif ($primary_type) { + $rg_node->_setAttribute('type', $primary_type->name); + $rg_node->_setAttribute('type-id', $primary_type->gid); } - elsif ($release_group->all_secondary_types) { - $attr{type} = $release_group->secondary_types->[0]->name; - $attr{"type-id"} = $release_group->secondary_types->[0]->gid; + elsif (@secondary_types) { + $rg_node->_setAttribute('type', $secondary_types[0]->name); + $rg_node->_setAttribute('type-id', $secondary_types[0]->gid); } - my @list; - push @list, $gen->title($release_group->name); - push @list, $gen->disambiguation($release_group->comment) if $release_group->comment; - $self->_serialize_annotation(\@list, $gen, $release_group, $inc, $opts) if $toplevel; - push @list, $gen->first_release_date($release_group->first_release_date->format); + $rg_node->appendTextChild('title', $release_group->name); + $rg_node->appendTextChild('disambiguation', $release_group->comment) if $release_group->comment; + $self->_serialize_annotation($rg_node, $release_group, $inc, $opts) if $toplevel; + $rg_node->appendTextChild('first-release-date', $release_group->first_release_date->format); - push @list, $gen->primary_type({ id => $release_group->primary_type->gid }, $release_group->primary_type->name) + add_type_elem($rg_node, 'primary-type', $primary_type) if $release_group->primary_type; - push @list, $gen->secondary_type_list( - map { - $gen->secondary_type({ id => $_->gid }, $_->name) - } $release_group->all_secondary_types - ) if $release_group->all_secondary_types; + + if (@secondary_types) { + my $sec_type_list_node = $rg_node->addNewChild(undef, 'secondary-type-list'); + add_type_elem($sec_type_list_node, 'secondary-type', $_) for @secondary_types; + } if ($toplevel) { - $self->_serialize_artist_credit(\@list, $gen, $release_group->artist_credit, $inc, $stash, $inc->artists) + $self->_serialize_artist_credit($rg_node, $release_group->artist_credit, $inc, $stash, $inc->artists) if $inc->artists || $inc->artist_credits; - $self->_serialize_release_list(\@list, $gen, $opts->{releases}, $inc, $stash) + $self->_serialize_release_list($rg_node, $opts->{releases}, $inc, $stash) if $inc->releases; } else { - $self->_serialize_artist_credit(\@list, $gen, $release_group->artist_credit, $inc, $stash) + $self->_serialize_artist_credit($rg_node, $release_group->artist_credit, $inc, $stash) if $inc->artist_credits; } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($rg_node, $opts->{aliases}, $inc, $opts) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($release_group, \@list, $gen, $release_group->relationships, $inc, $stash) if $inc->has_rels; - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->release_group(\%attr, @list); + $self->_serialize_relation_lists($rg_node, $release_group, $release_group->relationships, $inc, $stash) if $inc->has_rels; + $self->_serialize_tags_and_ratings($rg_node, $release_group, $inc, $stash); } sub _serialize_release_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'release-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $release (sort_by { $_->gid } @{ $list->{items} }) + foreach my $release (@{ $list->{items} }) { - $self->_serialize_release(\@list, $gen, $release, $inc, $stash, $toplevel); + $self->_serialize_release($list_node, $release, $inc, $stash, $toplevel); } - push @$data, $gen->release_list($self->_list_attributes($list), @list); } -sub _serialize_quality -{ - my ($self, $data, $gen, $release, $inc) = @_; - my %quality_names = ( - $QUALITY_LOW => 'low', - $QUALITY_NORMAL => 'normal', - $QUALITY_HIGH => 'high' - ); +my %quality_names = ( + $QUALITY_LOW => 'low', + $QUALITY_NORMAL => 'normal', + $QUALITY_HIGH => 'high' +); - my $quality = - $release->quality == $QUALITY_UNKNOWN ? $QUALITY_UNKNOWN_MAPPED - : $release->quality; +sub _serialize_release_event { + my ($self, $parent_node, $release_event, $inc, $stash, $toplevel, $include_country) = @_; + + if (my $date = $release_event->date) { + $parent_node->appendTextChild('date', $date->format) unless $date->is_empty; + } - push @$data, $gen->quality( - $quality_names{$quality} - ); + if (my $country = $release_event->country) { + if ($include_country) { + my $country_code = $country->country_code; + $parent_node->appendTextChild('country', $country_code) if $country_code; + } else { + $self->_serialize_area($parent_node, $country, $inc, $stash, $toplevel); + } + } } sub _serialize_release { - my ($self, $data, $gen, $release, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $release, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($release); $inc = $inc->clone( releases => 0 ); - my @list; + my $release_node = $parent_node->addNewChild(undef, 'release'); + $release_node->_setAttribute('id', $release->gid); + + $release_node->appendTextChild('title', $release->name); + + if (my $status = $release->status) { + add_type_elem($release_node, 'status', $status); + } + + my $quality = ($release->quality == $QUALITY_UNKNOWN + ? $QUALITY_UNKNOWN_MAPPED + : $release->quality); - push @list, $gen->title($release->name); - push @list, $gen->status({ id => $release->status->gid }, $release->status->name) if $release->status; - $self->_serialize_quality(\@list, $gen, $release, $inc, $opts); - push @list, $gen->disambiguation($release->comment) if $release->comment; - $self->_serialize_annotation(\@list, $gen, $release, $inc, $opts) if $toplevel; - push @list, $gen->packaging({ id => $release->packaging->gid }, $release->packaging->name) if $release->packaging; + $release_node->appendTextChild('quality', $quality_names{$quality}); + $release_node->appendTextChild('disambiguation', $release->comment) if $release->comment; + $self->_serialize_annotation($release_node, $release, $inc, $stash) if $toplevel; - $self->_serialize_text_representation(\@list, $gen, $release, $inc, $opts); + if (my $packaging = $release->packaging) { + add_type_elem($release_node, 'packaging', $packaging); + } + + $self->_serialize_text_representation($release_node, $release, $inc, $stash); if ($toplevel) { - $self->_serialize_artist_credit(\@list, $gen, $release->artist_credit, $inc, $stash, $inc->artists) + $self->_serialize_artist_credit($release_node, $release->artist_credit, $inc, $stash, $inc->artists) if $inc->artist_credits || $inc->artists; } else { - $self->_serialize_artist_credit(\@list, $gen, $release->artist_credit, $inc, $stash) + $self->_serialize_artist_credit($release_node, $release->artist_credit, $inc, $stash) if $inc->artist_credits; } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($release_node, $opts->{aliases}, $inc, $opts) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_release_group(\@list, $gen, $release->release_group, $inc, $stash) + $self->_serialize_release_group($release_node, $release->release_group, $inc, $stash) if ($release->release_group && $inc->release_groups); - if (my ($earliest_release_event) = $release->all_events) { - my $serialize_release_event = sub { - my ($event, $include_country) = @_; - my @r = (); - - push @r, $gen->date($event->date->format) - if $event->date && !$event->date->is_empty; + if (my @release_events = $release->all_events) { + my ($earliest_release_event) = @release_events; + $self->_serialize_release_event($release_node, $earliest_release_event, $inc, $stash, $toplevel, 1); - if ($include_country) { - push @r, $gen->country($event->country->country_code) if $event->country && $event->country->country_code; - } else { - $self->_serialize_area(\@r, $gen, $event->country, $inc, $stash, $toplevel) if $event->country; - } - - return @r; - }; + my $re_list_node = $release_node->addNewChild(undef, 'release-event-list'); + $re_list_node->_setAttribute('count', $release->event_count); - push @list, $serialize_release_event->($earliest_release_event, 1); - push @list, $gen->release_event_list( - $self->_list_attributes({ total => $release->event_count }), - map { $gen->release_event($serialize_release_event->($_)) } - $release->all_events - ) + for my $release_event (@release_events) { + $self->_serialize_release_event( + $re_list_node->addNewChild(undef, 'release-event'), + $release_event, $inc, $stash, $toplevel, 0); + } } - push @list, $gen->barcode($release->barcode->code) if defined $release->barcode->code; - push @list, $gen->asin($release->amazon_asin) if $release->amazon_asin; - $self->_serialize_cover_art_archive(\@list, $gen, $release, $inc, $stash) if $release->cover_art_presence; + $release_node->appendTextChild('barcode', $release->barcode->code) if defined $release->barcode->code; + $release_node->appendTextChild('asin', $release->amazon_asin) if $release->amazon_asin; + $self->_serialize_cover_art_archive($release_node, $release, $inc, $stash) if $release->cover_art_presence; if ($toplevel) { - $self->_serialize_label_info_list(\@list, $gen, $release->labels, $inc, $stash) + $self->_serialize_label_info_list($release_node, $release->labels, $inc, $stash) if ($release->labels && $inc->labels); } - $self->_serialize_medium_list(\@list, $gen, $release->mediums, $inc, $stash) + $self->_serialize_medium_list($release_node, $release->mediums, $inc, $stash) if ($release->mediums && ($inc->media || $inc->discids || $inc->recordings)); - $self->_serialize_relation_lists($release, \@list, $gen, $release->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - $self->_serialize_collection_list(\@list, $gen, $opts->{collections}, $inc, $stash, 0) + $self->_serialize_relation_lists($release_node, $release, $release->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($release_node, $release, $inc, $stash); + $self->_serialize_collection_list($release_node, $opts->{collections}, $inc, $stash, 0) if $opts->{collections} && @{ $opts->{collections}{items} }; # MBS-8845: Don't output , since at breaks (at least) Picard. - - push @$data, $gen->release({ id => $release->gid }, @list); } sub _serialize_cover_art_archive { - my ($self, $data, $gen, $release, $inc, $stash) = @_; + my ($self, $parent_node, $release, $inc, $stash) = @_; + my $coverart = $stash->store($release)->{'cover-art-archive'}; - my @list; - push @list, $gen->artwork($release->cover_art_presence eq 'present' ? 'true' : 'false'); - push @list, $gen->count($coverart->{total}); - push @list, $gen->front($coverart->{front} ? 'true' : 'false'); - push @list, $gen->back($coverart->{back} ? 'true' : 'false'); - push @list, $gen->darkened('true') if $release->cover_art_presence eq 'darkened'; + my $caa_node = $parent_node->addNewChild(undef, 'cover-art-archive'); - push @$data, $gen->cover_art_archive(@list); + $caa_node->appendTextChild('artwork', $release->cover_art_presence eq 'present' ? 'true' : 'false'); + $caa_node->appendTextChild('count', $coverart->{total} // 0); + $caa_node->appendTextChild('front', $coverart->{front} ? 'true' : 'false'); + $caa_node->appendTextChild('back', $coverart->{back} ? 'true' : 'false'); + $caa_node->appendTextChild('darkened', 'true') if $release->cover_art_presence eq 'darkened'; } sub _serialize_work_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'work-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $work (sort_by { $_->gid } @{ $list->{items} }) + foreach my $work (@{ $list->{items} }) { - $self->_serialize_work(\@list, $gen, $work, $inc, $stash, $toplevel); + $self->_serialize_work($list_node, $work, $inc, $stash, $toplevel); } - push @$data, $gen->work_list($self->_list_attributes($list), @list); } sub _serialize_work { - my ($self, $data, $gen, $work, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $work, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($work); - my %attrs; - $attrs{id} = $work->gid; - $attrs{type} = $work->type->name if ($work->type); - $attrs{"type-id"} = $work->type->gid if ($work->type); + my $work_node = $parent_node->addNewChild(undef, 'work'); + $work_node->_setAttribute('id', $work->gid); - my @list; - push @list, $gen->title($work->name); + if (my $type = $work->type) { + $work_node->_setAttribute('type', $type->name); + $work_node->_setAttribute('type-id', $type->gid); + } + + $work_node->appendTextChild('title', $work->name); if ($work->all_languages) { my @languages = map { $_->language->alpha_3_code } $work->all_languages; + # Pre-MBS-5452 element. - push @list, $gen->language(@languages > 1 ? 'mul' : $languages[0]); - push @list, $gen->language_list(map { $gen->language($_) } @languages); + $work_node->appendTextChild('language', @languages > 1 ? 'mul' : $languages[0]); + + my $lang_list_node = $work_node->addNewChild(undef, 'language-list'); + $lang_list_node->appendTextChild('language', $_) for @languages; } - if ($work->all_iswcs) { - push @list, $gen->iswc($work->iswcs->[0]->iswc); - push @list, $gen->iswc_list(map { - $gen->iswc($_->iswc); - } $work->all_iswcs); + if (my @iswcs = $work->all_iswcs) { + $work_node->appendTextChild('iswc', $work->iswcs->[0]->iswc); + my $iswc_list_node = $work_node->addNewChild(undef, 'iswc-list'); + $iswc_list_node->appendTextChild('iswc', $_->iswc) for @iswcs; } - if ($work->all_attributes) { - push @list, $gen->attribute_list(map { - $gen->attribute({ - "value-id" => $_->value_gid, - type => $_->type->name, - "type-id" => $_->type->gid, - }, $_->value); - } $work->all_attributes); + if (my @attributes = $work->all_attributes) { + my $attr_list_node = $work_node->addNewChild(undef, 'attribute-list'); + for my $attr (@attributes) { + my $attr_node = $attr_list_node->addNewChild(undef, 'attribute'); + $attr_node->appendText($attr->value); + $attr_node->_setAttribute('value-id', $attr->value_gid); + $attr_node->_setAttribute('type', $attr->type->name); + $attr_node->_setAttribute('type-id', $attr->type->gid); + } } - push @list, $gen->disambiguation($work->comment) if ($work->comment); - $self->_serialize_annotation(\@list, $gen, $work, $inc, $opts) if $toplevel; + $work_node->appendTextChild('disambiguation', $work->comment) if $work->comment; + $self->_serialize_annotation($work_node, $work, $inc, $stash) if $toplevel; - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($work_node, $opts->{aliases}, $inc, $stash) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($work, \@list, $gen, $work->relationships, $inc, $stash) if $inc->has_rels; - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->work(\%attrs, @list); + $self->_serialize_relation_lists($work_node, $work, $work->relationships, $inc, $stash) if $inc->has_rels; + $self->_serialize_tags_and_ratings($work_node, $work, $inc, $stash); } sub _serialize_url { - my ($self, $data, $gen, $url, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $url, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($url); - my %attrs; - $attrs{id} = $url->gid; + my $url_node = $parent_node->addNewChild(undef, 'url'); + $url_node->_setAttribute('id', $url->gid); - my @list; - push @list, $gen->resource($url->url); - $self->_serialize_relation_lists($url, \@list, $gen, $url->relationships, $inc, $stash) if ($inc->has_rels); - - push @$data, $gen->url(\%attrs, @list); + $url_node->appendTextChild('resource', $url->url); + $self->_serialize_relation_lists($url_node, $url, $url->relationships, $inc, $stash) if $inc->has_rels; } sub _serialize_recording_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'recording-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $recording (sort_by { $_->gid } @{ $list->{items} }) + foreach my $recording (@{ $list->{items} }) { - $self->_serialize_recording(\@list, $gen, $recording, $inc, $stash, $toplevel); + $self->_serialize_recording($list_node, $recording, $inc, $stash, $toplevel); } - - push @$data, $gen->recording_list($self->_list_attributes($list), @list); } sub _serialize_recording { - my ($self, $data, $gen, $recording, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $recording, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($recording); - my @list; - push @list, $gen->title($recording->name); - push @list, $gen->length($recording->length) if $recording->length; - push @list, $gen->disambiguation($recording->comment) if ($recording->comment); - push @list, $gen->video('true') if $recording->video; + my $rec_node = $parent_node->addNewChild(undef, 'recording'); + $rec_node->_setAttribute('id', $recording->gid); + + $rec_node->appendTextChild('title', $recording->name); + $rec_node->appendTextChild('length', $recording->length) if $recording->length; + $rec_node->appendTextChild('disambiguation', $recording->comment) if ($recording->comment); + $rec_node->appendTextChild('video', 'true') if $recording->video; + if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $recording, $inc, $opts); + $self->_serialize_annotation($rec_node, $recording, $inc, $stash); - $self->_serialize_artist_credit(\@list, $gen, $recording->artist_credit, $inc, $stash, $inc->artists) + $self->_serialize_artist_credit($rec_node, $recording->artist_credit, $inc, $stash, $inc->artists) if $inc->artists || $inc->artist_credits; - $self->_serialize_release_list(\@list, $gen, $opts->{releases}, $inc, $stash) + $self->_serialize_release_list($rec_node, $opts->{releases}, $inc, $stash) if $inc->releases; } else { - $self->_serialize_artist_credit(\@list, $gen, $recording->artist_credit, $inc, $stash) + $self->_serialize_artist_credit($rec_node, $recording->artist_credit, $inc, $stash) if $inc->artist_credits; } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($rec_node, $opts->{aliases}, $inc, $opts) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_isrc_list(\@list, $gen, $opts->{isrcs}, $inc, $stash) + $self->_serialize_isrc_list($rec_node, $opts->{isrcs}, $inc, $stash) if ($opts->{isrcs} && $inc->isrcs); - $self->_serialize_relation_lists($recording, \@list, $gen, $recording->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->recording({ id => $recording->gid }, @list); + $self->_serialize_relation_lists($rec_node, $recording, $recording->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($rec_node, $recording, $inc, $stash); } sub _serialize_medium_list { - my ($self, $data, $gen, $mediums, $inc, $stash) = @_; + my ($self, $parent_node, $mediums, $inc, $stash) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'medium-list'); + $list_node->_setAttribute('count', scalar @$mediums); - my @list; foreach my $medium (nsort_by { $_->position } @$mediums) { - $self->_serialize_medium(\@list, $gen, $medium, $inc, $stash); + $self->_serialize_medium($list_node, $medium, $inc, $stash); } - push @$data, $gen->medium_list({ count => scalar(@$mediums) }, @list); } sub _serialize_medium { - my ($self, $data, $gen, $medium, $inc, $stash) = @_; + my ($self, $parent_node, $medium, $inc, $stash) = @_; - my @med; - push @med, $gen->title($medium->name) if $medium->name; - push @med, $gen->position($medium->position); - push @med, $gen->format({ id => $medium->format->gid }, $medium->format->name) if ($medium->format); - $self->_serialize_disc_list(\@med, $gen, $medium->cdtocs, $inc, $stash) if ($inc->discids); + my $med_node = $parent_node->addNewChild(undef, 'medium'); - $self->_serialize_tracks(\@med, $gen, $medium, $inc, $stash); + $med_node->appendTextChild('title', $medium->name) if $medium->name; + $med_node->appendTextChild('position', $medium->position); - push @$data, $gen->medium(@med); + if (my $format = $medium->format) { + add_type_elem($med_node, 'format', $format); + } + + $self->_serialize_disc_list($med_node, $medium->cdtocs, $inc, $stash) if $inc->discids; + $self->_serialize_tracks($med_node, $medium, $inc, $stash); } sub _serialize_tracks { - my ($self, $data, $gen, $medium, $inc, $stash) = @_; + my ($self, $parent_node, $medium, $inc, $stash) = @_; # Not all tracks in the tracklists may have been loaded. If not all # tracks have been loaded, only one them will have been loaded which @@ -642,45 +668,44 @@ sub _serialize_tracks my $min = @tracks ? $tracks[0]->position : 0; if (@tracks && $medium->has_pregap) { - $self->_serialize_track($data, $gen, $tracks[0], $inc, $stash, 1); + $self->_serialize_track($parent_node, $tracks[0], $inc, $stash, 1); } - my @list; + my $track_list_node = $parent_node->addNewChild(undef, 'track-list'); + $track_list_node->_setAttribute('count', $medium->cdtoc_track_count); + $track_list_node->_setAttribute('offset', $medium->has_pregap ? 0 : $min - 1) if @tracks; + foreach my $track (@{ $medium->cdtoc_tracks }) { $min = $track->position if $track->position < $min; - $self->_serialize_track(\@list, $gen, $track, $inc, $stash); + $self->_serialize_track($track_list_node, $track, $inc, $stash); } - my %attr = ( count => $medium->cdtoc_track_count ); - $attr{offset} = ($medium->has_pregap ? 0 : $min - 1) if @tracks; - - push @$data, $gen->track_list(\%attr, @list); - if (my @data_tracks = grep { $_->position > 0 && $_->is_data_track } @tracks) { - @list = (); - $self->_serialize_track(\@list, $gen, $_, $inc, $stash) for @data_tracks; - push @$data, $gen->data_track_list({ count => scalar(@list) }, @list); + my $data_track_list_node = $parent_node->addNewChild(undef, 'data-track-list'); + $data_track_list_node->_setAttribute('count', scalar @data_tracks); + $self->_serialize_track($data_track_list_node, $_, $inc, $stash) for @data_tracks; } } sub _serialize_track { - my ($self, $data, $gen, $track, $inc, $stash, $pregap) = @_; + my ($self, $parent_node, $track, $inc, $stash, $pregap) = @_; - my @track; - push @track, $gen->position($track->position); - push @track, $gen->number($track->number); + my $track_node = $parent_node->addNewChild(undef, $pregap ? 'pregap' : 'track'); + $track_node->_setAttribute('id', $track->gid); - push @track, $gen->title($track->name) + $track_node->appendTextChild('position', $track->position); + $track_node->appendTextChild('number', $track->number); + + $track_node->appendTextChild('title', $track->name) if ($track->recording && $track->name ne $track->recording->name) || (!$track->recording); - push @track, $gen->length($track->length) - if $track->length; + $track_node->appendTextChild('length', $track->length) if $track->length; do { local $show_aliases = 1; - $self->_serialize_artist_credit(\@track, $gen, $track->artist_credit, $inc, $stash) + $self->_serialize_artist_credit($track_node, $track->artist_credit, $inc, $stash) if $inc->artist_credits && ( ($track->recording && @@ -689,966 +714,748 @@ sub _serialize_track ); }; - $self->_serialize_recording(\@track, $gen, $track->recording, $inc, $stash) + $self->_serialize_recording($track_node, $track->recording, $inc, $stash) if ($track->recording); - - my $node_name = $pregap ? 'pregap' : 'track'; - push @$data, $gen->$node_name({ id => $track->gid }, @track); } sub _serialize_disc_list { - my ($self, $data, $gen, $cdtoclist, $inc, $stash) = @_; + my ($self, $parent_node, $cdtoclist, $inc, $stash) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'disc-list'); + $list_node->_setAttribute('count', scalar @$cdtoclist); - my @list; foreach my $cdtoc (sort_by { $_->cdtoc->discid } @$cdtoclist) { - $self->_serialize_disc(\@list, $gen, $cdtoc->cdtoc, $inc, $stash); + $self->_serialize_disc($list_node, $cdtoc->cdtoc, $inc, $stash); } - push @$data, $gen->disc_list({ count => scalar(@$cdtoclist) }, @list); } sub _serialize_disc { - my ($self, $data, $gen, $cdtoc, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $cdtoc, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($cdtoc); - my @list; - push @list, $gen->sectors($cdtoc->leadout_offset); + my $disc_node = $parent_node->addNewChild(undef, 'disc'); + $disc_node->_setAttribute('id', $cdtoc->discid); - $self->_serialize_disc_offsets(\@list, $gen, $cdtoc, $inc, $stash); + $disc_node->appendTextChild('sectors', $cdtoc->leadout_offset); + + $self->_serialize_disc_offsets($disc_node, $cdtoc, $inc, $stash); if ($toplevel) { - $self->_serialize_release_list(\@list, $gen, $opts->{releases}, $inc, $stash, $toplevel); + $self->_serialize_release_list($disc_node, $opts->{releases}, $inc, $stash, $toplevel); } - - push @$data, $gen->disc({ id => $cdtoc->discid }, @list); } sub _serialize_disc_offsets { - my ($self, $data, $gen, $cdtoc, $inc, $stash) = @_; + my ($self, $parent_node, $cdtoc, $inc, $stash) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'offset-list'); + $list_node->_setAttribute('count', $cdtoc->track_count); - my @list; foreach my $track (0 .. ($cdtoc->track_count - 1)) { - push @list, $gen->offset({ position => $track + 1 }, $cdtoc->track_offset->[$track]); + my $offset_node = $list_node->addNewChild(undef, 'offset'); + $offset_node->appendText($cdtoc->track_offset->[$track]); + $offset_node->_setAttribute('position', $track + 1); } - - push @$data, $gen->offset_list({ count => $cdtoc->track_count }, @list); } sub _serialize_cdstub { - my ($self, $data, $gen, $cdstub, $inc, $stash, $toplevel) = @_; - - my @contents = ( - $gen->title($cdstub->title), - $gen->artist($cdstub->artist), - ); - push @contents, $gen->barcode($cdstub->barcode) - if $cdstub->barcode; - push @contents, $gen->disambiguation($cdstub->comment) - if $cdstub->comment; + my ($self, $parent_node, $cdstub, $inc, $stash, $toplevel) = @_; - my @tracks = map { - my @track = ( $gen->title($_->title) ); - push @track, $gen->artist($_->artist) - if $_->artist; - push @track, $gen->length($_->length); + my $cdstub_node = $parent_node->addNewChild(undef, 'cdstub'); + $cdstub_node->_setAttribute('id', $cdstub->discid); - $gen->track(@track); - } $cdstub->all_tracks; + $cdstub_node->appendTextChild('title', $cdstub->title); + $cdstub_node->appendTextChild('artist', $cdstub->artist); + $cdstub_node->appendTextChild('barcode', $cdstub->barcode) if $cdstub->barcode; + $cdstub_node->appendTextChild('disambiguation', $cdstub->comment) if $cdstub->comment; - push @contents, $gen->track_list({ count => $cdstub->track_count }, @tracks); + my $track_list_node = $cdstub_node->addNewChild(undef, 'track-list'); + $track_list_node->_setAttribute('count', $cdstub->track_count); - push @$data, $gen->cdstub({ id => $cdstub->discid }, @contents); + for my $track ($cdstub->all_tracks) { + my $track_node = $track_list_node->addNewChild(undef, 'track'); + $track_node->appendTextChild('title', $track->title); + $track_node->appendTextChild('artist', $track->artist) if $track->artist; + $track_node->appendTextChild('length', $track->length); + } } sub _serialize_label_info_list { - my ($self, $data, $gen, $rel_labels, $inc, $stash) = @_; + my ($self, $parent_node, $rel_labels, $inc, $stash) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'label-info-list'); + $list_node->_setAttribute('count', scalar @$rel_labels); - my @list; foreach my $rel_label (@$rel_labels) { - $self->_serialize_label_info(\@list, $gen, $rel_label, $inc, $stash); + $self->_serialize_label_info($list_node, $rel_label, $inc, $stash); } - push @$data, $gen->label_info_list({ count => scalar(@$rel_labels) }, @list); } sub _serialize_label_info { - my ($self, $data, $gen, $rel_label, $inc, $stash) = @_; + my ($self, $parent_node, $rel_label, $inc, $stash) = @_; + + my $label_info_node = $parent_node->addNewChild(undef, 'label-info'); - my @list; - push @list, $gen->catalog_number($rel_label->catalog_number) + $label_info_node->appendTextChild('catalog-number', $rel_label->catalog_number) if $rel_label->catalog_number; - $self->_serialize_label(\@list, $gen, $rel_label->label, $inc, $stash) + + $self->_serialize_label($label_info_node, $rel_label->label, $inc, $stash) if $rel_label->label; - push @$data, $gen->label_info(@list); } sub _serialize_label_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'label-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $label (sort_by { $_->gid } @{ $list->{items} }) + foreach my $label (@{ $list->{items} }) { - $self->_serialize_label(\@list, $gen, $label, $inc, $stash, $toplevel); + $self->_serialize_label($list_node, $label, $inc, $stash, $toplevel); } - push @$data, $gen->label_list($self->_list_attributes($list), @list); } sub _serialize_label { - my ($self, $data, $gen, $label, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $label, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($label); - my %attrs; - $attrs{id} = $label->gid; - $attrs{type} = $label->type->name if $label->type; - $attrs{"type-id"} = $label->type->gid if $label->type; - - my @list; - push @list, $gen->name($label->name); - push @list, $gen->sort_name($label->name); - push @list, $gen->disambiguation($label->comment) if $label->comment; - push @list, $gen->label_code($label->label_code) if $label->label_code; - push @list, $gen->ipi($label->ipi_codes->[0]->ipi) if ($label->all_ipi_codes); - push @list, $gen->ipi_list( - map { $gen->ipi($_->ipi) } $label->all_ipi_codes - ) if ($label->all_ipi_codes); - - push @list, $gen->isni_list( - map { $gen->isni($_->isni) } $label->all_isni_codes - ) if ($label->all_isni_codes); + my $label_node = $parent_node->addNewChild(undef, 'label'); + $label_node->_setAttribute('id', $label->gid); + + if (my $type = $label->type) { + $label_node->_setAttribute('type', $type->name); + $label_node->_setAttribute('type-id', $type->gid); + } + + $label_node->appendTextChild('name', $label->name); + $label_node->appendTextChild('sort-name', $label->name); + $label_node->appendTextChild('disambiguation', $label->comment) if $label->comment; + $label_node->appendTextChild('label-code', $label->label_code) if $label->label_code; + + if (my @ipi_codes = $label->all_ipi_codes) { + $label_node->appendTextChild('ipi', $ipi_codes[0]->ipi); + my $ipi_list_node = $label_node->addNewChild(undef, 'ipi-list'); + $ipi_list_node->appendTextChild('ipi', $_->ipi) for @ipi_codes; + } + + if (my @isni_codes = $label->all_isni_codes) { + my $isni_list_node = $label_node->addNewChild(undef, 'isni-list'); + $isni_list_node->appendTextChild('isni', $_->isni) for @isni_codes; + } if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $label, $inc, $opts); - push @list, $gen->country($label->area->country_code) if $label->area && $label->area->country_code; - $self->_serialize_area(\@list, $gen, $label->area, $inc, $stash, $toplevel) if $label->area; - $self->_serialize_life_span(\@list, $gen, $label, $inc, $opts); + $self->_serialize_annotation($label_node, $label, $inc, $stash); + + if (my $area = $label->area) { + my $country_code = $area->country_code; + $label_node->appendTextChild('country', $country_code) if $country_code; + $self->_serialize_area($label_node, $area, $inc, $stash, $toplevel); + } + + $self->_serialize_life_span($label_node, $label, $inc, $stash); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($label_node, $opts->{aliases}, $inc, $stash) if ($inc->aliases && $opts->{aliases}); if ($toplevel) { - $self->_serialize_release_list(\@list, $gen, $opts->{releases}, $inc, $stash) + $self->_serialize_release_list($label_node, $opts->{releases}, $inc, $stash) if $inc->releases; } - $self->_serialize_relation_lists($label, \@list, $gen, $label->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->label(\%attrs, @list); + $self->_serialize_relation_lists($label_node, $label, $label->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($label_node, $label, $inc, $stash); } sub _serialize_area_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; - my @list; - foreach my $area (sort_by { $_->gid } @{ $list->{items} }) + my $list_node = $parent_node->addNewChild(undef, 'area-list'); + set_list_attributes($list_node, $list); + + foreach my $area (@{ $list->{items} }) { - $self->_serialize_area(\@list, $gen, $area, $inc, $stash, $toplevel); + $self->_serialize_area($list_node, $area, $inc, $stash, $toplevel); } - push @$data, $gen->area_list($self->_list_attributes($list), @list); } sub _serialize_area_inner { - my ($self, $data, $gen, $area, $inc, $stash, $toplevel) = @_; + my ($self, $area_node, $area, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($area); - my %attrs; - $attrs{id} = $area->gid; - $attrs{type} = $area->type->name if $area->type; - $attrs{"type-id"} = $area->type->gid if $area->type; - - my @list; - push @list, $gen->name($area->name); - push @list, $gen->sort_name($area->name); - push @list, $gen->disambiguation($area->comment) if ($area->comment); - if ($area->iso_3166_1_codes) { - push @list, $gen->iso_3166_1_code_list(map { - $gen->iso_3166_1_code($_); - } $area->iso_3166_1_codes); - } - if ($area->iso_3166_2_codes) { - push @list, $gen->iso_3166_2_code_list(map { - $gen->iso_3166_2_code($_); - } $area->iso_3166_2_codes); - } - if ($area->iso_3166_3_codes) { - push @list, $gen->iso_3166_3_code_list(map { - $gen->iso_3166_3_code($_); - } $area->iso_3166_3_codes); + $area_node->_setAttribute('id', $area->gid); + + if (my $type = $area->type) { + $area_node->_setAttribute('type', $type->name); + $area_node->_setAttribute('type-id', $type->gid); + } + + $area_node->appendTextChild('name', $area->name); + $area_node->appendTextChild('sort-name', $area->name); + $area_node->appendTextChild('disambiguation', $area->comment) if $area->comment; + + if (my @codes = $area->iso_3166_1_codes) { + my $list_node = $area_node->addNewChild(undef ,'iso-3166-1-code-list'); + $list_node->appendTextChild('iso-3166-1-code', $_) for @codes; + } + + if (my @codes = $area->iso_3166_2_codes) { + my $list_node = $area_node->addNewChild(undef ,'iso-3166-2-code-list'); + $list_node->appendTextChild('iso-3166-2-code', $_) for @codes; } + + if (my @codes = $area->iso_3166_3_codes) { + my $list_node = $area_node->addNewChild(undef ,'iso-3166-3-code-list'); + $list_node->appendTextChild('iso-3166-3-code', $_) for @codes; + } + if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $area, $inc, $opts); - $self->_serialize_life_span(\@list, $gen, $area, $inc, $opts); + $self->_serialize_annotation($area_node, $area, $inc, $opts); + $self->_serialize_life_span($area_node, $area, $inc, $opts); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($area_node, $opts->{aliases}, $inc, $opts) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($area, \@list, $gen, $area->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - return (\%attrs, @list); + $self->_serialize_relation_lists($area_node, $area, $area->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($area_node, $area, $inc, $stash); } sub _serialize_area { - my ($self, $data, $gen, $area, $inc, $stash, $toplevel) = @_; - - my ($attrs, @list) = $self->_serialize_area_inner($data, $gen, $area, $inc, $stash, $toplevel); + my ($self, $parent_node, $area, $inc, $stash, $toplevel) = @_; - push @$data, $gen->area($attrs, @list); + my $area_node = $parent_node->addNewChild(undef, 'area'); + $self->_serialize_area_inner($area_node, $area, $inc, $stash, $toplevel); } sub _serialize_begin_area { - my ($self, $data, $gen, $area, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $area, $inc, $stash, $toplevel) = @_; - my ($attrs, @list) = $self->_serialize_area_inner($data, $gen, $area, $inc, $stash, $toplevel); - - push @$data, $gen->begin_area($attrs, @list); + my $area_node = $parent_node->addNewChild(undef, 'begin-area'); + $self->_serialize_area_inner($area_node, $area, $inc, $stash, $toplevel); } sub _serialize_end_area { - my ($self, $data, $gen, $area, $inc, $stash, $toplevel) = @_; - - my ($attrs, @list) = $self->_serialize_area_inner($data, $gen, $area, $inc, $stash, $toplevel); + my ($self, $parent_node, $area, $inc, $stash, $toplevel) = @_; - push @$data, $gen->end_area($attrs, @list); + my $area_node = $parent_node->addNewChild(undef, 'end-area'); + $self->_serialize_area_inner($area_node, $area, $inc, $stash, $toplevel); } sub _serialize_place_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'place-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $place (sort_by { $_->gid } @{ $list->{items} }) + foreach my $place (@{ $list->{items} }) { - $self->_serialize_place(\@list, $gen, $place, $inc, $stash, $toplevel); + $self->_serialize_place($list_node, $place, $inc, $stash, $toplevel); } - push @$data, $gen->place_list($self->_list_attributes($list), @list); } sub _serialize_place { - my ($self, $data, $gen, $place, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $place, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($place); - my %attrs; - $attrs{id} = $place->gid; - $attrs{type} = $place->type->name if $place->type; - $attrs{"type-id"} = $place->type->gid if $place->type; + my $place_node = $parent_node->addNewChild(undef, 'place'); - my @list; - push @list, $gen->name($place->name); - push @list, $gen->disambiguation($place->comment) if $place->comment; - push @list, $gen->address($place->address) if $place->address; - $self->_serialize_coordinates(\@list, $gen, $place, $inc, $stash, $toplevel) if $place->coordinates; + $place_node->_setAttribute('id', $place->gid); + + if (my $type = $place->type) { + $place_node->_setAttribute('type', $type->name); + $place_node->_setAttribute('type-id', $type->gid); + } + + $place_node->appendTextChild('name', $place->name); + $place_node->appendTextChild('disambiguation', $place->comment) if $place->comment; + $place_node->appendTextChild('address', $place->address) if $place->address; + $self->_serialize_coordinates($place_node, $place, $inc, $stash, $toplevel) if $place->coordinates; if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $place, $inc, $opts); - $self->_serialize_area(\@list, $gen, $place->area, $inc, $stash, $toplevel) if $place->area; - $self->_serialize_life_span(\@list, $gen, $place, $inc, $opts); + $self->_serialize_annotation($place_node, $place, $inc, $stash); + $self->_serialize_area($place_node, $place->area, $inc, $stash, $toplevel) if $place->area; + $self->_serialize_life_span($place_node, $place, $inc, $stash); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($place_node, $opts->{aliases}, $inc, $stash) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($place, \@list, $gen, $place->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->place(\%attrs, @list); + $self->_serialize_relation_lists($place_node, $place, $place->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($place_node, $place, $inc, $stash); } sub _serialize_instrument_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'instrument-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $instrument (sort_by { $_->gid } @{ $list->{items} }) { - $self->_serialize_instrument(\@list, $gen, $instrument, $inc, $stash, $toplevel); + foreach my $instrument (@{ $list->{items} }) { + $self->_serialize_instrument($list_node, $instrument, $inc, $stash, $toplevel); } - push @$data, $gen->instrument_list($self->_list_attributes($list), @list); } sub _serialize_instrument { - my ($self, $data, $gen, $instrument, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $instrument, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($instrument); - my %attrs; - $attrs{id} = $instrument->gid; - $attrs{type} = $instrument->type->name if $instrument->type; - $attrs{"type-id"} = $instrument->type->gid if $instrument->type; + my $inst_node = $parent_node->addNewChild(undef, 'instrument'); + + $inst_node->_setAttribute('id', $instrument->gid); + + if (my $type = $instrument->type) { + $inst_node->_setAttribute('type', $type->name); + $inst_node->_setAttribute('type-id', $type->gid); + } - my @list; - push @list, $gen->name($instrument->name); - push @list, $gen->disambiguation($instrument->comment) if $instrument->comment; - push @list, $gen->description($instrument->description) if $instrument->description; + $inst_node->appendTextChild('name', $instrument->name); + $inst_node->appendTextChild('disambiguation', $instrument->comment) if $instrument->comment; + $inst_node->appendTextChild('description', $instrument->description) if $instrument->description; if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $instrument, $inc, $opts); + $self->_serialize_annotation($inst_node, $instrument, $inc, $stash); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($inst_node, $opts->{aliases}, $inc, $stash) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($instrument, \@list, $gen, $instrument->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->instrument(\%attrs, @list); + $self->_serialize_relation_lists($inst_node, $instrument, $instrument->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($inst_node, $instrument, $inc, $stash); } sub _serialize_relation_lists { - my ($self, $src_entity, $data, $gen, $rels, $inc, $stash) = @_; + my ($self, $parent_node, $src_entity, $rels, $inc, $stash) = @_; my %types = (); - foreach my $rel (sort { $a <=> $b } @$rels) + foreach my $rel (@$rels) { $types{$rel->target_type} = [] if !exists $types{$rel->target_type}; push @{$types{$rel->target_type}}, $rel; } foreach my $type (sort keys %types) { - my @list; - foreach my $rel (sort_by { $_->target_key . $_->link->type->name } @{$types{$type}}) - { - $self->_serialize_relation($src_entity, \@list, $gen, $rel, $inc, $stash); + my $rel_list_node = $parent_node->addNewChild(undef, 'relation-list'); + $rel_list_node->_setAttribute('target-type', $type); + + foreach my $rel (@{$types{$type}}) { + $self->_serialize_relation($rel_list_node, $src_entity, $rel, $inc, $stash); } - push @$data, $gen->relation_list({ 'target-type' => $type }, @list); } } sub _serialize_relation { - my ($self, $src_entity, $data, $gen, $rel, $inc, $stash) = @_; + my ($self, $parent, $src_entity, $rel, $inc, $stash) = @_; + + my $target = $rel->target; + my $target_type = $rel->target_type; + my $link = $rel->link; + my $link_type = $link->type; + + my $rel_node = $parent->addNewChild(undef, 'relation'); + $rel_node->_setAttribute('type', $link_type->name); + $rel_node->_setAttribute('type-id', $link_type->gid); - my @list; - my $type = $rel->link->type->name; - my $type_id = $rel->link->type->gid; + if ($target_type eq 'url') { + my $target_node = $rel_node->addNewChild(undef, 'target'); + $target_node->_setAttribute('id', $target->gid); + $target_node->appendText($target->url); + } else { + $rel_node->appendTextChild('target', $target->gid); + } - if ($rel->target_type eq 'url') { - push @list, $gen->target({ 'id' => $rel->target->gid }, $rel->target_key); + $rel_node->appendTextChild('ordering-key', $rel->link_order) if $rel->link_order; + if ($rel->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD) { + $rel_node->appendTextChild('direction', 'backward'); } else { - push @list, $gen->target($rel->target_key); - } - - push @list, $gen->ordering_key($rel->link_order) if $rel->link_order; - push @list, $gen->direction('backward') if ($rel->direction == $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD); - push @list, $gen->begin($rel->link->begin_date->format) unless $rel->link->begin_date->is_empty; - push @list, $gen->end($rel->link->end_date->format) unless $rel->link->end_date->is_empty; - push @list, $gen->ended('true') if $rel->link->ended; - - push @list, $gen->attribute_list( - map { - if (non_empty($_->text_value)) { - $gen->attribute({ value => $_->text_value, 'type-id' => $_->type->gid }, $_->type->name); - } elsif (non_empty($_->credited_as)) { - $gen->attribute({ 'credited-as' => $_->credited_as, 'type-id' => $_->type->gid }, $_->type->name); - } else { - $gen->attribute({ 'type-id' => $_->type->gid }, $_->type->name); + $rel_node->appendTextChild('direction', 'forward'); + } + + my $begin_date = $link->begin_date; + my $end_date = $link->end_date; + + $rel_node->appendTextChild('begin', $begin_date->format) unless $begin_date->is_empty; + $rel_node->appendTextChild('end', $end_date->format) unless $end_date->is_empty; + $rel_node->appendTextChild('ended', 'true') if $link->ended; + + if (my @attributes = $link->all_attributes) { + my $attr_list_node = $rel_node->addNewChild(undef, 'attribute-list'); + + for my $attr (@attributes) { + my $attr_type = $attr->type; + my $attr_elem = $attr_list_node->addNewChild(undef, 'attribute'); + $attr_elem->appendText($attr_type->name); + $attr_elem->_setAttribute('type-id', $attr_type->gid); + + if (non_empty($attr->text_value)) { + $attr_elem->_setAttribute('value', $attr->text_value); + } elsif (non_empty($attr->credited_as)) { + $attr_elem->_setAttribute('credited-as', $attr->credited_as); } - } $rel->link->all_attributes - ) if ($rel->link->all_attributes); + } + } - unless ($rel->target_type eq 'url') + unless ($target_type eq 'url') { - my $method = "_serialize_" . $rel->target_type; + my $method = "_serialize_" . $target_type; local $in_relation_node = 1; local $show_aliases = 0; - $self->$method(\@list, $gen, $rel->target, $inc, $stash); + $self->$method($rel_node, $rel->target, $inc, $stash); } - push @list, $gen->source_credit($rel->source_credit) if $rel->source_credit; - push @list, $gen->target_credit($rel->target_credit) if $rel->target_credit; + if (my $source_credit = $rel->source_credit) { + $rel_node->appendTextChild('source-credit', $source_credit); + } - push @$data, $gen->relation({ type => $type, "type-id" => $type_id }, @list); + if (my $target_credit = $rel->target_credit) { + $rel_node->appendTextChild('target-credit', $target_credit); + } } sub _serialize_series_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'series-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $series (sort_by { $_->gid } @{ $list->{items} }) + foreach my $series (@{ $list->{items} }) { - $self->_serialize_series(\@list, $gen, $series, $inc, $stash, $toplevel); + $self->_serialize_series($list_node, $series, $inc, $stash, $toplevel); } - push @$data, $gen->series_list($self->_list_attributes($list), @list); } sub _serialize_series { - my ($self, $data, $gen, $series, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $series, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($series); - my %attrs; - $attrs{id} = $series->gid; - $attrs{type} = $series->type->name if $series->type; - $attrs{"type-id"} = $series->type->gid if $series->type; + my $series_node = $parent_node->addNewChild(undef, 'series'); + + $series_node->_setAttribute('id', $series->gid); + + if (my $type = $series->type) { + $series_node->_setAttribute('type', $type->name); + $series_node->_setAttribute('type-id', $type->gid); + } - my @list; - push @list, $gen->name($series->name); - push @list, $gen->disambiguation($series->comment) if $series->comment; + $series_node->appendTextChild('name', $series->name); + $series_node->appendTextChild('disambiguation', $series->comment) if $series->comment; if ($toplevel) { - $self->_serialize_annotation(\@list, $gen, $series, $inc, $opts); + $self->_serialize_annotation($series_node, $series, $inc, $stash); } - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) + $self->_serialize_alias_list($series_node, $opts->{aliases}, $inc, $stash) if ($inc->aliases && $opts->{aliases}); - $self->_serialize_relation_lists($series, \@list, $gen, $series->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); - - push @$data, $gen->series(\%attrs, @list); + $self->_serialize_relation_lists($series_node, $series, $series->relationships, $inc, $stash) if ($inc->has_rels); + $self->_serialize_tags_and_ratings($series_node, $series, $inc, $stash); } sub _serialize_event_list { - my ($self, $data, $gen, $list, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $list, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'event-list'); + set_list_attributes($list_node, $list); - my @list; - foreach my $event (sort_by { $_->gid } @{ $list->{items} }) + foreach my $event (@{ $list->{items} }) { - $self->_serialize_event(\@list, $gen, $event, $inc, $stash, $toplevel); + $self->_serialize_event($list_node, $event, $inc, $stash, $toplevel); } - push @$data, $gen->event_list($self->_list_attributes ($list), @list); } sub _serialize_event { - my ($self, $data, $gen, $event, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $event, $inc, $stash, $toplevel) = @_; - my $opts = $stash->store ($event); + my $opts = $stash->store($event); - my %attrs; - $attrs{id} = $event->gid; - $attrs{type} = $event->type->name if $event->type; - $attrs{"type-id"} = $event->type->gid if $event->type; + my $event_node = $parent_node->addNewChild(undef, 'event'); + $event_node->_setAttribute('id', $event->gid); - my @list; - push @list, $gen->name($event->name); - push @list, $gen->disambiguation($event->comment) if $event->comment; + if (my $type = $event->type) { + $event_node->_setAttribute('type', $type->name); + $event_node->_setAttribute('type-id', $type->gid); + } - push @list, $gen->cancelled('true') if $event->cancelled; + $event_node->appendTextChild('name', $event->name); + $event_node->appendTextChild('disambiguation', $event->comment) if $event->comment; - $self->_serialize_life_span(\@list, $gen, $event, $inc, $opts); - push @list, $gen->time($event->formatted_time) if $event->formatted_time; - push @list, $gen->setlist($event->setlist) if $event->setlist; + $event_node->appendTextChild('cancelled', 'true') if $event->cancelled; - $self->_serialize_annotation(\@list, $gen, $event, $inc, $opts) if $toplevel; + $self->_serialize_life_span($event_node, $event, $inc, $stash); - $self->_serialize_alias(\@list, $gen, $opts->{aliases}, $inc, $opts) - if ($inc->aliases && $opts->{aliases}); + if (my $time = $event->formatted_time) { + $event_node->appendTextChild('time', $time); + } + + $event_node->appendTextChild('setlist', $event->setlist) if $event->setlist; - $self->_serialize_relation_lists($event, \@list, $gen, $event->relationships, $inc, $stash) if ($inc->has_rels); - $self->_serialize_tags_and_ratings(\@list, $gen, $inc, $opts); + $self->_serialize_annotation($event_node, $event, $inc, $stash) if $toplevel; - push @$data, $gen->event(\%attrs, @list); + $self->_serialize_alias_list($event_node, $opts->{aliases}, $inc, $stash) + if ($inc->aliases && $opts->{aliases}); + + $self->_serialize_relation_lists($event_node, $event, $event->relationships, $inc, $stash) + if $inc->has_rels; + $self->_serialize_tags_and_ratings($event_node, $event, $inc, $stash); } sub _serialize_isrc_list { - my ($self, $data, $gen, $isrcs, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $isrcs, $inc, $stash, $toplevel) = @_; + + my $list_node = $parent_node->addNewChild(undef, 'isrc-list'); + $list_node->_setAttribute('count', scalar @$isrcs); - my @list; foreach my $isrc (sort_by { $_->isrc } @{$isrcs}) { - $self->_serialize_isrc(\@list, $gen, $isrc, $inc, $stash, $toplevel); + $self->_serialize_isrc($list_node, $isrc, $inc, $stash, $toplevel); } - push @$data, $gen->isrc_list({ count => scalar(@{$isrcs}) }, @list); } sub _serialize_isrc { - my ($self, $data, $gen, $isrc, $inc, $stash, $toplevel) = @_; + my ($self, $parent_node, $isrc, $inc, $stash, $toplevel) = @_; my $opts = $stash->store($isrc); my @recordings = @{ $opts->{recordings}{items} // [] }; - my @list; + + my $isrc_node = $parent_node->addNewChild(undef, 'isrc'); + $isrc_node->_setAttribute('id', $isrc->isrc); if (@recordings) { my $recordings = { items => \@recordings, total => scalar @recordings, }; - $self->_serialize_recording_list(\@list, $gen, $recordings, $inc, $stash, $toplevel) + $self->_serialize_recording_list($isrc_node, $recordings, $inc, $stash, $toplevel) } - - push @$data, $gen->isrc({ id => $isrc->isrc }, @list); } sub _serialize_tags_and_ratings { - my ($self, $data, $gen, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; - $self->_serialize_tag_list($data, $gen, $inc, $opts) + my $opts = $stash->store($entity); + + $self->_serialize_tag_list($parent_node, $entity, $inc, $stash) if $opts->{tags} && $inc->{tags}; - $self->_serialize_user_tag_list($data, $gen, $inc, $opts) + $self->_serialize_user_tag_list($parent_node, $entity, $inc, $stash) if $opts->{user_tags} && $inc->{user_tags}; - $self->_serialize_genre_list($data, $gen, $inc, $opts) + $self->_serialize_genre_list($parent_node, $entity, $inc, $stash) if $opts->{genres} && $inc->{genres}; - $self->_serialize_user_genre_list($data, $gen, $inc, $opts) + $self->_serialize_user_genre_list($parent_node, $entity, $inc, $stash) if $opts->{user_genres} && $inc->{user_genres}; - $self->_serialize_rating($data, $gen, $inc, $opts) + $self->_serialize_rating($parent_node, $entity, $inc, $stash) if $opts->{ratings} && $inc->{ratings}; - $self->_serialize_user_rating($data, $gen, $inc, $opts) + $self->_serialize_user_rating($parent_node, $entity, $inc, $stash) if $opts->{user_ratings} && $inc->{user_ratings}; } sub _serialize_tag_list { - my ($self, $data, $gen, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; return if $in_relation_node; - my @list; + my $opts = $stash->store($entity); + my $list_node = $parent_node->addNewChild(undef, 'tag-list'); + foreach my $tag (sort_by { $_->tag->name } @{$opts->{tags}}) { - $self->_serialize_tag(\@list, $gen, $tag, $inc, $opts); + $self->_serialize_tag($list_node, $tag); } - push @$data, $gen->tag_list(@list); } sub _serialize_tag { - my ($self, $data, $gen, $tag, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $tag) = @_; - push @$data, $gen->tag({ count => $tag->count }, $gen->name($tag->tag->name)); + my $genre_node = $parent_node->addNewChild(undef, 'tag'); + $genre_node->_setAttribute('count', $tag->count); + $genre_node->appendTextChild('name', $tag->tag->name); } sub _serialize_genre_list { - my ($self, $data, $gen, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; return if $in_relation_node; - my @list; + my $opts = $stash->store($entity); + my $list_node = $parent_node->addNewChild(undef, 'genre-list'); + foreach my $tag (sort_by { $_->tag->name } @{$opts->{genres}}) { - $self->_serialize_genre(\@list, $gen, $tag, $inc, $opts); + $self->_serialize_genre($list_node, $tag); } - push @$data, $gen->genre_list(@list); } sub _serialize_genre { - my ($self, $data, $gen, $tag, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $tag) = @_; - push @$data, $gen->genre({ count => $tag->count }, $gen->name($tag->tag->name)); + my $genre_node = $parent_node->addNewChild(undef, 'genre'); + $genre_node->_setAttribute('count', $tag->count); + $genre_node->appendTextChild('name', $tag->tag->name); } sub _serialize_user_tag_list { - my ($self, $data, $gen, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; + + my $opts = $stash->store($entity); + my $list_node = $parent_node->addNewChild(undef, 'user-tag-list'); - my @list; foreach my $tag (sort_by { $_->tag->name } @{$opts->{user_tags}}) { - $self->_serialize_user_tag(\@list, $gen, $tag, $inc, $opts, $modelname, $entity); + $self->_serialize_user_tag($list_node, $tag); } - push @$data, $gen->user_tag_list(@list); } sub _serialize_user_tag { - my ($self, $data, $gen, $tag, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $tag) = @_; if ($tag->is_upvote) { - push @$data, $gen->user_tag($gen->name($tag->tag->name)); + $parent_node->addNewChild(undef, 'user-tag')->appendTextChild('name', $tag->tag->name); } } sub _serialize_user_genre_list { - my ($self, $data, $gen, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; + + my $opts = $stash->store($entity); + my $list_node = $parent_node->addNewChild(undef, 'user-genre-list'); - my @list; foreach my $tag (sort_by { $_->tag->name } @{$opts->{user_genres}}) { - $self->_serialize_user_genre(\@list, $gen, $tag, $inc, $opts, $modelname, $entity); + $self->_serialize_user_genre($list_node, $tag); } - push @$data, $gen->user_genre_list(@list); } sub _serialize_user_genre { - my ($self, $data, $gen, $tag, $inc, $opts, $modelname, $entity) = @_; + my ($self, $parent_node, $tag) = @_; if ($tag->is_upvote) { - push @$data, $gen->user_genre($gen->name($tag->tag->name)); + $parent_node->addNewChild(undef, 'user-genre')->appendTextChild('name', $tag->tag->name); } } sub _serialize_rating { - my ($self, $data, $gen, $inc, $opts) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; - my $count = $opts->{ratings}->{count}; - my $rating = $opts->{ratings}->{rating}; + my $opts = $stash->store($entity); + my $count = $opts->{ratings}{count}; + my $rating = $opts->{ratings}{rating}; - push @$data, $gen->rating({ 'votes-count' => $count }, $rating); + my $rating_node = $parent_node->addNewChild(undef, 'rating'); + $rating_node->appendText($rating); + $rating_node->_setAttribute('votes-count', $count); } sub _serialize_user_rating { - my ($self, $data, $gen, $inc, $opts) = @_; - - push @$data, $gen->user_rating($opts->{user_ratings}); -} - -sub output_error -{ - my ($self, $err) = @_; - - my $gen = MusicBrainz::XML->new; - - return '' . - $gen->error($gen->text($err), $gen->text( - "For usage, please see: https://musicbrainz.org/development/mmd")); -} - -sub output_success -{ - my ($self, $msg) = @_; - - my $gen = MusicBrainz::XML->new(); - - $msg ||= 'OK'; - - my $xml = $xml_decl_begin; - $xml .= $gen->message($gen->text($msg)); - $xml .= $xml_decl_end; - return $xml; -} - -sub serialize -{ - my ($self, $type, $entity, $inc, $stash) = @_; - $inc ||= 0; - - my $gen = MusicBrainz::XML->new(); - - my $method = ($type =~ tr/-/_/r) . "_resource"; - my $xml = $xml_decl_begin; - $xml .= $self->$method($gen, $entity, $inc, $stash); - $xml .= $xml_decl_end; - return $xml; -} - -sub artist_resource -{ - my ($self, $gen, $artist, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_artist($data, $gen, $artist, $inc, $stash, 1); - - return $data->[0]; -} - -sub collection_resource -{ - my ($self, $gen, $collection, $inc, $stash) = @_; + my ($self, $parent_node, $entity, $inc, $stash) = @_; - my $data = []; - $self->_serialize_collection($data, $gen, $collection, $inc, $stash, 1); - - return $data->[0]; -} - -sub collection_list_resource -{ - my ($self, $gen, $collections, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_collection_list($data, $gen, $collections, $inc, $stash, 1); - - return $data->[0]; -} - -sub label_resource -{ - my ($self, $gen, $label, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_label($data, $gen, $label, $inc, $stash, 1); - return $data->[0]; -} - -sub release_group_resource -{ - my ($self, $gen, $release_group, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_release_group($data, $gen, $release_group, $inc, $stash, 1); - return $data->[0]; -} - -sub release_resource -{ - my ($self, $gen, $release, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_release($data, $gen, $release, $inc, $stash, 1); - return $data->[0]; -} - -sub recording_resource -{ - my ($self, $gen, $recording, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_recording($data, $gen, $recording, $inc, $stash, 1); - - return $data->[0]; -} - -sub work_resource -{ - my ($self, $gen, $work, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_work($data, $gen, $work, $inc, $stash, 1); - return $data->[0]; -} - -sub area_resource -{ - my ($self, $gen, $area, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_area($data, $gen, $area, $inc, $stash, 1); - return $data->[0]; -} - -sub place_resource -{ - my ($self, $gen, $place, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_place($data, $gen, $place, $inc, $stash, 1); - return $data->[0]; -} - -sub instrument_resource { - my ($self, $gen, $instrument, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_instrument($data, $gen, $instrument, $inc, $stash, 1); - - return $data->[0]; -} - -sub series_resource -{ - my ($self, $gen, $series, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_series($data, $gen, $series, $inc, $stash, 1); - - return $data->[0]; -} - -sub event_resource -{ - my ($self, $gen, $event, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_event($data, $gen, $event, $inc, $stash, 1); - return $data->[0]; -} - -sub url_resource -{ - my ($self, $gen, $url, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_url($data, $gen, $url, $inc, $stash, 1); - - return $data->[0]; -} - -sub isrc_resource -{ - my ($self, $gen, $isrc, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_isrc($data, $gen, $isrc, $inc, $stash, 1); - return $data->[0]; -} - -sub discid_resource -{ - my ($self, $gen, $cdtoc, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_disc($data, $gen, $cdtoc, $inc, $stash, 1); - return $data->[0]; -} - -sub cdstub_resource -{ - my ($self, $gen, $cdtoc, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_cdstub($data, $gen, $cdtoc, $inc, $stash, 1); - return $data->[0]; -} - -sub artist_list_resource -{ - my ($self, $gen, $artists, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_artist_list($data, $gen, $artists, $inc, $stash, 1); - - return $data->[0]; -} - -sub label_list_resource -{ - my ($self, $gen, $labels, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_label_list($data, $gen, $labels, $inc, $stash, 1); - - return $data->[0]; -} - -sub recording_list_resource -{ - my ($self, $gen, $recordings, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_recording_list($data, $gen, $recordings, $inc, $stash, 1); - - return $data->[0]; -} - -sub release_list_resource -{ - my ($self, $gen, $releases, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_release_list($data, $gen, $releases, $inc, $stash, 1); - - return $data->[0]; -} - -sub release_group_list_resource -{ - my ($self, $gen, $rgs, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_release_group_list($data, $gen, $rgs, $inc, $stash, 1); - - return $data->[0]; -} - -sub work_list_resource -{ - my ($self, $gen, $works, $inc, $stash) = @_; + my $opts = $stash->store($entity); - my $data = []; - $self->_serialize_work_list($data, $gen, $works, $inc, $stash, 1); + return '' unless $opts->{user_ratings}; - return $data->[0]; + $parent_node->appendTextChild('user-rating', $opts->{user_ratings}); } -sub area_list_resource -{ - my ($self, $gen, $areas, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_area_list($data, $gen, $areas, $inc, $stash, 1); +sub _create_metadata_node { + my ($dom) = @_; - return $data->[0]; + my $metadata = $dom->createElement('metadata'); + $metadata->setAttribute('xmlns', 'http://musicbrainz.org/ns/mmd-2.0#'); + $dom->setDocumentElement($metadata); + return $metadata; } -sub place_list_resource +sub output_error { - my ($self, $gen, $places, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_place_list($data, $gen, $places, $inc, $stash, 1); - - return $data->[0]; -} - -sub instrument_list_resource { - my ($self, $gen, $instruments, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_instrument_list($data, $gen, $instruments, $inc, $stash, 1); - - return $data->[0]; -} + my ($self, $err) = @_; -sub event_list_resource -{ - my ($self, $gen, $events, $inc, $stash) = @_; + my $dom = XML::LibXML::Document->createDocument('1.0', 'UTF-8'); + my $error_node = $dom->createElement('error'); + $dom->setDocumentElement($error_node); - my $data = []; - $self->_serialize_event_list($data, $gen, $events, $inc, $stash, 1); + $error_node->appendTextChild('text', $err); + $error_node->appendTextChild('text', 'For usage, please see: https://musicbrainz.org/development/mmd'); - return $data->[0]; + return IO::String->new($dom->toString()); } -sub rating_resource +sub output_success { - my ($self, $gen, $entity, $inc, $stash) = @_; + my ($self, $msg) = @_; - my $opts = $stash->store($entity); + $msg ||= 'OK'; - return '' unless $opts->{user_ratings}; + my $dom = XML::LibXML::Document->createDocument('1.0', 'UTF-8'); + my $metadata = _create_metadata_node($dom); - my $data = []; - $self->_serialize_user_rating($data, $gen, $inc, $opts); + my $msg_node = $metadata->addNewChild(undef, 'message'); + $msg_node->appendTextChild('text', $msg); - return $data->[0]; + return IO::String->new($dom->toString()); } -sub series_list_resource +sub serialize { - my ($self, $gen, $series, $inc, $stash) = @_; - - my $data = []; - $self->_serialize_series_list($data, $gen, $series, $inc, $stash, 1); - - return $data->[0]; -} + my ($self, $type, $entity, $inc, $stash) = @_; + $inc ||= 0; -sub tag_list_resource -{ - my ($self, $gen, $entity, $inc, $stash) = @_; + my $dom = XML::LibXML::Document->createDocument('1.0', 'UTF-8'); - my $opts = $stash->store($entity); + my $metadata = $dom->createElement('metadata'); + $metadata->setAttribute('xmlns', 'http://musicbrainz.org/ns/mmd-2.0#'); + $dom->setDocumentElement($metadata); - my $data = []; - $self->_serialize_user_tag_list($data, $gen, $inc, $opts); + my $method = '_serialize_' . ($type =~ tr/-/_/r); + $self->$method($metadata, $entity, $inc, $stash, 1); - return $data->[0]; + # $dom->toString() produces an encoded byte string. Wrapping it in an + # IO::String prevents Catalyst from double-encoding the request body. + return IO::String->new($dom->toString()); } __PACKAGE__->meta->make_immutable; diff --git a/lib/MusicBrainz/XML.pm b/lib/MusicBrainz/XML.pm deleted file mode 100644 index f4f4185cb2e..00000000000 --- a/lib/MusicBrainz/XML.pm +++ /dev/null @@ -1,69 +0,0 @@ -package MusicBrainz::XML; -use Moose; - -sub AUTOLOAD { - my $self = shift; - - my ($tag) = our $AUTOLOAD =~ /.*::(.*)/; - $tag =~ tr/_/-/; - - my %attrs = ref($_[0]) eq 'HASH' ? %{ shift() } : (); - - return bless { - tag => $tag, - attrs => \%attrs, - body => [ map { - ref($_) eq 'MusicBrainz::XML::Element' - ? $_ - : bless(\"$_", 'MusicBrainz::XML::Text') - } @_ ] - }, 'MusicBrainz::XML::Element'; -} - -sub BUILDARGS { return () } - -package MusicBrainz::XML::Element; -use overload '""' => \&as_string; - -sub as_string { - my $element = shift; - my $tag = $element->{tag}; - my %attrs = %{ $element->{attrs} }; - my $body = join('', map { ref($_) eq 'MusicBrainz::XML::Text' - ? _escape($$_) : $_ } - @{ $element->{body} }); - my @attributes = - map { "$_=" . q{"} . _escape($attrs{$_}) . q{"} } - grep { defined $attrs{$_} } keys %attrs; - - if (defined($body) && $body ne '') { - return - q{<} . join(' ', $tag, @attributes) . q{>} . - $body . - ""; - } - else { - return - q{<} . join(' ', $tag, @attributes) . q{ />}; - } -} - -sub _escape -{ - my $t = $_[0]; - - return '' unless defined($t); - - # Remove control characters as they cause XML to not be parsed - $t =~ s/[\x00-\x08\x0A-\x0C\x0E-\x1A]//g; - - $t =~ s/\xFFFD//g; # remove invalid characters - $t =~ s/&/&/g; # remove XML entities - $t =~ s//>/g; - $t =~ s/"/"/g; - - return $t; -} - -1; diff --git a/package.json b/package.json index d821df5edd8..f144e4be418 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "detect-node": "2.0.3", "file-loader": "3.0.1", "filesize": "2.0.4", + "generic-diff": "1.0.1", "he": "1.1.1", "imports-loader": "0.8.0", "jed": "git://github.com/mwiencek/Jed.git#cd3f71b", @@ -49,8 +50,8 @@ "querystring": "0.2.0", "raven": "2.0.0", "raven-js": "3.17.0", - "react": "16.8.2", - "react-dom": "16.8.2", + "react": "16.10.2", + "react-dom": "16.10.2", "redux": "3.6.0", "shell-quote": "1.6.1", "shelljs": "0.5.3", @@ -73,9 +74,9 @@ "eslint-plugin-import": "2.16.0", "eslint-plugin-react": "7.12.4", "file-url": "2.0.2", - "flow-bin": "0.106.2", + "flow-bin": "0.109.0", "gettext-parser": "3.1.0", - "http-proxy": "1.16.2", + "http-proxy": "1.18.0", "jsdom": "13.2.0", "selenium-webdriver": "3.6.0", "tape": "4.7.0", diff --git a/root/artist/ArtistIndex.js b/root/artist/ArtistIndex.js new file mode 100644 index 00000000000..ade0757b08d --- /dev/null +++ b/root/artist/ArtistIndex.js @@ -0,0 +1,308 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import {withCatalystContext} from '../context'; +import Annotation from '../static/scripts/common/components/Annotation'; +import DescriptiveLink from '../static/scripts/common/components/DescriptiveLink'; +import Filter from '../static/scripts/common/components/Filter'; +import {type FilterFormT} from '../static/scripts/common/components/FilterForm'; +import WikipediaExtract + from '../static/scripts/common/components/WikipediaExtract'; +import {addColonText} from '../static/scripts/common/i18n/addColon'; +import commaOnlyList, {commaOnlyListText} from '../static/scripts/common/i18n/commaOnlyList'; +import {bracketedText} from '../static/scripts/common/utility/bracketed'; +import FormSubmit from '../components/FormSubmit'; +import RecordingList from '../components/list/RecordingList'; +import ReleaseGroupList from '../components/list/ReleaseGroupList'; +import * as manifest from '../static/manifest'; +import entityHref from '../static/scripts/common/utility/entityHref'; + +import ArtistLayout from './ArtistLayout'; + +type RelatedArtistsProps = {| + +children: React$Node, + +title: string, +|}; + +const RelatedArtists = ({children, title}: RelatedArtistsProps) => ( +

+ {addColonText(title)} + {' '} + {children} +

+); + +type Props = {| + +$c: CatalystContextT, + +ajaxFilterFormUrl: string, + +artist: ArtistT, + +eligibleForCleanup: boolean, + +filterForm: ?FilterFormT, + +hasFilter: boolean, + +includingAllStatuses: boolean, + +legalName: ?ArtistT, + +legalNameAliases: ?$ReadOnlyArray, + +legalNameArtistAliases: ?$ReadOnlyArray, + +numberOfRevisions: number, + +otherIdentities: $ReadOnlyArray, + +recordings: ?$ReadOnlyArray, + +releaseGroups: ?$ReadOnlyArray, + +showingVariousArtistsOnly: boolean, + +wantAllStatuses: boolean, + +wantVariousArtistsOnly: boolean, + +wikipediaExtract: WikipediaExtractT, +|}; + +const ArtistIndex = ({ + $c, + ajaxFilterFormUrl, + artist, + eligibleForCleanup, + filterForm, + hasFilter, + includingAllStatuses, + legalName, + legalNameAliases, + legalNameArtistAliases, + numberOfRevisions, + otherIdentities, + recordings, + releaseGroups, + showingVariousArtistsOnly, + wantAllStatuses, + wantVariousArtistsOnly, + wikipediaExtract, +}: Props) => { + const hasRecordings = !!(recordings && recordings.length); + const hasReleaseGroups = !!(releaseGroups && releaseGroups.length); + const artistLink = entityHref(artist); + let message = ''; + + if (hasRecordings) { + message = l( + 'This artist has no release groups, only standalone recordings.', + ); + } else if (!hasReleaseGroups && hasFilter) { + message = l('No release groups found that match this search.'); + } else if (!wantAllStatuses && !wantVariousArtistsOnly) { + if (!includingAllStatuses && !showingVariousArtistsOnly) { + if (hasReleaseGroups) { + message = exp.l( + `Showing official release groups by this artist. + {show_all|Show all release groups instead}, or + {show_va|show various artists release groups}.`, + { + show_all: `${artistLink}?all=1`, + show_va: `${artistLink}?va=1`, + }, + ); + } else { + message = l(`This artist does not have any release groups or + standalone recordings.`); + } + } else if (includingAllStatuses && !showingVariousArtistsOnly) { + message = ( + <> + {l('This artist only has unofficial release groups.')} + {' '} + {exp.l( + `Showing all release groups by this artist. + {show_va|Show various artists release groups instead}.`, + {show_va: `${artistLink}?va=1`}, + )} + + ); + } else if (!includingAllStatuses && showingVariousArtistsOnly) { + message = ( + <> + {l('This artist only has release groups by various artists.')} + {' '} + {exp.l( + `Showing official release groups for various artists. + {show_all|Show all various artists release groups instead}.`, + {show_all: `${artistLink}?all=1&va=1`}, + )} + + ); + } else if (includingAllStatuses && showingVariousArtistsOnly) { + message = ( + l(`This artist only has unofficial release groups by + various artists.`) + ' ' + + l('Showing all release groups for various artists.') + ); + } + } else if (wantAllStatuses && !wantVariousArtistsOnly) { + if (includingAllStatuses && !showingVariousArtistsOnly) { + message = exp.l( + `Showing all release groups by this artist. + {show_official|Show only official release groups instead}, or + {show_va|show various artists release groups}.`, + { + show_official: `${artistLink}?all=0`, + show_va: `${artistLink}?all=1&va=1`, + }, + ); + } else if (!hasReleaseGroups) { + message = l(`This artist does not have any release groups or + standalone recordings.`); + } else if (includingAllStatuses && showingVariousArtistsOnly) { + message = ( + <> + {l('This artist only has release groups by various artists.')} + {' '} + {exp.l( + `Showing all release groups for various artists. + {show_official|Show only official various artists + release groups instead}.`, + {show_official: `${artistLink}?all=0&va=1`}, + )} + + ); + } + } else if (!wantAllStatuses && wantVariousArtistsOnly) { + if (!includingAllStatuses && showingVariousArtistsOnly) { + message = exp.l( + `Showing official release groups for various artists. + {show_all|Show all various artists release groups instead}, or + {show_non_va|show release groups by this artist}.`, + { + show_all: `${artistLink}?all=1&va=1`, + show_non_va: `${artistLink}?va=0`, + }, + ); + } else if (!hasReleaseGroups) { + message = exp.l( + `This artist does not have any various artists release groups. + {show_non_va|Show release groups by this artist instead}.`, + {show_non_va: `${artistLink}?va=0`}, + ); + } else if (includingAllStatuses && showingVariousArtistsOnly) { + message = ( + <> + {l(`This artist only has unofficial release groups by + various artists.`)} + {' '} + {exp.l( + `Showing all release groups for various artists. + {show_non_va|Show release groups by this artist instead}.`, + {show_non_va: `${artistLink}?va=0`}, + )} + + ); + } + } else if (wantAllStatuses && wantVariousArtistsOnly) { + if (hasReleaseGroups) { + message = exp.l( + `Showing all release groups for various artists. + {show_official|Show only official various artists + release groups instead}, or + {show_non_va|show release groups by this artist}.`, + { + show_non_va: `${artistLink}?all=1&va=0`, + show_official: `${artistLink}?all=0&va=1`, + }, + ); + } else { + message = exp.l( + `This artist does not have any various artists release groups. + {show_non_va|Show release groups by this artist instead}.`, + {show_non_va: `${artistLink}?all=1&va=0`}, + ); + } + } + + return ( + + {eligibleForCleanup ? ( +

+ {l(`This artist has no relationships, recordings, releases or + release groups, and will be removed automatically in the next + few days. If this is not intended, please add more data to + this artist.`)} +

+ ) : null} + + + + {legalName ? ( + + + {legalNameArtistAliases + ? ' ' + bracketedText(commaOnlyListText(legalNameArtistAliases)) + : null} + + + ) : (legalNameAliases && legalNameAliases.length) ? ( + + {commaOnlyListText(legalNameAliases)} + + ) : null} + + {otherIdentities && otherIdentities.length ? ( + + {commaOnlyList( + otherIdentities.map(a => ( + + )), + )} + + ) : null} + + + +

{l('Discography')}

+ + + + {hasReleaseGroups ? ( +
+ {/* TODO: MBS-10155 */} + + {$c.user_exists ? ( +
+ +
+ ) : null} + + ) : null} + + {hasRecordings ? ( + + ) : null} + +

{message}

+ + {manifest.js('artist/index.js', {async: 'async'})} +
+ ); +}; + +export default withCatalystContext(ArtistIndex); diff --git a/root/artist/ArtistRecordings.js b/root/artist/ArtistRecordings.js new file mode 100644 index 00000000000..e0d84b4690e --- /dev/null +++ b/root/artist/ArtistRecordings.js @@ -0,0 +1,108 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import FormSubmit from '../components/FormSubmit'; +import RecordingList from '../components/list/RecordingList'; +import PaginatedResults from '../components/PaginatedResults'; +import {withCatalystContext} from '../context'; +import Filter from '../static/scripts/common/components/Filter'; +import {type FilterFormT} from '../static/scripts/common/components/FilterForm'; + +import ArtistLayout from './ArtistLayout'; + +type Props = { + +$c: CatalystContextT, + +ajaxFilterFormUrl: string, + +artist: ArtistT, + +filterForm: ?FilterFormT, + +hasFilter: boolean, + +pager: PagerT, + +recordings: $ReadOnlyArray, + +standaloneOnly: boolean, + +videoOnly: boolean, +}; + +const ArtistRecordings = ({ + $c, + ajaxFilterFormUrl, + artist, + filterForm, + hasFilter, + pager, + recordings, + standaloneOnly, + videoOnly, +}: Props) => ( + +

{l('Recordings')}

+ + + + {recordings.length ? ( +
+ + + + {$c.user_exists ? ( +
+ +
+ ) : null} +
+ ) : ( +

+ {hasFilter + ? l('No recordings found that match this search.') + : l('No recordings found.')} +

+ )} + + {standaloneOnly ? ( +

+ {exp.l( + `Showing only standalone recordings. + {show_all|Show all recordings instead}.`, + {show_all: `/artist/${artist.gid}/recordings?standalone=0`}, + )} +

+ + ) : videoOnly ? ( +

+ {exp.l( + 'Showing only videos. {show_all|Show all recordings instead}.', + {show_all: `/artist/${artist.gid}/recordings?video=0`}, + )} +

+ + ) : ( +

+ {exp.l( + `Showing all recordings. + {show_sa|Show only standalone recordings instead}, or + {show_vid|show only videos}.`, + { + show_sa: `/artist/${artist.gid}/recordings?standalone=1`, + show_vid: `/artist/${artist.gid}/recordings?video=1`, + }, + )} +

+ )} +
+); + +export default withCatalystContext(ArtistRecordings); diff --git a/root/artist/ArtistReleases.js b/root/artist/ArtistReleases.js new file mode 100644 index 00000000000..8145a3567e1 --- /dev/null +++ b/root/artist/ArtistReleases.js @@ -0,0 +1,101 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import FormSubmit from '../components/FormSubmit'; +import ReleaseList from '../components/list/ReleaseList'; +import PaginatedResults from '../components/PaginatedResults'; +import {withCatalystContext} from '../context'; +import Filter from '../static/scripts/common/components/Filter'; +import {type FilterFormT} from '../static/scripts/common/components/FilterForm'; + +import ArtistLayout from './ArtistLayout'; + +type Props = { + +$c: CatalystContextT, + +ajaxFilterFormUrl: string, + +artist: ArtistT, + +filterForm: ?FilterFormT, + +hasFilter: boolean, + +pager: PagerT, + +releases: $ReadOnlyArray, + +showingVariousArtistsOnly: boolean, + +wantVariousArtistsOnly: boolean, +}; + +const ArtistReleases = ({ + $c, + ajaxFilterFormUrl, + artist, + filterForm, + hasFilter, + pager, + releases, + showingVariousArtistsOnly, + wantVariousArtistsOnly, +}: Props) => ( + +

{l('Releases')}

+ + + + {releases.length ? ( +
+ + + + {$c.user_exists ? ( +
+ +
+ ) : null} +
+ ) : null} + + {releases.length === 0 ? ( +

+ {hasFilter + ? l('No releases found that match this search.') + : l('No releases found.')} +

+ ) : null} + + {wantVariousArtistsOnly ? ( + exp.l( + `Showing Various Artist releases. + {show_subset|Show releases by this artist instead}.`, + {show_subset: `/artist/${artist.gid}/releases?va=0`}, + ) + ) : showingVariousArtistsOnly ? ( + /* + * The user didn't specifically ask for VA releases, but nothing + * else was found, so we're showing them anyway. + */ +

+ {hasFilter ? ( + l('This search only found releases by various artists.') + ) : ( + l('This artist only has releases by various artists.') + )} +

+ ) : ( + exp.l( + `Showing releases by this artist. + {show_all|Show Various Artist releases instead}.`, + {show_all: `/artist/${artist.gid}/releases?va=1`}, + ) + )} +
+); + +export default withCatalystContext(ArtistReleases); diff --git a/root/artist/index.tt b/root/artist/index.tt deleted file mode 100644 index 20b41336f14..00000000000 --- a/root/artist/index.tt +++ /dev/null @@ -1,108 +0,0 @@ -[% MACRO rel_artist_line(title, list, extra) BLOCK; - IF list.size; - SET ll = []; - ll.push(descriptive_link(e)) FOR e = list; - extra_list = ' (' _ comma_only_list(extra)_ ')' IF extra.size; - '

' _ add_colon(title) _ ' ' _ comma_only_list(ll)_ extra_list _ '

'; - END; -END %] - -[%- WRAPPER "artist/layout.tt" page='index' -%] - [% IF eligible_for_cleanup %] -

- [% l('This artist has no relationships, recordings, releases or - release groups, and will be removed automatically in the next - few days. If this is not intended, please add more data to this - artist.') %] -

- [% END %] - - [%- INCLUDE 'annotation/summary.tt' -%] - - [%- IF legal_name.defined; - rel_artist_line(l('Legal name'), [ legal_name ], legal_name_artist_aliases); - ELSIF legal_name_aliases.size -%] -

[% add_colon(l('Legal name')) %] [% comma_only_list(legal_name_aliases) %]

- [% END %] - [% rel_artist_line(l('Also performs as'), other_identities) %] - - [%- show_wikipedia_extract() -%] - - [%- filter_button() -%] -

[% l('Discography') %]

- - [%- INCLUDE 'components/filter.tt' - ajax_form_url=c.uri_for_action('/ajax/filter_artist_release_groups_form', { artist_id => artist.id }) -%] - - [%- IF release_groups.size -%] -
- [% React.embed(c, 'components/list/ReleaseGroupList', { releaseGroups => release_groups, checkboxes => 'add-to-merge', showRatings => 1, groupByType => 1 }) # TODO: On conversion to React, please check MBS-10155. - WRAPPER 'components/with-pager.tt' %] - [% form_submit(l('Merge release groups')) WRAPPER form_row IF c.user_exists %] -
- [%- END -%] - - [%- IF recordings.size -%] - [% React.embed(c, 'components/list/RecordingList', { recordings => recordings, checkboxes => 'add-to-merge', showRatings => 1 }) - WRAPPER 'components/with-pager.tt' -%] - [%- END -%] - -

- [%~ IF recordings.size -%] - [%- l('This artist has no release groups, only standalone recordings.') -%] - [%- ELSIF release_groups.size == 0 && has_filter -%] - [%- l('No release groups found.') -%] - [%- ELSIF !show_all && !show_va -%] - [%- IF !including_all && !including_va -%] - [%- l('Showing official release groups by this artist. {show_all|Show all release groups instead}, or {show_va|show various artists release groups}.', - { show_all = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1 }), show_va = c.uri_for_action('/artist/show', [ artist.gid ], { va = 1 }) }) -%] - [%- ELSIF including_all && !including_va -%] - [%- l('This artist only has unofficial release groups.') =%] - [%= l('Showing all release groups by this artist. {show_va|Show various artists release groups instead}.', - { show_va = c.uri_for_action('/artist/show', [ artist.gid ], { va = 1 }) }) -%] - [%- ELSIF !including_all && including_va -%] - [%- l('This artist only has release groups by various artists.') =%] - [%= l('Showing official release groups for various artists. {show_all|Show all various artists release groups instead}.', - { show_all = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1, va = 1}) }) -%] - [%- ELSIF release_groups.size == 0 -%] - [%- l('This artist does not have any release groups or standalone recordings.') -%] - [%- ELSIF including_all && including_va -%] - [%- l('This artist only has unofficial release groups by various artists.') =%] - [%= l('Showing all release groups for various artists.') -%] - [%- END -%] - [%- ELSIF show_all && !show_va -%] - [%- IF including_all && !including_va -%] - [%- l('Showing all release groups by this artist. {show_official|Show only official release groups instead}, or {show_va|show various artists release groups}.', - { show_official = c.uri_for_action('/artist/show', [ artist.gid ], { all = 0 }), show_va = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1, va = 1 }) }) -%] - [%- ELSIF release_groups.size == 0 -%] - [%- l('This artist does not have any release groups or standalone recordings.') -%] - [%- ELSIF including_all && including_va -%] - [%- l('This artist only has release groups by various artists.') =%] - [%= l('Showing all release groups for various artists. {show_official|Show only official various artists release groups instead}.', - { show_official = c.uri_for_action('/artist/show', [ artist.gid ], { all = 0, va = 1 }) }) -%] - [%- END -%] - [%- ELSIF !show_all && show_va -%] - [%- IF !including_all && including_va -%] - [%- l('Showing official release groups for various artists. {show_all|Show all various artists release groups instead}, or {show_non_va|show release groups by this artist}.', - { show_all = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1, va = 1}), show_non_va = c.uri_for_action('/artist/show', [ artist.gid ], { va = 0 }) }) -%] - [%- ELSIF release_groups.size == 0 -%] - [%- l('This artist does not have any various artists release groups. {show_non_va|Show release groups by this artist instead}.', - { show_non_va = c.uri_for_action('/artist/show', [ artist.gid ], { va = 0 }) }) -%] - [%- ELSIF including_all && including_va -%] - [%- l('This artist only has unofficial release groups by various artists.') =%] - [%= l('Showing all release groups for various artists. {show_non_va|Show release groups by this artist instead}.', - { show_non_va = c.uri_for_action('/artist/show', [ artist.gid ], { va = 0 }) }) -%] - [%- END -%] - [%- ELSIF show_all && show_va -%] - [%- IF release_groups.size == 0 -%] - [%- l('This artist does not have any various artists release groups. {show_non_va|Show release groups by this artist instead}.', - { show_non_va = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1, va = 0 }) }) -%] - [%- ELSE -%] - [%- l('Showing all release groups for various artists. {show_official|Show only official various artists release groups instead}, or {show_non_va|show release groups by this artist}.', - { show_official = c.uri_for_action('/artist/show', [ artist.gid ], { all = 0, va = 1 }), show_non_va = c.uri_for_action('/artist/show', [ artist.gid ], { all = 1, va = 0 }) }) -%] - [%- END -%] - [%- END ~%] -

- - [%- script_manifest('artist/index.js', {async => 'async'}) -%] -[% END %] diff --git a/root/artist/recordings.tt b/root/artist/recordings.tt deleted file mode 100644 index 110a8586de1..00000000000 --- a/root/artist/recordings.tt +++ /dev/null @@ -1,31 +0,0 @@ -[%- WRAPPER 'artist/layout.tt' title=l('Recordings') page='recordings' -%] - - [%- filter_button() -%] -

[% l('Recordings') %]

- - [%- INCLUDE 'components/filter.tt' - ajax_form_url=c.uri_for_action('/ajax/filter_artist_recordings_form', { artist_id => artist.id }) -%] - - [%- IF recordings.size -%] -
- [% React.embed(c, 'components/list/RecordingList', { recordings => recordings, checkboxes => 'add-to-merge', showRatings => 1 }) - WRAPPER 'components/with-pager.tt' -%] - [% form_submit(l('Add selected recordings for merging')) WRAPPER form_row IF c.user_exists %] -
- [%- ELSE -%] -

[%- l('No recordings found.') -%]

- [%- END -%] - - [% IF standalone_only %] -

[% l('Showing only standalone recordings. {show_all|Show all recordings instead}.', - { show_all => c.uri_for_action('/artist/recordings', [ artist.gid ], { standalone => 0 }) }) %]

- [% ELSIF video_only %] -

[% l('Showing only videos. {show_all|Show all recordings instead}.', - { show_all => c.uri_for_action('/artist/recordings', [ artist.gid ], { video => 0 }) }) %]

- [% ELSE %] -

[% l('Showing all recordings. {show_sa|Show only standalone recordings instead}, or {show_vid|show only videos}.', - { show_sa => c.uri_for_action('/artist/recordings', [ artist.gid ], { standalone => 1 }), - show_vid => c.uri_for_action('/artist/recordings', [ artist.gid ], { video => 1 }) }) %]

- [% END %] -[%- END -%] diff --git a/root/artist/releases.tt b/root/artist/releases.tt deleted file mode 100644 index 7c56f2e2ed4..00000000000 --- a/root/artist/releases.tt +++ /dev/null @@ -1,37 +0,0 @@ -[%- WRAPPER 'artist/layout.tt' title=l('Releases') page='releases' -%] - - [%- filter_button() -%] -

[% l('Releases') %]

- - [%- INCLUDE 'components/filter.tt' - ajax_form_url=c.uri_for_action('/ajax/filter_artist_releases_form', { artist_id => artist.id }) -%] - - [%- IF releases.size -%] -
- [% React.embed(c, 'components/list/ReleaseList', { releases => releases, checkboxes => 'add-to-merge'}) - WRAPPER 'components/with-pager.tt' -%] - [% form_submit(l('Add selected releases for merging')) WRAPPER form_row IF c.user_exists %] -
- [%- END -%] - - [% IF va_only && pager.total_entries == 0 %] -

[% l('This artist does not have any releases') %]

- [% ELSE %] - [% IF releases.size == 0 %] -

[% l('No releases found') %]

- [% END %] - - [% IF va_only %] -

[% l('This artist only has releases by various artists.') %]

- [% ELSE %] - [% IF !show_va %] -

[% l('Showing releases by this artist. {show_all|Show Various Artist releases instead}.', - { show_all = c.uri_for_action('/artist/releases', [ artist.gid ], { va = 1 }) }) %]

- [% ELSE %] -

[% l('Showing Various Artist releases. {show_subset|Show releases by this artist instead}.', - { show_subset = c.uri_for_action('/artist/releases', [ artist.gid ], { va = 0 }) }) %]

- [% END %] - [% END %] - [% END %] -[%- END -%] diff --git a/root/components/Aliases/AliasTable.js b/root/components/Aliases/AliasTable.js index 0bff0e740d7..80224b9cfb8 100644 --- a/root/components/Aliases/AliasTable.js +++ b/root/components/Aliases/AliasTable.js @@ -29,7 +29,7 @@ const AliasTable = (props: Props) => ( {l('Type')} {l('Locale')} {props.allowEditing - ? {l('Actions')} + ? {l('Actions')} : null} diff --git a/root/components/Aliases/AliasTableRow.js b/root/components/Aliases/AliasTableRow.js index 526ba2d9e41..b83b378a9ba 100644 --- a/root/components/Aliases/AliasTableRow.js +++ b/root/components/Aliases/AliasTableRow.js @@ -49,7 +49,7 @@ const AliasTableRow = ({alias, allowEditing, entity, row}: Props) => ( ) : null} - + {allowEditing ? ( <> diff --git a/root/components/Aliases/ArtistCreditList.js b/root/components/Aliases/ArtistCreditList.js index 621f00c1497..95ff404038c 100644 --- a/root/components/Aliases/ArtistCreditList.js +++ b/root/components/Aliases/ArtistCreditList.js @@ -44,7 +44,7 @@ const ArtistCreditList = ({$c, artistCredits, entity}: Props) => { {l('Name')} {$c.user_exists ? ( - + {l('Actions')} ) : null} @@ -57,7 +57,7 @@ const ArtistCreditList = ({$c, artistCredits, entity}: Props) => { {$c.user_exists ? ( - +
{credit.editsPending ? {l('Edit')} diff --git a/root/components/CleanupBanner.js b/root/components/CleanupBanner.js new file mode 100644 index 00000000000..3fed9157920 --- /dev/null +++ b/root/components/CleanupBanner.js @@ -0,0 +1,52 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import React from 'react'; + +const cleanupBannerStrings = { + artist: N_l( + `This artist has no relationships, recordings, releases or + release groups, and will be removed automatically in the next + few days. If this is not intended, please add more data to + this artist.`, + ), + event: N_l( + `This event has no relationships and will be removed automatically + in the next few days. If this is not intended, + please add more data to this event.`, + ), + label: N_l( + `This label has no relationships or releases and will be removed + automatically in the next few days. If this is not intended, + please add more data to this label.`, + ), + place: N_l( + `This place has no relationships and will be removed automatically + in the next few days. If this is not intended, + please add more data to this place.`, + ), + release_group: N_l( + `This release group has no relationships or releases associated, + and will be removed automatically in the next few days. If this + is not intended, please add more data to this release group.`, + ), + work: N_l( + `This work has no relationships and will be removed + automatically in the next few days. If this is not intended, please add + relationships to this work.`, + ), +}; + +const CleanupBanner = ({entityType}: {entityType: string}) => ( +

+ {cleanupBannerStrings[entityType]()} +

+); + +export default CleanupBanner; diff --git a/root/components/ConfirmLayout.js b/root/components/ConfirmLayout.js index 7781e77ec95..44d4ca080a4 100644 --- a/root/components/ConfirmLayout.js +++ b/root/components/ConfirmLayout.js @@ -24,17 +24,19 @@ const ConfirmLayout = ({action, question, title}: Props) => (
diff --git a/root/components/EnterEditNote.js b/root/components/EnterEditNote.js index e48d60805a2..9771631fd59 100644 --- a/root/components/EnterEditNote.js +++ b/root/components/EnterEditNote.js @@ -30,7 +30,13 @@ const EnterEditNote = ({ note: {href: '/doc/Edit_Note', target: '_blank'}, })}

-

{l('Even just providing a URL or two is helpful!')}

+

+ {exp.l( + `Even just providing a URL or two is helpful! + For more suggestions, see {doc_how_to|our guide for writing good edit notes}.`, + {doc_how_to: {href: '/doc/How_to_Write_Edit_Notes', target: '_blank'}}, + )} +

)} diff --git a/root/components/EntityTabs.js b/root/components/EntityTabs.js index 647b62a17c4..a26b1b4b3ef 100644 --- a/root/components/EntityTabs.js +++ b/root/components/EntityTabs.js @@ -18,18 +18,17 @@ import EntityTabLink from './EntityTabLink'; const tabLinkNames = { 'artists': N_l('Artists'), - 'cover-art': N_l('Cover Art'), - 'discids': N_l('Disc IDs'), - 'events': N_l('Events'), - 'fingerprints': N_l('Fingerprints'), - 'labels': N_l('Labels'), - 'map': N_l('Map'), - 'performances': N_l('Performances'), - 'places': N_l('Places'), - 'recordings': N_l('Recordings'), - 'releases': N_l('Releases'), - 'users': N_l('Users'), - 'works': N_l('Works'), + discids: N_l('Disc IDs'), + events: N_l('Events'), + fingerprints: N_l('Fingerprints'), + labels: N_l('Labels'), + map: N_l('Map'), + performances: N_l('Performances'), + places: N_l('Places'), + recordings: N_l('Recordings'), + releases: N_l('Releases'), + users: N_l('Users'), + works: N_l('Works'), }; const buildLink = ( @@ -66,12 +65,13 @@ function showEditTab( } function buildLinks( - user: ?EditorT, + $c: CatalystContextT, entity: CoreEntityT, page: string, editTab: ?React.Node, ): React.Node { const links = [buildLink(l('Overview'), entity, '', page, 'index')]; + const user = $c.user; const entityProperties = ENTITIES[entity.entityType]; @@ -85,6 +85,10 @@ function buildLinks( links.push(buildLink(l('Relationships'), entity, 'relationships', page)); } + if (entityProperties.cover_art) { + links.push(buildLink(texp.l('Cover Art ({num})', {num: $c.stash.release_artwork_count || 0}), entity, 'cover-art', page)); + } + if (entityProperties.aliases) { links.push(buildLink(l('Aliases'), entity, 'aliases', page)); } @@ -105,6 +109,10 @@ function buildLinks( } } + if (entity.entityType === 'release') { + links.push(buildLink(l('Edit Relationships'), entity, 'edit-relationships', page)); + } + return links; } @@ -121,7 +129,7 @@ const EntityTabs = ({ }: Props) => ( - {($c: CatalystContextT) => buildLinks($c.user, entity, page, editTab)} + {($c: CatalystContextT) => buildLinks($c, entity, page, editTab)} ); diff --git a/root/components/FormRowRadio.js b/root/components/FormRowRadio.js index da37d2ba757..ea0fdc34b3a 100644 --- a/root/components/FormRowRadio.js +++ b/root/components/FormRowRadio.js @@ -47,7 +47,7 @@ const FormRowRadio = ({ value={option.value} /> {' '} - {unwrapNl(option.label)} + {unwrapNl(option.label)} {index < options.length - 1 ?
: null} diff --git a/root/components/SelectField.js b/root/components/SelectField.js index 5cf4e90cff4..65c16ef9b24 100644 --- a/root/components/SelectField.js +++ b/root/components/SelectField.js @@ -14,7 +14,7 @@ import getSelectValue from '../utility/getSelectValue'; const buildOption = (option: SelectOptionT, index: number) => ( ); @@ -24,15 +24,28 @@ const buildOptGroup = (optgroup, index) => ( ); -type SelectFieldProps = {| +type SelectElementProps = { + className?: string, + defaultValue?: StrOrNum, + disabled?: boolean, + id?: string, + name?: string, + onChange?: (event: SyntheticEvent) => void, + required?: boolean, + style?: {}, + value?: StrOrNum, +}; + +type SelectFieldProps = { +allowEmpty?: boolean, + +className?: string, +disabled?: boolean, +field: ReadOnlyFieldT, +onChange?: (event: SyntheticEvent) => void, +options: MaybeGroupedOptionsT, +required?: boolean, +uncontrolled?: boolean, -|}; +}; const SelectField = ({ allowEmpty = true, @@ -42,25 +55,29 @@ const SelectField = ({ options, required, uncontrolled = false, + ...props }: SelectFieldProps) => { - const selectElementProps: any = { - className: 'with-button', - disabled: disabled, - id: 'id-' + field.html_name, - name: field.html_name, - required: required, - }; + const selectProps: SelectElementProps = props; + + if (selectProps.className === undefined) { + selectProps.className = 'with-button'; + } + + selectProps.disabled = disabled; + selectProps.id = 'id-' + field.html_name; + selectProps.name = field.html_name; + selectProps.required = required; if (uncontrolled) { - selectElementProps.defaultValue = + selectProps.defaultValue = getSelectValue(field, options, allowEmpty); } else { - selectElementProps.onChange = onChange; - selectElementProps.value = getSelectValue(field, options, allowEmpty); + selectProps.onChange = onChange; + selectProps.value = getSelectValue(field, options, allowEmpty); } return ( - {allowEmpty ? : null} diff --git a/root/components/common-macros.tt b/root/components/common-macros.tt index 19c740a6d82..70cd14c7a63 100644 --- a/root/components/common-macros.tt +++ b/root/components/common-macros.tt @@ -759,10 +759,6 @@ END ~%]
[%- END -%] -[%~ MACRO filter_button BLOCK -%] - -[%- END -%] - [%~ MACRO artist_begin_label_from_type(type_id) BLOCK ~%] [%~ type_id == 1 ? l('Born:') : (type_id == 2 || type_id == 5 || type_id == 6) ? l('Founded:') : l('Begin date:') ~%] [%~ END -%] diff --git a/root/components/filter-form.tt b/root/components/filter-form.tt deleted file mode 100644 index 270978591f8..00000000000 --- a/root/components/filter-form.tt +++ /dev/null @@ -1,27 +0,0 @@ -[% USE r = FormRenderer(filter_form) %] - - [% IF filter_form.field('type_id') %] - - - - - [% END %] - [% IF filter_form.field('artist_credit_id') %] - - - - - [% END %] - - - - - - - - -
[% l('Type:') %][% r.select('type_id', { style => 'max-width: 40em' }) %]
[% l('Artist credit:') %][% r.select('artist_credit_id', { style => 'max-width: 40em' }) %]
[% l('Name:') %][% r.text('name', { size => 47 }) %]
- - -
- diff --git a/root/components/filter.tt b/root/components/filter.tt deleted file mode 100644 index 389c701f7e3..00000000000 --- a/root/components/filter.tt +++ /dev/null @@ -1,6 +0,0 @@ -
-
- [% INCLUDE "components/filter-form.tt" IF filter_form.defined %] - -
-
diff --git a/root/components/forms.tt b/root/components/forms.tt index 2ac5745dbbb..b67bb420242 100644 --- a/root/components/forms.tt +++ b/root/components/forms.tt @@ -29,7 +29,7 @@

[% l('Entering an {note|edit note} that describes where you got your information is highly recommended. Not only does it make it clear where you got your information, but it can also encourage other users to vote on your edit — thus making your edit get applied faster.', { note => { href => doc_link('Edit_Note'), target => '_blank' } }) %]

-

[% l('Even just providing a URL or two is helpful!') %]

+

[% l('Even just providing a URL or two is helpful! For more suggestions, see {doc_how_to|our guide for writing good edit notes}.', { doc_how_to => { href => doc_link('How_to_Write_Edit_Notes'), target => '_blank' } }) %]

[% END %] [% WRAPPER form_row %] [% IF ko %] diff --git a/root/edit/details/AddAnnotation.js b/root/edit/details/AddAnnotation.js index 41fff01e420..61bdcb366fd 100644 --- a/root/edit/details/AddAnnotation.js +++ b/root/edit/details/AddAnnotation.js @@ -17,7 +17,6 @@ type AnnotatedEntityTypeT = $ElementType; type AddAnnotationEditT = {| ...EditT, +display_data: {| - +annotation_id: number, +changelog: string, +entity_type: AnnotatedEntityTypeT, [AnnotatedEntityTypeT]: AnnotatedEntityT, diff --git a/root/edit/details/add_event_annotation.tt b/root/edit/details/add_event_annotation.tt deleted file mode 100644 index ffe65349004..00000000000 --- a/root/edit/details/add_event_annotation.tt +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - -
[% l('Event:') %][% descriptive_link(edit.display_data.event) %]
[% l('Changelog:') %] - - [% html_escape(edit.display_data.changelog) || l('(no changelog)') %] - -
diff --git a/root/edit/details/edit_relationship.tt b/root/edit/details/edit_relationship.tt index 18bf465c1bd..0d3939748b1 100644 --- a/root/edit/details/edit_relationship.tt +++ b/root/edit/details/edit_relationship.tt @@ -1,11 +1,8 @@ -[%- - PROCESS 'edit/details/macros.tt'; - old_rel = edit.display_data.old; - new_rel = edit.display_data.new; --%] - - [%- display_relationship_differences(l('Relationship:'), old_rel, new_rel) -%] + [%- React.embed(c, 'static/scripts/edit/components/edit/RelationshipDiff', { + oldRelationship => edit.display_data.old, + newRelationship => edit.display_data.new, + }) -%] [% IF edit.display_data.unknown_attributes %] diff --git a/root/edit/details/edit_relationship_type.tt b/root/edit/details/edit_relationship_type.tt index 3f568867e9d..6b9c91f5849 100644 --- a/root/edit/details/edit_relationship_type.tt +++ b/root/edit/details/edit_relationship_type.tt @@ -13,7 +13,7 @@ html_escape(edit.data.new.name)) || '' -%] - [%- display_html_diff(l('Description:'), + [%- display_word_diff(l('Description:'), edit.data.old.description, edit.data.new.description) || '' -%] diff --git a/root/edit/details/edit_release.tt b/root/edit/details/edit_release.tt index c445d2951c0..3a503fe6956 100644 --- a/root/edit/details/edit_release.tt +++ b/root/edit/details/edit_release.tt @@ -40,32 +40,10 @@ [%- IF edit.display_data.events -%] - [%- MACRO diff_side(side) BLOCK; - ''; - END -%] - - - - [%- diff_side('old') -%] - [%- diff_side('new') -%] - + [%- React.embed(c, 'static/scripts/edit/components/edit/ReleaseEventsDiff', { + oldEvents => edit.display_data.events.old, + newEvents => edit.display_data.events.new, + }) -%] [%- END -%] [%- display_full_change(l('Packaging:'), diff --git a/root/edit/details/macros.tt b/root/edit/details/macros.tt index a0d082cebd9..9708e737dd9 100644 --- a/root/edit/details/macros.tt +++ b/root/edit/details/macros.tt @@ -1,6 +1,6 @@ [%- USE Diff -%] -[%- MACRO display_diff(label, old, new, split) BLOCK -%] +[%- MACRO display_diff(label, old, new, split) BLOCK # Converted to React at root/static/scripts/edit/components/edit/Diff.js -%] [%- IF old != new -%] [%- split = split || '' -%] @@ -11,21 +11,11 @@ [%- END -%] [%- END -%] -[%- MACRO display_word_diff(label, old, new) BLOCK; +[%- MACRO display_word_diff(label, old, new) BLOCK; # Converted to React at root/static/scripts/edit/components/edit/WordDiff.js display_diff(label, old, new, '\s+'); END -%] -[%- MACRO display_html_diff(label, old, new) BLOCK -%] - [%- IF old != new -%] - - - - - - [%- END -%] -[%- END -%] - -[%- MACRO display_full_change(label, old, new) BLOCK -%] +[%- MACRO display_full_change(label, old, new) BLOCK # Converted to React at root/static/scripts/edit/components/edit/FullChangeDiff.js -%] [%- IF old != new -%] @@ -35,7 +25,7 @@ [%- END -%] [%- END -%] -[%- MACRO relationship_date_text(relationship) BLOCK; +[%- MACRO relationship_date_text(relationship) BLOCK; # Converted to React at root/utility/relationshipDateText.js IF !relationship.link.begin_date.is_empty; IF !relationship.link.end_date.is_empty; IF relationship.link.begin_date.format == relationship.link.end_date.format; @@ -62,49 +52,6 @@ END; END; -%] -[%- MACRO relationship_extra_attributes(relationship) BLOCK -%] -[%- IF relationship.extra_verbose_phrase_attributes -%] -([% relationship.extra_verbose_phrase_attributes %]) -[%- END -%] -[%- END -%] - -[%~ MACRO differences_span(old_rel, new_rel, entity_prop, side, class) BLOCK ~%] - [%~ old_entity = old_rel.$entity_prop; - new_entity = new_rel.$entity_prop; - credit_prop = entity_prop _ '_credit' ~%] - - [% descriptive_link(old_entity, old_rel.$credit_prop) IF side == "old" %] - [% descriptive_link(new_entity, new_rel.$credit_prop) IF side == "new" %] - -[%~ END ~%] - -[%- MACRO display_relationship_differences(label, old_rel, new_rel) BLOCK -%] - - - - - - - - -[%- END -%] - [%- MACRO display_edit_artwork(artwork, release, colspan) BLOCK -%] diff --git a/root/event/EventIndex.js b/root/event/EventIndex.js index b0de9970417..a84f235aeed 100644 --- a/root/event/EventIndex.js +++ b/root/event/EventIndex.js @@ -13,6 +13,7 @@ import Annotation from '../static/scripts/common/components/Annotation'; import WikipediaExtract from '../static/scripts/common/components/WikipediaExtract'; import expand2react from '../static/scripts/common/i18n/expand2react'; +import CleanupBanner from '../components/CleanupBanner'; import Relationships from '../components/Relationships'; import * as manifest from '../static/manifest'; @@ -33,13 +34,7 @@ const EventIndex = ({ }: Props) => ( {eligibleForCleanup ? ( -

- {l( - `This event has no relationships and will be removed automatically - in the next few days. If this is not intended, - please add more data to this event.`, - )} -

+ ) : null} ( {eligibleForCleanup ? ( -

- {l( - `This label has no relationships or releases and will be removed - automatically in the next few days. If this is not intended, - please add more data to this label.`, - )} -

+ ) : null} ( {eligibleForCleanup ? ( -

- {l( - `This place has no relationships and will be removed automatically - in the next few days. If this is not intended, - please add more data to this place.`, - )} -

+ ) : null} ( + +

{l('Associated AcoustIDs')}

+ + +
+); + +export default RecordingFingerprints; diff --git a/root/recording/RecordingIndex.js b/root/recording/RecordingIndex.js index c0f22f15705..761c5f10041 100644 --- a/root/recording/RecordingIndex.js +++ b/root/recording/RecordingIndex.js @@ -35,8 +35,10 @@ type Props = {| |}; const RecordingAppearancesTable = ({ + recording, tracks, }: { + recording: RecordingT, tracks: $ElementType, }) => (
' _ l('Name:') _ '' _ html_escape(edit.data.old.name) _ '
' _ l('Description:') _ '' _ html_escape(edit.data.old.description) _ '
'; - '
    '; - FOR common_event=edit.display_data.events.unchanged_countries; - event = - edit.display_data.events.unchanged_countries.${common_event.key}; - '
  • '; - Diff.diff_html_side( - release_event(event.old), - release_event(event.new), - side == 'old' ? '-' : '+', - ''); - '
  • '; - END; - FOR event=edit.display_data.events.changed_countries.${side}; - '
  • ' _ release_event(event) _ '
  • '; - END; - '
'; - '
[% l('Release events:') %]
[% label %][% Diff.diff_html_side(old, new, '-') %][% Diff.diff_html_side(old, new, '+') %]
[% label %]
[% l('Relationship:') %] - [% expand(Diff.diff_html_side(old_rel.verbose_phrase_with_placeholders, - new_rel.verbose_phrase_with_placeholders, '-', '\s+'), { - entity0 => differences_span(old_rel, new_rel, 'source', 'old', 'diff-only-a'), - entity1 => differences_span(old_rel, new_rel, 'target', 'old', 'diff-only-a') - }) %] - [% Diff.diff_side(relationship_date_text(old_rel), relationship_date_text(new_rel), '-') %] - [% Diff.diff_html_side(relationship_extra_attributes(old_rel), relationship_extra_attributes(new_rel), '-') %] -
- [% expand(Diff.diff_html_side(old_rel.verbose_phrase_with_placeholders, - new_rel.verbose_phrase_with_placeholders, '+', '\s+'), { - entity0 => differences_span(old_rel, new_rel, 'source', 'new', 'diff-only-b'), - entity1 => differences_span(old_rel, new_rel, 'target', 'new', 'diff-only-b') - }) %] - [% Diff.diff_side(relationship_date_text(old_rel), relationship_date_text(new_rel), '+') %] - [% Diff.diff_html_side(relationship_extra_attributes(old_rel), relationship_extra_attributes(new_rel), '+') %] -
[% l('Cover art:') %]
@@ -45,6 +47,7 @@ const RecordingAppearancesTable = ({ + @@ -63,7 +66,7 @@ const RecordingAppearancesTable = ({ return ( - + {/* The class being added is for usage with userscripts */} + @@ -136,7 +147,7 @@ const RecordingIndex = ({

{l('Appears on releases')}

{tracks && tracks.length > 0 ? ( - + ) : (

{l('No releases found which feature this recording.')}

)} diff --git a/root/recording/fingerprints.tt b/root/recording/fingerprints.tt deleted file mode 100644 index 6f877c9e7f1..00000000000 --- a/root/recording/fingerprints.tt +++ /dev/null @@ -1,53 +0,0 @@ -[%- WRAPPER 'recording/layout.tt' page='fingerprints' title=l('Fingerprints') %] - -

[%- l('Associated AcoustIDs') -%]

- - - -[%- END -%] diff --git a/root/release/edit/information.tt b/root/release/edit/information.tt index 6ee8978fa61..0619e4dee3d 100644 --- a/root/release/edit/information.tt +++ b/root/release/edit/information.tt @@ -339,7 +339,7 @@

- [% l('The comment field is used to help users distinguish between identically named releases.') %] + [% l('The disambiguation field is used to help users distinguish between identically named releases.') %]

[% l('This field is not a place to store general background information about the release: that kind of information should go in the annotation field.') %] diff --git a/root/release/edit/layout.tt b/root/release/edit/layout.tt index 3fdea42899a..b91c3290688 100644 --- a/root/release/edit/layout.tt +++ b/root/release/edit/layout.tt @@ -55,7 +55,7 @@

- + @@ -64,7 +64,7 @@ - +
diff --git a/root/release/edit_relationships.tt b/root/release/edit_relationships.tt index b41498c00a1..320cb056d91 100644 --- a/root/release/edit_relationships.tt +++ b/root/release/edit_relationships.tt @@ -4,7 +4,7 @@ [% PROCESS 'components/relationship-editor.tt' %]
- [%- React.embed(c, 'release/ReleaseHeader', { release => release, page => 'edit_relationships' }) -%] + [%- React.embed(c, 'release/ReleaseHeader', { release => release, page => 'edit-relationships' }) -%]

[% l('Relationships highlighted yellow will be edited, diff --git a/root/release_group/ReleaseGroupIndex.js b/root/release_group/ReleaseGroupIndex.js index e9a5678af91..a43318795d2 100644 --- a/root/release_group/ReleaseGroupIndex.js +++ b/root/release_group/ReleaseGroupIndex.js @@ -20,6 +20,7 @@ import PaginatedResults from '../components/PaginatedResults'; import TaggerIcon from '../static/scripts/common/components/TaggerIcon'; import loopParity from '../utility/loopParity'; import EntityLink from '../static/scripts/common/components/EntityLink'; +import CleanupBanner from '../components/CleanupBanner'; import FormRow from '../components/FormRow'; import FormSubmit from '../components/FormSubmit'; import Relationships from '../components/Relationships'; @@ -109,13 +110,7 @@ const ReleaseGroupIndex = ({ }: Props) => ( {eligibleForCleanup ? ( -

- {l( - `This release group has no relationships or releases associated, - and will be removed automatically in the next few days. If this - is not intended, please add more data to this release group.`, - )} -

+ ) : null} 0; - self.state = getBooleanCookie('filter'); - - $('.filter-button').on('click', function () { - if (self.state) { - self.hide(); - } else { - self.show(); - } - return false; - }); - - if (self.state) { - self.show(); - } else { - self.hide(); - } - - return self; -} - -$(document).ready(function () { - MB.Control.filter_button = MB.Control.FilterButton(); -}); diff --git a/root/static/scripts/common/MB/edit_search.js b/root/static/scripts/common/MB/edit_search.js index 17bda5b6dab..08761df8904 100644 --- a/root/static/scripts/common/MB/edit_search.js +++ b/root/static/scripts/common/MB/edit_search.js @@ -3,125 +3,124 @@ import $ from 'jquery'; import MB from '../MB'; $(function () { + /* eslint-disable sort-keys */ + const cardinalityMap = { + id: { + '=': 1, '!=': 1, '>': 1, '<': 1, 'BETWEEN': 2, + }, + date: { + '=': 1, '!=': 1, '>': 1, '<': 1, 'BETWEEN': 2, + }, + set: { + '=': 1, '!=': 1, // Not directly true, but it here it means "show one argument control" + }, + voter: { + '=': 1, '!=': 1, 'me': 0, 'not_me': 0, 'subscribed': 0, 'not_subscribed': 0, + }, + subscription: { + '=': 1, '!=': 1, 'subscribed': 0, 'not_subscribed': 0, + }, + link_type: { + '=': 1, + }, + user: { + '=': 1, '!=': 1, 'me': 0, 'not_me': 0, 'subscribed': 0, 'not_subscribed': 0, 'beginner': 0, + }, + }; + /* eslint-enable sort-keys */ + + let conditionCounter = 0; + + $(document).on('change', '#extra-condition select', function () { + const newCondition = $(this).parent('li'); + + const append = newCondition.clone(); + append.find('select').val(''); + + newCondition + .after(append) + .attr('id', null) + .addClass('condition'); + + newCondition.find('select:first') + .addClass('field') + .find('option:first') + .remove(); + + newCondition.find('button.remove-item').show(); + }).on('click', 'ul.conditions li.condition button.remove-item', function () { + $(this).parent('li').remove(); + }) + .on('change', 'ul.conditions select.field', function () { + const val = $(this).val(); + const $replacement = $('#fields .field-' + val).clone(); + if ($replacement.length) { + const $li = $(this).parent('li'); + $li.find('span.field-container span.field').replaceWith($replacement); + + const $field = $(this).parent('li').find('span.field-container span.field'); + $field + .show() + .find('select.operator').trigger('change'); + + $li.find('span.autocomplete').each(function () { + MB.Control.EntityAutocomplete({inputs: $(this)}); + }); + + $li.find(':input').each(function () { + addInputNamePrefix($(this)); + }); - var cardinalityMap = { - 'id': { - '=': 1, '!=': 1, '>': 1, '<': 1, 'BETWEEN': 2 - }, - 'date': { - '=': 1, '!=': 1, '>': 1, '<': 1, 'BETWEEN': 2 - }, - 'set': { - '=': 1, '!=': 1 // Not directly true, but it here it means "show one argument control" - }, - 'voter': { - '=': 1, '!=': 1, 'me': 0, 'not_me': 0, 'subscribed': 0, 'not_subscribed': 0 - }, - 'subscription': { - '=': 1, '!=': 1, 'subscribed': 0, 'not_subscribed': 0 - }, - 'link_type': { - '=': 1 - }, - 'user': { - '=': 1, '!=': 1, 'me': 0, 'not_me': 0, 'subscribed': 0, 'not_subscribed': 0, 'beginner': 0 - }, - }; - - var conditionCounter = 0; - - $(document).on("change", "#extra-condition select", function () { - var newCondition = $(this).parent('li'); - - var append = newCondition.clone(); - append.find('select').val(''); - - newCondition - .after(append) - .attr('id', null) - .addClass('condition'); - - newCondition.find('select:first') - .addClass('field') - .find('option:first').remove(); - - newCondition.find('button.remove-item').show(); - - }).on("click", "ul.conditions li.condition button.remove-item", function () { - $(this).parent('li').remove(); - - }).on("change", "ul.conditions select.field", function () { - var val = $(this).val(); - var $replacement = $('#fields .field-' + val).clone(); - if ($replacement.length) { - var $li = $(this).parent('li'); - $li.find('span.field-container span.field').replaceWith($replacement); - - var $field = $(this).parent('li').find('span.field-container span.field'); - $field - .show() - .find('select.operator').trigger('change'); - - $li.find('span.autocomplete').each(function () { - MB.Control.EntityAutocomplete({ 'inputs': $(this) }); - }); - - $li.find(':input').each(function () { - addInputNamePrefix($(this)); - }); - - conditionCounter++; - } - else { - console.error('There is no field-' + val); - } - - }).on("change", "ul.conditions select.operator", function () { - var $field = $(this).parent('span.field'); - - var predicate = filteredClassName($field, 'predicate-'); - var cardinality = cardinalityMap[predicate][$(this).val()]; - - $field.find('.arg').hide(); - $field.find('.arg:lt(' + cardinality + ')').show(); + conditionCounter++; + } else { + console.error('There is no field-' + val); + } + }) + .on('change', 'ul.conditions select.operator', function () { + const $field = $(this).parent('span.field'); + + const predicate = filteredClassName($field, 'predicate-'); + const cardinality = cardinalityMap[predicate][$(this).val()]; + + $field.find('.arg').hide(); + $field.find('.arg:lt(' + cardinality + ')').show(); }); - function prefixedInputName($element) { - return 'conditions.' + conditionCounter + '.' + $element.attr('name').replace(/conditions\.\d+\./, ''); - } + function prefixedInputName($element) { + return 'conditions.' + conditionCounter + '.' + $element.attr('name').replace(/conditions\.\d+\./, ''); + } - function addInputNamePrefix($input) { - if ($input.attr('name')) - { - $input.attr('name', prefixedInputName($input)); - } + function addInputNamePrefix($input) { + if ($input.attr('name')) { + $input.attr('name', prefixedInputName($input)); } - - function filteredClassName($element, prefix) { - var classList = $element.attr('class').split(/\s+/); - var ret; - for (var i = 0; i < classList.length; i++) { - if (classList[i].substring(0, prefix.length) === prefix) { - ret = classList[i].substring(prefix.length); - break; - } - } - - return ret; + } + + function filteredClassName($element, prefix) { + const classList = $element.attr('class').split(/\s+/); + let ret; + for (let i = 0; i < classList.length; i++) { + if (classList[i].substring(0, prefix.length) === prefix) { + ret = classList[i].substring(prefix.length); + break; + } } - $('ul.conditions li.condition span.field').show(); - $('ul.conditions li.condition select.operator').trigger('change'); - $('ul.conditions li.condition button.remove-item').show(); + return ret; + } - $('ul.conditions li.condition').each(function () { - $(this).find(':input').each(function () { - addInputNamePrefix($(this)); - }); - conditionCounter++; - }); + $('ul.conditions li.condition span.field').show(); + $('ul.conditions li.condition select.operator').trigger('change'); + $('ul.conditions li.condition button.remove-item').show(); - $('ul.conditions span.autocomplete').each(function () { - MB.Control.EntityAutocomplete({ 'inputs': $(this) }); + $('ul.conditions li.condition').each(function () { + $(this).find(':input').each(function () { + addInputNamePrefix($(this)); }); + conditionCounter++; + }); + + $('ul.conditions span.autocomplete').each(function () { + MB.Control.EntityAutocomplete({inputs: $(this)}); + }); }); diff --git a/root/static/scripts/common/components/Autocomplete2.js b/root/static/scripts/common/components/Autocomplete2.js new file mode 100644 index 00000000000..8d19c83fb8d --- /dev/null +++ b/root/static/scripts/common/components/Autocomplete2.js @@ -0,0 +1,570 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import partition from 'lodash/partition'; +import unionBy from 'lodash/unionBy'; +import React, {useEffect, useMemo, useReducer, useRef} from 'react'; + +import ENTITIES from '../../../../../entities'; +import useOutsideClickEffect from '../hooks/useOutsideClickEffect'; +import {unwrapNl} from '../i18n'; +import clean from '../utility/clean'; + +import { + HIDE_MENU, + HIGHLIGHT_NEXT_ITEM, + HIGHLIGHT_PREVIOUS_ITEM, + SELECT_HIGHLIGHTED_ITEM, + SHOW_LOOKUP_ERROR, + SHOW_LOOKUP_TYPE_ERROR, + SHOW_MENU, + SHOW_SEARCH_ERROR, + STOP_SEARCH, +} from './Autocomplete2/actions'; +import { + ARIA_LIVE_STYLE, + DISPLAY_NONE_STYLE, + EMPTY_ARRAY, + MBID_REGEXP, + MENU_ITEMS, + SEARCH_PLACEHOLDERS, +} from './Autocomplete2/constants'; +import reducer from './Autocomplete2/reducer'; +import type { + Actions, + Item, + Props, + Instance, + State, +} from './Autocomplete2/types'; + +const INITIAL_STATE: State = { + highlightedIndex: 0, + indexedSearch: true, + inputTimeout: null, + inputValue: '', + isOpen: false, + items: EMPTY_ARRAY, + page: 1, + pendingSearch: null, + selectedItem: null, + statusMessage: '', + xhr: null, +}; + +/* + * If the autocomplete is provided an `items` prop, it's assumed that it + * contains the complete list of searchable options. In that case, we filter + * them based on a simple substring match via `doFilter`. + */ +function doFilter( + parent: Instance, + items: $ReadOnlyArray, + searchTerm: string, +) { + let results = items; + let resultCount = results.length; + + if (searchTerm) { + results = items.filter(item => ( + unwrapNl(item.name) + .toLowerCase() + .includes(searchTerm.toLowerCase()) + )); + resultCount = results.length; + if (!resultCount) { + results.push(MENU_ITEMS.NO_RESULTS); + } + } + + parent.dispatch({ + items: results, + page: 1, + resultCount, + type: 'show-results', + }); +} + +/* + * `doLookup` performs a direct MBID lookup (via /ws/js/entity) in case the + * the user pastes an MBID or some URL containing one. + */ +function doLookup(parent: Instance, mbid: string) { + parent.stopRequests(); + + const lookupXhr = new XMLHttpRequest(); + parent.xhr = lookupXhr; + + lookupXhr.addEventListener('load', () => { + parent.xhr = null; + + if (lookupXhr.status !== 200) { + parent.dispatch(SHOW_LOOKUP_ERROR); + return; + } + + const {entityType, onTypeChange} = parent.props; + const entity = JSON.parse(lookupXhr.responseText); + + if (entity.entityType !== entityType && + (!onTypeChange || onTypeChange(entity.entityType) === false)) { + parent.dispatch(SHOW_LOOKUP_TYPE_ERROR); + } else { + parent.dispatch({item: entity, type: 'select-item'}); + } + }); + + lookupXhr.open('GET', '/ws/js/entity/' + mbid); + lookupXhr.send(); +} + +/* + * `doSearch` performs a direct or indexed search (via /ws/js). This is the + * default behavior if no `items` prop is given. + */ +function doSearch(instance: Instance) { + const searchXhr = new XMLHttpRequest(); + instance.xhr = searchXhr; + + searchXhr.addEventListener('load', () => { + instance.xhr = null; + + if (searchXhr.status !== 200) { + instance.dispatch(SHOW_SEARCH_ERROR); + return; + } + + const actions = []; + let newItems = JSON.parse(searchXhr.responseText); + const pager = newItems.pop(); + const newPage = parseInt(pager.current, 10); + const totalPages = parseInt(pager.pages, 10); + + if (newItems.length) { + if (newPage < totalPages) { + actions.push(MENU_ITEMS.SHOW_MORE); + } + } else if (newPage === 1) { + actions.push(MENU_ITEMS.NO_RESULTS); + } + + actions.push(instance.state.indexedSearch + ? MENU_ITEMS.TRY_AGAIN_DIRECT + : MENU_ITEMS.TRY_AGAIN_INDEXED); + + const [, prevItems] = partition(instance.state.items, hasAction); + + newItems = newPage > 1 + ? unionBy(prevItems, newItems, x => x.id) + : newItems; + + instance.dispatch({ + items: newItems.concat(actions), + page: newPage, + resultCount: newItems.length, + type: 'show-results', + }); + }); + + const url = ( + '/ws/js/' + ENTITIES[instance.props.entityType].url + + '/?q=' + encodeURIComponent(instance.state.inputValue || '') + + '&page=' + String(instance.state.page) + + '&direct=' + (instance.state.indexedSearch ? 'false' : 'true') + ); + + searchXhr.open('GET', url); + searchXhr.send(); +} + +function doSearchOrFilter(instance: Instance, searchTerm: string) { + if (instance.props.items) { + doFilter(instance, instance.props.items, searchTerm); + } else if (searchTerm) { + instance.dispatch({searchTerm, type: 'search-after-timeout'}); + } +} + +function findItem(instance: Instance, itemId: string) { + const items = instance.state.items; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (String(item.id) === itemId) { + return [i, item]; + } + } + return [-1, null]; +} + +const hasOwnProperty = Object.prototype.hasOwnProperty; + +function hasAction(x: Item) { + return hasOwnProperty.call(x, 'action'); +} + +function setScrollPosition(menuId: string, siblingAccessor: string) { + const menu = document.getElementById(menuId); + if (!menu) { + return; + } + // $FlowFixMe + const item = menu.querySelector('li[aria-selected=true]')[siblingAccessor]; + if (!item) { + return; + } + const position = + (item.offsetTop + (item.offsetHeight / 2)) - menu.scrollTop; + const middle = menu.offsetHeight / 2; + if (position < middle) { + menu.scrollTop -= (middle - position); + } + if (position > middle) { + menu.scrollTop += (position - middle); + } +} + +export default function Autocomplete2(props: Props) { + const {entityType, id} = props; + + const [state, dispatch] = useReducer( + reducer, + INITIAL_STATE, + ); + + let activeElementBeforeItemClick = null; + let itemClickInProgress = false; + let prevChildren = null; + + /* + * The "instance" below, including associated methods, is created only once + * on initial render. This avoids unnecessary allocations and closures for + * each render by reusing the previous ones. It's important to note, + * however, that the `state` variable above (and `props` for that matter) + * should not be accessed in the closures below unless you know what you're + * doing (because it'll refer to the *original* state on the initial + * render). If access to the current state is needed in a callback or event + * handler, `instance.state` should be used instead, as it'll refer to the + * state of the last render. + */ + const instanceRef = useRef(null); + const instance: Instance = instanceRef.current || (instanceRef.current = { + container: {current: null}, + + dispatch, + + handleBlur() { + if (itemClickInProgress) { + return; + } + setTimeout(() => { + const container = instance.container.current; + if (container && !container.contains(document.activeElement)) { + instance.stopRequests(); + if (instance.state.isOpen) { + dispatch(HIDE_MENU); + } + } + }, 10); + }, + + handleButtonClick() { + const state = instance.state; + + instance.stopRequests(); + + if (state.isOpen) { + instance.dispatch(HIDE_MENU); + } else if (state.items.length) { + instance.dispatch(SHOW_MENU); + } else if (state.inputValue) { + doSearchOrFilter(instance, state.inputValue); + } + }, + + handleInputChange(event: SyntheticKeyboardEvent) { + const newInputValue = event.currentTarget.value; + const newCleanInputValue = clean(newInputValue); + + dispatch({type: 'type-value', value: newInputValue}); + + const mbidMatch = newCleanInputValue.match(MBID_REGEXP); + if (mbidMatch) { + doLookup(instance, mbidMatch[0]); + } else if (clean(instance.state.inputValue) !== newCleanInputValue) { + instance.stopRequests(); + doSearchOrFilter(instance, newCleanInputValue); + } + }, + + handleInputKeyDown( + event: SyntheticKeyboardEvent, + ) { + const isInputNonEmpty = !!instance.state.inputValue; + const isMenuNonEmpty = instance.state.items.length > 0; + const isMenuOpen = instance.state.isOpen; + const menuId = instance.props.id + '-menu'; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + + if (isMenuOpen) { + setScrollPosition(menuId, 'nextElementSibling'); + dispatch(HIGHLIGHT_NEXT_ITEM); + } else if (isMenuNonEmpty) { + dispatch(SHOW_MENU); + } else if (isInputNonEmpty) { + doSearchOrFilter(instance, instance.state.inputValue); + } + break; + + case 'ArrowUp': + if (isMenuOpen) { + event.preventDefault(); + setScrollPosition(menuId, 'previousElementSibling'); + dispatch(HIGHLIGHT_PREVIOUS_ITEM); + } + break; + + case 'Enter': + if (isMenuOpen) { + event.preventDefault(); + dispatch(SELECT_HIGHLIGHTED_ITEM); + } + break; + + case 'Escape': + instance.stopRequests(); + if (isMenuOpen) { + dispatch(HIDE_MENU); + } + break; + } + }, + + handleItemClick(event: SyntheticMouseEvent) { + const active = activeElementBeforeItemClick; + if (active) { + setTimeout(() => { + active.focus(); + itemClickInProgress = false; + }, 10); + activeElementBeforeItemClick = null; + } + const [, item] = findItem(instance, event.currentTarget.dataset.itemId); + item && instance.dispatch({item, type: 'select-item'}); + }, + + handleItemMouseDown() { + activeElementBeforeItemClick = document.activeElement; + itemClickInProgress = true; + }, + + handleItemMouseOver(event: SyntheticMouseEvent) { + const [index] = findItem(instance, event.currentTarget.dataset.itemId); + index >= 0 && instance.dispatch({index, type: 'highlight-item'}); + }, + + handleOuterClick() { + instance.stopRequests(); + dispatch(HIDE_MENU); + }, + + inputTimeout: null, + + props, + + /* + * This needs to accept parameters for the state at the time of render. + * (The state outside this closure refers to the original component state, + * because the closure was created on the initial render; instance.state + * refers to the state of the last render, not the current one.) + */ + renderItems(items, highlightedIndex, selectedItem) { + const children = new Map(); + + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const isHighlighted = index === highlightedIndex; + const isSelected = !!(selectedItem && item.id === selectedItem.id); + const itemMapKey = item.id + ',' + + String(isHighlighted) + ',' + + String(isSelected); + const style = item.level + ? {paddingLeft: String((item.level - 1) * 8) + 'px'} + : null; + + children.set( + itemMapKey, + (prevChildren && prevChildren.get(itemMapKey)) || ( +
  • + {unwrapNl(item.name)} +
  • + ), + ); + } + + prevChildren = children; + return children; + }, + + setContainer(node) { + instance.container.current = node; + }, + + state, + + stopRequests() { + if (instance.xhr) { + instance.xhr.abort(); + instance.xhr = null; + } + + if (instance.inputTimeout) { + clearTimeout(instance.inputTimeout); + instance.inputTimeout = null; + } + + dispatch(STOP_SEARCH); + }, + + xhr: null, + }); + + const activeDescendant = state.items.length + ? `${id}-item-${state.items[state.highlightedIndex].id}` + : null; + const inputId = `${id}-input`; + const labelId = `${id}-label`; + const menuId = `${id}-menu`; + const statusId = `${id}-status`; + + const menuItems = useMemo(() => instance.renderItems( + state.items, + state.highlightedIndex, + state.selectedItem, + ), [state.items, state.highlightedIndex, state.selectedItem]); + + useOutsideClickEffect( + instance.container, + instance.handleOuterClick, + instance.stopRequests, + ); + + useEffect(() => { + /* + * This gives event handlers access to the props and state of the most + * recent render. `useEffect` runs after a completed render; this does + * *not* allow access to props or state from any current, "in progress" + * render. This should generally be fine for events, since they're + * triggered through what's visible on screen, but care is advised. + */ + instance.props = props; + instance.state = state; + + if ( + state.pendingSearch && + !instance.inputTimeout && + !instance.xhr && + !props.items + ) { + instance.inputTimeout = setTimeout(() => { + instance.inputTimeout = null; + + // Check if the input value has changed before proceeding. + if (clean(state.pendingSearch) === clean(instance.state.inputValue)) { + doSearch(instance); + } + }, 300); + } + }); + + return ( +
    + +
    + +
    + +
      + {Array.from(menuItems.values())} +
    + +
    + {state.statusMessage} +
    +
    + ); +} diff --git a/root/static/scripts/common/components/Autocomplete2/actions.js b/root/static/scripts/common/components/Autocomplete2/actions.js new file mode 100644 index 00000000000..233fe9fa95c --- /dev/null +++ b/root/static/scripts/common/components/Autocomplete2/actions.js @@ -0,0 +1,62 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +export const HIDE_MENU = { + type: 'set-menu-visibility', + value: false, +}; + +export const HIGHLIGHT_NEXT_ITEM = { + type: 'highlight-next-item', +}; + +export const HIGHLIGHT_PREVIOUS_ITEM = { + type: 'highlight-previous-item', +}; + +export const NOOP = { + type: 'noop', +}; + +export const SELECT_HIGHLIGHTED_ITEM = { + type: 'select-highlighted-item', +}; + +export const SHOW_MENU = { + type: 'set-menu-visibility', + value: true, +}; + +export const SHOW_MORE_RESULTS = { + type: 'show-more-results', +}; + +export const SEARCH_AGAIN = { + type: 'search-after-timeout', +}; + +export const SHOW_LOOKUP_ERROR = { + type: 'show-lookup-error', +}; + +export const SHOW_LOOKUP_TYPE_ERROR = { + type: 'show-lookup-type-error', +}; + +export const SHOW_SEARCH_ERROR = { + type: 'show-search-error', +}; + +export const STOP_SEARCH = { + type: 'stop-search', +}; + +export const TOGGLE_INDEXED_SEARCH = { + type: 'toggle-indexed-search', +}; diff --git a/root/static/scripts/common/components/Autocomplete2/constants.js b/root/static/scripts/common/components/Autocomplete2/constants.js new file mode 100644 index 00000000000..3bc92cd2cb7 --- /dev/null +++ b/root/static/scripts/common/components/Autocomplete2/constants.js @@ -0,0 +1,100 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import {bracketedText} from '../../utility/bracketed'; + +import { + NOOP, + SEARCH_AGAIN, + SHOW_MORE_RESULTS, + TOGGLE_INDEXED_SEARCH, +} from './actions'; +import type {Item} from './types'; + +export const ARIA_LIVE_STYLE = Object.seal({ + height: '1px', + left: '-1px', + overflow: 'hidden', + position: 'absolute', + top: '-1px', + width: '1px', +}); + +export const DISPLAY_NONE_STYLE = {display: 'none'}; + +export const EMPTY_ARRAY: $ReadOnlyArray = Object.freeze([]); + +export const MBID_REGEXP = /[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}/; + +export const MENU_ITEMS = { + ERROR_TRY_AGAIN_DIRECT: { + action: TOGGLE_INDEXED_SEARCH, + id: 'error-try-again-direct', + name: N_l('Try again with direct search.'), + }, + ERROR_TRY_AGAIN_INDEXED: { + action: TOGGLE_INDEXED_SEARCH, + id: 'error-try-again-indexed', + name: N_l('Try again with indexed search.'), + }, + LOOKUP_ERROR: { + action: NOOP, + id: 'lookup-error', + name: N_l('An error occurred while looking up the MBID you entered.'), + }, + LOOKUP_TYPE_ERROR: { + action: NOOP, + id: 'lookup-type-error', + name: N_l('The type of entity you pasted isn’t supported here.'), + }, + NO_RESULTS: { + action: NOOP, + id: 'no-results', + name: () => bracketedText(l('No results')), + }, + SEARCH_ERROR: { + action: SEARCH_AGAIN, + id: 'try-again', + name: N_l('An error occurred while searching. Click here to try again.'), + separator: true, + }, + SHOW_MORE: { + action: SHOW_MORE_RESULTS, + id: 'show-more', + name: N_l('Show more...'), + separator: true, + }, + TRY_AGAIN_DIRECT: { + action: TOGGLE_INDEXED_SEARCH, + id: 'try-again-direct', + name: N_l('Not found? Try again with direct search.'), + separator: true, + }, + TRY_AGAIN_INDEXED: { + action: TOGGLE_INDEXED_SEARCH, + id: 'try-again-indexed', + name: N_l('Slow? Switch back to indexed search.'), + separator: true, + }, +}; + +export const SEARCH_PLACEHOLDERS = { + area: N_l('Search for an area'), + artist: N_l('Search for an artist'), + editor: N_l('Search for an editor'), + event: N_l('Search for an event'), + instrument: N_l('Search for an instrument'), + label: N_l('Search for a label'), + place: N_l('Search for a place'), + recording: N_l('Search for a recording'), + release: N_l('Search for a release'), + release_group: N_l('Search for a release group'), + series: N_l('Search for a series'), + work: N_l('Search for a work'), +}; diff --git a/root/static/scripts/common/components/Autocomplete2/reducer.js b/root/static/scripts/common/components/Autocomplete2/reducer.js new file mode 100644 index 00000000000..ad3a1471ca0 --- /dev/null +++ b/root/static/scripts/common/components/Autocomplete2/reducer.js @@ -0,0 +1,235 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import {unwrapNl} from '../../i18n'; + +import { + SEARCH_AGAIN, + TOGGLE_INDEXED_SEARCH, +} from './actions'; +import {EMPTY_ARRAY, MENU_ITEMS} from './constants'; +import type { + ActionItem, + Actions, + Item, + SearchAction, + State, +} from './types'; + +const hasOwnProperty = Object.prototype.hasOwnProperty; + +function initSearch(state: State, action: SearchAction) { + if (action.indexed !== undefined) { + state.indexedSearch = action.indexed; + } + + state.statusMessage = ''; + + let searchTerm; + if (hasOwnProperty.call(action, 'searchTerm')) { + searchTerm = action.searchTerm; + } else { + /* + * If we didn't provide a searchTerm, then that indicates we want to + * search again with the text we already have. + */ + searchTerm = state.inputValue; + } + + if (!searchTerm) { + return; + } + + state.pendingSearch = searchTerm; +} + +function resetPage(state: State) { + state.highlightedIndex = 0; + state.isOpen = false; + state.items = EMPTY_ARRAY; + state.page = 1; +} + +function selectItem(state: State, item: Item) { + if (item.action) { + runReducer(state, item.action); + return; + } + + state.isOpen = false; + state.selectedItem = item; + state.statusMessage = item.name; + + if (item.name !== state.inputValue) { + state.inputValue = item.name; + resetPage(state); + } +} + +function selectItemAtIndex(state: State, index: number) { + const item = state.items[index]; + if (item) { + selectItem(state, item); + } +} + +function showError(state: State, error: ActionItem) { + state.highlightedIndex = 0; + state.isOpen = true; + state.items = [error]; + state.statusMessage = unwrapNl(error.name); +} + +// `runReducer` should only be run on a copy of the existing state. +function runReducer( + state: State, + action: Actions, +) { + switch (action.type) { + case 'highlight-item': { + state.highlightedIndex = action.index; + break; + } + + case 'highlight-next-item': { + let index = state.highlightedIndex + 1; + if (index >= state.items.length) { + index = 0; + } + state.highlightedIndex = index; + break; + } + + case 'highlight-previous-item': { + let index = state.highlightedIndex - 1; + if (index < 0) { + index = state.items.length - 1; + } + state.highlightedIndex = index; + break; + } + + case 'noop': + break; + + case 'search-after-timeout': + state.page = 1; + initSearch(state, action); + break; + + case 'select-highlighted-item': + selectItemAtIndex(state, state.highlightedIndex); + break; + + case 'select-item': + selectItem(state, action.item); + break; + + case 'set-menu-visibility': + state.isOpen = action.value; + break; + + case 'show-lookup-error': { + showError(state, MENU_ITEMS.LOOKUP_ERROR); + break; + } + + case 'show-lookup-type-error': { + showError(state, MENU_ITEMS.LOOKUP_TYPE_ERROR); + break; + } + + case 'show-results': { + const {items, page, resultCount} = action; + + if (page === 1) { + state.highlightedIndex = 0; + } else if (state.highlightedIndex >= items.length) { + state.highlightedIndex = items.length - 1; + } + + const highlightedItem = items[state.highlightedIndex]; + + state.isOpen = true; + state.items = items; + state.page = page; + state.pendingSearch = null; + state.statusMessage = items.length ? ( + (highlightedItem + ? unwrapNl(highlightedItem.name) + '. ' + : '') + + texp.ln( + `1 result found. + Press enter to select, or + use the up and down arrow keys to navigate.`, + `{n} results found. + Press enter to select, or + use the up and down arrow keys to navigate.`, + items.length, + {n: resultCount}, + ) + ) : ''; + break; + } + + case 'show-search-error': { + showError(state, MENU_ITEMS.SEARCH_ERROR); + state.items = state.items.concat( + state.indexedSearch + ? MENU_ITEMS.ERROR_TRY_AGAIN_DIRECT + : MENU_ITEMS.ERROR_TRY_AGAIN_INDEXED + ); + state.pendingSearch = null; + break; + } + + case 'show-more-results': + state.page++; + initSearch(state, SEARCH_AGAIN); + break; + + case 'stop-search': + state.pendingSearch = null; + break; + + case 'toggle-indexed-search': + state.indexedSearch = !state.indexedSearch; + state.page = 1; + initSearch(state, SEARCH_AGAIN); + break; + + case 'type-value': + state.inputValue = action.value; + state.pendingSearch = null; + state.selectedItem = null; + state.statusMessage = ''; + + if (!state.inputValue) { + resetPage(state); + } + + break; + + default: + throw new Error('Unknown action: ' + action.type); + } +} + +export default function reducer( + state: State, + action: Actions, +) { + if (action.type === 'noop') { + return state; + } + + const nextState = {...state}; + runReducer(nextState, action); + return nextState; +} diff --git a/root/static/scripts/common/components/Autocomplete2/types.js b/root/static/scripts/common/components/Autocomplete2/types.js new file mode 100644 index 00000000000..52781095771 --- /dev/null +++ b/root/static/scripts/common/components/Autocomplete2/types.js @@ -0,0 +1,99 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +export type Instance = {| + container: {|current: HTMLElement | null|}, + dispatch: (Actions) => void, + handleBlur: () => void, + handleButtonClick: () => void, + handleInputChange: (SyntheticKeyboardEvent) => void, + handleInputKeyDown: (SyntheticKeyboardEvent) => void, + handleItemClick: (SyntheticMouseEvent) => void, + handleItemMouseDown: () => void, + handleItemMouseOver: (SyntheticMouseEvent) => void, + handleOuterClick: () => void, + inputTimeout: TimeoutID | null, + props: Props, + renderItems: ( + $ReadOnlyArray, + number, + EntityItem | null, + ) => Map>, + setContainer: (HTMLDivElement | null) => void, + state: State, + stopRequests: () => void, + xhr: XMLHttpRequest | null, +|}; + +export type Props = { + entityType: string, + id: string, + items?: $ReadOnlyArray, + onChange: () => void, + onTypeChange?: (string) => boolean, + placeholder?: string, + width?: string, +}; + +export type State = { + highlightedIndex: number, + indexedSearch: boolean, + inputValue: string, + isOpen: boolean, + items: $ReadOnlyArray, + page: number, + pendingSearch: string | null, + selectedItem: EntityItem | null, + statusMessage: string, +}; + +export type SearchAction = {| + +indexed?: boolean, + +searchTerm?: string, + +type: 'search-after-timeout', +|}; + +export type Actions = + | SearchAction + | {| +index: number, +type: 'highlight-item' |} + | {| +type: 'highlight-next-item' |} + | {| +type: 'highlight-previous-item' |} + | {| +type: 'noop' |} + | {| +type: 'select-highlighted-item' |} + | {| +item: Item, +type: 'select-item' |} + | {| +type: 'set-menu-visibility', +value: boolean |} + | {| + +items: $ReadOnlyArray, + +page: number, + +resultCount: number, + +type: 'show-results', + |} + | {| +type: 'show-lookup-error' |} + | {| +type: 'show-lookup-type-error' |} + | {| +type: 'show-more-results' |} + | {| +type: 'show-search-error' |} + | {| +type: 'stop-search' |} + | {| +type: 'toggle-indexed-search' |} + | {| +type: 'type-value', +value: string |} + ; + +export type ActionItem = {| + +action: Actions, + +id: number | string, + +name: string | () => string, + +separator?: boolean, +|}; + +export type EntityItem = {| + +id: number | string, + +level?: number, + +name: string, +|}; + +export type Item = ActionItem | EntityItem; diff --git a/root/static/scripts/common/components/EntityLink.js b/root/static/scripts/common/components/EntityLink.js index 694fee6d419..f168877f4dc 100644 --- a/root/static/scripts/common/components/EntityLink.js +++ b/root/static/scripts/common/components/EntityLink.js @@ -103,10 +103,11 @@ type EntityLinkProps = { +content?: React.Node, +entity: CoreEntityT | CollectionT, +hover?: string, - +showEditsPending?: boolean, - +showEventDate?: boolean, + +nameVariation?: boolean, +showDeleted?: boolean, +showDisambiguation?: boolean, + +showEditsPending?: boolean, + +showEventDate?: boolean, +subPath?: string, // ...anchorProps @@ -121,10 +122,11 @@ const EntityLink = ({ content, entity, hover, - showEditsPending = true, - showEventDate = true, + nameVariation, showDeleted = true, showDisambiguation, + showEditsPending = true, + showEventDate = true, subPath, ...anchorProps }: EntityLinkProps) => { @@ -158,7 +160,6 @@ const EntityLink = ({ } let href = entityHref(entity, subPath); - let nameVariation; let infoLink; if (entity.entityType === 'url' && !hasCustomContent) { @@ -170,11 +171,13 @@ const EntityLink = ({ // TODO: support name variations for all entity types? if (!subPath && (entity.entityType === 'artist' || entity.entityType === 'recording')) { - nameVariation = ( - React.isValidElement(content) - ? reactTextContent(content) - : content - ) !== entity.name; + if (nameVariation === undefined) { + nameVariation = ( + React.isValidElement(content) + ? reactTextContent(content) + : content + ) !== entity.name; + } if (nameVariation) { if (hover) { diff --git a/root/static/scripts/common/components/Filter.js b/root/static/scripts/common/components/Filter.js new file mode 100644 index 00000000000..dbeca683577 --- /dev/null +++ b/root/static/scripts/common/components/Filter.js @@ -0,0 +1,75 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import React, {useState} from 'react'; + +import hydrate from '../../../../utility/hydrate'; +import setCookie from '../utility/setCookie'; + +import FilterForm, {type FilterFormT} from './FilterForm'; + +type Props = {| + +ajaxFormUrl: string, + +initialFilterForm: ?FilterFormT, +|}; + +const Filter = ({ajaxFormUrl, initialFilterForm}: Props) => { + const [filterForm, setFilterForm] = useState( + initialFilterForm, + ); + const [hidden, setHidden] = useState(!initialFilterForm); + + function show() { + setHidden(false); + setCookie('filter', '1'); + } + + function hide() { + setHidden(true); + setCookie('filter', ''); + } + + function onButtonClick(event) { + event.preventDefault(); + + if (filterForm) { + hidden ? show() : hide(); + } else { + const $ = require('jquery'); + + $.getJSON(ajaxFormUrl, function (data) { + setFilterForm(data); + setHidden(false); + }); + } + } + + return ( + <> + + + {(filterForm && !hidden) ? ( + + ) : null} + + ); +}; + +export default hydrate('div.filter', Filter); diff --git a/root/static/scripts/common/components/FilterForm.js b/root/static/scripts/common/components/FilterForm.js new file mode 100644 index 00000000000..b2074b7609c --- /dev/null +++ b/root/static/scripts/common/components/FilterForm.js @@ -0,0 +1,125 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import SelectField from '../../../../components/SelectField'; +import {addColonText} from '../i18n/addColon'; + +export type FilterFormT = + & FormT<{| + +artist_credit_id: ReadOnlyFieldT, + +name: ReadOnlyFieldT, + +type_id?: ReadOnlyFieldT, + |}> + & { + +entity_type: 'recording' | 'release' | 'release_group', + +options_artist_credit_id: SelectOptionsT, + +options_type_id?: SelectOptionsT, + }; + +type Props = {| + +form: FilterFormT, +|}; + +function getSubmitText(type: string) { + switch (type) { + case 'recording': + return l('Filter recordings'); + case 'release': + return l('Filter releases'); + case 'release_group': + return l('Filter release groups'); + } + return ''; +} + +const FilterForm = ({form}: Props) => { + const typeIdField = form.field.type_id; + const typeIdOptions = form.options_type_id; + const artistCreditIdField = form.field.artist_credit_id; + const artistCreditIdOptions = form.options_artist_credit_id; + + return ( +
    +
    +
    {l('#')} {l('Title')} {l('Length')}{l('Track Artist')} {l('Release Title')} {l('Release Artist')} {l('Date')}
    + {status ? lp_attributes(status.name, 'release_status') : l('(unknown)') @@ -91,6 +94,14 @@ const RecordingAppearancesTable = ({ {isolateText(track.name)} {formatTrackLength(track.length)} + +
    + + {typeIdField && typeIdOptions ? ( + + + + + ) : null} + + {artistCreditIdField && artistCreditIdOptions ? ( + + + + + ) : null} + + + + + + + + + +
    + {addColonText(l('Type'))} + + +
    + {l('Artist credit:')} + + +
    {addColonText(l('Name'))} + +
    + + + + + +
    + + + ); +}; + +export default FilterForm; diff --git a/root/static/scripts/common/components/FingerprintTable.js b/root/static/scripts/common/components/FingerprintTable.js new file mode 100644 index 00000000000..8eac1381f9d --- /dev/null +++ b/root/static/scripts/common/components/FingerprintTable.js @@ -0,0 +1,90 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import React, {useEffect, useState} from 'react'; + +import loopParity from '../../../../utility/loopParity'; +import hydrate from '../../../../utility/hydrate'; + +function orderTracks(a, b) { + if (a.disabled && !b.disabled) { + return 1; + } + if (!a.disabled && b.disabled) { + return -1; + } + return a.id - b.id; +} + +const FingerprintTable = ({recording}: {recording: RecordingT}) => { + const [tracks, setTracks] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + // We ensure fetch only runs client-side since it's not in node + useEffect(() => { + fetch( + '//api.acoustid.org/v2/track/list_by_mbid' + + `?format=json&disabled=1&jsoncallback=?&mbid=${recording.gid}`, + ).then( + function (response) { + return response.json(); + }, + ).then( + function (data) { + data.tracks.sort(orderTracks); + setTracks(data.tracks); + setIsLoaded(true); + }, + ); + }, []); + + return ( + tracks && tracks.length ? ( + + + + + + + + + {tracks.map((track, index) => ( + + + + + ))} + +
    {'AcoustID'}{l('Actions')}
    + + + {track.id} + + + + + {track.disabled ? l('Link') : l('Unlink')} + +
    + ) : isLoaded ? ( +

    {l('This recording does not have any associated AcoustIDs')}

    + ) :

    {l('Loading...')}

    + ); +}; + +export default hydrate<{recording: RecordingT}>('div.acoustid-fingerprints', FingerprintTable); diff --git a/root/static/scripts/common/hooks/useOutsideClickEffect.js b/root/static/scripts/common/hooks/useOutsideClickEffect.js new file mode 100644 index 00000000000..a1e4a0dd9bb --- /dev/null +++ b/root/static/scripts/common/hooks/useOutsideClickEffect.js @@ -0,0 +1,47 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import noop from 'lodash/noop'; +import {useEffect} from 'react'; + +const EMPTY_ARRAY = []; + +const TARGET_REFS = new Map(); + +if (typeof document !== 'undefined') { + document.addEventListener('mouseup', function (event: MouseEvent) { + for (const [ref, action] of TARGET_REFS) { + const target = ref.current; + // $FlowFixMe + if (target && !target.contains(event.target)) { + action(); + } + } + }); +} + +export default function useOutsideClickEffect( + targetRef: {|current: HTMLElement | null|}, + action: () => void, + cleanup?: () => void, +) { + if (typeof document === 'undefined') { + return; + } + + useEffect(() => { + TARGET_REFS.set(targetRef, action); + return () => { + TARGET_REFS.delete(targetRef); + if (cleanup) { + cleanup(); + } + }; + }, EMPTY_ARRAY); +} diff --git a/root/static/scripts/common/i18n.js b/root/static/scripts/common/i18n.js index 8f7e0ff5cf8..352f94dad25 100644 --- a/root/static/scripts/common/i18n.js +++ b/root/static/scripts/common/i18n.js @@ -23,9 +23,9 @@ export const N_lp = (key: string, context: string) => ( () => lp(key, context) ); -export const unwrapNl = ( - value: React$MixedElement | string | (() => React$MixedElement | string), -) => ( +export const unwrapNl = ( + value: T | (() => T), +): T => ( typeof value === 'function' ? value() : value ); diff --git a/root/static/scripts/common/i18n/expand2react.js b/root/static/scripts/common/i18n/expand2react.js index 7ead2d6af68..fa9a1a3a0a9 100644 --- a/root/static/scripts/common/i18n/expand2react.js +++ b/root/static/scripts/common/i18n/expand2react.js @@ -61,8 +61,26 @@ function handleTextContentText(text: string) { return he.decode(text); } +/* + * `reactTextContentHook`, when overridden from the outside, allows + * customizing each bit of free text content in the expanded string. This can + * be used, for example, to wrap them in spans to apply a certain style. + * (This is how our relationship edit diff display works.) + * + * The use of the word "hooks" here is completely unrelated to the React + * concept with the same name. + */ +export const hooks: { + reactTextContentHook: ((Expand2ReactOutput) => Expand2ReactOutput) | null, +} = { + reactTextContentHook: null, +}; + function handleTextContentReact(text: string) { const replacement = state.replacement; + const hook = hooks.reactTextContentHook; + let content; + if (gotMatch(replacement) && percentSign.test(text)) { const parts = text.split(percentSign); const result: Array = []; @@ -75,12 +93,15 @@ function handleTextContentReact(text: string) { } } if (typeof replacement === 'string') { - return result.join(''); + content = result.join(''); + } else { + content = React.createElement(React.Fragment, null, ...result); } - return React.createElement(React.Fragment, null, ...result); } else { - return he.decode(text); + content = he.decode(text); } + + return hook ? hook(content) : content; } const parseRootTextContent = createTextContentParser( diff --git a/root/static/scripts/common/linkedEntities.js b/root/static/scripts/common/linkedEntities.js index d6fc301d870..1656db6a50e 100644 --- a/root/static/scripts/common/linkedEntities.js +++ b/root/static/scripts/common/linkedEntities.js @@ -99,7 +99,12 @@ const linkedEntities/*: LinkedEntities */ = Object.create(Object.seal({ setLinkedEntities(update/*: ?LinkedEntities */) { for (const key of Object.keys(linkedEntities)) { + // $FlowFixMe delete linkedEntities[key]; + /* + * The above line is deleting the own property only, not the one on the + * prototype. However, Flow thinks it'll make the object key undefined. + */ } if (update) { Object.assign(linkedEntities, update); diff --git a/root/static/scripts/common/utility/clean.js b/root/static/scripts/common/utility/clean.js index e0be7eea448..c6a622d3e65 100644 --- a/root/static/scripts/common/utility/clean.js +++ b/root/static/scripts/common/utility/clean.js @@ -1,3 +1,5 @@ -export default function clean(str) { +// @flow + +export default function clean(str: ?string) { return String(str || '').trim().replace(/\s+/g, ' '); -}; +} diff --git a/root/static/scripts/common/utility/displayLinkAttribute.js b/root/static/scripts/common/utility/displayLinkAttribute.js index 31926e7d9cb..68d6f87b750 100644 --- a/root/static/scripts/common/utility/displayLinkAttribute.js +++ b/root/static/scripts/common/utility/displayLinkAttribute.js @@ -18,7 +18,7 @@ import clean from '../utility/clean'; function _displayLinkAttribute( attribute: LinkAttrT, getAttributeValue: (LinkAttrTypeT) => T, - l: (string, VarArgsObject) => T, + l: (string, VarArgsObject) => T, ): T { const type = linkedEntities.link_attribute_type[attribute.typeID]; let value = getAttributeValue(type); diff --git a/root/static/scripts/common/utility/reactTextContent.js b/root/static/scripts/common/utility/reactTextContent.js index f1902e0df1f..adf510bb1d8 100644 --- a/root/static/scripts/common/utility/reactTextContent.js +++ b/root/static/scripts/common/utility/reactTextContent.js @@ -1,4 +1,5 @@ /* + * @flow * Copyright (C) 2015 MetaBrainz Foundation * * This file is part of MusicBrainz, the open internet music database, @@ -6,23 +7,38 @@ * later version: http://www.gnu.org/licenses/gpl-2.0.txt */ +import * as React from 'react'; + import nonEmpty from './nonEmpty'; -function reactTextContent(reactElement, previous = '') { - if (!nonEmpty(reactElement.props)) { +export default function reactTextContent( + reactElement: React.Node, + previous: string = '', +): string { + if (!React.isValidElement(reactElement)) { + return previous + String(reactElement); + } + + const props = ((reactElement: any): React.Element).props; + + if (!nonEmpty(props)) { return previous; } - let children = reactElement.props.children; + let children = props.children; if (nonEmpty(children)) { if (Array.isArray(children)) { - return children.reduce(reactTextContent, previous); + for (let i = 0; i < children.length; i++) { + previous = reactTextContent(children[i], previous); + } + return previous; + } + if (React.isValidElement(children)) { + return reactTextContent(children, previous); } return previous + children; } return previous; } - -export default reactTextContent; diff --git a/root/static/scripts/edit/components/edit/Diff.js b/root/static/scripts/edit/components/edit/Diff.js new file mode 100644 index 00000000000..506770dc042 --- /dev/null +++ b/root/static/scripts/edit/components/edit/Diff.js @@ -0,0 +1,51 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import {INSERT, DELETE} from '../../utility/editDiff'; + +import DiffSide from './DiffSide'; + +export type DiffProps = {| + +label: string, + +newText: string, + +oldText: string, +|}; + +type Props = {| + ...DiffProps, + +split: string, +|}; + +const Diff = ({label, newText, oldText, split = ''}: Props) => ( + oldText === newText ? null : ( + + {label} + + + + + + + + ) +); + +export default Diff; diff --git a/root/static/scripts/edit/components/edit/DiffSide.js b/root/static/scripts/edit/components/edit/DiffSide.js new file mode 100644 index 00000000000..b1998044c77 --- /dev/null +++ b/root/static/scripts/edit/components/edit/DiffSide.js @@ -0,0 +1,110 @@ +/* + * @flow + * Copyright (C) 2015 Ulrich Klauer + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import editDiff, { + INSERT, + EQUAL, + CHANGE, + CLASS_MAP, + type EditType, +} from '../../utility/editDiff'; + +function splitText(text, split = '') { + if (split !== '') { + split = '(' + split + ')'; + } + // the capture group becomes a separate part of the split output + return text.split(new RegExp(split)); +} + +type Props = {| + +filter: EditType, + +newText: string, + +oldText: string, + +split?: string, +|}; + +const DiffSide = ({filter, newText, oldText, split = ''}: Props) => { + const stack = []; + const splitMatch = new RegExp('^(?:' + split + ')$'); + const diffs = editDiff( + splitText(oldText, split), + splitText(newText, split), + ); + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + const changeType = diff.type; + + if (!(changeType === CHANGE || + changeType === EQUAL || + changeType === filter)) { + continue; + } + + oldText = diff.oldItems.join(''); + newText = diff.newItems.join(''); + + const sameChangeTypeAsBefore = !!( + stack.length && stack[stack.length - 1].type === changeType + ); + + let nextChangeType; + if ((i + 1) < diffs.length) { + nextChangeType = diffs[i + 1].type; + } + + /* + * If an unchanged separator is between two changed sections, mark + * it like its surroundings; it looks nicer to humans when there is + * no gap. + */ + const isSeparatorBetweenChanges = !!( + stack.length && + nextChangeType && + stack[stack.length - 1].type === nextChangeType && + split !== '' && + changeType === EQUAL && + splitMatch.test(newText) + ); + + if (!sameChangeTypeAsBefore && !isSeparatorBetweenChanges) { + // start new section + stack.push({text: '', type: changeType}); + } + + if (changeType === CHANGE) { + stack[stack.length - 1].text += filter === INSERT ? newText : oldText; + } else { + stack[stack.length - 1].text += changeType === INSERT + ? newText : oldText; + } + } + + const children = stack.map(change => { + const className = + change.type === CHANGE ? CLASS_MAP[filter] : CLASS_MAP[change.type]; + return className ? ( + + {change.text} + + ) : change.text; + }); + + return children.length > 1 ? React.createElement( + React.Fragment, + null, + ...children, + ) : (children.length ? children[0] : ''); +}; + +export default DiffSide; diff --git a/root/static/scripts/edit/components/edit/FullChangeDiff.js b/root/static/scripts/edit/components/edit/FullChangeDiff.js new file mode 100644 index 00000000000..7e06813e16c --- /dev/null +++ b/root/static/scripts/edit/components/edit/FullChangeDiff.js @@ -0,0 +1,24 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; + +import {type DiffProps} from './Diff'; + +const FullChangeDiff = ({label, newText, oldText}: DiffProps) => ( + oldText === newText ? null : ( + + {label} + {oldText} + {newText} + + ) +); + +export default FullChangeDiff; diff --git a/root/static/scripts/edit/components/edit/RelationshipDiff.js b/root/static/scripts/edit/components/edit/RelationshipDiff.js new file mode 100644 index 00000000000..74df7a8b884 --- /dev/null +++ b/root/static/scripts/edit/components/edit/RelationshipDiff.js @@ -0,0 +1,190 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import keyBy from 'lodash/keyBy'; +import * as React from 'react'; + +import relationshipDateText from '../../../../../utility/relationshipDateText'; +import {INSERT, DELETE} from '../../utility/editDiff'; +import DescriptiveLink from '../../../common/components/DescriptiveLink'; +import commaList from '../../../common/i18n/commaList'; +import commaOnlyList from '../../../common/i18n/commaOnlyList'; +import expand2react, {hooks as expand2reactHooks} from '../../../common/i18n/expand2react'; +import linkedEntities from '../../../common/linkedEntities'; +import bracketed from '../../../common/utility/bracketed'; +import displayLinkAttribute from '../../../common/utility/displayLinkAttribute'; +import { + getPhraseAndExtraAttributes, + type CachedLinkPhraseData, + type LinkPhraseI18n, + type RelationshipInfoT, +} from '../../utility/linkPhrase'; + +import DiffSide from './DiffSide'; + +const diffOnlyA = content => {content}; +const diffOnlyB = content => {content}; + +type Props = { + newRelationship: RelationshipT, + oldRelationship: RelationshipT, +}; + +const RelationshipDiff = ({ + newRelationship, + oldRelationship, +}: Props) => { + const oldAttrs = oldRelationship.attributes + ? keyBy(oldRelationship.attributes, 'typeID') + : {}; + const newAttrs = newRelationship.attributes + ? keyBy(newRelationship.attributes, 'typeID') + : {}; + + const i18nConfig: LinkPhraseI18n = { + cache: new WeakMap< + RelationshipInfoT, + CachedLinkPhraseData, + >(), + commaList, + commaOnlyList, + displayLinkAttribute: function (attr: LinkAttrT) { + const typeId = attr.typeID; + const display = displayLinkAttribute(attr); + + if (oldAttrs[typeId] && !newAttrs[typeId]) { + return diffOnlyA(display); + } + + if (newAttrs[typeId] && !oldAttrs[typeId]) { + return diffOnlyB(display); + } + + return display; + }, + expand: expand2react, + }; + + const oldLinkType = linkedEntities.link_type[oldRelationship.linkTypeID]; + const newLinkType = linkedEntities.link_type[newRelationship.linkTypeID]; + + /* + * The display data relationships are created with direction=forward, + * so entity0 is always the source. + */ + const oldSource = + linkedEntities[oldLinkType.type0][oldRelationship.entity0_id]; + const newSource = + linkedEntities[newLinkType.type0][newRelationship.entity0_id]; + + const oldTarget = oldRelationship.target; + const newTarget = newRelationship.target; + + const oldSourceLink = ( + + ); + + const newSourceLink = ( + + ); + + const oldTargetLink = ( + + ); + + const newTargetLink = ( + + ); + + let [oldPhrase, oldExtraAttributes] = ['', null]; + let [newPhrase, newExtraAttributes] = ['', null]; + + try { + if (oldLinkType !== newLinkType) { + expand2reactHooks.reactTextContentHook = diffOnlyA; + } + + [oldPhrase, oldExtraAttributes] = getPhraseAndExtraAttributes( + i18nConfig, + oldRelationship, + 'long_link_phrase', + false, /* forGrouping */ + oldSource.id === newSource.id + ? oldSourceLink : diffOnlyA(oldSourceLink), + oldTarget.id === newTarget.id + ? oldTargetLink : diffOnlyA(oldTargetLink), + ); + + if (oldLinkType !== newLinkType) { + expand2reactHooks.reactTextContentHook = diffOnlyB; + } + + [newPhrase, newExtraAttributes] = getPhraseAndExtraAttributes( + i18nConfig, + newRelationship, + 'long_link_phrase', + false, /* forGrouping */ + oldSource.id === newSource.id + ? newSourceLink : diffOnlyB(newSourceLink), + oldTarget.id === newTarget.id + ? newTargetLink : diffOnlyB(newTargetLink), + ); + } finally { + expand2reactHooks.reactTextContentHook = null; + } + + const oldDateText = relationshipDateText(oldRelationship); + const newDateText = relationshipDateText(newRelationship); + + return ( + <> + + {l('Relationship:')} + + {oldPhrase} + {' '} + + {' '} + {bracketed(oldExtraAttributes)} + + + + + {newPhrase} + {' '} + + {' '} + {bracketed(newExtraAttributes)} + + + + ); +}; + +export default RelationshipDiff; diff --git a/root/static/scripts/edit/components/edit/ReleaseEventsDiff.js b/root/static/scripts/edit/components/edit/ReleaseEventsDiff.js new file mode 100644 index 00000000000..bc1b1ac5e2b --- /dev/null +++ b/root/static/scripts/edit/components/edit/ReleaseEventsDiff.js @@ -0,0 +1,132 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import keyBy from 'lodash/keyBy'; +import * as React from 'react'; + +import {l} from '../../../common/i18n'; +import formatDate from '../../../common/utility/formatDate'; +import { + INSERT, + DELETE, + CLASS_MAP, +} from '../../utility/editDiff'; +import EntityLink from '../../../common/components/EntityLink'; + +function areReleaseCountriesEqual(a, b) { + return !!( + !(a.country || b.country) || + (a.country && b.country && a.country.id === b.country.id) + ); +} + +function areReleaseDatesEqual(a, b) { + return formatDate(a.date) === formatDate(b.date); +} + +const countryId = x => x.country ? x.country.id : null; + +const changeSide = ( + oldEvent: ?ReleaseEventT, + newEvent: ?ReleaseEventT, + type: typeof INSERT | typeof DELETE, +) => { + const sideA = type === DELETE ? oldEvent : newEvent; + const sideB = type === DELETE ? newEvent : oldEvent; + + const countryDisplay = (sideA && sideA.country) ? ( + {sideA.country.name} + } + entity={sideA.country} + /> + ) : null; + + const dateDisplay = (sideA && sideA.date) ? ( + <> + {countryDisplay ?
    : null} + {sideB && areReleaseDatesEqual(sideA, sideB) + ? formatDate(sideA.date) + : {formatDate(sideA.date)} + } + + ) : null; + + return ( +
  • + {countryDisplay} + {dateDisplay} +
  • + ); +}; + +type Props = {| + +newEvents: $ReadOnlyArray, + +oldEvents: $ReadOnlyArray, +|}; + +const ReleaseEventsDiff = ({newEvents, oldEvents}: Props) => { + const oldEventsByCountry = keyBy(oldEvents, countryId); + const newEventsByCountry = keyBy(newEvents, countryId); + + const oldKeys = Object.keys(oldEventsByCountry).sort(); + const newKeys = Object.keys(newEventsByCountry).sort(); + + const oldSide = []; + const newSide = []; + + for (let i = 0; i < oldKeys.length; i++) { + const key = oldKeys[i]; + const oldEvent = oldEventsByCountry[key]; + let newEvent = newEventsByCountry[key]; + /* + * If this country was removed, compare against the new entry at + * the same position visually. + */ + if (!newEvent && i < newKeys.length) { + newEvent = newEventsByCountry[newKeys[i]]; + } + oldSide.push(changeSide(oldEvent, newEvent, DELETE)); + } + + for (let i = 0; i < newKeys.length; i++) { + const key = newKeys[i]; + let oldEvent = oldEventsByCountry[key]; + /* + * If this country was added, compare against the old entry at + * the same position visually. + */ + if (!oldEvent && i < oldKeys.length) { + oldEvent = oldEventsByCountry[oldKeys[i]]; + } + const newEvent = newEventsByCountry[key]; + newSide.push(changeSide(oldEvent, newEvent, INSERT)); + } + + return ( + + {l('Release events:')} + +
      + {React.createElement(React.Fragment, null, ...oldSide)} +
    + + +
      + {React.createElement(React.Fragment, null, ...newSide)} +
    + + + ); +}; + +export default ReleaseEventsDiff; diff --git a/root/static/scripts/common/components/FilterIcon.js b/root/static/scripts/edit/components/edit/WordDiff.js similarity index 58% rename from root/static/scripts/common/components/FilterIcon.js rename to root/static/scripts/edit/components/edit/WordDiff.js index 946daf86242..e2db4d2319d 100644 --- a/root/static/scripts/common/components/FilterIcon.js +++ b/root/static/scripts/edit/components/edit/WordDiff.js @@ -9,11 +9,15 @@ import * as React from 'react'; -const FilterIcon = () => ( - ( + ); -export default FilterIcon; +export default WordDiff; diff --git a/root/static/scripts/edit/externalLinks.js b/root/static/scripts/edit/externalLinks.js index aab2e08237b..c3f2e9e3c13 100644 --- a/root/static/scripts/edit/externalLinks.js +++ b/root/static/scripts/edit/externalLinks.js @@ -212,7 +212,8 @@ export class ExternalLinksEditor } else if (!isValidURL(link.url)) { error = l('Enter a valid url e.g. "http://google.com/"'); } else if (isShortened(link.url)) { - error = l("Please don't use shortened URLs."); + error = l(`Please don’t enter bundled/shortened URLs, + enter the destination URL(s) instead.`); } else if (!link.type) { error = l(`Please select a link type for the URL you’ve entered.`); @@ -541,18 +542,37 @@ function isValidURL(url) { const URL_SHORTENERS = [ 'adf.ly', + 'band.link', + 'biglink.to', 'bit.ly', + 'bitly.com', + 'bruit.app', 'cli.gs', 'deck.ly', + 'distrokid.com', + 'fanlink.to', + 'ffm.to', + 'fty.li', 'fur.ly', 'goo.gl', + 'hyperurl.co', 'is.gd', 'kl.am', + 'laburbain.com', + 'linkco.re', + 'linktr.ee', + 'listen.lt', + 'lnk.bio', 'lnk.co', + 'lnk.to', 'mcaf.ee', 'moourl.com', 'owl.ly', 'rubyurl.com', + 'smarturl.it', + 'song.link', + 'songwhip.com', + 'spread.link', 'su.pr', 't.co', 'tiny.cc', diff --git a/root/static/scripts/edit/utility/diffArtistCredits.js b/root/static/scripts/edit/utility/diffArtistCredits.js new file mode 100644 index 00000000000..36d78e052c4 --- /dev/null +++ b/root/static/scripts/edit/utility/diffArtistCredits.js @@ -0,0 +1,154 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import * as React from 'react'; +import zip from 'lodash/zip'; + +import EntityLink from '../../common/components/EntityLink'; +import DiffSide from '../components/edit/DiffSide'; + +import editDiff, { + INSERT, + EQUAL, + DELETE, + CHANGE, + CLASS_MAP, +} from './editDiff'; + +function areArtistCreditNamesEqual(a, b) { + return ( + a.artist.id === b.artist.id && + a.name === b.name && + a.joinPhrase === b.joinPhrase + ); +} + +type ArtistLinkProps = {| + +content?: React.Node, + +credit: ArtistCreditNameT, + +nameVariation?: boolean, +|}; + +const ArtistLink = ({content, credit, nameVariation}: ArtistLinkProps) => ( + +); + +export default function diffArtistCredits( + oldArtistCredit: ArtistCreditT, + newArtistCredit: ArtistCreditT, +) { + const diffs = editDiff( + oldArtistCredit.names, + newArtistCredit.names, + areArtistCreditNamesEqual, + ); + + const oldNames = []; + const newNames = []; + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + + switch (diff.type) { + case EQUAL: + diff.oldItems.forEach(function (credit) { + const link = ; + oldNames.push(link, credit.joinPhrase); + newNames.push(link, credit.joinPhrase); + }); + break; + + case CHANGE: + // $FlowFixMe - zip doesn't like $ReadOnlyArray + zip(diff.oldItems, diff.newItems).forEach(function (pair) { + const oldCredit = pair[0] || {artist: null, joinPhrase: '', name: ''}; + const newCredit = pair[1] || {artist: null, joinPhrase: '', name: ''}; + + const oldJoin = ( + + ); + + const newJoin = ( + + ); + + oldNames.push( + + } + credit={oldCredit} + nameVariation={oldCredit.artist.name !== oldCredit.name} + />, + oldJoin, + ); + + newNames.push( + + } + credit={newCredit} + nameVariation={newCredit.artist.name !== newCredit.name} + />, + newJoin, + ); + }); + + break; + + case DELETE: + oldNames.push(...diff.oldItems.map(credit => ( + + + {credit.joinPhrase} + + ))); + break; + + case INSERT: + newNames.push(...diff.newItems.map(credit => ( + + + {credit.joinPhrase} + + ))); + break; + } + } + + return { + new: React.createElement(React.Fragment, null, ...newNames), + old: React.createElement(React.Fragment, null, ...oldNames), + }; +} diff --git a/root/static/scripts/edit/utility/editDiff.js b/root/static/scripts/edit/utility/editDiff.js new file mode 100644 index 00000000000..410cee395b8 --- /dev/null +++ b/root/static/scripts/edit/utility/editDiff.js @@ -0,0 +1,100 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import genericDiff from 'generic-diff'; + +const INSERT: 1 = 1; +const EQUAL: 2 = 2; +const DELETE: 3 = 3; +const CHANGE: 4 = 4; + +export type EditType = + | typeof CHANGE + | typeof DELETE + | typeof EQUAL + | typeof INSERT + ; + +const CLASS_MAP = { + [CHANGE]: '', + [DELETE]: 'diff-only-a', + [EQUAL]: '', + [INSERT]: 'diff-only-b', +}; + +export {INSERT, EQUAL, DELETE, CHANGE, CLASS_MAP}; + +type GenericEditDiff<+T> = {| + +added: boolean, + +items: $ReadOnlyArray, + +removed: boolean, +|}; + +export type EditDiff<+T> = {| + +newItems: $ReadOnlyArray, + +oldItems: $ReadOnlyArray, + +type: EditType, +|}; + +function getChangeType(diff) { + if (!diff.added && !diff.removed) { + return EQUAL; + } + return diff.added ? INSERT : DELETE; +} + +// Combines adjacent (DELETE, INSERT) diffs into a single CHANGE diff. +export default function editDiff( + oldSide: $ReadOnlyArray, + newSide: $ReadOnlyArray, + eqFunc?: (T, T) => boolean, +): $ReadOnlyArray> { + const diffs: $ReadOnlyArray> = + genericDiff(oldSide, newSide, eqFunc); + const normalized = []; + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + + let changeType = getChangeType(diff); + + let nextDiff; + if ((i + 1) < diffs.length) { + nextDiff = diffs[i + 1]; + } + + let oldItems = []; + let newItems = []; + + switch (changeType) { + case INSERT: + newItems = diff.items; + break; + + case EQUAL: + oldItems = diff.items; + newItems = diff.items; + break; + + case DELETE: + oldItems = diff.items; + + if (nextDiff && getChangeType(nextDiff) === INSERT) { + i++; // skip next + changeType = CHANGE; + newItems = nextDiff.items; + } + break; + } + + normalized.push({newItems, oldItems, type: changeType}); + } + + return normalized; +} diff --git a/root/static/scripts/edit/utility/linkPhrase.js b/root/static/scripts/edit/utility/linkPhrase.js index 471093eb163..0607382ca54 100644 --- a/root/static/scripts/edit/utility/linkPhrase.js +++ b/root/static/scripts/edit/utility/linkPhrase.js @@ -23,13 +23,15 @@ import displayLinkAttribute, {displayLinkAttributeText} const EMPTY_OBJECT = Object.freeze({}); const emptyResult = Object.freeze(['', '']); +const entity0Subst = /\{entity0\}/; +const entity1Subst = /\{entity1\}/; -type CachedResult = {| - attributeValues: ?{+[string]: Array}, +export type CachedLinkPhraseData = {| + attributeValues: ?{+[string]: Array | T}, phraseAndExtraAttributes: {[string]: [T, T]}, |}; -type RelationshipInfoT = { +export type RelationshipInfoT = { +attributes?: $ReadOnlyArray, +linkTypeID: number, }; @@ -41,9 +43,9 @@ type LinkPhraseProp = ; function _getResultCache( - resultCache: WeakMap>, + resultCache: WeakMap>, relationship: RelationshipInfoT, -): CachedResult { +): CachedLinkPhraseData { let result = resultCache.get(relationship); if (!result) { result = { @@ -88,24 +90,24 @@ class PhraseVarArgs extends VarArgs> { } } -type I18n = { - cache: WeakMap>, +export type LinkPhraseI18n = { + cache: WeakMap>, commaList: ($ReadOnlyArray) => T, commaOnlyList: ($ReadOnlyArray) => T, expand: (string, PhraseVarArgs) => T, displayLinkAttribute: (LinkAttrT) => T, }; -const reactI18n: I18n = { - cache: new WeakMap>(), +const reactI18n: LinkPhraseI18n = { + cache: new WeakMap>(), commaList, commaOnlyList, expand: expand2react, displayLinkAttribute, }; -const textI18n: I18n = { - cache: new WeakMap>(), +const textI18n: LinkPhraseI18n = { + cache: new WeakMap>(), commaList: commaListText, commaOnlyList: commaOnlyListText, expand: expand2text, @@ -113,17 +115,21 @@ const textI18n: I18n = { }; function _setAttributeValues( - i18n: I18n, + i18n: LinkPhraseI18n, relationship: RelationshipInfoT, - cache: CachedResult, + entity0: ?T, + entity1: ?T, + cache: CachedLinkPhraseData, ) { const attributes = relationship.attributes; + const values = entity0 && entity1 ? {entity0, entity1} : {}; + + cache.attributeValues = values; + if (!attributes) { - cache.attributeValues = EMPTY_OBJECT; return; } - let values; const linkType = linkedEntities.link_type[relationship.linkTypeID]; for (let i = 0; i < attributes.length; i++) { @@ -131,10 +137,6 @@ function _setAttributeValues( const value = i18n.displayLinkAttribute(attribute); if (value) { - if (!values) { - values = {}; - } - const type = linkedEntities.link_attribute_type[attribute.typeID]; const info = linkType.attributes[type.root_id]; const rootName = linkedEntities.link_attribute_type[type.root_id].name; @@ -146,8 +148,6 @@ function _setAttributeValues( } } } - - cache.attributeValues = values || EMPTY_OBJECT; } const requiredAttributesCache: { @@ -171,11 +171,13 @@ function _getRequiredAttributes(linkType: LinkTypeT) { return (requiredAttributesCache[linkType.id] = required || EMPTY_OBJECT); } -function _getPhraseAndExtraAttributes( - i18n: I18n, +export function getPhraseAndExtraAttributes( + i18n: LinkPhraseI18n, relationship: RelationshipInfoT, phraseProp: LinkPhraseProp, forGrouping?: boolean = false, + entity0?: T, + entity1?: T, ): [T | string, T | string] { const cache = _getResultCache(i18n.cache, relationship); const key = phraseProp + '\0' + (forGrouping ? '1' : '0'); @@ -190,13 +192,19 @@ function _getPhraseAndExtraAttributes( return emptyResult; } - const phraseSource = l_relationships(linkType[phraseProp]); + let phraseSource = l_relationships(linkType[phraseProp]); if (!phraseSource) { return emptyResult; } if (!cache.attributeValues) { - _setAttributeValues(i18n, relationship, cache); + _setAttributeValues( + i18n, + relationship, + entity0, + entity1, + cache, + ); } const attributeValues = cache.attributeValues; @@ -221,6 +229,15 @@ function _getPhraseAndExtraAttributes( forGrouping && linkType.orderable_direction > 0; + if (phraseProp === 'long_link_phrase' && entity0 && entity1) { + if (!entity0Subst.test(phraseSource)) { + phraseSource = '{entity0} ' + phraseSource; + } + if (!entity1Subst.test(phraseSource)) { + phraseSource += ' {entity1}'; + } + } + const varArgs = new PhraseVarArgs( shouldStripAttributes ? _getRequiredAttributes(linkType) @@ -259,7 +276,7 @@ export const getPhraseAndExtraAttributesText = ( relationship: RelationshipInfoT, phraseProp: LinkPhraseProp, forGrouping?: boolean = false, -) => _getPhraseAndExtraAttributes( +) => getPhraseAndExtraAttributes( textI18n, relationship, phraseProp, @@ -270,11 +287,15 @@ export const interpolate = ( relationship: RelationshipInfoT, phraseProp: LinkPhraseProp, forGrouping?: boolean = false, -) => _getPhraseAndExtraAttributes( + entity0?: React$MixedElement, + entity1?: React$MixedElement, +) => getPhraseAndExtraAttributes( reactI18n, relationship, phraseProp, forGrouping, + entity0, + entity1, )[0]; export const interpolateText = ( @@ -291,7 +312,7 @@ export const getExtraAttributes = ( relationship: RelationshipInfoT, phraseProp: LinkPhraseProp, forGrouping?: boolean = false, -) => _getPhraseAndExtraAttributes( +) => getPhraseAndExtraAttributes( reactI18n, relationship, phraseProp, diff --git a/root/static/scripts/release-editor/dialogs.js b/root/static/scripts/release-editor/dialogs.js index 36879a0238f..b7d036856b6 100644 --- a/root/static/scripts/release-editor/dialogs.js +++ b/root/static/scripts/release-editor/dialogs.js @@ -123,8 +123,16 @@ class SearchResult { if (track.artistCredit) { track.artist = reduceArtistCredit(track.artistCredit); } else { + // If the track artist matches the release artist, reuse the AC + const release = releaseEditor.rootField.release(); + const releaseArtistCredit = release.artistCredit(); + const releaseArtistName = reduceArtistCredit(releaseArtistCredit); track.artist = track.artist || this.artist || ""; - track.artistCredit = {names: [{ name: track.artist }]}; + if (track.artist === releaseArtistName) { + track.artistCredit = releaseArtistCredit; + } else { + track.artistCredit = {names: [{ name: track.artist }]}; + } } } diff --git a/root/static/scripts/release-editor/seeding.js b/root/static/scripts/release-editor/seeding.js index 0100591ea54..b61697d8664 100644 --- a/root/static/scripts/release-editor/seeding.js +++ b/root/static/scripts/release-editor/seeding.js @@ -75,7 +75,11 @@ releaseEditor.seedRelease = function (release, data) { } if (data.barcode) { - release.barcode.value(data.barcode); + if (data.barcode === 'none') { + release.barcode.none(true); + } else { + release.barcode.value(data.barcode); + } } if (data.artistCredit) { diff --git a/root/static/scripts/tests/autocomplete2.html b/root/static/scripts/tests/autocomplete2.html new file mode 100644 index 00000000000..8e14e3f524e --- /dev/null +++ b/root/static/scripts/tests/autocomplete2.html @@ -0,0 +1,12 @@ + + + + + Autocomplete2 Test + + + + + + + diff --git a/root/static/scripts/tests/autocomplete2.js b/root/static/scripts/tests/autocomplete2.js new file mode 100644 index 00000000000..b0b2f054dd1 --- /dev/null +++ b/root/static/scripts/tests/autocomplete2.js @@ -0,0 +1,74 @@ +// IE 11 support. +require('core-js/modules/es6.object.assign'); +require('core-js/modules/es6.array.from'); +require('core-js/modules/es6.array.iterator'); +require('core-js/modules/es6.string.iterator'); +require('core-js/es6/set'); +require('core-js/es6/map'); +require('core-js/es6/promise'); +require('core-js/es6/symbol'); + +const $ = require('jquery'); +const React = require('react'); +const ReactDOM = require('react-dom'); +const Autocomplete2 = require('../common/components/Autocomplete2').default; + +const vocals = [ + {id: 3, name: 'vocal', level: 1}, + {id: 4, name: 'lead vocals', level: 2}, + {id: 5, name: 'alto vocals', level: 3}, + {id: 6, name: 'baritone vocals', level: 3}, + {id: 7, name: 'bass vocals', level: 3}, + {id: 8, name: 'countertenor vocals', level: 3}, + {id: 9, name: 'mezzo-soprano vocals', level: 3}, + {id: 10, name: 'soprano vocals', level: 3}, + {id: 11, name: 'tenor vocals', level: 3}, + {id: 230, name: 'contralto vocals', level: 3}, + {id: 231, name: 'bass-baritone vocals', level: 3}, + {id: 834, name: 'treble vocals', level: 3}, + {id: 1060, name: 'meane vocals', level: 3}, + {id: 12, name: 'background vocals', level: 2}, + {id: 13, name: 'choir vocals', level: 2}, + {id: 461, name: 'other vocals', level: 2}, + {id: 561, name: 'spoken vocals', level: 3}, +]; + +$(function () { + const container = document.createElement('div'); + document.body.insertBefore(container, document.getElementById('page')); + + function render(entityType) { + ReactDOM.render( + <> +
    +

    Entity autocomplete

    +

    + Current entity type: {entityType}. + Paste an MBID to change it. +

    + +
    +
    +

    Vocal autocomplete

    + +
    + , + container, + ); + } + + render('artist'); +}); diff --git a/root/static/scripts/tests/react-macros.js b/root/static/scripts/tests/react-macros.js index b0f3e883d72..6608ca4b367 100644 --- a/root/static/scripts/tests/react-macros.js +++ b/root/static/scripts/tests/react-macros.js @@ -21,6 +21,10 @@ const DescriptiveLink = require('../common/components/DescriptiveLink').default; const EditorLink = require('../common/components/EditorLink').default; const EntityLink = require('../common/components/EntityLink').default; const l = require('../common/i18n').l; +const diffArtistCredits = require('../edit/utility/diffArtistCredits').default; +const Diff = require('../edit/components/edit/Diff').default; +const FullChangeDiff = require('../edit/components/edit/FullChangeDiff').default; +const WordDiff = require('../edit/components/edit/WordDiff').default; function throwNotEquivalent(message, got, expected) { throw {message: message, got: got, expected: expected}; @@ -118,6 +122,12 @@ const testResults = []; testData.forEach(function (test) { let entity = test.entity; + let ttMarkup = test.tt_markup + .replace(/\s+<(td|th)>/g, '<$1>') + .replace(/<\/(td|th)>\s+<(td|th)/g, '<$2') + .replace(/<\/(td|th)>\s+<\/tr>/g, '') + .replace(''', '''); + let reactMarkup = ReactDOMServer.renderToStaticMarkup( React.createElement('div', null, eval(test.react_element)) @@ -127,7 +137,7 @@ testData.forEach(function (test) { let testCases = [ { - got: test.tt_markup, + got: ttMarkup, failMessage: 'TT markup does not match what was expected', }, { diff --git a/root/static/styles/autocomplete2.less b/root/static/styles/autocomplete2.less new file mode 100644 index 00000000000..41086b50882 --- /dev/null +++ b/root/static/styles/autocomplete2.less @@ -0,0 +1,15 @@ +@import "variables.less"; +@import "colors.less"; +@import "forms.less"; +@import "layout.less"; +@import "widgets.less"; + +html { + height: 100%; +} + +body { + padding: 1em; + width: 100%; + height: 100%; +} diff --git a/root/static/styles/forms.less b/root/static/styles/forms.less index bf941855f71..3f85ff98668 100644 --- a/root/static/styles/forms.less +++ b/root/static/styles/forms.less @@ -477,7 +477,6 @@ ul.conditions button.remove img { background: @very-light-bg; .border-radius(3px); padding: 0.5em; - display: none; } input.icon, div.icon.img, button.icon { diff --git a/root/static/styles/layout.less b/root/static/styles/layout.less index 12b37bcfd28..2430237c5c2 100644 --- a/root/static/styles/layout.less +++ b/root/static/styles/layout.less @@ -1011,8 +1011,9 @@ a.tagger-icon { display: block; } -.actions-header { - min-width: 10em; +.actions { + white-space: nowrap; + width: 1px; } dl.ars, dl.ars dd, dl.ars dt { @@ -1060,3 +1061,10 @@ dl.ars dt { padding-left: @form-margin; } } + +/* AcoustID fingerprints */ + +.disabled-acoustid { + text-decoration: line-through; + opacity: 0.5; +} diff --git a/root/static/styles/widgets.less b/root/static/styles/widgets.less index 7956bf9ec55..d8faa55a7d6 100644 --- a/root/static/styles/widgets.less +++ b/root/static/styles/widgets.less @@ -286,3 +286,67 @@ div.g-recaptcha { width: 100%; height: 100%; } + +/* Autocomplete2 */ + +div.autocomplete2 { + display: inline-block; + position: relative; + + div[role="combobox"] { + display: flex; + flex-direction: row; + + input { + flex-grow: 1; + } + + button.search { + background-color: transparent; + background-image: data-uri('../images/icons/search.svg'); + background-repeat: no-repeat; + background-size: contain; + border: none; + height: 20px; + vertical-align: top; + width: 20px; + + &.loading { + background-image: data-uri('../images/icons/loading.gif'); + } + } + } + + ul { + border: 1px @musicbrainz-orange solid; + list-style: none; + margin: 0; + max-width: 150%; + max-height: 400px; + overflow-y: auto; + padding: 0; + position: absolute; + width: max-content; + z-index: 100; + + li { + background: @text-white; + border-width: 1px 0 1px 0; + border-color: @text-white; + border-style: solid; + cursor: pointer; + padding: 4px; + &.highlighted { + background: lighten(@musicbrainz-orange, 30%); + border-color: lighten(@musicbrainz-orange, 20%); + } + &.selected { + font-weight: bold; + } + &.separator { + border-top-color: @medium-grey; + margin-top: 0px; + } + } + } +} diff --git a/root/statistics/Relationships.js b/root/statistics/Relationships.js index 0534b347640..13a39df13d7 100644 --- a/root/statistics/Relationships.js +++ b/root/statistics/Relationships.js @@ -88,8 +88,8 @@ const Relationships = ({ - {l('Exclusive')} - {l('Inclusive')} + {lp('This type only', 'relationships')} + {lp('Including subtypes', 'relationships')} diff --git a/root/statistics/StatisticsLayout.js b/root/statistics/StatisticsLayout.js index b972e03cdfb..7c56fb6fa50 100644 --- a/root/statistics/StatisticsLayout.js +++ b/root/statistics/StatisticsLayout.js @@ -33,7 +33,9 @@ type TabPropsT = { const LinkStatisticsTab = ({link, title, page, selected}: TabPropsT) => (
  • - {unwrapNl(title)} + + {unwrapNl(title)} +
  • ); diff --git a/root/types.js b/root/types.js index fc75164b3f6..4c40d91cc8c 100644 --- a/root/types.js +++ b/root/types.js @@ -212,6 +212,7 @@ type CatalystStashT = {| +number_of_revisions?: number, +own_collections?: $ReadOnlyArray, +release_artwork?: ArtworkT, + +release_artwork_count?: number, +server_languages?: $ReadOnlyArray, +subscribed?: boolean, +top_tags?: $ReadOnlyArray, @@ -753,6 +754,8 @@ declare type RelationshipT = {| +direction?: 'backward', +entity0_credit: string, +entity1_credit: string, + +entity0_id: number, + +entity1_id: number, +id: number, +linkOrder: number, +linkTypeID: number, diff --git a/root/user/collections.tt b/root/user/collections.tt index 43d284c0b01..c860b35e4e7 100644 --- a/root/user/collections.tt +++ b/root/user/collections.tt @@ -69,7 +69,7 @@ [% IF viewing_own_profile %] [% l('Subscribed') %] [% l('Privacy') %] - [% l('Actions') %] + [% l('Actions') %] [% END %] @@ -79,11 +79,11 @@ [% link_collection(collection) %] [% collection.type.l_name %] [% collection.entity_count %] -

    [% collaborator_number(collection, c.user) %]

    + [% collaborator_number(collection, c.user) %] [% IF viewing_own_profile %] [% yesno(collection.subscribed) %] [% collection.public ? l('Public') : l('Private') %] - + [% link_collection(collection, 'edit', l('Edit')) %] | [% link_collection(collection, 'delete', l('Remove')) %] @@ -121,7 +121,7 @@ [% link_collection(collection) %] [% collection.type.l_name %] [% collection.entity_count %] -

    [% collaborator_number(collection, c.user) %]

    + [% collaborator_number(collection, c.user) %] [% IF viewing_own_profile %] [% yesno(collection.subscribed) %] [% END %] diff --git a/root/utility/relationshipDateText.js b/root/utility/relationshipDateText.js new file mode 100644 index 00000000000..1a74fa5b3cd --- /dev/null +++ b/root/utility/relationshipDateText.js @@ -0,0 +1,37 @@ +/* + * @flow + * This file is part of MusicBrainz, the open internet music database. + * Copyright (C) 2019 MetaBrainz Foundation + * Licensed under the GPL version 2, or (at your option) any later version: + * http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import {bracketedText} from '../static/scripts/common/utility/bracketed'; +import formatDate from '../static/scripts/common/utility/formatDate'; + +import areDatesEqual from './areDatesEqual'; + +export default function relationshipDateText(r: RelationshipT) { + if (r.begin_date) { + if (r.end_date) { + if (areDatesEqual(r.begin_date, r.end_date)) { + if (r.begin_date.day) { + return texp.l('on {date}', {date: formatDate(r.begin_date)}); + } + return texp.l('in {date}', {date: formatDate(r.begin_date)}); + } + return texp.l('from {begin_date} until {end_date}', { + begin_date: formatDate(r.begin_date), + end_date: formatDate(r.end_date), + }); + } else if (r.ended) { + return texp.l('from {date} to ????', {date: formatDate(r.begin_date)}); + } + return texp.l('from {date} to present', {date: formatDate(r.begin_date)}); + } else if (r.end_date) { + return texp.l('until {date}', {date: formatDate(r.end_date)}); + } else if (r.ended) { + return bracketedText(l('ended')); + } + return ''; +} diff --git a/root/work/WorkIndex.js b/root/work/WorkIndex.js new file mode 100644 index 00000000000..7a2cafd5dd2 --- /dev/null +++ b/root/work/WorkIndex.js @@ -0,0 +1,55 @@ +/* + * @flow + * Copyright (C) 2019 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import React from 'react'; + +import Annotation from '../static/scripts/common/components/Annotation'; +import WikipediaExtract + from '../static/scripts/common/components/WikipediaExtract'; +import CleanupBanner from '../components/CleanupBanner'; +import Relationships from '../components/Relationships'; +import RelationshipsTable from '../components/RelationshipsTable'; +import * as manifest from '../static/manifest'; + +import WorkLayout from './WorkLayout'; + +type Props = {| + +eligibleForCleanup: boolean, + +numberOfRevisions: number, + +wikipediaExtract: WikipediaExtractT | null, + +work: WorkT, +|}; + +const WorkIndex = ({ + eligibleForCleanup, + numberOfRevisions, + wikipediaExtract, + work, +}: Props) => ( + + {eligibleForCleanup ? ( + + ) : null} + + + + + {manifest.js('work/index.js', {async: 'async'})} + +); + +export default WorkIndex; diff --git a/root/work/index.tt b/root/work/index.tt deleted file mode 100644 index 6f20651bd2b..00000000000 --- a/root/work/index.tt +++ /dev/null @@ -1,19 +0,0 @@ -[%- WRAPPER "work/layout.tt" page='index' -%] - [% IF eligible_for_cleanup %] -

    - [% l('This work has no relationships and will be removed - automatically in the next few days. If this is not intended, please add - relationships to this work.') %] -

    - [% END %] - - [%- INCLUDE 'annotation/summary.tt' -%] - - [%- show_wikipedia_extract() -%] - - [% React.embed(c, 'components/Relationships', {source => work}) %] - - [% React.embed(c, 'components/RelationshipsTable', {entity => work, heading => l('Recordings')}) %] - - [%- script_manifest('work/index.js', {async => 'async'}) -%] -[%- END -%] diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseArtists.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseArtists.pm index 637de3ec185..93c5a8636f8 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseArtists.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseArtists.pm @@ -36,6 +36,19 @@ ws2_test_xml 'browse artists via recording', ' + + BoABoA + + 1986-11-05 + + + Beat of Angel + BoA Kwon + Kwon BoA + ボア + 보아 + + m-flom-flo @@ -50,19 +63,6 @@ ws2_test_xml 'browse artists via recording', エムフロウ - - BoABoA - - 1986-11-05 - - - Beat of Angel - BoA Kwon - Kwon BoA - ボア - 보아 - - '; @@ -71,13 +71,6 @@ ws2_test_xml 'browse artists via release, inc=tags+ratings', ' - - m-flom-flo - - 1998 - - 3 - BAGDAD CAFE THE trench town BAGDAD CAFE THE trench town @@ -98,6 +91,13 @@ ws2_test_xml 'browse artists via release, inc=tags+ratings', 4.35 + + m-flom-flo + + 1998 + + 3 + '; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRecordings.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRecordings.pm index 8baffedb491..5db4a732dbd 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRecordings.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRecordings.pm @@ -22,14 +22,14 @@ ws_test 'browse recordings via artist (first page)', ' - - Busy Working217440 + + Be Rude to Your School208706 Bibi Plone173960 - - Be Rude to Your School208706 + + Busy Working217440 '; @@ -39,15 +39,15 @@ ws_test 'browse recordings via artist (second page)', ' + + Marbles229826 + On My Bus267560 Plock237133 - - Marbles229826 - '; @@ -59,14 +59,14 @@ ws_test 'browse recordings via release', Cella334000 - - Delight339000 + + Confined314000 Cyclops265000 - - Confined314000 + + Delight339000 '; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRelease.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRelease.pm index f8a3af5a82c..a7b2baa3fc2 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRelease.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseRelease.pm @@ -65,19 +65,19 @@ ws_test 'browse releases via label', ' - - Repercussions + + My Demons Official normal eng - 2008-11-17 + 2007-01-29 GB - 2008-11-17 + 2007-01-29 United Kingdom United Kingdom @@ -87,40 +87,35 @@ ws_test 'browse releases via label', - 600116822123 - B001IKWNCE + 600116817020 + B000KJTG6K false 0 false false - + 1 CD - - - - Chestplate Singles2 - CD - + - - My Demons + + Repercussions Official normal eng - 2007-01-29 + 2008-11-17 GB - 2007-01-29 + 2008-11-17 United Kingdom United Kingdom @@ -130,19 +125,24 @@ ws_test 'browse releases via label', - 600116817020 - B000KJTG6K + 600116822123 + B001IKWNCE false 0 false false - + 1 CD - + + + + Chestplate Singles2 + CD + @@ -300,7 +300,7 @@ ws_test 'browse releases via recording', ' - + LOVE & HONESTY Official normal @@ -322,8 +322,8 @@ ws_test 'browse releases via recording', - 4988064173891 - B0000YGBSG + 4988064173907 + B0000YG9NS false 0 @@ -332,14 +332,14 @@ ws_test 'browse releases via recording', - AVCD-17389 + AVCD-17390 - + LOVE & HONESTY Official normal @@ -361,8 +361,8 @@ ws_test 'browse releases via recording', - 4988064173907 - B0000YG9NS + 4988064173891 + B0000YGBSG false 0 @@ -371,7 +371,7 @@ ws_test 'browse releases via recording', - AVCD-17390 + AVCD-17389 @@ -381,8 +381,8 @@ ws_test 'browse releases via recording', '; -ws_test 'browse releases via track artist', - '/release?track_artist=a16d1433-ba89-4f72-a47b-a370add0bb55' => +ws_test 'browse releases via track artist, including RGs and ratings', + '/release?track_artist=a16d1433-ba89-4f72-a47b-a370add0bb55&inc=release-groups+ratings' => ' @@ -391,6 +391,12 @@ ws_test 'browse releases via track artist', Official normal eng + + the Love Bug + 2004-03-17 + Single + 5 + 2004-03-17 JP diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseWork.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseWork.pm index d6392100524..ee0dee7362f 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseWork.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/BrowseWork.pm @@ -22,20 +22,20 @@ ws_test 'browse works via artist (first page)', ' - - On My Bus + + Be Rude to Your School - - Marbles + + Bibi Plone Busy Working - - Be Rude to Your School + + Marbles - - Bibi Plone + + On My Bus '; @@ -45,9 +45,6 @@ ws_test 'browse works via artist (second page)', ' - - Top & Low Rent - Plock @@ -60,6 +57,9 @@ ws_test 'browse works via artist (second page)', The Greek Alphabet + + Top & Low Rent + '; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupArtist.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupArtist.pm index c5f6614a6ab..c37dbf28236 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupArtist.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupArtist.pm @@ -276,15 +276,15 @@ test 'artist lookup with releases' => sub { "type-id" => "e431f5f6-b5d2-343d-8b36-72607fffb74b", releases => [ { - id => "0385f276-5f4f-4c81-a7a4-6bd7b8d85a7e", - title => "サマーれげぇ!レインボー", + id => "b3b7e934-445b-4c68-a097-730c6a6d47e6", + title => "Summer Reggae! Rainbow", disambiguation => "", packaging => JSON::null, "packaging-id" => JSON::null, - status => "Official", - "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", + status => "Pseudo-Release", + "status-id" => "41121bb9-3413-3818-8a9a-9742318349aa", quality => "normal", - "text-representation" => { language => "jpn", script => "Jpan" }, + "text-representation" => { language => "jpn", script => "Latn" }, date => "2001-07-04", country => "JP", "release-events" => [{ @@ -300,15 +300,15 @@ test 'artist lookup with releases' => sub { barcode => "4942463511227", }, { - id => "b3b7e934-445b-4c68-a097-730c6a6d47e6", - title => "Summer Reggae! Rainbow", + id => "0385f276-5f4f-4c81-a7a4-6bd7b8d85a7e", + title => "サマーれげぇ!レインボー", disambiguation => "", packaging => JSON::null, "packaging-id" => JSON::null, - status => "Pseudo-Release", - "status-id" => "41121bb9-3413-3818-8a9a-9742318349aa", + status => "Official", + "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", quality => "normal", - "text-representation" => { language => "jpn", script => "Latn" }, + "text-representation" => { language => "jpn", script => "Jpan" }, date => "2001-07-04", country => "JP", "release-events" => [{ @@ -322,8 +322,8 @@ test 'artist lookup with releases' => sub { }, }], barcode => "4942463511227", - } - ], + }, + ], ipis => [], isnis => [], gender => JSON::null, @@ -410,6 +410,59 @@ test 'artist lookup with releases and discids' => sub { type => "Person", "type-id" => "b6e035f4-3ce9-331c-97df-83397230b0df", releases => [ + { + id => "adcf7b48-086e-48ee-b420-1001f88d672f", + title => "My Demons", + status => "Official", + "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", + quality => "normal", + "text-representation" => { language => "eng", script => "Latn" }, + date => "2007-01-29", + disambiguation => "", + packaging => JSON::null, + "packaging-id" => JSON::null, + country => "GB", + barcode => "600116817020", + media => [ + { + title => '', + format => "CD", + "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", + position => 1, + discs => [ + { + id => "75S7Yp3IiqPVREQhjAjMXPhwz0Y-", + 'offset-count' => 12, + offsets => [ + 150, + 27852, + 49751, + 75876, + 99845, + 120876, + 140765, + 165856, + 188422, + 211757, + 232229, + 255810 + ], + sectors => 281289 + } + ], + "track-count" => 12, + }], + "release-events" => [{ + date => "2007-01-29", + "area" => { + disambiguation => '', + "id" => "8a754a16-0027-3a29-b6d7-2b40ea0481ed", + "name" => "United Kingdom", + "sort-name" => "United Kingdom", + "iso-3166-1-codes" => ["GB"], + }, + }], + }, { id => "3b3d130a-87a8-4a47-b9fb-920f2530d134", title => "Repercussions", @@ -485,59 +538,7 @@ test 'artist lookup with releases and discids' => sub { }, }] }, - { - id => "adcf7b48-086e-48ee-b420-1001f88d672f", - title => "My Demons", - status => "Official", - "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", - quality => "normal", - "text-representation" => { language => "eng", script => "Latn" }, - date => "2007-01-29", - disambiguation => "", - packaging => JSON::null, - "packaging-id" => JSON::null, - country => "GB", - barcode => "600116817020", - media => [ - { - title => '', - format => "CD", - "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", - position => 1, - discs => [ - { - id => "75S7Yp3IiqPVREQhjAjMXPhwz0Y-", - 'offset-count' => 12, - offsets => [ - 150, - 27852, - 49751, - 75876, - 99845, - 120876, - 140765, - 165856, - 188422, - 211757, - 232229, - 255810 - ], - sectors => 281289 - } - ], - "track-count" => 12, - }], - "release-events" => [{ - date => "2007-01-29", - "area" => { - disambiguation => '', - "id" => "8a754a16-0027-3a29-b6d7-2b40ea0481ed", - "name" => "United Kingdom", - "sort-name" => "United Kingdom", - "iso-3166-1-codes" => ["GB"], - }, - }], - }], + ], ipis => [], isnis => [], gender => JSON::null, @@ -852,8 +853,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { works => [ { attributes => [], - id => "286ecfdd-2ffe-3bc7-b3e9-04cc8cea229b", - title => "Easy To Be Hard", + id => "53d1fbac-e60a-38cb-85ff-e5a9224c9749", + title => "Be the one", disambiguation => "", iswcs => [], language => JSON::null, @@ -863,8 +864,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "2d967c29-63dc-309d-bbc1-a2d38639aaa1", - title => "心の手紙", + id => "303f9bd2-152f-3145-9e09-afa34edb6a57", + title => "DOUBLE", disambiguation => "", iswcs => [], language => JSON::null, @@ -874,8 +875,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "303f9bd2-152f-3145-9e09-afa34edb6a57", - title => "DOUBLE", + id => "286ecfdd-2ffe-3bc7-b3e9-04cc8cea229b", + title => "Easy To Be Hard", disambiguation => "", iswcs => [], language => JSON::null, @@ -885,8 +886,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "46724ef1-241e-3d7f-9f3b-e51ba34e2aa1", - title => "the Love Bug", + id => "7e78f281-52b4-315b-9d7b-6d215732f3d7", + title => "EXPECT", disambiguation => "", iswcs => [], language => JSON::null, @@ -896,8 +897,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "4b6a46c2-a904-3471-9bff-3942d4549f47", - title => "SOME DAY ONE DAY )", + id => "f23ae726-0300-3830-b1ca-634f4362f78c", + title => "LOVE & HONESTY", disambiguation => "", iswcs => [], language => JSON::null, @@ -907,8 +908,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "50c07b24-7ee2-31ac-ab87-f0d399011c71", - title => "Milky Way 〜君の歌〜", + id => "6f08d5a8-1811-3e5e-848b-35ffa77babe5", + title => "Midnight Parade", disambiguation => "", iswcs => [], language => JSON::null, @@ -918,8 +919,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "511f5124-c0ae-3386-bb76-4b6521498a68", - title => "Milky Way-君の歌-", + id => "50c07b24-7ee2-31ac-ab87-f0d399011c71", + title => "Milky Way 〜君の歌〜", disambiguation => "", iswcs => [], language => JSON::null, @@ -929,8 +930,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "53d1fbac-e60a-38cb-85ff-e5a9224c9749", - title => "Be the one", + id => "511f5124-c0ae-3386-bb76-4b6521498a68", + title => "Milky Way-君の歌-", disambiguation => "", iswcs => [], language => JSON::null, @@ -940,8 +941,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "61ab56f0-e803-3aef-a91b-63564b7a8043", - title => "Rock With You", + id => "d2f1ea1f-de2e-3d0c-b534-e96377912478", + title => "OVER~across the time~", disambiguation => "", iswcs => [], language => JSON::null, @@ -951,8 +952,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "6f08d5a8-1811-3e5e-848b-35ffa77babe5", - title => "Midnight Parade", + id => "61ab56f0-e803-3aef-a91b-63564b7a8043", + title => "Rock With You", disambiguation => "", iswcs => [], language => JSON::null, @@ -973,8 +974,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "7e78f281-52b4-315b-9d7b-6d215732f3d7", - title => "EXPECT", + id => "4b6a46c2-a904-3471-9bff-3942d4549f47", + title => "SOME DAY ONE DAY )", disambiguation => "", iswcs => [], language => JSON::null, @@ -995,8 +996,8 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "d2f1ea1f-de2e-3d0c-b534-e96377912478", - title => "OVER~across the time~", + id => "46724ef1-241e-3d7f-9f3b-e51ba34e2aa1", + title => "the Love Bug", disambiguation => "", iswcs => [], language => JSON::null, @@ -1006,15 +1007,16 @@ test 'artist lookup with works (using l_recording_work)' => sub { }, { attributes => [], - id => "f23ae726-0300-3830-b1ca-634f4362f78c", - title => "LOVE & HONESTY", + id => "2d967c29-63dc-309d-bbc1-a2d38639aaa1", + title => "心の手紙", disambiguation => "", iswcs => [], language => JSON::null, languages => [], type => JSON::null, "type-id" => JSON::null, - }], + }, + ], ipis => [], isnis => [], gender => JSON::null, diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupDiscID.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupDiscID.pm index 5ea9342a34b..4fcbfdbdf81 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupDiscID.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupDiscID.pm @@ -117,6 +117,8 @@ EOSQL ws_test_json 'lookup via toc', '/discid/aa11.sPglQ1x0cybDcDi0OsZw9Q-?toc=1 9 189343 150 6614 32287 54041 61236 88129 92729 115276 153877&cdstubs=no&inc=tags+genres' => { + 'release-count' => 2, + 'release-offset' => 0, releases => [ { id => "9b3d9383-3d2a-417f-bfbb-56f7c15f075b", diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupLabel.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupLabel.pm index 892de9684f3..3afc2f28c1e 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupLabel.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupLabel.pm @@ -150,16 +150,16 @@ test 'label lookup with releases, inc=media' => sub { }, releases => [ { - id => "3b3d130a-87a8-4a47-b9fb-920f2530d134", - title => "Repercussions", + id => "adcf7b48-086e-48ee-b420-1001f88d672f", + title => "My Demons", status => "Official", "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", quality => "normal", "text-representation" => { language => "eng", script => "Latn" }, - date => "2008-11-17", + date => "2007-01-29", country => "GB", "release-events" => [{ - date => "2008-11-17", + date => "2007-01-29", "area" => { disambiguation => '', "id" => "8a754a16-0027-3a29-b6d7-2b40ea0481ed", @@ -168,7 +168,7 @@ test 'label lookup with releases, inc=media' => sub { "iso-3166-1-codes" => ["GB"], }, }], - barcode => "600116822123", + barcode => "600116817020", disambiguation => "", packaging => JSON::null, "packaging-id" => JSON::null, @@ -176,30 +176,22 @@ test 'label lookup with releases, inc=media' => sub { { format => "CD", "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", - "track-count" => 9, + "track-count" => 12, position => 1, title => '', - }, - { - format => "CD", - "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", - "track-count" => 9, - position => 2, - title => "Chestplate Singles" - }, - ], + } ] }, { - id => "adcf7b48-086e-48ee-b420-1001f88d672f", - title => "My Demons", + id => "3b3d130a-87a8-4a47-b9fb-920f2530d134", + title => "Repercussions", status => "Official", "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", quality => "normal", "text-representation" => { language => "eng", script => "Latn" }, - date => "2007-01-29", + date => "2008-11-17", country => "GB", "release-events" => [{ - date => "2007-01-29", + date => "2008-11-17", "area" => { disambiguation => '', "id" => "8a754a16-0027-3a29-b6d7-2b40ea0481ed", @@ -208,7 +200,7 @@ test 'label lookup with releases, inc=media' => sub { "iso-3166-1-codes" => ["GB"], }, }], - barcode => "600116817020", + barcode => "600116822123", disambiguation => "", packaging => JSON::null, "packaging-id" => JSON::null, @@ -216,11 +208,19 @@ test 'label lookup with releases, inc=media' => sub { { format => "CD", "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", - "track-count" => 12, + "track-count" => 9, position => 1, title => '', - } ] - } + }, + { + format => "CD", + "format-id" => "9712d52a-4509-3d4b-a1a2-67c88c643e31", + "track-count" => 9, + position => 2, + title => "Chestplate Singles" + }, + ], + }, ], ipis => [], isnis => [], diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupPlace.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupPlace.pm new file mode 100644 index 00000000000..385b5669f63 --- /dev/null +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupPlace.pm @@ -0,0 +1,54 @@ +package t::MusicBrainz::Server::Controller::WS::2::JSON::LookupPlace; + +use utf8; +use JSON; +use Test::Routine; +use Test::More; +use MusicBrainz::Server::Test ws_test_json => { + version => 2 +}; + +with 't::Mechanize', 't::Context'; + +test 'basic place lookup' => sub { + + MusicBrainz::Server::Test->prepare_test_database(shift->c, '+webservice'); + + ws_test_json 'basic place lookup', '/place/df9269dd-0470-4ea2-97e8-c11e46080edd' => { + 'address' => 'An Address', + 'area' => { + 'disambiguation' => '', + 'id' => '89a675c2-3e37-3518-b83c-418bad59a85a', + 'iso-3166-1-codes' => ['XE'], + 'name' => 'Europe', + 'sort-name' => 'Europe', + }, + 'coordinates' => { + 'latitude' => 0.323, + 'longitude' => 1.234, + }, + 'disambiguation' => 'A PLACE!', + 'id' => 'df9269dd-0470-4ea2-97e8-c11e46080edd', + 'life-span' => { + 'begin' => '2013', + 'end' => JSON::null, + 'ended' => JSON::false, + }, + 'name' => 'A Test Place', + 'type' => 'Venue', + 'type-id' => 'cd92781a-a73f-30e8-a430-55d7521338db', + }, { + content_cb => sub { + my $content = shift; + + like $content, qr{"longitude":\s*1.234}, + 'longitude is outputted as a float'; + + like $content, qr{"latitude":\s*0.323}, + 'latitude is outputted as a float'; + }, + extra_plan => 2, + }; +}; + +1; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRecording.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRecording.pm index 802505b9006..620b248ff2e 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRecording.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRecording.pm @@ -56,12 +56,12 @@ test 'recording lookup with releases' => sub { video => JSON::false, releases => [ { - id => "0385f276-5f4f-4c81-a7a4-6bd7b8d85a7e", - title => "サマーれげぇ!レインボー", - status => "Official", - "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", + id => "b3b7e934-445b-4c68-a097-730c6a6d47e6", + title => "Summer Reggae! Rainbow", + status => "Pseudo-Release", + "status-id" => "41121bb9-3413-3818-8a9a-9742318349aa", quality => "normal", - "text-representation" => { language => "jpn", script => "Jpan" }, + "text-representation" => { language => "jpn", script => "Latn" }, date => "2001-07-04", country => "JP", "release-events" => [{ @@ -80,12 +80,12 @@ test 'recording lookup with releases' => sub { "packaging-id" => JSON::null, }, { - id => "b3b7e934-445b-4c68-a097-730c6a6d47e6", - title => "Summer Reggae! Rainbow", - status => "Pseudo-Release", - "status-id" => "41121bb9-3413-3818-8a9a-9742318349aa", + id => "0385f276-5f4f-4c81-a7a4-6bd7b8d85a7e", + title => "サマーれげぇ!レインボー", + status => "Official", + "status-id" => "4e304316-386d-3409-af2e-78857eec5cfe", quality => "normal", - "text-representation" => { language => "jpn", script => "Latn" }, + "text-representation" => { language => "jpn", script => "Jpan" }, date => "2001-07-04", country => "JP", "release-events" => [{ @@ -102,7 +102,8 @@ test 'recording lookup with releases' => sub { disambiguation => "", packaging => JSON::null, "packaging-id" => JSON::null, - }] + }, + ], }; }; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRelease.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRelease.pm index d5b08e7223c..a84211cb5db 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRelease.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupRelease.pm @@ -260,8 +260,8 @@ EOSQL %$common_release_json, collections => [ { - id => "5e8dd65f-7d52-4d6e-93f6-f84651e137ca", - name => "My Private Collection", + id => "f34c079d-374e-4436-9448-da92dedef3cd", + name => "My Collection", editor => "editor", type => "Release", "type-id" => "d94659b2-4ce5-3a98-b4b8-da1131cf33ee", @@ -269,8 +269,8 @@ EOSQL "release-count" => 1 }, { - id => "f34c079d-374e-4436-9448-da92dedef3cd", - name => "My Collection", + id => "5e8dd65f-7d52-4d6e-93f6-f84651e137ca", + name => "My Private Collection", editor => "editor", type => "Release", "type-id" => "d94659b2-4ce5-3a98-b4b8-da1131cf33ee", @@ -566,7 +566,7 @@ test 'release lookup with release-group and ratings' => sub { "primary-type-id" => "d6038452-8ee0-3f68-affc-2de9a1ede0b9", "secondary-types" => [], "secondary-type-ids" => [], - rating => { "votes-count" => 0, value => JSON::null }, + rating => { "votes-count" => 2, value => 5 }, } }; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupReleaseGroup.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupReleaseGroup.pm index 64d406b9de4..4ac830010d9 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupReleaseGroup.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/JSON/LookupReleaseGroup.pm @@ -173,7 +173,7 @@ test 'release group lookup with inc=artists+releases+tags+ratings' => sub { tags => [], }], disambiguation => "", - rating => { "votes-count" => 0, value => JSON::null }, + rating => { "votes-count" => 2, value => 5 }, tags => [], }; }; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupArtist.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupArtist.pm index f37f9ec929a..6c6580f56e6 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupArtist.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupArtist.pm @@ -68,13 +68,13 @@ ws_test 'artist lookup with releases', 7人祭7nin Matsuri - - サマーれげぇ!レインボー - Official + + Summer Reggae! Rainbow + Pseudo-Release normal jpn - + 2001-07-04 JP @@ -92,13 +92,13 @@ ws_test 'artist lookup with releases', 4942463511227 - - Summer Reggae! Rainbow - Pseudo-Release + + サマーれげぇ!レインボー + Official normal jpn - + 2001-07-04 JP @@ -162,6 +162,56 @@ ws_test 'artist lookup with releases and discids', DistanceDistanceUK dubstep artist Greg Sanders + + My Demons + Official + normal + + eng + + + 2007-01-29 + GB + + + 2007-01-29 + + United Kingdom + United Kingdom + + GB + + + + + 600116817020 + + + 1 + CD + + + 281289 + + 150 + 27852 + 49751 + 75876 + 99845 + 120876 + 140765 + 165856 + 188422 + 211757 + 232229 + 255810 + + + + + + + Repercussions Official @@ -231,56 +281,6 @@ ws_test 'artist lookup with releases and discids', - - My Demons - Official - normal - - eng - - - 2007-01-29 - GB - - - 2007-01-29 - - United Kingdom - United Kingdom - - GB - - - - - 600116817020 - - - 1 - CD - - - 281289 - - 150 - 27852 - 49751 - 75876 - 99845 - 120876 - 140765 - 165856 - 188422 - 211757 - 232229 - 255810 - - - - - - - '; @@ -460,21 +460,21 @@ ws_test 'artist lookup with works (using l_recording_work)', BoABoA 1986-11-05 - Easy To Be Hard - 心の手紙 + Be the one DOUBLE - the Love Bug - SOME DAY ONE DAY ) + Easy To Be Hard + EXPECT + LOVE & HONESTY + Midnight Parade Milky Way 〜君の歌〜 Milky Way-君の歌- - Be the one + OVER~across the time~ Rock With You - Midnight Parade Shine We Are! - EXPECT + SOME DAY ONE DAY ) Song With No Name~名前のない歌~ - OVER~across the time~ - LOVE & HONESTY + the Love Bug + 心の手紙 '; @@ -501,6 +501,7 @@ ws_test 'artist lookup with artist relations', 802673f0-9b88-4e8a-bb5c-dd01d68b086f + forward 2001 7人祭 diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupDiscID.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupDiscID.pm index 74f73f74a18..71e2178d2ed 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupDiscID.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupDiscID.pm @@ -99,14 +99,14 @@ ws_test 'lookup via toc', '/discid/aa11.sPglQ1x0cybDcDi0OsZw9Q-?toc=1 9 189343 150 6614 32287 54041 61236 88129 92729 115276 153877&cdstubs=no&inc=tags+genres' => ' - - + + Aerial normal - 2008 + 2007 - 2008 + 2007 @@ -127,31 +127,33 @@ ws_test 'lookup via toc', A Sky of Honey 2 Format - + + + 189343 + + 150 + 6614 + 32287 + 54041 + 61236 + 88129 + 92729 + 115276 + 153877 + + + - - - musical - - - - - musical - - - not-used - - - + Aerial normal - 2007 + 2008 - 2007 + 2008 @@ -172,25 +174,23 @@ ws_test 'lookup via toc', A Sky of Honey 2 Format - - - 189343 - - 150 - 6614 - 32287 - 54041 - 61236 - 88129 - 92729 - 115276 - 153877 - - - + + + + musical + + + + + musical + + + not-used + + '; @@ -209,14 +209,14 @@ ws_test 'lookup via toc with invalid discid parameter', '/discid/-?toc=1 9 189343 150 6614 32287 54041 61236 88129 92729 115276 153877&cdstubs=no' => ' - - + + Aerial normal - 2008 + 2007 - 2008 + 2007 @@ -237,18 +237,33 @@ ws_test 'lookup via toc with invalid discid parameter', A Sky of Honey 2 Format - + + + 189343 + + 150 + 6614 + 32287 + 54041 + 61236 + 88129 + 92729 + 115276 + 153877 + + + - + Aerial normal - 2007 + 2008 - 2007 + 2008 @@ -269,22 +284,7 @@ ws_test 'lookup via toc with invalid discid parameter', A Sky of Honey 2 Format - - - 189343 - - 150 - 6614 - 32287 - 54041 - 61236 - 88129 - 92729 - 115276 - 153877 - - - + diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupLabel.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupLabel.pm index 0f3fb6578f0..6bbf3c52da8 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupLabel.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupLabel.pm @@ -103,19 +103,19 @@ ws_test 'label lookup with releases, inc=media', 1995 - - Repercussions + + My Demons Official normal eng - 2008-11-17 + 2007-01-29 GB - 2008-11-17 + 2007-01-29 United Kingdom United Kingdom @@ -125,30 +125,26 @@ ws_test 'label lookup with releases, inc=media', - 600116822123 - - - 1CD - + 600116817020 + - Chestplate Singles - 2CD + 1CD - - My Demons + + Repercussions Official normal eng - 2007-01-29 + 2008-11-17 GB - 2007-01-29 + 2008-11-17 United Kingdom United Kingdom @@ -158,10 +154,14 @@ ws_test 'label lookup with releases, inc=media', - 600116817020 - + 600116822123 + - 1CD + 1CD + + + Chestplate Singles + 2CD diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupNonCore.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupNonCore.pm index ce9ed96c985..571494ab2ac 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupNonCore.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupNonCore.pm @@ -133,12 +133,12 @@ ws_test 'isrc lookup with releases', HELLO! また会おうね (7人祭 version)213106 - - サマーれげぇ!レインボー - Official + + Summer Reggae! Rainbow + Pseudo-Release normal - jpn + jpn 2001-07-04 JP @@ -156,12 +156,12 @@ ws_test 'isrc lookup with releases', 4942463511227 - - Summer Reggae! Rainbow - Pseudo-Release + + サマーれげぇ!レインボー + Official normal - jpn + jpn 2001-07-04 JP diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRecording.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRecording.pm index e86356ef57a..3f46ce35ca5 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRecording.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRecording.pm @@ -34,12 +34,12 @@ ws_test 'recording lookup with releases', サマーれげぇ!レインボー296026 - - サマーれげぇ!レインボー - Official + + Summer Reggae! Rainbow + Pseudo-Release normal - jpn + jpn 2001-07-04 JP @@ -57,12 +57,12 @@ ws_test 'recording lookup with releases', 4942463511227 - - Summer Reggae! Rainbow - Pseudo-Release + + サマーれげぇ!レインボー + Official normal - jpn + jpn 2001-07-04 JP @@ -220,6 +220,7 @@ ws_test 'recording lookup with release relationships', 4ccb3e54-caab-4ad4-94a6-a598e0e52eec + forward 2008 An Inextricable Tale Audiobook diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelationship.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelationship.pm index 622b71f6dd7..d3cccfa0a18 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelationship.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelationship.pm @@ -25,17 +25,21 @@ ws_test 'artist lookup with url relationships', DistanceDistanceUK dubstep artist Greg Sanders + forward http://dj-distance.blogspot.com/ - - http://en.wikipedia.org/wiki/Distance_(musician) - + forward http://www.discogs.com/artist/DJ+Distance + forward http://www.myspace.com/djdistancedub + + forward + http://en.wikipedia.org/wiki/Distance_(musician) + '; @@ -52,6 +56,7 @@ ws_test 'artist lookup with non-url relationships', 0cf3008f-e246-428f-abc1-35f87d584d60 + forward guest the Love Bug243000 @@ -97,6 +102,7 @@ ws_test 'release lookup with release relationships', b3b7e934-445b-4c68-a097-730c6a6d47e6 + forward Summer Reggae! Rainbow @@ -183,18 +189,22 @@ ws_test 'label lookup with label and url relationships', + + forward + http://www.discogs.com/label/Rhythm+Zone + + + forward + http://rzn.jp/ + + forward http://en.wikipedia.org/wiki/Rhythm_Zone + forward http://ja.wikipedia.org/wiki/Rhythm_zone - - http://rzn.jp/ - - - http://www.discogs.com/label/Rhythm+Zone - '; @@ -209,9 +219,11 @@ ws_test 'release group lookup with url relationships', Single + forward http://en.wikipedia.org/wiki/The_Love_Bug_(song) + forward http://ja.wikipedia.org/wiki/The_Love_Bug @@ -265,6 +277,7 @@ ws_test 'release lookup with recording-level relationships', 256666 + forward e8d55116-1ea6-339a-a059-228d71c2f27d Reverend Charisma @@ -282,6 +295,7 @@ ws_test 'release lookup with recording-level relationships', 2cd04f80-fbd7-343f-8499-bf0028f0f530 + forward Dear Diary @@ -298,6 +312,7 @@ ws_test 'release lookup with recording-level relationships', b07e71c7-1cc7-3c6f-8c31-22be30a472dd + forward Black Sundress @@ -314,6 +329,7 @@ ws_test 'release lookup with recording-level relationships', c4a1c334-ccd3-37df-b248-40653cefb181 + forward Allegiance?WTF? @@ -330,6 +346,7 @@ ws_test 'release lookup with recording-level relationships', b26203e5-73cb-3579-b575-a12d8b3f8209 + forward Maggie&Heidi @@ -354,6 +371,7 @@ ws_test 'release lookup with recording-level relationships', 9c38c012-9b30-30a2-a2fb-4b44afdc3973 + forward Still Unsatisfied @@ -370,6 +388,7 @@ ws_test 'release lookup with recording-level relationships', f5cdd40d-6dc3-358b-8d7d-22dd9d8f87a8 + forward Asseswaving jpn @@ -418,6 +437,7 @@ ws_test 'recording lookup with work-level relationships', f5cdd40d-6dc3-358b-8d7d-22dd9d8f87a8 + forward Asseswaving jpn diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelease.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelease.pm index c462df8c4d9..76e140c48d0 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelease.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupRelease.pm @@ -403,8 +403,8 @@ ws_test 'release lookup with labels, recordings and tags', '; -ws_test 'release lookup with release-groups', - '/release/aff4a693-5970-4e2e-bd46-e2ee49c22de7?inc=artist-credits+release-groups' => +ws_test 'release lookup with release-groups and ratings', + '/release/aff4a693-5970-4e2e-bd46-e2ee49c22de7?inc=artist-credits+release-groups+ratings' => ' @@ -420,6 +420,7 @@ ws_test 'release lookup with release-groups', m-flo m-flo + 3 @@ -432,9 +433,11 @@ ws_test 'release lookup with release-groups', m-flo m-flo + 3 + 5 2004-03-17 JP @@ -654,6 +657,7 @@ ws_test 'release lookup, relation attributes', 757a1723-3769-4298-89cd-48d31177852a + forward LOVE & HONESTY normal @@ -749,7 +753,8 @@ ws_test 'release lookup, related artists have no tags', On My Bus267560 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -763,7 +768,8 @@ ws_test 'release lookup, related artists have no tags', Top & Low Rent230506 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -777,7 +783,8 @@ ws_test 'release lookup, related artists have no tags', Plock237133 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -791,7 +798,8 @@ ws_test 'release lookup, related artists have no tags', Marbles229826 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -805,7 +813,8 @@ ws_test 'release lookup, related artists have no tags', Busy Working217440 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -819,7 +828,8 @@ ws_test 'release lookup, related artists have no tags', The Greek Alphabet227293 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -833,7 +843,8 @@ ws_test 'release lookup, related artists have no tags', Press a Key244506 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -847,7 +858,8 @@ ws_test 'release lookup, related artists have no tags', Bibi Plone173960 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -861,7 +873,8 @@ ws_test 'release lookup, related artists have no tags', Be Rude to Your School208706 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -875,7 +888,8 @@ ws_test 'release lookup, related artists have no tags', Summer Plays Out320067 - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -888,7 +902,8 @@ ws_test 'release lookup, related artists have no tags', - 3088b672-fba9-4b4b-8ae0-dce13babfbb4backward + 3088b672-fba9-4b4b-8ae0-dce13babfbb4 + backward PlonePlone @@ -967,19 +982,22 @@ ws_test 'release lookup, track artists have no aliases', - 22dd2db3-88ea-4428-a7a8-5cd3acf23175backward + 22dd2db3-88ea-4428-a7a8-5cd3acf23175 + backward m-flom-flo - 22dd2db3-88ea-4428-a7a8-5cd3acf23175backward + 22dd2db3-88ea-4428-a7a8-5cd3acf23175 + backward m-flom-flo - a16d1433-ba89-4f72-a47b-a370add0bb55backward + a16d1433-ba89-4f72-a47b-a370add0bb55 + backward guest diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupReleaseGroup.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupReleaseGroup.pm index 7bab55893f8..720e8b54ad7 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupReleaseGroup.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/2/LookupReleaseGroup.pm @@ -143,6 +143,7 @@ ws_test 'release group lookup with inc=artists+releases+tags+ratings', 4988064451180 + 5 '; diff --git a/t/lib/t/MusicBrainz/Server/Controller/WS/js/Release.pm b/t/lib/t/MusicBrainz/Server/Controller/WS/js/Release.pm index 02bdd90fc11..14000acaa8d 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/WS/js/Release.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/WS/js/Release.pm @@ -77,6 +77,8 @@ test all => sub { linkOrder => 0, entity0_credit => '', entity1_credit => '', + entity0_id => 9496, + entity1_id => 4525123, }, "BoA performed vocals"); is_deeply( diff --git a/t/lib/t/MusicBrainz/Server/Data/CoverArt.pm b/t/lib/t/MusicBrainz/Server/Data/CoverArt.pm index 5cce913c0ad..127c0380f79 100644 --- a/t/lib/t/MusicBrainz/Server/Data/CoverArt.pm +++ b/t/lib/t/MusicBrainz/Server/Data/CoverArt.pm @@ -139,6 +139,7 @@ sub make_release { my ($type, $url) = @_; my $release = Release->new( name => 'Test release' ); + my $url = URL->new( url => $url ); $release->add_relationship( Relationship->new( link => Link->new( @@ -146,9 +147,11 @@ sub make_release name => $type ), ), - entity1 => URL->new( - url => $url, - ), + entity1 => $url, + source => $release, + target => $url, + source_type => 'release', + target_type => 'url', direction => $MusicBrainz::Server::Entity::Relationship::DIRECTION_FORWARD, ) ); diff --git a/t/lib/t/MusicBrainz/Server/Data/DurationLookup.pm b/t/lib/t/MusicBrainz/Server/Data/DurationLookup.pm index b9f46cda0fb..a830dbd4ed5 100644 --- a/t/lib/t/MusicBrainz/Server/Data/DurationLookup.pm +++ b/t/lib/t/MusicBrainz/Server/Data/DurationLookup.pm @@ -49,17 +49,17 @@ test 'tracklist used to fit lookup criteria but no longer does' => sub { my $toc = "1 2 44412 0 24762"; - my $durationlookup = $c->model('DurationLookup')->lookup($toc, 10000); - is(scalar @$durationlookup, 0, "disc does not exist yet, no match with TOC lookup"); + my ($durationlookup, $hits) = $c->model('DurationLookup')->lookup($toc, 10000); + is($hits, 0, "disc does not exist yet, no match with TOC lookup"); my $created = $c->model('Medium')->insert($insert_hash); my $medium = $c->model('Medium')->get_by_id($created->{id}); isa_ok($medium, 'MusicBrainz::Server::Entity::Medium'); - $durationlookup = $c->model('DurationLookup')->lookup($toc, 10000); - is(scalar @$durationlookup, 1, "one match with TOC lookup"); + ($durationlookup, $hits) = $c->model('DurationLookup')->lookup($toc, 10000); + is($hits, 1, "one match with TOC lookup"); - $medium = $durationlookup->[0]->medium; + $medium = $c->model('Medium')->get_by_id($durationlookup->[0]{results}[0]{medium}); $c->model('Track')->load_for_mediums($medium); $c->model('ArtistCredit')->load($medium->all_tracks); @@ -78,8 +78,8 @@ test 'tracklist used to fit lookup criteria but no longer does' => sub { accept_edit($c, $edit); - $durationlookup = $c->model('DurationLookup')->lookup($toc, 10000); - is(scalar @$durationlookup, 0, "duration lookup did not find medium after it was edited"); + ($durationlookup, $hits) = $c->model('DurationLookup')->lookup($toc, 10000); + is($hits, 0, "duration lookup did not find medium after it was edited"); }; test 'TOC lookup for disc with pregap track' => sub { @@ -129,10 +129,10 @@ test 'TOC lookup for disc with pregap track' => sub { $c->model('Track')->load_for_mediums($medium); is($medium->length, 1122 + 330160, "inserted medium has expected length"); - my $durationlookup = $c->model('DurationLookup')->lookup("1 1 39872 15110", 1); - is(scalar @$durationlookup, 1, "one match with TOC lookup"); + my ($durationlookup, $hits) = $c->model('DurationLookup')->lookup("1 1 39872 15110", 1); + is($hits, 1, "one match with TOC lookup"); - $medium = $durationlookup->[0]->medium; + $medium = $c->model('Medium')->get_by_id($durationlookup->[0]{results}[0]{medium}); is($medium->id, $created->{id}); is($medium->name, 'Bonus disc', 'TOC lookup found correct disc'); }; @@ -147,38 +147,36 @@ my $sql = $test->c->sql; my $lookup_data = MusicBrainz::Server::Data::DurationLookup->new(c => $test->c); does_ok($lookup_data, 'MusicBrainz::Server::Data::Role::Context'); -my $result = $lookup_data->lookup("1 7 171327 150 22179 49905 69318 96240 121186 143398", 10000); -ok ( scalar(@$result) > 0, 'found results' ); +my ($release_results, $hits) = $lookup_data->lookup("1 7 171327 150 22179 49905 69318 96240 121186 143398", 10000); +ok ( $hits > 0, 'found results' ); +my $results = $release_results->[0]{results}; -if (my ($result) = grep { $_->medium_id == 1 } @$result) { +if (my ($result) = grep { $_->{medium} == 1 } @$results) { ok($result, 'returned medium 1'); - is( $result->distance, 1 ); - is( $result->medium->id, 1 ); - is( $result->medium_id, 1 ); + is( $result->{distance}, 1 ); + is( $result->{medium}, 1 ); } -if (my ($result) = grep { $_->medium_id == 3 } @$result) { +if (my ($result) = grep { $_->{medium} == 3 } @$results) { ok($result, 'returned medium 3'); - is( $result->distance, 1 ); - is( $result->medium->id, 3 ); - is( $result->medium_id, 3 ); + is( $result->{distance}, 1 ); + is( $result->{medium}, 3 ); } -$result = $lookup_data->lookup("1 9 189343 150 6614 32287 54041 61236 88129 92729 115276 153877", 10000); +($release_results, $hits) = $lookup_data->lookup("1 9 189343 150 6614 32287 54041 61236 88129 92729 115276 153877", 10000); +$results = $release_results->[0]{results}; -if (my ($result) = grep { $_->medium_id == 2 } @$result) { +if (my ($result) = grep { $_->{medium} == 2 } @$results) { ok($result, 'returned medium 1'); - is( $result->distance, 1 ); - is( $result->medium->id, 2 ); - is( $result->medium_id, 2 ); + is( $result->{distance}, 1 ); + is( $result->{medium}, 2 ); } -if (my ($result) = grep { $_->medium_id == 4 } @$result) { +if (my ($result) = grep { $_->{medium} == 4 } @$results) { ok($result, 'returned medium 4'); - is( $result->distance, 1 ); - is( $result->medium->id, 4 ); - is( $result->medium_id, 4 ); + is( $result->{distance}, 1 ); + is( $result->{medium}, 4 ); } diff --git a/t/lib/t/MusicBrainz/Server/Data/Medium.pm b/t/lib/t/MusicBrainz/Server/Data/Medium.pm index d977df85fa9..70857615a20 100644 --- a/t/lib/t/MusicBrainz/Server/Data/Medium.pm +++ b/t/lib/t/MusicBrainz/Server/Data/Medium.pm @@ -64,10 +64,10 @@ test 'Insert medium' => sub { my $toc = "1 2 $leadoutoffset $trackoffset0 $trackoffset1"; my $fuzzy = 1; - my $durationlookup = $c->model('DurationLookup')->lookup($toc, $fuzzy); - is(scalar @$durationlookup, 1, "one match with TOC lookup"); + my ($durationlookup, $hits) = $c->model('DurationLookup')->lookup($toc, $fuzzy); + is($hits, 1, "one match with TOC lookup"); - $medium = $durationlookup->[0]->medium; + $medium = $c->model('Medium')->get_by_id($durationlookup->[0]{results}[0]{medium}); is($medium->id, $created->{id}); is($medium->name, 'Bonus disc', 'TOC lookup found correct disc'); }; diff --git a/t/lib/t/MusicBrainz/Server/Data/Release.pm b/t/lib/t/MusicBrainz/Server/Data/Release.pm index bd1c8d3caa8..5d89f3de408 100644 --- a/t/lib/t/MusicBrainz/Server/Data/Release.pm +++ b/t/lib/t/MusicBrainz/Server/Data/Release.pm @@ -374,8 +374,8 @@ is( scalar(@$releases), 6 ); ok( (grep { $_->id == 1 } @$releases), 'found release by release group' ); ok( (grep { $_->id == 2 } @$releases), 'found release by release group' ); -my @releases = $release_data->find_by_medium(1); -is( $releases[0]->id, 3 ); +($releases, $hits) = $release_data->find_by_medium([1], 25, 0); +is( $releases->[0]->id, 3 ); my $annotation = $release_data->annotation->get_latest(1); is ( $annotation->text, "Annotation" ); diff --git a/t/lib/t/MusicBrainz/Server/Edit/Artist/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/Artist/AddAnnotation.pm index cd96d29a86b..7abb9b45a8c 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/Artist/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/Artist/AddAnnotation.pm @@ -32,7 +32,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{artist}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); my $artist = $c->model('Artist')->get_by_id(1); diff --git a/t/lib/t/MusicBrainz/Server/Edit/Label/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/Label/AddAnnotation.pm index 624aa39f219..a8095199b06 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/Label/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/Label/AddAnnotation.pm @@ -33,7 +33,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{label}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); $label = $c->model('Label')->get_by_id(1); $c->model('Label')->annotation->load_latest($label); diff --git a/t/lib/t/MusicBrainz/Server/Edit/Recording/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/Recording/AddAnnotation.pm index e638d89d921..661b6ce0285 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/Recording/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/Recording/AddAnnotation.pm @@ -32,7 +32,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{recording}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); my $recording = $c->model('Recording')->get_by_id(1); @@ -46,7 +45,6 @@ is($annotation->changelog, 'A changelog'); my $annotation2 = $c->model('Recording')->annotation->get_by_id($edit->annotation_id); is_deeply($annotation, $annotation2); - }; 1; diff --git a/t/lib/t/MusicBrainz/Server/Edit/Release/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/Release/AddAnnotation.pm index 0cfc4e200c4..0eef8a75acd 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/Release/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/Release/AddAnnotation.pm @@ -32,7 +32,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{release}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); my $release = $c->model('Release')->get_by_id(1); diff --git a/t/lib/t/MusicBrainz/Server/Edit/ReleaseGroup/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/ReleaseGroup/AddAnnotation.pm index 454009959f9..b4fab1688bf 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/ReleaseGroup/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/ReleaseGroup/AddAnnotation.pm @@ -32,7 +32,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{release_group}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); my $release_group = $c->model('ReleaseGroup')->get_by_id(1); diff --git a/t/lib/t/MusicBrainz/Server/Edit/Work/AddAnnotation.pm b/t/lib/t/MusicBrainz/Server/Edit/Work/AddAnnotation.pm index 1f7c2aa53e5..57def16b491 100644 --- a/t/lib/t/MusicBrainz/Server/Edit/Work/AddAnnotation.pm +++ b/t/lib/t/MusicBrainz/Server/Edit/Work/AddAnnotation.pm @@ -32,7 +32,6 @@ is($edits->[0]->id, $edit->id); $c->model('Edit')->load_all($edit); is($edit->display_data->{work}->id, 1); is($edit->display_data->{changelog}, 'A changelog'); -is($edit->display_data->{annotation_id}, $edit->annotation_id); my $work = $c->model('Work')->get_by_id(1); diff --git a/t/lib/t/MusicBrainz/Server/Entity/Medium.pm b/t/lib/t/MusicBrainz/Server/Entity/Medium.pm index c7dd7afd663..dab1486eba6 100644 --- a/t/lib/t/MusicBrainz/Server/Entity/Medium.pm +++ b/t/lib/t/MusicBrainz/Server/Entity/Medium.pm @@ -57,12 +57,18 @@ test 'combined_track_relationships' => sub { my $artist = Artist->new(id => 1, name => 'Person', sort_name => 'Person'); for my $i (0, 2, 3, 5) { + my $recording = $medium->tracks->[$i]->recording; + $medium->tracks->[$i]->recording->add_relationship( Relationship->new( direction => $MusicBrainz::Server::Entity::Relationship::DIRECTION_BACKWARD, link => $link, entity0 => $artist, - entity1 => $medium->tracks->[$i]->recording + entity1 => $recording, + source => $recording, + target => $artist, + source_type => 'recording', + target_type => 'artist', ) ); } diff --git a/t/lib/t/TemplateMacros.pm b/t/lib/t/TemplateMacros.pm index a3fdca7c0a3..173a79a68b6 100644 --- a/t/lib/t/TemplateMacros.pm +++ b/t/lib/t/TemplateMacros.pm @@ -4,6 +4,7 @@ use utf8; use Catalyst::Test 'MusicBrainz::Server'; use JSON::XS; use MusicBrainz::Server::Entity::Preferences; +use Scalar::Util qw( blessed ); use String::ShellQuote qw( shell_quote ); use Test::More; use Test::Routine; @@ -479,9 +480,275 @@ test all => sub { name => 'http://www.cdbaby.com/cd/shetler', ), ], + [ + q( + USE Diff; + acdiff = Diff.diff_artist_credits(entity.old, entity.new); + '
    ' _ acdiff.old _ '
    '; + '
    ' _ acdiff.new _ '
    '; + ), + + q((function () { + const acdiff = diffArtistCredits(entity['old'], entity['new']); + return React.createElement( + React.Fragment, + null, + React.createElement('div', {className: 'old'}, acdiff.old), + React.createElement('div', {className: 'new'}, acdiff.new), + ); + }())), + + '' . + '
    ' . + '' . + '' . + 'Hoenig' . + '' . + '' . + ' ' . + '' . + '' . + 'Göttsching' . + '' . + '' . + '
    ', + + { + old => ArtistCredit->new( + names => [ + ArtistCreditName->new( + name => 'Michael Hoenig', + artist => Artist->new( + gid => '3215500f-f03e-4adf-94fe-5ca842e17f5b', + id => 105321, + name => 'Michael Hoenig', + sort_name => 'Hoenig, Michael', + ), + join_phrase => ' and ', + ), + ArtistCreditName->new( + name => 'Manuel Göttsching', + artist => Artist->new( + gid => '00e3ab6b-4c4f-4ed6-991f-461a0ffa01b3', + id => 117488, + name => 'Manuel Göttsching', + sort_name => 'Göttsching, Manuel', + ), + join_phrase => '', + ), + ], + ), + new => ArtistCredit->new( + names => [ + ArtistCreditName->new( + name => 'Hoenig', + artist => Artist->new( + gid => '3215500f-f03e-4adf-94fe-5ca842e17f5b', + id => 105321, + name => 'Michael Hoenig', + sort_name => 'Hoenig, Michael', + ), + join_phrase => ' • ', + ), + ArtistCreditName->new( + name => 'Göttsching', + artist => Artist->new( + gid => '00e3ab6b-4c4f-4ed6-991f-461a0ffa01b3', + id => 117488, + name => 'Manuel Göttsching', + sort_name => 'Göttsching, Manuel', + ), + join_phrase => '', + ), + ], + ), + }, + ], + # MBS-8709 + [ + q( + USE Diff; + acdiff = Diff.diff_artist_credits(entity.old, entity.new); + '
    ' _ acdiff.old _ '
    '; + '
    ' _ acdiff.new _ '
    '; + ), + + q((function () { + const acdiff = diffArtistCredits(entity['old'], entity['new']); + return React.createElement( + React.Fragment, + null, + React.createElement('div', {className: 'old'}, acdiff.old), + React.createElement('div', {className: 'new'}, acdiff.new), + ); + }())), + + '' . + '
    ' . + '' . + 'The Jacksons' . + '' . + '
    ', + + { + old => ArtistCredit->new( + names => [ + ArtistCreditName->new( + name => 'Michael Jackson', + artist => Artist->new( + gid => 'f27ec8db-af05-4f36-916e-3d57f91ecf5e', + id => 519, + name => 'Michael Jackson', + sort_name => 'Jackson, Michael', + ), + join_phrase => '', + ) + ], + ), + new => ArtistCredit->new( + names => [ + ArtistCreditName->new( + name => 'The Jacksons', + artist => Artist->new( + id => 56345, + name => 'The Jacksons', + sort_name => 'Jacksons, The', + ), + join_phrase => '', + ) + ], + ), + }, + ], + # MBS-8709 + [ + q( + PROCESS 'edit/details/macros.tt'; + display_word_diff('Name:', entity.old, entity.new); + ), + + 'React.createElement(WordDiff, {label: "Name:", oldText: entity.old, newText: entity.new})', + + '' . + 'Name:' . + '' . + 'The Only Michael - Someone Else's Fur' . + '' . + '' . + 'Someone Else's Fur' . + '' . + '', + + { + old => "The Only Michael - Someone Else's Fur", + new => "Someone Else's Fur", + }, + ], + [ + q( + PROCESS 'edit/details/macros.tt'; + display_word_diff('Name:', entity.old, entity.new); + ), + + 'React.createElement(WordDiff, {label: "Name:", oldText: entity.old, newText: entity.new})', + + '' . + 'Name:' . + '' . + 'Some random text' . + '' . + '' . + 'Other arbitrary text' . + '' . + '', + + { + old => 'Some random text', + new => 'Other arbitrary text', + }, + ], + [ + q( + PROCESS 'edit/details/macros.tt'; + display_word_diff('Name:', entity.old, entity.new); + ), + + 'React.createElement(WordDiff, {label: "Name:", oldText: entity.old, newText: entity.new})', + + '' . + 'Name:' . + '' . + 'Die Diebische ' . + 'Elster (La Gazza Ladra) ' . + '(The Theiving Magpie · La Pie Voleuse)' . + '' . + '' . + 'Die diebische ' . + 'Elster (La gazza ladra) ' . + '(The Theiving Magpie · La pie voleuse)' . + '' . + '', + + { + old => 'Die Diebische Elster (La Gazza Ladra) (The Theiving Magpie · La Pie Voleuse)', + new => 'Die diebische Elster (La gazza ladra) (The Theiving Magpie · La pie voleuse)', + }, + ], + [ + q( + PROCESS 'edit/details/macros.tt'; + display_full_change('Name:', entity.old, entity.new); + ), + + 'React.createElement(FullChangeDiff, {label: "Name:", oldText: entity.old, newText: entity.new})', + + '' . + 'Name:' . + 'Old' . + 'New' . + '', + + { + old => 'Old', + new => 'New', + }, + ], + [ + q( + PROCESS 'edit/details/macros.tt'; + display_diff('Codes:', entity.old, entity.new, ', '); + ), + + 'React.createElement(Diff, {label: "Codes:", oldText: entity.old, newText: entity.new, split: ", "})', + + '' . + 'Codes:' . + 'A A, B B, C C' . + 'A A, C C, D D' . + '', + + { + old => 'A A, B B, C C', + new => 'A A, C C, D D', + }, + ], ); - my $test_data = shell_quote(encode_json([ + my $json = JSON::XS->new->allow_blessed->convert_blessed->utf8; + + my $test_data = shell_quote($json->encode([ map { my ($tt_macro, $react_element, $expected, $entity) = @{$_}; @@ -492,12 +759,12 @@ test all => sub { tt_markup => trim($ctx->response->body), react_element => $react_element, expected_markup => $expected, - entity => $entity->TO_JSON, + entity => blessed($entity) ? $entity->TO_JSON : $entity, }; } @tests ])); - my $test_results = decode_json( + my $test_results = $json->decode( `node ./root/static/build/react-macros-tests.js $test_data` ); diff --git a/t/selenium.js b/t/selenium.js index d3ade73e2cb..f0a193bc423 100755 --- a/t/selenium.js +++ b/t/selenium.js @@ -105,7 +105,9 @@ const customProxyServer = http.createServer(function (req, res) { req.headers['mb-set-database'] = 'SELENIUM'; req.rawHeaders['mb-set-database'] = 'SELENIUM'; } - proxy.web(req, res, {target: 'http://' + host}); + proxy.web(req, res, {target: 'http://' + host}, function (e) { + console.error(e); + }); }); const driver = (x => { diff --git a/t/selenium/External_Links_Editor.html b/t/selenium/External_Links_Editor.html index c4e091bbca6..bd9545c9cf8 100644 --- a/t/selenium/External_Links_Editor.html +++ b/t/selenium/External_Links_Editor.html @@ -72,7 +72,7 @@ assertText xpath=//table[@id='external-links-editor']//tr[2]//div[contains(@class, 'error')] - Please don't use shortened URLs. + Please don’t enter bundled/shortened URLs, enter the destination URL(s) instead. click diff --git a/t/selenium/release-editor/Seeding.html b/t/selenium/release-editor/Seeding.html index 92cfd5ed5fa..f5467f7c50f 100644 --- a/t/selenium/release-editor/Seeding.html +++ b/t/selenium/release-editor/Seeding.html @@ -464,6 +464,22 @@ document.querySelector('tr.track td.title input').value foo + + + openFile + ./seeds/no_barcode.html + + + + clickAndWait + css=button[type=submit] + + + + assertEval + window.document.getElementById('barcode').disabled + true + diff --git a/t/selenium/release-editor/seeds/no_barcode.html b/t/selenium/release-editor/seeds/no_barcode.html new file mode 100644 index 00000000000..c2ec794c540 --- /dev/null +++ b/t/selenium/release-editor/seeds/no_barcode.html @@ -0,0 +1,9 @@ + + + +
    + + +
    + + diff --git a/t/sql/webservice.sql b/t/sql/webservice.sql index 43122bf5168..9eff9f6ac1d 100644 --- a/t/sql/webservice.sql +++ b/t/sql/webservice.sql @@ -164,7 +164,7 @@ UPDATE release_group_meta SET first_release_date_month = 7, rating_count = NULL, UPDATE release_group_meta SET first_release_date_month = 9, rating_count = NULL, first_release_date_year = 1999, release_count = 3, first_release_date_day = 13, rating = NULL WHERE id = 155364; UPDATE release_group_meta SET first_release_date_month = 1, rating_count = 1, first_release_date_year = 2007, release_count = 1, first_release_date_day = 29, rating = 80 WHERE id = 597897; UPDATE release_group_meta SET first_release_date_month = 11, rating_count = NULL, first_release_date_year = 2008, release_count = 1, first_release_date_day = 17, rating = NULL WHERE id = 761939; -UPDATE release_group_meta SET first_release_date_month = 3, rating_count = NULL, first_release_date_year = 2004, release_count = 1, first_release_date_day = 17, rating = NULL WHERE id = 403214; +UPDATE release_group_meta SET first_release_date_month = 3, rating_count = 2, first_release_date_year = 2004, release_count = 1, first_release_date_day = 17, rating = 100 WHERE id = 403214; UPDATE release_group_meta SET first_release_date_month = 1, rating_count = NULL, first_release_date_year = 2004, release_count = 3, first_release_date_day = 15, rating = NULL WHERE id = 326504; UPDATE release_group_meta SET first_release_date_month = NULL, rating_count = 3, first_release_date_year = 1999, release_count = 9, first_release_date_day = NULL, rating = 87 WHERE id = 724150; UPDATE release_group_meta SET first_release_date_month = 6, rating_count = NULL, first_release_date_year = 1998, release_count = 2, first_release_date_day = 15, rating = NULL WHERE id = 148670; diff --git a/webpack.tests.config.js b/webpack.tests.config.js index d46e46f385b..57a78c32e34 100644 --- a/webpack.tests.config.js +++ b/webpack.tests.config.js @@ -29,6 +29,7 @@ const baseTestsConfig = { const webTestsConfig = { entry: { + 'autocomplete2': path.resolve(dirs.SCRIPTS, 'tests', 'autocomplete2.js'), 'web-tests': path.resolve(dirs.SCRIPTS, 'tests', 'browser-runner.js'), }, diff --git a/yarn.lock b/yarn.lock index 79ff1705c84..ac73fc3dad8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2019,6 +2019,13 @@ debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: dependencies: ms "2.0.0" +debug@^3.0.0: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + debug@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" @@ -2490,10 +2497,10 @@ esutils@^2.0.0, esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= -eventemitter3@1.x.x: - version "1.2.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" - integrity sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg= +eventemitter3@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" + integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== events@^3.0.0: version "3.0.0" @@ -2712,10 +2719,10 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg== -flow-bin@0.106.2: - version "0.106.2" - resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.106.2.tgz#624db7c04b00879ac14853434817c7bc5e1419db" - integrity sha512-pALWFKf+AQiX4VfSEdxruj2bSMigsrAcg8djV6Hue2y3FJyiA/Z42UkEv6zEvSIpDj1EL+cRBvJNUx6L2+gdTQ== +flow-bin@0.109.0: + version "0.109.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.109.0.tgz#dcdcb7402dd85b58200392d8716ccf14e5a8c24c" + integrity sha512-tpcMTpAGIRivYhFV3KJq+zHI2HzcXo8MoGe9pXS4G/UZuey2Faq/e8/gdph2WF0erRlML5hmwfwiq7v9c25c7w== flush-write-stream@^1.0.0: version "1.1.1" @@ -2725,6 +2732,13 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +follow-redirects@^1.0.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f" + integrity sha512-CRcPzsSIbXyVDl0QI01muNDu69S8trU4jArW9LpOt2WtC6LyUJetcIrmfHsRBx7/Jb6GHJUiuqyYxPooFfNt6A== + dependencies: + debug "^3.0.0" + for-each@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" @@ -2856,6 +2870,13 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +generic-diff@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/generic-diff/-/generic-diff-1.0.1.tgz#21d40763a18fc5acd52065c11a8d9b2f95572259" + integrity sha1-IdQHY6GPxazVIGXBGo2bL5VXIlk= + dependencies: + object-assign "^2.0.0" + get-caller-file@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" @@ -3145,13 +3166,14 @@ html-encoding-sniffer@^1.0.2: dependencies: whatwg-encoding "^1.0.1" -http-proxy@1.16.2: - version "1.16.2" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.16.2.tgz#06dff292952bf64dbe8471fa9df73066d4f37742" - integrity sha1-Bt/ykpUr9k2+hHH6nfcwZtTzd0I= +http-proxy@1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" + integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== dependencies: - eventemitter3 "1.x.x" - requires-port "1.x.x" + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" http-signature@~1.1.0: version "1.1.1" @@ -4354,6 +4376,11 @@ oauth-sign@~0.9.0: resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== +object-assign@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-2.1.1.tgz#43c36e5d569ff8e4816c4efa8be02d26967c18aa" + integrity sha1-Q8NuXVaf+OSBbE76i+AtJpZ8GKo= + object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -4910,25 +4937,24 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-dom@16.8.2: - version "16.8.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.2.tgz#7c8a69545dd554d45d66442230ba04a6a0a3c3d3" - integrity sha512-cPGfgFfwi+VCZjk73buu14pYkYBR1b/SRMSYqkLDdhSEHnSwcuYTPu6/Bh6ZphJFIk80XLvbSe2azfcRzNF+Xg== +react-dom@16.10.2: + version "16.10.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.10.2.tgz#4840bce5409176bc3a1f2bd8cb10b92db452fda6" + integrity sha512-kWGDcH3ItJK4+6Pl9DZB16BXYAZyrYQItU4OMy0jAkv5aNqc+mAKb4TpFtAteI6TJZu+9ZlNhaeNQSVQDHJzkw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.13.2" + scheduler "^0.16.2" -react@16.8.2: - version "16.8.2" - resolved "https://registry.yarnpkg.com/react/-/react-16.8.2.tgz#83064596feaa98d9c2857c4deae1848b542c9c0c" - integrity sha512-aB2ctx9uQ9vo09HVknqv3DGRpI7OIGJhCx3Bt0QqoRluEjHSaObJl+nG12GDdYH6sTgE7YiPJ6ZUyMx9kICdXw== +react@16.10.2: + version "16.10.2" + resolved "https://registry.yarnpkg.com/react/-/react-16.10.2.tgz#a5ede5cdd5c536f745173c8da47bda64797a4cf0" + integrity sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.13.2" read-pkg-up@^2.0.0: version "2.0.0" @@ -5220,7 +5246,7 @@ require-main-filename@^1.0.1: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= -requires-port@1.x.x: +requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= @@ -5394,10 +5420,10 @@ saxes@^3.1.5: dependencies: xmlchars "^1.3.1" -scheduler@^0.13.2: - version "0.13.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.2.tgz#969eaee2764a51d2e97b20a60963b2546beff8fa" - integrity sha512-qK5P8tHS7vdEMCW5IPyt8v9MJOHqTrOUgPXib7tqm9vh834ibBX5BNhwkplX/0iOzHW5sXyluehYfS9yrkz9+w== +scheduler@^0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.16.2.tgz#f74cd9d33eff6fc554edfb79864868e4819132c1" + integrity sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"