diff --git a/autoload/Domain/Tasks/MailToTaskImporter.php b/autoload/Domain/Tasks/MailToTaskImporter.php new file mode 100644 index 0000000..1478eb0 --- /dev/null +++ b/autoload/Domain/Tasks/MailToTaskImporter.php @@ -0,0 +1,651 @@ + mdb = $mdb; + else + { + global $mdb; + $this -> mdb = $mdb; + } + + $this -> attachments = new TaskAttachmentRepository( $this -> mdb ); + } + + public function importFromImap( array $config ) + { + if ( !extension_loaded( 'imap' ) ) + return [ 'status' => 'error', 'msg' => 'Brak rozszerzenia IMAP w PHP.' ]; + + $mailbox = $this -> buildMailbox( $config ); + $username = isset( $config['username'] ) ? (string)$config['username'] : ''; + $password = isset( $config['password'] ) ? (string)$config['password'] : ''; + + if ( $mailbox === '' or $username === '' or $password === '' ) + return [ 'status' => 'error', 'msg' => 'Brak konfiguracji skrzynki IMAP.' ]; + + $imap = @imap_open( $mailbox, $username, $password ); + if ( !$imap ) + return [ 'status' => 'error', 'msg' => 'Nie udalo sie polaczyc z IMAP: ' . imap_last_error() ]; + + $this -> ensureImportTable(); + + $message_numbers = imap_search( $imap, 'ALL' ); + if ( !is_array( $message_numbers ) or !count( $message_numbers ) ) + { + imap_close( $imap ); + return [ 'status' => 'empty', 'msg' => 'Brak wiadomosci do importu.' ]; + } + + sort( $message_numbers, SORT_NUMERIC ); + + $imported = 0; + $skipped = 0; + $errors = 0; + $first_error_msg = null; + + foreach ( $message_numbers as $message_no ) + { + $header = @imap_headerinfo( $imap, $message_no ); + if ( !$header ) + { + $errors++; + continue; + } + + $sender = $this -> extractSender( $header ); + $subject = $this -> decodeHeaderValue( isset( $header -> subject ) ? $header -> subject : '' ); + $received_at = $this -> parseReceivedDate( isset( $header -> date ) ? $header -> date : '' ); + $message_key = $this -> messageKey( $header, $sender, $subject, $message_no ); + + if ( $this -> isMessageFinalized( $message_key ) ) + { + $skipped++; + @imap_delete( $imap, $message_no ); + continue; + } + + if ( $sender !== self::ALLOWED_SENDER ) + { + $this -> saveImportLog( $message_key, null, $sender, $subject, 'skipped_sender', null ); + $skipped++; + @imap_delete( $imap, $message_no ); + continue; + } + + try + { + $content = $this -> extractMessageContent( $imap, $message_no ); + $task_name = trim( $subject ) !== '' ? trim( $subject ) : '(bez tematu)'; + $task_text = $this -> prepareImportedTaskText( $content['text'] ); + $client_id = $this -> resolveClientIdBySenderDomain( $sender ); + + $task_id = \factory\Tasks::task_save( + null, + null, + self::TASK_USER_ID, + $task_name, + $task_text, + $received_at, + $received_at, + self::TASK_PROJECT_ID, + $client_id, + null, + null, + 'off', + 0, + 0, + [ self::TASK_USER_ID ], + null, + null, + false, + 'off', + false, + self::TASK_STATUS_ID, + 'on', + 0 + ); + + if ( !$task_id ) + { + $db_error = $this -> mdb -> error(); + $db_error_text = is_array( $db_error ) ? implode( ' | ', array_filter( $db_error ) ) : ''; + $error_msg = 'Nie udalo sie utworzyc zadania.' . ( $db_error_text !== '' ? ' DB: ' . $db_error_text : '' ); + $this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $error_msg ); + if ( $first_error_msg === null ) + $first_error_msg = $error_msg; + $errors++; + continue; + } + + foreach ( $content['attachments'] as $attachment ) + { + $this -> attachments -> uploadFromContent( $task_id, self::TASK_USER_ID, $attachment['name'], $attachment['content'] ); + } + + $this -> saveImportLog( $message_key, $task_id, $sender, $subject, 'imported', null ); + $imported++; + } + catch ( \Throwable $e ) + { + $this -> saveImportLog( $message_key, null, $sender, $subject, 'error', $e -> getMessage() ); + if ( $first_error_msg === null ) + $first_error_msg = $e -> getMessage(); + $errors++; + } + + $status_tmp = $this -> getImportStatus( $message_key ); + if ( in_array( $status_tmp, [ 'imported', 'skipped_sender' ], true ) ) + @imap_delete( $imap, $message_no ); + } + + @imap_expunge( $imap ); + @imap_close( $imap ); + + if ( $imported > 0 ) + return [ 'status' => 'ok', 'msg' => 'Zaimportowano: ' . $imported . ', pominieto: ' . $skipped . ', bledy: ' . $errors . '.' ]; + + if ( $errors > 0 ) + { + $msg = 'Brak nowych zadan. Bledy: ' . $errors . '.'; + if ( $first_error_msg ) + $msg .= ' Ostatni blad: ' . $first_error_msg; + return [ 'status' => 'error', 'msg' => $msg ]; + } + + return [ 'status' => 'empty', 'msg' => 'Brak nowych zadan. Pominieto: ' . $skipped . '.' ]; + } + + private function buildMailbox( array $config ) + { + $mailbox = isset( $config['mailbox'] ) ? trim( (string)$config['mailbox'] ) : ''; + if ( $mailbox !== '' ) + return $mailbox; + + $host = isset( $config['host'] ) ? trim( (string)$config['host'] ) : ''; + if ( $host === '' ) + return ''; + + $port = isset( $config['port'] ) ? (int)$config['port'] : 993; + $flags = isset( $config['flags'] ) ? trim( (string)$config['flags'] ) : '/imap/ssl'; + $folder = isset( $config['folder'] ) ? trim( (string)$config['folder'] ) : 'INBOX'; + + return '{' . $host . ':' . $port . $flags . '}' . $folder; + } + + private function resolveClientIdBySenderDomain( $sender ) + { + $domain = $this -> extractDomainFromEmail( $sender ); + if ( $domain === '' ) + return null; + + $rows = $this -> mdb -> select( 'crm_client', [ 'id', 'emails' ], [ + 'emails[!]' => null + ] ); + + if ( !is_array( $rows ) or !count( $rows ) ) + return null; + + foreach ( $rows as $row ) + { + $emails = $this -> parseEmailsField( isset( $row['emails'] ) ? $row['emails'] : '' ); + foreach ( $emails as $email ) + { + $email_domain = $this -> extractDomainFromEmail( $email ); + if ( $email_domain !== '' and $email_domain === $domain ) + return (int)$row['id']; + } + } + + return null; + } + + private function parseEmailsField( $emails_raw ) + { + $emails_raw = (string)$emails_raw; + if ( trim( $emails_raw ) === '' ) + return []; + + preg_match_all( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', $emails_raw, $matches ); + if ( isset( $matches[0] ) and is_array( $matches[0] ) and count( $matches[0] ) ) + return array_values( array_unique( array_map( 'strtolower', $matches[0] ) ) ); + + return []; + } + + private function extractDomainFromEmail( $email ) + { + $email = strtolower( trim( (string)$email ) ); + if ( $email === '' or strpos( $email, '@' ) === false ) + return ''; + + $parts = explode( '@', $email ); + $domain = trim( end( $parts ) ); + return $domain !== '' ? $domain : ''; + } + + private function parseReceivedDate( $value ) + { + $timestamp = strtotime( (string)$value ); + if ( $timestamp === false ) + return date( 'Y-m-d' ); + + return date( 'Y-m-d', $timestamp ); + } + + private function extractSender( $header ) + { + if ( !isset( $header -> from ) or !is_array( $header -> from ) or !isset( $header -> from[0] ) ) + return ''; + + $item = $header -> from[0]; + $mailbox = isset( $item -> mailbox ) ? strtolower( trim( $item -> mailbox ) ) : ''; + $host = isset( $item -> host ) ? strtolower( trim( $item -> host ) ) : ''; + + if ( $mailbox === '' or $host === '' ) + return ''; + + return $mailbox . '@' . $host; + } + + private function decodeHeaderValue( $value ) + { + $value = (string)$value; + if ( $value === '' ) + return ''; + + $chunks = @imap_mime_header_decode( $value ); + if ( !is_array( $chunks ) or !count( $chunks ) ) + return trim( $value ); + + $decoded = ''; + foreach ( $chunks as $chunk ) + $decoded .= isset( $chunk -> text ) ? $chunk -> text : ''; + + return trim( $decoded ); + } + + private function messageKey( $header, $sender, $subject, $message_no ) + { + $message_id = ''; + if ( isset( $header -> message_id ) and trim( (string)$header -> message_id ) !== '' ) + $message_id = trim( (string)$header -> message_id ); + + if ( $message_id === '' ) + $message_id = sha1( $sender . '|' . $subject . '|' . ( isset( $header -> date ) ? $header -> date : '' ) . '|' . $message_no ); + + return $message_id; + } + + private function isMessageFinalized( $message_key ) + { + $status = $this -> getImportStatus( $message_key ); + return in_array( $status, [ 'imported', 'skipped_sender' ], true ); + } + + private function getImportStatus( $message_key ) + { + return $this -> mdb -> get( 'tasks_mail_import', 'status', [ 'message_key' => (string)$message_key ] ); + } + + private function saveImportLog( $message_key, $task_id, $sender, $subject, $status, $error ) + { + $data = [ + 'task_id' => $task_id ? (int)$task_id : null, + 'sender' => substr( (string)$sender, 0, 255 ), + 'subject' => substr( (string)$subject, 0, 255 ), + 'status' => substr( (string)$status, 0, 40 ), + 'error_msg' => $error !== null ? (string)$error : null, + 'date_add' => date( 'Y-m-d H:i:s' ) + ]; + + if ( $this -> mdb -> count( 'tasks_mail_import', [ 'message_key' => (string)$message_key ] ) ) + $this -> mdb -> update( 'tasks_mail_import', $data, [ 'message_key' => (string)$message_key ] ); + else + $this -> mdb -> insert( 'tasks_mail_import', array_merge( [ 'message_key' => (string)$message_key ], $data ) ); + } + + private function ensureImportTable() + { + $this -> mdb -> query( + 'CREATE TABLE IF NOT EXISTS `tasks_mail_import` ( + `id` INT NOT NULL AUTO_INCREMENT, + `message_key` VARCHAR(255) NOT NULL, + `task_id` INT NULL, + `sender` VARCHAR(255) NULL, + `subject` VARCHAR(255) NULL, + `status` VARCHAR(40) NOT NULL, + `error_msg` TEXT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tasks_mail_import_message_key` (`message_key`), + INDEX `idx_tasks_mail_import_task` (`task_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8' + ); + } + + private function extractMessageContent( $imap, $message_no ) + { + $structure = @imap_fetchstructure( $imap, $message_no ); + + $parts = []; + $this -> flattenParts( $imap, $message_no, $structure, '', $parts ); + + $plain = ''; + $html = ''; + $attachment_candidates = []; + + foreach ( $parts as $part ) + { + if ( $part['mime'] === 'text/plain' and $plain === '' ) + $plain = $part['body']; + + if ( $part['mime'] === 'text/html' and $html === '' ) + $html = $part['body']; + + if ( $part['is_attachment'] ) + $attachment_candidates[] = $part; + } + + $cid_refs = $this -> extractReferencedCidValues( $html ); + $attachments = []; + foreach ( $attachment_candidates as $part ) + { + if ( !$this -> shouldImportAttachment( $part, $cid_refs ) ) + continue; + + $attachments[] = [ + 'name' => $part['name'], + 'content' => $part['body'] + ]; + } + + $text = $plain !== '' ? $plain : $this -> htmlToText( $html ); + $text = $this -> cleanBodyText( $text ); + + return [ + 'text' => $text, + 'attachments' => $attachments + ]; + } + + private function flattenParts( $imap, $message_no, $structure, $prefix, array &$parts ) + { + if ( !$structure ) + { + $parts[] = [ + 'mime' => 'text/plain', + 'body' => '', + 'name' => '', + 'is_attachment' => false, + 'is_inline' => false + ]; + return; + } + + if ( !isset( $structure -> parts ) or !is_array( $structure -> parts ) or !count( $structure -> parts ) ) + { + $part_number = $prefix !== '' ? $prefix : null; + $parts[] = $this -> parseSinglePart( $imap, $message_no, $structure, $part_number ); + return; + } + + foreach ( $structure -> parts as $index => $subpart ) + { + $part_number = $prefix === '' ? (string)( $index + 1 ) : $prefix . '.' . ( $index + 1 ); + if ( isset( $subpart -> parts ) and is_array( $subpart -> parts ) and count( $subpart -> parts ) ) + $this -> flattenParts( $imap, $message_no, $subpart, $part_number, $parts ); + else + $parts[] = $this -> parseSinglePart( $imap, $message_no, $subpart, $part_number ); + } + } + + private function parseSinglePart( $imap, $message_no, $part, $part_number ) + { + $raw = ''; + if ( $part_number === null ) + $raw = (string)@imap_body( $imap, $message_no, FT_PEEK ); + else + $raw = (string)@imap_fetchbody( $imap, $message_no, $part_number, FT_PEEK ); + + $mime = $this -> mimeType( $part ); + $decoded = $this -> decodePartBody( $raw, isset( $part -> encoding ) ? (int)$part -> encoding : 0 ); + if ( strpos( $mime, 'text/' ) === 0 ) + $decoded = str_replace( [ "\r\n", "\r" ], "\n", (string)$decoded ); + + $params = $this -> partParams( $part ); + + $name = ''; + if ( isset( $params['filename'] ) ) + $name = $params['filename']; + elseif ( isset( $params['name'] ) ) + $name = $params['name']; + + $name = $this -> decodeHeaderValue( $name ); + + $disposition = isset( $part -> disposition ) ? strtoupper( trim( (string)$part -> disposition ) ) : ''; + $content_id = ''; + if ( isset( $part -> id ) and trim( (string)$part -> id ) !== '' ) + $content_id = $this -> normalizeContentId( (string)$part -> id ); + elseif ( isset( $params['content-id'] ) and trim( (string)$params['content-id'] ) !== '' ) + $content_id = $this -> normalizeContentId( (string)$params['content-id'] ); + + $is_inline = $disposition === 'INLINE' or $content_id !== ''; + $is_attachment = trim( $name ) !== '' or $disposition === 'ATTACHMENT'; + + return [ + 'mime' => $mime, + 'body' => $decoded, + 'name' => $name, + 'is_attachment' => $is_attachment, + 'is_inline' => $is_inline, + 'disposition' => $disposition, + 'content_id' => $content_id + ]; + } + + private function partParams( $part ) + { + $params = []; + + if ( isset( $part -> parameters ) and is_array( $part -> parameters ) ) + { + foreach ( $part -> parameters as $param ) + { + if ( isset( $param -> attribute ) ) + $params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : ''; + } + } + + if ( isset( $part -> dparameters ) and is_array( $part -> dparameters ) ) + { + foreach ( $part -> dparameters as $param ) + { + if ( isset( $param -> attribute ) ) + $params[ strtolower( (string)$param -> attribute ) ] = isset( $param -> value ) ? (string)$param -> value : ''; + } + } + + return $params; + } + + private function decodePartBody( $raw, $encoding ) + { + if ( $encoding === 3 ) + return (string)base64_decode( $raw ); + + if ( $encoding === 4 ) + return (string)quoted_printable_decode( $raw ); + + return (string)$raw; + } + + private function mimeType( $part ) + { + $primary = [ + 0 => 'text', + 1 => 'multipart', + 2 => 'message', + 3 => 'application', + 4 => 'audio', + 5 => 'image', + 6 => 'video', + 7 => 'other' + ]; + + $type = isset( $part -> type ) ? (int)$part -> type : 0; + $subtype = isset( $part -> subtype ) ? strtolower( (string)$part -> subtype ) : 'plain'; + + return ( isset( $primary[$type] ) ? $primary[$type] : 'text' ) . '/' . $subtype; + } + + private function htmlToText( $html ) + { + $html = (string)$html; + if ( trim( $html ) === '' ) + return ''; + + $html = preg_replace( '/]*>.*?<\/style>/is', ' ', $html ); + $html = preg_replace( '/]*>.*?<\/script>/is', ' ', $html ); + $html = preg_replace( '/]*>.*?<\/blockquote>/is', ' ', $html ); + $html = preg_replace( '/]*class="[^"]*(gmail_quote|gmail_signature)[^"]*"[^>]*>.*?<\/div>/is', ' ', $html ); + $html = preg_replace( '//i', "\n", $html ); + $html = preg_replace( '/<\/(p|div|li|tr|h[1-6])>/i', "\n", $html ); + $text = html_entity_decode( strip_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + + return $text; + } + + private function cleanBodyText( $text ) + { + $text = str_replace( [ "\r\n", "\r" ], "\n", (string)$text ); + $text = preg_replace( '/\x{00a0}/u', ' ', $text ); + + $lines = explode( "\n", $text ); + $clean = []; + + foreach ( $lines as $line ) + { + $line_trim = rtrim( $line ); + + if ( preg_match( '/^\s*>+/', $line_trim ) ) + break; + + if ( preg_match( '/^\s*On\s.+wrote:\s*$/i', $line_trim ) ) + break; + + if ( preg_match( '/^\s*W\s+dniu\s+.+napisal\(a\):\s*$/iu', $line_trim ) ) + break; + + if ( preg_match( '/^\s*-----Original Message-----\s*$/i', $line_trim ) ) + break; + + if ( preg_match( '/^\s*Od:\s.+$/iu', $line_trim ) ) + break; + + if ( preg_match( '/^\s*From:\s.+$/i', $line_trim ) ) + break; + + if ( preg_match( '/^\s*Sent:\s.+$/i', $line_trim ) ) + break; + + if ( preg_match( '/^\s*--\s*$/', $line_trim ) ) + break; + + $clean[] = $line_trim; + } + + $text = trim( implode( "\n", $clean ) ); + + return trim( $text ); + } + + private function prepareImportedTaskText( $text ) + { + $text = trim( (string)$text ); + if ( $text === '' ) + return '(brak tresci)'; + + if ( preg_match( '/<\s*[a-z][^>]*>/i', $text ) ) + { + $text = $this -> htmlToText( $text ); + $text = $this -> cleanBodyText( $text ); + } + + $text = str_replace( [ "\r\n", "\r" ], "\n", $text ); + $text = trim( $text ); + + if ( $text === '' ) + return '(brak tresci)'; + + return nl2br( $text ); + } + + private function shouldImportAttachment( array $part, array $cid_refs ) + { + $name = trim( isset( $part['name'] ) ? (string)$part['name'] : '' ); + if ( $name === '' ) + return false; + + $mime = strtolower( isset( $part['mime'] ) ? (string)$part['mime'] : '' ); + $disposition = strtoupper( isset( $part['disposition'] ) ? (string)$part['disposition'] : '' ); + $content_id = $this -> normalizeContentId( isset( $part['content_id'] ) ? (string)$part['content_id'] : '' ); + + if ( !empty( $part['is_inline'] ) ) + return false; + + if ( $mime === 'text/plain' or $mime === 'text/html' ) + return false; + + if ( $content_id !== '' and isset( $cid_refs[$content_id] ) ) + return false; + + if ( strpos( $mime, 'image/' ) === 0 and $disposition !== 'ATTACHMENT' ) + return false; + + return true; + } + + private function extractReferencedCidValues( $html ) + { + $html = (string)$html; + if ( trim( $html ) === '' ) + return []; + + preg_match_all( '/cid:([^"\'>\s]+)/i', $html, $matches ); + if ( !isset( $matches[1] ) or !is_array( $matches[1] ) or !count( $matches[1] ) ) + return []; + + $refs = []; + foreach ( $matches[1] as $raw ) + { + $cid = $this -> normalizeContentId( $raw ); + if ( $cid !== '' ) + $refs[$cid] = true; + } + + return $refs; + } + + private function normalizeContentId( $value ) + { + $value = trim( (string)$value ); + if ( $value === '' ) + return ''; + + $value = trim( $value, '<>' ); + return strtolower( $value ); + } +} diff --git a/autoload/Domain/Tasks/TaskAttachmentRepository.php b/autoload/Domain/Tasks/TaskAttachmentRepository.php index 3473cca..0a0acf6 100644 --- a/autoload/Domain/Tasks/TaskAttachmentRepository.php +++ b/autoload/Domain/Tasks/TaskAttachmentRepository.php @@ -48,10 +48,10 @@ class TaskAttachmentRepository $this -> ensureStorage(); if ( !isset( $file['error'] ) or $file['error'] !== UPLOAD_ERR_OK ) - return [ 'status' => 'error', 'msg' => 'Nie udało się wgrać pliku.' ]; + return [ 'status' => 'error', 'msg' => 'Nie udalo sie wgrac pliku.' ]; if ( !isset( $file['tmp_name'] ) or !is_uploaded_file( $file['tmp_name'] ) ) - return [ 'status' => 'error', 'msg' => 'Nieprawidłowy plik.' ]; + return [ 'status' => 'error', 'msg' => 'Nieprawidlowy plik.' ]; $original_name = trim( (string)$file['name'] ); if ( $original_name === '' ) @@ -68,25 +68,33 @@ class TaskAttachmentRepository $target_file = $target_dir . DIRECTORY_SEPARATOR . $stored_name; if ( !move_uploaded_file( $file['tmp_name'], $target_file ) ) - return [ 'status' => 'error', 'msg' => 'Nie udało się zapisać pliku.' ]; + return [ 'status' => 'error', 'msg' => 'Nie udalo sie zapisac pliku.' ]; - $insert = $this -> mdb -> insert( 'tasks_attachments', [ - 'task_id' => (int)$task_id, - 'user_id' => (int)$user_id, - 'title' => null, - 'original_name' => $safe_original_name, - 'stored_name' => $stored_name, - 'relative_path' => $relative_path, - 'file_ext' => $ext, - 'file_size' => isset( $file['size'] ) ? (int)$file['size'] : 0, - 'date_add' => date( 'Y-m-d H:i:s' ), - 'deleted' => 0 - ] ); + return $this -> storeMeta( $task_id, $user_id, $safe_original_name, $stored_name, $relative_path, $ext, isset( $file['size'] ) ? (int)$file['size'] : 0 ); + } - if ( !$insert ) - return [ 'status' => 'error', 'msg' => 'Nie udało się zapisać załącznika w bazie.' ]; + public function uploadFromContent( $task_id, $user_id, $original_name, $content ) + { + $this -> ensureStorage(); - return [ 'status' => 'success' ]; + $original_name = trim( (string)$original_name ); + if ( $original_name === '' ) + $original_name = 'zalacznik'; + + $safe_original_name = self::sanitizeFileName( $original_name ); + $ext = strtolower( pathinfo( $safe_original_name, PATHINFO_EXTENSION ) ); + $stored_name = uniqid( 'att_', true ) . ( $ext ? '.' . $ext : '' ); + + $relative_path = date( 'Y' ) . '/' . date( 'm' ); + $target_dir = $this -> upload_dir . DIRECTORY_SEPARATOR . str_replace( '/', DIRECTORY_SEPARATOR, $relative_path ); + if ( !is_dir( $target_dir ) ) + @mkdir( $target_dir, 0777, true ); + + $target_file = $target_dir . DIRECTORY_SEPARATOR . $stored_name; + if ( file_put_contents( $target_file, (string)$content ) === false ) + return [ 'status' => 'error', 'msg' => 'Nie udalo sie zapisac pliku.' ]; + + return $this -> storeMeta( $task_id, $user_id, $safe_original_name, $stored_name, $relative_path, $ext, strlen( (string)$content ) ); } public function rename( $attachment_id, $title ) @@ -126,6 +134,28 @@ class TaskAttachmentRepository ], [ 'id' => (int)$attachment_id ] ); } + public function purgeByTaskId( $task_id ) + { + $this -> ensureStorage(); + + $rows = $this -> mdb -> select( 'tasks_attachments', '*', [ + 'task_id' => (int)$task_id + ] ); + + if ( !is_array( $rows ) ) + return false; + + foreach ( $rows as $attachment ) + { + $file_path = $this -> resolveFilePath( $attachment ); + if ( $file_path !== '' and file_exists( $file_path ) ) + @unlink( $file_path ); + } + + $this -> mdb -> delete( 'tasks_attachments', [ 'task_id' => (int)$task_id ] ); + return true; + } + public static function effectiveTitle( $title, $fallback ) { $title = trim( (string)$title ); @@ -176,6 +206,27 @@ class TaskAttachmentRepository ); } + private function storeMeta( $task_id, $user_id, $safe_original_name, $stored_name, $relative_path, $ext, $size ) + { + $insert = $this -> mdb -> insert( 'tasks_attachments', [ + 'task_id' => (int)$task_id, + 'user_id' => (int)$user_id, + 'title' => null, + 'original_name' => $safe_original_name, + 'stored_name' => $stored_name, + 'relative_path' => $relative_path, + 'file_ext' => $ext, + 'file_size' => (int)$size, + 'date_add' => date( 'Y-m-d H:i:s' ), + 'deleted' => 0 + ] ); + + if ( !$insert ) + return [ 'status' => 'error', 'msg' => 'Nie udalo sie zapisac zalacznika w bazie.' ]; + + return [ 'status' => 'success' ]; + } + private function buildPublicUrl( $relative_path, $stored_name ) { return $this -> upload_url . '/' . trim( $relative_path, '/' ) . '/' . rawurlencode( $stored_name ); @@ -192,4 +243,14 @@ class TaskAttachmentRepository return number_format( $kb / 1024, 1, '.', '' ) . ' MB'; } + + private function resolveFilePath( array $attachment ) + { + if ( !isset( $attachment['relative_path'] ) or !isset( $attachment['stored_name'] ) ) + return ''; + + return $this -> upload_dir . DIRECTORY_SEPARATOR . + str_replace( '/', DIRECTORY_SEPARATOR, (string)$attachment['relative_path'] ) . + DIRECTORY_SEPARATOR . (string)$attachment['stored_name']; + } } diff --git a/autoload/class.Cron.php b/autoload/class.Cron.php index 5b9851b..cf238d6 100644 --- a/autoload/class.Cron.php +++ b/autoload/class.Cron.php @@ -118,6 +118,17 @@ class Cron return false; } + public static function import_tasks_from_email() + { + global $imap_tasks; + + if ( !is_array( $imap_tasks ) ) + return [ 'status' => 'empty', 'msg' => 'Brak konfiguracji importu email.' ]; + + $importer = new \Domain\Tasks\MailToTaskImporter(); + return $importer -> importFromImap( $imap_tasks ); + } + public static function tasks_emails() { global $mdb, $setttings; @@ -197,4 +208,4 @@ class Cron } return ['status' => 'empty']; } -} \ No newline at end of file +} diff --git a/autoload/controls/class.Tasks.php b/autoload/controls/class.Tasks.php index c5af149..5463a09 100644 --- a/autoload/controls/class.Tasks.php +++ b/autoload/controls/class.Tasks.php @@ -317,6 +317,20 @@ class Tasks exit; } + static public function task_change_client() { + global $mdb; + + $client_id = (int)\S::get( 'client_id' ); + $client_value = $client_id > 0 ? $client_id : null; + + if ( $mdb -> update( 'tasks', [ 'client_id' => $client_value ], [ 'id' => \S::get( 'task_id' ) ] ) ) { + echo json_encode( [ 'status' => 'success' ] ); + } else { + echo json_encode( [ 'status' => 'error' ] ); + } + exit; + } + static public function task_change_priority() { global $mdb; @@ -424,6 +438,7 @@ class Tasks 'task' => \factory\Tasks::task_details( \S::get( 'task_id' ), $user['id'] ), 'task_works' => \factory\Tasks::task_works( \S::get( 'task_id' ) ), 'task_attachments' => $attachments_repository -> listByTaskId( \S::get( 'task_id' ) ), + 'clients' => \factory\Crm::get_client_list(), 'user' => $user, 'statuses' => \factory\Tasks::get_statuses(), 'projects' => \factory\Projects::user_projects( $user['id'] ) diff --git a/autoload/factory/class.Projects.php b/autoload/factory/class.Projects.php index a227332..d314e2a 100644 --- a/autoload/factory/class.Projects.php +++ b/autoload/factory/class.Projects.php @@ -247,6 +247,10 @@ class Projects public static function task_delete( $task_id ) { global $mdb; + + $attachments_repository = new \Domain\Tasks\TaskAttachmentRepository( $mdb ); + $attachments_repository -> purgeByTaskId( (int)$task_id ); + return $mdb -> update( 'tasks', [ 'deleted' => 1 ], [ 'id' => $task_id ] ); } diff --git a/autoload/factory/class.Tasks.php b/autoload/factory/class.Tasks.php index 01640c1..fc8fe6d 100644 --- a/autoload/factory/class.Tasks.php +++ b/autoload/factory/class.Tasks.php @@ -3,6 +3,8 @@ namespace factory; class Tasks { + private const MIN_WORK_LOG_SECONDS = 60; + public static $statuses = [ 0 => 'nowe', 3 => 'do rozliczenia', 5 => 'do zrobienia', 1 => 'do sprawdzenia', 2 => 'zamknięte' ]; public static $priorities = [ 0 => 'niski', 1 => 'normalny', 2 => 'wysoki', 3 => 'pilny' ]; @@ -300,14 +302,32 @@ class Tasks { global $mdb; - $mdb -> update( 'tasks_work', [ - 'date_end' => date( 'Y-m-d H:i:s' ) - ], [ - 'AND' => [ - 'date_end' => null, - 'user_id' => $user_id - ] - ] ); + $open_works = $mdb -> select( 'tasks_work', [ 'id', 'date_start' ], [ + 'AND' => [ + 'date_end' => null, + 'user_id' => $user_id, + 'deleted' => 0 + ] + ] ); + + if ( is_array( $open_works ) and count( $open_works ) ) + { + $date_end_now = date( 'Y-m-d H:i:s' ); + + foreach ( $open_works as $open_work ) + { + $work_id = isset( $open_work['id'] ) ? (int)$open_work['id'] : 0; + if ( !$work_id ) + continue; + + $is_too_short = self::is_work_duration_too_short( isset( $open_work['date_start'] ) ? $open_work['date_start'] : null, $date_end_now ); + + $mdb -> update( 'tasks_work', [ + 'date_end' => $date_end_now, + 'deleted' => $is_too_short ? 1 : 0 + ], [ 'id' => $work_id ] ); + } + } $mdb -> insert( 'tasks_work', [ 'user_id' => $user_id, @@ -321,18 +341,41 @@ class Tasks { global $mdb; + $work = $mdb -> get( 'tasks_work', [ 'id', 'date_start' ], [ + 'AND' => [ + 'task_id' => $task_id, + 'user_id' => $user_id, + 'date_end' => null, + 'deleted' => 0 + ], + 'ORDER' => [ 'id' => 'DESC' ] + ] ); + + if ( !$work or !isset( $work['id'] ) ) + return [ 'status' => 'success' ]; + + $date_end_now = date( 'Y-m-d H:i:s' ); + $is_too_short = self::is_work_duration_too_short( isset( $work['date_start'] ) ? $work['date_start'] : null, $date_end_now ); + $mdb -> update( 'tasks_work', [ - 'date_end' => date( 'Y-m-d H:i:s' ) - ], [ - 'AND' => [ - 'task_id' => $task_id, - 'user_id' => $user_id, - 'date_end' => null - ] - ] ); + 'date_end' => $date_end_now, + 'deleted' => $is_too_short ? 1 : 0 + ], [ 'id' => (int)$work['id'] ] ); + return [ 'status' => 'success' ]; } + private static function is_work_duration_too_short( $date_start, $date_end ) + { + $start_timestamp = strtotime( (string)$date_start ); + $end_timestamp = strtotime( (string)$date_end ); + + if ( $start_timestamp === false or $end_timestamp === false ) + return false; + + return ( $end_timestamp - $start_timestamp ) < self::MIN_WORK_LOG_SECONDS; + } + static public function task_details( $task_id, $user_id = null ) { global $mdb; @@ -527,6 +570,9 @@ class Tasks $children = $mdb -> get( 'tasks', 'id', [ 'parent_id' => $task_id ] ); + $attachments_repository = new \Domain\Tasks\TaskAttachmentRepository( $mdb ); + $attachments_repository -> purgeByTaskId( $task_id ); + $mdb -> delete( 'tasks', [ 'id' => $task_id ] ); $mdb -> update( 'tasks', [ 'parent_id' => null ], [ 'parent_id' => $task_id ] ); diff --git a/config.php b/config.php index c7bff96..1aeb5d6 100644 --- a/config.php +++ b/config.php @@ -8,3 +8,10 @@ $settings['email_host'] = 'mail.projectpro.pl'; $settings['email_port'] = 25; $settings['email_login'] = 'www@projectpro.pl'; $settings['email_password'] = 'ProjectPro2025!'; + +$imap_tasks['host'] = 'host700513.hostido.net.pl'; +$imap_tasks['port'] = 993; +$imap_tasks['flags'] = '/imap/ssl/novalidate-cert'; +$imap_tasks['folder'] = 'INBOX'; +$imap_tasks['username'] = 'zadania@project-pro.pl'; +$imap_tasks['password'] = 'ProjectPro2025!'; diff --git a/cron.php b/cron.php index 9d64325..9f09c5d 100644 --- a/cron.php +++ b/cron.php @@ -53,6 +53,19 @@ $mdb = new medoo( [ return R::getRedBean() -> dispense( $type ); } ); +/* import zadan z email */ +$response = \Cron::import_tasks_from_email(); +if ( $response['status'] == 'ok' ) +{ + echo json_encode( $response ); + exit; +} +if ( $response['status'] == 'error' ) +{ + echo json_encode( $response ); + exit; +} + if ( date( 'G' ) > 6 ) { diff --git a/templates/tasks/main_view.php b/templates/tasks/main_view.php index b4c8d2d..095e9de 100644 --- a/templates/tasks/main_view.php +++ b/templates/tasks/main_view.php @@ -241,7 +241,78 @@ return $( '.task_popup .task_details' ).hasClass( 'open_works_time' ); } - function task_popup( task_id, open_works_time = false ) { + function start_task_timer( task_id ) { + if ( !task_id ) + return; + + if ( task_refresh ) { + clearInterval( task_refresh ); + } + + $.ajax({ + type: 'POST', + cache: false, + url: '/tasks/task_start/', + data: { + task_id: task_id + }, + beforeSend: function() {}, + success: function( response ) { + data = jQuery.parseJSON( response ); + if ( data.status == 'success' ) + { + $( 'li.task .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); + $( 'li.task .task_start' ).removeClass( 'hidden' ); + $( 'li[task_id="' + task_id + '"] .task_start' ).addClass( 'hidden' ); + $( 'li[task_id="' + task_id + '"] .task_end' ).removeClass( 'hidden' ).addClass( 'animate' ); + $( '.task_popup .task_end' ).removeClass( 'hidden' ).addClass( 'animate' ); + $( '.task_popup .task_start' ).addClass( 'hidden' ); + } + } + }); + } + + function stop_task_timer( task_id ) { + if ( !task_id ) + return; + + $.ajax({ + type: 'POST', + cache: false, + url: '/tasks/task_end/', + data: { + task_id: task_id + }, + beforeSend: function() {}, + success: function( response ) { + data = jQuery.parseJSON( response ); + if ( data.status == 'success' ) + { + $( 'li[task_id="' + task_id + '"] .task_start' ).removeClass( 'hidden' ); + $( 'li[task_id="' + task_id + '"] .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); + $( '.task_popup .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); + $( '.task_popup .task_start' ).removeClass( 'hidden' ); + } + } + }); + } + + function close_task_popup() { + var popup = $( '.task_popup' ); + var details = popup.find( '.task_details' ); + var task_id = details.attr( 'task_id' ); + var is_timer_running = details.find( '.task_end' ).length && !details.find( '.task_end' ).hasClass( 'hidden' ); + + popup.empty().hide(); + + if ( task_id && is_timer_running ) + stop_task_timer( task_id ); + } + + function task_popup( task_id, open_works_time = false, auto_start_timer = true ) { + var current_popup_task_id = $( '.task_popup .task_details' ).attr( 'task_id' ); + var popup_already_open = $( '.task_popup' ).is( ':visible' ) && $( '.task_popup .task_details' ).length; + var should_auto_start_timer = auto_start_timer && !( popup_already_open && String( current_popup_task_id ) === String( task_id ) ); $.ajax({ url: '/tasks/task_popup/', @@ -277,6 +348,9 @@ if ( open_works_time ) $( '.task_popup .task_details' ).addClass( 'open_works_time' ); + + if ( should_auto_start_timer ) + start_task_timer( task_id ); } }); } @@ -359,7 +433,7 @@ $( 'body' ).on( 'click', '.task_popup .close', function(e) { e.preventDefault(); - $( '.task_popup' ).empty().hide(); + close_task_popup(); return false; }); @@ -405,7 +479,7 @@ return this.value; }).get(); // close popup - $( '.task_popup' ).empty().hide(); + close_task_popup(); reload_tasks( projects, users ); } } @@ -437,7 +511,37 @@ return this.value; }).get(); // close popup - $( '.task_popup' ).empty().hide(); + close_task_popup(); + reload_tasks( projects, users ); + } + } + }); + }); + + // change task client + $( 'body' ).on( 'change', 'select[name="task_client"]', function() { + var task_id = $( this ).attr( 'task_id' ); + var client_id = $( this ).val(); + + $.ajax({ + url: '/tasks/task_change_client/', + type: 'POST', + data: { + task_id: task_id, + client_id: client_id + }, + success: function( response ) + { + var data = jQuery.parseJSON( response ); + if ( data.status == 'success' ) + { + var projects = jQuery( 'input[name="projects"]:checked' ).map(function() { + return this.value; + }).get(); + projects.join( "," ); + var users = jQuery( 'input[name="users"]:checked' ).map(function() { + return this.value; + }).get(); reload_tasks( projects, users ); } } @@ -468,7 +572,7 @@ return this.value; }).get(); // close popup - $( '.task_popup' ).empty().hide(); + close_task_popup(); reload_tasks( projects, users ); } } @@ -616,57 +720,13 @@ $( 'body' ).on( 'click', '.task_start', function(e) { e.preventDefault(); var task_id = $( this ).attr( 'task_id' ); - - if ( task_refresh ) { - clearInterval( task_refresh ); - } - - $.ajax({ - type: 'POST', - cache: false, - url: '/tasks/task_start/', - data: { - task_id: task_id - }, - beforeSend: function() {}, - success: function( response ) { - data = jQuery.parseJSON( response ); - if ( data.status == 'success' ) - { - $( 'li.task .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); - $( 'li.task .task_start' ).removeClass( 'hidden' ); - $( 'li[task_id="' + task_id + '"] .task_start' ).addClass( 'hidden' ); - $( 'li[task_id="' + task_id + '"] .task_end' ).removeClass( 'hidden' ).addClass( 'animate' ); - $( '.task_popup .task_end' ).removeClass( 'hidden' ).addClass( 'animate' ); - $( '.task_popup .task_start' ).addClass( 'hidden' ); - } - } - }); + start_task_timer( task_id ); }); $( 'body' ).on( 'click', '.task_end', function(e) { e.preventDefault(); var task_id = $( this ).attr( 'task_id' ); - - $.ajax({ - type: 'POST', - cache: false, - url: '/tasks/task_end/', - data: { - task_id: task_id - }, - beforeSend: function() {}, - success: function( response ) { - data = jQuery.parseJSON( response ); - if ( data.status == 'success' ) - { - $( 'li[task_id="' + task_id + '"] .task_start' ).removeClass( 'hidden' ); - $( 'li[task_id="' + task_id + '"] .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); - $( '.task_popup .task_end' ).addClass( 'hidden' ).removeClass( 'animate' ); - $( '.task_popup .task_start' ).removeClass( 'hidden' ); - } - } - }); + stop_task_timer( task_id ); }); $( 'body' ).on( 'click', '.task_popup .task_end', function(e) { @@ -1097,7 +1157,7 @@ return this.value; }).get(); reload_tasks( checkedVals ); - $( '.task_popup' ).empty().hide(); + close_task_popup(); } } }); diff --git a/templates/tasks/task_popup.php b/templates/tasks/task_popup.php index f68e665..4f65e96 100644 --- a/templates/tasks/task_popup.php +++ b/templates/tasks/task_popup.php @@ -130,9 +130,16 @@

Klient

- task['client_id'] ):?> - task['client_id'] );?> - +
+ +

Przepracowany czas

@@ -168,6 +175,24 @@ if ( !popup.length ) return; + if ( $.fn.select2 ) + { + var client_select = popup.find( '.task_client_select' ); + client_select.select2({ + width: '100%', + placeholder: 'Wybierz klienta', + dropdownParent: popup + }); + + client_select.on( 'select2:open', function() { + setTimeout( function() { + var search_field = document.querySelector( '.select2-container--open .select2-search__field' ); + if ( search_field ) + search_field.focus(); + }, 0 ); + } ); + } + var time_worked = popup.find( '.time_worked' ); var time_value = popup.find( '.js-time-worked-value' ); var total_seconds = parseInt( time_worked.attr( 'data-total-seconds' ), 10 ) || 0;