View | Details | Raw Unified | Return to bug 36947
Collapse All | Expand All

(-)a/C4/Search.pm (-2 / +40 lines)
Lines 40-45 use URI::Escape; Link Here
40
use Business::ISBN;
40
use Business::ISBN;
41
use MARC::Record;
41
use MARC::Record;
42
use MARC::Field;
42
use MARC::Field;
43
use POSIX qw(setlocale LC_COLLATE);
44
use Unicode::Collate::Locale;
43
45
44
our ( @ISA, @EXPORT_OK );
46
our ( @ISA, @EXPORT_OK );
45
47
Lines 291-296 See verbose embedded documentation. Link Here
291
293
292
=cut
294
=cut
293
295
296
=head2 _sort_facets_zebra
297
298
    my $sorted_facets = _sort_facets_zebra($facets, $locale);
299
300
Sorts facets using a configurable locale for Zebra search engine.
301
302
=cut
303
304
sub _sort_facets_zebra {
305
    my ( $facets, $locale ) = @_;
306
307
    if ( !$locale ) {
308
309
        # Get locale from system preference, falling back to system LC_COLLATE
310
        $locale = C4::Context->preference('FacetSortingLocale') || 'default';
311
        if ( $locale eq 'default' || !$locale ) {
312
313
            #NOTE: When setlocale is run with only the 1st parameter, it is a "get" not a "set" function.
314
            $locale = setlocale(LC_COLLATE) || 'default';
315
        }
316
    }
317
318
    my $collator = Unicode::Collate::Locale->new( locale => $locale );
319
    if ( $collator && $facets ) {
320
        my @sorted_facets = sort { $collator->cmp( $a->{facet_label_value}, $b->{facet_label_value} ) } @{$facets};
321
        if (@sorted_facets) {
322
            return \@sorted_facets;
323
        }
324
    }
325
326
    #NOTE: If there was a problem, at least return the not sorted facets
327
    return $facets;
328
}
329
294
sub getRecords {
330
sub getRecords {
295
    my (
331
    my (
296
        $koha_query,       $simple_query, $sort_by_ref, $servers_ref,
332
        $koha_query,       $simple_query, $sort_by_ref, $servers_ref,
Lines 554-561 sub getRecords { Link Here
554
    if (@facets_loop) {
590
    if (@facets_loop) {
555
        foreach my $f (@facets_loop) {
591
        foreach my $f (@facets_loop) {
556
            if ( C4::Context->preference('FacetOrder') eq 'Alphabetical' ) {
592
            if ( C4::Context->preference('FacetOrder') eq 'Alphabetical' ) {
557
                $f->{facets} =
593
                my $sorted_facets = _sort_facets_zebra( $f->{facets} );
558
                    [ sort { uc( $a->{facet_label_value} ) cmp uc( $b->{facet_label_value} ) } @{ $f->{facets} } ];
594
                if ($sorted_facets) {
595
                    $f->{facets} = $sorted_facets;
596
                }
559
            }
597
            }
560
        }
598
        }
561
    }
599
    }
(-)a/Koha/I18N.pm (+99 lines)
Lines 53-58 our @EXPORT = qw( Link Here
53
    N__np
53
    N__np
54
);
54
);
55
55
56
our @EXPORT_OK = qw(
57
    available_locales
58
);
59
56
our $textdomain = 'Koha';
60
our $textdomain = 'Koha';
57
61
58
sub init {
62
sub init {
Lines 224-227 sub _expand { Link Here
224
    return $text;
228
    return $text;
225
}
229
}
226
230
231
=head2 available_locales
232
233
    my $locales = Koha::I18N::available_locales();
234
235
Returns an arrayref of available system locales for use in system preferences.
236
237
Each locale is a hashref with:
238
  - C<value>: The locale identifier (e.g., 'en_US.utf8', 'default')
239
  - C<text>: Human-readable description (e.g., 'English (United States) - en_US.utf8')
240
241
Always includes 'default' as the first option. Additional locales are detected
242
from the system using C<locale -a> and filtered for UTF-8 locales.
243
244
=cut
245
246
sub available_locales {
247
    my @available_locales = ();
248
249
    # Always include default option
250
    push @available_locales, {
251
        value => 'default',
252
        text  => 'Default Unicode collation'
253
    };
254
255
    # Get system locales using the same approach as init()
256
    my @system_locales = grep { chomp; not( /^C/ || $_ eq 'POSIX' ) } qx/locale -a/;
257
258
    my @filtered_locales = ();
259
    for my $locale (@system_locales) {
260
261
        # Filter for useful locales (UTF-8 ones and common patterns)
262
        if ( $locale =~ /^[a-z]{2}_[A-Z]{2}\.utf8?$/i || $locale =~ /^[a-z]{2}_[A-Z]{2}$/i ) {
263
264
            # Create friendly display names
265
            my $display_name = $locale;
266
            if ( $locale =~ /^([a-z]{2})_([A-Z]{2})/ ) {
267
                my %lang_names = (
268
                    'en' => 'English',
269
                    'fr' => 'French',
270
                    'de' => 'German',
271
                    'es' => 'Spanish',
272
                    'it' => 'Italian',
273
                    'pt' => 'Portuguese',
274
                    'nl' => 'Dutch',
275
                    'pl' => 'Polish',
276
                    'fi' => 'Finnish',
277
                    'sv' => 'Swedish',
278
                    'da' => 'Danish',
279
                    'no' => 'Norwegian',
280
                    'ru' => 'Russian',
281
                    'ja' => 'Japanese',
282
                    'zh' => 'Chinese',
283
                    'ar' => 'Arabic',
284
                    'hi' => 'Hindi'
285
                );
286
                my %country_names = (
287
                    'US' => 'United States',
288
                    'GB' => 'United Kingdom',
289
                    'FR' => 'France',
290
                    'DE' => 'Germany',
291
                    'ES' => 'Spain',
292
                    'IT' => 'Italy',
293
                    'PT' => 'Portugal',
294
                    'BR' => 'Brazil',
295
                    'NL' => 'Netherlands',
296
                    'PL' => 'Poland',
297
                    'FI' => 'Finland',
298
                    'SE' => 'Sweden',
299
                    'DK' => 'Denmark',
300
                    'NO' => 'Norway',
301
                    'RU' => 'Russia',
302
                    'JP' => 'Japan',
303
                    'CN' => 'China',
304
                    'TW' => 'Taiwan',
305
                    'SA' => 'Saudi Arabia',
306
                    'IN' => 'India'
307
                );
308
                my $lang    = $lang_names{$1}    || uc($1);
309
                my $country = $country_names{$2} || $2;
310
                $display_name = "$lang ($country) - $locale";
311
            }
312
            push @filtered_locales, {
313
                value => $locale,
314
                text  => $display_name
315
            };
316
        }
317
    }
318
319
    # Sort locales by display name and add to available list
320
    @filtered_locales = sort { $a->{text} cmp $b->{text} } @filtered_locales;
321
    push @available_locales, @filtered_locales;
322
323
    return \@available_locales;
324
}
325
227
1;
326
1;
(-)a/Koha/SearchEngine/Elasticsearch/Search.pm (-4 / +9 lines)
Lines 456-462 sub max_result_window { Link Here
456
    return $max_result_window;
456
    return $max_result_window;
457
}
457
}
458
458
459
460
=head2 _sort_facets
459
=head2 _sort_facets
461
460
462
    my $facets = _sort_facets($facets);
461
    my $facets = _sort_facets($facets);
Lines 469-479 sub _sort_facets { Link Here
469
    my ( $self, $args ) = @_;
468
    my ( $self, $args ) = @_;
470
    my $facets = $args->{facets};
469
    my $facets = $args->{facets};
471
    my $locale = $args->{locale};
470
    my $locale = $args->{locale};
471
472
    if ( !$locale ) {
472
    if ( !$locale ) {
473
473
474
        #NOTE: When setlocale is run with only the 1st parameter, it is a "get" not a "set" function.
474
        # Get locale from system preference, falling back to system LC_COLLATE
475
        $locale = setlocale(LC_COLLATE);
475
        $locale = C4::Context->preference('FacetSortingLocale') || 'default';
476
        if ( $locale eq 'default' || !$locale ) {
477
478
            #NOTE: When setlocale is run with only the 1st parameter, it is a "get" not a "set" function.
479
            $locale = setlocale(LC_COLLATE) || 'default';
480
        }
476
    }
481
    }
482
477
    my $collator = Unicode::Collate::Locale->new( locale => $locale );
483
    my $collator = Unicode::Collate::Locale->new( locale => $locale );
478
    if ( $collator && $facets ) {
484
    if ( $collator && $facets ) {
479
        my @sorted_facets = sort { $collator->cmp( $a->{facet_label_value}, $b->{facet_label_value} ) } @{$facets};
485
        my @sorted_facets = sort { $collator->cmp( $a->{facet_label_value}, $b->{facet_label_value} ) } @{$facets};
Lines 486-492 sub _sort_facets { Link Here
486
    return $facets;
492
    return $facets;
487
}
493
}
488
494
489
490
=head2 _convert_facets
495
=head2 _convert_facets
491
496
492
    my $koha_facets = _convert_facets($es_facets);
497
    my $koha_facets = _convert_facets($es_facets);
(-)a/admin/preferences.pl (+6 lines)
Lines 122-127 sub _get_chunk { Link Here
122
        }
122
        }
123
        $chunk->{'languages'} = getTranslatedLanguages( $interface, $theme );
123
        $chunk->{'languages'} = getTranslatedLanguages( $interface, $theme );
124
        $chunk->{'type'}      = 'languages';
124
        $chunk->{'type'}      = 'languages';
125
    } elsif ( $options{'type'} && $options{'type'} eq 'locale-list' ) {
126
127
        # Dynamic locale detection for facet sorting using Koha::I18N
128
        require Koha::I18N;
129
        $chunk->{'choices'} = Koha::I18N::available_locales();
130
        $chunk->{'type'}    = 'select';
125
    } elsif ( $options{'choices'} ) {
131
    } elsif ( $options{'choices'} ) {
126
        my $add_blank;
132
        my $add_blank;
127
        if ( $options{'choices'} && ref( $options{'choices'} ) eq '' ) {
133
        if ( $options{'choices'} && ref( $options{'choices'} ) eq '' ) {
(-)a/installer/data/mysql/atomicupdate/bug_36947.pl (+22 lines)
Line 0 Link Here
1
use Modern::Perl;
2
use Koha::Installer::Output qw(say_warning say_success say_info);
3
4
return {
5
    bug_number  => "36947",
6
    description => "Add FacetSortingLocale system preference for locale-based facet sorting",
7
    up          => sub {
8
        my ($args) = @_;
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
10
11
        $dbh->do(
12
            q{
13
            INSERT IGNORE INTO systempreferences (`variable`,`value`,`options`,`explanation`,`type`)
14
            VALUES ('FacetSortingLocale','default','',
15
                'Choose the locale for sorting facet names when FacetOrder is set to Alphabetical. This enables proper Unicode-aware sorting of accented characters and locale-specific alphabetical ordering.',
16
                'Choice')
17
        }
18
        );
19
20
        say_success( $out, "Added new system preference 'FacetSortingLocale'" );
21
    },
22
};
(-)a/installer/data/mysql/mandatory/sysprefs.sql (+1 lines)
Lines 278-283 INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` Link Here
278
('FacetLabelTruncationLength','20',NULL,'Specify the facet max length in OPAC','Integer'),
278
('FacetLabelTruncationLength','20',NULL,'Specify the facet max length in OPAC','Integer'),
279
('FacetMaxCount','20',NULL,'Specify the max facet count for each category','Integer'),
279
('FacetMaxCount','20',NULL,'Specify the max facet count for each category','Integer'),
280
('FacetOrder','Alphabetical','Alphabetical|Usage','Specify the order of facets within each category','Choice'),
280
('FacetOrder','Alphabetical','Alphabetical|Usage','Specify the order of facets within each category','Choice'),
281
('FacetSortingLocale','default','','Choose the locale for sorting facet names when FacetOrder is set to Alphabetical. This enables proper Unicode-aware sorting of accented characters and locale-specific alphabetical ordering.','Choice'),
281
('FailedLoginAttempts','','','Number of login attempts before lockout the patron account','Integer'),
282
('FailedLoginAttempts','','','Number of login attempts before lockout the patron account','Integer'),
282
('FallbackToSMSIfNoEmail', 0, 'Enable|Disable', 'Send messages by SMS if no patron email is defined', 'YesNo'),
283
('FallbackToSMSIfNoEmail', 0, 'Enable|Disable', 'Send messages by SMS if no patron email is defined', 'YesNo'),
283
('FeeOnChangePatronCategory','1','','If set, when a patron changes to a category with enrolment fee, a fee is charged','YesNo'),
284
('FeeOnChangePatronCategory','1','','If set, when a patron changes to a category with enrolment fee, a fee is charged','YesNo'),
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/i18n_l10n.pref (+5 lines)
Lines 63-68 I18N/L10N: Link Here
63
              de: German style ([Address] [Street number] - [ZIP/Postal Code] [City] - [Country])
63
              de: German style ([Address] [Street number] - [ZIP/Postal Code] [City] - [Country])
64
              fr: French style ([Street number] [Address] - [ZIP/Postal Code] [City] - [Country])
64
              fr: French style ([Street number] [Address] - [ZIP/Postal Code] [City] - [Country])
65
        - .
65
        - .
66
    -
67
        - Sort facet names using
68
        - pref: FacetSortingLocale
69
          type: locale-list
70
        - locale when FacetOrder is set to Alphabetical. This enables proper Unicode-aware sorting of accented characters and locale-specific alphabetical ordering.
66
    -
71
    -
67
        - pref: TranslateNotices
72
        - pref: TranslateNotices
68
          choices:
73
          choices:
(-)a/t/Koha/I18N.t (-1 / +47 lines)
Lines 2-8 Link Here
2
2
3
use Modern::Perl;
3
use Modern::Perl;
4
use Test::NoWarnings;
4
use Test::NoWarnings;
5
use Test::More tests => 36;
5
use Test::More tests => 37;
6
use Test::MockModule;
6
use Test::MockModule;
7
use FindBin qw($Bin);
7
use FindBin qw($Bin);
8
use Encode;
8
use Encode;
Lines 61-63 my @tests = ( Link Here
61
foreach my $test (@tests) {
61
foreach my $test (@tests) {
62
    is( $test->[0], decode_utf8( $test->[1] ), $test->[1] );
62
    is( $test->[0], decode_utf8( $test->[1] ), $test->[1] );
63
}
63
}
64
65
subtest 'available_locales' => sub {
66
    plan tests => 6;
67
68
    # Test basic functionality
69
    my $locales = Koha::I18N::available_locales();
70
71
    # Should return an arrayref
72
    is( ref($locales), 'ARRAY', 'available_locales returns an arrayref' );
73
74
    # Should have at least the default option
75
    ok( scalar(@$locales) >= 1, 'At least one locale returned (default)' );
76
77
    # First locale should be default
78
    is( $locales->[0]->{value}, 'default',                   'First locale is default' );
79
    is( $locales->[0]->{text},  'Default Unicode collation', 'Default locale has correct text' );
80
81
    # All locales should have value and text keys
82
    my $all_have_keys = 1;
83
    for my $locale (@$locales) {
84
        unless ( exists $locale->{value} && exists $locale->{text} ) {
85
            $all_have_keys = 0;
86
            last;
87
        }
88
    }
89
    ok( $all_have_keys, 'All locales have value and text keys' );
90
91
    # Test structure for real system locales (if any)
92
    my $system_locales = [ grep { $_->{value} ne 'default' } @$locales ];
93
    if (@$system_locales) {
94
95
        # Should have friendly display names for common locales
96
        my $has_friendly_name = 0;
97
        for my $locale (@$system_locales) {
98
            if ( $locale->{text} =~ /^[A-Z][a-z]+ \([^)]+\) - / ) {
99
                $has_friendly_name = 1;
100
                last;
101
            }
102
        }
103
        ok( $has_friendly_name, 'System locales have friendly display names' ) if @$system_locales;
104
    } else {
105
106
        # If no system locales, just pass this test
107
        ok( 1, 'No system locales found (test environment)' );
108
    }
109
};
(-)a/t/Koha/SearchEngine/Elasticsearch/Search.t (-1 / +8 lines)
Lines 29-35 use utf8; Link Here
29
use_ok('Koha::SearchEngine::Elasticsearch::Search');
29
use_ok('Koha::SearchEngine::Elasticsearch::Search');
30
30
31
subtest '_sort_facets' => sub {
31
subtest '_sort_facets' => sub {
32
    plan tests => 2;
32
    plan tests => 3;
33
    t::lib::Mocks::mock_preference( 'SearchEngine', 'Elasticsearch' );
33
    t::lib::Mocks::mock_preference( 'SearchEngine', 'Elasticsearch' );
34
    my $facets = [
34
    my $facets = [
35
        { facet_label_value => 'Mary' },
35
        { facet_label_value => 'Mary' },
Lines 81-86 subtest '_sort_facets' => sub { Link Here
81
    ];
81
    ];
82
    is_deeply( $sorted_facets, $expected, "Facets sorted correctly with default locale" );
82
    is_deeply( $sorted_facets, $expected, "Facets sorted correctly with default locale" );
83
83
84
    # Test system preference integration
85
    t::lib::Mocks::mock_preference( 'FacetSortingLocale', 'en_US.utf8' );
86
    my $sorted_facets_syspref = $search->_sort_facets( { facets => $facets } );
87
88
    # Should return sorted facets (exact order may vary by system locale availability)
89
    is( ref($sorted_facets_syspref), 'ARRAY', "System preference integration works" );
90
84
    #NOTE: If "locale" is not provided to _sort_facets, it will look up the LC_COLLATE
91
    #NOTE: If "locale" is not provided to _sort_facets, it will look up the LC_COLLATE
85
    #for the local system. This is what allows this function to work well in production.
92
    #for the local system. This is what allows this function to work well in production.
86
    #However, since LC_COLLATE could vary from system to system running these unit tests,
93
    #However, since LC_COLLATE could vary from system to system running these unit tests,
(-)a/t/db_dependent/Search_FacetSorting.t (-1 / +68 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Test::More tests => 1;
21
use Test::MockModule;
22
use t::lib::Mocks;
23
use C4::Search;
24
25
use utf8;
26
27
subtest '_sort_facets_zebra with system preference' => sub {
28
    plan tests => 3;
29
30
    my $facets = [
31
        { facet_label_value => 'Mary' },
32
        { facet_label_value => 'Harry' },
33
        { facet_label_value => 'Fairy' },
34
        { facet_label_value => 'Ari' },
35
        { facet_label_value => 'mary' },
36
        { facet_label_value => 'harry' },
37
        { facet_label_value => 'fairy' },
38
        { facet_label_value => 'ari' },
39
        { facet_label_value => 'étienne' },
40
        { facet_label_value => 'Åuthor' },
41
    ];
42
43
    # Test with explicit locale parameter
44
    my $sorted_facets_explicit = C4::Search::_sort_facets_zebra( $facets, 'default' );
45
    my $expected               = [
46
        { facet_label_value => 'ari' },
47
        { facet_label_value => 'Ari' },
48
        { facet_label_value => 'Åuthor' },
49
        { facet_label_value => 'étienne' },
50
        { facet_label_value => 'fairy' },
51
        { facet_label_value => 'Fairy' },
52
        { facet_label_value => 'harry' },
53
        { facet_label_value => 'Harry' },
54
        { facet_label_value => 'mary' },
55
        { facet_label_value => 'Mary' },
56
    ];
57
    is_deeply( $sorted_facets_explicit, $expected, "Zebra facets sorted correctly with explicit locale" );
58
59
    # Test with system preference
60
    t::lib::Mocks::mock_preference( 'FacetSortingLocale', 'default' );
61
    my $sorted_facets_syspref = C4::Search::_sort_facets_zebra($facets);
62
    is_deeply( $sorted_facets_syspref, $expected, "Zebra facets sorted correctly with system preference" );
63
64
    # Test fallback behavior
65
    t::lib::Mocks::mock_preference( 'FacetSortingLocale', '' );
66
    my $sorted_facets_fallback = C4::Search::_sort_facets_zebra($facets);
67
    is( ref($sorted_facets_fallback), 'ARRAY', "Zebra facets sorting fallback works" );
68
};

Return to bug 36947