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.

We also need to be able to translate all translatable properties of a
particular entity.

The solution proposed here is to add a DBIC component that will add
several DBIC relations to the `localization` table:
* a relation named `localizations` that will return all translations for
  all translatable properties for all languages.
* for each translatable property:
  * a relation named `{$property}_localizations` that will return all
    translations for a given property, for all languages
  * a relation named `{$property}_localization` that will return the
    translation for a given property, for the current language

To enable translations for a particular entity, we will need to:
* 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(
        'itemtype', # the PK column
        'description' # a list of translatable columns
    );

(see POD in Koha/Schema/Component/Localization.pm for more information)

Final notes:
* 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::Schema::Component::Localization::localization).
  Localizations in cache are grouped by object type and language (keys
  look like 'KOHA:localization:Itemtype:en')
* By keeping a single table for all translations of all entities, we
  cannot have data integrity at the RDBMS level, but automatic deletion
  of translations is handled by the DBIC `cascade_delete` feature, so
  integrity should be fine as long as no raw SQL update/delete operation
  is involved.

Signed-off-by: Caroline Cyr La Rose <caroline.cyr-la-rose@inlibro.com>
Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Julian Maurice 2024-10-09 22:03:16 +02:00 committed by Lucas Gass
parent 99eb693cd9
commit e7f483def5
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
24 changed files with 532 additions and 469 deletions

View File

@ -95,6 +95,7 @@ use C4::Linker;
use C4::OAI::Sets;
use Koha::Logger;
use Koha::Cache::Memory::Lite;
use Koha::Caches;
use Koha::ClassSources;
use Koha::Authority::Types;
@ -1570,15 +1571,15 @@ sub GetAuthorisedValueDesc {
#---- itemtypes
if ( $tagslib->{$tag}->{$subfield}->{'authorised_value'} eq "itemtypes" ) {
my $lang = C4::Languages::getlanguage;
$lang //= 'en';
$cache_key = 'itemtype:description:' . $lang;
my $itypes = $cache->get_from_cache( $cache_key, { unsafe => 1 } );
if ( !$itypes ) {
$itypes = { map { $_->itemtype => $_->translated_description } Koha::ItemTypes->search()->as_list };
$cache->set_in_cache( $cache_key, $itypes );
my $memory_cache = Koha::Cache::Memory::Lite->get_instance();
my $cache_key = 'GetAuthorisedValueDesc:itemtypes';
my $itemtypes = $memory_cache->get_from_cache($cache_key);
unless ($itemtypes) {
$itemtypes = { map { $_->itemtype => $_ } Koha::ItemTypes->as_list };
$memory_cache->set_in_cache( $cache_key, $itemtypes );
}
return $itypes->{$value};
my $itemtype = $itemtypes->{$value};
return $itemtype ? $itemtype->translated_description : undef;
}
if ( $tagslib->{$tag}->{$subfield}->{'authorised_value'} eq "cn_source" ) {

View File

@ -19,15 +19,11 @@ use Modern::Perl;
use C4::Koha qw( getitemtypeimagelocation );
use C4::Languages;
use Koha::Caches;
use Koha::Database;
use Koha::CirculationRules;
use Koha::Localizations;
use base qw(Koha::Object Koha::Object::Limit::Library);
my $cache = Koha::Caches->get_instance();
=head1 NAME
Koha::ItemType - Koha Item type Object class
@ -36,48 +32,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<delete> 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 +47,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 +57,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 +165,32 @@ sub to_api_mapping {
rentalcharge_hourly => 'hourly_rental_charge',
rentalcharge_hourly_calendar => 'hourly_rental_charge_calendar',
bookable_itemtype => 'bookable_itemtype',
# TODO Remove after having updated all code using unblessed translated_description
translated_description => undef,
};
}
=head3 unblessed
See L<Koha::Object/unblessed>
Overridden to add a C<translated_description> key for backward compatibility.
This should not be relied on as it may be removed in the future.
=cut
# 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

View File

@ -32,8 +32,6 @@ Koha::ItemTypes - Koha ItemType Object set class
=head2 Class methods
=cut
=head3 search_with_localization
my $itemtypes = Koha::ItemTypes->search_with_localization
@ -43,22 +41,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

View File

@ -1,74 +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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Database;
use base qw(Koha::Object);
use Koha::Caches;
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<delete> 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;

View File

@ -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 <https://www.gnu.org/licenses>.
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;

View File

@ -956,6 +956,32 @@ 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

View File

@ -0,0 +1,214 @@
package Koha::Schema::Component::Localization;
=head1 NAME
Koha::Schema::Component::Localization
=head1 SYNOPSIS
package Koha::Schema::Result::SomeTable;
# ... generated code ...
__PACKAGE__->load_components('+Koha::Schema::Component::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 <property>_localizations, giving access
to all localizations of a particular table row for this particular property
=item * a "might_have" relationship named <property>_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<localization($property, $lang)>
which returns a specific translation and uses cache to avoid executing lots of
queries
=cut
use Modern::Perl;
use Carp;
use base qw(DBIx::Class);
=head2 localization_add_relationships
Add relationships to the localization table
=cut
sub localization_add_relationships {
my ( $class, $pk_column, @properties ) = @_;
my $rel_class = 'Koha::Schema::Result::Localization';
my $source_name = $class =~ s/.*:://r;
$class->has_many(
'localizations',
$rel_class,
sub {
my ($args) = @_;
return (
{
"$args->{foreign_alias}.code" => { -ident => "$args->{self_alias}.$pk_column" },
"$args->{foreign_alias}.entity" => $source_name,
},
!$args->{self_result_object} ? () : {
"$args->{foreign_alias}.code" => $args->{self_result_object}->get_column($pk_column),
"$args->{foreign_alias}.entity" => $source_name,
},
);
},
{ cascade_copy => 0, cascade_delete => 1, cascade_update => 0 },
);
foreach my $property (@properties) {
$class->might_have(
$property . '_localization',
$rel_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}.code" => { -ident => "$args->{self_alias}.$pk_column" },
"$args->{foreign_alias}.entity" => $source_name,
"$args->{foreign_alias}.property" => $property,
"$args->{foreign_alias}.lang" => $lang,
},
!$args->{self_result_object} ? () : {
"$args->{foreign_alias}.code" => $args->{self_result_object}->get_column($pk_column),
"$args->{foreign_alias}.entity" => $source_name,
"$args->{foreign_alias}.property" => $property,
"$args->{foreign_alias}.lang" => $lang,
},
);
},
{ cascade_copy => 0, cascade_delete => 0, cascade_update => 0 },
);
$class->has_many(
$property . '_localizations',
$rel_class,
sub {
my ($args) = @_;
return (
{
"$args->{foreign_alias}.code" => { -ident => "$args->{self_alias}.$pk_column" },
"$args->{foreign_alias}.entity" => $source_name,
"$args->{foreign_alias}.property" => $property,
},
!$args->{self_result_object} ? () : {
"$args->{foreign_alias}.code" => $args->{self_result_object}->get_column($pk_column),
"$args->{foreign_alias}.entity" => $source_name,
"$args->{foreign_alias}.property" => $property,
},
);
},
{ 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 $localizations = $result_source->schema->resultset('Localization')->search( { lang => $lang } );
while ( my $localization = $localizations->next ) {
my $fk = $localization->get_column('code');
my $localization_key = sprintf( '%s:%s', $fk, $localization->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;

View File

@ -377,22 +377,8 @@ __PACKAGE__->add_columns(
'+bookable' => { is_boolean => 1, is_nullable => 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::Schema::Component::Localization');
__PACKAGE__->localization_add_relationships('itemtype', 'description');
=head2 koha_object_class

View File

@ -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;

View File

@ -202,12 +202,11 @@ sub generate_subfield_form {
}
} elsif ( $subfield->{authorised_value} eq "itemtypes" ) {
push @authorised_values, "";
my $itemtypes;
my $itemtypes = Koha::ItemTypes->new;
if ($branch_limit) {
$itemtypes = Koha::ItemTypes->search_with_localization( { branchcode => $branch_limit } );
} else {
$itemtypes = Koha::ItemTypes->search_with_localization;
$itemtypes = $itemtypes->search_with_library_limits( {}, {}, $branch_limit );
}
$itemtypes = $itemtypes->order_by_translated_description;
while ( my $itemtype = $itemtypes->next ) {
push @authorised_values, $itemtype->itemtype;
$authorised_lib{ $itemtype->itemtype } = $itemtype->translated_description;

View File

@ -31,7 +31,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');

View File

@ -20,8 +20,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,19 +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(); # opac and intranet
@ -56,8 +58,9 @@ my $translated_languages = C4::Languages::getTranslatedLanguages(); # opac an
$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;

View File

@ -0,0 +1,24 @@
use Modern::Perl;
use Koha::Installer::Output qw(say_warning say_failure say_success say_info);
return {
bug_number => '38136',
description => 'Add localization.property',
up => sub {
my ($args) = @_;
my ( $dbh, $out ) = @$args{qw(dbh out)};
unless ( column_exists( 'localization', 'property' ) ) {
$dbh->do("alter table `localization` add `property` varchar(100) null after `code`");
$dbh->do("update `localization` set `property` = 'description', entity = 'Itemtype'");
$dbh->do("alter table `localization` modify `property` varchar(100) not null");
$dbh->do("alter table `localization` drop key `entity_code_lang`");
$dbh->do(
"alter table `localization` add unique key `entity_code_property_lang` (`entity`, `code`, `property`, `lang`)"
);
say_success( $out, 'Added column localization.property and updated localization.entity values' );
}
},
};

View File

@ -4583,10 +4583,11 @@ CREATE TABLE `localization` (
`localization_id` int(11) NOT NULL AUTO_INCREMENT,
`entity` varchar(16) NOT NULL,
`code` varchar(64) NOT NULL,
`property` varchar(100) NOT NULL,
`lang` varchar(25) NOT NULL COMMENT 'could be a foreign key',
`translation` mediumtext DEFAULT NULL,
PRIMARY KEY (`localization_id`),
UNIQUE KEY `entity_code_lang` (`entity`,`code`,`lang`)
UNIQUE KEY `entity_code_property_lang` (`entity`,`code`,`property`,`lang`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

View File

@ -0,0 +1 @@
<a href="/cgi-bin/koha/admin/localization.pl?source=[% source | uri %]&object_id=[% object_id | uri %]&property=[% property | uri %]"><i class="fa-solid fa-pencil" aria-hidden="true"></i> Translate into other languages</a>

View File

@ -194,7 +194,7 @@
<label for="description" class="required">Description: </label>
<input type="text" id="description" name="description" size="48" value="[% itemtype.description | html %]" required="required" /> <span class="required">Required</span>
[% IF can_be_translated %]
<a href="/cgi-bin/koha/admin/localization.pl?entity=itemtypes&code=[% itemtype.itemtype | uri %]"> <i class="fa-solid fa-pencil" aria-hidden="true"></i> Translate into other languages </a>
[% INCLUDE 'localization-link.inc' source='Itemtype' object_id=itemtype.itemtype property='description' %]
[% END %]
</li>
<li>

View File

@ -5,12 +5,12 @@
[% INCLUDE 'doc-head-open.inc' %]
<title>
[% FILTER collapse %]
[% SWITCH entity %]
[% CASE "itemtypes" %]
[% tx("Add translations for item type '{code}'", { code = code }) | html %]
[% SWITCH source %]
[% CASE "Itemtype" %]
[% tx("Add translations for item type '{code}'", { code = object_id }) | html %]
&rsaquo; [% t("Item types") | html %] &rsaquo; [% t("Administration") | html %]
[% CASE %]
[% tx("Add translations for '{code}'", { code = code }) | html %]
[% tx("Add translations for '{code}'", { code = object_id }) | html %]
[% END %]
&rsaquo; [% t("Koha") | html %]
[% END %]
@ -29,8 +29,8 @@
[% END %]
[% WRAPPER 'sub-header.inc' %]
[% WRAPPER breadcrumbs %]
[% SWITCH entity %]
[% CASE "itemtypes" %]
[% SWITCH source %]
[% CASE "Itemtype" %]
[% WRAPPER breadcrumb_item %]
<a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a>
[% END %]
@ -38,37 +38,37 @@
<a href="/cgi-bin/koha/admin/itemtypes.pl">Item types</a>
[% END %]
[% WRAPPER breadcrumb_item %]
<a href="/cgi-bin/koha/admin/itemtypes.pl?op=add_form&itemtype=[% code | uri %]"> [% tx("Modify item type '{code}'", { code = code }) | html %] </a>
<a href="/cgi-bin/koha/admin/itemtypes.pl?op=add_form&itemtype=[% object_id | uri %]"> [% tx("Modify item type '{code}'", { code = object_id }) | html %] </a>
[% END %]
[% WRAPPER breadcrumb_item bc_active= 1 %]
<span>Add translations</span>
[% END %]
[% CASE %]
[% WRAPPER breadcrumb_item bc_active= 1 %]
[% tx("Add translations for '{code}'", { code = code }) | html %]
[% tx("Add translations for '{code}'", { code = object_id }) | html %]
[% END %]
[% END %]
[% END #/ WRAPPER breadcrumbs %]
[% END #/ WRAPPER sub-header.inc %]
[% WRAPPER 'main-container.inc' %]
[% SWITCH entity %]
[% CASE "itemtypes" %]
<h1>[% tx("Add translations for item type '{code}'", { code = code }) | html %]</h1>
[% SWITCH source %]
[% CASE "Itemtype" %]
<h1>[% tx("Add translations for item type '{code}'", { code = object_id }) | html %]</h1>
[% CASE %]
<h1>[% tx("Add translations for '{code}'", { code = code }) | html %]</h1>
[% END %]
<form id="add_translation" method="get">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="entity" value="[% entity | html %]" />
<input type="hidden" name="code" value="[% code | html %]" />
<input type="hidden" name="interface" value="[% interface_side | html %]" />
<input type="hidden" name="source" value="[% source | html %]" />
<input type="hidden" name="object_id" value="[% object_id | html %]" />
<input type="hidden" name="property" value="[% property | html %]" />
<fieldset class="rows clearfix">
<ol>
<li>
<span class="label">Authorized value:</span>
[% code | html %]
<span class="label">Code:</span>
[% object_id | html %]
</li>
<li>
<label for="lang">Language:</label>
@ -100,9 +100,6 @@
<table id="localization">
<thead>
<tr>
<th>Id</th>
<th>Entity</th>
<th>Code</th>
<th class="lang">Language</th>
<th class="translation">Translation</th>
<th class="no-sort">&nbsp;</th>
@ -110,17 +107,7 @@
</thead>
<tbody>
[% FOR t IN translations %]
<tr id="row_id_[% t.id | html %]" data-id="[% t.id | html %]">
<td>[% t.id | html %]</td>
<td>
[% SWITCH entity %]
[% CASE "itemtypes" %]
Item type
[% CASE %]
[% t.entity | html %]
[% END %]
</td>
<td>[% t.code | html %]</td>
<tr id="row_id_[% t.localization_id | html %]" data-id="[% t.localization_id | html %]">
<td class="lang">[% t.lang | html %]</td>
<td class="translation" contenteditable="true">[% t.translation | html %]</td>
<td class="actions"
@ -149,7 +136,7 @@
var message;
if (type == "success_on_update") {
message = $('<div class="alert alert-info"></div>');
message.text(_("Entity %s (code %s) for lang %s has 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 = $('<div class="alert alert-warning"></div>');
if (data.error_code == "already_exists") {
@ -159,13 +146,13 @@
}
} else if (type == "success_on_delete") {
message = $('<div class="alert alert-info"></div>');
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 = $('<div class="alert alert-warning"></div>');
message.text(_("An error occurred when deleting this translation"));
} else if (type == "success_on_insert") {
message = $('<div class="alert alert-info"></div>');
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 = $('<div class="alert alert-warning"></div>');
if (data.error_code == "already_exists") {
@ -182,14 +169,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 });
}
@ -213,16 +204,22 @@
);
}
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)
.row("#row_id_" + localization_id)
.remove()
.draw();
show_message({ type: "success_on_delete", data: { id } });
show_message({ type: "success_on_delete", data: { localization_id } });
},
error => {
$(cell).css("background-color", "#FF9090");
@ -247,6 +244,10 @@
});
$("body").on("click", "td.lang", function () {
var td = $(this);
if (td[0].childElementCount > 0) {
// do nothing if there is already something there (like a select for instance)
return;
}
var tr = td.parent();
var id = tr.data("id");
var lang = td.text();
@ -258,9 +259,9 @@
});
var lang;
my_select.on("change", function () {
lang = td.find("option:selected").val();
var translation = tr.find("td.translation").text();
send_update_request({ id, lang, translation }, td[0]);
var localization_id = $(tr).data('id');
lang = td.find('option:selected').val();
send_update_request( {localization_id, lang}, td[0] );
});
my_select.on("blur", function () {
td.html(lang);
@ -270,10 +271,9 @@
$("body").on("blur", "td.translation", 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) {
@ -291,25 +291,28 @@
$("#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_dt.row
.add([success.id, success.entity, success.code, success.lang, success.translation, '<a href="#" class="btn btn-default btn-xs delete"><i class="fa fa-trash-can"></i> Delete</a>'])
var new_row = table.api().row
.add([success.lang, success.translation, '<a href="#" class="btn btn-xs btn-default delete"><i class="fa fa-trash-can"></i> %s</a>'.format(escape_str(_("Delete")))])
.draw()
.node();
$(new_row)
.attr("id", "row_id_" + success.id)
.data("id", success.id);
.attr("id", "row_id_" + success.localization_id)
.data("id", success.localization_id);
$(new_row.children[th_lang_index]).prop("contenteditable", true).addClass("lang");
$(new_row.children[th_translation_index]).prop("contenteditable", true).addClass("translation");
show_message({ type: "success_on_insert", data: success });

View File

@ -10,12 +10,7 @@ export class LocalizationAPIClient {
create: localization =>
this.httpClient.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",
@ -24,23 +19,16 @@ export class LocalizationAPIClient {
update: localization =>
this.httpClient.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.httpClient.delete({
endpoint: "/?id=%s".format(id),
headers: {
"Content-Type":
"application/x-www-form-urlencoded;charset=utf-8",
},
endpoint:
"?" + new URLSearchParams(localization).toString(),
}),
};
}

View File

@ -2,111 +2,114 @@
use Modern::Perl;
use Encode qw( encode );
use Try::Tiny;
use JSON qw( to_json );
use C4::Service;
use Koha::Localizations;
use Koha::Caches;
use Koha::Database;
use C4::Output qw( output_with_http_headers );
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 $lang = $query->param('lang');
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:$original_lang");
Koha::Caches->get_instance('localization')->clear_from_cache("$source:$lang") if $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 $lang = $query->param('lang');
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 $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,
entity => $source,
lang => $lang,
translation => $translation,
}
);
$localization->store;
$response->param(
id => $localization->localization_id,
entity => $localization->entity,
code => $localization->code,
lang => $localization->lang,
translation => $localization->translation,
);
Koha::Caches->get_instance('localization')->clear_from_cache("$source:$lang");
$response->param(
lang => $localization->lang,
translation => $localization->translation,
localization_id => $localization->id,
);
} 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);
unless ($localization) {
my $json = to_json( { errors => 'Not found' } );
output_with_http_headers $query, undef, $json, 'js', '404 Not Found';
exit;
}
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 ],
);

View File

@ -71,10 +71,11 @@ subtest 'ExpandCodedFields tests' => sub {
# Clear GetAuthorisedValueDesc-generated cache
$cache->clear_from_cache("libraries:name");
$cache->clear_from_cache("itemtype:description:en");
$cache->clear_from_cache("cn_sources:description");
$cache->clear_from_cache( "AV_descriptions:" . $av->category );
Koha::Caches->get_instance('localization')->clear_from_cache('Itemtype:en');
C4::Biblio::ModBiblio( $record, $biblio->biblionumber );
$biblio = Koha::Biblios->find( $biblio->biblionumber );
$record = $biblio->metadata->record;

View File

@ -30,6 +30,7 @@ use C4::Biblio qw( GetMarcSubfieldStructure );
use C4::Circulation qw( AddIssue AddReturn );
use Koha::Caches;
use Koha::Cache::Memory::Lite;
use Koha::Items;
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
@ -2455,10 +2456,11 @@ subtest 'columns_to_str' => sub {
$cache->clear_from_cache("MarcStructure-1-");
$cache->clear_from_cache("MarcSubfieldStructure-");
$cache->clear_from_cache("libraries:name");
$cache->clear_from_cache("itemtype:description:en");
$cache->clear_from_cache("cn_sources:description");
$cache->clear_from_cache("AV_descriptions:LOST");
Koha::Caches->get_instance('localization')->clear_from_cache('Itemtype:en');
# Creating subfields 'é', 'è' that are not linked with a kohafield
Koha::MarcSubfieldStructures->search(
{
@ -2540,10 +2542,11 @@ subtest 'columns_to_str' => sub {
$cache->clear_from_cache("MarcStructure-1-");
$cache->clear_from_cache("MarcSubfieldStructure-");
$cache->clear_from_cache("libraries:name");
$cache->clear_from_cache("itemtype:description:en");
$cache->clear_from_cache("cn_sources:description");
$cache->clear_from_cache("AV_descriptions:LOST");
Koha::Caches->get_instance('localization')->clear_from_cache('Itemtype:en');
$schema->storage->txn_rollback;
};
@ -2560,10 +2563,11 @@ subtest 'strings_map() tests' => sub {
$cache->clear_from_cache("MarcStructure-1-");
$cache->clear_from_cache("MarcSubfieldStructure-");
$cache->clear_from_cache("libraries:name");
$cache->clear_from_cache("itemtype:description:en");
$cache->clear_from_cache("cn_sources:description");
$cache->clear_from_cache("AV_descriptions:LOST");
Koha::Caches->get_instance('localization')->clear_from_cache('Itemtype:en');
# Recreating subfields just to be sure tests will be ok
# 1 => av (LOST)
# 3 => no link
@ -2675,6 +2679,7 @@ subtest 'strings_map() tests' => sub {
)->store();
Koha::Caches->get_instance->flush_all;
Koha::Cache::Memory::Lite->get_instance->flush;
$item->set(
{
@ -2747,9 +2752,10 @@ subtest 'strings_map() tests' => sub {
$cache->clear_from_cache("MarcStructure-1-");
$cache->clear_from_cache("MarcSubfieldStructure-");
$cache->clear_from_cache("libraries:name");
$cache->clear_from_cache("itemtype:description:en");
$cache->clear_from_cache("cn_sources:description");
Koha::Caches->get_instance('localization')->clear_from_cache('Itemtype:en');
$schema->storage->txn_rollback;
};

View File

@ -27,6 +27,7 @@ use t::lib::TestBuilder;
use C4::Calendar;
use Koha::Biblioitems;
use Koha::Caches;
use Koha::Libraries;
use Koha::Database;
use Koha::DateUtils qw(dt_from_string);
@ -73,30 +74,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' );

View File

@ -20,6 +20,7 @@ use Test::NoWarnings;
use Test::More tests => 11;
use C4::Context;
use Koha::Caches;
use Koha::Database;
use Koha::ItemTypes;
@ -53,14 +54,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',
@ -70,14 +70,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',
@ -88,6 +87,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 );

View File

@ -26,6 +26,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',