3 # Copyright ByWater Solutions 2014
5 # This file is part of Koha.
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
22 use List::MoreUtils qw( any );
24 use URI::Escape qw( uri_escape_utf8 );
26 use C4::Koha qw( GetNormalizedISBN );
27 use C4::XSLT qw( transformMARCXML4XSLT );
30 use Koha::DateUtils qw( dt_from_string );
32 use base qw(Koha::Object);
34 use Koha::Acquisition::Orders;
35 use Koha::ArticleRequests;
36 use Koha::Biblio::Metadatas;
37 use Koha::Biblioitems;
38 use Koha::CirculationRules;
39 use Koha::Item::Transfer::Limits;
42 use Koha::Suggestions;
43 use Koha::Subscriptions;
44 use Koha::SearchEngine;
45 use Koha::SearchEngine::Search;
46 use Koha::SearchEngine::QueryBuilder;
50 Koha::Biblio - Koha Biblio Object class
60 Overloaded I<store> method to set default values
67 $self->datecreated( dt_from_string ) unless $self->datecreated;
69 return $self->SUPER::store;
74 my $metadata = $biblio->metadata();
76 Returns a Koha::Biblio::Metadata object
83 my $metadata = $self->_result->metadata;
84 return Koha::Biblio::Metadata->_new_from_dbic($metadata);
89 my $orders = $biblio->orders();
91 Returns a Koha::Acquisition::Orders object
98 my $orders = $self->_result->orders;
99 return Koha::Acquisition::Orders->_new_from_dbic($orders);
104 my $active_orders = $biblio->active_orders();
106 Returns the active acquisition orders related to this biblio.
107 An order is considered active when it is not cancelled (i.e. when datecancellation
115 return $self->orders->search({ datecancellationprinted => undef });
118 =head3 can_article_request
120 my $bool = $biblio->can_article_request( $borrower );
122 Returns true if article requests can be made for this record
124 $borrower must be a Koha::Patron object
128 sub can_article_request {
129 my ( $self, $borrower ) = @_;
131 my $rule = $self->article_request_type($borrower);
132 return q{} if $rule eq 'item_only' && !$self->items()->count();
133 return 1 if $rule && $rule ne 'no';
138 =head3 can_be_transferred
140 $biblio->can_be_transferred({ to => $to_library, from => $from_library })
142 Checks if at least one item of a biblio can be transferred to given library.
144 This feature is controlled by two system preferences:
145 UseBranchTransferLimits to enable / disable the feature
146 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
147 for setting the limitations
149 Performance-wise, it is recommended to use this method for a biblio instead of
150 iterating each item of a biblio with Koha::Item->can_be_transferred().
152 Takes HASHref that can have the following parameters:
153 MANDATORY PARAMETERS:
156 $from : Koha::Library # if given, only items from that
157 # holdingbranch are considered
159 Returns 1 if at least one of the item of a biblio can be transferred
160 to $to_library, otherwise 0.
164 sub can_be_transferred {
165 my ($self, $params) = @_;
167 my $to = $params->{to};
168 my $from = $params->{from};
170 return 1 unless C4::Context->preference('UseBranchTransferLimits');
171 my $limittype = C4::Context->preference('BranchTransferLimitsType');
174 foreach my $item_of_bib ($self->items->as_list) {
175 next unless $item_of_bib->holdingbranch;
176 next if $from && $from->branchcode ne $item_of_bib->holdingbranch;
177 return 1 if $item_of_bib->holdingbranch eq $to->branchcode;
178 my $code = $limittype eq 'itemtype'
179 ? $item_of_bib->effective_itemtype
180 : $item_of_bib->ccode;
181 return 1 unless $code;
182 $items->{$code}->{$item_of_bib->holdingbranch} = 1;
185 # At this point we will have a HASHref containing each itemtype/ccode that
186 # this biblio has, inside which are all of the holdingbranches where those
187 # items are located at. Then, we will query Koha::Item::Transfer::Limits to
188 # find out whether a transfer limits for such $limittype from any of the
189 # listed holdingbranches to the given $to library exist. If at least one
190 # holdingbranch for that $limittype does not have a transfer limit to given
191 # $to library, then we know that the transfer is possible.
192 foreach my $code (keys %{$items}) {
193 my @holdingbranches = keys %{$items->{$code}};
194 return 1 if Koha::Item::Transfer::Limits->search({
195 toBranch => $to->branchcode,
196 fromBranch => { 'in' => \@holdingbranches },
199 group_by => [qw/fromBranch/]
200 })->count == scalar(@holdingbranches) ? 0 : 1;
207 =head3 pickup_locations
209 my $pickup_locations = $biblio->pickup_locations( {patron => $patron } );
211 Returns a Koha::Libraries set of possible pickup locations for this biblio's items,
212 according to patron's home library (if patron is defined and holds are allowed
213 only from hold groups) and if item can be transferred to each pickup location.
217 sub pickup_locations {
218 my ( $self, $params ) = @_;
220 my $patron = $params->{patron};
222 my @pickup_locations;
223 foreach my $item_of_bib ( $self->items->as_list ) {
224 push @pickup_locations,
225 $item_of_bib->pickup_locations( { patron => $patron } )
226 ->_resultset->get_column('branchcode')->all;
229 return Koha::Libraries->search(
230 { branchcode => { '-in' => \@pickup_locations } }, { order_by => ['branchname'] } );
233 =head3 hidden_in_opac
235 my $bool = $biblio->hidden_in_opac({ [ rules => $rules ] })
237 Returns true if the biblio matches the hidding criteria defined in $rules.
238 Returns false otherwise. It involves the I<OpacHiddenItems> and
239 I<OpacHiddenItemsHidesRecord> system preferences.
241 Takes HASHref that can have the following parameters:
243 $rules : { <field> => [ value_1, ... ], ... }
245 Note: $rules inherits its structure from the parsed YAML from reading
246 the I<OpacHiddenItems> system preference.
251 my ( $self, $params ) = @_;
253 my $rules = $params->{rules} // {};
255 my @items = $self->items->as_list;
257 return 0 unless @items; # Do not hide if there is no item
259 # Ok, there are items, don't even try the rules unless OpacHiddenItemsHidesRecord
260 return 0 unless C4::Context->preference('OpacHiddenItemsHidesRecord');
262 return !(any { !$_->hidden_in_opac({ rules => $rules }) } @items);
265 =head3 article_request_type
267 my $type = $biblio->article_request_type( $borrower );
269 Returns the article request type based on items, or on the record
270 itself if there are no items.
272 $borrower must be a Koha::Patron object
276 sub article_request_type {
277 my ( $self, $borrower ) = @_;
279 return q{} unless $borrower;
281 my $rule = $self->article_request_type_for_items( $borrower );
282 return $rule if $rule;
284 # If the record has no items that are requestable, go by the record itemtype
285 $rule = $self->article_request_type_for_bib($borrower);
286 return $rule if $rule;
291 =head3 article_request_type_for_bib
293 my $type = $biblio->article_request_type_for_bib
295 Returns the article request type 'yes', 'no', 'item_only', 'bib_only', for the given record
299 sub article_request_type_for_bib {
300 my ( $self, $borrower ) = @_;
302 return q{} unless $borrower;
304 my $borrowertype = $borrower->categorycode;
305 my $itemtype = $self->itemtype();
307 my $rule = Koha::CirculationRules->get_effective_rule(
309 rule_name => 'article_requests',
310 categorycode => $borrowertype,
311 itemtype => $itemtype,
315 return q{} unless $rule;
316 return $rule->rule_value || q{}
319 =head3 article_request_type_for_items
321 my $type = $biblio->article_request_type_for_items
323 Returns the article request type 'yes', 'no', 'item_only', 'bib_only', for the given record's items
325 If there is a conflict where some items are 'bib_only' and some are 'item_only', 'bib_only' will be returned.
329 sub article_request_type_for_items {
330 my ( $self, $borrower ) = @_;
333 foreach my $item ( $self->items()->as_list() ) {
334 my $rule = $item->article_request_type($borrower);
335 return $rule if $rule eq 'bib_only'; # we don't need to go any further
339 return 'item_only' if $counts->{item_only};
340 return 'yes' if $counts->{yes};
341 return 'no' if $counts->{no};
345 =head3 article_requests
347 my $article_requests = $biblio->article_requests
349 Returns the article requests associated with this biblio
353 sub article_requests {
356 return Koha::ArticleRequests->_new_from_dbic( scalar $self->_result->article_requests );
361 my $items = $biblio->items();
363 Returns the related Koha::Items object for this biblio
370 my $items_rs = $self->_result->items;
372 return Koha::Items->_new_from_dbic( $items_rs );
377 my $host_items = $biblio->host_items();
379 Return the host items (easy analytical record)
386 return Koha::Items->new->empty
387 unless C4::Context->preference('EasyAnalyticalRecords');
389 my $marcflavour = C4::Context->preference("marcflavour");
390 my $analyticfield = '773';
391 if ( $marcflavour eq 'MARC21' ) {
392 $analyticfield = '773';
394 elsif ( $marcflavour eq 'UNIMARC' ) {
395 $analyticfield = '461';
397 my $marc_record = $self->metadata->record;
399 foreach my $field ( $marc_record->field($analyticfield) ) {
400 push @itemnumbers, $field->subfield('9');
403 return Koha::Items->search( { itemnumber => { -in => \@itemnumbers } } );
408 my $itemtype = $biblio->itemtype();
410 Returns the itemtype for this record.
417 return $self->biblioitem()->itemtype();
422 my $holds = $biblio->holds();
424 return the current holds placed on this record
429 my ( $self, $params, $attributes ) = @_;
430 $attributes->{order_by} = 'priority' unless exists $attributes->{order_by};
431 my $hold_rs = $self->_result->reserves->search( $params, $attributes );
432 return Koha::Holds->_new_from_dbic($hold_rs);
437 my $holds = $biblio->current_holds
439 Return the holds placed on this bibliographic record.
440 It does not include future holds.
446 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
448 { reservedate => { '<=' => $dtf->format_date(dt_from_string) } } );
453 my $field = $self->biblioitem()->itemtype
455 Returns the related Koha::Biblioitem object for this Biblio object
462 $self->{_biblioitem} ||= Koha::Biblioitems->find( { biblionumber => $self->biblionumber() } );
464 return $self->{_biblioitem};
469 my $suggestions = $self->suggestions
471 Returns the related Koha::Suggestions object for this Biblio object
478 my $suggestions_rs = $self->_result->suggestions;
479 return Koha::Suggestions->_new_from_dbic( $suggestions_rs );
482 =head3 get_marc_components
484 my $components = $self->get_marc_components();
486 Returns an array of MARCXML data, which are component parts of
487 this object (MARC21 773$w points to this)
491 sub get_marc_components {
492 my ($self, $max_results) = @_;
494 return [] if (C4::Context->preference('marcflavour') ne 'MARC21');
496 my $searchstr = $self->get_components_query;
499 if (defined($searchstr)) {
500 my $searcher = Koha::SearchEngine::Search->new({index => $Koha::SearchEngine::BIBLIOS_INDEX});
501 my ( $errors, $results, $total_hits ) = $searcher->simple_search_compat( $searchstr, 0, $max_results );
502 $components = $results if defined($results) && @$results;
505 return $components // [];
508 =head2 get_components_query
510 Returns a query which can be used to search for all component parts of MARC21 biblios
514 sub get_components_query {
517 my $builder = Koha::SearchEngine::QueryBuilder->new(
518 { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
519 my $marc = $self->metadata->record;
522 if ( C4::Context->preference('UseControlNumber') ) {
523 my $pf001 = $marc->field('001') || undef;
525 if ( defined($pf001) ) {
527 my $pf003 = $marc->field('003') || undef;
529 if ( !defined($pf003) ) {
530 # search for 773$w='Host001'
531 $searchstr .= "rcn:" . $pf001->data();
535 # search for (773$w='Host001' and 003='Host003') or 773$w='(Host003)Host001'
536 $searchstr .= "(rcn:" . $pf001->data() . " AND cni:" . $pf003->data() . ")";
537 $searchstr .= " OR rcn:\"" . $pf003->data() . " " . $pf001->data() . "\"";
541 # limit to monograph and serial component part records
542 $searchstr .= " AND (bib-level:a OR bib-level:b)";
547 my $cleaned_title = $marc->subfield('245', "a");
548 $cleaned_title =~ tr|/||;
549 $cleaned_title = $builder->clean_search_term($cleaned_title);
550 $searchstr = "Host-item:($cleaned_title)";
558 my $subscriptions = $self->subscriptions
560 Returns the related Koha::Subscriptions object for this Biblio object
567 $self->{_subscriptions} ||= Koha::Subscriptions->search( { biblionumber => $self->biblionumber } );
569 return $self->{_subscriptions};
572 =head3 has_items_waiting_or_intransit
574 my $itemsWaitingOrInTransit = $biblio->has_items_waiting_or_intransit
576 Tells if this bibliographic record has items waiting or in transit.
580 sub has_items_waiting_or_intransit {
583 if ( Koha::Holds->search({ biblionumber => $self->id,
584 found => ['W', 'T'] })->count ) {
588 foreach my $item ( $self->items->as_list ) {
589 return 1 if $item->get_transfer;
597 my $coins = $biblio->get_coins;
599 Returns the COinS (a span) which can be included in a biblio record
606 my $record = $self->metadata->record;
608 my $pos7 = substr $record->leader(), 7, 1;
609 my $pos6 = substr $record->leader(), 6, 1;
612 my ( $aulast, $aufirst ) = ( '', '' );
623 # For the purposes of generating COinS metadata, LDR/06-07 can be
624 # considered the same for UNIMARC and MARC21
633 'i' => 'audioRecording',
634 'j' => 'audioRecording',
637 'm' => 'computerProgram',
642 'a' => 'journalArticle',
646 $genre = $fmts6->{$pos6} ? $fmts6->{$pos6} : 'book';
648 if ( $genre eq 'book' ) {
649 $genre = $fmts7->{$pos7} if $fmts7->{$pos7};
652 ##### We must transform mtx to a valable mtx and document type ####
653 if ( $genre eq 'book' ) {
656 } elsif ( $genre eq 'journal' ) {
659 } elsif ( $genre eq 'journalArticle' ) {
667 if ( C4::Context->preference("marcflavour") eq "UNIMARC" ) {
670 $aulast = $record->subfield( '700', 'a' ) || '';
671 $aufirst = $record->subfield( '700', 'b' ) || '';
672 push @authors, "$aufirst $aulast" if ($aufirst or $aulast);
675 if ( $record->field('200') ) {
676 for my $au ( $record->field('200')->subfield('g') ) {
681 $title = $record->subfield( '200', 'a' );
682 my $subfield_210d = $record->subfield('210', 'd');
683 if ($subfield_210d and $subfield_210d =~ /(\d{4})/) {
686 $publisher = $record->subfield( '210', 'c' ) || '';
687 $isbn = $record->subfield( '010', 'a' ) || '';
688 $issn = $record->subfield( '011', 'a' ) || '';
691 # MARC21 need some improve
694 if ( $record->field('100') ) {
695 push @authors, $record->subfield( '100', 'a' );
699 if ( $record->field('700') ) {
700 for my $au ( $record->field('700')->subfield('a') ) {
704 $title = $record->field('245');
705 $title &&= $title->as_string('ab');
706 if ($titletype eq 'a') {
707 $pubyear = $record->field('008') || '';
708 $pubyear = substr($pubyear->data(), 7, 4) if $pubyear;
709 $isbn = $record->subfield( '773', 'z' ) || '';
710 $issn = $record->subfield( '773', 'x' ) || '';
711 $hosttitle = $record->subfield( '773', 't' ) || $record->subfield( '773', 'a') || q{};
712 my @rels = $record->subfield( '773', 'g' );
713 $pages = join(', ', @rels);
715 $pubyear = $record->subfield( '260', 'c' ) || '';
716 $publisher = $record->subfield( '260', 'b' ) || '';
717 $isbn = $record->subfield( '020', 'a' ) || '';
718 $issn = $record->subfield( '022', 'a' ) || '';
724 [ 'ctx_ver', 'Z39.88-2004' ],
725 [ 'rft_val_fmt', "info:ofi/fmt:kev:mtx:$mtx" ],
726 [ ($mtx eq 'dc' ? 'rft.type' : 'rft.genre'), $genre ],
727 [ "rft.${titletype}title", $title ],
730 # rft.title is authorized only once, so by checking $titletype
731 # we ensure that rft.title is not already in the list.
732 if ($hosttitle and $titletype) {
733 push @params, [ 'rft.title', $hosttitle ];
737 [ 'rft.isbn', $isbn ],
738 [ 'rft.issn', $issn ],
741 # If it's a subscription, these informations have no meaning.
742 if ($genre ne 'journal') {
744 [ 'rft.aulast', $aulast ],
745 [ 'rft.aufirst', $aufirst ],
746 (map { [ 'rft.au', $_ ] } @authors),
747 [ 'rft.pub', $publisher ],
748 [ 'rft.date', $pubyear ],
749 [ 'rft.pages', $pages ],
753 my $coins_value = join( '&',
754 map { $$_[1] ? $$_[0] . '=' . uri_escape_utf8( $$_[1] ) : () } @params );
761 my $url = $biblio->get_openurl;
763 Returns url for OpenURL resolver set in OpenURLResolverURL system preference
770 my $OpenURLResolverURL = C4::Context->preference('OpenURLResolverURL');
772 if ($OpenURLResolverURL) {
773 my $uri = URI->new($OpenURLResolverURL);
775 if (not defined $uri->query) {
776 $OpenURLResolverURL .= '?';
778 $OpenURLResolverURL .= '&';
780 $OpenURLResolverURL .= $self->get_coins;
783 return $OpenURLResolverURL;
788 my $serial = $biblio->is_serial
790 Return boolean true if this bibbliographic record is continuing resource
797 return 1 if $self->serial;
799 my $record = $self->metadata->record;
800 return 1 if substr($record->leader, 7, 1) eq 's';
805 =head3 custom_cover_image_url
807 my $image_url = $biblio->custom_cover_image_url
809 Return the specific url of the cover image for this bibliographic record.
810 It is built regaring the value of the system preference CustomCoverImagesURL
814 sub custom_cover_image_url {
816 my $url = C4::Context->preference('CustomCoverImagesURL');
817 if ( $url =~ m|{isbn}| ) {
818 my $isbn = $self->biblioitem->isbn;
820 $url =~ s|{isbn}|$isbn|g;
822 if ( $url =~ m|{normalized_isbn}| ) {
823 my $normalized_isbn = C4::Koha::GetNormalizedISBN($self->biblioitem->isbn);
824 return unless $normalized_isbn;
825 $url =~ s|{normalized_isbn}|$normalized_isbn|g;
827 if ( $url =~ m|{issn}| ) {
828 my $issn = $self->biblioitem->issn;
830 $url =~ s|{issn}|$issn|g;
833 my $re = qr|{(?<field>\d{3})(\$(?<subfield>.))?}|;
835 my $field = $+{field};
836 my $subfield = $+{subfield};
837 my $marc_record = $self->metadata->record;
840 $value = $marc_record->subfield( $field, $subfield );
842 my $controlfield = $marc_record->field($field);
843 $value = $controlfield->data() if $controlfield;
845 return unless $value;
846 $url =~ s|$re|$value|;
854 Return the cover images associated with this biblio.
861 my $cover_images_rs = $self->_result->cover_images;
862 return unless $cover_images_rs;
863 return Koha::CoverImages->_new_from_dbic($cover_images_rs);
866 =head3 get_marc_notes
868 $marcnotesarray = $biblio->get_marc_notes({ marcflavour => $marcflavour });
870 Get all notes from the MARC record and returns them in an array.
871 The notes are stored in different fields depending on MARC flavour.
872 MARC21 5XX $u subfields receive special attention as they are URIs.
877 my ( $self, $params ) = @_;
879 my $marcflavour = $params->{marcflavour};
880 my $opac = $params->{opac};
882 my $scope = $marcflavour eq "UNIMARC"? '3..': '5..';
885 #MARC21 specs indicate some notes should be private if first indicator 0
886 my %maybe_private = (
894 my %hiddenlist = map { $_ => 1 }
895 split( /,/, C4::Context->preference('NotesToHide'));
896 my $record = $self->metadata->record;
897 $record = transformMARCXML4XSLT( $self->biblionumber, $record, $opac );
899 foreach my $field ( $record->field($scope) ) {
900 my $tag = $field->tag();
901 next if $hiddenlist{ $tag };
902 next if $opac && $maybe_private{$tag} && !$field->indicator(1);
903 if( $marcflavour ne 'UNIMARC' && $field->subfield('u') ) {
904 # Field 5XX$u always contains URI
905 # Examples: 505u, 506u, 510u, 514u, 520u, 530u, 538u, 540u, 542u, 552u, 555u, 561u, 563u, 583u
906 # We first push the other subfields, then all $u's separately
907 # Leave further actions to the template (see e.g. opac-detail)
909 join '', ( 'a' .. 't', 'v' .. 'z', '0' .. '9' ); # excl 'u'
910 push @marcnotes, { marcnote => $field->as_string($othersub) };
911 foreach my $sub ( $field->subfield('u') ) {
912 $sub =~ s/^\s+|\s+$//g; # trim
913 push @marcnotes, { marcnote => $sub };
916 push @marcnotes, { marcnote => $field->as_string() };
924 my $json = $biblio->to_api;
926 Overloaded method that returns a JSON representation of the Koha::Biblio object,
927 suitable for API output. The related Koha::Biblioitem object is merged as expected
933 my ($self, $args) = @_;
935 my $response = $self->SUPER::to_api( $args );
936 my $biblioitem = $self->biblioitem->to_api;
938 return { %$response, %$biblioitem };
941 =head3 to_api_mapping
943 This method returns the mapping for representing a Koha::Biblio object
950 biblionumber => 'biblio_id',
951 frameworkcode => 'framework_id',
952 unititle => 'uniform_title',
953 seriestitle => 'series_title',
954 copyrightdate => 'copyright_date',
955 datecreated => 'creation_date'
961 $host = $biblio->get_marc_host;
963 ( $host, $relatedparts ) = $biblio->get_marc_host;
965 Returns host biblio record from MARC21 773 (undef if no 773 present).
966 It looks at the first 773 field with MARCorgCode or only a control
967 number. Complete $w or numeric part is used to search host record.
968 The optional parameter no_items triggers a check if $biblio has items.
969 If there are, the sub returns undef.
970 Called in list context, it also returns 773$g (related parts).
975 my ($self, $params) = @_;
976 my $no_items = $params->{no_items};
977 return if C4::Context->preference('marcflavour') eq 'UNIMARC'; # TODO
978 return if $params->{no_items} && $self->items->count > 0;
981 eval { $record = $self->metadata->record };
984 # We pick the first $w with your MARCOrgCode or the first $w that has no
985 # code (between parentheses) at all.
986 my $orgcode = C4::Context->preference('MARCOrgCode') // q{};
988 foreach my $f ( $record->field('773') ) {
989 my $w = $f->subfield('w') or next;
990 if( $w =~ /^\($orgcode\)\s*(\d+)/i or $w =~ /^\d+/ ) {
996 my $rcn = $hostfld->subfield('w');
998 # Look for control number with/without orgcode
999 my $engine = Koha::SearchEngine::Search->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
1001 for my $try (1..2) {
1002 my ( $error, $results, $total_hits ) = $engine->simple_search_compat( 'Control-number='.$rcn, 0,1 );
1003 if( !$error and $total_hits == 1 ) {
1004 $bibno = $engine->extract_biblionumber( $results->[0] );
1007 # Add or remove orgcode for second try
1008 if( $try == 1 && $rcn =~ /\)\s*(\d+)/ ) {
1009 $rcn = $1; # number only
1010 } elsif( $try == 1 && $rcn =~ /^\d+/ ) {
1011 $rcn = "($orgcode)$rcn";
1017 my $host = Koha::Biblios->find($bibno) or return;
1018 return wantarray ? ( $host, $hostfld->subfield('g') ) : $host;
1022 =head2 Internal methods
1034 Kyle M Hall <kyle@bywatersolutions.com>