1 package SL::Controller::File;
5 use parent qw(SL::Controller::Base);
7 use List::Util qw(first max);
10 use Encode qw(decode);
11 use English qw( -no_match_vars );
16 use File::Slurp qw(slurp);
18 use File::Spec::Win32;
19 use File::MimeInfo::Magic;
20 use SL::DB::Helper::Mappings;
22 use SL::DB::DeliveryOrder;
25 use SL::DB::PurchaseInvoice;
27 use SL::DB::GLTransaction;
31 use SL::Helper::CreatePDF qw(:all);
32 use SL::Locale::String;
34 use SL::SessionFile::Random;
36 use SL::Controller::Helper::ThumbnailCreator qw(file_probe_image_type file_probe_type);
38 use constant DO_DELETE => 0;
39 use constant DO_UNIMPORT => 1;
41 use Rose::Object::MakeMethods::Generic
43 'scalar --get_set_init' => [ qw() ],
44 'scalar' => [ qw(object object_type object_model object_id object_right file_type files is_global existing) ],
47 __PACKAGE__->run_before('check_object_params', only => [ qw(list ajax_delete ajax_importdialog ajax_import ajax_unimport ajax_upload ajax_files_uploaded) ]);
49 # gen: bitmask: bit 1 (value is 1, 3, 5 or 7) => file created
50 # bit 2 (value is 2, 3, 6 or 7) => file from other source (e.g. directory for scanned documents)
51 # bit 3 (value is 4, 5, 6 or 7) => upload as other source
52 # gltype: is this used somewhere?
53 # dir: is this used somewhere?
54 # model: base name of the rose model
55 # right: access right used for import
57 'sales_quotation' => { gen => 1, gltype => '', dir =>'SalesQuotation', model => 'Order', right => 'import_ar' },
58 'sales_order' => { gen => 5, gltype => '', dir =>'SalesOrder', model => 'Order', right => 'import_ar' },
59 'sales_delivery_order' => { gen => 1, gltype => '', dir =>'SalesDeliveryOrder', model => 'DeliveryOrder', right => 'import_ar' },
60 'invoice' => { gen => 1, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' },
61 'credit_note' => { gen => 1, gltype => '', dir =>'CreditNote', model => 'Invoice', right => 'import_ar' },
62 'request_quotation' => { gen => 7, gltype => '', dir =>'RequestForQuotation', model => 'Order', right => 'import_ap' },
63 'purchase_order' => { gen => 7, gltype => '', dir =>'PurchaseOrder', model => 'Order', right => 'import_ap' },
64 'purchase_delivery_order' => { gen => 7, gltype => '', dir =>'PurchaseDeliveryOrder',model => 'DeliveryOrder', right => 'import_ap' },
65 'purchase_invoice' => { gen => 6, gltype => 'ap', dir =>'PurchaseInvoice', model => 'PurchaseInvoice',right => 'import_ap' },
66 'vendor' => { gen => 0, gltype => '', dir =>'Vendor', model => 'Vendor', right => 'xx' },
67 'customer' => { gen => 1, gltype => '', dir =>'Customer', model => 'Customer', right => 'xx' },
68 'part' => { gen => 0, gltype => '', dir =>'Part', model => 'Part', right => 'xx' },
69 'gl_transaction' => { gen => 6, gltype => 'gl', dir =>'GeneralLedger', model => 'GLTransaction', right => 'import_ap' },
70 'draft' => { gen => 0, gltype => '', dir =>'Draft', model => 'Draft', right => 'xx' },
71 'csv_customer' => { gen => 1, gltype => '', dir =>'Reports', model => 'Customer', right => 'xx' },
72 'csv_vendor' => { gen => 1, gltype => '', dir =>'Reports', model => 'Vendor', right => 'xx' },
73 'shop_image' => { gen => 0, gltype => '', dir =>'ShopImages', model => 'Part', right => 'xx' },
74 'letter' => { gen => 7, gltype => '', dir =>'Letter', model => 'Letter', right => 'sales_letter_edit | purchase_letter_edit' },
78 # $main::locale->text('imported')
88 $is_json = 1 if $::form->{json};
90 $self->_do_list($is_json);
93 sub action_ajax_importdialog {
95 $::auth->assert($self->object_right);
96 my $path = $::form->{path};
97 my @files = $self->_get_from_import($path);
99 'name' => $::form->{source},
101 'chk_action' => $::form->{source}.'_import',
102 'chk_title' => $main::locale->text('Import scanned documents'),
103 'chkall_title' => $main::locale->text('Import all'),
106 $self->render('file/import_dialog',
113 sub action_ajax_import {
115 $::auth->assert($self->object_right);
116 my $ids = $::form->{ids};
117 my $source = $::form->{source};
118 my $path = $::form->{path};
119 my @files = $self->_get_from_import($path);
120 foreach my $filename (@{ $::form->{$ids} || [] }) {
121 my ($file, undef) = grep { $_->{name} eq $filename } @files;
123 my $obj = SL::File->save(object_id => $self->object_id,
124 object_type => $self->object_type,
125 mime_type => 'application/pdf',
127 file_type => 'document',
128 file_name => $file->{filename},
129 file_path => $file->{path}
131 unlink($file->{path}) if $obj;
137 sub action_ajax_delete {
139 $self->_delete_all(DO_DELETE, $::locale->text('Following files are deleted:'));
142 sub action_ajax_unimport {
144 $self->_delete_all(DO_UNIMPORT, $::locale->text('Following files are unimported:'));
147 sub action_ajax_rename {
149 my ($id, $version) = split /_/, $::form->{id};
150 my $file = SL::File->get(id => $id);
152 $self->js->flash('error', $::locale->text('File not exists !'))->render();
155 my $sessionfile = $::form->{sessionfile};
156 if ( $sessionfile && -f $sessionfile ) {
158 if ( $::form->{to} eq $file->file_name ) {
159 # no rename so use as new version
160 $file->save_file($sessionfile);
161 $self->js->flash('warning', $::locale->text('File \'#1\' is used as new Version !', $file->file_name));
164 # new filename, so it is a new file with the same attributes as the old file
166 SL::File->save(object_id => $file->object_id,
167 object_type => $file->object_type,
168 mime_type => $file->mime_type,
169 source => $file->source,
170 file_type => $file->file_type,
171 file_name => $::form->{to},
172 file_path => $sessionfile
174 unlink($sessionfile);
177 $self->js->flash( 'error', t8('internal error (see details)'))
178 ->flash_detail('error', $@)->render;
188 $result = $file->rename($::form->{to});
191 $self->js->flash( 'error', t8('internal error (see details)'))
192 ->flash_detail('error', $@)->render;
196 if ($result != SL::File::RENAME_OK) {
197 $self->js->flash('error',
198 $result == SL::File::RENAME_EXISTS ? $::locale->text('File still exists !')
199 : $result == SL::File::RENAME_SAME ? $::locale->text('Same Filename !')
200 : $::locale->text('File not exists !'))
205 $self->is_global($::form->{is_global});
206 $self->file_type( $file->file_type);
207 $self->object_type($file->object_type);
208 $self->object_id( $file->object_id);
209 #$self->object_model($file_types{$file->module}->{model});
210 #$self->object_right($file_types{$file->module}->{right});
211 if ( $::form->{next_ids} ) {
212 my @existing = split(/,/, $::form->{next_ids});
213 $self->existing(\@existing);
218 sub action_ajax_upload {
220 $self->{maxsize} = $::instance_conf->get_doc_max_filesize;
221 $self->{accept_types} = '';
222 $self->{accept_types} = 'image/png,image/gif,image/jpeg,image/tiff,*png,*gif,*.jpg,*.tif' if $self->{file_type} eq 'image';
223 $self->render('file/upload_dialog',
229 sub action_ajax_files_uploaded {
232 my $source = 'uploaded';
234 if ( $::form->{ATTACHMENTS}->{uploadfiles} ) {
235 my @upfiles = @{ $::form->{ATTACHMENTS}->{uploadfiles} };
236 foreach my $idx (0 .. scalar(@upfiles) - 1) {
238 my $fname = uri_unescape($upfiles[$idx]->{filename});
239 # normalize and find basename
240 # first split with unix rules
241 # after that split with windows rules
242 my ($volume, $directories, $basefile) = File::Spec::Unix->splitpath($fname);
243 ($volume, $directories, $basefile) = File::Spec::Win32->splitpath($basefile);
245 # to find real mime_type by magic we must save the filedata
247 my $sess_fname = "file_upload_" . $self->object_type . "_" . $self->object_id . "_" . $idx;
248 my $sfile = SL::SessionFile->new($sess_fname, mode => 'w');
250 $sfile->fh->print(${$upfiles[$idx]->{data}});
252 my $mime_type = File::MimeInfo::Magic::magic($sfile->file_name);
255 # if filename has the suffix "pdf", but isn't really a pdf, set mimetype for no suffix
256 $mime_type = File::MimeInfo::Magic::mimetype($basefile);
257 $mime_type = 'application/octet-stream' if $mime_type eq 'application/pdf' || !$mime_type;
259 if ( $self->file_type eq 'image' && $self->file_probe_image_type($mime_type, $basefile)) {
262 my ($existobj) = SL::File->get_all(object_id => $self->object_id,
263 object_type => $self->object_type,
264 mime_type => $mime_type,
266 file_type => $self->file_type,
267 file_name => $basefile,
271 push @existing, $existobj->id.'_'.$sfile->file_name;
273 my $fileobj = SL::File->save(object_id => $self->object_id,
274 object_type => $self->object_type,
275 mime_type => $mime_type,
277 file_type => $self->file_type,
278 file_name => $basefile,
279 title => $::form->{title},
280 description => $::form->{description},
281 ## two possibilities: what is better ? content or sessionfile ??
282 file_contents => ${$upfiles[$idx]->{data}},
283 file_path => $sfile->file_name
285 unlink($sfile->file_name);
289 $self->js->flash( 'error', t8('internal error (see details)'))
290 ->flash_detail('error', $@)->render;
295 $self->existing(\@existing);
299 sub action_download {
301 my ($id, $version) = split /_/, $::form->{id};
302 my $file = SL::File->get(id => $id );
303 $file->version($version) if $version;
304 my $ref = $file->get_content;
305 if ( $file && $ref ) {
306 return $self->send_file($ref,
307 type => $file->mime_type,
308 name => $file->file_name,
317 sub check_object_params {
320 my $id = ($::form->{object_id} // 0) * 1;
321 my $draftid = ($::form->{draft_id} // 0) * 1;
325 if ( $draftid == 0 && $id == 0 && $::form->{is_global} ) {
327 $type = $::form->{object_type};
330 $id = $::form->{draft_id};
332 } elsif ( $::form->{object_type} ) {
333 $type = $::form->{object_type};
335 die "No object type" unless $type;
336 die "No file type" unless $::form->{file_type};
337 die "Unknown object type" unless $file_types{$type};
339 $self->is_global($gldoc);
340 $self->file_type($::form->{file_type});
341 $self->object_type($type);
342 $self->object_id($id);
343 $self->object_model($file_types{$type}->{model});
344 $self->object_right($file_types{$type}->{right});
346 # $::auth->assert($self->object_right);
348 # my $model = 'SL::DB::' . $self->object_model;
349 # $self->object($model->new(id => $self->object_id)->load || die "Record not found");
359 my ($self, $do_unimport, $infotext) = @_;
361 my $ids = $::form->{ids};
362 foreach my $id_version (@{ $::form->{$ids} || [] }) {
363 my ($id, $version) = split /_/, $id_version;
364 my $dbfile = SL::File->get(id => $id);
367 $dbfile->version($version);
368 $files .= ' ' . $dbfile->file_name if $dbfile->delete_version;
370 $files .= ' ' . $dbfile->file_name if $dbfile->delete;
374 $self->js->flash('info', $infotext . $files) if $files;
379 my ($self, $json) = @_;
381 if ( $self->file_type eq 'document' ) {
383 push @object_types, $self->object_type;
384 push @object_types, qw(dunning dunning1 dunning2 dunning3) if $self->object_type eq 'invoice'; # hardcoded object types?
385 @files = SL::File->get_all_versions(object_id => $self->object_id,
386 object_type => \@object_types,
387 file_type => $self->file_type,
391 elsif ( $self->file_type eq 'attachment' || $self->file_type eq 'image' ) {
392 @files = SL::File->get_all(object_id => $self->object_id,
393 object_type => $self->object_type,
394 file_type => $self->file_type,
397 $self->files(\@files);
399 $_->{thumbnail} = _create_thumbnail($_) for @files;
401 if($self->object_type eq 'shop_image'){
403 ->run('kivi.ShopPart.show_images', $self->object_id)
406 $self->_mk_render('file/list', 1, 0, $json);
410 sub _get_from_import {
411 my ($self, $path) = @_;
414 my $language = $::lx_office_conf{system}->{language};
415 my $timezone = $::locale->get_local_time_zone()->name;
416 if (opendir my $dir, $path) {
417 my @files = (readdir $dir);
418 foreach my $file ( @files) {
419 next if (($file eq '.') || ($file eq '..'));
420 $file = Encode::decode('utf-8', $file);
422 next if ( -d "$path/$file" );
424 my $tmppath = File::Spec->catfile( $path, $file );
425 next if( ! -f $tmppath );
427 my $st = stat($tmppath);
428 my $dt = DateTime->from_epoch( epoch => $st->mtime, time_zone => $timezone, locale => $language );
429 my $sname = $main::locale->quote_special_chars('HTML', $file);
432 'filename' => $sname,
434 'mtime' => $st->mtime,
435 'date' => $dt->dmy('.') . " " . $dt->hms,
442 $::lxdebug->message(LXDebug::WARN(), "SL::File::_get_from_import opendir failed to open dir " . $path);
449 my ($self, $template, $edit, $scanner, $json) = @_;
452 ##TODO make code configurable
455 my @sources = $self->_get_sources();
456 foreach my $source ( @sources ) {
457 @{$source->{files}} = grep { $_->source eq $source->{name}} @{ $self->files };
459 if ( $self->file_type eq 'document' ) {
460 $title = $main::locale->text('Documents');
461 } elsif ( $self->file_type eq 'attachment' ) {
462 $title = $main::locale->text('Attachments');
463 } elsif ( $self->file_type eq 'image' ) {
464 $title = $main::locale->text('Images');
467 my $output = SL::Presenter->get->render(
470 SOURCES => \@sources,
471 edit_attachments => $edit,
472 object_type => $self->object_type,
473 object_id => $self->object_id,
474 file_type => $self->file_type,
475 is_global => $self->is_global,
479 $self->js->html('#'.$self->file_type.'_list_'.$self->object_type, $output);
480 if ( $self->existing && scalar(@{$self->existing}) > 0) {
481 my $first = shift @{$self->existing};
482 my ($first_id, $sfile) = split('_', $first, 2);
483 my $file = SL::File->get(id => $first_id );
484 $self->js->run('kivi.File.askForRename', $first_id, $file->file_type, $file->file_name, $sfile, join (',', @{$self->existing}), $self->is_global);
488 $self->render(\$output, { layout => 0, process => 0 });
493 $self->js->flash( 'error', t8('internal error (see details)'))
494 ->flash_detail('error', $@)->render;
496 $self->render('generic/error', { layout => 0 }, label_error => $@);
505 if ( $self->file_type eq 'document' ) {
506 # TODO statt gen neue attribute in filetypes :
507 if (($file_types{$self->object_type}->{gen}*1 & 4)==4) {
508 # bit 3 is set => means upload
510 'name' => 'uploaded',
511 'title' => $main::locale->text('uploaded Documents'),
512 'chk_action' => 'uploaded_documents_delete',
513 'chk_title' => $main::locale->text('Delete Documents'),
514 'chkall_title' => $main::locale->text('Delete all'),
515 'file_title' => $main::locale->text('filename'),
516 'confirm_text' => $main::locale->text('delete'),
518 'are_existing' => $self->existing ? 1 : 0,
519 'rename_title' => $main::locale->text('Rename Attachments'),
522 'upload_title' => $main::locale->text('Upload Documents'),
523 'done_text' => $main::locale->text('deleted')
525 push @sources , $source;
528 if (($file_types{$self->object_type}->{gen}*1 & 1)==1) {
531 'title' => $main::locale->text('generated Files'),
532 'chk_action' => 'documents_delete',
533 'chk_title' => $main::locale->text('Delete Documents'),
534 'chkall_title' => $main::locale->text('Delete all'),
535 'file_title' => $main::locale->text('filename'),
536 'confirm_text' => $main::locale->text('delete'),
537 'can_delete' => $::instance_conf->get_doc_delete_printfiles,
538 'can_rename' => $::instance_conf->get_doc_delete_printfiles,
539 'rename_title' => $main::locale->text('Rename Documents'),
540 'done_text' => $main::locale->text('deleted')
542 push @sources , $gendata;
545 if (($file_types{$self->object_type}->{gen}*1 & 2)==2) {
546 my @others = SL::File->get_other_sources();
547 foreach my $scanner_or_mailrx (@others) {
549 'name' => $scanner_or_mailrx->{name},
550 'title' => $main::locale->text('from \'#1\' imported Files', $scanner_or_mailrx->{description}),
551 'chk_action' => $scanner_or_mailrx->{name}.'_unimport',
552 'chk_title' => $main::locale->text('Unimport documents'),
553 'chkall_title' => $main::locale->text('Unimport all'),
554 'file_title' => $main::locale->text('filename'),
555 'confirm_text' => $main::locale->text('unimport'),
557 'rename_title' => $main::locale->text('Rename Documents'),
560 'import_title' => $main::locale->text('Add Document from \'#1\'', $scanner_or_mailrx->{name}),
561 'path' => $scanner_or_mailrx->{directory},
562 'done_text' => $main::locale->text('unimported')
564 push @sources , $other;
568 elsif ( $self->file_type eq 'attachment' ) {
570 'name' => 'uploaded',
571 'title' => $main::locale->text(''),
572 'chk_action' => 'attachments_delete',
573 'chk_title' => $main::locale->text('Delete Attachments'),
574 'chkall_title' => $main::locale->text('Delete all'),
575 'file_title' => $main::locale->text('filename'),
576 'confirm_text' => $main::locale->text('delete'),
578 'are_existing' => $self->existing ? 1 : 0,
579 'rename_title' => $main::locale->text('Rename Attachments'),
582 'upload_title' => $main::locale->text('Upload Attachments'),
583 'done_text' => $main::locale->text('deleted')
585 push @sources , $attdata;
587 elsif ( $self->file_type eq 'image' ) {
589 'name' => 'uploaded',
590 'title' => $main::locale->text(''),
591 'chk_action' => 'images_delete',
592 'chk_title' => $main::locale->text('Delete Images'),
593 'chkall_title' => $main::locale->text('Delete all'),
594 'file_title' => $main::locale->text('filename'),
595 'confirm_text' => $main::locale->text('delete'),
597 'are_existing' => $self->existing ? 1 : 0,
598 'rename_title' => $main::locale->text('Rename Images'),
601 'upload_title' => $main::locale->text('Upload Images'),
602 'done_text' => $main::locale->text('deleted')
604 push @sources , $attdata;
610 # todo: cache thumbs?
611 sub _create_thumbnail {
615 if (!eval { $filename = $file->get_file(); 1; }) {
616 $::lxdebug->message(LXDebug::WARN(), "SL::File::_create_thumbnail get_file failed: " . $EVAL_ERROR);
620 # Workaround for pfds which are not handled by file_probe_type.
621 # Maybe use mime info stored in db?
622 my $mime_type = File::MimeInfo::Magic::magic($filename);
623 if ($mime_type =~ m{pdf}) {
624 $filename = _convert_pdf_to_png($filename);
626 return if !$filename;
629 if (!eval { $content = slurp $filename; 1; }) {
630 $::lxdebug->message(LXDebug::WARN(), "SL::File::_create_thumbnail slurp failed: " . $EVAL_ERROR);
635 if (!eval { $ret = file_probe_type($content); 1; }) {
636 $::lxdebug->message(LXDebug::WARN(), "SL::File::_create_thumbnail file_probe_type failed: " . $EVAL_ERROR);
640 # file_probe_type returns a hash ref with thumbnail info and content
641 # or an error message
642 if ('HASH' ne ref $ret) {
643 $::lxdebug->message(LXDebug::WARN(), "SL::File::_create_thumbnail file_probe_type returned an error: " . $ret);
650 sub _convert_pdf_to_png {
653 my $sfile = SL::SessionFile::Random->new();
655 my $command = 'pdftoppm -singlefile -scale-to 64 -png' . ' ' . $filename . ' ' . $sfile->file_name;
657 if (system($command) == -1) {
658 $::lxdebug->message(LXDebug::WARN(), "SL::File::_convert_pdf_to_png: system call failed: " . $ERRNO);
662 $::lxdebug->message(LXDebug::WARN(), "SL::File::_convert_pdf_to_png: pdftoppm failed with error code: " . ($CHILD_ERROR >> 8));
666 return $sfile->file_name . '.png';
679 SL::Controller::File - Controller for managing files
683 The Controller is called directly from the webpages
685 <a href="controller.pl?action=File/list&file_type=document\
686 &object_type=[% HTML.escape(type) %]&object_id=[% HTML.url(id) %]">
689 or indirectly via javascript functions from js/kivi.File.js
691 kivi.popup_dialog({ url: 'controller.pl',
692 data: { action : 'File/ajax_upload',
693 file_type : 'uploaded',
701 This is a controller for handling files in a storage independent way.
702 The storage may be a Filesystem,a WebDAV, a Database or DMS.
703 These backends must be configered in ClientConfig.
704 This Controller use as intermediate layer for storage C<SL::File>.
706 The Controller is responsible to display forms for displaying the files at the ERP-objects and
707 for uploading and downloading the files.
709 More description of the intermediate layer see L<SL::File>.
713 =head2 C<action_list>
715 This loads a list of files on a webpage. This can be done with a normal submit or via an ajax/json call.
716 Dependent of file_type different sources are available.
718 For documents there are the 'created' source and the imports from scanners or email.
719 For attachments and images only the 'uploaded' source available.
721 Available C<FORM PARAMS>:
725 =item C<form.object_id>
727 The Id of the ERP-object.
729 =item C<form.object_type>
731 The Type of the ERP-object like "sales_quotation". A clear mapping to the class/model exists in the controller.
733 =item C<form.file_type>
735 For one ERP-object may exists different type of documents the type may be "documents","attachments" or "images".
736 This file_type is a filter for the list.
740 The method can be used as normal HTTP-Request (json=0) or as AJAX-JSON call to refresh the list if the parameter is set to 1.
745 =head2 C<action_ajax_upload>
748 A new file or more files can selected by a dialog and insert into the system.
751 Available C<FORM PARAMS>:
755 =item C<form.file_type>
757 This parameter describe here the source for a new file :
758 "attachments" and "images"
760 This is a normal upload selection, which may be more then one file to upload.
762 =item C<form.object_id>
766 =item C<form.object_type>
768 are the same as at C<action_list>
772 =head2 C<action_ajax_files_uploaded>
774 The Upload of selected Files. The "multipart_formdata" is parsed in SL::Request into the formsvariable "form.ATTACHMENTS".
775 The filepaths are checked about Unix and Windows paths. Also the MIME type of the files are verified ( IS the contents of a *.pdf real PDF?).
776 If the same filename still exists at this object after the download for each existing filename a rename dialog will be opened.
778 If the filename is not changed the new uploaded file is a new version of the file, if the name is changed it is a new file.
780 Available C<FORM PARAMS>:
784 =item C<form.ATTACHMENTS.uploadfiles>
786 This is an array of elements which have {filename} for the name and {data} for the contents.
788 Also object_id, object_type and file_type
792 =head2 C<action_download>
794 This is the real download of a file normally called via javascript "$.download("controller.pl", data);"
796 Available C<FORM PARAMS>:
800 Also object_id, object_type and file_type
804 =head2 C<action_ajax_importdialog>
806 A Dialog with all available and not imported files to import is open.
807 More then one file can be selected.
809 Available C<FORM PARAMS>:
815 The name of the source like "scanner1" or "email"
819 The full path to the directory on the server, where the files to import can found
821 Also object_id, object_type and file_type
825 =head2 C<action_ajax_delete>
827 Some files can be deleted
829 Available C<FORM PARAMS>:
835 The ids of the files to delete. Only this files are deleted not all versions of a file if the exists
839 =head2 C<action_ajax_unimport>
841 Some files can be unimported, dependent of the source of the file. This means they are moved
842 back to the directory of the source
844 Available C<FORM PARAMS>:
850 The ids of the files to unimport. Only these files are unimported not all versions of a file if the exists
854 =head2 C<action_ajax_rename>
856 One file can be renamed. There can be some checks if the same filename still exists at one object.
860 Martin Helmling E<lt>martin.helmling@opendynamic.deE<gt>