EscolaLMS/Courses-Import-Export

View on GitHub
src/Services/ExportImportService.php

Summary

Maintainability
C
1 day
Test Coverage
A
95%
<?php

namespace EscolaLms\CoursesImportExport\Services;

use EscolaLms\Categories\Models\Category;
use EscolaLms\Categories\Repositories\Contracts\CategoriesRepositoryContract;
use EscolaLms\Courses\Http\Requests\CreateTopicAPIRequest;
use EscolaLms\Courses\Models\Lesson;
use EscolaLms\Courses\Models\Topic;
use EscolaLms\Courses\Repositories\Contracts\CourseRepositoryContract;
use EscolaLms\Courses\Repositories\Contracts\LessonRepositoryContract;
use EscolaLms\Courses\Repositories\Contracts\TopicRepositoryContract;
use EscolaLms\Courses\Repositories\Contracts\TopicResourceRepositoryContract;
use EscolaLms\CoursesImportExport\Http\Resources\CourseExportResource;
use EscolaLms\CoursesImportExport\Models\Course;
use EscolaLms\CoursesImportExport\Services\Contracts\ExportImportServiceContract;
use EscolaLms\CoursesImportExport\Strategies\Contract\TopicImportStrategy;
use EscolaLms\CoursesImportExport\Strategies\RichTextTopicTypeStrategy;
use EscolaLms\CoursesImportExport\Strategies\ScormScoTopicTypeStrategy;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\File as HttpFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use ZanySoft\Zip\Facades\Zip;
use ZipArchive;

class ExportImportService implements ExportImportServiceContract
{
    private CourseRepositoryContract $courseRepository;
    private LessonRepositoryContract $lessonRepository;
    private TopicRepositoryContract $topicRepository;
    private TopicResourceRepositoryContract $topicResourceRepo;
    private CategoriesRepositoryContract $categoriesRepository;

    private string $dirFullPath;

    private array $topicTypes = [
        'EscolaLms\\TopicTypes\\Models\\TopicContent\\ScormSco',
        'EscolaLms\\TopicTypes\\Models\\TopicContent\\H5P',
    ];

    public function __construct(
        CourseRepositoryContract        $courseRepository,
        LessonRepositoryContract        $lessonRepository,
        TopicRepositoryContract         $topicRepository,
        TopicResourceRepositoryContract $topicResourceRepository,
        CategoriesRepositoryContract    $categoriesRepository
    )
    {
        $this->courseRepository = $courseRepository;
        $this->lessonRepository = $lessonRepository;
        $this->topicRepository = $topicRepository;
        $this->topicResourceRepo = $topicResourceRepository;
        $this->categoriesRepository = $categoriesRepository;
    }

    private function fixAllPathsBeforeZipping(int $courseId): void
    {
        $course = Course::findOrFail($courseId);
        $course->fixAssetPaths();
    }

    private function createExportJson(\EscolaLms\Courses\Models\Course $course, $dirName): void
    {
        $program = CourseExportResource::make($course);

        $json = json_encode($program);

        Storage::put($dirName . '/content/content.json', $json);
    }

    private function copyCourseFilesToExportFolder(int $courseId): string
    {
        $dirName = 'exports/courses/' . $courseId;

        if (Storage::exists($dirName)) {
            Storage::deleteDirectory($dirName);
        }

        Storage::makeDirectory($dirName);

        $dirFrom = 'course/' . $courseId;
        $dirTo = 'exports/courses/' . $courseId . '/content';
        $fromFiles = Storage::allFiles($dirFrom);

        foreach ($fromFiles as $fromFile) {
            $toFile = str_replace($dirFrom, $dirTo, $fromFile);
            Storage::copy($fromFile, $toFile);
        }

        return $dirName;
    }

    private function createZipFromFolder($dirName, bool $withUrl = true): string
    {
        $filename = uniqid(rand(), true) . '.zip';
        $zip = new ZipArchive();

        if (!Storage::disk('local')->exists($dirName)) {
            Storage::disk('local')->makeDirectory($dirName);
        }

        $zipFile = Storage::disk('local')->path($dirName . DIRECTORY_SEPARATOR . $filename);

        if (!$zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
            throw new \Exception("Zip file could not be created: " . $zip->getStatusString());
        }

        $files = Storage::allFiles($dirName . '/content');
        foreach ($files as $file) {
            $content = Storage::get($file);
            $dir = str_replace($dirName . '/content', '', $file);

            if (!$zip->addFromString($dir, $content)) {
                throw new \Exception("File [`{$file}`] could not be added to the zip file: " . $zip->getStatusString());
            }
        }

        $zip->close();

        Storage::deleteDirectory($dirName . '/content');

        return $withUrl ? $this->getExportUrl($dirName, $filename) : $this->getExportDir($dirName, $filename);
    }

    private function getExportUrl(string $dirName, string $fileName): string
    {
        return Storage::disk('local')->url($dirName . DIRECTORY_SEPARATOR . $fileName);
    }

    private function getExportDir(string $dirName, string $fileName): string
    {
        return Storage::disk('local')->path($dirName . DIRECTORY_SEPARATOR . $fileName);
    }

    public function export($courseId, bool $withUrl = true): string
    {
        $this->fixAllPathsBeforeZipping($courseId);
        $dirName = $this->copyCourseFilesToExportFolder($courseId);

        $course = \EscolaLms\Courses\Models\Course::with(['lessons.topics.topicable', 'scormSco', 'categories', 'tags'])
            ->findOrFail($courseId);
        $this->createExportJson($course, $dirName);

        return $this->createZipFromFolder($dirName, $withUrl);
    }

    public function import(UploadedFile $zipFile): Model
    {
        $dirPath = 'imports' . DIRECTORY_SEPARATOR . 'courses' . DIRECTORY_SEPARATOR . uniqid(rand(), true);
        $dirFullPath = $this->dirFullPath = $this->extractZipFile($zipFile, $dirPath);
        try {
            $content = json_decode(File::get($dirFullPath . DIRECTORY_SEPARATOR . 'content.json'), true);
            $course = DB::transaction(function () use ($content, $dirFullPath) {
                return $this->createCourseFromImport($content, $dirFullPath);
            });
        } catch (Exception $e) {
            $message = '[' . self::class . '] ' . $e->getMessage();
            Log::error($message);
            throw new Exception($message);
        } finally {
            Storage::deleteDirectory($dirPath);
        }

        return $course->load('categories', 'tags', 'lessons', 'lessons.topics', 'lessons.topics.topicable');
    }

    private function extractZipFile(UploadedFile $zipFile, string $dirPath): string
    {
        if (Storage::disk('local')->exists($dirPath)) {
            Storage::disk('local')->deleteDirectory($dirPath);
        }

        $dirFullPath = Storage::disk('local')->path($dirPath);
        Zip::open($zipFile)->extract($dirFullPath);

        return $dirFullPath;
    }

    private function createCategories(array $categories): array
    {
        $ids = [];
        foreach ($categories as $category) {
            $model = $this->createCategory($category);
            $ids[] = $model->getKey();
        }

        return Arr::flatten($ids);
    }

    private function createCategory(array $category): Model
    {
        $foundCategory = Category::whereSlug($category['slug'])->first();
        if ($foundCategory) {
            return $foundCategory;
        }

        $filePath = $this->dirFullPath . DIRECTORY_SEPARATOR . $category['icon'];
        if (File::exists($filePath)) {
            $file = new HttpFile($filePath);
            $category['icon'] = Storage::putFile('categories', $file, 'public');
        }

        if ($category['parent']) {
            $parent = $this->createCategory($category['parent']);
            $category['parent_id'] = $parent->getKey();
        }

        unset($category['parent']);
        return $this->categoriesRepository->create($category);
    }

    private function createCourseFromImport(array $courseData, string $dirFullPath): Model
    {
        unset($courseData['author_id']);

        $courseData = $this->addFilesToArrayBasedOnPath($courseData, $dirFullPath);

        if (isset($courseData['scorm_sco'])) {
            $strategy = new ScormScoTopicTypeStrategy();
            $courseData['scorm_sco_id'] = $strategy->make($dirFullPath, $courseData['scorm_sco']);
        }

        $courseValidator = Validator::make($courseData, Course::rules());
        /** @var Course $course */
        $course = $this->courseRepository->create($courseValidator->validate());

        // create categories
        if (isset($courseData['categories'])) {
            $categoryIds = $this->createCategories($courseData['categories']);
            $course->categories()->sync($categoryIds);
        }

        // create tags
        if (isset($courseData['tags'])) {
            $tags = array_map(function ($tag) {
                return ['title' => $tag['title']];
            }, $courseData['tags']);
            $course->tags()->createMany($tags);
        }

        if (isset($courseData['lessons']) && is_array($courseData['lessons'])) {
            foreach ($courseData['lessons'] as $lesson) {
                $lesson['course_id'] = $course->getKey();
                $this->createLessonFromImport($lesson, $dirFullPath);
            }
        }

        return $course;
    }

    private function createLessonFromImport(array $lessonData, string $dirFullPath): Model
    {
        $lessonValidator = Validator::make($lessonData, Lesson::$rules);
        /** @var Lesson $lesson */
        $lesson = $this->lessonRepository->create($lessonValidator->validate());

        if (isset($lessonData['topics']) && is_array($lessonData['topics'])) {
            foreach ($lessonData['topics'] as $topic) {
                $topic['lesson_id'] = $lesson->getKey();
                $this->createTopicFromImport(array_filter($topic), $dirFullPath, $lessonData['course_id']);
            }
        }

        if (isset($lessonData['lessons']) && is_array($lessonData['lessons'])) {
            foreach ($lessonData['lessons'] as $child) {
                $child['parent_lesson_id'] = $lesson->getKey();
                $child['course_id'] = $lesson->course_id;
                $this->createLessonFromImport($child, $dirFullPath);
            }
        }

        return $lesson;
    }

    private function createTopicFromImport(array $topicData, string $dirFullPath, int $courseId): ?Model
    {
        if (!isset($topicData['topicable_type'])) {
            return null;
        }

        $topicData = array_merge($topicData, $topicData['topicable'] ?? []);
        unset($topicData['topicable']);

        if (in_array($topicData['topicable_type'], $this->topicTypes)) {
            $strategy = $this->getTopicTypeImportStrategy($topicData['topicable_type']);
            $topicData['value'] = $strategy->make($dirFullPath, $topicData);
        }

        $request = new CreateTopicAPIRequest($topicData);
        $request->setValidator(Validator::make($topicData, $request->rules()));

        foreach (['value', 'poster'] as $key) {
            if (isset($topicData[$key]) && File::exists($dirFullPath . DIRECTORY_SEPARATOR . $topicData[$key])) {
                $request->files->add([
                    $key => new UploadedFile(
                        $dirFullPath . DIRECTORY_SEPARATOR . $topicData[$key],
                        $topicData[$key],
                        null,
                        null,
                        true
                    )
                ]);
            }
        }

        $topic = $this->topicRepository->createFromRequest($request);

        if ($topicData['topicable_type'] === 'EscolaLms\\TopicTypes\\Models\\TopicContent\\RichText'
            && array_key_exists('asset_folder', $topicData)) {
            $this->handleRichTextTopicImport($topic, $dirFullPath, $topicData, $courseId);
        }
        if (isset($topicData['resources']) && is_array($topicData['resources'])) {
            $this->createTopicResources($topicData['resources'], $topic, $dirFullPath);
        }
        return $topic;
    }

    private function createTopicResources(array $resources, Topic $topic, string $dirFullPath): array
    {
        $createdResources = [];
        foreach ($resources as $resource) {
            if (isset($resource['path'])
                && isset($resource['name'])
                && File::exists($dirFullPath . DIRECTORY_SEPARATOR . $resource['path'])
            ) {
                $createdResources[] = $this->topicResourceRepo->storeUploadedResourceForTopic(
                    $topic,
                    new UploadedFile($dirFullPath . DIRECTORY_SEPARATOR . $resource['path'], $resource['name'])
                );
            }
        }

        return $createdResources;
    }

    private function addFilesToArrayBasedOnPath(array $data, string $dirFullPath): array
    {
        foreach ($data as $key => $value) {
            if (Str::endsWith($key, '_path')
                && File::exists($dirFullPath . DIRECTORY_SEPARATOR . $value)
                && !File::isDirectory($dirFullPath . DIRECTORY_SEPARATOR . $value)
            ) {
                $fileKey = Str::before($key, '_path');
                $data[$fileKey] = new UploadedFile(
                    $dirFullPath . DIRECTORY_SEPARATOR . $value,
                    $value,
                    null,
                    null,
                    true
                );
                unset($data[$key]);
            }
        }

        return $data;
    }

    private function getTopicTypeImportStrategy(string $topicType): TopicImportStrategy
    {
        $strategy = 'EscolaLms\\CoursesImportExport\\Strategies\\' . substr(strrchr($topicType, "\\"), 1) . 'TopicTypeStrategy';
        return new $strategy();
    }

    private function handleRichTextTopicImport(Topic $topic, string $zipFilesPath, array $data, $courseId): void
    {
        $destinationPath = $this->getTopicAssetsDestinationPath($topic, $courseId);

        $this->updateRichTextTopicableValue($topic, $destinationPath);
        $this->importRichTextTopicAssets($zipFilesPath, $destinationPath, $data['asset_folder']);
    }

    private function importRichTextTopicAssets(string $filesPath, string $destinationPath, string $assetFolder): void
    {
        $topicAssetsPath =
            $filesPath
            . DIRECTORY_SEPARATOR
            . 'topic'
            . DIRECTORY_SEPARATOR
            . $assetFolder
            . DIRECTORY_SEPARATOR;

        if (!is_dir($topicAssetsPath)) {
            return;
        }

        $files = array_diff(scandir($topicAssetsPath), array('.', '..'));
        foreach ($files as $file) {
            $fileFullPath = $topicAssetsPath . DIRECTORY_SEPARATOR . $file;
            if (!is_file($fileFullPath)) {
                continue;
            }

            $fileToStore = file_get_contents($fileFullPath);
            Storage::put($destinationPath . basename($file), $fileToStore);
        }
    }

    private function updateRichTextTopicableValue(Topic $topic, $path): void
    {
        $topicable = $topic->topicable;
        //api images
        $topicable->value = preg_replace_callback(
            '/\!\[\]\((course\/.*?\.\w+)\)/',
            function ($matches) use ($path) {
                return '![](' . url('api/images/img') . '?path=' . $path . basename($matches[1]) . '&w=1000)';
            },
            $topicable->value
        );
        //other assets
        $topicable->value = preg_replace_callback(
            '/!\[(course.*?)\]\(\1\)/',
            function ($matches) use ($path) {
                return '![' . url('storage/' . $path) . '/' . basename($matches[1]) . '](' . url('storage/' . $path ) . '/' . basename($matches[1]) . ')';
            },
            $topicable->value
        );
        $topicable->save();
    }

    private function getTopicAssetsDestinationPath(Topic $topic, int $courseId): string
    {
        return "course/$courseId/topic/{$topic->getKey()}/";
    }
}