diff --git a/composer.json b/composer.json index b116278..2871bbe 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "php": "^5.5.9 || ^7.0", "illuminate/support": "5.1.* || 5.2.* || 5.3.*", "illuminate/console": "5.1.* || 5.2.* || 5.3.*", - "illuminate/filesystem": "5.1.* || 5.2.* || 5.3.*" + "illuminate/filesystem": "5.1.* || 5.2.* || 5.3.*", + "phpoffice/phpexcel": "^1.8" }, "require-dev": { "phpunit/phpunit" : "^4.8 || ^5.0", diff --git a/config/langman.php b/config/langman.php index f5cee0c..55864dc 100644 --- a/config/langman.php +++ b/config/langman.php @@ -12,4 +12,16 @@ */ 'path' => realpath(base_path('resources/lang')), + + /* + * -------------------------------------------------------------------------- + * Path to the directory where Excel files should be exported + * -------------------------------------------------------------------------- + * + * This option determines where to put the exported Excel files. This directory + * must be writable by server. By default storage/langman directory + * will be used. + */ + + 'exports_path' => storage_path('langman-exports'), ]; diff --git a/src/Commands/ExportCommand.php b/src/Commands/ExportCommand.php new file mode 100644 index 0000000..5334660 --- /dev/null +++ b/src/Commands/ExportCommand.php @@ -0,0 +1,211 @@ +manager = $manager; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $path = $this->generateExcelFile($this->option('path')); + + $this->info('Excel file successfully generated in ' . $path .'.'); + } + + /** + * Generates an Excel file from translations files and putting it in + * the given path. + * + * @param string|null $path + * @return string + */ + protected function generateExcelFile($path = null) + { + $filePath = $this->getFilePath($path); + + $userSelectedFiles = $this->filterFilesForExport(); + + $this->writeContentsToFile($this->getHeaderContent(), $this->getBodyContent($userSelectedFiles), $filePath); + + return $filePath; + } + + /** + * Filter files based on user options + * + * @return array|string + */ + protected function filterFilesForExport() + { + if (! is_null($this->option('only')) && ! is_null($this->option('exclude'))) { + $this->error('You cannot combine --only and --exclude options. Please use one of them.'); + return; + } + + $onlyFiles = []; + + if (! is_null($this->option('only'))) { + $onlyFiles = array_keys($this->manager->files(explode(',', $this->option('only')))); + } + + if (! is_null($this->option('exclude'))) { + $excludeFiles = explode(',', $this->option('exclude')); + $onlyFiles = array_diff(array_keys($this->manager->files()), $excludeFiles); + } + + return $onlyFiles; + } + + /** + * Creating a Excel file from the given content. + * + * @param $header + * @param $content + * @param $filepath + * @return void + */ + protected function writeContentsToFile($header, $content, $filepath) + { + array_unshift($content, $header); + + $excelObj = new \PHPExcel(); + $excelObj->getProperties() + ->setTitle('Laravel Langman Exported Language File') + ->setSubject('Laravel Langman Exported Language File') + ->setCreator('Laravel Langman'); + + $rowNumber = 1; + foreach ($content as $record) { + $excelObj->getActiveSheet()->fromArray($record, '', 'A'. $rowNumber); + $rowNumber++; + } + + $writer = \PHPExcel_IOFactory::createWriter($excelObj, 'Excel2007'); + $writer->save($filepath); + } + + /** + * Get the file path for the CSV file. + * + * @param $path + * @return string + */ + protected function getFilePath($path) + { + $exportDir = is_null($path) ? config('langman.exports_path') : base_path($path); + + if (! file_exists($exportDir)) { + mkdir($exportDir, 0755); + } + + return $exportDir . '/' . $this->getDatePrefix() . '_langman.xlsx'; + } + + /* + * Get the date prefix for the Excel file. + * + * @return string + */ + protected function getDatePrefix() + { + return date('Y_m_d_His'); + } + + /** + * Get the Excel header content. + * + * @return array + */ + protected function getHeaderContent() + { + return array_merge(['Language File', 'Key'], $this->manager->languages()); + } + + /** + * Get the Excel body content from language files. + * + * @return array + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + protected function getBodyContent($files) + { + $langFiles = $this->manager->getFilesContentGroupedByFilenameAndKey($files); + $content = []; + + foreach ($langFiles as $langFileName => $langProps) { + foreach ($langProps as $key => $translations) { + $row = [$langFileName, $key]; + + foreach ($this->manager->languages() as $language) { + // If an UndefinedIndex Exception was thrown, it means that $key + // does not have translation in the $language, so we will + // handle it by just assigning it to an empty string + try { + // If a translation is just an array (empty), it means that it doesn't have + // any translation so we will skip it by assigning it an empty string. + $row[] = is_array($translations[$language]) ? '' : $translations[$language]; + } catch (\ErrorException $ex) { + $row[] = ''; + } + } + + $content[] = $row; + } + } + + return $content; + } +} diff --git a/src/Commands/ImportCommand.php b/src/Commands/ImportCommand.php new file mode 100644 index 0000000..04456d5 --- /dev/null +++ b/src/Commands/ImportCommand.php @@ -0,0 +1,193 @@ +manager = $manager; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $excelFileContents = $this->getExcelFileContents(); + + if (is_null($excelFileContents)) { + $this->error('No such file found.'); + return; + } + + $filesToBeChanged = join("\n\t", array_keys(array_first($excelFileContents))); + + if (! $this->confirm("The following files will be overridden: \n\t" . $filesToBeChanged . "\nAre you sure?")) { + $this->line('No files changed. Closing.'); + return; + } + + $this->writeToLangFiles($excelFileContents); + + $this->info('Import complete.'); + } + + /** + * Gets the user chosen excel file content. + * + * @return array + */ + protected function getExcelFileContents() + { + $filePath = $this->getPathFromUserArgs(); + + if (! file_exists($filePath)) { + return null; + } + + return $this->readExcelFileContents($filePath); + } + + /** + * Gets file path from user passed argument and option. + * + * @return string + */ + protected function getPathFromUserArgs() + { + if (! is_null($fileName = $this->argument('filename'))) { + return config('langman.exports_path') . DIRECTORY_SEPARATOR . $fileName; + } + + if (is_null($this->option('path'))) { + $this->error('No path specified.'); + exit(); + } + + return base_path($this->option('path')); + } + + /** + * Reads the actual Excel file from the specified path and returns + * conent in an array grouped by directory and file names. + * + * @param string $filePath + * @return array + */ + protected function readExcelFileContents($filePath) + { + $excelObj = \PHPExcel_IOFactory::load($filePath); + $rows = $excelObj->getActiveSheet()->toArray('', true, true, true); + + $headerRow = array_shift($rows); + $langDirs = $this->extractLangages($headerRow); + + $groupedByDirName = []; + + foreach ($langDirs as $index => $langDir) { + $groupedByFileNames = []; + $trans = []; + $langDirName = ''; + + foreach ($rows as $langRow) { + // Override PHPExcel's column based key array into regular numbered key array + $langRow = array_values($langRow); + + if ($langDirName != '' && $langDirName != $langRow[0]) { + $trans = []; + } + + $langDirName = $langRow[0]; + $langKey = $langRow[1]; + + $langIndex = $index+2; + $trans[$langKey] = $langRow[$langIndex]; + + $groupedByFileNames[$langDirName] = $trans; + } + + $groupedByDirName[$langDir] = $groupedByFileNames; + } + + return $groupedByDirName; + } + + /** + * Extract available language locales from file rows. + * + * @param array $rows + * @return array + */ + protected function extractLangages($header) + { + return array_values(array_slice($header, 2)); + } + + /** + * Write the content to language files. + * + * @param array $data + * @return void + */ + protected function writeToLangFiles($data) + { + foreach ($data as $langDirName => $langDirContent) { + $langDirPath = config('langman.path') . DIRECTORY_SEPARATOR . $langDirName; + + if (! file_exists($langDirPath)) { + mkdir($langDirPath); + } + + foreach ($langDirContent as $fileName => $fileContent) { + $fileContent = Arr::unDot($fileContent); + $fileContent = "disk->allFiles($this->path)); @@ -81,6 +81,12 @@ public function files() if (! Str::contains($this->path, 'vendor')) { $filesByFile = $this->neglectVendorFiles($filesByFile); } + + $fileNames = (array) $fileNames; + + if (! empty($fileNames)) { + return array_intersect_key($filesByFile, array_combine($fileNames, $fileNames)); + } return $filesByFile; } @@ -386,4 +392,30 @@ public function getKeysExistingInALanguageButNotTheOther($values) return $missing; } + + /** + * Get all language files content as an array grouped by their filename + * and their key. + * + * @return array + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + public function getFilesContentGroupedByFilenameAndKey($selectedFiles = []) + { + $files = $this->files((array) $selectedFiles); + + $allLangs = []; + $filesContent = []; + + foreach ($files as $langFileName => $langFilePath) { + foreach ($langFilePath as $languageKey => $file) { + foreach ($filesContent[$languageKey] = Arr::dot($this->getFileContent($file)) as $key => $value) { + $allLangs[$langFileName][$key]['key'] = $key; + $allLangs[$langFileName][$key][$languageKey] = $value; + } + } + } + + return $allLangs; + } } diff --git a/src/Support/Arr.php b/src/Support/Arr.php new file mode 100644 index 0000000..a979d71 --- /dev/null +++ b/src/Support/Arr.php @@ -0,0 +1,38 @@ + $value) { + if (count($dottedKeys = explode('.', $key, 2)) > 1) { + $results[$dottedKeys[0]][$dottedKeys[1]] = $value; + } else { + $results[$key] = $value; + } + } + + if ($recursively) { + foreach ($results as $key => $value) { + if (is_array($value) && ! empty($value)) { + $results[$key] = self::undot($value, $recursively); + } + } + } + + return $results; + } +} diff --git a/tests/ExportCommandTest.php b/tests/ExportCommandTest.php new file mode 100644 index 0000000..1a796ab --- /dev/null +++ b/tests/ExportCommandTest.php @@ -0,0 +1,167 @@ +createTempFiles([ + 'en' => ['user' => " 'Address', 'contact' => ['cellphone' => 'Mobile']];"], + 'es' => ['user' => " 'Dirección', 'contact' => ['cellphone' => 'Movil']];"], + ]); + + $this->artisan('langman:export'); + + $exportedFilePath = $this->getExportedFilePath(); + + $excelRows = $this->getExcelFileContents($exportedFilePath); + + $this->assertFileExists($exportedFilePath); + $this->assertExcelRowEquals($excelRows[1], ['Language File', 'Key', 'en', 'es']); + $this->assertExcelRowEquals($excelRows[2], ['user', 'address', 'Address', 'Dirección']); + $this->assertExcelRowEquals($excelRows[3], ['user', 'contact.cellphone', 'Mobile', 'Movil']); + } + + public function testCommandExportOnlyExportsSpecifiedFiles() + { + $this->createTempFiles([ + 'en' => ['user' => " 'Address'];", 'course' => " 'Start Date'];",], + 'es' => ['user' => " 'Dirección'];", 'course' => " 'Fecha De Inicio'];"], + ]); + + $this->artisan('langman:export', ['--only' => 'course']); + + $exportedFilePath = $this->getExportedFilePath(); + + $excelRows = $this->getExcelFileContents($exportedFilePath); + + $this->assertFileExists($exportedFilePath); + + // Always remember that first row is the header row, + // it does not contain any language file content + $this->assertCount(2, $excelRows); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['course', 'start_date', 'Start Date', 'Fecha De Inicio'])); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['user', 'address', 'Address', 'Dirección'])); + } + + public function testOptionOnlySupportsCommaSeparatedNames() + { + $this->createTempFiles([ + 'en' => [ + 'user' => " 'Address'];", + 'course' => " 'Start Date'];", + 'product' => " 'Name', 'description' => 'Description'];" + ], + 'es' => [ + 'user' => " 'Dirección'];", + 'course' => " 'Fecha De Inicio'];", + 'product' => " 'Nombre', 'description' => 'Descripción'];" + ], + ]); + + $this->artisan('langman:export', ['--only' => 'user,product']); + + $exportedFilePath = $this->getExportedFilePath(); + + $excelRows = $this->getExcelFileContents($exportedFilePath); + + $this->assertFileExists($exportedFilePath); + $this->assertCount(4, $excelRows); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['user', 'address', 'Address', 'Dirección'])); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['product', 'name', 'Name', 'Nombre'])); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['product', 'description', 'Description', 'Descripción'])); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['course', 'start_date', 'Start Date', 'Fecha De Inicio'])); + } + + public function testCommandExportExcludesSpecifiedFiles() + { + $this->createTempFiles([ + 'en' => ['user' => " 'Address'];", 'course' => " 'Start Date'];",], + 'es' => ['user' => " 'Dirección'];", 'course' => " 'Fecha De Inicio'];"], + ]); + + $this->artisan('langman:export', ['--exclude' => 'user']); + + $exportedFilePath = $this->getExportedFilePath(); + + $excelRows = $this->getExcelFileContents($exportedFilePath); + + $this->assertFileExists($exportedFilePath); + + $this->assertCount(2, $excelRows); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['course', 'start_date', 'Start Date', 'Fecha De Inicio'])); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['user', 'address', 'Address', 'Dirección'])); + } + + public function testOptionExcludeSupportsCommaSeparatedNames() + { + $this->createTempFiles([ + 'en' => [ + 'user' => " 'Address'];", + 'course' => " 'Start Date'];", + 'product' => " 'Name', 'description' => 'Description'];" + ], + 'es' => [ + 'user' => " 'Dirección'];", + 'course' => " 'Fecha De Inicio'];", + 'product' => " 'Nombre', 'description' => 'Descripción'];" + ], + ]); + + $this->artisan('langman:export', ['--exclude' => 'user,product']); + + $exportedFilePath = $this->getExportedFilePath(); + + $excelRows = $this->getExcelFileContents($exportedFilePath); + + $this->assertFileExists($exportedFilePath); + $this->assertCount(2, $excelRows); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['user', 'address', 'Address', 'Dirección'])); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['product', 'name', 'Name', 'Nombre'])); + $this->assertFalse($this->excelContentContainsRow($excelRows, ['product', 'description', 'Description', 'Descripción'])); + $this->assertTrue($this->excelContentContainsRow($excelRows, ['course', 'start_date', 'Start Date', 'Fecha De Inicio'])); + } + + public function testExcludeAndOnlyOptionCannotBeCombined() + { + $this->artisan('langman:export', ['--exclude' => 'somefile', '--only' => 'someanotherfile']); + + $this->assertContains('You cannot combine --only and --exclude options.', $this->consoleOutput()); + } + + protected function getExcelFileContents($exportedFilePath) + { + $excelObj = \PHPExcel_IOFactory::load($exportedFilePath); + $rows = $excelObj->getActiveSheet()->toArray('', true, true, true); + + return $rows; + } + + protected function assertExcelRowEquals($row, $content) + { + $columns = array_values($row); + + $this->assertEquals(count($columns), count($content)); + + foreach ($columns as $index => $column) { + $this->assertEquals($column, $content[$index]); + } + } + + protected function getExportedFilePath() + { + return $this->app['config']['langman.exports_path'] . '/' . date('Y_m_d_His') . '_langman.xlsx'; + } + + protected function excelContentContainsRow($excelRows, $row) + { + foreach ($excelRows as $excelRow) { + $excelRow = array_values($excelRow); + + if ($excelRow == $row) { + return true; + } + } + + return false; + } +} diff --git a/tests/ImportCommandTest.php b/tests/ImportCommandTest.php new file mode 100644 index 0000000..6ed9d83 --- /dev/null +++ b/tests/ImportCommandTest.php @@ -0,0 +1,80 @@ +app[Manager::class]; + + $path = $this->createTempExcelFile([ + ['Language File', 'Key', 'en', 'es'], + ['course', 'start_date', 'Start Date', 'Fecha De Inicio'], + ['user', 'address', 'Address', 'Dirección'], + ['user', 'education.major', 'Major', 'Importante'], + ['user', 'education.minor', 'Minor', 'Menor'] + ]); + + $filename = basename($path); + + $filesToBeChanged = join("\n\t", ['course', 'user']); + + $command = m::mock('\Themsaid\Langman\Commands\ImportCommand[confirm]', [$manager]); + $command->shouldReceive('confirm')->once() + ->with("The following files will be overridden: \n\t" . $filesToBeChanged . "\nAre you sure?")->andReturn(true); + + $this->app['artisan']->add($command); + $this->artisan('langman:import', ['filename' => $filename]); + + $langPath = $this->app['config']['langman.path']; + + $userEnglish = include $langPath . '/en/user.php'; + $userSpanish = include $langPath . '/es/user.php'; + $courseEnglish = include $langPath . '/en/course.php'; + $courseSpanish = include $langPath . '/es/course.php'; + + $this->assertContains('Import complete', $this->consoleOutput()); + + // Assert user.php content + $this->assertEquals($userEnglish['address'], 'Address'); + $this->assertEquals($userSpanish['address'], 'Dirección'); + + // Assert dotted keys + $this->assertEquals($userEnglish['education']['major'], 'Major'); + $this->assertEquals($userEnglish['education']['minor'], 'Minor'); + $this->assertEquals($userSpanish['education']['major'], 'Importante'); + $this->assertEquals($userSpanish['education']['minor'], 'Menor'); + + // Assert course.php content + $this->assertEquals($courseEnglish['start_date'], 'Start Date'); + $this->assertEquals($courseSpanish['start_date'], 'Fecha De Inicio'); + } + + public function testCommandShowsErrorIfFileNotFound() + { + $this->artisan('langman:import', ['filename' => 'nofile.xlsx']); + + $this->assertContains('No such file found', $this->consoleOutput()); + } + + /** + * Create a temporary excel file. + * + * @param $contentArray + * @return string + */ + protected function createTempExcelFile($contentArray) + { + $excelObj = new PHPExcel(); + + $excelObj->getActiveSheet()->fromArray($contentArray, ''); + + $objWriter = PHPExcel_IOFactory::createWriter($excelObj, 'Excel2007'); + $filePath = $this->app['config']['langman.exports_path'] . '/translations.xlsx'; + $objWriter->save($filePath); + + return $filePath; + } +} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php index deac505..e0ed0ae 100644 --- a/tests/ManagerTest.php +++ b/tests/ManagerTest.php @@ -35,7 +35,15 @@ public function testFilesMethod() // ], ]; + $expectedUserOnly = [ + 'user' => [ + 'en' => __DIR__.DIRECTORY_SEPARATOR.'temp'.DIRECTORY_SEPARATOR.'en'.DIRECTORY_SEPARATOR.'user.php', + 'nl' => __DIR__.DIRECTORY_SEPARATOR.'temp'.DIRECTORY_SEPARATOR.'nl'.DIRECTORY_SEPARATOR.'user.php', + ] + ]; + $this->assertEquals($expected, $manager->files()); + $this->assertEquals($expectedUserOnly, $manager->files('user')); } public function testLanguagesMethod() @@ -268,4 +276,69 @@ public function testGetKeysExistingInALanguageButNotTheOther() $this->assertNotContains('user.address:en', $results); $this->assertNotContains('user.address:nl', $results); } + + public function testGetFilesContentGroupedByFilenameAndKeyMethod() + { + $manager = $this->app[\Themsaid\Langman\Manager::class]; + + $filesList = [ + 'en' => [ + 'user' => " ['not_found' => 'user not found'], 'edit-user' => 'Edit User'];", + 'category' => " ['cat-not-found' => 'category not found'], 'edit-cat' => 'Edit Category'];", + ], + 'fr' => [ + 'user' => " ['not_found' => 'french user not found'], 'edit-user' => 'French Edit User'];", + 'category' => " ['cat-not-found' => 'french category not found'], 'edit-cat' => 'French Edit Category'];", + ] + ]; + + $expected = [ + 'user' => [ + 'missing.not_found' => [ + 'key' => 'missing.not_found', + 'en' => 'user not found', + 'fr' => 'french user not found' + ], + 'edit-user' => [ + 'key' => 'edit-user', + 'en' => 'Edit User', + 'fr' => 'French Edit User' + ] + ], + 'category' => [ + 'category-missing.cat-not-found' => [ + 'key' => 'category-missing.cat-not-found', + 'en' => 'category not found', + 'fr' => 'french category not found' + ], + 'edit-cat' => [ + 'key' => 'edit-cat', + 'en' => 'Edit Category', + 'fr' => 'French Edit Category' + ] + ] + ]; + + $expectedUserOnly = [ + 'user' => [ + 'missing.not_found' => [ + 'key' => 'missing.not_found', + 'en' => 'user not found', + 'fr' => 'french user not found' + ], + 'edit-user' => [ + 'key' => 'edit-user', + 'en' => 'Edit User', + 'fr' => 'French Edit User' + ] + ] + ]; + + $this->createTempFiles($filesList); + + $this->assertEquals($expected, $manager->getFilesContentGroupedByFilenameAndKey()); + $this->assertEquals($expected, $manager->getFilesContentGroupedByFilenameAndKey([])); + $this->assertEquals($expectedUserOnly, $manager->getFilesContentGroupedByFilenameAndKey(['user'])); + $this->assertEquals($expectedUserOnly, $manager->getFilesContentGroupedByFilenameAndKey('user')); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9e94e0b..91db137 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -12,6 +12,7 @@ protected function getPackageProviders($app) protected function getEnvironmentSetUp($app) { $app['config']->set('langman.path', __DIR__.'/temp'); + $app['config']->set('langman.exports_path', __DIR__.'/temp_excel_path'); $app['config']->set('view.paths', [__DIR__.'/views_temp']); } @@ -20,6 +21,7 @@ public function setUp() parent::setUp(); exec('rm -rf '.__DIR__.'/temp/*'); + exec('rm -rf '.__DIR__.'/temp_excel_path/*'); } public function tearDown() @@ -27,6 +29,7 @@ public function tearDown() parent::tearDown(); exec('rm -rf '.__DIR__.'/temp/*'); + exec('rm -rf '.__DIR__.'/temp_excel_path/*'); $this->consoleOutput = ''; } diff --git a/tests/temp_excel_path/.gitignore b/tests/temp_excel_path/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/temp_excel_path/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore