Squashed commit of the following:
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 40s

commit 579f0878e5240dc17db69be1e0b0c0f5af7ef9fe
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Tue Jun 9 09:25:44 2026 +0200

    feat: Refactor error handling in Dataset Edit form and improve validation messages

    - Updated error handling in the Dataset Edit form to use a centralized formatError function for displaying validation messages.
    - Enhanced user feedback by ensuring that error messages are displayed consistently across various fields.
    - Modified the validation rule for arrayContainsTypes to provide clearer error messages for missing main and translated titles/abstracts.
    - Introduced a new ValidationService to manage manual construction of validation errors.
    - Updated Vite configuration to streamline asset loading and improve performance.
    - Adjusted Inertia setup to utilize dynamic imports for page-specific assets.
    - Cleaned up unnecessary comments and code in various files for better readability.

commit 5efddc2a58c0e164fef585cc7344c06155dbc2c1
Author: Arno Kaimbacher <arno.kaimbacher@geosphere.at>
Date:   Mon Jan 12 17:02:47 2026 +0100

    feat: add dataset change detection and form submission composables

    - Implemented `useDatasetChangeDetection` for tracking unsaved changes in dataset forms, including comparisons for licenses, basic properties, files, coverage, and more.
    - Added `useDatasetFormSubmission` for handling dataset form submissions with validation, success/error handling, and auto-save functionality.
This commit is contained in:
Kaimbacher 2026-06-09 09:35:15 +02:00
commit 9368a0dd8d
38 changed files with 5588 additions and 6181 deletions

View file

@ -14,9 +14,7 @@ import Person from '#models/person';
import db from '@adonisjs/lucid/services/db';
import { TransactionClientContract } from '@adonisjs/lucid/types/database';
import Subject from '#models/subject';
// import CreateDatasetValidator from '#validators/create_dataset_validator';
import { createDatasetValidator, updateDatasetValidator } from '#validators/dataset';
// import UpdateDatasetValidator from '#validators/update_dataset_validator';
import {
TitleTypes,
DescriptionTypes,
@ -40,7 +38,8 @@ import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import type { Multipart } from '@adonisjs/bodyparser';
import * as fs from 'fs';
import { parseBytesSize, getConfigFor, getTmpPath, formatBytes } from '#app/utils/utility-functions';
import { parseBytesSize, getConfigFor, getTmpPath, formatBytes, errorMessage } from '#app/utils/utility-functions';
import validation from '#services/validation_service';
interface Dictionary {
[index: string]: string;
@ -207,6 +206,7 @@ export default class DatasetController {
.translatedLanguage({ mainLanguageField: 'language', typeField: 'type' }),
}),
)
.minLength(1) // Ensure at least the main title exists
// .minLength(2)
.arrayContainsTypes({ typeA: 'main', typeB: 'translated' }),
descriptions: vine
@ -426,37 +426,31 @@ export default class DatasetController {
}
public async store({ auth, request, response, session }: HttpContext) {
// At the top of the store() method, declare an array to hold temporary file paths
const uploadedTmpFiles: string[] = [];
// Aggregated limit example (adjust as needed)
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
// const aggregatedLimit = 200 * 1024 * 1024;
let totalUploadedSize = 0;
// // Helper function to format bytes as human-readable text
// function formatBytes(bytes: number): string {
// if (bytes === 0) return '0 Bytes';
// const k = 1024;
// const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
// const i = Math.floor(Math.log(bytes) / Math.log(k));
// return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
// }
// const enabledExtensions = await this.getEnabledExtensions();
const multipart: Multipart = request.multipart;
// NULL GUARD: kein multipart-Body → sauberer Validierungsfehler statt Crash
if (!multipart) {
validation.throw('files', 'Please select at least one file.', 'required');
}
// Configuration for limits
const multipartConfig = getConfigFor('multipart');
const aggregatedLimit = multipartConfig.limit ? parseBytesSize(multipartConfig.limit) : 100 * 1024 * 1024;
let totalUploadedSize = 0;
let filesCount = 0; // Tracker: ensure at least one file is processed
multipart.onFile('files', { deferValidations: true }, async (part) => {
// Attach an individual file size accumulator if needed
filesCount++;
let fileUploadedSize = 0;
// Simply accumulate the size in on('data') without performing the expensive check per chunk
// Accumulate the size per chunk (cheap), defer the limit check to 'end'
part.on('data', (chunk) => {
// reporter(chunk);
// Increase counters using the chunk length
fileUploadedSize += chunk.length;
});
// After the file is completely read, update the global counter and check aggregated limit
// After the file is fully read, update the global counter and check the aggregated limit
part.on('end', () => {
totalUploadedSize += fileUploadedSize;
part.file.size = fileUploadedSize;
@ -465,6 +459,7 @@ export default class DatasetController {
uploadedTmpFiles.push(part.file.tmpPath);
}
// AGGREGATED LIMIT CHECK: abort immediately if too big
if (totalUploadedSize > aggregatedLimit) {
// Clean up all temporary files if aggregate limit is exceeded
uploadedTmpFiles.forEach((tmpPath) => {
@ -474,48 +469,44 @@ export default class DatasetController {
console.error('Error cleaning up temporary file:', cleanupError);
}
});
const error = new errors.E_VALIDATION_ERROR({
'upload error': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`,
});
request.multipart.abort(error);
request.multipart.abort(
validation.make('files', `Upload limit of ${formatBytes(aggregatedLimit)} exceeded.`, 'limit')
);
}
});
part.on('error', (error) => {
// fileUploadError = error;
request.multipart.abort(error);
});
// await pipeline(part, createWriteStream(filePath));
// return { filePath };
// Process file with error handling
try {
// Extract extension from the client file name, e.g. "Tethys 5 - Ampflwang_dataset.zip"
const ext = path.extname(part.file.clientName).replace('.', '');
// Attach the extracted extension to the file object for later use
part.file.extname = ext;
// part.file.sortOrder = part.file.sortOrder;
const tmpPath = getTmpPath(multipartConfig);
(part.file as any).tmpPath = tmpPath;
const writeStream = createWriteStream(tmpPath);
await pipeline(part, writeStream);
} catch (error) {
request.multipart.abort(new errors.E_VALIDATION_ERROR({ 'upload error': error.message }));
request.multipart.abort(validation.make('files', error.message, 'upload'));
}
});
try {
await multipart.process();
// // Instead of letting an error abort the controller, check if any error occurred
// EMPTY FIELD CHECK: triggered if process finishes but onFile never ran
if (filesCount === 0) {
validation.throw('files', 'Please select at least one file.', 'required');
}
} catch (error) {
// This is where you'd expect to catch any errors.
session.flash('errors', error.messages);
return response.redirect().back();
// If it's already a validation error, let it bubble up unchanged
if (error instanceof errors.E_VALIDATION_ERROR) throw error;
// Wrap any other stream/size errors
validation.throw('files', errorMessage(error) || 'Upload failed.');
}
// Proceed to transaction and createDatasetAndAssociations
let trx: TransactionClientContract | null = null;
try {
await request.validateUsing(createDatasetValidator);
@ -539,13 +530,11 @@ export default class DatasetController {
await trx.rollback();
}
console.error('Failed to create dataset and related models:', error);
// throw new ValidationException(true, { 'upload error': `failed to create dataset and related models. ${error}` });
throw error;
}
session.flash('message', 'Dataset has been created successfully');
return response.redirect().toRoute('dataset.list');
// return response.redirect().back();
}
private async createDatasetAndAssociations(
user: User,
@ -1106,7 +1095,7 @@ export default class DatasetController {
}
});
const error = new errors.E_VALIDATION_ERROR({
'upload error': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`,
'files': `Aggregated upload limit of ${formatBytes(aggregatedLimit)} exceeded. The total size of files being uploaded would exceed the limit.`,
});
request.multipart.abort(error);
}
@ -1132,7 +1121,7 @@ export default class DatasetController {
try {
await multipart.process();
} catch (error) {
session.flash('errors', error.messages);
// session.flash('errors', error.messages);
return response.redirect().back();
}
}