From d598d9498811d761ff3f6b55ec7f7fe4c41219ec Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Wed, 9 Oct 2024 22:03:16 +0200 Subject: [PATCH] Bug 38136: Refactor database translations (alternative) This has the same goals of bug 24975 but with a different approach. Bug 24975 tried the "POT/PO files as database tables" approach but it appeared that is not what best fits our needs. Instead of a "source text / translated text" mapping, we need translations to be attached to a specific "object" (or table row), which has the added benefit of keeping translations unaffected when the original string changes. It may or may not be what you want but at least this avoids the problem where fixing an error in the original string forces users to re-translate everything. The solution proposed here is to store translations in separate tables, one table for each object type (so, one for itemtypes, another for authorised values, ...). While it may seem redundant and tedious to add another table with the same structure for each localizable object type, this has the benefit of being able to add foreign key constraints and thus having integrity checks "for free". Also this patch adds all the necessary tools so that the tedium of localizing a new object type is reduced to a minimum. In summary, we will only need to: * create the table structure (atomicupdate, kohastructure.sql) * add two method calls in the "result class" corresponding to the localizable object type * in the admin interface, add a link to the the translation popup. Unlike bug 24975 there is not a page where you can translate everything. I don't know if it is really useful (probably better to translate itemtypes when you are on the itemtype page). It can be added later if needed. To give an example, this is what needs to be added manually in Koha/Schema/Result/Itemtype.pm: __PACKAGE__->load_components('+Koha::DBIx::Class::Localization'); __PACKAGE__->localization_add_relationships( 'itemtypes_localizations', 'itemtype' => 'itemtype', 'description' ); (see POD in Koha/DBIx/Class/Localization.pm for more information) Final notes: * If having several localization tables is really a problem, I believe the current implementation can be changed easily to use a single table (just need to change the dbic relationship conditions) * I wanted this patch to have as few changes as possible so I kept methods like `search_with_localization`, `children_with_localization` even if I believe they can be removed (replaced by search/children) Localizations do not need to be prefetched/joined unless you want to sort on them. For the same reason, $itemtype->unblessed now always return a `translated_description` key. This can be fixed in followup patches * There is cache involved (see Koha::DBIx::Class::Localization::localization). Localizations in cache are grouped by object type and language (keys look like 'KOHA:localization:Itemtype:en') --- Koha/DBIx/Class/Localization.pm | 200 ++++++++++++++++++ Koha/ItemType.pm | 88 ++------ Koha/ItemTypes.pm | 34 ++- Koha/Localization.pm | 73 ------- Koha/Localizations.pm | 34 --- Koha/Object.pm | 27 +++ Koha/Schema/Result/Itemtype.pm | 39 ++-- Koha/Schema/Result/ItemtypeLocalization.pm | 32 --- Koha/Schema/Result/ItemtypesLocalization.pm | 124 +++++++++++ Koha/Schema/Result/Localization.pm | 108 ---------- admin/itemtypes.pl | 1 - admin/localization.pl | 34 +-- .../data/mysql/atomicupdate/bug-38136.pl | 36 ++++ installer/data/mysql/kohastructure.sql | 17 ++ .../prog/en/includes/localization-link.inc | 1 + .../prog/en/modules/admin/itemtypes.tt | 2 +- .../prog/en/modules/admin/localization.tt | 71 ++++--- .../prog/js/fetch/localization-api-client.js | 21 +- svc/localization | 127 ++++++----- t/db_dependent/Koha/ItemTypes.t | 24 +-- .../Koha/Template/Plugin/ItemTypes.t | 17 +- t/db_dependent/api/v1/item_types.t | 27 +-- 22 files changed, 618 insertions(+), 519 deletions(-) create mode 100644 Koha/DBIx/Class/Localization.pm delete mode 100644 Koha/Localization.pm delete mode 100644 Koha/Localizations.pm delete mode 100644 Koha/Schema/Result/ItemtypeLocalization.pm create mode 100644 Koha/Schema/Result/ItemtypesLocalization.pm delete mode 100644 Koha/Schema/Result/Localization.pm create mode 100644 installer/data/mysql/atomicupdate/bug-38136.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/localization-link.inc diff --git a/Koha/DBIx/Class/Localization.pm b/Koha/DBIx/Class/Localization.pm new file mode 100644 index 0000000000..cfc09502ae --- /dev/null +++ b/Koha/DBIx/Class/Localization.pm @@ -0,0 +1,200 @@ +package Koha::DBIx::Class::Localization; + +=head1 NAME + +Koha::DBIx::Class::Localization + +=head1 SYNOPSIS + + package Koha::Schema::Result::SomeTable; + + # ... generated code ... + + __PACKAGE__->load_components('+Koha::DBIx::Class::Localization'); + + __PACKAGE__->localization_add_relationships( + 'some_table_localizations', + 'some_table_id' => 'some_table_id', + 'first_column', + 'second_column', + # ... + ); + + package main; + + my $rows = $schema->resultset('SomeTable'); + my $row = $rows->first; + $row->localizations->search($cond); + $row->create_related('first_column_localizations', { lang => $lang, translation => $translation }) + + while (my $row = $rows->next) + # first call will fetch all localizations for the current language and + # the result will be cached, next calls will not execute a query + $row->localization('first_column', $lang); + + # no query executed + $row->localization('second_column', $lang); + } + +=head1 DESCRIPTION + +This is a DBIx::Class component that helps to manage database localizations by +adding several relationships and methods to a "Result Class" + +This can handle several localizable columns (also referred as "properties") per +table + +To add database localizations to an existing database table, you need to: + +=over + +=item * Create a new table with: + +=over + +=item * An auto incremented column as primary key + +=item * A foreign key column referencing the existing table + +=item * 3 string (varchar or text) columns named 'property', 'lang', +'translation' + +=item * A unique key comprising the foreign key column, 'property' and 'lang' + +=back + +=item * Regenerate the DBIx::Class schema with +misc/devel/update_dbix_class_files.pl + +=item * Add calls to load_components and localization_add_relationships at the +end of the result class + +=back + +This will give you a relationship named 'localizations' through which you can +access all localizations of a particular table row. + +And for every property, you will have: + +=over + +=item * a "has_many" relationship named _localizations, giving access +to all localizations of a particular table row for this particular property + +=item * a "might_have" relationship named _localization, giving +access to the localization of a particular table row for this particular +property and for the current language (uses C4::Languages::getlanguage) + +=back + +The "row" object will also gain a method C +which returns a specific translation and uses cache to avoid executing lots of +queries + +=cut + +use Modern::Perl; + +use base qw(DBIx::Class); + +sub localization_add_relationships { + my ($class, $rel_name, $fk_column, $pk_column, @properties) = @_; + + my $rel_info = $class->relationship_info($rel_name); + + $class->has_many( + 'localizations', + $rel_info->{class}, + { "foreign.$fk_column" => "self.$pk_column" }, + { cascade_copy => 0, cascade_delete => 0, cascade_update => 0 }, + ); + + foreach my $property (@properties) { + $class->might_have( + $property . "_localization", + $rel_info->{class}, + sub { + my ($args) = @_; + + # Not a 'use' because we don't want to load C4::Languages (and + # thus C4::Context) while loading the schema + require C4::Languages; + my $lang = C4::Languages::getlanguage(); + + return ( + { + "$args->{foreign_alias}.$fk_column" => { -ident => "$args->{self_alias}.$pk_column" }, + "$args->{foreign_alias}.property" => $property, + "$args->{foreign_alias}.lang" => $lang, + }, + !$args->{self_result_object} ? () : { + "$args->{foreign_alias}.$fk_column" => $args->{self_result_object}->get_column($pk_column), + "$args->{foreign_alias}.property" => $property, + "$args->{foreign_alias}.lang" => $lang, + }, + !$args->{foreign_values} ? () : { + "$args->{foreign_alias}.$fk_column" => $args->{foreign_values}->{$pk_column}, + } + ); + }, + { cascade_copy => 0, cascade_delete => 0, cascade_update => 0 }, + ); + + $class->has_many( + $property . '_localizations', + $rel_info->{class}, + sub { + my ($args) = @_; + + return ( + { + "$args->{foreign_alias}.$fk_column" => { -ident => "$args->{self_alias}.$pk_column" }, + "$args->{foreign_alias}.property" => $property, + }, + !$args->{self_result_object} ? () : { + "$args->{foreign_alias}.$fk_column" => $args->{self_result_object}->get_column($pk_column), + "$args->{foreign_alias}.property" => $property, + }, + !$args->{foreign_values} ? () : { + "$args->{foreign_alias}.$fk_column" => $args->{foreign_values}->{$pk_column}, + } + ); + }, + { cascade_copy => 0, cascade_delete => 0, cascade_update => 0 }, + ); + } +} + +sub localization { + my ($self, $property, $lang) = @_; + + my $result_source = $self->result_source; + + my $cache = Koha::Caches->get_instance('localization'); + my $cache_key = sprintf('%s:%s', $result_source->source_name, $lang); + my $localizations_map = $cache->get_from_cache($cache_key); + unless ($localizations_map) { + $localizations_map = {}; + + my $rel_info = $self->relationship_info('localizations'); + my $rel_source = $rel_info->{class} =~ s/.*:://r; + + my ($fk_column) = map { s/^foreign\.//r } keys %{ $rel_info->{cond} }; + + my $localizations = $result_source->schema->resultset($rel_source)->search({ lang => $lang }); + while (my $localization = $localizations->next) { + my $fk = $localization->get_column($fk_column); + my $localization_key = sprintf('%s:%s', $fk, $property); + $localizations_map->{$localization_key} = $localization->translation; + } + + $cache->set_in_cache($cache_key, $localizations_map); + } + + my ($pk) = $self->id; + my $localization_key = sprintf('%s:%s', $pk, $property); + + return $localizations_map->{$localization_key}; +} + +1; diff --git a/Koha/ItemType.pm b/Koha/ItemType.pm index fb88518c25..0f352c84e6 100644 --- a/Koha/ItemType.pm +++ b/Koha/ItemType.pm @@ -22,7 +22,6 @@ use C4::Languages; use Koha::Caches; use Koha::Database; use Koha::CirculationRules; -use Koha::Localizations; use base qw(Koha::Object Koha::Object::Limit::Library); @@ -36,48 +35,6 @@ Koha::ItemType - Koha Item type Object class =head2 Class methods -=cut - -=head3 store - -ItemType specific store to ensure relevant caches are flushed on change - -=cut - -sub store { - my ($self) = @_; - - my $flush = 0; - - if ( !$self->in_storage ) { - $flush = 1; - } else { - my $self_from_storage = $self->get_from_storage; - $flush = 1 if ( $self_from_storage->description ne $self->description ); - } - - $self = $self->SUPER::store; - - if ($flush) { - my $key = "itemtype:description:en"; - $cache->clear_from_cache($key); - } - - return $self; -} - -=head2 delete - -ItemType specific C to clear relevant caches on delete. - -=cut - -sub delete { - my $self = shift @_; - $cache->clear_from_cache('itemtype:description:en'); - $self->SUPER::delete(@_); -} - =head3 image_location =cut @@ -93,26 +50,8 @@ sub image_location { sub translated_description { my ( $self, $lang ) = @_; - if ( my $translated_description = eval { $self->get_column('translated_description') } ) { - - # If the value has already been fetched (eg. from sarch_with_localization), - # do not search for it again - # Note: This is a bit hacky but should be fast - return $translated_description - ? $translated_description - : $self->description; - } - $lang ||= C4::Languages::getlanguage; - my $translated_description = Koha::Localizations->search( - { - code => $self->itemtype, - entity => 'itemtypes', - lang => $lang - } - )->next; - return $translated_description - ? $translated_description->translation - : $self->description; + + my $localization = $self->localization('description', $lang || C4::Languages::getlanguage()); } =head3 translated_descriptions @@ -121,19 +60,14 @@ sub translated_description { sub translated_descriptions { my ($self) = @_; - my @translated_descriptions = Koha::Localizations->search( - { - entity => 'itemtypes', - code => $self->itemtype, - } - )->as_list; + return [ map { { lang => $_->lang, translation => $_->translation, } - } @translated_descriptions + } $self->_result->description_localizations ]; } @@ -234,9 +168,23 @@ sub to_api_mapping { rentalcharge_daily_calendar => 'daily_rental_charge_calendar', rentalcharge_hourly => 'hourly_rental_charge', rentalcharge_hourly_calendar => 'hourly_rental_charge_calendar', + + # TODO Remove after having updated all code using unblessed translated_description + translated_description => undef, }; } +# TODO Remove after having updated all code using unblessed translated_description +sub unblessed { + my ($self) = @_; + + my $unblessed = $self->SUPER::unblessed(); + + $unblessed->{translated_description} = $self->translated_description; + + return $unblessed; +} + =head2 Internal methods =head3 _type diff --git a/Koha/ItemTypes.pm b/Koha/ItemTypes.pm index 13caa648a8..fc138a6478 100644 --- a/Koha/ItemTypes.pm +++ b/Koha/ItemTypes.pm @@ -33,8 +33,6 @@ Koha::ItemTypes - Koha ItemType Object set class =head2 Class methods -=cut - =head3 search_with_localization my $itemtypes = Koha::ItemTypes->search_with_localization @@ -44,22 +42,22 @@ my $itemtypes = Koha::ItemTypes->search_with_localization sub search_with_localization { my ( $self, $params, $attributes ) = @_; - my $language = C4::Languages::getlanguage(); - $Koha::Schema::Result::Itemtype::LANGUAGE = $language; - $attributes->{order_by} = 'translated_description' unless exists $attributes->{order_by}; - $attributes->{join} = 'localization'; - $attributes->{'+select'} = [ - { - coalesce => [qw( localization.translation me.description )], - -as => 'translated_description' - } - ]; - if(defined $params->{branchcode}) { - my $branchcode = delete $params->{branchcode}; - $self->search_with_library_limits( $params, $attributes, $branchcode ); - } else { - $self->SUPER::search( $params, $attributes ); - } + return $self->search( $params, $attributes )->order_by_translated_description; +} + +=head3 order_by_translated_description + +=cut + +sub order_by_translated_description { + my ($self) = @_; + + my $attributes = { + join => 'description_localization', + order_by => \['COALESCE(description_localization.translation, me.description)'], + }; + + return $self->search( {}, $attributes ); } =head2 Internal methods diff --git a/Koha/Localization.pm b/Koha/Localization.pm deleted file mode 100644 index 90201b8870..0000000000 --- a/Koha/Localization.pm +++ /dev/null @@ -1,73 +0,0 @@ -package Koha::Localization; - -# This file is part of Koha. -# -# Koha is free software; you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# Koha is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Koha; if not, see . - -use Modern::Perl; - -use Koha::Database; - -use base qw(Koha::Object); - -my $cache = Koha::Caches->get_instance(); - -=head1 NAME - -Koha::Localization - Koha Localization type Object class - -=head1 API - -=head2 Class methods - -=cut - -=head3 store - -Localization specific store to ensure relevant caches are flushed on change - -=cut - -sub store { - my ($self) = @_; - $self = $self->SUPER::store; - - if ($self->entity eq 'itemtypes') { - my $key = "itemtype:description:".$self->lang; - $cache->clear_from_cache($key); - } - - return $self; -} - -=head2 delete - -Localization specific C to clear relevant caches on delete. - -=cut - -sub delete { - my $self = shift @_; - if ($self->entity eq 'itemtypes') { - my $key = "itemtype:description:".$self->lang; - $cache->clear_from_cache($key); - } - $self->SUPER::delete(@_); -} - -sub _type { - return 'Localization'; -} - -1; diff --git a/Koha/Localizations.pm b/Koha/Localizations.pm deleted file mode 100644 index 8720334acd..0000000000 --- a/Koha/Localizations.pm +++ /dev/null @@ -1,34 +0,0 @@ -package Koha::Localizations; - -# This file is part of Koha. -# -# Koha is free software; you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# Koha is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Koha; if not, see . - -use Modern::Perl; - -use Koha::Database; - -use Koha::Localization; - -use base qw(Koha::Objects); - -sub _type { - return 'Localization'; -} - -sub object_class { - return 'Koha::Localization'; -} - -1; diff --git a/Koha/Object.pm b/Koha/Object.pm index ad34bbc948..0f6b50e67c 100644 --- a/Koha/Object.pm +++ b/Koha/Object.pm @@ -907,6 +907,33 @@ sub unblessed_all_relateds { return \%data; } +=head3 localization + +Returns a localized (translated) value of the property, or the original +property value if no translation exist + + $localization = $object->localization($property, $lang); + +C<$property> is the property name. Often this will correspond to an SQL column name + +C<$lang> is the language code (for instance: 'en-GB') + +=cut + +sub localization { + my ($self, $property, $lang) = @_; + + my $result = $self->_result; + if ($result->can('localization')) { + if (my $localization = $result->localization($property, $lang)) { + return $localization; + } + } + + return $result->get_column($property); +} + + =head3 $object->_result(); Returns the internal DBIC Row object diff --git a/Koha/Schema/Result/Itemtype.pm b/Koha/Schema/Result/Itemtype.pm index 22880fdb43..95340ed4a2 100644 --- a/Koha/Schema/Result/Itemtype.pm +++ b/Koha/Schema/Result/Itemtype.pm @@ -283,6 +283,21 @@ __PACKAGE__->has_many( { cascade_copy => 0, cascade_delete => 0 }, ); +=head2 itemtypes_localizations + +Type: has_many + +Related object: L + +=cut + +__PACKAGE__->has_many( + "itemtypes_localizations", + "Koha::Schema::Result::ItemtypesLocalization", + { "foreign.itemtype" => "self.itemtype" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + =head2 old_reserves Type: has_many @@ -334,8 +349,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07051 @ 2024-05-08 14:38:07 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9NChRQA4eBUqHpLXUFmOuw +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2024-10-09 21:42:51 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:+/gmrTCXAvhf12UGXmC3yg __PACKAGE__->add_columns( '+automatic_checkin' => { is_boolean => 1 }, @@ -345,21 +360,11 @@ __PACKAGE__->add_columns( '+rentalcharge_hourly_calendar' => { is_boolean => 1 }, ); -# Use the ItemtypeLocalization view to create the join on localization -our $LANGUAGE; -__PACKAGE__->has_many( - "localization" => "Koha::Schema::Result::ItemtypeLocalization", - sub { - my $args = shift; - - die "no lang specified!" unless $LANGUAGE; - - return ({ - "$args->{self_alias}.itemtype" => { -ident => "$args->{foreign_alias}.code" }, - "$args->{foreign_alias}.lang" => $LANGUAGE, - }); - - } +__PACKAGE__->load_components('+Koha::DBIx::Class::Localization'); +__PACKAGE__->localization_add_relationships( + 'itemtypes_localizations', + 'itemtype' => 'itemtype', + 'description' ); sub koha_object_class { diff --git a/Koha/Schema/Result/ItemtypeLocalization.pm b/Koha/Schema/Result/ItemtypeLocalization.pm deleted file mode 100644 index d5966128c3..0000000000 --- a/Koha/Schema/Result/ItemtypeLocalization.pm +++ /dev/null @@ -1,32 +0,0 @@ -package Koha::Schema::Result::ItemtypeLocalization; - -use base 'DBIx::Class::Core'; - -use Modern::Perl; - -__PACKAGE__->table_class('DBIx::Class::ResultSource::View'); - -__PACKAGE__->table('itemtype_localizations'); -__PACKAGE__->result_source_instance->is_virtual(1); -__PACKAGE__->result_source_instance->view_definition( - "SELECT localization_id, code, lang, translation FROM localization WHERE entity='itemtypes'" -); - -__PACKAGE__->add_columns( - "localization_id", - { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, - "code", - { data_type => "varchar", is_nullable => 0, size => 64 }, - "lang", - { data_type => "varchar", is_nullable => 0, size => 25 }, - "translation", - { data_type => "text", is_nullable => 1 }, -); - -__PACKAGE__->belongs_to( - "itemtype", - "Koha::Schema::Result::Itemtype", - { code => 'itemtype' } -); - -1; diff --git a/Koha/Schema/Result/ItemtypesLocalization.pm b/Koha/Schema/Result/ItemtypesLocalization.pm new file mode 100644 index 0000000000..d7c2721b69 --- /dev/null +++ b/Koha/Schema/Result/ItemtypesLocalization.pm @@ -0,0 +1,124 @@ +use utf8; +package Koha::Schema::Result::ItemtypesLocalization; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::ItemtypesLocalization + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("itemtypes_localizations"); + +=head1 ACCESSORS + +=head2 itemtypes_localizations_id + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 itemtype + + data_type: 'varchar' + is_foreign_key: 1 + is_nullable: 0 + size: 10 + +=head2 property + + data_type: 'varchar' + is_nullable: 0 + size: 100 + +=head2 lang + + data_type: 'varchar' + is_nullable: 0 + size: 25 + +=head2 translation + + data_type: 'mediumtext' + is_nullable: 1 + +=cut + +__PACKAGE__->add_columns( + "itemtypes_localizations_id", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "itemtype", + { data_type => "varchar", is_foreign_key => 1, is_nullable => 0, size => 10 }, + "property", + { data_type => "varchar", is_nullable => 0, size => 100 }, + "lang", + { data_type => "varchar", is_nullable => 0, size => 25 }, + "translation", + { data_type => "mediumtext", is_nullable => 1 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("itemtypes_localizations_id"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=item * L + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("itemtype_property_lang", ["itemtype", "property", "lang"]); + +=head1 RELATIONS + +=head2 itemtype + +Type: belongs_to + +Related object: L + +=cut + +__PACKAGE__->belongs_to( + "itemtype", + "Koha::Schema::Result::Itemtype", + { itemtype => "itemtype" }, + { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07052 @ 2024-10-08 14:32:00 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9xi1V/qaD/R9u3KHcW7saA + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/Koha/Schema/Result/Localization.pm b/Koha/Schema/Result/Localization.pm deleted file mode 100644 index 1919258b36..0000000000 --- a/Koha/Schema/Result/Localization.pm +++ /dev/null @@ -1,108 +0,0 @@ -use utf8; -package Koha::Schema::Result::Localization; - -# Created by DBIx::Class::Schema::Loader -# DO NOT MODIFY THE FIRST PART OF THIS FILE - -=head1 NAME - -Koha::Schema::Result::Localization - -=cut - -use strict; -use warnings; - -use base 'DBIx::Class::Core'; - -=head1 TABLE: C - -=cut - -__PACKAGE__->table("localization"); - -=head1 ACCESSORS - -=head2 localization_id - - data_type: 'integer' - is_auto_increment: 1 - is_nullable: 0 - -=head2 entity - - data_type: 'varchar' - is_nullable: 0 - size: 16 - -=head2 code - - data_type: 'varchar' - is_nullable: 0 - size: 64 - -=head2 lang - - data_type: 'varchar' - is_nullable: 0 - size: 25 - -could be a foreign key - -=head2 translation - - data_type: 'mediumtext' - is_nullable: 1 - -=cut - -__PACKAGE__->add_columns( - "localization_id", - { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, - "entity", - { data_type => "varchar", is_nullable => 0, size => 16 }, - "code", - { data_type => "varchar", is_nullable => 0, size => 64 }, - "lang", - { data_type => "varchar", is_nullable => 0, size => 25 }, - "translation", - { data_type => "mediumtext", is_nullable => 1 }, -); - -=head1 PRIMARY KEY - -=over 4 - -=item * L - -=back - -=cut - -__PACKAGE__->set_primary_key("localization_id"); - -=head1 UNIQUE CONSTRAINTS - -=head2 C - -=over 4 - -=item * L - -=item * L - -=item * L - -=back - -=cut - -__PACKAGE__->add_unique_constraint("entity_code_lang", ["entity", "code", "lang"]); - - -# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-01-21 13:39:29 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Elbup2i+1JON+xa38uzd6A - - -# You can replace this text with custom code or comments, and it will be preserved on regeneration -1; diff --git a/admin/itemtypes.pl b/admin/itemtypes.pl index 684f9377db..390bb4ce00 100755 --- a/admin/itemtypes.pl +++ b/admin/itemtypes.pl @@ -32,7 +32,6 @@ use C4::Auth qw( get_template_and_user ); use C4::Output qw( output_html_with_http_headers ); use Koha::ItemTypes; use Koha::ItemType; -use Koha::Localizations; my $input = CGI->new; my $searchfield = $input->param('description'); diff --git a/admin/localization.pl b/admin/localization.pl index 58205d8df7..2440b11a26 100755 --- a/admin/localization.pl +++ b/admin/localization.pl @@ -21,8 +21,9 @@ use Modern::Perl; use C4::Auth qw( get_template_and_user ); use C4::Output qw( output_html_with_http_headers ); -use Koha::Localization; -use Koha::Localizations; +use Koha::Database; + +my $schema = Koha::Database->schema; use CGI qw( -utf8 ); @@ -36,18 +37,20 @@ my ( $template, $borrowernumber, $cookie ) = get_template_and_user( } ); -my $entity = $query->param('entity'); -my $code = $query->param('code'); -my $rs = Koha::Localizations->search( { entity => $entity, code => $code } ); +my $source = $query->param('source'); +my $object_id = $query->param('object_id'); +my $property = $query->param('property'); + +my $row = $schema->resultset($source)->find($object_id); + my @translations; -while ( my $s = $rs->next ) { - push @translations, - { id => $s->localization_id, - entity => $s->entity, - code => $s->code, - lang => $s->lang, - translation => $s->translation, - }; +my $localizations = $row->localizations->search({ property => $property }); +while ( my $localization = $localizations->next ) { + push @translations, { + localization_id => $localization->id, + lang => $localization->lang, + translation => $localization->translation, + }; } my $translated_languages = C4::Languages::getTranslatedLanguages( 'intranet', C4::Context->preference('template') ); @@ -55,8 +58,9 @@ my $translated_languages = C4::Languages::getTranslatedLanguages( 'intranet', C4 $template->param( translations => \@translations, languages => $translated_languages, - entity => $entity, - code => $code, + source => $source, + object_id => $object_id, + property => $property, ); output_html_with_http_headers $query, $cookie, $template->output; diff --git a/installer/data/mysql/atomicupdate/bug-38136.pl b/installer/data/mysql/atomicupdate/bug-38136.pl new file mode 100644 index 0000000000..0585f03087 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug-38136.pl @@ -0,0 +1,36 @@ +use Modern::Perl; +use Koha::Installer::Output qw(say_warning say_failure say_success say_info); + +return { + bug_number => '38136', + description => 'Add itemtypes_localizations table', + up => sub { + my ($args) = @_; + my ( $dbh, $out ) = @$args{qw(dbh out)}; + + unless ( TableExists('itemtypes_localizations') ) { + $dbh->do( " + CREATE TABLE `itemtypes_localizations` ( + `itemtypes_localizations_id` int(11) NOT NULL AUTO_INCREMENT, + `itemtype` varchar(10) NOT NULL, + `property` varchar(100) NOT NULL, + `lang` varchar(25) NOT NULL, + `translation` mediumtext DEFAULT NULL, + PRIMARY KEY (`itemtypes_localizations_id`), + UNIQUE KEY `itemtype_property_lang` (`itemtype`, `property`, `lang`), + CONSTRAINT `itemtypes_localizations_itemtype_fk` + FOREIGN KEY (`itemtype`) REFERENCES `itemtypes` (`itemtype`) + ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + " ); + + if ( TableExists('localization') ) { + $dbh->do( " + INSERT INTO `itemtypes_localizations` (`itemtype`, `property`, `lang`, `translation`) + SELECT `code`, 'description', `lang`, `translation` FROM `localization` WHERE `entity` = 'itemtypes' + " ); + $dbh->do('DROP TABLE localization'); + } + } + }, +}; diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql index 31f0828a24..34c813fc90 100644 --- a/installer/data/mysql/kohastructure.sql +++ b/installer/data/mysql/kohastructure.sql @@ -4166,6 +4166,23 @@ CREATE TABLE `itemtypes_branches` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `itemtypes_localizations` +-- + +CREATE TABLE `itemtypes_localizations` ( + `itemtypes_localizations_id` int(11) NOT NULL AUTO_INCREMENT, + `itemtype` varchar(10) NOT NULL, + `property` varchar(100) NOT NULL, + `lang` varchar(25) NOT NULL, + `translation` mediumtext DEFAULT NULL, + PRIMARY KEY (`itemtypes_localizations_id`), + UNIQUE KEY `itemtype_property_lang` (`itemtype`, `property`, `lang`), + CONSTRAINT `itemtypes_localizations_itemtype_fk` + FOREIGN KEY (`itemtype`) REFERENCES `itemtypes` (`itemtype`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- -- Table structure for table `keyboard_shortcuts` -- diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/localization-link.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/localization-link.inc new file mode 100644 index 0000000000..e4d5c1111c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/localization-link.inc @@ -0,0 +1 @@ + Translate into other languages diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/itemtypes.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/itemtypes.tt index dfb27e10e9..82d67269a8 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/itemtypes.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/itemtypes.tt @@ -192,7 +192,7 @@ Required [% IF can_be_translated %] - Translate into other languages + [% INCLUDE 'localization-link.inc' source='Itemtype' object_id=itemtype.itemtype property='description' %] [% END %]
  • diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/localization.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/localization.tt index fc500a8e31..2ff364fb66 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/localization.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/localization.tt @@ -18,9 +18,9 @@

    Localization

    [% INCLUDE 'csrf-token.inc' %] - - - + + +
    1. @@ -66,9 +66,6 @@ - - - @@ -76,10 +73,7 @@ [% FOR t IN translations %] - - - - + @@ -102,7 +96,7 @@ var message; if ( type == 'success_on_update' ) { message = $('
      '); - message.text(_("Entity %s (code %s) for lang %s has correctly been updated with '%s'").format(data.entity, data.code, data.lang, data.translation)); + message.text(_("Translation for lang %s has correctly been updated with '%s'").format(data.lang, data.translation)); } else if ( type == 'error_on_update' ) { message = $('
      '); if ( data.error_code == 'already_exists' ) { @@ -112,13 +106,13 @@ } } else if ( type == 'success_on_delete' ) { message = $('
      '); - message.text(_("The translation (id %s) has been removed successfully").format(data.id)); + message.text(_("The translation has been removed successfully")); } else if ( type == 'error_on_delete' ) { message = $('
      '); message.text(_("An error occurred when deleting this translation")); } else if ( type == 'success_on_insert' ) { message = $('
      '); - message.text(_("Translation (id %s) has been added successfully").format(data.id)); + message.text(_("Translation has been added successfully")); } else if ( type == 'error_on_insert' ) { message = $('
      '); if ( data.error_code == 'already_exists' ) { @@ -135,14 +129,18 @@ }, 3000); } - function send_update_request( data, cell ) { + function send_update_request( _data, cell ) { + const form = document.forms.add_translation; + const source = form.elements.source.value; + const object_id = form.elements.object_id.value; + const data = Object.assign({}, _data, { source, object_id }); const client = APIClient.localization; client.localizations.update(data).then( success => { if ( success.error ) { $(cell).css('background-color', '#FF0000'); show_message({ type: 'error_on_update', data: success }); - } else if ( success.is_changed == 1 ) { + } else { $(cell).css('background-color', '#00FF00'); show_message({ type: 'success_on_update', data: success }); } @@ -166,12 +164,18 @@ ); } - function send_delete_request( id, cell ) { + function send_delete_request( localization_id, cell ) { + const form = document.forms.add_translation; + const source = form.elements.source.value; + const object_id = form.elements.object_id.value; + const property = form.elements.property.value; + const data = { source, object_id, property, localization_id }; + const client = APIClient.localization; - client.localizations.delete(id).then( + client.localizations.delete(data).then( success => { - $("#localization").DataTable().row( '#row_id_' + id ).remove().draw(); - show_message({ type: 'success_on_delete', data: {id} }); + $("#localization").DataTable().row( '#row_id_' + localization_id ).remove().draw(); + show_message({ type: 'success_on_delete', data: {localization_id} }); }, error => { $(cell).css('background-color', '#FF9090'); @@ -221,10 +225,9 @@ }); $(my_select).on('change', function(){ var tr = $(this).parent().parent(); - var id = $(tr).data('id'); + var localization_id = $(tr).data('id'); var lang = $(this).find('option:selected').val(); - var translation = $(this).text(); - send_update_request( {id, lang, translation}, td ); + send_update_request( {localization_id, lang}, td ); }); $(my_select).on('blur', function(){ $(td).html(lang); @@ -234,10 +237,9 @@ $("td.translation").on('blur', function(){ var tr = $(this).parent(); - var id = $(tr).data('id'); - var lang = $(tr).find('td.lang').text(); + var localization_id = $(tr).data('id'); var translation = $(this).text(); - send_update_request( {id, lang, translation}, this ); + send_update_request( {localization_id, translation}, this ); }); $("body").on("click", "a.delete", function(e){ @@ -252,20 +254,23 @@ $("#add_translation").on('submit', function(e){ e.preventDefault(); - let localization = { - entity: $(this).find('input[name="entity"]').val(), - code: $(this).find('input[name="code"]').val(), - lang: $(this).find('select[name="lang"] option:selected').val(), - translation: $(this).find('input[name="translation"]').val(), - }; + + const form = this; + const source = form.elements.source.value; + const object_id = form.elements.object_id.value; + const property = form.elements.property.value; + const lang = form.elements.lang.value + const translation = form.elements.translation.value + + let localization = { source, object_id, property, lang, translation }; const client = APIClient.localization; client.localizations.create(localization).then( success => { if ( success.error ) { show_message({ type: 'error_on_insert', data: success }); } else { - var new_row = table.row.add( [ success.id, success.entity, success.code, success.lang, success.translation, " Delete" ] ).draw().node(); - $( new_row ).attr("id", "row_id_" + success.id ).data("id", success.id ); + var new_row = table.row.add( [ success.lang, success.translation, " Delete" ] ).draw().node(); + $( new_row ).attr("id", "row_id_" + success.localization_id ).data("id", success.localization_id ); show_message({ type: 'success_on_insert', data: success }); } }, diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/localization-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/localization-api-client.js index 4fbede4c67..c6d81f16b9 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/fetch/localization-api-client.js +++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/localization-api-client.js @@ -13,12 +13,7 @@ export class LocalizationAPIClient extends HttpClient { create: localization => this.post({ endpoint: "", - body: "entity=%s&code=%s&lang=%s&translation=%s".format( - encodeURIComponent(localization.entity), - encodeURIComponent(localization.code), - encodeURIComponent(localization.lang), - encodeURIComponent(localization.translation) - ), + body: (new URLSearchParams(localization)).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", @@ -27,23 +22,15 @@ export class LocalizationAPIClient extends HttpClient { update: localization => this.put({ endpoint: "", - body: "id=%s&lang=%s&translation=%s".format( - encodeURIComponent(localization.id), - encodeURIComponent(localization.lang), - encodeURIComponent(localization.translation) - ), + body: (new URLSearchParams(localization)).toString(), headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", }, }), - delete: id => + delete: localization => this.delete({ - endpoint: "/?id=%s".format(id), - headers: { - "Content-Type": - "application/x-www-form-urlencoded;charset=utf-8", - }, + endpoint: "?" + (new URLSearchParams(localization)).toString(), }), }; } diff --git a/svc/localization b/svc/localization index 6eed60632c..6dd2c5c1d0 100755 --- a/svc/localization +++ b/svc/localization @@ -2,103 +2,102 @@ use Modern::Perl; use Encode qw( encode ); +use Try::Tiny; use C4::Service; -use Koha::Localizations; +use Koha::Caches; +use Koha::Database; our ( $query, $response ) = C4::Service->init( parameters => 'manage_itemtypes' ); -sub get_translations { - my $rs = Koha::Localizations->search({ entity => $query->param('entity'), code => $query->param('code') }); - my @translations; - while ( my $s = $rs->next ) { - push @translations, { - id => $s->localization_id, - entity => $s->entity, - code => $s->code, - lang => $s->lang, - translation => $s->translation, - } - } - $response->param( translations => \@translations ); - C4::Service->return_success( $response ); -} - sub update_translation { - my $id = $query->param('id'); - my $translation = $query->param('translation'); + my $source = $query->param('source'); + my $localization_id = $query->param('localization_id'); + my $object_id = $query->param('object_id'); my $lang = $query->param('lang'); + my $translation = $query->param('translation'); - my $localization = Koha::Localizations->find( $id ); - if ( defined $lang and $localization->lang ne $lang ) { - $localization->lang( $lang ) - } - if ( defined $translation and $localization->translation ne $translation ) { - $localization->translation( $translation ) - } - my %params; - my $is_changed; - if ( $localization->is_changed ) { - $is_changed = 1; - unless ( Koha::Localizations->search( { entity => $localization->entity, code => $localization->code, lang => $lang, localization_id => { '!=' => $localization->localization_id }, } )->count ) { - $localization->store; - } else { - $params{error} = 1; - $params{error_code} = 'already_exists'; + my $schema = Koha::Database->schema; + my $row = $schema->resultset($source)->find($object_id); + if ($row) { + my $localization = $row->localizations->find($localization_id); + if ($localization) { + try { + my $original_lang = $localization->lang; + $localization->lang($lang) if $lang; + $localization->translation($translation) if $translation; + $localization->update(); + Koha::Caches->get_instance('localization')->clear_from_cache("$source:$lang"); + } catch { + $localization->discard_changes(); + $response->param(error => 1, error_code => 'already_exists'); + }; } + + $response->param( + lang => $localization->lang, + translation => $localization->translation, + ); } - $response->param( - %params, - id => $localization->localization_id, - entity => $localization->entity, - code => $localization->code, - lang => $localization->lang, - translation => $localization->translation, - is_changed => $is_changed, - ); + C4::Service->return_success( $response ); } sub add_translation { - my $entity = $query->param('entity'); - my $code = $query->param('code'); + my $source = $query->param('source'); + my $localization_id = $query->param('localization_id'); + my $object_id = $query->param('object_id'); + my $property = $query->param('property'); my $lang = $query->param('lang'); my $translation = $query->param('translation'); - unless ( Koha::Localizations->search({entity => $entity, code => $code, lang => $lang, })->count ) { - my $localization = Koha::Localization->new( + my $schema = Koha::Database->schema; + my $row = $schema->resultset($source)->find($object_id); + try { + my $localization = $row->create_related( + "${property}_localizations", { - entity => $entity, - code => $code, lang => $lang, translation => $translation, } ); - $localization->store; + Koha::Caches->get_instance('localization')->clear_from_cache("$source:$lang"); + $response->param( - id => $localization->localization_id, - entity => $localization->entity, - code => $localization->code, lang => $localization->lang, translation => $localization->translation, ); + } catch { + $response->param(error => 1, error_code => 'already_exists'); + }; - } else { - $response->param( error => 1, error_code => 'already_exists', ); - } C4::Service->return_success( $response ); } sub delete_translation { - my $id = $query->param('id'); - Koha::Localizations->find($id)->delete; - $response->param( id => $id ); + my $source = $query->param('source'); + my $object_id = $query->param('object_id'); + my $localization_id = $query->param('localization_id'); + + my $schema = Koha::Database->schema; + my $row = $schema->resultset($source)->find($object_id); + if ($row && $row->can('localizations')) { + my $localization = $row->localizations->find($localization_id); + if ($localization) { + $localization->delete(); + Koha::Caches->get_instance('localization')->clear_from_cache("$source:" . $localization->lang); + } + + $response->param( + localization_id => $localization_id, + ); + } + C4::Service->return_success( $response ); } C4::Service->dispatch( - [ 'GET /', [ 'id' ], \&get_translations ], - [ 'PUT /', [ 'id' ], \&update_translation ], - [ 'POST /', [ 'entity', 'code', 'lang', 'translation' ], \&add_translation ], - [ 'DELETE /', ['id'], \&delete_translation ], + [ 'PUT /', [], \&update_translation ], + [ 'POST /', [], \&add_translation ], + [ 'DELETE /', [], \&delete_translation ], ); diff --git a/t/db_dependent/Koha/ItemTypes.t b/t/db_dependent/Koha/ItemTypes.t index d62cf05e48..09073e320a 100755 --- a/t/db_dependent/Koha/ItemTypes.t +++ b/t/db_dependent/Koha/ItemTypes.t @@ -26,6 +26,7 @@ use t::lib::TestBuilder; use C4::Calendar qw( new ); use Koha::Biblioitems; +use Koha::Caches; use Koha::Libraries; use Koha::Database; use Koha::DateUtils qw(dt_from_string);; @@ -66,30 +67,29 @@ my $child3 = $builder->build_object({ } }); -Koha::Localization->new( +$child1->_result->localizations->create( { - entity => 'itemtypes', - code => $child1->itemtype, + property => 'description', lang => 'en', translation => 'b translated itemtype desc' } -)->store; -Koha::Localization->new( +); +$child2->_result->localizations->create( { - entity => 'itemtypes', - code => $child2->itemtype, + property => 'description', lang => 'en', translation => 'a translated itemtype desc' } -)->store; -Koha::Localization->new( +); +$child3->_result->localizations->create( { - entity => 'something_else', - code => $child2->itemtype, + property => 'description', lang => 'en', translation => 'another thing' } -)->store; +); + +Koha::Caches->get_instance('localization')->flush_all(); my $type = Koha::ItemTypes->find($child1->itemtype); ok( defined($type), 'first result' ); diff --git a/t/db_dependent/Koha/Template/Plugin/ItemTypes.t b/t/db_dependent/Koha/Template/Plugin/ItemTypes.t index 59726397e5..cfc0c23b67 100755 --- a/t/db_dependent/Koha/Template/Plugin/ItemTypes.t +++ b/t/db_dependent/Koha/Template/Plugin/ItemTypes.t @@ -19,6 +19,7 @@ use Modern::Perl; use Test::More tests => 10; use C4::Context; +use Koha::Caches; use Koha::Database; use Koha::ItemTypes; @@ -52,14 +53,13 @@ my $itemtypeA = $builder->build_object( } } ); -Koha::Localization->new( +$itemtypeA->_result->localizations->create( { - entity => 'itemtypes', - code => $itemtypeA->itemtype, + property => 'description', lang => 'en', translation => 'Translated itemtype A' } -)->store; +); my $itemtypeB = $builder->build_object( { class => 'Koha::ItemTypes', @@ -69,14 +69,13 @@ my $itemtypeB = $builder->build_object( } } ); -Koha::Localization->new( +$itemtypeB->_result->localizations->create( { - entity => 'itemtypes', - code => $itemtypeB->itemtype, + property => 'description', lang => 'en', translation => 'Translated itemtype B' } -)->store; +); my $itemtypeC = $builder->build_object( { class => 'Koha::ItemTypes', @@ -87,6 +86,8 @@ my $itemtypeC = $builder->build_object( } ); +Koha::Caches->get_instance('localization')->flush_all(); + my $GetDescriptionA1 = $plugin->GetDescription($itemtypeA->itemtype); is($GetDescriptionA1, "Translated itemtype A", "ItemType without parent - GetDescription without want parent"); my $GetDescriptionA2 = $plugin->GetDescription($itemtypeA->itemtype, 1); diff --git a/t/db_dependent/api/v1/item_types.t b/t/db_dependent/api/v1/item_types.t index 3196bbbb3b..9589f00ff1 100755 --- a/t/db_dependent/api/v1/item_types.t +++ b/t/db_dependent/api/v1/item_types.t @@ -25,6 +25,7 @@ use t::lib::Mocks; use Mojo::JSON qw(encode_json); +use Koha::Caches; use Koha::ItemTypes; use Koha::Database; @@ -66,29 +67,23 @@ subtest 'list() tests' => sub { } ); - my $en = $builder->build_object( + $item_type->_result->localizations->create( { - class => 'Koha::Localizations', - value => { - entity => 'itemtypes', - code => $item_type->id, - lang => 'en', - translation => 'English word "test"', - } + property => 'description', + lang => 'en', + translation => 'English word "test"', } ); - my $sv = $builder->build_object( + $item_type->_result->localizations->create( { - class => 'Koha::Localizations', - value => { - entity => 'itemtypes', - code => $item_type->id, - lang => 'sv_SE', - translation => 'Swedish word "test"', - } + property => 'description', + lang => 'sv_SE', + translation => 'Swedish word "test"', } ); + Koha::Caches->get_instance('localization')->flush_all(); + my $librarian = $builder->build_object( { class => 'Koha::Patrons', -- 2.39.2
      IdEntityCode Language Translation  
      [% t.id | html %][% t.entity | html %][% t.code | html %]
      [% t.lang | html %] [% t.translation | html %] Delete