Skip to content

Commit e1334ec

Browse files
authored
Merge pull request #3635 from codeeu/dev
Support Learn & Teach bulk import f
2 parents c5a05f6 + f3ee3b5 commit e1334ec

16 files changed

Lines changed: 1308 additions & 258 deletions

app/Http/Controllers/ResourcesImportController.php

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Imports\ResourcesImport;
66
use App\Imports\ResourcesPreviewImport;
7+
use App\Services\LearnTeachWorkbookParser;
78
use App\Services\ResourcesImportResult;
89
use App\Services\ResourcesUploadValidator;
910
use Illuminate\Http\RedirectResponse;
@@ -14,6 +15,7 @@
1415
use Illuminate\Support\Str;
1516
use Illuminate\View\View;
1617
use Maatwebsite\Excel\Facades\Excel;
18+
use ZipArchive;
1719

1820
class ResourcesImportController extends Controller
1921
{
@@ -38,7 +40,7 @@ public function verify(Request $request): RedirectResponse
3840
'file' => [
3941
'required',
4042
'file',
41-
'max:10240',
43+
'max:51200',
4244
function ($attribute, $value, $fail) {
4345
if ($value) {
4446
$ext = strtolower($value->getClientOriginalExtension());
@@ -52,10 +54,12 @@ function ($attribute, $value, $fail) {
5254
}
5355
},
5456
],
57+
'assets_zip' => ['nullable', 'file', 'mimes:zip', 'max:512000'],
5558
'focus' => ['nullable', 'boolean'],
5659
], [
5760
'file.required' => 'Please select a file to upload.',
58-
'file.max' => 'The file may not be greater than 10 MB.',
61+
'file.max' => 'The file may not be greater than 50 MB.',
62+
'assets_zip.max' => 'The assets ZIP may not be greater than 500 MB.',
5963
]);
6064

6165
$file = $request->file('file');
@@ -84,13 +88,28 @@ function ($attribute, $value, $fail) {
8488
$request->session()->forget([self::SESSION_FILE_PATH, self::SESSION_ROWS, self::SESSION_FOCUS]);
8589

8690
$path = $file->storeAs('temp', 'resources_import_'.time().'.'.$extension, $tempDisk);
91+
$absolutePath = Storage::disk($tempDisk)->path($path);
92+
$assetsDir = null;
93+
94+
if ($request->hasFile('assets_zip')) {
95+
try {
96+
$assetsDir = $this->extractAssetsZip($request->file('assets_zip'), $tempDisk);
97+
} catch (\Throwable $e) {
98+
Storage::disk($tempDisk)->delete($path);
99+
100+
return redirect()->route('admin.resources-import.index')
101+
->withErrors(['assets_zip' => 'Could not extract assets ZIP: '.$e->getMessage()])
102+
->withInput();
103+
}
104+
}
87105

88106
try {
89-
$import = new ResourcesPreviewImport;
90-
Excel::import($import, $path, $tempDisk);
91-
$rows = $import->data;
107+
$rows = $this->parseUploadedMetadata($absolutePath, $path, $extension, $tempDisk);
92108
} catch (\Throwable $e) {
93109
Storage::disk($tempDisk)->delete($path);
110+
if ($assetsDir) {
111+
Storage::disk($tempDisk)->deleteDirectory($assetsDir);
112+
}
94113

95114
return redirect()->route('admin.resources-import.index')
96115
->withErrors(['file' => 'Could not parse file: '.$e->getMessage()])
@@ -100,13 +119,19 @@ function ($attribute, $value, $fail) {
100119
$headerCheck = ResourcesUploadValidator::validateRequiredColumnsFromRows($rows);
101120
if (! $headerCheck['valid']) {
102121
Storage::disk($tempDisk)->delete($path);
122+
if ($assetsDir) {
123+
Storage::disk($tempDisk)->deleteDirectory($assetsDir);
124+
}
103125

104126
return redirect()->route('admin.resources-import.index')
105127
->withErrors(['file' => 'Missing required column(s): '.implode(', ', $headerCheck['missing']).'. Please add a header row with at least "name_of_the_resource".'])
106128
->withInput();
107129
}
108130
if (empty($rows)) {
109131
Storage::disk($tempDisk)->delete($path);
132+
if ($assetsDir) {
133+
Storage::disk($tempDisk)->deleteDirectory($assetsDir);
134+
}
110135

111136
return redirect()->route('admin.resources-import.index')
112137
->withErrors(['file' => 'The file has no data rows.'])
@@ -123,6 +148,7 @@ function ($attribute, $value, $fail) {
123148
'path' => $path,
124149
'disk' => $tempDisk,
125150
'focus' => $focus,
151+
'assets_dir' => $assetsDir,
126152
], now()->addHours(1));
127153
$request->session()->put('resources_import_token', $importToken);
128154

@@ -172,6 +198,7 @@ public function import(Request $request): RedirectResponse
172198
{
173199
$path = null;
174200
$focus = false;
201+
$assetsDir = null;
175202
$tempDisk = config('filesystems.resources_import_temp_disk', 'local');
176203

177204
$token = $request->input('import_token');
@@ -181,6 +208,7 @@ public function import(Request $request): RedirectResponse
181208
$path = $cached['path'];
182209
$tempDisk = $cached['disk'] ?? $tempDisk;
183210
$focus = (bool) ($cached['focus'] ?? false);
211+
$assetsDir = $cached['assets_dir'] ?? null;
184212
}
185213
}
186214

@@ -266,10 +294,15 @@ public function import(Request $request): RedirectResponse
266294

267295
try {
268296
$result = new ResourcesImportResult;
269-
$import = new ResourcesImport(null, null, $focus, $overrides, $result, $filenameMode, $batchTimestamp, $customSuffix);
297+
$imagesDir = $assetsDir ? Storage::disk($tempDisk)->path($assetsDir.'/images') : null;
298+
$pdfsDir = $assetsDir ? Storage::disk($tempDisk)->path($assetsDir.'/links') : null;
299+
$import = new ResourcesImport($imagesDir, $pdfsDir, $focus, $overrides, $result, $filenameMode, $batchTimestamp, $customSuffix);
270300
Excel::import($import, $path, $tempDisk);
271301

272302
Storage::disk($tempDisk)->delete($path);
303+
if ($assetsDir) {
304+
Storage::disk($tempDisk)->deleteDirectory($assetsDir);
305+
}
273306
$request->session()->forget([self::SESSION_FILE_PATH, self::SESSION_ROWS, self::SESSION_FOCUS, 'resources_import_token']);
274307
if (is_string($token = $request->input('import_token')) && $token !== '') {
275308
Cache::forget('resources_import_' . $token);
@@ -284,6 +317,9 @@ public function import(Request $request): RedirectResponse
284317
if (Storage::disk($tempDisk)->exists($path)) {
285318
Storage::disk($tempDisk)->delete($path);
286319
}
320+
if ($assetsDir) {
321+
Storage::disk($tempDisk)->deleteDirectory($assetsDir);
322+
}
287323
$request->session()->forget([self::SESSION_FILE_PATH, self::SESSION_ROWS, self::SESSION_FOCUS, 'resources_import_token']);
288324
if (is_string($token = $request->input('import_token')) && $token !== '') {
289325
Cache::forget('resources_import_' . $token);
@@ -314,4 +350,48 @@ public function report(Request $request): View|RedirectResponse
314350
'failures' => $failures ?? [],
315351
]);
316352
}
353+
354+
/**
355+
* @return array<int, array<string, mixed>>
356+
*/
357+
private function parseUploadedMetadata(string $absolutePath, string &$storedPath, string $extension, string $disk): array
358+
{
359+
if (in_array($extension, ['xlsx', 'xls'], true) && LearnTeachWorkbookParser::looksLikeLearnTeachWorkbook($absolutePath)) {
360+
$parser = new LearnTeachWorkbookParser;
361+
$rows = $parser->parse($absolutePath);
362+
$csvPath = 'temp/resources_import_'.time().'.csv';
363+
$parser->writeCsv($rows, Storage::disk($disk)->path($csvPath));
364+
Storage::disk($disk)->delete($storedPath);
365+
$storedPath = $csvPath;
366+
367+
return $rows;
368+
}
369+
370+
$import = new ResourcesPreviewImport;
371+
Excel::import($import, $storedPath, $disk);
372+
373+
return $import->data;
374+
}
375+
376+
private function extractAssetsZip(\Illuminate\Http\UploadedFile $zipFile, string $disk): string
377+
{
378+
$assetsDir = 'temp/learn_teach_assets_'.time();
379+
Storage::disk($disk)->makeDirectory($assetsDir);
380+
$targetPath = Storage::disk($disk)->path($assetsDir);
381+
382+
$zip = new ZipArchive;
383+
$opened = $zip->open($zipFile->getRealPath());
384+
if ($opened !== true) {
385+
throw new \RuntimeException('Invalid ZIP archive.');
386+
}
387+
388+
if (! $zip->extractTo($targetPath)) {
389+
$zip->close();
390+
throw new \RuntimeException('ZIP extraction failed.');
391+
}
392+
393+
$zip->close();
394+
395+
return $assetsDir;
396+
}
317397
}

app/Imports/ResourcesImport.php

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\ResourceSubject;
1111
use App\ResourceCategory;
1212
use App\ResourceLanguage;
13+
use App\Services\SharePointAssetFetcher;
1314
use Illuminate\Database\Eloquent\Model;
1415
use Illuminate\Support\Facades\Log;
1516
use Illuminate\Support\Facades\Schema;
@@ -206,7 +207,7 @@ protected function processRow(array $row, int $rowIndex): ?Model
206207
$imageValue = trim((string) ($row['image'] ?? ''));
207208
if ($imageValue !== '') {
208209
if (str_starts_with($imageValue, 'http://') || str_starts_with($imageValue, 'https://')) {
209-
$thumbnail = $imageValue;
210+
$thumbnail = $this->uploadRemoteImage($imageValue, $row, $rowIndex) ?? $imageValue;
210211
} elseif ($this->imagesDir) {
211212
$localPath = $this->imagesDir . DIRECTORY_SEPARATOR . $imageValue;
212213
if (file_exists($localPath)) {
@@ -228,7 +229,10 @@ protected function processRow(array $row, int $rowIndex): ?Model
228229
}
229230

230231
$pdfLink = null;
231-
if (!empty($row['link']) && stripos($row['link'], 'http://') !== 0 && stripos($row['link'], 'https://') !== 0 && $this->pdfsDir) {
232+
$linkValue = trim((string) ($row['link'] ?? ''));
233+
if ($linkValue !== '' && $this->isHttpUrl($linkValue)) {
234+
$pdfLink = $this->uploadRemotePdf($linkValue, $row, $rowIndex);
235+
} elseif ($linkValue !== '' && $this->pdfsDir) {
232236
$groupName = !empty($row['group_name']) ? trim($row['group_name']) : '';
233237
$groupSlug = $groupName ? Str::slug($groupName) : 'default';
234238

@@ -405,4 +409,64 @@ protected function processRow(array $row, int $rowIndex): ?Model
405409

406410
return $item;
407411
}
412+
413+
protected function isHttpUrl(string $value): bool
414+
{
415+
return str_starts_with($value, 'http://') || str_starts_with($value, 'https://');
416+
}
417+
418+
protected function uploadRemotePdf(string $url, array $row, int $rowIndex): ?string
419+
{
420+
/** @var SharePointAssetFetcher $fetcher */
421+
$fetcher = app(SharePointAssetFetcher::class);
422+
if (! $fetcher->isSharePointUrl($url) || ! $fetcher->looksLikePdfUrl($url)) {
423+
return null;
424+
}
425+
426+
$bytes = $fetcher->fetch($url);
427+
if ($bytes === null || ! str_starts_with($bytes, '%PDF')) {
428+
return null;
429+
}
430+
431+
$groupName = ! empty($row['group_name']) ? trim((string) $row['group_name']) : '';
432+
$groupSlug = $groupName ? Str::slug($groupName) : 'default';
433+
$baseSlug = Str::slug(pathinfo(parse_url($url, PHP_URL_PATH) ?? 'resource', PATHINFO_FILENAME) ?: 'resource');
434+
$basename = $this->filenameMode === 'preserve'
435+
? $this->preserveModeBasename(basename(parse_url($url, PHP_URL_PATH) ?: 'resource.pdf'))
436+
: $this->buildStoredBasename($baseSlug, 'pdf', $row, $rowIndex, false);
437+
$storagePath = $groupSlug.'/'.$basename;
438+
Storage::disk($this->disk)->put($storagePath, $bytes);
439+
440+
return Storage::disk($this->disk)->url($storagePath);
441+
}
442+
443+
protected function uploadRemoteImage(string $url, array $row, int $rowIndex): ?string
444+
{
445+
/** @var SharePointAssetFetcher $fetcher */
446+
$fetcher = app(SharePointAssetFetcher::class);
447+
if (! $this->isHttpUrl($url)) {
448+
return null;
449+
}
450+
451+
$bytes = $fetcher->fetch($url);
452+
if ($bytes === null || str_starts_with($bytes, '<')) {
453+
return null;
454+
}
455+
456+
$path = parse_url($url, PHP_URL_PATH);
457+
$filename = is_string($path) && $path !== '' ? basename($path) : 'thumbnail.jpg';
458+
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
459+
$basename = $this->filenameMode === 'preserve'
460+
? $this->preserveModeBasename($filename)
461+
: $this->buildStoredBasename(
462+
Str::slug($row['name_of_the_resource'] ?? 'resource'),
463+
$ext,
464+
$row,
465+
$rowIndex,
466+
true
467+
);
468+
Storage::disk($this->disk)->put($basename, $bytes);
469+
470+
return Storage::disk($this->disk)->url($basename);
471+
}
408472
}

0 commit comments

Comments
 (0)