f68781f828d7a036165c2509afe01322109b98fb
[bse.git] / site / cgi-bin / modules / BSE / Importer / Target / Article.pm
1 package BSE::Importer::Target::Article;
2 use strict;
3 use base 'BSE::Importer::Target::Base';
4 use BSE::API qw(bse_make_article bse_add_image bse_add_step_parent);
5 use BSE::TB::Articles;
6 use BSE::TB::Products;
7 use BSE::TB::OtherParents;
8
9 our $VERSION = "1.012";
10
11 =head1 NAME
12
13 BSE::Importer::Target::Article - import target for articles.
14
15 =head1 SYNOPSIS
16
17   [import profile foo]
18   ...
19   ; these are the defaults
20   codes=0
21   code_field=linkAlias
22   parent=-1
23   ignore_missing=1
24   reset_images=0
25   reset_files=0
26   reset_steps=0
27
28   # done by the importer
29   my $target = BSE::Importer::Target::Article->new
30      (importer => $importer, opts => \%opts)
31   ...
32   $target->start($imp);
33   # for each row:
34   $target->row($imp, \%entry, \@parents);
35
36
37 =head1 DESCRIPTION
38
39 Provides a target for importing BSE articles.
40
41 C<update_only> profiles must provide a mapping for one of C<id> or
42 C<linkAlias>.
43
44 Non-C<update_only> profiles must provide a mapping for C<title>.
45
46 =head1 CONFIGURATION
47
48 The following extra configuration can be set in the import profile:
49
50 =over
51
52 =item *
53
54 C<codes> - set to true to use the configured C<code_field> to update
55 existing articles rather than creating new articles.  This is forced
56 on when the import profile enables C<update_only>.
57
58 =item *
59
60 C<code_field> - the field to use to identify existing articles.
61 Default: C<linkAlias> for article imports.
62
63 =item *
64
65 C<parent> - the base of the tree of parent articles to create the
66 parent tree under.
67
68 =item *
69
70 C<ignore_missing> - set to 0 to error on missing image or article
71 files.  Default: 1.
72
73 =item *
74
75 C<reset_images> - set to true to delete all images from an article
76 before adding the imported images.
77
78 =item *
79
80 C<reset_files> - set to true to delete all files from an article
81 before adding the imported files.
82
83 =item *
84
85 C<reset_steps> - set to true to delete all step parents from an
86 article before adding the imported steps.
87
88 =back
89
90 =head1 SPECIAL FIELDS
91
92 The following fields are used to import extra information into
93 articles:
94
95 =over
96
97 =item *
98
99 C<< imageI<index>_I<field> >> - used to import images,
100 eg. C<image1_file> to specify the image file.  Note: images are not
101 replaced unless C<reset_images> is set.  I<index> is a number from 1
102 to 10, I<field> can be any of C<file>, C<alt>, C<name>, C<url>,
103 C<storage>, with the C<file> entry being required.
104
105 =item *
106
107 C<< stepI<index> >> - specify step parents for the article.  This can
108 either be the article id or the article link alias.
109
110 =item *
111
112 C<tags> - this is split on C</> to set the tags for the article.
113
114 =back
115
116 =head1 METHODS
117
118 =over
119
120 =item new()
121
122 Create a new article import target.  Follows the protocol specified by
123 L<BSE::Importer::Target::Base>.
124
125 =cut
126
127 sub new {
128   my ($class, %opts) = @_;
129
130   my $self = $class->SUPER::new(%opts);
131
132   my $importer = delete $opts{importer};
133
134   $self->{use_codes} = $importer->cfg_entry('codes', 0);
135   my $map = $importer->maps;
136   if ($importer->update_only) {
137     my $def_code;
138     my $found_key = 0;
139   KEYS:
140     for my $key ($self->key_fields) {
141       if ($map->{$key}) {
142         $found_key = 1;
143         $def_code = $key;
144         last KEYS;
145       }
146     }
147     $found_key
148       or die "No key field (", join(",", $self->key_fields),
149         ") mapping found\n";
150
151     $self->{code_field} = $importer->cfg_entry("code_field", $def_code);
152     $self->{use_codes} = 1;
153   }
154   else {
155     defined $map->{title}
156       or die "No title mapping found\n";
157
158     $self->{code_field} = $importer->cfg_entry("code_field", $self->default_code_field);
159
160   }
161
162   $self->{parent} = $importer->cfg_entry("parent", $self->default_parent);
163
164   if ($self->{use_codes} && !defined $map->{$self->{code_field}}) {
165     die "No $self->{code_field} mapping found with 'codes' enabled\n";
166   }
167   $self->{ignore_missing} = $importer->cfg_entry("ignore_missing", 1);
168   $self->{reset_images} = $importer->cfg_entry("reset_images", 0);
169   $self->{reset_files} = $importer->cfg_entry("reset_files", 0);
170   $self->{reset_steps} = $importer->cfg_entry("reset_steps", 0);
171
172   return $self;
173 }
174
175 =item start()
176
177 Start import processing.
178
179 =cut
180
181 sub start {
182   my ($self) = @_;
183
184   $self->{parent_cache} = {};
185   $self->{leaves} = [];
186   $self->{parents} = [];
187 }
188
189 =item row()
190
191 Process a row of data.
192
193 =cut
194
195 sub row {
196   my ($self, $importer, $entry, $parents) = @_;
197
198   $self->xform_entry($importer, $entry);
199
200   if (!$importer->update_only || @$parents) {
201     $entry->{parentid} = $self->_find_parent($importer, $self->{parent}, @$parents);
202   }
203
204   my $leaf;
205   if ($self->{use_codes}) {
206     my $leaf_id = $entry->{$self->{code_field}};
207
208     if ($importer->{update_only}) {
209       $leaf_id =~ /\S/
210         or die "$self->{code_field} blank for update_only profile\n";
211     }
212
213     $leaf = $self->find_leaf($leaf_id, $importer);
214   }
215   if ($leaf) {
216     @{$leaf}{keys %$entry} = values %$entry;
217     $leaf->mark_modified(actor => $importer->actor);
218     $leaf->save;
219     $importer->info("Updated $leaf->{id}: ".$leaf->title);
220     if ($self->{reset_images}) {
221       $leaf->remove_images($importer->cfg);
222       $importer->info(" $leaf->{id}: Reset images");
223     }
224     if ($self->{reset_files}) {
225       $leaf->remove_files($importer->cfg);
226       $importer->info(" $leaf->{id}: Reset files");
227     }
228     if ($self->{reset_steps}) {
229       my @steps = BSE::TB::OtherParents->getBy(childId => $leaf->{id});
230       for my $step (@steps) {
231         $step->remove;
232       }
233     }
234   }
235   elsif (!$importer->update_only) {
236     $entry->{createdBy} ||= ref $importer->actor ? $importer->actor->logon : "";
237     $entry->{lastModifiedBy} ||= ref $importer->actor ? $importer->actor->logon : "";
238     $self->validate_make_leaf($importer, $entry);
239     $leaf = $self->make_leaf
240       (
241        $importer, 
242        cfg => $importer->cfg,
243        %$entry
244       );
245     $importer->info("Added $leaf->{id}: $entry->{title}");
246   }
247   else {
248     die "No leaf found for $entry->{$self->{code_field}} for update_only profile\n";
249   }
250   for my $image_index (1 .. 10) {
251     my $file = $entry->{"image${image_index}_file"};
252     $file
253       or next;
254     my $full_file = $importer->find_file($file);
255
256     unless ($full_file) {
257       $self->{ignore_missing}
258         and next;
259       die "File '$file' not found for image$image_index\n";
260     }
261
262     my %opts =
263       (
264        file => $full_file,
265        display_name => $file,
266       );
267     for my $key (qw/alt name url storage/) {
268       my $fkey = "image${image_index}_$key";
269       $entry->{$fkey}
270         and $opts{$key} = $entry->{$fkey};
271     }
272     
273     my %errors;
274     my $im = bse_add_image($importer->cfg, $leaf, %opts, 
275                            errors => \%errors);
276     $im 
277       or die join(", ",map "$_: $errors{$_}", keys %errors), "\n";
278     $importer->info(" $leaf->{id}: Add image '$file'");
279   }
280   $self->_add_files($importer, $entry, $leaf);
281   for my $step_index (1 .. 10) {
282     my $step_id = $entry->{"step$step_index"};
283     $step_id
284       or next;
285     my $step;
286     if ($step_id =~ /^\d+$/) {
287       $step = BSE::TB::Articles->getByPkey($step_id);
288     }
289     else {
290       $step = BSE::TB::Articles->getBy(linkAlias => $step_id);
291     }
292     $step
293       or die "Cannot find stepparent with id $step_id\n";
294
295     bse_add_step_parent($importer->cfg, child => $leaf, parent => $step);
296   }
297   $self->fill_leaf($importer, $leaf, %$entry);
298   push @{$self->{leaves}}, $leaf;
299
300   $importer->event(endrow => { leaf => $leaf });
301 }
302
303 sub _add_files {
304   my ($self, $importer, $entry, $leaf) = @_;
305
306   my %named_files = map { $_->name => $_ } grep $_->name ne '', $leaf->files;
307
308   for my $file_index (1 .. 10) {
309     my %opts;
310
311     my $found = 0;
312     for my $key (qw/name displayName storage description forSale download requireUser notes hide_from_list category/) {
313       my $fkey = "file${file_index}_$key";
314       if (defined $entry->{$fkey}) {
315         $opts{$key} = $entry->{$fkey};
316         $found = 1;
317       }
318     }
319
320     my $filename = $entry->{"file${file_index}_file"};
321     if ($filename) {
322       my $full_file = $importer->find_file($filename);
323
324       unless ($full_file) {
325         $self->{ignore_missing}
326           and next;
327         die "File '$filename' not found for file$file_index\n";
328       }
329
330       $opts{filename} = $full_file;
331       $found = 1;
332     }
333
334     $found
335       or next;
336
337     my $file;
338     if ($opts{name}) {
339       $file = $named_files{$opts{name}};
340     }
341
342     if (!$file && !$opts{filename}) {
343       $importer->warn("No file${file_index}_file supplied but other file${file_index}_* field supplied");
344       next;
345     }
346
347     if ($filename && !$opts{displayName}) {
348       unless (($opts{displayName}) = $filename =~ /([^\\\/:]+)$/) {
349         $importer->warn("Cannot create displayName for $filename");
350         next;
351       }
352     }
353
354     eval {
355       if ($file) {
356         my @warnings;
357         $file->update
358           (
359            _actor => $importer->actor,
360            _warnings => \@warnings,
361            %opts,
362           );
363
364         $importer->info(" $leaf->{id}: Update file '".$file->displayName ."'");
365       }
366       else {
367         # this dies on failure
368         $file = $leaf->add_file
369           (
370            $importer->cfg,
371            %opts,
372            store => 1,
373           );
374
375         $importer->info(" $leaf->{id}: Add file '$filename'");
376       }
377       1;
378     } or do {
379       $importer->warn($@);
380     };
381   }
382 }
383
384 =item xform_entry()
385
386 Called by row() to perform an extra data transformation needed.
387
388 Currently this forces a non-blank, non-newline title, and defaults the
389 values of C<summary>, C<description> and C<body> to the title.
390
391 =cut
392
393 sub xform_entry {
394   my ($self, $importer, $entry) = @_;
395
396   if (exists $entry->{title}) {
397     $entry->{title} =~ /\S/
398       or die "title blank\n";
399
400     $entry->{title} =~ /\n/
401       and die "Title may not contain newlines";
402   }
403   unless ($importer->update_only) {
404     $entry->{summary}
405       or $entry->{summary} = $entry->{title};
406     $entry->{description}
407       or $entry->{description} = $entry->{title};
408     $entry->{body}
409       or $entry->{body} = $entry->{title};
410   }
411
412   if (defined $entry->{linkAlias}) {
413     $entry->{linkAlias} =~ tr/A-Za-z0-9_-//cd;
414   }
415 }
416
417 =item children_of()
418
419 Utility method to find the children of a given article.
420
421 =cut
422
423 sub children_of {
424   my ($self, $parent) = @_;
425
426   BSE::TB::Articles->children($parent);
427 }
428
429 =item make_parent()
430
431 Create a parent article.
432
433 Overridden in the product importer to create catalogs.
434
435 =cut
436
437 sub make_parent {
438   my ($self, $importer, %entry) = @_;
439
440   return bse_make_article(%entry);
441 }
442
443 =item find_leaf()
444
445 Find a leave article based on the supplied code.
446
447 =cut
448
449 sub find_leaf {
450   my ($self, $leaf_id, $importer) = @_;
451
452   $leaf_id =~ s/\A\s+//;
453   $leaf_id =~ s/\s+\z//;
454
455   my ($leaf) = BSE::TB::Articles->getBy($self->{code_field}, $leaf_id)
456     or return;
457
458   $importer->event(find_leaf => { id => $leaf_id, leaf => $leaf });
459
460   return $leaf;
461 }
462
463 =item make_leaf()
464
465 Create an article based on the imported data.
466
467 Overridden in the product importer to create products.
468
469 =cut
470
471 sub make_leaf {
472   my ($self, $importer, %entry) = @_;
473
474   my $leaf = bse_make_article(%entry);
475
476   $importer->event(make_leaf => { leaf => $leaf });
477
478   return $leaf;
479 }
480
481 =item fill_leaf()
482
483 Fill the article some more.
484
485 Currently sets the tags.
486
487 Overridden by the product target to set product options and tiered
488 pricing.
489
490 =cut
491
492 sub fill_leaf {
493   my ($self, $importer, $leaf, %entry) = @_;
494
495   if ($entry{tags}) {
496     my @tags = split '/', $entry{tags};
497     my $error;
498     unless ($leaf->set_tags(\@tags, \$error)) {
499       die "Error setting tags: $error";
500     }
501   }
502
503   return 1;
504 }
505
506 =item _find_parent()
507
508 Find a parent article.
509
510 This method calls itself recursively to work down a tree of parents.
511
512 =cut
513
514 sub _find_parent {
515   my ($self, $importer, $parent, @parents) = @_;
516
517   @parents
518     or return $parent;
519   my $cache = $self->{parent_cache};
520   unless ($cache->{$parent}) {
521     my @kids = $self->children_of($parent);
522     $cache->{$parent} = \@kids;
523   }
524
525   my $title = shift @parents;
526   my ($cat) = grep lc $_->{title} eq lc $title, @{$cache->{$parent}};
527   unless ($cat) {
528     my %opts =
529       (
530        cfg => $importer->cfg,
531        parentid => $parent,
532        title => $title,
533        body => $title,
534       );
535     $self->{catalog_template}
536       and $opts{template} = $self->{catalog_template};
537     $cat = $self->make_parent($importer, %opts);
538     $importer->info("Add parent $cat->{id}: $title");
539     push @{$cache->{$parent}}, $cat;
540   }
541
542   unless ($self->{catseen}{$cat->{id}}) {
543     $self->{catseen}{$cat->{id}} = 1;
544     push @{$self->{parents}}, $cat;
545   }
546
547   return $self->_find_parent($importer, $cat->{id}, @parents);
548 }
549
550 =item default_parent()
551
552 Return the default parent id.
553
554 Overridden by the product target to return the shop id.
555
556 =cut
557
558 sub default_parent { -1 }
559
560 =item default_code_field()
561
562 Return the default code field.
563
564 Overridden by the product target to return the C<product_code> field.
565
566 =cut
567
568 sub default_code_field { "linkAlias" }
569
570 =item leaves()
571
572 Return the leaf articles created or modified by the import run.
573
574 =cut
575
576 sub leaves {
577   return @{$_[0]{leaves}}
578 }
579
580 =item parents()
581
582 Return the parent articles created or used by the import run.
583
584 =cut
585
586 sub parents {
587   return @{$_[0]{parents}}
588 }
589
590 =item key_fields()
591
592 Columns that can act as keys.
593
594 =cut
595
596 sub key_fields {
597   return qw(id linkAlias);
598 }
599
600 =item validate_make_leaf
601
602 Perform validation only needed on creation
603
604 =cut
605
606 sub validate_make_leaf {
607   my ($self, $importer, $entry) = @_;
608
609   if (defined $entry->{linkAlias} && $entry->{linkAlias} ne '') {
610     my $other = BSE::TB::Articles->getBy(linkAlias => $entry->{linkAlias});
611     $other
612       and die "Duplicate linkAlias value with article ", $other->id, "\n";
613   }
614 }
615
616 1;
617
618 =back
619
620 =head1 AUTHOR
621
622 Tony Cook <tony@develop-help.com>
623
624 =cut