Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
8f67839f93 | |||
06ed2f3625 | |||
e1ccf0ddc8 |
25 changed files with 5225 additions and 1918 deletions
|
@ -11,9 +11,10 @@ export default defineConfig({
|
||||||
|
|
||||||
*/
|
*/
|
||||||
commands: [
|
commands: [
|
||||||
() => import('@adonisjs/core/commands'),
|
() => import('@adonisjs/core/commands'),
|
||||||
() => import('@adonisjs/lucid/commands'),
|
() => import('@adonisjs/lucid/commands'),
|
||||||
() => import('@adonisjs/mail/commands')],
|
() => import('@adonisjs/mail/commands')
|
||||||
|
],
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Preloads
|
| Preloads
|
||||||
|
@ -36,6 +37,7 @@ export default defineConfig({
|
||||||
() => import('#start/rules/referenceValidation'),
|
() => import('#start/rules/referenceValidation'),
|
||||||
() => import('#start/rules/valid_mimetype'),
|
() => import('#start/rules/valid_mimetype'),
|
||||||
() => import('#start/rules/array_contains_types'),
|
() => import('#start/rules/array_contains_types'),
|
||||||
|
() => import('#start/rules/orcid'),
|
||||||
],
|
],
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
@ -4,20 +4,29 @@ import Person from '#models/person';
|
||||||
|
|
||||||
// node ace make:controller Author
|
// node ace make:controller Author
|
||||||
export default class AuthorsController {
|
export default class AuthorsController {
|
||||||
public async index({}: HttpContext) {
|
public async index({}: HttpContext) {
|
||||||
// select * from gba.persons
|
|
||||||
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id"
|
|
||||||
// where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id"));
|
|
||||||
const authors = await Person.query()
|
const authors = await Person.query()
|
||||||
.preload('datasets')
|
.select([
|
||||||
.where('name_type', 'Personal')
|
'id',
|
||||||
.whereHas('datasets', (dQuery) => {
|
'academic_title',
|
||||||
dQuery.wherePivot('role', 'author');
|
'first_name',
|
||||||
})
|
'last_name',
|
||||||
.withCount('datasets', (query) => {
|
'identifier_orcid',
|
||||||
query.as('datasets_count');
|
'status',
|
||||||
})
|
'name_type',
|
||||||
.orderBy('datasets_count', 'desc');
|
'created_at'
|
||||||
|
// Note: 'email' is omitted
|
||||||
|
])
|
||||||
|
.preload('datasets')
|
||||||
|
.where('name_type', 'Personal')
|
||||||
|
.whereHas('datasets', (dQuery) => {
|
||||||
|
dQuery.wherePivot('role', 'author');
|
||||||
|
})
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.orderBy('datasets_count', 'desc');
|
||||||
|
|
||||||
return authors;
|
return authors;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,26 +2,46 @@ import type { HttpContext } from '@adonisjs/core/http';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import redis from '@adonisjs/redis/services/main';
|
import redis from '@adonisjs/redis/services/main';
|
||||||
|
|
||||||
const PREFIXES = ['von', 'van'];
|
const PREFIXES = ['von', 'van', 'de', 'del', 'della', 'di', 'da', 'dos', 'du', 'le', 'la'];
|
||||||
const DEFAULT_SIZE = 50;
|
const DEFAULT_SIZE = 50;
|
||||||
|
const MIN_SIZE = 16;
|
||||||
|
const MAX_SIZE = 512;
|
||||||
const FONT_SIZE_RATIO = 0.4;
|
const FONT_SIZE_RATIO = 0.4;
|
||||||
const COLOR_LIGHTENING_PERCENT = 60;
|
const COLOR_LIGHTENING_PERCENT = 60;
|
||||||
const COLOR_DARKENING_FACTOR = 0.6;
|
const COLOR_DARKENING_FACTOR = 0.6;
|
||||||
|
const CACHE_TTL = 24 * 60 * 60; // 24 hours instead of 1 hour
|
||||||
|
|
||||||
export default class AvatarController {
|
export default class AvatarController {
|
||||||
public async generateAvatar({ request, response }: HttpContext) {
|
public async generateAvatar({ request, response }: HttpContext) {
|
||||||
try {
|
try {
|
||||||
const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
|
const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']);
|
||||||
if (!name) {
|
|
||||||
return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required' });
|
// Enhanced validation
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return response.status(StatusCodes.BAD_REQUEST).json({
|
||||||
|
error: 'Name is required and must be a non-empty string',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedSize = this.validateSize(size);
|
||||||
|
if (!parsedSize.isValid) {
|
||||||
|
return response.status(StatusCodes.BAD_REQUEST).json({
|
||||||
|
error: parsedSize.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a unique cache key for the given name and size
|
// Build a unique cache key for the given name and size
|
||||||
const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
const cacheKey = `avatar:${this.sanitizeName(name)}-${parsedSize.value}`;
|
||||||
const cachedSvg = await redis.get(cacheKey);
|
// const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
|
||||||
if (cachedSvg) {
|
try {
|
||||||
this.setResponseHeaders(response);
|
const cachedSvg = await redis.get(cacheKey);
|
||||||
return response.send(cachedSvg);
|
if (cachedSvg) {
|
||||||
|
this.setResponseHeaders(response);
|
||||||
|
return response.send(cachedSvg);
|
||||||
|
}
|
||||||
|
} catch (redisError) {
|
||||||
|
// Log redis error but continue without cache
|
||||||
|
console.warn('Redis cache read failed:', redisError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = this.getInitials(name);
|
const initials = this.getInitials(name);
|
||||||
|
@ -29,41 +49,85 @@ export default class AvatarController {
|
||||||
const svgContent = this.createSvg(size, colors, initials);
|
const svgContent = this.createSvg(size, colors, initials);
|
||||||
|
|
||||||
// // Cache the generated avatar for future use, e.g. 1 hour expiry
|
// // Cache the generated avatar for future use, e.g. 1 hour expiry
|
||||||
await redis.setex(cacheKey, 3600, svgContent);
|
try {
|
||||||
|
await redis.setex(cacheKey, CACHE_TTL, svgContent);
|
||||||
|
} catch (redisError) {
|
||||||
|
// Log but don't fail the request
|
||||||
|
console.warn('Redis cache write failed:', redisError);
|
||||||
|
}
|
||||||
|
|
||||||
this.setResponseHeaders(response);
|
this.setResponseHeaders(response);
|
||||||
return response.send(svgContent);
|
return response.send(svgContent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message });
|
console.error('Avatar generation error:', error);
|
||||||
|
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
|
||||||
|
error: 'Failed to generate avatar',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInitials(name: string): string {
|
private validateSize(size: any): { isValid: boolean; value?: number; error?: string } {
|
||||||
const parts = name
|
const numSize = Number(size);
|
||||||
|
|
||||||
|
if (isNaN(numSize)) {
|
||||||
|
return { isValid: false, error: 'Size must be a valid number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numSize < MIN_SIZE || numSize > MAX_SIZE) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `Size must be between ${MIN_SIZE} and ${MAX_SIZE}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, value: Math.floor(numSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeName(name: string): string {
|
||||||
|
return name
|
||||||
.trim()
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitials(name: string): string {
|
||||||
|
const sanitized = name.trim().replace(/\s+/g, ' '); // normalize whitespace
|
||||||
|
const parts = sanitized
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.filter((part) => part.length > 0);
|
.filter((part) => part.length > 0)
|
||||||
|
.map((part) => part.trim());
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return 'NA';
|
return 'NA';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length === 1) {
|
||||||
return this.getMultiWordInitials(parts);
|
// For single word, take first 2 characters or first char if only 1 char
|
||||||
|
return parts[0].substring(0, Math.min(2, parts[0].length)).toUpperCase();
|
||||||
}
|
}
|
||||||
return parts[0].substring(0, 2).toUpperCase();
|
|
||||||
|
return this.getMultiWordInitials(parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMultiWordInitials(parts: string[]): string {
|
private getMultiWordInitials(parts: string[]): string {
|
||||||
const firstName = parts[0];
|
// Filter out prefixes and short words
|
||||||
const lastName = parts[parts.length - 1];
|
const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1);
|
||||||
const firstInitial = firstName.charAt(0).toUpperCase();
|
|
||||||
const lastInitial = lastName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
|
if (significantParts.length === 0) {
|
||||||
return firstInitial + lastName.charAt(1).toUpperCase();
|
// Fallback to first and last regardless of prefixes
|
||||||
|
const firstName = parts[0];
|
||||||
|
const lastName = parts[parts.length - 1];
|
||||||
|
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
|
||||||
}
|
}
|
||||||
return firstInitial + lastInitial;
|
|
||||||
|
if (significantParts.length === 1) {
|
||||||
|
return significantParts[0].substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take first and last significant parts
|
||||||
|
const firstName = significantParts[0];
|
||||||
|
const lastName = significantParts[significantParts.length - 1];
|
||||||
|
return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateColors(name: string): { background: string; text: string } {
|
private generateColors(name: string): { background: string; text: string } {
|
||||||
|
@ -75,31 +139,44 @@ export default class AvatarController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
|
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
|
||||||
const fontSize = size * FONT_SIZE_RATIO;
|
const fontSize = Math.max(12, Math.floor(size * FONT_SIZE_RATIO)); // Ensure readable font size
|
||||||
return `
|
|
||||||
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
// Escape any potential HTML/XML characters in initials
|
||||||
<rect width="100%" height="100%" fill="#${colors.background}"/>
|
const escapedInitials = this.escapeXml(initials);
|
||||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${fontSize}" fill="#${colors.text}">${initials}</text>
|
|
||||||
</svg>
|
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">
|
||||||
`;
|
<rect width="100%" height="100%" fill="#${colors.background}" rx="${size * 0.1}"/>
|
||||||
|
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
|
||||||
|
font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||||
|
font-size="${fontSize}" fill="#${colors.text}">${escapedInitials}</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeXml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
private setResponseHeaders(response: HttpContext['response']): void {
|
private setResponseHeaders(response: HttpContext['response']): void {
|
||||||
response.header('Content-type', 'image/svg+xml');
|
response.header('Content-Type', 'image/svg+xml');
|
||||||
response.header('Cache-Control', 'no-cache');
|
response.header('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||||
response.header('Pragma', 'no-cache');
|
response.header('ETag', `"${Date.now()}"`); // Simple ETag
|
||||||
response.header('Expires', '0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getColorFromName(name: string): string {
|
private getColorFromName(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < name.length; i++) {
|
const normalizedName = name.toLowerCase().trim();
|
||||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
|
for (let i = 0; i < normalizedName.length; i++) {
|
||||||
|
hash = normalizedName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we get vibrant colors by constraining the color space
|
||||||
const colorParts = [];
|
const colorParts = [];
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
let value = (hash >> (i * 8)) & 0xff;
|
||||||
|
// Ensure minimum color intensity for better contrast
|
||||||
|
value = Math.max(50, value);
|
||||||
colorParts.push(value.toString(16).padStart(2, '0'));
|
colorParts.push(value.toString(16).padStart(2, '0'));
|
||||||
}
|
}
|
||||||
return colorParts.join('');
|
return colorParts.join('');
|
||||||
|
@ -110,7 +187,7 @@ export default class AvatarController {
|
||||||
const g = parseInt(hexColor.substring(2, 4), 16);
|
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||||
const b = parseInt(hexColor.substring(4, 6), 16);
|
const b = parseInt(hexColor.substring(4, 6), 16);
|
||||||
|
|
||||||
const lightenValue = (value: number) => Math.min(255, Math.floor((value * (100 + percent)) / 100));
|
const lightenValue = (value: number) => Math.min(255, Math.floor(value + (255 - value) * (percent / 100)));
|
||||||
|
|
||||||
const newR = lightenValue(r);
|
const newR = lightenValue(r);
|
||||||
const newG = lightenValue(g);
|
const newG = lightenValue(g);
|
||||||
|
@ -124,7 +201,7 @@ export default class AvatarController {
|
||||||
const g = parseInt(hexColor.slice(2, 4), 16);
|
const g = parseInt(hexColor.slice(2, 4), 16);
|
||||||
const b = parseInt(hexColor.slice(4, 6), 16);
|
const b = parseInt(hexColor.slice(4, 6), 16);
|
||||||
|
|
||||||
const darkenValue = (value: number) => Math.round(value * COLOR_DARKENING_FACTOR);
|
const darkenValue = (value: number) => Math.max(0, Math.floor(value * COLOR_DARKENING_FACTOR));
|
||||||
|
|
||||||
const darkerR = darkenValue(r);
|
const darkerR = darkenValue(r);
|
||||||
const darkerG = darkenValue(g);
|
const darkerG = darkenValue(g);
|
||||||
|
|
|
@ -9,8 +9,7 @@ export default class DatasetController {
|
||||||
// Select datasets with server_state 'published' or 'deleted' and sort by the last published date
|
// Select datasets with server_state 'published' or 'deleted' and sort by the last published date
|
||||||
const datasets = await Dataset.query()
|
const datasets = await Dataset.query()
|
||||||
.where(function (query) {
|
.where(function (query) {
|
||||||
query.where('server_state', 'published')
|
query.where('server_state', 'published').orWhere('server_state', 'deleted');
|
||||||
.orWhere('server_state', 'deleted');
|
|
||||||
})
|
})
|
||||||
.preload('titles')
|
.preload('titles')
|
||||||
.preload('identifier')
|
.preload('identifier')
|
||||||
|
@ -39,12 +38,26 @@ export default class DatasetController {
|
||||||
.where('publish_id', params.publish_id)
|
.where('publish_id', params.publish_id)
|
||||||
.preload('titles')
|
.preload('titles')
|
||||||
.preload('descriptions')
|
.preload('descriptions')
|
||||||
.preload('user')
|
.preload('user', (builder) => {
|
||||||
|
builder.select(['id', 'firstName', 'lastName', 'avatar', 'login']);
|
||||||
|
})
|
||||||
.preload('authors', (builder) => {
|
.preload('authors', (builder) => {
|
||||||
builder.orderBy('pivot_sort_order', 'asc');
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order'])
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
})
|
})
|
||||||
.preload('contributors', (builder) => {
|
.preload('contributors', (builder) => {
|
||||||
builder.orderBy('pivot_sort_order', 'asc');
|
builder
|
||||||
|
.select(['id', 'academic_title', 'first_name', 'last_name', 'identifier_orcid', 'status', 'name_type'])
|
||||||
|
.withCount('datasets', (query) => {
|
||||||
|
query.as('datasets_count');
|
||||||
|
})
|
||||||
|
.pivotColumns(['role', 'sort_order', 'contributor_type'])
|
||||||
|
.orderBy('pivot_sort_order', 'asc');
|
||||||
})
|
})
|
||||||
.preload('subjects')
|
.preload('subjects')
|
||||||
.preload('coverage')
|
.preload('coverage')
|
||||||
|
|
|
@ -2,53 +2,89 @@ import type { HttpContext } from '@adonisjs/core/http';
|
||||||
import File from '#models/file';
|
import File from '#models/file';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
// node ace make:controller Author
|
// node ace make:controller Author
|
||||||
export default class FileController {
|
export default class FileController {
|
||||||
// @Get("download/:id")
|
// @Get("download/:id")
|
||||||
public async findOne({ response, params }: HttpContext) {
|
public async findOne({ response, params }: HttpContext) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const file = await File.findOrFail(id);
|
// const file = await File.findOrFail(id);
|
||||||
// const file = await File.findOne({
|
// Load file with its related dataset to check embargo
|
||||||
// where: { id: id },
|
const file = await File.query()
|
||||||
// });
|
.where('id', id)
|
||||||
if (file) {
|
.preload('dataset') // or 'dataset' - whatever your relationship is named
|
||||||
const filePath = '/storage/app/data/' + file.pathName;
|
.firstOrFail();
|
||||||
const ext = path.extname(filePath);
|
|
||||||
const fileName = file.label + ext;
|
|
||||||
try {
|
|
||||||
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
|
|
||||||
// console.log("can read/write:", path);
|
|
||||||
|
|
||||||
response
|
|
||||||
.header('Cache-Control', 'no-cache private')
|
|
||||||
.header('Content-Description', 'File Transfer')
|
|
||||||
.header('Content-Type', file.mimeType)
|
|
||||||
.header('Content-Disposition', 'inline; filename=' + fileName)
|
|
||||||
.header('Content-Transfer-Encoding', 'binary')
|
|
||||||
.header('Access-Control-Allow-Origin', '*')
|
|
||||||
.header('Access-Control-Allow-Methods', 'GET,POST');
|
|
||||||
|
|
||||||
response.status(StatusCodes.OK).download(filePath);
|
|
||||||
} catch (err) {
|
|
||||||
// console.log("no access:", path);
|
|
||||||
response.status(StatusCodes.NOT_FOUND).send({
|
|
||||||
message: `File with id ${id} doesn't exist on file server`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// res.status(StatusCodes.OK).sendFile(filePath, (err) => {
|
if (!file) {
|
||||||
// // res.setHeader("Content-Type", "application/json");
|
return response.status(StatusCodes.NOT_FOUND).send({
|
||||||
// // res.removeHeader("Content-Disposition");
|
|
||||||
// res.status(StatusCodes.NOT_FOUND).send({
|
|
||||||
// message: `File with id ${id} doesn't exist on file server`,
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
} else {
|
|
||||||
response.status(StatusCodes.NOT_FOUND).send({
|
|
||||||
message: `Cannot find File with id=${id}.`,
|
message: `Cannot find File with id=${id}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataset = file.dataset;
|
||||||
|
// Files from unpublished datasets are now blocked
|
||||||
|
if (dataset.server_state !== 'published') {
|
||||||
|
return response.status(StatusCodes.FORBIDDEN).send({
|
||||||
|
message: `File access denied: Dataset is not published.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dataset && this.isUnderEmbargo(dataset.embargo_date)) {
|
||||||
|
return response.status(StatusCodes.FORBIDDEN).send({
|
||||||
|
message: `File is under embargo until ${dataset.embargo_date?.toFormat('yyyy-MM-dd')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with file download
|
||||||
|
const filePath = '/storage/app/data/' + file.pathName;
|
||||||
|
const fileExt = file.filePath.split('.').pop() || '';
|
||||||
|
// const fileName = file.label + fileExt;
|
||||||
|
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`)
|
||||||
|
? file.label
|
||||||
|
: `${file.label}.${fileExt}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.R_OK); //| fs.constants.W_OK);
|
||||||
|
// console.log("can read/write:", filePath);
|
||||||
|
|
||||||
|
response
|
||||||
|
.header('Cache-Control', 'no-cache private')
|
||||||
|
.header('Content-Description', 'File Transfer')
|
||||||
|
.header('Content-Type', file.mimeType)
|
||||||
|
.header('Content-Disposition', 'inline; filename=' + fileName)
|
||||||
|
.header('Content-Transfer-Encoding', 'binary')
|
||||||
|
.header('Access-Control-Allow-Origin', '*')
|
||||||
|
.header('Access-Control-Allow-Methods', 'GET');
|
||||||
|
|
||||||
|
response.status(StatusCodes.OK).download(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
// console.log("no access:", path);
|
||||||
|
response.status(StatusCodes.NOT_FOUND).send({
|
||||||
|
message: `File with id ${id} doesn't exist on file server`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the dataset is under embargo
|
||||||
|
* Compares only dates (ignoring time) for embargo check
|
||||||
|
* @param embargoDate - The embargo date from dataset
|
||||||
|
* @returns true if under embargo, false if embargo has passed or no embargo set
|
||||||
|
*/
|
||||||
|
private isUnderEmbargo(embargoDate: DateTime | null): boolean {
|
||||||
|
// No embargo date set - allow download
|
||||||
|
if (!embargoDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current date at start of day (00:00:00)
|
||||||
|
const today = DateTime.now().startOf('day');
|
||||||
|
|
||||||
|
// Get embargo date at start of day (00:00:00)
|
||||||
|
const embargoDateOnly = embargoDate.startOf('day');
|
||||||
|
|
||||||
|
// File is under embargo if embargo date is after today
|
||||||
|
// This means the embargo lifts at the start of the embargo date
|
||||||
|
return embargoDateOnly >= today;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -252,7 +252,6 @@ export default class DatasetsController {
|
||||||
dataset.reject_editor_note = null;
|
dataset.reject_editor_note = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//save main and additional titles
|
//save main and additional titles
|
||||||
const reviewer_id = request.input('reviewer_id', null);
|
const reviewer_id = request.input('reviewer_id', null);
|
||||||
dataset.reviewer_id = reviewer_id;
|
dataset.reviewer_id = reviewer_id;
|
||||||
|
@ -290,8 +289,6 @@ export default class DatasetsController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public async rejectUpdate({ request, response, auth }: HttpContext) {
|
public async rejectUpdate({ request, response, auth }: HttpContext) {
|
||||||
const authUser = auth.user!;
|
const authUser = auth.user!;
|
||||||
|
|
||||||
|
@ -402,12 +399,10 @@ export default class DatasetsController {
|
||||||
.back();
|
.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return inertia.render('Editor/Dataset/Publish', {
|
return inertia.render('Editor/Dataset/Publish', {
|
||||||
dataset,
|
dataset,
|
||||||
can: {
|
can: {
|
||||||
reject: await auth.user?.can(['dataset-editor-reject']),
|
reject: await auth.user?.can(['dataset-editor-reject']),
|
||||||
publish: await auth.user?.can(['dataset-publish']),
|
publish: await auth.user?.can(['dataset-publish']),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -454,7 +449,7 @@ export default class DatasetsController {
|
||||||
public async rejectToReviewer({ request, inertia, response }: HttpContext) {
|
public async rejectToReviewer({ request, inertia, response }: HttpContext) {
|
||||||
const id = request.param('id');
|
const id = request.param('id');
|
||||||
const dataset = await Dataset.query()
|
const dataset = await Dataset.query()
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
.preload('reviewer', (builder) => {
|
.preload('reviewer', (builder) => {
|
||||||
builder.select('id', 'login', 'email');
|
builder.select('id', 'login', 'email');
|
||||||
})
|
})
|
||||||
|
@ -555,7 +550,6 @@ export default class DatasetsController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
.flash(
|
.flash(
|
||||||
`You have successfully rejected dataset ${dataset.id} reviewed by ${dataset.reviewer.login}.${emailStatusMessage}`,
|
`You have successfully rejected dataset ${dataset.id} reviewed by ${dataset.reviewer.login}.${emailStatusMessage}`,
|
||||||
|
@ -605,11 +599,13 @@ export default class DatasetsController {
|
||||||
doiIdentifier.dataset_id = dataset.id;
|
doiIdentifier.dataset_id = dataset.id;
|
||||||
doiIdentifier.type = 'doi';
|
doiIdentifier.type = 'doi';
|
||||||
doiIdentifier.status = 'findable';
|
doiIdentifier.status = 'findable';
|
||||||
// save modified date of datset for re-caching model in db an update the search index
|
|
||||||
dataset.server_date_modified = DateTime.now();
|
|
||||||
|
|
||||||
// save updated dataset to db an index to OpenSearch
|
// save updated dataset to db an index to OpenSearch
|
||||||
try {
|
try {
|
||||||
|
// save modified date of datset for re-caching model in db an update the search index
|
||||||
|
dataset.server_date_modified = DateTime.now();
|
||||||
|
// autoUpdate: true only triggers when dataset.save() is called, not when saving a related model like below
|
||||||
|
await dataset.save();
|
||||||
await dataset.related('identifier').save(doiIdentifier);
|
await dataset.related('identifier').save(doiIdentifier);
|
||||||
const index_name = 'tethys-records';
|
const index_name = 'tethys-records';
|
||||||
await Index.indexDocument(dataset, index_name);
|
await Index.indexDocument(dataset, index_name);
|
||||||
|
@ -900,6 +896,7 @@ export default class DatasetsController {
|
||||||
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
|
const input = request.only(['project_id', 'embargo_date', 'language', 'type', 'creating_corporation']);
|
||||||
// dataset.type = request.input('type');
|
// dataset.type = request.input('type');
|
||||||
dataset.merge(input);
|
dataset.merge(input);
|
||||||
|
dataset.server_date_modified = DateTime.now();
|
||||||
// let test: boolean = dataset.$isDirty;
|
// let test: boolean = dataset.$isDirty;
|
||||||
await dataset.useTransaction(trx).save();
|
await dataset.useTransaction(trx).save();
|
||||||
|
|
||||||
|
@ -1121,9 +1118,20 @@ export default class DatasetsController {
|
||||||
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||||
const filePath = file.filePath;
|
const filePath = file.filePath;
|
||||||
const fileExt = file.filePath.split('.').pop() || '';
|
const fileExt = file.filePath.split('.').pop() || '';
|
||||||
|
|
||||||
|
// Check if label already includes the extension
|
||||||
|
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
|
||||||
|
|
||||||
// Set the response headers and download the file
|
// Set the response headers and download the file
|
||||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
response
|
||||||
response.attachment(`${file.label}.${fileExt}`);
|
.header('Cache-Control', 'no-cache private')
|
||||||
|
.header('Content-Description', 'File Transfer')
|
||||||
|
.header('Content-Type', file.mime_type || 'application/octet-stream')
|
||||||
|
// .header('Content-Disposition', 'inline; filename=' + fileName)
|
||||||
|
.header('Content-Transfer-Encoding', 'binary')
|
||||||
|
.header('Access-Control-Allow-Origin', '*')
|
||||||
|
.header('Access-Control-Allow-Methods', 'GET');
|
||||||
|
response.attachment(fileName);
|
||||||
return response.download(filePath);
|
return response.download(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,13 +107,12 @@ export default class DatasetsController {
|
||||||
}
|
}
|
||||||
|
|
||||||
return inertia.render('Reviewer/Dataset/Review', {
|
return inertia.render('Reviewer/Dataset/Review', {
|
||||||
dataset,
|
dataset,
|
||||||
can: {
|
can: {
|
||||||
review: await auth.user?.can(['dataset-review']),
|
review: await auth.user?.can(['dataset-review']),
|
||||||
reject: await auth.user?.can(['dataset-review-reject']),
|
reject: await auth.user?.can(['dataset-review-reject']),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async review_old({ request, inertia, response, auth }: HttpContext) {
|
public async review_old({ request, inertia, response, auth }: HttpContext) {
|
||||||
|
@ -370,6 +369,19 @@ export default class DatasetsController {
|
||||||
.flash(`You have rejected dataset ${dataset.id}! to editor ${dataset.editor.login}`, 'message');
|
.flash(`You have rejected dataset ${dataset.id}! to editor ${dataset.editor.login}`, 'message');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// public async download({ params, response }: HttpContext) {
|
||||||
|
// const id = params.id;
|
||||||
|
// // Find the file by ID
|
||||||
|
// const file = await File.findOrFail(id);
|
||||||
|
// // const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||||
|
// const filePath = file.filePath;
|
||||||
|
// const fileExt = file.filePath.split('.').pop() || '';
|
||||||
|
// // Set the response headers and download the file
|
||||||
|
// response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
||||||
|
// response.attachment(`${file.label}.${fileExt}`);
|
||||||
|
// return response.download(filePath);
|
||||||
|
// }
|
||||||
|
|
||||||
public async download({ params, response }: HttpContext) {
|
public async download({ params, response }: HttpContext) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
// Find the file by ID
|
// Find the file by ID
|
||||||
|
@ -377,9 +389,20 @@ export default class DatasetsController {
|
||||||
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
// const filePath = await drive.use('local').getUrl('/'+ file.filePath)
|
||||||
const filePath = file.filePath;
|
const filePath = file.filePath;
|
||||||
const fileExt = file.filePath.split('.').pop() || '';
|
const fileExt = file.filePath.split('.').pop() || '';
|
||||||
|
|
||||||
|
// Check if label already includes the extension
|
||||||
|
const fileName = file.label.toLowerCase().endsWith(`.${fileExt.toLowerCase()}`) ? file.label : `${file.label}.${fileExt}`;
|
||||||
|
|
||||||
// Set the response headers and download the file
|
// Set the response headers and download the file
|
||||||
response.header('Content-Type', file.mime_type || 'application/octet-stream');
|
response
|
||||||
response.attachment(`${file.label}.${fileExt}`);
|
.header('Cache-Control', 'no-cache private')
|
||||||
|
.header('Content-Description', 'File Transfer')
|
||||||
|
.header('Content-Type', file.mime_type || 'application/octet-stream')
|
||||||
|
// .header('Content-Disposition', 'inline; filename=' + fileName)
|
||||||
|
.header('Content-Transfer-Encoding', 'binary')
|
||||||
|
.header('Access-Control-Allow-Origin', '*')
|
||||||
|
.header('Access-Control-Allow-Methods', 'GET');
|
||||||
|
response.attachment(fileName);
|
||||||
return response.download(filePath);
|
return response.download(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,7 +233,7 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ -249,7 +249,7 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
|
@ -324,7 +324,7 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ -340,7 +340,7 @@ export default class DatasetController {
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
|
@ -983,19 +983,6 @@ export default class DatasetController {
|
||||||
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
|
const years = Array.from({ length: currentYear - 1990 + 1 }, (_, index) => 1990 + index);
|
||||||
|
|
||||||
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
|
const licenses = await License.query().select('id', 'name_long').where('active', 'true').pluck('name_long', 'id');
|
||||||
// const userHasRoles = user.roles;
|
|
||||||
// const datasetHasLicenses = await dataset.related('licenses').query().pluck('id');
|
|
||||||
// const checkeds = dataset.licenses.first().id;
|
|
||||||
|
|
||||||
// const doctypes = {
|
|
||||||
// analysisdata: { label: 'Analysis', value: 'analysisdata' },
|
|
||||||
// measurementdata: { label: 'Measurements', value: 'measurementdata' },
|
|
||||||
// monitoring: 'Monitoring',
|
|
||||||
// remotesensing: 'Remote Sensing',
|
|
||||||
// gis: 'GIS',
|
|
||||||
// models: 'Models',
|
|
||||||
// mixedtype: 'Mixed Type',
|
|
||||||
// };
|
|
||||||
|
|
||||||
return inertia.render('Submitter/Dataset/Edit', {
|
return inertia.render('Submitter/Dataset/Edit', {
|
||||||
dataset,
|
dataset,
|
||||||
|
@ -1015,7 +1002,7 @@ export default class DatasetController {
|
||||||
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
|
referenceIdentifierTypes: Object.entries(ReferenceIdentifierTypes).map(([key, value]) => ({ value: key, label: value })),
|
||||||
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
|
relationTypes: Object.entries(RelationTypes).map(([key, value]) => ({ value: key, label: value })),
|
||||||
doctypes: DatasetTypes,
|
doctypes: DatasetTypes,
|
||||||
can: {
|
can: {
|
||||||
edit: await auth.user?.can(['dataset-edit']),
|
edit: await auth.user?.can(['dataset-edit']),
|
||||||
delete: await auth.user?.can(['dataset-delete']),
|
delete: await auth.user?.can(['dataset-delete']),
|
||||||
},
|
},
|
||||||
|
@ -1163,42 +1150,93 @@ export default class DatasetController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all subjects/keywords from the request
|
// ============================================
|
||||||
const subjects = request.input('subjects');
|
// IMPROVED SUBJECTS PROCESSING
|
||||||
|
// ============================================
|
||||||
|
const subjects = request.input('subjects', []);
|
||||||
|
const currentDatasetSubjectIds = new Set<number>();
|
||||||
|
|
||||||
for (const subjectData of subjects) {
|
for (const subjectData of subjects) {
|
||||||
// Case 1: Subject already exists in the database (has an ID)
|
let subjectToRelate: Subject;
|
||||||
|
|
||||||
|
// Case 1: Subject has an ID (existing subject being updated)
|
||||||
if (subjectData.id) {
|
if (subjectData.id) {
|
||||||
// Retrieve the existing subject
|
|
||||||
const existingSubject = await Subject.findOrFail(subjectData.id);
|
const existingSubject = await Subject.findOrFail(subjectData.id);
|
||||||
|
|
||||||
// Update subject properties from the request data
|
// Check if the updated value conflicts with another existing subject
|
||||||
existingSubject.value = subjectData.value;
|
const duplicateSubject = await Subject.query()
|
||||||
existingSubject.type = subjectData.type;
|
.where('value', subjectData.value)
|
||||||
existingSubject.external_key = subjectData.external_key;
|
.where('type', subjectData.type)
|
||||||
|
.where('language', subjectData.language || 'en') // Default language if not provided
|
||||||
|
.where('id', '!=', subjectData.id) // Exclude the current subject
|
||||||
|
.first();
|
||||||
|
|
||||||
// Only save if there are actual changes
|
if (duplicateSubject) {
|
||||||
if (existingSubject.$isDirty) {
|
// A duplicate exists - use the existing duplicate instead
|
||||||
await existingSubject.save();
|
subjectToRelate = duplicateSubject;
|
||||||
|
|
||||||
|
// Check if the original subject should be deleted (if it's only used by this dataset)
|
||||||
|
const originalSubjectUsage = await Subject.query()
|
||||||
|
.where('id', existingSubject.id)
|
||||||
|
.withCount('datasets')
|
||||||
|
.firstOrFail();
|
||||||
|
|
||||||
|
if (originalSubjectUsage.$extras.datasets_count <= 1) {
|
||||||
|
// Only used by this dataset, safe to delete after detaching
|
||||||
|
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
|
||||||
|
await existingSubject.useTransaction(trx).delete();
|
||||||
|
} else {
|
||||||
|
// Used by other datasets, just detach from this one
|
||||||
|
await dataset.useTransaction(trx).related('subjects').detach([existingSubject.id]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No duplicate found, update the existing subject
|
||||||
|
existingSubject.value = subjectData.value;
|
||||||
|
existingSubject.type = subjectData.type;
|
||||||
|
existingSubject.language = subjectData.language;
|
||||||
|
existingSubject.external_key = subjectData.external_key;
|
||||||
|
|
||||||
|
if (existingSubject.$isDirty) {
|
||||||
|
await existingSubject.useTransaction(trx).save();
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectToRelate = existingSubject;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The relationship between dataset and subject is already established,
|
|
||||||
// so we don't need to attach it again
|
|
||||||
}
|
}
|
||||||
// Case 2: New subject being added (no ID)
|
// Case 2: New subject being added (no ID)
|
||||||
else {
|
else {
|
||||||
// Check if a subject with the same value and type already exists in the database
|
// Use firstOrNew to either find existing or create new subject
|
||||||
const subject = await Subject.firstOrNew({ value: subjectData.value, type: subjectData.type }, subjectData);
|
subjectToRelate = await Subject.firstOrNew(
|
||||||
|
{
|
||||||
|
value: subjectData.value,
|
||||||
|
type: subjectData.type,
|
||||||
|
language: subjectData.language || 'en',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: subjectData.value,
|
||||||
|
type: subjectData.type,
|
||||||
|
language: subjectData.language || 'en',
|
||||||
|
external_key: subjectData.external_key,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (subject.$isNew === true) {
|
if (subjectToRelate.$isNew) {
|
||||||
// If it's a completely new subject, create and associate it with the dataset
|
await subjectToRelate.useTransaction(trx).save();
|
||||||
await dataset.useTransaction(trx).related('subjects').save(subject);
|
|
||||||
} else {
|
|
||||||
// If the subject already exists, just create the relationship
|
|
||||||
await dataset.useTransaction(trx).related('subjects').attach([subject.id]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the relationship exists between dataset and subject
|
||||||
|
const relationshipExists = await dataset.related('subjects').query().where('subject_id', subjectToRelate.id).first();
|
||||||
|
|
||||||
|
if (!relationshipExists) {
|
||||||
|
await dataset.useTransaction(trx).related('subjects').attach([subjectToRelate.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which subjects should remain associated with this dataset
|
||||||
|
currentDatasetSubjectIds.add(subjectToRelate.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle explicit deletions
|
||||||
const subjectsToDelete = request.input('subjectsToDelete', []);
|
const subjectsToDelete = request.input('subjectsToDelete', []);
|
||||||
for (const subjectData of subjectsToDelete) {
|
for (const subjectData of subjectsToDelete) {
|
||||||
if (subjectData.id) {
|
if (subjectData.id) {
|
||||||
|
@ -1211,16 +1249,16 @@ export default class DatasetController {
|
||||||
.withCount('datasets')
|
.withCount('datasets')
|
||||||
.firstOrFail();
|
.firstOrFail();
|
||||||
|
|
||||||
// Check if the subject is used by multiple datasets
|
// Detach the subject from this dataset
|
||||||
if (subject.$extras.datasets_count > 1) {
|
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
||||||
// If used by multiple datasets, just detach it from the current dataset
|
|
||||||
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
|
||||||
} else {
|
|
||||||
// If only used by this dataset, delete the subject completely
|
|
||||||
|
|
||||||
await dataset.useTransaction(trx).related('subjects').detach([subject.id]);
|
// If this was the only dataset using this subject, delete it entirely
|
||||||
|
if (subject.$extras.datasets_count <= 1) {
|
||||||
await subject.useTransaction(trx).delete();
|
await subject.useTransaction(trx).delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from current set if it was added earlier
|
||||||
|
currentDatasetSubjectIds.delete(subjectData.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default class Person extends BaseModel {
|
||||||
@column({})
|
@column({})
|
||||||
public lastName: string;
|
public lastName: string;
|
||||||
|
|
||||||
@column({})
|
@column({ columnName: 'identifier_orcid' })
|
||||||
public identifierOrcid: string;
|
public identifierOrcid: string;
|
||||||
|
|
||||||
@column({})
|
@column({})
|
||||||
|
@ -95,4 +95,34 @@ export default class Person extends BaseModel {
|
||||||
pivotColumns: ['role', 'sort_order', 'allow_email_contact'],
|
pivotColumns: ['role', 'sort_order', 'allow_email_contact'],
|
||||||
})
|
})
|
||||||
public datasets: ManyToMany<typeof Dataset>;
|
public datasets: ManyToMany<typeof Dataset>;
|
||||||
|
|
||||||
|
// public toJSON() {
|
||||||
|
// const json = super.toJSON();
|
||||||
|
|
||||||
|
// // Check if this person is loaded through a pivot relationship with sensitive roles
|
||||||
|
// const pivotRole = this.$extras?.pivot_role;
|
||||||
|
// if (pivotRole === 'author' || pivotRole === 'contributor') {
|
||||||
|
// // Remove sensitive information for public-facing roles
|
||||||
|
// delete json.email;
|
||||||
|
// // delete json.identifierOrcid;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return json;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @afterFind()
|
||||||
|
// public static async afterFindHook(person: Person) {
|
||||||
|
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
|
||||||
|
// person.email = undefined as any;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @afterFetch()
|
||||||
|
// public static async afterFetchHook(persons: Person[]) {
|
||||||
|
// persons.forEach(person => {
|
||||||
|
// if (person.$extras?.pivot_role === 'author' || person.$extras?.pivot_role === 'contributor') {
|
||||||
|
// person.email = undefined as any;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,24 +89,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
||||||
@column({})
|
@column({})
|
||||||
public avatar: string;
|
public avatar: string;
|
||||||
|
|
||||||
// @hasOne(() => TotpSecret, {
|
|
||||||
// foreignKey: 'user_id',
|
|
||||||
// })
|
|
||||||
// public totp_secret: HasOne<typeof TotpSecret>;
|
|
||||||
|
|
||||||
// @beforeSave()
|
|
||||||
// public static async hashPassword(user: User) {
|
|
||||||
// if (user.$dirty.password) {
|
|
||||||
// user.password = await hash.use('laravel').make(user.password);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
public get isTwoFactorEnabled(): boolean {
|
public get isTwoFactorEnabled(): boolean {
|
||||||
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
|
return Boolean(this?.twoFactorSecret && this.state == TotpState.STATE_ENABLED);
|
||||||
// return Boolean(this.totp_secret?.twoFactorSecret);
|
// return Boolean(this.totp_secret?.twoFactorSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@manyToMany(() => Role, {
|
@manyToMany(() => Role, {
|
||||||
pivotForeignKey: 'account_id',
|
pivotForeignKey: 'account_id',
|
||||||
pivotRelatedForeignKey: 'role_id',
|
pivotRelatedForeignKey: 'role_id',
|
||||||
|
@ -142,7 +129,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
|
||||||
@beforeFind()
|
@beforeFind()
|
||||||
@beforeFetch()
|
@beforeFetch()
|
||||||
public static preloadRoles(user: User) {
|
public static preloadRoles(user: User) {
|
||||||
user.preload('roles')
|
user.preload('roles', (builder) => {
|
||||||
|
builder.select(['id', 'name', 'display_name', 'description']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBackupCodes(this: User): Promise<BackupCode[]> {
|
public async getBackupCodes(this: User): Promise<BackupCode[]> {
|
||||||
|
|
|
@ -67,8 +67,9 @@ export const createDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
@ -83,9 +84,10 @@ export const createDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.distinct('email')
|
.distinct('email')
|
||||||
|
@ -214,8 +216,9 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
@ -230,8 +233,9 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ -307,10 +311,9 @@ export const updateDatasetValidator = vine.compile(
|
||||||
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
|
.dependentArrayMinLength({ dependentArray: 'fileInputs', min: 1 }),
|
||||||
fileInputs: vine.array(
|
fileInputs: vine.array(
|
||||||
vine.object({
|
vine.object({
|
||||||
label: vine.string().trim().maxLength(100),
|
label: vine.string().trim().maxLength(100),
|
||||||
//extnames: extensions,
|
|
||||||
}),
|
}),
|
||||||
),
|
).optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -365,8 +368,9 @@ export const updateEditorDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.minLength(1)
|
.minLength(1)
|
||||||
|
@ -381,8 +385,9 @@ export const updateEditorDatasetValidator = vine.compile(
|
||||||
.email()
|
.email()
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
.isUniquePerson({ table: 'persons', column: 'email', idField: 'id' }),
|
||||||
first_name: vine.string().trim().minLength(3).maxLength(255),
|
first_name: vine.string().trim().minLength(3).maxLength(255).optional().requiredWhen('name_type', '=', 'Personal'),
|
||||||
last_name: vine.string().trim().minLength(3).maxLength(255),
|
last_name: vine.string().trim().minLength(3).maxLength(255),
|
||||||
|
identifier_orcid: vine.string().trim().maxLength(255).orcid().optional(),
|
||||||
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
pivot_contributor_type: vine.enum(Object.keys(ContributorTypes)),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,10 +12,8 @@ import { getDomain } from '#app/utils/utility-functions';
|
||||||
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
import { BaseCommand, flags } from '@adonisjs/core/ace';
|
||||||
import { CommandOptions } from '@adonisjs/core/types/ace';
|
import { CommandOptions } from '@adonisjs/core/types/ace';
|
||||||
import env from '#start/env';
|
import env from '#start/env';
|
||||||
// import db from '@adonisjs/lucid/services/db';
|
|
||||||
// import { default as Dataset } from '#models/dataset';
|
|
||||||
import logger from '@adonisjs/core/services/logger';
|
import logger from '@adonisjs/core/services/logger';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
|
const opensearchNode = env.get('OPENSEARCH_HOST', 'localhost');
|
||||||
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
|
const client = new Client({ node: `${opensearchNode}` }); // replace with your OpenSearch endpoint
|
||||||
|
@ -30,11 +28,10 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
public publish_id: number;
|
public publish_id: number;
|
||||||
|
|
||||||
public static options: CommandOptions = {
|
public static options: CommandOptions = {
|
||||||
startApp: true,
|
startApp: true, // Ensures the IoC container is ready to use
|
||||||
staysAlive: false,
|
staysAlive: false, // Command exits after running
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
logger.debug('Hello world!');
|
logger.debug('Hello world!');
|
||||||
// const { default: Dataset } = await import('#models/dataset');
|
// const { default: Dataset } = await import('#models/dataset');
|
||||||
|
@ -44,10 +41,12 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
const index_name = 'tethys-records';
|
const index_name = 'tethys-records';
|
||||||
|
|
||||||
for (var dataset of datasets) {
|
for (var dataset of datasets) {
|
||||||
// Logger.info(`File publish_id ${dataset.publish_id}`);
|
const shouldUpdate = await this.shouldUpdateDataset(dataset, index_name);
|
||||||
// const jsonString = await this.getJsonString(dataset, proc);
|
if (shouldUpdate) {
|
||||||
// console.log(jsonString);
|
await this.indexDocument(dataset, index_name, proc);
|
||||||
await this.indexDocument(dataset, index_name, proc);
|
} else {
|
||||||
|
logger.info(`Dataset with publish_id ${dataset.publish_id} is up to date, skipping indexing`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +64,46 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
return await query.exec();
|
return await query.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async shouldUpdateDataset(dataset: Dataset, index_name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if publish_id exists before proceeding
|
||||||
|
if (!dataset.publish_id) {
|
||||||
|
// Return true to update since document doesn't exist in OpenSearch yet
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Get the existing document from OpenSearch
|
||||||
|
const response = await client.get({
|
||||||
|
index: index_name,
|
||||||
|
id: dataset.publish_id?.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingDoc = response.body._source;
|
||||||
|
|
||||||
|
// Compare server_date_modified
|
||||||
|
if (existingDoc && existingDoc.server_date_modified) {
|
||||||
|
// Convert Unix timestamp (seconds) to milliseconds for DateTime.fromMillis()
|
||||||
|
const existingModified = DateTime.fromMillis(Number(existingDoc.server_date_modified) * 1000);
|
||||||
|
const currentModified = dataset.server_date_modified;
|
||||||
|
|
||||||
|
// Only update if the dataset has been modified more recently
|
||||||
|
if (currentModified <= existingModified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// If document doesn't exist or other error, we should index it
|
||||||
|
if (error.statusCode === 404) {
|
||||||
|
logger.info(`Dataset with publish_id ${dataset.publish_id} not found in index, will create new document`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`Error checking existing document for publish_id ${dataset.publish_id}: ${error.message}`);
|
||||||
|
return true; // Index anyway if we can't determine the status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
|
private async indexDocument(dataset: Dataset, index_name: string, proc: Buffer): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const doc = await this.getJsonString(dataset, proc);
|
const doc = await this.getJsonString(dataset, proc);
|
||||||
|
@ -78,7 +117,8 @@ export default class IndexDatasets extends BaseCommand {
|
||||||
});
|
});
|
||||||
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
logger.info(`dataset with publish_id ${dataset.publish_id} successfully indexed`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.`);
|
logger.error(`An error occurred while indexing dataset with publish_id ${dataset.publish_id}.
|
||||||
|
Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
3794
package-lock.json
generated
3794
package-lock.json
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
@ -111,7 +111,14 @@
|
||||||
<!--5 server_date_modified -->
|
<!--5 server_date_modified -->
|
||||||
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
|
<xsl:if test="ServerDateModified/@UnixTimestamp != ''">
|
||||||
<xsl:text>"server_date_modified": "</xsl:text>
|
<xsl:text>"server_date_modified": "</xsl:text>
|
||||||
<xsl:value-of select="/ServerDateModified/@UnixTimestamp" />
|
<xsl:value-of select="ServerDateModified/@UnixTimestamp" />
|
||||||
|
<xsl:text>",</xsl:text>
|
||||||
|
</xsl:if>
|
||||||
|
|
||||||
|
<!--5 embargo_date -->
|
||||||
|
<xsl:if test="EmbargoDate/@UnixTimestamp != ''">
|
||||||
|
<xsl:text>"embargo_date": "</xsl:text>
|
||||||
|
<xsl:value-of select="EmbargoDate/@UnixTimestamp" />
|
||||||
<xsl:text>",</xsl:text>
|
<xsl:text>",</xsl:text>
|
||||||
</xsl:if>
|
</xsl:if>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, PropType } from 'vue';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
// import { Link } from '@inertiajs/inertia-vue3';
|
// import { Link } from '@inertiajs/inertia-vue3';
|
||||||
import { getButtonColor } from '@/colors';
|
import { getButtonColor } from '@/colors';
|
||||||
|
@ -30,8 +30,8 @@ const props = defineProps({
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
type: String,
|
type: String as PropType<'white' | 'contrast' | 'light' | 'success' | 'danger' | 'warning' | 'info' | 'modern'>,
|
||||||
default: 'white',
|
default: 'white',
|
||||||
},
|
},
|
||||||
as: {
|
as: {
|
||||||
|
@ -45,11 +45,18 @@ const props = defineProps({
|
||||||
roundedFull: Boolean,
|
roundedFull: Boolean,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
const is = computed(() => {
|
const is = computed(() => {
|
||||||
if (props.as) {
|
if (props.as) {
|
||||||
return props.as;
|
return props.as;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If disabled, always render as button or span to prevent navigation
|
||||||
|
if (props.disabled) {
|
||||||
|
return props.routeName || props.href ? 'span' : 'button';
|
||||||
|
}
|
||||||
|
|
||||||
if (props.routeName) {
|
if (props.routeName) {
|
||||||
return Link;
|
return Link;
|
||||||
}
|
}
|
||||||
|
@ -69,47 +76,105 @@ const computedType = computed(() => {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only provide href/routeName when not disabled
|
||||||
|
const computedHref = computed(() => {
|
||||||
|
if (props.disabled) return null;
|
||||||
|
return props.routeName || props.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only provide target when not disabled and has href
|
||||||
|
const computedTarget = computed(() => {
|
||||||
|
if (props.disabled || !props.href) return null;
|
||||||
|
return props.target;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only provide disabled attribute for actual button elements
|
||||||
|
const computedDisabled = computed(() => {
|
||||||
|
if (is.value === 'button') {
|
||||||
|
return props.disabled;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
|
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'));
|
||||||
|
|
||||||
const componentClass = computed(() => {
|
const componentClass = computed(() => {
|
||||||
const base = [
|
const base = [
|
||||||
'inline-flex',
|
'inline-flex',
|
||||||
'cursor-pointer',
|
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'items-center',
|
'items-center',
|
||||||
'whitespace-nowrap',
|
'whitespace-nowrap',
|
||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
'focus:ring-2',
|
|
||||||
'duration-150',
|
'duration-150',
|
||||||
'border',
|
'border',
|
||||||
props.roundedFull ? 'rounded-full' : 'rounded',
|
props.roundedFull ? 'rounded-full' : 'rounded',
|
||||||
props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700',
|
|
||||||
getButtonColor(props.color, props.outline, !props.disabled),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only add focus ring styles when not disabled
|
||||||
|
if (!props.disabled) {
|
||||||
|
base.push('focus:ring-2');
|
||||||
|
base.push(props.active ? 'ring ring-black dark:ring-white' : 'ring-blue-700');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add button colors
|
||||||
|
// Add button colors - handle both string and array returns
|
||||||
|
// const buttonColors = getButtonColor(props.color, props.outline, !props.disabled);
|
||||||
|
base.push(getButtonColor(props.color, props.outline, !props.disabled));
|
||||||
|
// if (Array.isArray(buttonColors)) {
|
||||||
|
// base.push(...buttonColors);
|
||||||
|
// } else {
|
||||||
|
// base.push(buttonColors);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Add size classes
|
||||||
if (props.small) {
|
if (props.small) {
|
||||||
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
|
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1');
|
||||||
} else {
|
} else {
|
||||||
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
|
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add disabled/enabled specific classes
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
base.push('cursor-not-allowed', props.outline ? 'opacity-50' : 'opacity-70');
|
base.push(
|
||||||
|
'cursor-not-allowed',
|
||||||
|
'opacity-60',
|
||||||
|
'pointer-events-none', // This prevents all interactions
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
base.push('cursor-pointer');
|
||||||
|
// Add hover effects only when not disabled
|
||||||
|
if (is.value === 'button' || is.value === 'a' || is.value === Link) {
|
||||||
|
base.push('hover:opacity-80');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle click events with disabled check
|
||||||
|
const handleClick = (event) => {
|
||||||
|
if (props.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('click', event);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="is"
|
:is="is"
|
||||||
:class="componentClass"
|
:class="componentClass"
|
||||||
:href="routeName ? routeName : href"
|
:href="computedHref"
|
||||||
|
:to="props.disabled ? null : props.routeName"
|
||||||
:type="computedType"
|
:type="computedType"
|
||||||
:target="target"
|
:target="computedTarget"
|
||||||
:disabled="disabled"
|
:disabled="computedDisabled"
|
||||||
|
:tabindex="props.disabled ? -1 : null"
|
||||||
|
:aria-disabled="props.disabled ? 'true' : null"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<BaseIcon v-if="icon" :path="icon" />
|
<BaseIcon v-if="icon" :path="icon" />
|
||||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch, ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -13,32 +13,138 @@ const props = defineProps<Props>();
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
|
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
|
||||||
|
|
||||||
|
// const computedValue = computed({
|
||||||
|
// get: () => props.modelValue,
|
||||||
|
// set: (value) => {
|
||||||
|
// emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
const computedValue = computed({
|
const computedValue = computed({
|
||||||
get: () => props.modelValue,
|
get: () => {
|
||||||
set: (value) => {
|
if (props.type === 'radio') {
|
||||||
emit('update:modelValue', props.type === 'radio' ? [value] : value);
|
// For radio buttons, return boolean indicating if this option is selected
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
return [props.modelValue];
|
||||||
|
} else {
|
||||||
|
// For checkboxes, return boolean indicating if this option is included
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.includes(props.inputValue);
|
||||||
|
}
|
||||||
|
return props.modelValue == props.inputValue;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (props.type === 'radio') {
|
||||||
|
// When radio is selected, emit the new value as array
|
||||||
|
emit('update:modelValue', [value]);
|
||||||
|
} else {
|
||||||
|
// Handle checkboxes
|
||||||
|
let updatedValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
|
||||||
|
if (value) {
|
||||||
|
if (!updatedValue.includes(props.inputValue)) {
|
||||||
|
updatedValue.push(props.inputValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedValue = updatedValue.filter(item => item != props.inputValue);
|
||||||
|
}
|
||||||
|
emit('update:modelValue', updatedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
|
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
|
||||||
|
|
||||||
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
|
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
|
||||||
const isChecked = computed(() => {
|
// const isChecked = computed(() => {
|
||||||
if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
// if (Array.isArray(computedValue.value) && computedValue.value.length > 0) {
|
||||||
return props.type === 'radio'
|
// return props.type === 'radio'
|
||||||
? computedValue.value[0] === props.inputValue
|
// ? computedValue.value[0] === props.inputValue
|
||||||
: computedValue.value.includes(props.inputValue);
|
// : computedValue.value.includes(props.inputValue);
|
||||||
|
// }
|
||||||
|
// return computedValue.value === props.inputValue;
|
||||||
|
// });
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// return computedValue.value[0] === props.inputValue;
|
||||||
|
// });
|
||||||
|
// Fix the isChecked computation with proper type handling
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// if (props.type === 'radio') {
|
||||||
|
// // Use loose equality to handle string/number conversion
|
||||||
|
// return computedValue.value == props.inputValue;
|
||||||
|
// }
|
||||||
|
// return computedValue.value === true;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const isChecked = computed(() => {
|
||||||
|
// if (props.type === 'radio') {
|
||||||
|
// if (Array.isArray(props.modelValue)) {
|
||||||
|
// return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||||
|
// }
|
||||||
|
// return props.modelValue == props.inputValue;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // For checkboxes
|
||||||
|
// if (Array.isArray(props.modelValue)) {
|
||||||
|
// return props.modelValue.includes(props.inputValue);
|
||||||
|
// }
|
||||||
|
// return props.modelValue == props.inputValue;
|
||||||
|
// });
|
||||||
|
// Use a ref for isChecked and update it with a watcher
|
||||||
|
const isChecked = ref(false);
|
||||||
|
// Calculate initial isChecked value
|
||||||
|
const calculateIsChecked = () => {
|
||||||
|
if (props.type === 'radio') {
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.length > 0 && props.modelValue[0] == props.inputValue;
|
||||||
|
}
|
||||||
|
return props.modelValue == props.inputValue;
|
||||||
}
|
}
|
||||||
return computedValue.value === props.inputValue;
|
|
||||||
});
|
// For checkboxes
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
return props.modelValue.includes(props.inputValue);
|
||||||
|
}
|
||||||
|
return props.modelValue == props.inputValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
|
||||||
|
// Watch for changes in modelValue and recalculate isChecked
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
console.log('modelValue changed:', {
|
||||||
|
newValue,
|
||||||
|
inputValue: props.inputValue,
|
||||||
|
type: props.type
|
||||||
|
});
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also watch inputValue in case it changes
|
||||||
|
watch(
|
||||||
|
() => props.inputValue,
|
||||||
|
() => {
|
||||||
|
isChecked.value = calculateIsChecked();
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label v-if="type === 'radio'" :class="[type]"
|
<label v-if="type === 'radio'" :class="[type]"
|
||||||
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
|
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
|
||||||
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
|
<input
|
||||||
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0"
|
v-model="computedValue"
|
||||||
:checked="isChecked" />
|
:type="inputType"
|
||||||
|
:name="name"
|
||||||
|
:value="inputValue"
|
||||||
|
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
|
||||||
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
|
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
|
||||||
'border-gray-700': !isChecked,
|
'border-gray-700': !isChecked,
|
||||||
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
|
'bg-radio-checked bg-no-repeat bg-center bg-lime-600 border-lime-600 border-4': isChecked
|
||||||
|
|
|
@ -38,32 +38,82 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
const computedValue = computed({
|
// const computedValue = computed({
|
||||||
// get: () => props.modelValue,
|
// // get: () => props.modelValue,
|
||||||
get: () => {
|
// get: () => {
|
||||||
// const ids = props.modelValue.map((obj) => obj.id);
|
// // const ids = props.modelValue.map((obj) => obj.id);
|
||||||
// return ids;
|
// // return ids;
|
||||||
if (Array.isArray(props.modelValue)) {
|
// if (Array.isArray(props.modelValue)) {
|
||||||
if (props.modelValue.every((item) => typeof item === 'number')) {
|
// if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||||
return props.modelValue;
|
// return props.modelValue;
|
||||||
} else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
// } else if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||||
const ids = props.modelValue.map((obj) => obj.id);
|
// const ids = props.modelValue.map((obj) => obj.id);
|
||||||
return ids;
|
// return ids;
|
||||||
}
|
// }
|
||||||
return props.modelValue;
|
// return props.modelValue;
|
||||||
}
|
// }
|
||||||
// return props.modelValue;
|
// // return props.modelValue;
|
||||||
},
|
// },
|
||||||
set: (value) => {
|
// set: (value) => {
|
||||||
emit('update:modelValue', value);
|
// emit('update:modelValue', value);
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Define a type guard to check if an object has an 'id' attribute
|
// Define a type guard to check if an object has an 'id' attribute
|
||||||
// function hasIdAttribute(obj: any): obj is { id: any } {
|
// function hasIdAttribute(obj: any): obj is { id: any } {
|
||||||
// return typeof obj === 'object' && 'id' in obj;
|
// return typeof obj === 'object' && 'id' in obj;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
const computedValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!props.modelValue) return props.modelValue;
|
||||||
|
|
||||||
|
if (Array.isArray(props.modelValue)) {
|
||||||
|
// Handle empty array
|
||||||
|
if (props.modelValue.length === 0) return [];
|
||||||
|
|
||||||
|
// If all items are objects with id property
|
||||||
|
if (props.modelValue.every((item) => hasIdAttribute(item))) {
|
||||||
|
return props.modelValue.map((obj) => {
|
||||||
|
// Ensure we return the correct type based on the options keys
|
||||||
|
const id = obj.id;
|
||||||
|
// Check if options keys are numbers or strings
|
||||||
|
const optionKeys = Object.keys(props.options);
|
||||||
|
if (optionKeys.length > 0) {
|
||||||
|
// If option keys are numeric strings, return number
|
||||||
|
if (optionKeys.every(key => !isNaN(Number(key)))) {
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all items are numbers
|
||||||
|
if (props.modelValue.every((item) => typeof item === 'number')) {
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all items are strings that represent numbers
|
||||||
|
if (props.modelValue.every((item) => typeof item === 'string' && !isNaN(Number(item)))) {
|
||||||
|
// Convert to numbers if options keys are numeric
|
||||||
|
const optionKeys = Object.keys(props.options);
|
||||||
|
if (optionKeys.length > 0 && optionKeys.every(key => !isNaN(Number(key)))) {
|
||||||
|
return props.modelValue.map(item => Number(item));
|
||||||
|
}
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is for other cases
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.modelValue;
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
const hasIdAttribute = (obj: any): obj is { id: any } => {
|
const hasIdAttribute = (obj: any): obj is { id: any } => {
|
||||||
return typeof obj === 'object' && 'id' in obj;
|
return typeof obj === 'object' && 'id' in obj;
|
||||||
};
|
};
|
||||||
|
@ -110,7 +160,7 @@ const inputElClass = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
<!-- <FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
||||||
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
|
:name="name" :input-value="key" :label="value" :class="componentClass" /> -->
|
||||||
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
|
<FormCheckRadio v-for="(value, key) in options" key="`${name}-${key}-${JSON.stringify(computedValue)}`" v-model="computedValue" :type="type"
|
||||||
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
|
:name="name" :input-value="isNaN(Number(key)) ? key : Number(key)" :label="value" :class="componentClass" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,45 +1,69 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
// import { MainService } from '@/Stores/main';
|
|
||||||
// import { StyleService } from '@/Stores/style.service';
|
|
||||||
import { mdiTrashCan } from '@mdi/js';
|
import { mdiTrashCan } from '@mdi/js';
|
||||||
import { mdiDragVariant } from '@mdi/js';
|
import { mdiDragVariant, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import BaseIcon from '@/Components/BaseIcon.vue';
|
import BaseIcon from '@/Components/BaseIcon.vue';
|
||||||
// import CardBoxModal from '@/Components/CardBoxModal.vue';
|
|
||||||
// import TableCheckboxCell from '@/Components/TableCheckboxCell.vue';
|
|
||||||
// import BaseLevel from '@/Components/BaseLevel.vue';
|
|
||||||
import BaseButtons from '@/Components/BaseButtons.vue';
|
import BaseButtons from '@/Components/BaseButtons.vue';
|
||||||
import BaseButton from '@/Components/BaseButton.vue';
|
import BaseButton from '@/Components/BaseButton.vue';
|
||||||
// import UserAvatar from '@/Components/UserAvatar.vue';
|
|
||||||
// import Person from 'App/Models/Person';
|
|
||||||
import { Person } from '@/Dataset';
|
import { Person } from '@/Dataset';
|
||||||
import Draggable from 'vuedraggable';
|
import Draggable from 'vuedraggable';
|
||||||
import FormControl from '@/Components/FormControl.vue';
|
import FormControl from '@/Components/FormControl.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
interface Props {
|
||||||
checkable: Boolean,
|
checkable?: boolean;
|
||||||
persons: {
|
persons?: Person[];
|
||||||
type: Array<Person>,
|
relation: string;
|
||||||
default: () => [],
|
contributortypes?: Record<string, string>;
|
||||||
},
|
errors?: Record<string, string[]>;
|
||||||
relation: {
|
isLoading?: boolean;
|
||||||
type: String,
|
canDelete?: boolean;
|
||||||
required: true,
|
canEdit?: boolean;
|
||||||
},
|
canReorder?: boolean;
|
||||||
contributortypes: {
|
}
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
// const props = defineProps({
|
||||||
},
|
// checkable: Boolean,
|
||||||
errors: {
|
// persons: {
|
||||||
type: Object,
|
// type: Array<Person>,
|
||||||
default: () => ({}),
|
// default: () => [],
|
||||||
},
|
// },
|
||||||
|
// relation: {
|
||||||
|
// type: String,
|
||||||
|
// required: true,
|
||||||
|
// },
|
||||||
|
// contributortypes: {
|
||||||
|
// type: Object,
|
||||||
|
// default: () => ({}),
|
||||||
|
// },
|
||||||
|
// errors: {
|
||||||
|
// type: Object,
|
||||||
|
// default: () => ({}),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
checkable: false,
|
||||||
|
persons: () => [],
|
||||||
|
contributortypes: () => ({}),
|
||||||
|
errors: () => ({}),
|
||||||
|
isLoading: false,
|
||||||
|
canDelete: true,
|
||||||
|
canEdit: true,
|
||||||
|
canReorder: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// const styleService = StyleService();
|
const emit = defineEmits<{
|
||||||
// const mainService = MainService();
|
'update:persons': [value: Person[]];
|
||||||
// const items = computed(() => props.persons);
|
'remove-person': [index: number, person: Person];
|
||||||
|
'person-updated': [index: number, person: Person];
|
||||||
|
'reorder': [oldIndex: number, newIndex: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const perPage = ref(5);
|
||||||
|
const currentPage = ref(0);
|
||||||
|
const dragEnabled = ref(props.canReorder);
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
const items = computed({
|
const items = computed({
|
||||||
get() {
|
get() {
|
||||||
return props.persons;
|
return props.persons;
|
||||||
|
@ -53,221 +77,419 @@ const items = computed({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const isModalActive = ref(false);
|
const itemsPaginated = computed(() => {
|
||||||
// const isModalDangerActive = ref(false);
|
const start = perPage.value * currentPage.value;
|
||||||
const perPage = ref(5);
|
const end = perPage.value * (currentPage.value + 1);
|
||||||
const currentPage = ref(0);
|
return items.value.slice(start, end);
|
||||||
// const checkedRows = ref([]);
|
|
||||||
|
|
||||||
const itemsPaginated = computed({
|
|
||||||
get() {
|
|
||||||
return items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1));
|
|
||||||
},
|
|
||||||
// setter
|
|
||||||
set(value) {
|
|
||||||
// Note: we are using destructuring assignment syntax here.
|
|
||||||
|
|
||||||
props.persons.length = 0;
|
|
||||||
props.persons.push(...value);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
|
||||||
|
|
||||||
const currentPageHuman = computed(() => currentPage.value + 1);
|
const currentPageHuman = computed(() => currentPage.value + 1);
|
||||||
|
const hasMultiplePages = computed(() => numPages.value > 1);
|
||||||
|
const showContributorTypes = computed(() => Object.keys(props.contributortypes).length > 0);
|
||||||
|
|
||||||
const pagesList = computed(() => {
|
const pagesList = computed(() => {
|
||||||
const pagesList: Array<number> = [];
|
const pages: number[] = [];
|
||||||
|
const maxVisible = 10;
|
||||||
|
|
||||||
for (let i = 0; i < numPages.value; i++) {
|
if (numPages.value <= maxVisible) {
|
||||||
pagesList.push(i);
|
for (let i = 0; i < numPages.value; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Smart pagination with ellipsis
|
||||||
|
if (currentPage.value <= 2) {
|
||||||
|
for (let i = 0; i < 4; i++) pages.push(i);
|
||||||
|
pages.push(-1); // Ellipsis marker
|
||||||
|
pages.push(numPages.value - 1);
|
||||||
|
} else if (currentPage.value >= numPages.value - 3) {
|
||||||
|
pages.push(0);
|
||||||
|
pages.push(-1);
|
||||||
|
for (let i = numPages.value - 4; i < numPages.value; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pages.push(0);
|
||||||
|
pages.push(-1);
|
||||||
|
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
pages.push(-1);
|
||||||
|
pages.push(numPages.value - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return pagesList;
|
return pages;
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeAuthor = (key: number) => {
|
// const removeAuthor = (key: number) => {
|
||||||
items.value.splice(key, 1);
|
// items.value.splice(key, 1);
|
||||||
|
// };
|
||||||
|
// Methods
|
||||||
|
const removeAuthor = (index: number) => {
|
||||||
|
const actualIndex = perPage.value * currentPage.value + index;
|
||||||
|
const person = items.value[actualIndex];
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to remove ${person.first_name || ''} ${person.last_name || person.email}?`)) {
|
||||||
|
items.value.splice(actualIndex, 1);
|
||||||
|
emit('remove-person', actualIndex, person);
|
||||||
|
|
||||||
|
// Adjust current page if needed
|
||||||
|
if (itemsPaginated.value.length === 0 && currentPage.value > 0) {
|
||||||
|
currentPage.value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// const remove = (arr, cb) => {
|
const updatePerson = (index: number, field: keyof Person, value: any) => {
|
||||||
// const newArr = [];
|
const actualIndex = perPage.value * currentPage.value + index;
|
||||||
|
const person = items.value[actualIndex];
|
||||||
|
(person as any)[field] = value;
|
||||||
|
emit('person-updated', actualIndex, person);
|
||||||
|
};
|
||||||
|
|
||||||
// arr.forEach((item) => {
|
const goToPage = (page: number) => {
|
||||||
// if (!cb(item)) {
|
if (page >= 0 && page < numPages.value) {
|
||||||
// newArr.push(item);
|
currentPage.value = page;
|
||||||
// }
|
}
|
||||||
// });
|
};
|
||||||
|
|
||||||
// return newArr;
|
const getFieldError = (index: number, field: string): string => {
|
||||||
// };
|
const actualIndex = perPage.value * currentPage.value + index;
|
||||||
|
const errorKey = `${props.relation}.${actualIndex}.${field}`;
|
||||||
|
return props.errors[errorKey]?.join(', ') || '';
|
||||||
|
};
|
||||||
|
|
||||||
// const checked = (isChecked, client) => {
|
const handleDragEnd = (evt: any) => {
|
||||||
// if (isChecked) {
|
if (evt.oldIndex !== evt.newIndex) {
|
||||||
// checkedRows.value.push(client);
|
emit('reorder', evt.oldIndex, evt.newIndex);
|
||||||
// } else {
|
}
|
||||||
// checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id);
|
};
|
||||||
// }
|
|
||||||
// };
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => props.persons.length,
|
||||||
|
() => {
|
||||||
|
// Reset to first page if current page is out of bounds
|
||||||
|
if (currentPage.value >= numPages.value && numPages.value > 0) {
|
||||||
|
currentPage.value = numPages.value - 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pagination helper
|
||||||
|
const perPageOptions = [
|
||||||
|
{ value: 5, label: '5 per page' },
|
||||||
|
{ value: 10, label: '10 per page' },
|
||||||
|
{ value: 20, label: '20 per page' },
|
||||||
|
{ value: 50, label: '50 per page' },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- <CardBoxModal v-model="isModalActive" title="Sample modal">
|
<div class="card">
|
||||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
<!-- Table Controls -->
|
||||||
<p>This is sample modal</p>
|
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-b border-gray-200 dark:border-slate-700">
|
||||||
</CardBoxModal>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {{ currentPage * perPage + 1 }} to
|
||||||
|
{{ Math.min((currentPage + 1) * perPage, items.length) }}
|
||||||
|
of {{ items.length }} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="perPage"
|
||||||
|
@change="currentPage = 0"
|
||||||
|
class="px-3 py-1 text-sm border rounded-md dark:bg-slate-800 dark:border-slate-600"
|
||||||
|
>
|
||||||
|
<option v-for="option in perPageOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
|
<!-- Table -->
|
||||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
<div class="overflow-x-auto">
|
||||||
<p>This is sample modal</p>
|
<table class="w-full">
|
||||||
</CardBoxModal> -->
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<th v-if="canReorder" class="w-10 p-3" />
|
||||||
|
<th scope="col" class="text-left p-3">#</th>
|
||||||
|
<th scope="col">Id</th>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Last Name / Organization</th>
|
||||||
|
<th>Orcid</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th v-if="showContributorTypes" scope="col" class="text-left p-3">Type</th>
|
||||||
|
<th v-if="canDelete" class="w-20 p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<!-- <tbody> -->
|
||||||
|
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
||||||
|
<draggable
|
||||||
|
v-if="canReorder && !hasMultiplePages"
|
||||||
|
tag="tbody"
|
||||||
|
v-model="items"
|
||||||
|
item-key="id"
|
||||||
|
:disabled="!dragEnabled || isLoading"
|
||||||
|
@end="handleDragEnd"
|
||||||
|
handle=".drag-handle"
|
||||||
|
>
|
||||||
|
<template #item="{ index, element }">
|
||||||
|
<tr class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800">
|
||||||
|
<td class="p-3">
|
||||||
|
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600">
|
||||||
|
<BaseIcon :path="mdiDragVariant" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">{{ index + 1 }}</td>
|
||||||
|
<td data-label="Id" class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
||||||
|
|
||||||
<!-- <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
|
<!-- First Name - Hidden for Organizational -->
|
||||||
<span v-for="checkedRow in checkedRows" :key="checkedRow.id"
|
<td class="p-3" data-label="First Name" v-if="element.name_type !== 'Organizational'">
|
||||||
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700">
|
<FormControl
|
||||||
{{ checkedRow.name }}
|
required
|
||||||
</span>
|
v-model="element.first_name"
|
||||||
</div> -->
|
type="text"
|
||||||
|
:is-read-only="element.status == true"
|
||||||
|
placeholder="[FIRST NAME]"
|
||||||
|
/>
|
||||||
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])">
|
||||||
|
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-else></td>
|
||||||
|
<!-- Empty cell for organizational entries -->
|
||||||
|
|
||||||
<table>
|
<!-- Last Name / Organization Name -->
|
||||||
<thead>
|
<td :data-label="element.name_type === 'Organizational' ? 'Organization Name' : 'Last Name'">
|
||||||
<tr>
|
<FormControl
|
||||||
<!-- <th v-if="checkable" /> -->
|
required
|
||||||
<th />
|
v-model="element.last_name"
|
||||||
<th scope="col">Sort</th>
|
type="text"
|
||||||
<th scope="col">Id</th>
|
:is-read-only="element.status == true"
|
||||||
<!-- <th class="hidden lg:table-cell"></th> -->
|
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
||||||
<th>First Name</th>
|
/>
|
||||||
<th>Last Name</th>
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])">
|
||||||
<th>Email</th>
|
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
||||||
<th scope="col" v-if="Object.keys(contributortypes).length">
|
</div>
|
||||||
<span>Type</span>
|
</td>
|
||||||
</th>
|
|
||||||
|
|
||||||
<!-- <th>Name Type</th> -->
|
<!-- Orcid -->
|
||||||
<!-- <th>Progress</th> -->
|
<td data-label="Orcid">
|
||||||
<!-- <th>Created</th> -->
|
<FormControl
|
||||||
<th />
|
v-model="element.identifier_orcid"
|
||||||
</tr>
|
type="text"
|
||||||
</thead>
|
:is-read-only="element.status == true"
|
||||||
<!-- <tbody> -->
|
/>
|
||||||
<!-- <tr v-for="(client, index) in itemsPaginated" :key="client.id"> -->
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])">
|
||||||
<draggable id="galliwasery" tag="tbody" v-model="items" item-key="id">
|
{{ errors[`${relation}.${index}.identifier_orcid`].join(', ') }}
|
||||||
<template #item="{ index, element }">
|
</div>
|
||||||
<tr>
|
</td>
|
||||||
<td class="drag-icon">
|
|
||||||
<BaseIcon :path="mdiDragVariant" />
|
<!-- Email -->
|
||||||
</td>
|
<td data-label="Email">
|
||||||
<td scope="row">{{ index + 1 }}</td>
|
<FormControl
|
||||||
<td data-label="Id">{{ element.id }}</td>
|
required
|
||||||
<!-- <TableCheckboxCell v-if="checkable" @checked="checked($event, client)" /> -->
|
v-model="element.email"
|
||||||
<!-- <td v-if="element.name" class="border-b-0 lg:w-6 before:hidden hidden lg:table-cell">
|
type="text"
|
||||||
<UserAvatar :username="element.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
|
:is-read-only="element.status == true"
|
||||||
</td> -->
|
placeholder="[EMAIL]"
|
||||||
<td data-label="First Name">
|
/>
|
||||||
<!-- {{ element.first_name }} -->
|
<div class="text-red-400 text-sm" v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])">
|
||||||
<FormControl
|
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
||||||
required
|
</div>
|
||||||
v-model="element.first_name"
|
</td>
|
||||||
type="text" :is-read-only="element.status==true"
|
|
||||||
placeholder="[FIRST NAME]"
|
<!-- Contributor Type -->
|
||||||
>
|
<td v-if="Object.keys(contributortypes).length">
|
||||||
<div
|
<FormControl
|
||||||
class="text-red-400 text-sm"
|
required
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"
|
v-model="element.pivot_contributor_type"
|
||||||
>
|
type="select"
|
||||||
{{ errors[`${relation}.${index}.first_name`].join(', ') }}
|
:options="contributortypes"
|
||||||
|
placeholder="[relation type]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-red-400 text-sm"
|
||||||
|
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
||||||
|
>
|
||||||
|
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
||||||
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
|
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" />
|
||||||
|
</BaseButtons>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<!-- </tbody> -->
|
||||||
|
<!-- Non-draggable tbody for paginated view -->
|
||||||
|
<tbody v-else>
|
||||||
|
<tr
|
||||||
|
v-for="(element, index) in itemsPaginated"
|
||||||
|
:key="element.id || index"
|
||||||
|
class="border-b border-gray-100 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<td v-if="canReorder" class="p-3 text-gray-400">
|
||||||
|
<BaseIcon :path="mdiDragVariant" />
|
||||||
|
</td>
|
||||||
|
<td class="p-3">{{ currentPage * perPage + index + 1 }}</td>
|
||||||
|
<td class="p-3 text-sm text-gray-600">{{ element.id || '-' }}</td>
|
||||||
|
|
||||||
|
<!-- Same field structure as draggable version -->
|
||||||
|
<td class="p-3">
|
||||||
|
<FormControl
|
||||||
|
v-if="element.name_type !== 'Organizational'"
|
||||||
|
required
|
||||||
|
:model-value="element.first_name"
|
||||||
|
@update:model-value="updatePerson(index, 'first_name', $event)"
|
||||||
|
type="text"
|
||||||
|
:is-read-only="element.status || !canEdit"
|
||||||
|
placeholder="[FIRST NAME]"
|
||||||
|
:error="getFieldError(index, 'first_name')"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
<div v-if="getFieldError(index, 'first_name')" class="text-red-400 text-sm">
|
||||||
|
{{ getFieldError(index, 'first_name') }}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</td>
|
||||||
</td>
|
|
||||||
<td data-label="Last Name">
|
<td class="p-3">
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
required
|
||||||
v-model="element.last_name"
|
:model-value="element.last_name"
|
||||||
type="text" :is-read-only="element.status==true"
|
@update:model-value="updatePerson(index, 'last_name', $event)"
|
||||||
placeholder="[LAST NAME]"
|
type="text"
|
||||||
>
|
:is-read-only="element.status || !canEdit"
|
||||||
<div
|
:placeholder="element.name_type === 'Organizational' ? '[ORGANIZATION NAME]' : '[LAST NAME]'"
|
||||||
class="text-red-400 text-sm"
|
:error="getFieldError(index, 'last_name')"
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"
|
/>
|
||||||
>
|
<div v-if="getFieldError(index, 'last_name')" class="text-red-400 text-sm">
|
||||||
{{ errors[`${relation}.${index}.last_name`].join(', ') }}
|
{{ getFieldError(index, 'last_name') }}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</td>
|
||||||
</td>
|
|
||||||
<td data-label="Email">
|
<td class="p-3">
|
||||||
<FormControl
|
<FormControl
|
||||||
required
|
:model-value="element.identifier_orcid"
|
||||||
v-model="element.email"
|
@update:model-value="updatePerson(index, 'identifier_orcid', $event)"
|
||||||
type="text" :is-read-only="element.status==true"
|
type="text"
|
||||||
placeholder="[EMAIL]"
|
:is-read-only="element.status || !canEdit"
|
||||||
>
|
:error="getFieldError(index, 'identifier_orcid')"
|
||||||
<div
|
/>
|
||||||
class="text-red-400 text-sm"
|
<div v-if="getFieldError(index, 'identifier_orcid')" class="text-red-400 text-sm">
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"
|
{{ getFieldError(index, 'identifier_orcid') }}
|
||||||
>
|
|
||||||
{{ errors[`${relation}.${index}.email`].join(', ') }}
|
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</td>
|
||||||
</td>
|
|
||||||
<td v-if="Object.keys(contributortypes).length">
|
<td class="p-3">
|
||||||
<!-- <select type="text" v-model="element.pivot.contributor_type">
|
<FormControl
|
||||||
<option v-for="(option, i) in contributortypes" :value="option" :key="i">
|
required
|
||||||
{{ option }}
|
:model-value="element.email"
|
||||||
</option>
|
@update:model-value="updatePerson(index, 'email', $event)"
|
||||||
</select> -->
|
type="email"
|
||||||
<FormControl
|
:is-read-only="element.status || !canEdit"
|
||||||
required
|
placeholder="[EMAIL]"
|
||||||
v-model="element.pivot_contributor_type"
|
:error="getFieldError(index, 'email')"
|
||||||
type="select"
|
/>
|
||||||
:options="contributortypes"
|
<div v-if="getFieldError(index, 'email')" class="text-red-400 text-sm">
|
||||||
placeholder="[relation type]"
|
{{ getFieldError(index, 'email') }}
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="text-red-400 text-sm"
|
|
||||||
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
|
|
||||||
>
|
|
||||||
{{ errors[`${relation}.${index}.pivot_contributor_type`].join(', ') }}
|
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</td>
|
||||||
</td>
|
|
||||||
<!-- <td data-label="Name Type">
|
<td v-if="showContributorTypes" class="p-3">
|
||||||
{{ client.name_type }}
|
<FormControl
|
||||||
</td> -->
|
required
|
||||||
<!-- <td data-label="Orcid">
|
:model-value="element.pivot_contributor_type"
|
||||||
{{ client.identifier_orcid }}
|
@update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
|
||||||
</td> -->
|
type="select"
|
||||||
<!-- <td data-label="Progress" class="lg:w-32">
|
:options="contributortypes"
|
||||||
<progress class="flex w-2/5 self-center lg:w-full" max="100" v-bind:value="client.progress">
|
:is-read-only="element.status || !canEdit"
|
||||||
{{ client.progress }}
|
placeholder="[Select type]"
|
||||||
</progress>
|
:error="getFieldError(index, 'pivot_contributor_type')"
|
||||||
</td> -->
|
/>
|
||||||
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
<div v-if="getFieldError(index, 'pivot_contributor_type')" class="text-red-400 text-sm">
|
||||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
{{ getFieldError(index, 'pivot_contributor_type') }}
|
||||||
<!-- <BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> -->
|
</div>
|
||||||
<BaseButton color="danger" :icon="mdiTrashCan" small @click.prevent="removeAuthor(index)" />
|
</td>
|
||||||
</BaseButtons>
|
|
||||||
</td>
|
<td v-if="canDelete" class="p-3">
|
||||||
</tr>
|
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||||
</template>
|
<BaseButton
|
||||||
</draggable>
|
color="danger"
|
||||||
<!-- </tbody> -->
|
:icon="mdiTrashCan"
|
||||||
</table>
|
small
|
||||||
<!-- :class="[ pagesList.length > 1 ? 'block' : 'hidden']" -->
|
@click="removeAuthor(index)"
|
||||||
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
:disabled="element.status || !canEdit"
|
||||||
<!-- <BaseLevel>
|
title="Remove person"
|
||||||
<BaseButtons>
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<tr v-if="items.length === 0">
|
||||||
|
<td :colspan="canReorder ? 8 : 7" class="text-center p-8 text-gray-500">No persons added yet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<BaseButton :disabled="currentPage === 0" @click="goToPage(currentPage - 1)" :icon="mdiChevronLeft" small outline />
|
||||||
|
|
||||||
|
<template v-for="(page, i) in pagesList" :key="i">
|
||||||
|
<span v-if="page === -1" class="px-3 py-1">...</span>
|
||||||
|
<BaseButton
|
||||||
|
v-else
|
||||||
|
@click="goToPage(page)"
|
||||||
|
:label="String(page + 1)"
|
||||||
|
:color="page === currentPage ? 'info' : 'whiteDark'"
|
||||||
|
small
|
||||||
|
:outline="page !== currentPage"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-for="page in pagesList"
|
:disabled="currentPage >= numPages - 1"
|
||||||
:key="page"
|
@click="goToPage(currentPage + 1)"
|
||||||
:active="page === currentPage"
|
:icon="mdiChevronRight"
|
||||||
:label="page + 1"
|
|
||||||
small
|
small
|
||||||
:outline="styleService.darkMode"
|
outline
|
||||||
@click="currentPage = page"
|
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</div>
|
||||||
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
|
|
||||||
</BaseLevel> -->
|
<span class="text-sm text-gray-600 dark:text-gray-400"> Page {{ currentPageHuman }} of {{ numPages }} </span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.drag-handle {
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-slate-900 rounded-lg shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve table responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
287
resources/js/Components/UnsavedChangesWarning.vue
Normal file
|
@ -0,0 +1,287 @@
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-300"
|
||||||
|
enter-from-class="opacity-0 transform -translate-y-2"
|
||||||
|
enter-to-class="opacity-100 transform translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="opacity-100 transform translate-y-0"
|
||||||
|
leave-to-class="opacity-0 transform -translate-y-2"
|
||||||
|
>
|
||||||
|
<div v-if="show" class="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg shadow-sm" role="alert" aria-live="polite">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<WarningTriangleIcon class="h-5 w-5 text-amber-500" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<h3 class="text-sm font-medium text-amber-800">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-1 text-sm text-amber-700">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
|
||||||
|
<!-- Optional detailed list of changes -->
|
||||||
|
<div v-if="showDetails && changesSummary.length > 0" class="mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleDetails"
|
||||||
|
class="text-amber-800 underline hover:text-amber-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded"
|
||||||
|
>
|
||||||
|
{{ detailsVisible ? 'Hide details' : 'Show details' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-40"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 max-h-40"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="detailsVisible" class="mt-2 overflow-hidden">
|
||||||
|
<ul class="text-xs text-amber-600 space-y-1">
|
||||||
|
<li v-for="change in changesSummary" :key="change" class="flex items-center">
|
||||||
|
<div class="w-1 h-1 bg-amber-400 rounded-full mr-2"></div>
|
||||||
|
{{ change }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div v-if="showActions" class="ml-4 flex-shrink-0 flex space-x-2">
|
||||||
|
<button
|
||||||
|
v-if="onSave"
|
||||||
|
type="button"
|
||||||
|
@click.stop="handleSave"
|
||||||
|
:disabled="isSaving"
|
||||||
|
class="bg-amber-100 text-amber-800 px-3 py-1 rounded text-sm font-medium hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="!isSaving">Save Now</span>
|
||||||
|
<span v-else class="flex items-center">
|
||||||
|
<LoadingSpinner class="w-3 h-3 mr-1" />
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="onDismiss"
|
||||||
|
type="button"
|
||||||
|
@click="handleDismiss"
|
||||||
|
class="text-amber-600 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-amber-50 rounded p-1"
|
||||||
|
:title="dismissLabel"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{{ dismissLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress indicator for auto-save -->
|
||||||
|
<div v-if="showAutoSaveProgress && autoSaveCountdown > 0" class="mt-3">
|
||||||
|
<div class="flex items-center justify-between text-xs text-amber-600">
|
||||||
|
<span>Auto-save in {{ autoSaveCountdown }}s</span>
|
||||||
|
<button @click="cancelAutoSave" class="underline hover:text-amber-700">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 w-full bg-amber-200 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
class="bg-amber-500 h-1 rounded-full transition-all duration-1000 ease-linear"
|
||||||
|
:style="{ width: `${((initialCountdown - autoSaveCountdown) / initialCountdown) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, defineComponent } from 'vue';
|
||||||
|
|
||||||
|
// Icons - you can replace these with your preferred icon library
|
||||||
|
const WarningTriangleIcon = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const XMarkIcon = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const LoadingSpinner = defineComponent({
|
||||||
|
template: `
|
||||||
|
<svg class="animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Control visibility
|
||||||
|
show?: boolean;
|
||||||
|
|
||||||
|
// Content
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
changesSummary?: string[];
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
showDetails?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
showAutoSaveProgress?: boolean;
|
||||||
|
autoSaveDelay?: number; // seconds
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onSave?: () => Promise<void> | void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
onAutoSave?: () => Promise<void> | void;
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
dismissLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
show: true,
|
||||||
|
title: 'You have unsaved changes',
|
||||||
|
message: 'Your changes will be lost if you leave this page without saving.',
|
||||||
|
changesSummary: () => [],
|
||||||
|
showDetails: false,
|
||||||
|
showActions: true,
|
||||||
|
showAutoSaveProgress: false,
|
||||||
|
autoSaveDelay: 30,
|
||||||
|
dismissLabel: 'Dismiss warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [];
|
||||||
|
dismiss: [];
|
||||||
|
autoSave: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const detailsVisible = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const autoSaveCountdown = ref(0);
|
||||||
|
const initialCountdown = ref(0);
|
||||||
|
let autoSaveTimer: NodeJS.Timeout | null = null;
|
||||||
|
let countdownTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const toggleDetails = () => {
|
||||||
|
detailsVisible.value = !detailsVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSaving.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving.value = true;
|
||||||
|
await props.onSave?.();
|
||||||
|
emit('save');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
// You might want to emit an error event here
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
props.onDismiss?.();
|
||||||
|
emit('dismiss');
|
||||||
|
stopAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoSave = () => {
|
||||||
|
if (!props.onAutoSave || autoSaveTimer) return;
|
||||||
|
|
||||||
|
autoSaveCountdown.value = props.autoSaveDelay;
|
||||||
|
initialCountdown.value = props.autoSaveDelay;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
autoSaveCountdown.value--;
|
||||||
|
|
||||||
|
if (autoSaveCountdown.value <= 0) {
|
||||||
|
executeAutoSave();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeAutoSave = async () => {
|
||||||
|
stopAutoSave();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.onAutoSave?.();
|
||||||
|
emit('autoSave');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
// Optionally restart auto-save on failure
|
||||||
|
if (props.show) {
|
||||||
|
startAutoSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAutoSave = () => {
|
||||||
|
stopAutoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoSave = () => {
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
autoSaveTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdownTimer) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSaveCountdown.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newShow) => {
|
||||||
|
if (newShow && props.showAutoSaveProgress && props.onAutoSave) {
|
||||||
|
startAutoSave();
|
||||||
|
} else if (!newShow) {
|
||||||
|
stopAutoSave();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.show && props.showAutoSaveProgress && props.onAutoSave) {
|
||||||
|
startAutoSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoSave();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Additional custom styles if needed */
|
||||||
|
.max-h-0 {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-h-40 {
|
||||||
|
max-height: 10rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -132,13 +132,25 @@ export interface Description {
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
// Name fields
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string; // Also used for organization name
|
||||||
|
name?: string; // Alternative full name field
|
||||||
email: string;
|
email: string;
|
||||||
name_type?: string;
|
name_type?: string;
|
||||||
|
// Additional identifiers
|
||||||
identifier_orcid?: string;
|
identifier_orcid?: string;
|
||||||
datasetCount?: string;
|
|
||||||
|
// Status and metadata
|
||||||
|
status: boolean; // true = read-only/locked, false = editable
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
status: boolean;
|
updated_at?: string;
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
datasetCount?: string;
|
||||||
|
|
||||||
|
// Relationship data (for many-to-many relationships)
|
||||||
|
pivot_contributor_type?: string; // Type of contribution (e.g., 'Author', 'Editor', 'Contributor')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IErrorMessage {
|
interface IErrorMessage {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -99,5 +99,6 @@ export const getButtonColor = (color: 'white' | 'contrast' | 'light' | 'success'
|
||||||
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
|
base.push(isOutlined ? colors.outlineHover[color] : colors.bgHover[color]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
// return base;
|
||||||
|
return base.join(' '); // Join array into single string
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Preloaded File - node ace make:preload rules/dependentArrayMinLength
|
| Preloaded File - node ace make:preload rules/dependentArrayMinLength
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|*/
|
*/
|
||||||
|
|
||||||
import { FieldContext } from '@vinejs/vine/types';
|
import { FieldContext } from '@vinejs/vine/types';
|
||||||
import vine, { VineArray } from '@vinejs/vine';
|
import vine, { VineArray } from '@vinejs/vine';
|
||||||
|
@ -17,39 +17,75 @@ type Options = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) {
|
async function dependentArrayMinLength(value: unknown, options: Options, field: FieldContext) {
|
||||||
const fileInputs = field.data[options.dependentArray]; // Access the dependent array
|
const dependentArrayValue = field.data[options.dependentArray];
|
||||||
const isArrayValue = Array.isArray(value);
|
|
||||||
const isArrayFileInputs = Array.isArray(fileInputs);
|
// Both values can be null/undefined or arrays, but not other types
|
||||||
|
const isMainValueValid = value === null || value === undefined || Array.isArray(value);
|
||||||
if (isArrayValue && isArrayFileInputs) {
|
const isDependentValueValid = dependentArrayValue === null || dependentArrayValue === undefined || Array.isArray(dependentArrayValue);
|
||||||
if (value.length >= options.min) {
|
|
||||||
return true; // Valid if the main array length meets the minimum
|
if (!isMainValueValid || !isDependentValueValid) {
|
||||||
} else if (value.length === 0 && fileInputs.length >= options.min) {
|
|
||||||
return true; // Valid if the main array is empty and the dependent array meets the minimum
|
|
||||||
} else {
|
|
||||||
field.report(
|
|
||||||
`At least {{ min }} item for {{field}} field must be defined`,
|
|
||||||
'array.dependentArrayMinLength',
|
|
||||||
field,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Report if either value or dependentArray is not an array
|
|
||||||
field.report(
|
field.report(
|
||||||
`Both the {{field}} field and dependent array {{dependentArray}} must be arrays.`,
|
`Invalid file data format. Please contact support if this error persists.`,
|
||||||
|
'array.dependentArrayMinLength',
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert null/undefined to empty arrays for length checking
|
||||||
|
const mainArray = Array.isArray(value) ? value : [];
|
||||||
|
const dependentArray = Array.isArray(dependentArrayValue) ? dependentArrayValue : [];
|
||||||
|
|
||||||
|
// Calculate total count across both arrays
|
||||||
|
const totalCount = mainArray.length + dependentArray.length;
|
||||||
|
|
||||||
|
// Check if minimum requirement is met
|
||||||
|
if (totalCount >= options.min) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: if dependent array has items, main array can be empty/null
|
||||||
|
if (dependentArray.length >= options.min && mainArray.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine appropriate error message based on context
|
||||||
|
const hasExistingFiles = dependentArray.length > 0;
|
||||||
|
const hasNewFiles = mainArray.length > 0;
|
||||||
|
|
||||||
|
if (!hasExistingFiles && !hasNewFiles) {
|
||||||
|
// No files at all
|
||||||
|
field.report(
|
||||||
|
`Your dataset must include at least {{ min }} file. Please upload a new file to continue.`,
|
||||||
|
'array.dependentArrayMinLength',
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else if (hasExistingFiles && !hasNewFiles && dependentArray.length < options.min) {
|
||||||
|
// Has existing files but marked for deletion, no new files
|
||||||
|
field.report(
|
||||||
|
`You have marked all existing files for deletion. Please upload at least {{ min }} new file or keep some existing files.`,
|
||||||
|
'array.dependentArrayMinLength',
|
||||||
|
field,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Generic fallback message
|
||||||
|
field.report(
|
||||||
|
`Your dataset must have at least {{ min }} file. You can either upload new files or keep existing ones.`,
|
||||||
'array.dependentArrayMinLength',
|
'array.dependentArrayMinLength',
|
||||||
field,
|
field,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false; // Invalid if none of the conditions are met
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength);
|
export const dependentArrayMinLengthRule = vine.createRule(dependentArrayMinLength);
|
||||||
|
|
||||||
// Extend the VineArray interface with the same type parameters
|
// Extend the VineArray interface
|
||||||
declare module '@vinejs/vine' {
|
declare module '@vinejs/vine' {
|
||||||
interface VineArray<Schema extends SchemaTypes> {
|
interface VineArray<Schema extends SchemaTypes> {
|
||||||
dependentArrayMinLength(options: Options): this;
|
dependentArrayMinLength(options: Options): this;
|
||||||
|
@ -58,4 +94,4 @@ declare module '@vinejs/vine' {
|
||||||
|
|
||||||
VineArray.macro('dependentArrayMinLength', function <Schema extends SchemaTypes>(this: VineArray<Schema>, options: Options) {
|
VineArray.macro('dependentArrayMinLength', function <Schema extends SchemaTypes>(this: VineArray<Schema>, options: Options) {
|
||||||
return this.use(dependentArrayMinLengthRule(options));
|
return this.use(dependentArrayMinLengthRule(options));
|
||||||
});
|
});
|
175
start/rules/orcid.ts
Normal file
175
start/rules/orcid.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Preloaded File - node ace make:preload rules/orcid
|
||||||
|
| ❯ Do you want to register the preload file in .adonisrc.ts file? (y/N) · true
|
||||||
|
| DONE: create start/rules/orcid.ts
|
||||||
|
| DONE: update adonisrc.ts file
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import vine, { VineString } from '@vinejs/vine';
|
||||||
|
import { FieldContext } from '@vinejs/vine/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ORCID Validator Implementation
|
||||||
|
*
|
||||||
|
* Validates ORCID identifiers using both format validation and checksum verification.
|
||||||
|
* ORCID (Open Researcher and Contributor ID) is a persistent digital identifier
|
||||||
|
* that distinguishes researchers and supports automated linkages between them
|
||||||
|
* and their professional activities.
|
||||||
|
*
|
||||||
|
* Format: 0000-0000-0000-0000 (where the last digit can be X for checksum 10)
|
||||||
|
* Algorithm: MOD-11-2 checksum validation as per ISO/IEC 7064:2003
|
||||||
|
*
|
||||||
|
* @param value - The ORCID value to validate
|
||||||
|
* @param _options - Unused options parameter (required by VineJS signature)
|
||||||
|
* @param field - VineJS field context for error reporting
|
||||||
|
*/
|
||||||
|
async function orcidValidator(value: unknown, _options: undefined, field: FieldContext) {
|
||||||
|
/**
|
||||||
|
* Type guard: We only validate string values
|
||||||
|
* The "string" rule should handle type validation before this rule runs
|
||||||
|
*/
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle optional fields: Skip validation for empty strings
|
||||||
|
* This allows the field to be truly optional when used with .optional()
|
||||||
|
*/
|
||||||
|
if (value.trim() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the ORCID value:
|
||||||
|
* - Remove any whitespace characters
|
||||||
|
* - Convert to uppercase (for potential X check digit)
|
||||||
|
*/
|
||||||
|
const cleanOrcid = value.replace(/\s/g, '').toUpperCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Validation
|
||||||
|
*
|
||||||
|
* ORCID format regex breakdown:
|
||||||
|
* ^(\d{4}-){3} - Three groups of exactly 4 digits followed by hyphen
|
||||||
|
* \d{3} - Three more digits
|
||||||
|
* [\dX]$ - Final character: either digit or 'X' (for checksum 10)
|
||||||
|
*
|
||||||
|
* Valid examples: 0000-0002-1825-0097, 0000-0002-1825-009X
|
||||||
|
*/
|
||||||
|
const orcidRegex = /^(\d{4}-){3}\d{3}[\dX]$/;
|
||||||
|
|
||||||
|
if (!orcidRegex.test(cleanOrcid)) {
|
||||||
|
field.report('ORCID must be in format: 0000-0000-0000-0000 or 0000-0000-0000-000X', 'orcid', field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checksum Validation - MOD-11-2 Algorithm
|
||||||
|
*
|
||||||
|
* This implements the official ORCID checksum algorithm based on ISO/IEC 7064:2003
|
||||||
|
* to verify mathematical validity and detect typos or invalid identifiers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Step 1: Extract digits and separate check digit
|
||||||
|
const digits = cleanOrcid.replace(/-/g, ''); // Remove hyphens: "0000000218250097"
|
||||||
|
const baseDigits = digits.slice(0, -1); // First 15 digits: "000000021825009"
|
||||||
|
const checkDigit = digits.slice(-1); // Last character: "7"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Calculate checksum using MOD-11-2 algorithm
|
||||||
|
*
|
||||||
|
* For each digit from left to right:
|
||||||
|
* 1. Add the digit to running total
|
||||||
|
* 2. Multiply result by 2
|
||||||
|
*
|
||||||
|
* Example for "000000021825009":
|
||||||
|
* - Start with total = 0
|
||||||
|
* - Process each digit: total = (total + digit) * 2
|
||||||
|
* - Continue until all 15 digits are processed
|
||||||
|
*/
|
||||||
|
let total = 0;
|
||||||
|
for (const digit of baseDigits) {
|
||||||
|
total = (total + parseInt(digit)) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: Calculate expected check digit
|
||||||
|
*
|
||||||
|
* Formula: (12 - (total % 11)) % 11
|
||||||
|
* - Get remainder when total is divided by 11
|
||||||
|
* - Subtract from 12 and take modulo 11 again
|
||||||
|
* - If result is 10, use 'X' (since we need single character)
|
||||||
|
*
|
||||||
|
* Example: total = 1314
|
||||||
|
* - remainder = 1314 % 11 = 5
|
||||||
|
* - result = (12 - 5) % 11 = 7
|
||||||
|
* - expectedCheckDigit = "7"
|
||||||
|
*/
|
||||||
|
const remainder = total % 11;
|
||||||
|
const result = (12 - remainder) % 11;
|
||||||
|
const expectedCheckDigit = result === 10 ? 'X' : result.toString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4: Verify checksum matches
|
||||||
|
*
|
||||||
|
* Compare the actual check digit with the calculated expected value.
|
||||||
|
* If they don't match, the ORCID is invalid (likely contains typos or is fabricated).
|
||||||
|
*/
|
||||||
|
if (checkDigit !== expectedCheckDigit) {
|
||||||
|
field.report('Invalid ORCID checksum', 'orcid', field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach this point, the ORCID is valid (both format and checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the VineJS validation rule
|
||||||
|
*
|
||||||
|
* This creates a reusable rule that can be chained with other VineJS validators
|
||||||
|
*/
|
||||||
|
const orcidRule = vine.createRule(orcidValidator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript module declaration
|
||||||
|
*
|
||||||
|
* Extends the VineString interface to include our custom orcid() method.
|
||||||
|
* This enables TypeScript autocompletion and type checking when using the rule.
|
||||||
|
*/
|
||||||
|
declare module '@vinejs/vine' {
|
||||||
|
interface VineString {
|
||||||
|
/**
|
||||||
|
* Validates that a string is a valid ORCID identifier
|
||||||
|
*
|
||||||
|
* Checks both format (0000-0000-0000-0000) and mathematical validity
|
||||||
|
* using the MOD-11-2 checksum algorithm.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Usage in validation schema
|
||||||
|
* identifier_orcid: vine.string().trim().maxLength(255).orcid().optional()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @returns {this} The VineString instance for method chaining
|
||||||
|
*/
|
||||||
|
orcid(): this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the macro with VineJS
|
||||||
|
*
|
||||||
|
* This adds the .orcid() method to all VineString instances,
|
||||||
|
* allowing it to be used in validation schemas.
|
||||||
|
*
|
||||||
|
* Usage example:
|
||||||
|
* ```typescript
|
||||||
|
* vine.string().orcid().optional()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
VineString.macro('orcid', function (this: VineString) {
|
||||||
|
return this.use(orcidRule());
|
||||||
|
});
|
Loading…
Add table
editor.link_modal.header
Reference in a new issue