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
6 changes: 4 additions & 2 deletions .ai/guidelines/frontend.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Frontend Build (BIOSPEX)

- This project builds assets with Laravel Mix (`webpack.mix.js`), not Vite. Ignore Vite-specific guidance.
- If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn run dev` or `yarn run production`. Ask them.
- This project builds assets with Vite (`vite.config.js`, `laravel-vite-plugin`) and yarn. Blade views load them with `@vite(...)`.
- `yarn build` writes `public/build`; `yarn dev` runs the dev server. If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn build` or `yarn dev`. Ask them.
- The scripts are ES modules. jQuery and the libraries the pages call are globals set by `resources/js/jquery-global.js` and the `*-globals.js` files, imported first. An inline `<script>` in a Blade view that uses them while the page loads must be `type="module"`, and a CDN jQuery plugin it needs must be `defer`, so both run after the bundles.

23 changes: 12 additions & 11 deletions .github/workflows/deploy-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Get Runner IP
id: ip
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

- name: Cache Composer downloads
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
Expand All @@ -70,7 +70,7 @@ jobs:
run: composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-scripts

- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'yarn'
Expand All @@ -79,12 +79,12 @@ jobs:
run: yarn install --frozen-lockfile --ignore-engines

- name: Build assets
run: npm run production
run: yarn build
env:
MIX_REVERB_APP_KEY: ${{ vars[format('{0}_REVERB_APP_KEY', inputs.vars_prefix)] }}
MIX_REVERB_HOST: ${{ vars[format('{0}_REVERB_HOST', inputs.vars_prefix)] }}
MIX_REVERB_PORT: ${{ vars[format('{0}_REVERB_PORT', inputs.vars_prefix)] }}
MIX_REVERB_SCHEME: ${{ vars[format('{0}_REVERB_SCHEME', inputs.vars_prefix)] }}
VITE_REVERB_APP_KEY: ${{ vars[format('{0}_REVERB_APP_KEY', inputs.vars_prefix)] }}
VITE_REVERB_HOST: ${{ vars[format('{0}_REVERB_HOST', inputs.vars_prefix)] }}
VITE_REVERB_PORT: ${{ vars[format('{0}_REVERB_PORT', inputs.vars_prefix)] }}
VITE_REVERB_SCHEME: ${{ vars[format('{0}_REVERB_SCHEME', inputs.vars_prefix)] }}

- name: Create deployment package
run: |
Expand All @@ -94,7 +94,8 @@ jobs:
# Create isolated temp dir
TEMP_DIR=$(mktemp -d)

# Sync repo files to temp (exclusions prevent bloat/self-ref)
# Sync repo files to temp (exclusions prevent bloat/self-ref).
# This includes the Vite build in public/build, which each release keeps for itself.
rsync -av \
--exclude=node_modules \
--exclude=.git \
Expand All @@ -104,8 +105,8 @@ jobs:
--exclude=vendor \
. "$TEMP_DIR/"

# Copy assets into temp/public/ (these are already built via npm run production)
cp -r public/css public/js public/fonts public/images public/svg public/mix-manifest.json "$TEMP_DIR/public/" || true
# Fail early if the build is missing, rather than deploying pages without assets
test -f "$TEMP_DIR/public/build/manifest.json"

# Rename to package name
mv "$TEMP_DIR" deployment-package
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
new_version: ${{ steps.new_version.outputs.new_version }}
current_version: ${{ steps.version_bump.outputs.current_version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- 27017:27017

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Pint --diff compares against the base branch

Expand All @@ -46,7 +46,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

- name: Cache Composer downloads
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
Expand Down Expand Up @@ -75,7 +75,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ node_modules
/public/css
/public/js
/public/fonts
/public/images
/public/mix-manifest.json
/public/build
/public/hot
storage/cmd.txt
storage/testing
storage/app/classifications/*
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

# Frontend Build (BIOSPEX)

- This project builds assets with Laravel Mix (`webpack.mix.js`), not Vite. Ignore Vite-specific guidance.
- If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn run dev` or `yarn run production`. Ask them.
- This project builds assets with Vite (`vite.config.js`, `laravel-vite-plugin`) and yarn. Blade views load them with `@vite(...)`.
- `yarn build` writes `public/build`; `yarn dev` runs the dev server. If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn build` or `yarn dev`. Ask them.
- The scripts are ES modules. jQuery and the libraries the pages call are globals set by `resources/js/jquery-global.js` and the `*-globals.js` files, imported first. An inline `<script>` in a Blade view that uses them while the page loads must be `type="module"`, and a CDN jQuery plugin it needs must be `defer`, so both run after the bundles.


=== .ai/project-context rules ===

Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

# Frontend Build (BIOSPEX)

- This project builds assets with Laravel Mix (`webpack.mix.js`), not Vite. Ignore Vite-specific guidance.
- If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn run dev` or `yarn run production`. Ask them.
- This project builds assets with Vite (`vite.config.js`, `laravel-vite-plugin`) and yarn. Blade views load them with `@vite(...)`.
- `yarn build` writes `public/build`; `yarn dev` runs the dev server. If the user doesn't see a frontend change reflected in the UI, they may need to run `yarn build` or `yarn dev`. Ask them.
- The scripts are ES modules. jQuery and the libraries the pages call are globals set by `resources/js/jquery-global.js` and the `*-globals.js` files, imported first. An inline `<script>` in a Blade view that uses them while the page loads must be `type="module"`, and a CDN jQuery plugin it needs must be `defer`, so both run after the bundles.


=== .ai/project-context rules ===

Expand Down
72 changes: 48 additions & 24 deletions app/Console/Commands/CleanEfsDirsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ class CleanEfsDirsCommand extends Command
*
* @var string
*/
protected $description = 'Deletes files older than 72 hours from the /efs directory, leaving empty directories intact. Skipped while an import, export, OCR run or GeoLocate job is in progress.';
protected $description = 'Deletes files and empty directories older than 72 hours from the /efs directories BIOSPEX writes to (import, scratch, GeoLocate export). Skipped while an import, export, OCR run or GeoLocate job is in progress.';

private int $deletedFiles = 0;

private int $deletedDirectories = 0;

/**
* Execute the console command.
Expand All @@ -61,9 +65,34 @@ public function handle(): void
return;
}

$deletedFiles = $this->cleanDirectory(config('filesystems.disks.efs.root'));
$threshold = Carbon::now()->subHours(72)->getTimestamp();

foreach ($this->directories() as $directory) {
if (File::isDirectory($directory)) {
$this->cleanDirectory($directory, $threshold);
}
}

Log::info("Cleanup completed. Files deleted: {$this->deletedFiles}. Directories deleted: {$this->deletedDirectories}.");
}

/**
* Get the /efs directories that BIOSPEX writes to.
*
* /efs is shared by both servers and holds other files too, such as database dumps, so nothing outside these
* directories is touched.
*
* @return list<string>
*/
private function directories(): array
{
$root = config('filesystems.disks.efs.root');

Log::info("Cleanup completed. Files deleted: $deletedFiles");
return [
"$root/".config('config.import_dir'),
"$root/".config('config.scratch_dir'),
"$root/".config('geolocate.dir.export'),
];
}

/**
Expand Down Expand Up @@ -100,32 +129,27 @@ private function queueHasJobs(string $queue): bool
}

/**
* Recursively clean files older than 72 hours in a directory.
* Recursively delete the files, and then the empty subdirectories, last modified before the threshold.
*
* The directory itself is kept. A subdirectory emptied by this run was just modified, so it's removed by a
* later run. Hidden files aren't deleted, and a subdirectory holding one isn't empty: on EFS a deleted file
* that is still open becomes a hidden .nfs file until it's closed.
*/
private function cleanDirectory(string $path): int
private function cleanDirectory(string $path, int $threshold): void
{
$deletedFiles = 0;

// Iterate through directory contents
$items = File::files($path);
$directories = File::directories($path);

// Delete files older than 72 hours
foreach ($items as $file) {
$lastModified = Carbon::createFromTimestamp(File::lastModified($file));
$threshold = Carbon::now()->subHours(72);

if ($lastModified->lessThan($threshold)) {
File::delete($file); // Use the File facade to delete
$deletedFiles++;
foreach (File::files($path) as $file) {
if (File::lastModified($file) < $threshold) {
File::delete($file);
$this->deletedFiles++;
}
}

// Recursively scan subdirectories
foreach ($directories as $directory) {
$deletedFiles += $this->cleanDirectory($directory);
}
foreach (File::directories($path) as $directory) {
$this->cleanDirectory($directory, $threshold);

return $deletedFiles;
if (File::isEmptyDirectory($directory) && File::lastModified($directory) < $threshold && File::deleteDirectory($directory)) {
$this->deletedDirectories++;
}
}
}
}
6 changes: 3 additions & 3 deletions app/Http/Controllers/Admin/ExpertReconcileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,14 @@ public function index(Expedition $expedition)
$expedition->load(['project.group.owner', 'panoptesProject']);

if (! CheckPermission::handle('updateProject', $expedition->project->group)) {
return Redirect::route('admin.expeditions.show', [$expedition->project_id, $expedition->id]);
return Redirect::route('admin.expeditions.show', [$expedition]);
}

$reconciles = $this->expertReconcileService->getPagination((int) $expedition->id);

if ($reconciles->isEmpty()) {

return Redirect::route('admin.expeditions.show', [$expedition->project_id, $expedition->id])
return Redirect::route('admin.expeditions.show', [$expedition])
->with('danger', t('Reconcile data for processing is missing.'));
}

Expand All @@ -87,7 +87,7 @@ public function create(Expedition $expedition, ReconcileService $reconcileLambda
$expedition->load('project.group.owner');

if (! CheckPermission::handle('updateProject', $expedition->project->group)) {
return Redirect::route('admin.expeditions.show', [$expedition->project_id, $expedition->id]);
return Redirect::route('admin.expeditions.show', [$expedition]);
}

if ($this->skipReconcile($expedition->id)) {
Expand Down
8 changes: 5 additions & 3 deletions app/Jobs/DwcBatchImportJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,11 @@ private function cleanupFiles(string $scratchFileDir, string $importFilePath): v
*/
private function cleanupScratchDirectory(string $scratchFileDir): void
{
if (File::isDirectory($scratchFileDir)) {
File::cleanDirectory($scratchFileDir);
File::deleteDirectory($scratchFileDir);
if (File::isDirectory($scratchFileDir) && ! File::deleteDirectory($scratchFileDir)) {
Log::warning('Could not remove the import scratch directory', [
'import_id' => $this->import->id,
'directory' => $scratchFileDir,
]);
}
}
}
4 changes: 0 additions & 4 deletions app/Jobs/ZooniverseExportDeleteFilesJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ public function handle(): void
Storage::disk('s3')->deleteDirectory($processDir);
}

if (Storage::disk('efs')->exists($processDir)) {
Storage::disk('efs')->deleteDirectory($processDir);
}

$this->exportQueue->delete();
}

Expand Down
27 changes: 20 additions & 7 deletions app/Services/Csv/Csv.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@

namespace App\Services\Csv;

use League\Csv\CannotInsertRecord;
use League\Csv\CharsetConverter;
use League\Csv\Exception;
use League\Csv\Reader;
use League\Csv\SyntaxError;
use League\Csv\Writer;

/**
Expand Down Expand Up @@ -57,6 +60,16 @@ public function readerCreateFromPath($file)
$this->reader = Reader::createFromPath($file);
}

/**
* Release the reader, closing its file.
*
* On EFS (NFS) a file that is still open can't really be deleted, so its directory can't be removed either.
*/
public function closeReader(): void
{
unset($this->reader);
}

/**
* Create reader from stream.
*
Expand Down Expand Up @@ -88,7 +101,7 @@ public function writerCreateFromStream($stream)
/**
* Set delimiter.
*
* @throws \League\Csv\Exception
* @throws Exception
*/
public function setDelimiter(string $delimiter = ',')
{
Expand All @@ -98,7 +111,7 @@ public function setDelimiter(string $delimiter = ',')
/**
* Set enclosure.
*
* @throws \League\Csv\Exception
* @throws Exception
*/
public function setEnclosure(string $enclosure = '"')
{
Expand All @@ -108,7 +121,7 @@ public function setEnclosure(string $enclosure = '"')
/**
* Set escape.
*
* @throws \League\Csv\Exception
* @throws Exception
*/
public function setEscape(string $escape = '\\')
{
Expand All @@ -119,7 +132,7 @@ public function setEscape(string $escape = '\\')
* Set header offset.
*
*
* @throws \League\Csv\Exception
* @throws Exception
*/
public function setHeaderOffset(int $offset = 0): void
{
Expand All @@ -131,7 +144,7 @@ public function setHeaderOffset(int $offset = 0): void
*
* @return string[]
*
* @throws \League\Csv\SyntaxError
* @throws SyntaxError
*/
public function getHeader(): array
{
Expand All @@ -141,7 +154,7 @@ public function getHeader(): array
/**
* Fetch all rows.
*
* @throws \League\Csv\Exception
* @throws Exception
*/
public function getRecords(array $header = []): \Iterator
{
Expand All @@ -151,7 +164,7 @@ public function getRecords(array $header = []): \Iterator
/**
* Insert one row.
*
* @throws \League\Csv\CannotInsertRecord|\League\Csv\Exception
* @throws CannotInsertRecord|Exception
*/
public function insertOne($row): void
{
Expand Down
Loading
Loading