Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
29 changes: 26 additions & 3 deletions app/Console/Commands/ImportResourcesFromExcel.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@ class ImportResourcesFromExcel extends Command
*
* @var string
*/
protected $signature = 'resources:import {file : Path to the Excel file} {--focus : Focus create related attributes}';
protected $signature = 'resources:import {file : Path to the Excel file}
{--focus : Focus create related attributes}
{--batch-timestamp : Use one Unix timestamp for every file in this run (same suffix for all)}
{--stable-names : Stable S3 paths without timestamp (overwrites same key on re-upload)}
{--preserve-filenames : Use local file basenames as S3 keys (rename locals to match live URLs, then overwrite)}
{--suffix= : Optional human-readable suffix for all files, e.g. 2026-03}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Import resources from an Excel file with accompanying images folder.';
protected $description = 'Import resources from an Excel file with accompanying images folder (images/, links/).';

/**
* Execute the console command.
Expand All @@ -46,10 +51,28 @@ public function handle()
$this->warn("Warning: Images folder not found at $imagesDir. Continuing without images.");
}

$filenameMode = 'per_file';
$batchTimestamp = null;
$globalSuffix = $this->option('suffix');
$globalSuffix = is_string($globalSuffix) && trim($globalSuffix) !== '' ? trim($globalSuffix) : null;

if ($this->option('preserve-filenames')) {
$filenameMode = 'preserve';
} elseif ($this->option('stable-names')) {
$filenameMode = 'stable';
} elseif ($this->option('batch-timestamp')) {
$filenameMode = 'batch';
$batchTimestamp = time();
}

try {
Excel::import(new ResourcesImport($imagesDir, $pdfsDir, $focus), $filePath);
Excel::import(
new ResourcesImport($imagesDir, $pdfsDir, $focus, [], null, $filenameMode, $batchTimestamp, $globalSuffix),
$filePath
);

$this->info('Import completed successfully.');
$this->line("Filename mode: {$filenameMode}" . ($globalSuffix ? " (suffix: {$globalSuffix})" : ''));
return 0;
} catch (Exception $e) {
Log::error('[ImportResourcesFromExcel] Error: ' . $e->getMessage(), [
Expand Down
103 changes: 103 additions & 0 deletions app/Console/Commands/ResourcesExportS3Urls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace App\Console\Commands;

use App\ResourceItem;
use Illuminate\Console\Command;

/**
* Export Learn & Teach ResourceItem S3 URLs so you can rename local import files
* to match production basenames, then run resources:import --preserve-filenames.
*/
class ResourcesExportS3Urls extends Command
{
protected $signature = 'resources:export-s3-urls
{--active-only : Only rows with active=1}
{--json : Output JSON array instead of CSV}
{--output= : Write CSV or JSON to this path (e.g. storage/app/resources_s3_urls.csv)}';

protected $description = 'Export ResourceItem id, name, source, thumbnail (for matching local filenames to live S3 keys)';

public function handle(): int
{
$q = ResourceItem::query()->orderBy('id');
if ($this->option('active-only')) {
$q->where('active', true);
}
$rows = $q->get(['id', 'name', 'source', 'thumbnail']);

$outputPath = $this->option('output');
$outputPath = is_string($outputPath) ? trim($outputPath) : '';

if ($this->option('json')) {
$payload = $rows->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($outputPath !== '') {
$full = $this->resolveOutputPath($outputPath);
if ($full === null) {
return self::FAILURE;
}
file_put_contents($full, $payload);
$this->info("Wrote JSON ({$rows->count()} items) to: {$full}");

return self::SUCCESS;
}
$this->line($payload);

return self::SUCCESS;
}

$out = fopen('php://temp', 'r+');
fputcsv($out, ['id', 'name', 'source', 'thumbnail', 'pdf_basename', 'thumb_basename']);
foreach ($rows as $r) {
$src = (string) ($r->source ?? '');
$thumb = (string) ($r->thumbnail ?? '');
fputcsv($out, [
$r->id,
$r->name,
$src,
$thumb,
$src !== '' ? basename(parse_url($src, PHP_URL_PATH) ?: $src) : '',
$thumb !== '' ? basename(parse_url($thumb, PHP_URL_PATH) ?: $thumb) : '',
]);
}
rewind($out);
$csv = stream_get_contents($out) ?: '';
fclose($out);

if ($outputPath !== '') {
$full = $this->resolveOutputPath($outputPath);
if ($full === null) {
return self::FAILURE;
}
file_put_contents($full, $csv);
$this->info("Wrote CSV ({$rows->count()} rows) to: {$full}");

return self::SUCCESS;
}

$this->output->write($csv);

return self::SUCCESS;
}

/**
* @return string|null Absolute path, or null on error
*/
private function resolveOutputPath(string $path): ?string
{
$full = str_starts_with($path, DIRECTORY_SEPARATOR) || preg_match('#^[a-zA-Z]:\\\\#', $path) === 1
? $path
: base_path($path);

$dir = dirname($full);
if (! is_dir($dir)) {
if (! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
$this->error("Cannot create directory: {$dir}");

return null;
}
}

return $full;
}
}
12 changes: 11 additions & 1 deletion app/Http/Controllers/ResourcesImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ public function import(Request $request): RedirectResponse
'filters_type', 'filters_target_audience', 'filters_level_of_difficulty',
'filters_programming_language', 'filters_subject', 'filters_topics', 'filters_language',
'category', 'group_name',
's3_suffix', 'file_suffix', 's3_file_suffix',
];
foreach ($edits as $index => $fields) {
if (! is_array($fields)) {
Expand All @@ -254,9 +255,18 @@ public function import(Request $request): RedirectResponse
}
}

$filenameMode = $request->input('filename_mode', 'per_file');
if (! is_string($filenameMode) || ! in_array($filenameMode, ['per_file', 'batch', 'stable', 'preserve'], true)) {
$filenameMode = 'per_file';
}
$batchTimestamp = $filenameMode === 'batch' ? time() : null;
$customSuffix = $request->input('custom_s3_suffix');
$customSuffix = is_string($customSuffix) ? trim($customSuffix) : '';
$customSuffix = $customSuffix !== '' ? $customSuffix : null;

try {
$result = new ResourcesImportResult;
$import = new ResourcesImport(null, null, $focus, $overrides, $result);
$import = new ResourcesImport(null, null, $focus, $overrides, $result, $filenameMode, $batchTimestamp, $customSuffix);
Excel::import($import, $path, $tempDisk);

Storage::disk($tempDisk)->delete($path);
Expand Down
102 changes: 96 additions & 6 deletions app/Imports/ResourcesImport.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,92 @@ class ResourcesImport extends DefaultValueBinder implements ToModel, WithCustomV
/** Optional result collector for web import report. */
protected ?ResourcesImportResult $result = null;

/**
* How S3 object names get a suffix:
* - per_file: Unix time() per upload (default; each file can differ by seconds).
* - batch: same Unix timestamp for every file in this import (set $batchTimestamp).
* - stable: no timestamp — {slug}.pdf / {slug}-{language}.png (overwrites same path on re-upload).
* - preserve: use the local file’s basename as the S3 key (after group folder for PDFs). Rename local files
* to match production (e.g. export URLs from live) so re-upload overwrites the same object without changing DB URLs.
*/
protected string $filenameMode = 'per_file';

/** Used when $filenameMode === 'batch'. */
protected ?int $batchTimestamp = null;

/** If set, all files use this suffix (slugged), e.g. "2026-03" → "-2026-03". Overrides timestamp modes. */
protected ?string $globalSuffix = null;

// public
private $disk = 'resources';

public function __construct($imagesDir = null, $pdfsDir = null, $focus = false, array $overrides = [], ?ResourcesImportResult $result = null)
{
public function __construct(
$imagesDir = null,
$pdfsDir = null,
$focus = false,
array $overrides = [],
?ResourcesImportResult $result = null,
string $filenameMode = 'per_file',
?int $batchTimestamp = null,
?string $globalSuffix = null
) {
$this->imagesDir = $imagesDir;
$this->focus = $focus;
$this->pdfsDir = $pdfsDir;
$this->overrides = $overrides;
$this->result = $result;
$this->filenameMode = in_array($filenameMode, ['per_file', 'batch', 'stable', 'preserve'], true) ? $filenameMode : 'per_file';
$this->batchTimestamp = $batchTimestamp;
$this->globalSuffix = $globalSuffix !== null && trim($globalSuffix) !== '' ? trim($globalSuffix) : null;
}

/**
* Build stored filename (without path) for PDF or thumbnail.
*
* @param string $baseSlug Slug from resource name or PDF basename
* @param bool $isThumbnail If true and mode is stable, language is appended for uniqueness
*/
protected function buildStoredBasename(string $baseSlug, string $ext, array $row, int $rowIndex, bool $isThumbnail): string
{
$rowSuffix = $this->getRowValue($row, ['s3_suffix', 'file_suffix', 's3_file_suffix']);
if (is_string($rowSuffix) && trim($rowSuffix) !== '') {
return $baseSlug . '-' . Str::slug(trim($rowSuffix)) . '.' . $ext;
}
if ($this->globalSuffix !== null) {
return $baseSlug . '-' . Str::slug($this->globalSuffix) . '.' . $ext;
}
if ($this->filenameMode === 'stable') {
if ($isThumbnail) {
$lang = $this->getRowValue($row, ['filters_language', 'language']);
$langPart = is_string($lang) && trim($lang) !== ''
? Str::slug(trim($lang))
: 'row-' . ($rowIndex + 1);

return $baseSlug . '-' . $langPart . '.' . $ext;
}

return $baseSlug . '.' . $ext;
}
if ($this->filenameMode === 'batch') {
$ts = $this->batchTimestamp ?? time();

return $baseSlug . '-' . $ts . '.' . $ext;
}

return $baseSlug . '-' . time() . '.' . $ext;
}

/**
* Use local filename as S3 object basename (no slugging, no extra suffix). For trusted admin imports only.
*/
protected function preserveModeBasename(string $filename): string
{
$b = basename(str_replace('\\', '/', $filename));
if ($b === '' || str_contains($b, '..')) {
throw new \InvalidArgumentException('Invalid upload filename: '.$filename);
}

return $b;
}

protected function createOrGetModel($class, $name)
Expand Down Expand Up @@ -134,8 +210,15 @@ protected function processRow(array $row, int $rowIndex): ?Model
} elseif ($this->imagesDir) {
$localPath = $this->imagesDir . DIRECTORY_SEPARATOR . $imageValue;
if (file_exists($localPath)) {
$ext = pathinfo($imageValue, PATHINFO_EXTENSION) ?: 'jpg';
$basename = Str::slug($row['name_of_the_resource']) . '-' . time() . '.' . $ext;
$basename = $this->filenameMode === 'preserve'
? $this->preserveModeBasename($imageValue)
: $this->buildStoredBasename(
Str::slug($row['name_of_the_resource']),
pathinfo($imageValue, PATHINFO_EXTENSION) ?: 'jpg',
$row,
$rowIndex,
true
);
Storage::disk($this->disk)->put($basename, file_get_contents($localPath));
$thumbnail = Storage::disk($this->disk)->url($basename);
} else {
Expand Down Expand Up @@ -164,8 +247,15 @@ protected function processRow(array $row, int $rowIndex): ?Model
}

if ($pdfLocalPath && file_exists($pdfLocalPath)) {
$ext = pathinfo($pdfFilename, PATHINFO_EXTENSION) ?: 'pdf';
$basename = Str::slug(pathinfo($pdfFilename, PATHINFO_FILENAME)) . '-' . time() . '.' . $ext;
$basename = $this->filenameMode === 'preserve'
? $this->preserveModeBasename($pdfFilename)
: $this->buildStoredBasename(
Str::slug(pathinfo($pdfFilename, PATHINFO_FILENAME)),
pathinfo($pdfFilename, PATHINFO_EXTENSION) ?: 'pdf',
$row,
$rowIndex,
false
);
$storagePath = $groupSlug . '/' . $basename;
Storage::disk($this->disk)->put($storagePath, file_get_contents($pdfLocalPath));
$pdfLink = Storage::disk($this->disk)->url($storagePath);
Expand Down
1 change: 1 addition & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function ($view) {
$this->commands([
\App\Console\Commands\CertificateReassignUser::class,
\App\Console\Commands\CertificateRegenerateInPlace::class,
\App\Console\Commands\ResourcesExportS3Urls::class,
]);

$this->bootAuth();
Expand Down
1 change: 1 addition & 0 deletions docs/resources/resources-s3-urls-export.example.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
id,name,source,thumbnail,pdf_basename,thumb_basename
3 changes: 2 additions & 1 deletion resources/views/admin/resources-import/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ class="rounded border-gray-300">
<label for="file" class="block font-medium mb-1">Excel / CSV file <span class="text-red-600">*</span></label>
<input type="file" name="file" id="file" accept=".csv,.xlsx,.xls" required
class="block w-full max-w-md" aria-required="true">
<p class="text-sm text-gray-600 mt-1">Required column: <code>name_of_the_resource</code>. Optional: link, description, image, filters_type, filters_target_audience, filters_level_of_difficulty, filters_programming_language, filters_subjects, filters_topics, filters_language, category, group_name. Max 10 MB.</p>
<p class="text-sm text-gray-600 mt-1">Required column: <code>name_of_the_resource</code>. Optional: link, description, image, filters_type, filters_target_audience, filters_level_of_difficulty, filters_programming_language, filters_subjects, filters_topics, filters_language, category, group_name, <code>s3_suffix</code> (or <code>file_suffix</code>) per row for file naming. Max 10 MB.</p>
<p class="text-sm text-gray-600 mt-1">After verify, the preview step lets you choose <strong>batch</strong> / <strong>stable</strong> / <strong>preserve</strong> naming or a <strong>custom suffix</strong>. On the server: <code>php artisan resources:export-s3-urls --output=storage/app/resources_s3_urls.csv</code> then rename local PDFs/images to match <code>pdf_basename</code> / <code>thumb_basename</code> and import with <code>--preserve-filenames</code>. Example header: <code>docs/resources/resources-s3-urls-export.example.csv</code>. See <code>resources:import --help</code>.</p>
<p id="file-required-hint" class="text-sm text-amber-600 mt-1 hidden">Please select a file first, then click Verify.</p>
</div>

Expand Down
20 changes: 20 additions & 0 deletions resources/views/admin/resources-import/preview.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ class="w-full min-w-[160px] px-2 py-1 border rounded text-sm" placeholder="https
</table>
</div>

<div class="mb-6 p-4 rounded border border-gray-200 bg-gray-50">
<h2 class="text-lg font-semibold mb-2">S3 file naming (Learn &amp; Teach re-uploads)</h2>
<p class="text-sm text-gray-700 mb-3">Choose how uploaded PDFs and images are named on S3. Default adds a new Unix timestamp per file (old links change after each import).</p>
<div class="mb-3">
<label class="block font-medium mb-1">Filename strategy</label>
<select name="filename_mode" class="block max-w-md w-full px-3 py-2 border rounded">
<option value="per_file" {{ old('filename_mode', 'per_file') === 'per_file' ? 'selected' : '' }}>Per file: new timestamp each upload (default)</option>
<option value="batch" {{ old('filename_mode') === 'batch' ? 'selected' : '' }}>Batch: one timestamp for this entire import (all files share the same suffix)</option>
<option value="stable" {{ old('filename_mode') === 'stable' ? 'selected' : '' }}>Stable: no timestamp — overwrites same path (clean URLs; use when re-uploading fixed PDFs)</option>
<option value="preserve" {{ old('filename_mode') === 'preserve' ? 'selected' : '' }}>Preserve: use local file name as S3 key (rename files to match live URLs from export, then overwrite)</option>
</select>
</div>
<div class="mb-1">
<label for="custom_s3_suffix" class="block font-medium mb-1">Optional custom suffix (all rows)</label>
<input type="text" name="custom_s3_suffix" id="custom_s3_suffix" value="{{ old('custom_s3_suffix') }}"
class="block max-w-md w-full px-3 py-2 border rounded" placeholder="e.g. 2026-03">
<p class="text-sm text-gray-600 mt-1">If set, every file becomes <code>slug-your-suffix.ext</code> (overrides batch/per_file timestamps). You can also add optional columns <code>s3_suffix</code>, <code>file_suffix</code>, or <code>s3_file_suffix</code> per row in the spreadsheet.</p>
</div>
</div>

<div class="codeweek-form-button-container">
<div class="codeweek-button">
<input type="submit" value="Import" class="bg-primary cursor-pointer px-6 py-3 rounded-full font-semibold text-[#20262C] hover:bg-hover-orange duration-300">
Expand Down
Loading