From f93c5370bfcd0ced2e1e079248620a9935f17f3a Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Mon, 24 Feb 2025 15:21:24 -0500 Subject: [PATCH] Add harvest and generic imports --- app/Service/ColorService.php | 5 + .../Importers/ClockifyTimeEntriesImporter.php | 3 +- .../Importers/GenericProjectsImporter.php | 104 +++++++++ .../Importers/GenericTimeEntriesImporter.php | 208 ++++++++++++++++++ .../Importers/HarvestClientsImporter.php | 76 +++++++ .../Importers/HarvestProjectsImporter.php | 107 +++++++++ .../Importers/HarvestTimeEntriesImporter.php | 191 ++++++++++++++++ .../Import/Importers/ImporterProvider.php | 5 + .../Import/Importers/SolidtimeImporter.php | 2 +- .../Importers/TogglTimeEntriesImporter.php | 3 +- lang/en/importer.php | 26 +++ .../generic_projects_import_test_1.csv | 4 + .../generic_time_entries_import_test_1.csv | 3 + .../harvest_clients_import_test_1.csv | 3 + .../harvest_projects_import_test_1.csv | 3 + .../harvest_time_entries_import_test_1.csv | 3 + .../Importers/GenericProjectsImporterTest.php | 87 ++++++++ .../GenericTimeEntriesImporterTest.php | 71 ++++++ .../Importers/HarvestClientsImporterTest.php | 43 ++++ .../Importers/HarvestProjectsImporterTest.php | 61 +++++ .../HarvestTimeEntriesImporterTest.php | 110 +++++++++ .../Import/Importers/ImporterProviderTest.php | 5 + 22 files changed, 1120 insertions(+), 3 deletions(-) create mode 100644 app/Service/Import/Importers/GenericProjectsImporter.php create mode 100644 app/Service/Import/Importers/GenericTimeEntriesImporter.php create mode 100644 app/Service/Import/Importers/HarvestClientsImporter.php create mode 100644 app/Service/Import/Importers/HarvestProjectsImporter.php create mode 100644 app/Service/Import/Importers/HarvestTimeEntriesImporter.php create mode 100644 resources/testfiles/generic_projects_import_test_1.csv create mode 100644 resources/testfiles/generic_time_entries_import_test_1.csv create mode 100644 resources/testfiles/harvest_clients_import_test_1.csv create mode 100644 resources/testfiles/harvest_projects_import_test_1.csv create mode 100644 resources/testfiles/harvest_time_entries_import_test_1.csv create mode 100644 tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php create mode 100644 tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php create mode 100644 tests/Unit/Service/Import/Importers/HarvestClientsImporterTest.php create mode 100644 tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php create mode 100644 tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php diff --git a/app/Service/ColorService.php b/app/Service/ColorService.php index 3b0a79ee..9b527e2d 100644 --- a/app/Service/ColorService.php +++ b/app/Service/ColorService.php @@ -33,6 +33,11 @@ class ColorService private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; + public function isBuiltInColor(string $color): bool + { + return in_array($color, self::COLORS, true); + } + public function getRandomColor(?string $seed = null): string { if ($seed !== null) { diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index aa3caa0a..3c95be01 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -11,6 +11,7 @@ use App\Models\TimeEntry; use Carbon\Exceptions\InvalidFormatException; use Exception; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use League\Csv\Exception as CsvException; use League\Csv\Reader; @@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter */ private function getTags(string $tags): array { - if (trim($tags) === '') { + if (Str::trim($tags) === '') { return []; } $tagsParsed = explode(', ', $tags); diff --git a/app/Service/Import/Importers/GenericProjectsImporter.php b/app/Service/Import/Importers/GenericProjectsImporter.php new file mode 100644 index 00000000..4f814f19 --- /dev/null +++ b/app/Service/Import/Importers/GenericProjectsImporter.php @@ -0,0 +1,104 @@ + + */ + private const array REQUIRED_FIELDS = [ + 'name', + ]; + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data, string $timezone): void + { + try { + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $reader->setEnclosure('"'); + $reader->setEscape(''); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $clientId = null; + if (isset($record['client']) && $record['client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['client'], + 'organization_id' => $this->organization->id, + ]); + } + if ($record['name'] !== '') { + $archivedAt = null; + if (isset($record['archived_at']) && $record['archived_at'] !== '') { + try { + $archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid'); + } + } + $this->projectImportHelper->getKey([ + 'name' => $record['name'], + 'organization_id' => $this->organization->id, + ], [ + 'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(), + 'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null, + 'is_public' => isset($record['is_public']) && $record['is_public'] === 'true', + 'client_id' => $clientId, + 'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true', + 'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null, + 'archived_at' => $archivedAt, + ]); + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + foreach (self::REQUIRED_FIELDS as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getName(): string + { + return __('importer.generic_projects.name'); + } + + #[\Override] + public function getDescription(): string + { + return __('importer.generic_projects.description'); + } +} diff --git a/app/Service/Import/Importers/GenericTimeEntriesImporter.php b/app/Service/Import/Importers/GenericTimeEntriesImporter.php new file mode 100644 index 00000000..1778f8a1 --- /dev/null +++ b/app/Service/Import/Importers/GenericTimeEntriesImporter.php @@ -0,0 +1,208 @@ + + */ + private const array REQUIRED_FIELDS = [ + 'description', + 'billable', + 'client', + 'project', + 'tags', + 'start', + 'end', + 'task', + 'user_name', + 'user_email', + ]; + + /** + * @return array + * + * @throws ImportException + */ + private function getTags(string $tags): array + { + if (Str::trim($tags) === '') { + return []; + } + $tagsParsed = explode(',', $tags); + $tagIds = []; + foreach ($tagsParsed as $tagParsed) { + $tagId = $this->tagImportHelper->getKey([ + 'name' => Str::trim($tagParsed), + 'organization_id' => $this->organization->id, + ]); + $tagIds[] = $tagId; + } + + return $tagIds; + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data, string $timezone): void + { + try { + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $reader->setEnclosure('"'); + $reader->setEscape(''); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $userId = $this->userImportHelper->getKey([ + 'email' => $record['user_email'], + ], [ + 'name' => $record['user_name'], + 'timezone' => 'UTC', + 'is_placeholder' => true, + ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ], [ + 'role' => Role::Placeholder->value, + ]); + $member = $this->memberImportHelper->getModelById($memberId); + $clientId = null; + if ($record['client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + $project = null; + $projectMember = null; + if ($record['project'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['project'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'is_billable' => false, + 'color' => $this->colorService->getRandomColor(), + ]); + $project = $this->projectImportHelper->getModelById($projectId); + $projectMember = $this->projectMemberImportHelper->getModel([ + 'project_id' => $projectId, + 'member_id' => $memberId, + ]); + } + $taskId = null; + if ($record['task'] !== '') { + $taskId = $this->taskImportHelper->getKey([ + 'name' => $record['task'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + $this->taskImportHelper->getModelById($taskId); + } + $timeEntry = new TimeEntry; + $timeEntry->disableAuditing(); + $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->client_id = $clientId; + $timeEntry->organization_id = $this->organization->id; + $timeEntry->description = $record['description']; + if (! in_array($record['billable'], ['true', 'false'], true)) { + throw new ImportException('Invalid billable value'); + } + $timeEntry->billable = $record['billable'] === 'true'; + $timeEntry->tags = $this->getTags($record['tags']); + $timeEntry->is_imported = true; + try { + $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('Value of start ("'.$record['start'].'") is invalid'); + } + if ($start === null) { + throw new ImportException('Value of start ("'.$record['start'].'") is invalid'); + } + $timeEntry->start = $start->utc(); + + try { + $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('Value of end ("'.$record['end'].'") is invalid'); + } + if ($end === null) { + throw new ImportException('Value of end ("'.$record['end'].'") is invalid'); + } + $timeEntry->end = $end->utc(); + $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( + $timeEntry, + $projectMember, + $project, + $member, + $this->organization + ); + $timeEntry->save(); + $this->timeEntriesCreated++; + } + foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { + RecalculateSpentTimeForProject::dispatch($usedProject); + } + foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { + RecalculateSpentTimeForTask::dispatch($usedTask); + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + foreach (self::REQUIRED_FIELDS as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getName(): string + { + return __('importer.generic_time_entries.name'); + } + + #[\Override] + public function getDescription(): string + { + return __('importer.generic_time_entries.description'); + } +} diff --git a/app/Service/Import/Importers/HarvestClientsImporter.php b/app/Service/Import/Importers/HarvestClientsImporter.php new file mode 100644 index 00000000..f4c0e943 --- /dev/null +++ b/app/Service/Import/Importers/HarvestClientsImporter.php @@ -0,0 +1,76 @@ + + */ + private const array REQUIRED_FIELDS = [ + 'Client Name', + ]; + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data, string $timezone): void + { + try { + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $reader->setEnclosure('"'); + $reader->setEscape(''); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $this->clientImportHelper->getKey([ + 'name' => $record['Client Name'], + 'organization_id' => $this->organization->id, + ]); + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + foreach (self::REQUIRED_FIELDS as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getName(): string + { + return __('importer.harvest_clients.name'); + } + + #[\Override] + public function getDescription(): string + { + return __('importer.harvest_clients.description'); + } +} diff --git a/app/Service/Import/Importers/HarvestProjectsImporter.php b/app/Service/Import/Importers/HarvestProjectsImporter.php new file mode 100644 index 00000000..07a93628 --- /dev/null +++ b/app/Service/Import/Importers/HarvestProjectsImporter.php @@ -0,0 +1,107 @@ + + */ + private const array REQUIRED_FIELDS = [ + 'Client', + 'Project', + 'Budget', + 'Billable Hours', + ]; + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data, string $timezone): void + { + try { + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $reader->setEnclosure('"'); + $reader->setEscape(''); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + if ($record['Project'] !== '') { + if (! isset($record['Budget']) || ! is_string($record['Budget'])) { + throw new ImportException('The value for "Budget" is invalid'); + } + $estimatedTimeField = Str::replace(',', '.', $record['Budget']); + $estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null; + if ($estimatedTime === 0) { + $estimatedTime = null; + } + if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) { + throw new ImportException('The value for "Billable Hours" is invalid'); + } + $billableHoursField = Str::replace(',', '.', $record['Billable Hours']); + $billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null; + $this->projectImportHelper->getKey([ + 'name' => $record['Project'], + 'organization_id' => $this->organization->id, + ], [ + 'color' => $this->colorService->getRandomColor(), + 'client_id' => $clientId, + 'estimated_time' => $estimatedTime, + 'is_billable' => $billableHours > 0, + ]); + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + foreach (self::REQUIRED_FIELDS as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getName(): string + { + return __('importer.harvest_projects.name'); + } + + #[\Override] + public function getDescription(): string + { + return __('importer.harvest_projects.description'); + } +} diff --git a/app/Service/Import/Importers/HarvestTimeEntriesImporter.php b/app/Service/Import/Importers/HarvestTimeEntriesImporter.php new file mode 100644 index 00000000..e005362f --- /dev/null +++ b/app/Service/Import/Importers/HarvestTimeEntriesImporter.php @@ -0,0 +1,191 @@ + + */ + private const array REQUIRED_FIELDS = [ + 'Date', + 'Hours', + 'Client', + 'Project', + 'Task', + 'Billable?', + 'First Name', + 'Last Name', + 'Notes', + ]; + + /** + * @throws ImportException + */ + #[Override] + public function importData(string $data, string $timezone): void + { + try { + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $reader->setEnclosure('"'); + $reader->setEscape(''); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $firstname = $record['First Name']; + $lastname = $record['Last Name']; + $userId = $this->userImportHelper->getKey([ + 'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test', + ], [ + 'name' => $firstname.' '.$lastname, + 'timezone' => 'UTC', + 'is_placeholder' => true, + ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ], [ + 'role' => Role::Placeholder->value, + ]); + $member = $this->memberImportHelper->getModelById($memberId); + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + $project = null; + $projectMember = null; + if ($record['Project'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Project'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $this->colorService->getRandomColor(), + 'is_billable' => true, + ]); + $project = $this->projectImportHelper->getModelById($projectId); + $projectMember = $this->projectMemberImportHelper->getModel([ + 'project_id' => $projectId, + 'member_id' => $memberId, + ]); + } + $taskId = null; + if ($record['Task'] !== '') { + $taskId = $this->taskImportHelper->getKey([ + 'name' => $record['Task'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + $this->taskImportHelper->getModelById($taskId); + } + $timeEntry = new TimeEntry; + $timeEntry->disableAuditing(); + $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->client_id = $clientId; + $timeEntry->organization_id = $this->organization->id; + if (strlen($record['Notes']) > 500) { + throw new ImportException('Time entry note is too long'); + } + $timeEntry->description = $record['Notes']; + if (! in_array($record['Billable?'], ['Yes', 'No'], true)) { + throw new ImportException('Invalid billable value'); + } + $timeEntry->billable = $record['Billable?'] === 'Yes'; + $timeEntry->tags = []; + $timeEntry->is_imported = true; + + // Start & End + try { + $date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone); + } catch (InvalidFormatException) { + throw new ImportException('Date ("'.$record['Date'].'") is invalid'); + } + if ($date === null) { + throw new ImportException('Date ("'.$record['Date'].'") is invalid'); + } + if (! isset($record['Hours']) || ! is_string($record['Hours'])) { + throw new ImportException('Hours ("'.($record['Hours'] ?? '').'") is invalid'); + } + $hoursField = Str::replace(',', '.', $record['Hours']); + if (! is_numeric($hoursField)) { + throw new ImportException('Hours ("'.$record['Hours'].'") is invalid'); + } + $hours = (float) $hoursField; + $timeEntry->start = $date->copy()->startOfDay()->utc(); + $timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc(); + $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( + $timeEntry, + $projectMember, + $project, + $member, + $this->organization + ); + $timeEntry->save(); + $this->timeEntriesCreated++; + } + foreach ($this->projectImportHelper->getCachedModels() as $usedProject) { + RecalculateSpentTimeForProject::dispatch($usedProject); + } + foreach ($this->taskImportHelper->getCachedModels() as $usedTask) { + RecalculateSpentTimeForTask::dispatch($usedTask); + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + foreach (self::REQUIRED_FIELDS as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[Override] + public function getName(): string + { + return __('importer.harvest_time_entries.name'); + } + + #[Override] + public function getDescription(): string + { + return __('importer.harvest_time_entries.description'); + } +} diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index 63e81f82..240416b4 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -15,6 +15,11 @@ class ImporterProvider 'clockify_time_entries' => ClockifyTimeEntriesImporter::class, 'clockify_projects' => ClockifyProjectsImporter::class, 'solidtime' => SolidtimeImporter::class, + 'harvest_projects' => HarvestProjectsImporter::class, + 'harvest_time_entries' => HarvestTimeEntriesImporter::class, + 'harvest_clients' => HarvestClientsImporter::class, + 'generic_projects' => GenericProjectsImporter::class, + 'generic_time_entries' => GenericTimeEntriesImporter::class, ]; /** diff --git a/app/Service/Import/Importers/SolidtimeImporter.php b/app/Service/Import/Importers/SolidtimeImporter.php index 41e8ae44..6005da11 100644 --- a/app/Service/Import/Importers/SolidtimeImporter.php +++ b/app/Service/Import/Importers/SolidtimeImporter.php @@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter */ private function getTags(string $tags): array { - if (trim($tags) === '') { + if (Str::trim($tags) === '') { return []; } $tagsParsed = json_decode($tags); diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 9547ff8f..a1e071d9 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -11,6 +11,7 @@ use App\Models\TimeEntry; use Carbon\Exceptions\InvalidFormatException; use Exception; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use League\Csv\Exception as CsvException; use League\Csv\Reader; @@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter */ private function getTags(string $tags): array { - if (trim($tags) === '') { + if (Str::trim($tags) === '') { return []; } $tagsParsed = explode(', ', $tags); diff --git a/lang/en/importer.php b/lang/en/importer.php index 68a59f69..2c6cec3d 100644 --- a/lang/en/importer.php +++ b/lang/en/importer.php @@ -13,6 +13,14 @@ return [ '
4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '. '

Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.', ], + 'generic_project' => [ + 'name' => 'Generic Projects', + 'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for more information about the CSV structure', + ], + 'generic_time_entries' => [ + 'name' => 'Generic Time Entries', + 'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for more information about the CSV structure', + ], 'clockify_projects' => [ 'name' => 'Clockify Projects', 'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".
'. @@ -38,4 +46,22 @@ return [ 'name' => 'Solidtime', 'description' => '1. Choose the organization you want to export in dropdown in the left top corner
2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)
3. Click on "Export".
4. Save the file and upload it here.', ], + 'harvest_clients' => [ + 'name' => 'Harvest Clients', + 'description' => '1. Go to "Manage" (top navigation)
2. Click on the "Clients"'. + '
3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '. + '
', + ], + 'harvest_projects' => [ + 'name' => 'Harvest Projects', + 'description' => '1. Go to "Projects" (top navigation)
2. Click on the "Export" button'. + '
3. Select which projects you would like to export and select CSV format '. + '

Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.', + ], + 'harvest_time_entries' => [ + 'name' => 'Harvest Time Entries', + 'description' => '1. Go to Settings (right top corner)
2. Click on "Import/Export" in the left navigation'. + '
3. Now click on "Export all time" '. + '

Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.', + ], ]; diff --git a/resources/testfiles/generic_projects_import_test_1.csv b/resources/testfiles/generic_projects_import_test_1.csv new file mode 100644 index 00000000..b6da159a --- /dev/null +++ b/resources/testfiles/generic_projects_import_test_1.csv @@ -0,0 +1,4 @@ +name,color,billable_rate,is_public,client,billable_default,estimated_time,archived_at +"Project for Big Company",,10001,false,"Big Company",true,, +"Project without Client",#ef5350,,false,,false,1000, +"Project (Archived)",#6a407f,,true,"Some client",true,0,2024-08-25T10:00:00Z diff --git a/resources/testfiles/generic_time_entries_import_test_1.csv b/resources/testfiles/generic_time_entries_import_test_1.csv new file mode 100644 index 00000000..94555630 --- /dev/null +++ b/resources/testfiles/generic_time_entries_import_test_1.csv @@ -0,0 +1,3 @@ +description,billable,client,project,tags,start,end,task,user_name,user_email +"","false","","Project without Client","Development, Backend","2024-03-04T09:23:52Z","2024-03-04T09:23:52Z","","Peter Tester","peter.test@email.test" +"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","Task 1","Peter Tester","peter.test@email.test" diff --git a/resources/testfiles/harvest_clients_import_test_1.csv b/resources/testfiles/harvest_clients_import_test_1.csv new file mode 100644 index 00000000..0dda0942 --- /dev/null +++ b/resources/testfiles/harvest_clients_import_test_1.csv @@ -0,0 +1,3 @@ +Client Name,Address +Example Client,"" +"\\ 🔥 Special characters """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\","" diff --git a/resources/testfiles/harvest_projects_import_test_1.csv b/resources/testfiles/harvest_projects_import_test_1.csv new file mode 100644 index 00000000..ea313733 --- /dev/null +++ b/resources/testfiles/harvest_projects_import_test_1.csv @@ -0,0 +1,3 @@ +Client,Project,Project Code,Start Date,End Date,Project Notes,Total Hours,Billable Hours,Billable Amount,Budget By,Budget,Budget Spent,Budget Remaining,Total Costs,Team Costs,Expenses +Example Client,Example Project,,"","",This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it.,"20,01","20,01","2.001,0",Hours,"50,0","20,01","29,99","0,0","0,0","0,0" +"\\ 🔥 Special characters client """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\","\\ 🔥 Special characters project """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",,"","",,"0,0","0,0","0,0",Hours,"0,0","0,0","50,0","0,0","0,0","0,0" diff --git a/resources/testfiles/harvest_time_entries_import_test_1.csv b/resources/testfiles/harvest_time_entries_import_test_1.csv new file mode 100644 index 00000000..87821b72 --- /dev/null +++ b/resources/testfiles/harvest_time_entries_import_test_1.csv @@ -0,0 +1,3 @@ +Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL +2024-03-04,,Project without Client,,,"","20,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR, +2024-03-04,Big Company,Project for Big Company,,Task 1,Working hard,"0,01",Yes,No,No,Peter,Tester,,Yes,"100,0","1,0","0,0","0,0",Euro - EUR, diff --git a/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php new file mode 100644 index 00000000..6b26b0af --- /dev/null +++ b/tests/Unit/Service/Import/Importers/GenericProjectsImporterTest.php @@ -0,0 +1,87 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new GenericProjectsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $clients = Client::all(); + $this->assertCount(2, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $client2 = $clients->firstWhere('name', 'Some client'); + $this->assertNotNull($client2); + $projects = Project::all(); + $this->assertCount(3, $projects); + // Project 1 + $project1 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project1); + $this->assertTrue(app(ColorService::class)->isBuiltInColor($project1->color)); + $this->assertSame(10001, $project1->billable_rate); + $this->assertFalse($project1->is_public); + $this->assertSame($client1->getKey(), $project1->client_id); + $this->assertTrue($project1->is_billable); + $this->assertSame(null, $project1->estimated_time); + $this->assertNull($project1->archived_at); + // Project 2 + $project2 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project2); + $this->assertSame('#ef5350', $project2->color); + $this->assertSame(null, $project2->billable_rate); + $this->assertFalse($project2->is_public); + $this->assertSame(null, $project2->client_id); + $this->assertFalse($project2->is_billable); + $this->assertSame(1000, $project2->estimated_time); + $this->assertSame(null, $project2->archived_at); + $project3 = $projects->firstWhere('name', 'Project (Archived)'); + $this->assertNotNull($project3); + $this->assertSame('#6a407f', $project3->color); + $this->assertSame(null, $project3->billable_rate); + $this->assertTrue($project3->is_public); + $this->assertSame($client2->getKey(), $project3->client_id); + $this->assertTrue($project3->is_billable); + $this->assertSame(null, $project3->estimated_time); + $this->assertSame('2024-08-25T10:00:00Z', $project3->archived_at->toIso8601ZuluString()); + + $tasks = Task::all(); + $this->assertCount(0, $tasks); + + $this->assertSame(0, $report->timeEntriesCreated); + $this->assertSame(0, $report->tagsCreated); + $this->assertSame(0, $report->tasksCreated); + $this->assertSame(0, $report->usersCreated); + $this->assertSame(3, $report->projectsCreated); + $this->assertSame(2, $report->clientsCreated); + } +} diff --git a/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php new file mode 100644 index 00000000..23e1341a --- /dev/null +++ b/tests/Unit/Service/Import/Importers/GenericTimeEntriesImporterTest.php @@ -0,0 +1,71 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new GenericTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $this->checkTimeEntries($testScenario); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(2, $report->tagsCreated); + $this->assertSame(1, $report->tasksCreated); + $this->assertSame(1, $report->usersCreated); + $this->assertSame(2, $report->projectsCreated); + $this->assertSame(1, $report->clientsCreated); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $timezone = 'Europe/Vienna'; + $importer = new GenericTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv'); + $importer->importData($data, $timezone); + $importer = new GenericTimeEntriesImporter; + $importer->init($organization); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $this->checkTimeEntries($testScenario, true); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(0, $report->tagsCreated); + $this->assertSame(0, $report->tasksCreated); + $this->assertSame(0, $report->usersCreated); + $this->assertSame(0, $report->projectsCreated); + $this->assertSame(0, $report->clientsCreated); + } +} diff --git a/tests/Unit/Service/Import/Importers/HarvestClientsImporterTest.php b/tests/Unit/Service/Import/Importers/HarvestClientsImporterTest.php new file mode 100644 index 00000000..f8bdc192 --- /dev/null +++ b/tests/Unit/Service/Import/Importers/HarvestClientsImporterTest.php @@ -0,0 +1,43 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new HarvestClientsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('harvest_clients_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $clients = Client::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $clients); + $client1 = $clients->where('name', 'Example Client')->first(); + $this->assertNotNull($client1); + // Client name in Harvest: \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ + $client2 = $clients->where('name', '\\\\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first(); + $this->assertNotNull($client2); + } +} diff --git a/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php b/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php new file mode 100644 index 00000000..ae513768 --- /dev/null +++ b/tests/Unit/Service/Import/Importers/HarvestProjectsImporterTest.php @@ -0,0 +1,61 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new HarvestProjectsImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + + // Assert + $clients = Client::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $clients); + /** @var Client|null $client1 */ + $client1 = $clients->where('name', 'Example Client')->first(); + $this->assertNotNull($client1); + // Client name in Harvest: \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ + /** @var Client|null $client2 */ + $client2 = $clients->where('name', '\\\\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first(); + $this->assertNotNull($client2); + + $projects = Project::query()->whereBelongsTo($organization, 'organization')->get(); + $this->assertCount(2, $projects); + /** @var Project|null $project1 */ + $project1 = $projects->where('name', 'Example Project')->first(); + $this->assertNotNull($project1); + $this->assertSame($client1->getKey(), $project1->client_id); + $this->assertSame(50 * 60 * 60, $project1->estimated_time); // 50h + $this->assertSame(true, $project1->is_billable); + /** @var Project|null $project2 */ + $project2 = $projects->where('name', '\\\\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first(); + $this->assertNotNull($project2); + $this->assertSame($client2->getKey(), $project2->client_id); + $this->assertSame(null, $project2->estimated_time); + $this->assertSame(false, $project2->is_billable); + } +} diff --git a/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php new file mode 100644 index 00000000..0b1c58e6 --- /dev/null +++ b/tests/Unit/Service/Import/Importers/HarvestTimeEntriesImporterTest.php @@ -0,0 +1,110 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new HarvestTimeEntriesImporter; + $importer->init($organization); + $data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv'); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $users = User::all(); + $this->assertCount(2, $users); + $user1 = $users->firstWhere('name', 'Peter Tester'); + $this->assertNotNull($user1); + $this->assertSame(null, $user1->password); + $this->assertSame('Peter Tester', $user1->name); + $this->assertSame('peter.tester@solidtime-import.test', $user1->email); + $members = Member::all(); + $this->assertCount(1, $members); + $member1 = $members->firstWhere('user_id', $user1->getKey()); + $this->assertNotNull($member1); + $this->assertSame(Role::Placeholder->value, $member1->role); + $clients = Client::all(); + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $this->assertNull($client1->archived_at); + $projects = Project::with(['members'])->get(); + $this->assertCount(2, $projects); + /** @var Project|null $project1 */ + $project1 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project1); + $this->assertNull($project1->client_id); + /** @var Project|null $project2 */ + $project2 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project2); + $this->assertSame($client1->getKey(), $project2->client_id); + $project3 = null; + // Project without Client + $this->assertSame(false, $project1->is_public); + // Project for Big Company + $this->assertSame(false, $project2->is_public); + $tasks = Task::all(); + $this->assertCount(1, $tasks); + $task1 = $tasks->firstWhere('name', 'Task 1'); + $this->assertNotNull($task1); + $this->assertNull($task1->done_at); + $this->assertSame($project2->getKey(), $task1->project_id); + $tags = Tag::all(); + $this->assertCount(0, $tags); + + $timeEntries = TimeEntry::all(); + $this->assertCount(2, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-03 23:00:00', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 19:00:00', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertTrue($timeEntry1->is_imported); + $this->assertSame([], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-03 23:00:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-03 23:00:36', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertTrue($timeEntry2->is_imported); + $this->assertSame([], $timeEntry2->tags); + + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(0, $report->tagsCreated); + $this->assertSame(1, $report->tasksCreated); + $this->assertSame(1, $report->usersCreated); + $this->assertSame(2, $report->projectsCreated); + $this->assertSame(1, $report->clientsCreated); + } +} diff --git a/tests/Unit/Service/Import/Importers/ImporterProviderTest.php b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php index ed5e3220..86d74d46 100644 --- a/tests/Unit/Service/Import/Importers/ImporterProviderTest.php +++ b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php @@ -42,6 +42,11 @@ class ImporterProviderTest extends TestCase 'clockify_time_entries', 'clockify_projects', 'solidtime', + 'harvest_projects', + 'harvest_time_entries', + 'harvest_clients', + 'generic_projects', + 'generic_time_entries', ], $keys); } }