feat: update API controllers, validations, and Vue components
All checks were successful
CI / container-job (push) Successful in 49s

- Modified Api/Authors.Controller.ts to use only personal types and sort by dataset_count.
- Completely rewritten AvatarController.ts.
- Added new Api/CollectionsController.ts for querying collections and collection_roles.
- Modified Api/DatasetController.ts to preload titles, identifier and order by server_date_published.
- Modified FileController.ts to serve files from /storage/app/data/ instead of /storage/app/public.
- Added new Api/UserController for requesting submitters (getSubmitters).
- Improved OaiController.ts with performant DB queries for better ResumptionToken handling.
- Modified Submitter/DatasetController.ts by adding a categorize method for library classification.
- Rewritten ResumptionToken.ts.
- Improved TokenWorkerService.ts to utilize browser fingerprint.
- Edited dataset.ts by adding the doiIdentifier property.
- Enhanced person.ts to improve the fullName property.
- Completely rewritten AsideMenuItem.vue component.
- Updated CarBoxClient.vue to use TypeScript.
- Added new CardBoxDataset.vue for displaying recent datasets on the dashboard.
- Completely rewritten TableSampleClients.vue for the dashboard.
- Completely rewritten UserAvatar.vue.
- Made small layout changes in Dashboard.vue.
- Added new Category.vue for browsing scientific collections.
- Adapted the pinia store in main.ts.
- Added additional routes in start/routes.ts and start/api/routes.ts.
- Improved referenceValidation.ts for better ISBN existence checking.
- NPM dependency updates.
This commit is contained in:
Kaimbacher 2025-03-14 17:39:58 +01:00
parent 36cd7a757b
commit b540547e4c
34 changed files with 1757 additions and 1018 deletions

View file

@ -9,12 +9,14 @@ export default class AuthorsController {
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id" // 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")); // where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id"));
const authors = await Person.query() const authors = await Person.query()
.where('name_type', 'Personal')
.whereHas('datasets', (dQuery) => { .whereHas('datasets', (dQuery) => {
dQuery.wherePivot('role', 'author'); dQuery.wherePivot('role', 'author');
}) })
.withCount('datasets', (query) => { .withCount('datasets', (query) => {
query.as('datasets_count'); query.as('datasets_count');
}); })
.orderBy('datasets_count', 'desc');
return authors; return authors;
} }

View file

@ -1,104 +1,135 @@
import type { HttpContext } from '@adonisjs/core/http'; 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';
const prefixes = ['von', 'van']; const PREFIXES = ['von', 'van'];
const DEFAULT_SIZE = 50;
const FONT_SIZE_RATIO = 0.4;
const COLOR_LIGHTENING_PERCENT = 60;
const COLOR_DARKENING_FACTOR = 0.6;
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 } = 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' });
}
// Build a unique cache key for the given name and size
const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`;
const cachedSvg = await redis.get(cacheKey);
if (cachedSvg) {
this.setResponseHeaders(response);
return response.send(cachedSvg);
}
const initials = this.getInitials(name); const initials = this.getInitials(name);
const colors = this.generateColors(name);
const svgContent = this.createSvg(size, colors, initials);
const originalColor = this.getColorFromName(name); // // Cache the generated avatar for future use, e.g. 1 hour expiry
const backgroundColor = this.lightenColor(originalColor, 60); await redis.setex(cacheKey, 3600, svgContent);
const textColor = this.darkenColor(originalColor);
const svgContent = ` this.setResponseHeaders(response);
<svg width="${size || 50}" height="${size || 50}" xmlns="http://www.w3.org/2000/svg"> return response.send(svgContent);
<rect width="100%" height="100%" fill="#${backgroundColor}"/> } catch (error) {
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-weight="bold" font-family="Arial, sans-serif" font-size="${ return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message });
(size / 100) * 40 || 25 }
}" fill="#${textColor}">${initials}</text> }
private getInitials(name: string): string {
const parts = name
.trim()
.split(' ')
.filter((part) => part.length > 0);
if (parts.length === 0) {
return 'NA';
}
if (parts.length >= 2) {
return this.getMultiWordInitials(parts);
}
return parts[0].substring(0, 2).toUpperCase();
}
private getMultiWordInitials(parts: string[]): string {
const firstName = parts[0];
const lastName = parts[parts.length - 1];
const firstInitial = firstName.charAt(0).toUpperCase();
const lastInitial = lastName.charAt(0).toUpperCase();
if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
return firstInitial + lastName.charAt(1).toUpperCase();
}
return firstInitial + lastInitial;
}
private generateColors(name: string): { background: string; text: string } {
const baseColor = this.getColorFromName(name);
return {
background: this.lightenColor(baseColor, COLOR_LIGHTENING_PERCENT),
text: this.darkenColor(baseColor),
};
}
private createSvg(size: number, colors: { background: string; text: string }, initials: string): string {
const fontSize = size * FONT_SIZE_RATIO;
return `
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#${colors.background}"/>
<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> </svg>
`; `;
}
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', 'no-cache');
response.header('Pragma', 'no-cache'); response.header('Pragma', 'no-cache');
response.header('Expires', '0'); response.header('Expires', '0');
return response.send(svgContent);
} catch (error) {
return response.status(StatusCodes.OK).json({ error: error.message });
}
} }
private getInitials(name: string) { private getColorFromName(name: string): string {
const parts = name.split(' ');
let initials = '';
if (parts.length >= 2) {
const firstName = parts[0];
const lastName = parts[parts.length - 1];
const firstInitial = firstName.charAt(0).toUpperCase();
const lastInitial = lastName.charAt(0).toUpperCase();
if (prefixes.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) {
initials = firstInitial + lastName.charAt(1).toUpperCase();
} else {
initials = firstInitial + lastInitial;
}
} else if (parts.length === 1) {
initials = parts[0].substring(0, 2).toUpperCase();
}
return initials;
}
private getColorFromName(name: string) {
let hash = 0; let hash = 0;
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash); hash = name.charCodeAt(i) + ((hash << 5) - hash);
} }
let color = '#';
const colorParts = [];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff; const value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2); colorParts.push(value.toString(16).padStart(2, '0'));
} }
return color.replace('#', ''); return colorParts.join('');
} }
private lightenColor(hexColor: string, percent: number) { private lightenColor(hexColor: string, percent: number): string {
let r = parseInt(hexColor.substring(0, 2), 16); const r = parseInt(hexColor.substring(0, 2), 16);
let g = parseInt(hexColor.substring(2, 4), 16); const g = parseInt(hexColor.substring(2, 4), 16);
let b = parseInt(hexColor.substring(4, 6), 16); const b = parseInt(hexColor.substring(4, 6), 16);
r = Math.floor((r * (100 + percent)) / 100); const lightenValue = (value: number) => Math.min(255, Math.floor((value * (100 + percent)) / 100));
g = Math.floor((g * (100 + percent)) / 100);
b = Math.floor((b * (100 + percent)) / 100);
r = r < 255 ? r : 255; const newR = lightenValue(r);
g = g < 255 ? g : 255; const newG = lightenValue(g);
b = b < 255 ? b : 255; const newB = lightenValue(b);
const lighterHex = ((r << 16) | (g << 8) | b).toString(16); return ((newR << 16) | (newG << 8) | newB).toString(16).padStart(6, '0');
return lighterHex.padStart(6, '0');
} }
private darkenColor(hexColor: string) { private darkenColor(hexColor: string): string {
const r = parseInt(hexColor.slice(0, 2), 16); const r = parseInt(hexColor.slice(0, 2), 16);
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 darkerR = Math.round(r * 0.6); const darkenValue = (value: number) => Math.round(value * COLOR_DARKENING_FACTOR);
const darkerG = Math.round(g * 0.6);
const darkerB = Math.round(b * 0.6);
const darkerColor = ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16); const darkerR = darkenValue(r);
const darkerG = darkenValue(g);
const darkerB = darkenValue(b);
return darkerColor.padStart(6, '0'); return ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16).padStart(6, '0');
} }
} }

View file

@ -6,10 +6,15 @@ import { StatusCodes } from 'http-status-codes';
// node ace make:controller Author // node ace make:controller Author
export default class DatasetController { export default class DatasetController {
public async index({}: HttpContext) { public async index({}: HttpContext) {
// select * from gba.persons // Select datasets with server_state 'published' or 'deleted' and sort by the last published date
// where exists (select * from gba.documents inner join gba.link_documents_persons on "documents"."id" = "link_documents_persons"."document_id" const datasets = await Dataset.query()
// where ("link_documents_persons"."role" = 'author') and ("persons"."id" = "link_documents_persons"."person_id")); .where(function (query) {
const datasets = await Dataset.query().where('server_state', 'published').orWhere('server_state', 'deleted'); query.where('server_state', 'published')
.orWhere('server_state', 'deleted');
})
.preload('titles')
.preload('identifier')
.orderBy('server_date_published', 'desc');
return datasets; return datasets;
} }

View file

@ -14,7 +14,7 @@ export default class FileController {
// where: { id: id }, // where: { id: id },
// }); // });
if (file) { if (file) {
const filePath = '/storage/app/public/' + file.pathName; const filePath = '/storage/app/data/' + file.pathName;
const ext = path.extname(filePath); const ext = path.extname(filePath);
const fileName = file.label + ext; const fileName = file.label + ext;
try { try {

View file

@ -9,6 +9,24 @@ import BackupCode from '#models/backup_code';
// Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database. // Here we are generating secret and recovery codes for the user thats enabling 2FA and storing them to our database.
export default class UserController { export default class UserController {
public async getSubmitters({ response }: HttpContext) {
try {
const submitters = await User.query()
.preload('roles', (query) => {
query.where('name', 'submitter')
})
.whereHas('roles', (query) => {
query.where('name', 'submitter')
})
.exec();
return submitters;
} catch (error) {
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
message: 'Invalid TOTP state',
});
}
}
public async enable({ auth, response, request }: HttpContext) { public async enable({ auth, response, request }: HttpContext) {
const user = (await User.find(auth.user?.id)) as User; const user = (await User.find(auth.user?.id)) as User;
// await user.load('totp_secret'); // await user.load('totp_secret');

View file

@ -0,0 +1,36 @@
import type { HttpContext } from '@adonisjs/core/http';
import Collection from '#models/collection';
export default class CollectionsController {
public async show({ params, response }: HttpContext) {
// Get the collection id from route parameters
const collectionId = params.id;
// Find the selected collection by id
const collection = await Collection.find(collectionId);
if (!collection) {
return response.status(404).json({ message: 'Collection not found' });
}
// Query for narrower concepts: collections whose parent_id equals the selected collection's id
const narrowerCollections = await Collection.query().where('parent_id', collection.id) || [];
// For broader concept, if the selected collection has a parent_id fetch that record (otherwise null)
const broaderCollection: Collection[] | never[] | null = await (async () => {
if (collection.parent_id) {
// Try to fetch the parent...
const parent = await Collection.find(collection.parent_id)
// If found, return it wrapped in an array; if not found, return null (or empty array if you prefer)
return parent ? [parent] : null
}
return []
})()
// Return the selected collection along with its narrower and broader concepts in JSON format
return response.json({
selectedCollection: collection,
narrowerCollections,
broaderCollection,
});
}
}

View file

@ -19,14 +19,13 @@ import XmlModel from '#app/Library/XmlModel';
import logger from '@adonisjs/core/services/logger'; import logger from '@adonisjs/core/services/logger';
import ResumptionToken from '#app/Library/Oai/ResumptionToken'; import ResumptionToken from '#app/Library/Oai/ResumptionToken';
// import Config from '@ioc:Adonis/Core/Config'; // import Config from '@ioc:Adonis/Core/Config';
import config from '@adonisjs/core/services/config' import config from '@adonisjs/core/services/config';
// import { inject } from '@adonisjs/fold'; // import { inject } from '@adonisjs/fold';
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core';
// import { TokenWorkerContract } from "MyApp/Models/TokenWorker"; // import { TokenWorkerContract } from "MyApp/Models/TokenWorker";
import TokenWorkerContract from '#library/Oai/TokenWorkerContract'; import TokenWorkerContract from '#library/Oai/TokenWorkerContract';
import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'; import { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
interface XslTParameter { interface XslTParameter {
[key: string]: any; [key: string]: any;
} }
@ -35,12 +34,14 @@ interface Dictionary {
[index: string]: string; [index: string]: string;
} }
interface ListParameter { interface PagingParameter {
cursor: number; cursor: number;
totalIds: number; totalLength: number;
start: number; start: number;
reldocIds: (number | null)[]; nextDocIds: number[];
activeWorkIds: number[];
metadataPrefix: string; metadataPrefix: string;
queryParams: Object;
} }
@inject() @inject()
@ -49,6 +50,7 @@ export default class OaiController {
private sampleRegEx = /^[A-Za-zäüÄÜß0-9\-_.!~]+$/; private sampleRegEx = /^[A-Za-zäüÄÜß0-9\-_.!~]+$/;
private xsltParameter: XslTParameter; private xsltParameter: XslTParameter;
private firstPublishedDataset: Dataset | null;
/** /**
* Holds xml representation of document information to be processed. * Holds xml representation of document information to be processed.
* *
@ -57,7 +59,6 @@ export default class OaiController {
private xml: XMLBuilder; private xml: XMLBuilder;
private proc; private proc;
constructor(public tokenWorker: TokenWorkerContract) { constructor(public tokenWorker: TokenWorkerContract) {
// Load the XSLT file // Load the XSLT file
this.proc = readFileSync('public/assets2/datasetxml2oai.sef.json'); this.proc = readFileSync('public/assets2/datasetxml2oai.sef.json');
@ -85,9 +86,9 @@ export default class OaiController {
let earliestDateFromDb; let earliestDateFromDb;
// const oaiRequest: OaiParameter = request.body; // const oaiRequest: OaiParameter = request.body;
try { try {
const firstPublishedDataset: Dataset | null = await Dataset.earliestPublicationDate(); this.firstPublishedDataset = await Dataset.earliestPublicationDate();
firstPublishedDataset != null && this.firstPublishedDataset != null &&
(earliestDateFromDb = firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")); (earliestDateFromDb = this.firstPublishedDataset.server_date_published.toFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"));
this.xsltParameter['earliestDatestamp'] = earliestDateFromDb; this.xsltParameter['earliestDatestamp'] = earliestDateFromDb;
// start the request // start the request
await this.handleRequest(oaiRequest, request); await this.handleRequest(oaiRequest, request);
@ -162,22 +163,19 @@ export default class OaiController {
} else if (verb == 'GetRecord') { } else if (verb == 'GetRecord') {
await this.handleGetRecord(oaiRequest); await this.handleGetRecord(oaiRequest);
} else if (verb == 'ListRecords') { } else if (verb == 'ListRecords') {
await this.handleListRecords(oaiRequest); // Get browser fingerprint from the request:
const browserFingerprint = this.getBrowserFingerprint(request);
await this.handleListRecords(oaiRequest, browserFingerprint);
} else if (verb == 'ListIdentifiers') { } else if (verb == 'ListIdentifiers') {
await this.handleListIdentifiers(oaiRequest); // Get browser fingerprint from the request:
const browserFingerprint = this.getBrowserFingerprint(request);
await this.handleListIdentifiers(oaiRequest, browserFingerprint);
} else if (verb == 'ListSets') { } else if (verb == 'ListSets') {
await this.handleListSets(); await this.handleListSets();
} else { } else {
this.handleIllegalVerb(); this.handleIllegalVerb();
} }
} else { } else {
// // try {
// // console.log("Async code example.")
// const err = new PageNotFoundException("verb not found");
// throw err;
// // } catch (error) { // manually catching
// // next(error); // passing to default middleware error handler
// // }
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The verb provided in the request is illegal.', 'The verb provided in the request is illegal.',
@ -187,11 +185,11 @@ export default class OaiController {
} }
protected handleIdentify() { protected handleIdentify() {
const email = process.env.OAI_EMAIL || 'repository@geosphere.at'; // Get configuration values from environment or a dedicated configuration service
const repositoryName = 'Tethys RDR'; const email = process.env.OAI_EMAIL ?? 'repository@geosphere.at';
const repIdentifier = 'tethys.at'; const repositoryName = process.env.OAI_REPOSITORY_NAME ?? 'Tethys RDR';
const sampleIdentifier = 'oai:' + repIdentifier + ':1'; //$this->_configuration->getSampleIdentifier(); const repIdentifier = process.env.OAI_REP_IDENTIFIER ?? 'tethys.at';
const sampleIdentifier = `oai:${repIdentifier}:1`;
// Dataset::earliestPublicationDate()->server_date_published->format('Y-m-d\TH:i:s\Z') : null; // Dataset::earliestPublicationDate()->server_date_published->format('Y-m-d\TH:i:s\Z') : null;
// earliestDateFromDb!= null && (this.xsltParameter['earliestDatestamp'] = earliestDateFromDb?.server_date_published); // earliestDateFromDb!= null && (this.xsltParameter['earliestDatestamp'] = earliestDateFromDb?.server_date_published);
@ -216,7 +214,7 @@ export default class OaiController {
const sets: { [key: string]: string } = { const sets: { [key: string]: string } = {
'open_access': 'Set for open access licenses', 'open_access': 'Set for open access licenses',
'openaire_data': "OpenAIRE", 'openaire_data': 'OpenAIRE',
'doc-type:ResearchData': 'Set for document type ResearchData', 'doc-type:ResearchData': 'Set for document type ResearchData',
...(await this.getSetsForDatasetTypes()), ...(await this.getSetsForDatasetTypes()),
...(await this.getSetsForCollections()), ...(await this.getSetsForCollections()),
@ -234,7 +232,15 @@ export default class OaiController {
const repIdentifier = 'tethys.at'; const repIdentifier = 'tethys.at';
this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['repIdentifier'] = repIdentifier;
// Validate that required parameter exists early
if (!('identifier' in oaiRequest)) {
throw new BadOaiModelException('The prefix of the identifier argument is unknown.');
}
// Validate and extract the dataset identifier from the request
const dataId = this.validateAndGetIdentifier(oaiRequest); const dataId = this.validateAndGetIdentifier(oaiRequest);
// Retrieve dataset with associated XML cache and collection roles
const dataset = await Dataset.query() const dataset = await Dataset.query()
.where('publish_id', dataId) .where('publish_id', dataId)
.preload('xmlCache') .preload('xmlCache')
@ -251,59 +257,61 @@ export default class OaiController {
); );
} }
// Validate and set the metadata prefix parameter
const metadataPrefix = this.validateAndGetMetadataPrefix(oaiRequest); const metadataPrefix = this.validateAndGetMetadataPrefix(oaiRequest);
this.xsltParameter['oai_metadataPrefix'] = metadataPrefix; this.xsltParameter['oai_metadataPrefix'] = metadataPrefix;
// do not deliver datasets which are restricted by document state defined in deliveringStates
// Ensure that the dataset is in an exportable state
this.validateDatasetState(dataset); this.validateDatasetState(dataset);
// add xml elements // Build the XML for the dataset record and add it to the root node
const datasetNode = this.xml.root().ele('Datasets'); const datasetNode = this.xml.root().ele('Datasets');
await this.createXmlRecord(dataset, datasetNode); await this.createXmlRecord(dataset, datasetNode);
} }
protected async handleListIdentifiers(oaiRequest: Dictionary) { protected async handleListIdentifiers(oaiRequest: Dictionary, browserFingerprint: string) {
!this.tokenWorker.isConnected && (await this.tokenWorker.connect()); if (!this.tokenWorker.isConnected) {
await this.tokenWorker.connect();
}
const maxIdentifier: number = config.get('oai.max.listidentifiers', 100); const maxIdentifier: number = config.get('oai.max.listidentifiers', 100);
await this.handleLists(oaiRequest, maxIdentifier); await this.handleLists(oaiRequest, maxIdentifier, browserFingerprint);
} }
protected async handleListRecords(oaiRequest: Dictionary) { protected async handleListRecords(oaiRequest: Dictionary, browserFingerprint: string) {
!this.tokenWorker.isConnected && (await this.tokenWorker.connect()); if (!this.tokenWorker.isConnected) {
await this.tokenWorker.connect();
}
const maxRecords: number = config.get('oai.max.listrecords', 100); const maxRecords: number = config.get('oai.max.listrecords', 100);
await this.handleLists(oaiRequest, maxRecords); await this.handleLists(oaiRequest, maxRecords, browserFingerprint);
} }
private async handleLists(oaiRequest: Dictionary, maxRecords: number) { private async handleLists(oaiRequest: Dictionary, maxRecords: number, browserFingerprint: string) {
maxRecords = maxRecords || 100;
const repIdentifier = 'tethys.at'; const repIdentifier = 'tethys.at';
this.xsltParameter['repIdentifier'] = repIdentifier; this.xsltParameter['repIdentifier'] = repIdentifier;
const datasetNode = this.xml.root().ele('Datasets'); const datasetNode = this.xml.root().ele('Datasets');
// list initialisation const paginationParams: PagingParameter ={
const numWrapper: ListParameter = {
cursor: 0, cursor: 0,
totalIds: 0, totalLength: 0,
start: maxRecords + 1, start: maxRecords + 1,
reldocIds: [], nextDocIds: [],
activeWorkIds: [],
metadataPrefix: '', metadataPrefix: '',
queryParams: {},
}; };
// resumptionToken is defined
if ('resumptionToken' in oaiRequest) { if ('resumptionToken' in oaiRequest) {
await this.handleResumptionToken(oaiRequest, maxRecords, numWrapper); await this.handleResumptionToken(oaiRequest, maxRecords, paginationParams);
} else { } else {
// no resumptionToken is given await this.handleNoResumptionToken(oaiRequest, paginationParams, maxRecords);
await this.handleNoResumptionToken(oaiRequest, numWrapper);
} }
// handling of document ids const nextIds: number[] = paginationParams.nextDocIds;
const restIds = numWrapper.reldocIds as number[]; const workIds: number[] = paginationParams.activeWorkIds;
const workIds = restIds.splice(0, maxRecords) as number[]; // array_splice(restIds, 0, maxRecords);
// no records returned if (workIds.length === 0) {
if (workIds.length == 0) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The combination of the given values results in an empty list.', 'The combination of the given values results in an empty list.',
@ -311,169 +319,218 @@ export default class OaiController {
); );
} }
const datasets: Dataset[] = await Dataset.query() const datasets = await Dataset.query()
.whereIn('publish_id', workIds) .whereIn('publish_id', workIds)
.preload('xmlCache') .preload('xmlCache')
.preload('collections', (builder) => { .preload('collections', (builder) => {
builder.preload('collectionRole'); builder.preload('collectionRole');
}) })
.orderBy('publish_id'); .orderBy('publish_id');
for (const dataset of datasets) { for (const dataset of datasets) {
await this.createXmlRecord(dataset, datasetNode); await this.createXmlRecord(dataset, datasetNode);
} }
await this.setResumptionToken(nextIds, paginationParams, browserFingerprint);
// store the further Ids in a resumption-file
const countRestIds = restIds.length; //84
if (countRestIds > 0) {
const token = new ResumptionToken();
token.startPosition = numWrapper.start; //101
token.totalIds = numWrapper.totalIds; //184
token.documentIds = restIds; //101 -184
token.metadataPrefix = numWrapper.metadataPrefix;
// $tokenWorker->storeResumptionToken($token);
const res: string = await this.tokenWorker.set(token);
// set parameters for the resumptionToken-node
// const res = token.ResumptionId;
this.setParamResumption(res, numWrapper.cursor, numWrapper.totalIds);
}
} }
private async handleResumptionToken(oaiRequest: Dictionary, maxRecords: number, numWrapper: ListParameter) { private async handleNoResumptionToken(oaiRequest: Dictionary, paginationParams: PagingParameter, maxRecords: number) {
const resParam = oaiRequest['resumptionToken']; //e.g. "158886496600000" this.validateMetadataPrefix(oaiRequest, paginationParams);
const finder: ModelQueryBuilderContract<typeof Dataset, Dataset> = Dataset.query().whereIn(
'server_state',
this.deliveringDocumentStates,
);
this.applySetFilter(finder, oaiRequest);
this.applyDateFilters(finder, oaiRequest);
await this.fetchAndSetResults(finder, paginationParams, oaiRequest, maxRecords);
}
private async fetchAndSetResults(
finder: ModelQueryBuilderContract<typeof Dataset, Dataset>,
paginationParams: PagingParameter,
oaiRequest: Dictionary,
maxRecords: number
) {
const totalResult = await finder
.clone()
.count('* as total')
.first()
.then((res) => res?.$extras.total);
paginationParams.totalLength = Number(totalResult);
const combinedRecords: Dataset[] = await finder.select('publish_id').orderBy('publish_id').offset(0).limit(maxRecords*2);
paginationParams.activeWorkIds = combinedRecords.slice(0, 100).map((dat) => Number(dat.publish_id));
paginationParams.nextDocIds = combinedRecords.slice(100).map((dat) => Number(dat.publish_id));
// No resumption token was used set queryParams from the current oaiRequest
paginationParams.queryParams = {
...oaiRequest,
deliveringStates: this.deliveringDocumentStates,
};
// paginationParams.totalLength = 230;
}
private async handleResumptionToken(oaiRequest: Dictionary, maxRecords: number, paginationParams: PagingParameter) {
const resParam = oaiRequest['resumptionToken'];
const token = await this.tokenWorker.get(resParam); const token = await this.tokenWorker.get(resParam);
if (!token) { if (!token) {
throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'cache is outdated.', OaiErrorCodes.BADRESUMPTIONTOKEN); throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'cache is outdated.', OaiErrorCodes.BADRESUMPTIONTOKEN);
} }
numWrapper.cursor = token.startPosition - 1; //startet dann bei Index 10 // this.setResumptionParameters(token, maxRecords, paginationParams);
numWrapper.start = token.startPosition + maxRecords; paginationParams.cursor = token.startPosition - 1;
numWrapper.totalIds = token.totalIds; paginationParams.start = token.startPosition + maxRecords;
numWrapper.reldocIds = token.documentIds; paginationParams.totalLength = token.totalIds;
numWrapper.metadataPrefix = token.metadataPrefix; paginationParams.activeWorkIds = token.documentIds;
paginationParams.metadataPrefix = token.metadataPrefix;
paginationParams.queryParams = token.queryParams;
this.xsltParameter['oai_metadataPrefix'] = token.metadataPrefix;
this.xsltParameter['oai_metadataPrefix'] = numWrapper.metadataPrefix; const finder = this.buildDatasetQueryViaToken(token);
const nextRecords: Dataset[] = await this.fetchNextRecords(finder, token, maxRecords);
paginationParams.nextDocIds = nextRecords.map((dat) => Number(dat.publish_id));
} }
private async handleNoResumptionToken(oaiRequest: Dictionary, numWrapper: ListParameter) { private async setResumptionToken(nextIds: number[], paginationParams: PagingParameter, browserFingerprint: string) {
// no resumptionToken is given const countRestIds = nextIds.length;
if ('metadataPrefix' in oaiRequest) { if (countRestIds > 0) {
numWrapper.metadataPrefix = oaiRequest['metadataPrefix']; // const token = this.createResumptionToken(paginationParams, nextIds);
} else { const token = new ResumptionToken();
token.startPosition = paginationParams.start;
token.totalIds = paginationParams.totalLength;
token.documentIds = nextIds;
token.metadataPrefix = paginationParams.metadataPrefix;
token.queryParams = paginationParams.queryParams;
const res: string = await this.tokenWorker.set(token, browserFingerprint);
this.setParamResumption(res, paginationParams.cursor, paginationParams.totalLength);
}
}
private buildDatasetQueryViaToken(token: ResumptionToken) {
const finder = Dataset.query();
const originalQuery = token.queryParams || {};
const deliveringStates = originalQuery.deliveringStates || this.deliveringDocumentStates;
finder.whereIn('server_state', deliveringStates);
this.applySetFilter(finder, originalQuery);
this.applyDateFilters(finder, originalQuery);
return finder;
}
private async fetchNextRecords(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, token: ResumptionToken, maxRecords: number) {
return finder
.select('publish_id')
.orderBy('publish_id')
.offset(token.startPosition - 1 + maxRecords)
.limit(100);
}
private validateMetadataPrefix(oaiRequest: Dictionary, paginationParams: PagingParameter) {
if (!('metadataPrefix' in oaiRequest)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The prefix of the metadata argument is unknown.', 'The prefix of the metadata argument is unknown.',
OaiErrorCodes.BADARGUMENT, OaiErrorCodes.BADARGUMENT,
); );
} }
this.xsltParameter['oai_metadataPrefix'] = numWrapper.metadataPrefix; paginationParams.metadataPrefix = oaiRequest['metadataPrefix'];
this.xsltParameter['oai_metadataPrefix'] = paginationParams.metadataPrefix;
let finder: ModelQueryBuilderContract<typeof Dataset, Dataset> = Dataset.query();
// add server state restrictions
finder.whereIn('server_state', this.deliveringDocumentStates);
if ('set' in oaiRequest) {
const set = oaiRequest['set'] as string;
const setArray = set.split(':');
if (setArray[0] == 'data-type') {
if (setArray.length == 2 && setArray[1]) {
finder.where('type', setArray[1]);
} }
} else if (setArray[0] == 'open_access') {
const openAccessLicences = ['CC-BY-4.0', 'CC-BY-SA-4.0']; private applySetFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, queryParams: any) {
if ('set' in queryParams) {
const [setType, setValue] = queryParams['set'].split(':');
switch (setType) {
case 'data-type':
setValue && finder.where('type', setValue);
break;
case 'open_access':
finder.andWhereHas('licenses', (query) => { finder.andWhereHas('licenses', (query) => {
query.whereIn('name', openAccessLicences); query.whereIn('name', ['CC-BY-4.0', 'CC-BY-SA-4.0']);
}); });
} else if (setArray[0] == 'ddc') { break;
if (setArray.length == 2 && setArray[1] != '') { case 'ddc':
setValue &&
finder.andWhereHas('collections', (query) => { finder.andWhereHas('collections', (query) => {
query.where('number', setArray[1]); query.where('number', setValue);
}); });
break;
} }
} }
} }
// const timeZone = "Europe/Vienna"; // Canonical time zone name private applyDateFilters(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, queryParams: any) {
// &from=2020-09-03&until2020-09-03 const { from, until } = queryParams;
// &from=2020-09-11&until=2021-05-11
if ('from' in oaiRequest && 'until' in oaiRequest) {
const from = oaiRequest['from'] as string;
let fromDate = dayjs(from); //.tz(timeZone);
const until = oaiRequest['until'] as string;
let untilDate = dayjs(until); //.tz(timeZone);
if (!fromDate.isValid() || !untilDate.isValid()) {
throw new OaiModelException(StatusCodes.INTERNAL_SERVER_ERROR, 'Date Parameter is not valid.', OaiErrorCodes.BADARGUMENT);
}
fromDate = dayjs.tz(from, 'Europe/Vienna');
untilDate = dayjs.tz(until, 'Europe/Vienna');
if (from.length != until.length) { if (from && until) {
this.handleFromUntilFilter(finder, from, until);
} else if (from) {
this.handleFromFilter(finder, from);
} else if (until) {
this.handleUntilFilter(finder, until);
}
}
private handleFromUntilFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, from: string, until: string) {
const fromDate = this.parseDateWithValidation(from, 'From');
const untilDate = this.parseDateWithValidation(until, 'Until');
if (from.length !== until.length) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'The request has different granularities for the from and until parameters.', 'The request has different granularities for the from and until parameters.',
OaiErrorCodes.BADARGUMENT, OaiErrorCodes.BADARGUMENT,
); );
} }
fromDate.hour() == 0 && (fromDate = fromDate.startOf('day'));
untilDate.hour() == 0 && (untilDate = untilDate.endOf('day'));
finder.whereBetween('server_date_published', [fromDate.format('YYYY-MM-DD HH:mm:ss'), untilDate.format('YYYY-MM-DD HH:mm:ss')]); finder.whereBetween('server_date_published', [fromDate.format('YYYY-MM-DD HH:mm:ss'), untilDate.format('YYYY-MM-DD HH:mm:ss')]);
} else if ('from' in oaiRequest && !('until' in oaiRequest)) {
const from = oaiRequest['from'] as string;
let fromDate = dayjs(from);
if (!fromDate.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
'From date parameter is not valid.',
OaiErrorCodes.BADARGUMENT,
);
} }
fromDate = dayjs.tz(from, 'Europe/Vienna');
fromDate.hour() == 0 && (fromDate = fromDate.startOf('day'));
private handleFromFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, from: string) {
const fromDate = this.parseDateWithValidation(from, 'From');
const now = dayjs(); const now = dayjs();
if (fromDate.isAfter(now)) { if (fromDate.isAfter(now)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
'Given from date is greater than now. The given values results in an empty list.', 'Given from date is greater than now. The given values results in an empty list.',
OaiErrorCodes.NORECORDSMATCH, OaiErrorCodes.NORECORDSMATCH,
); );
} else { }
finder.andWhere('server_date_published', '>=', fromDate.format('YYYY-MM-DD HH:mm:ss')); finder.andWhere('server_date_published', '>=', fromDate.format('YYYY-MM-DD HH:mm:ss'));
} }
} else if (!('from' in oaiRequest) && 'until' in oaiRequest) {
const until = oaiRequest['until'] as string;
let untilDate = dayjs(until);
if (!untilDate.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
'Until date parameter is not valid.',
OaiErrorCodes.BADARGUMENT,
);
}
untilDate = dayjs.tz(until, 'Europe/Vienna');
untilDate.hour() == 0 && (untilDate = untilDate.endOf('day'));
const firstPublishedDataset: Dataset = (await Dataset.earliestPublicationDate()) as Dataset; private handleUntilFilter(finder: ModelQueryBuilderContract<typeof Dataset, Dataset>, until: string) {
const earliestPublicationDate = dayjs(firstPublishedDataset.server_date_published.toISO()); //format("YYYY-MM-DDThh:mm:ss[Z]")); const untilDate = this.parseDateWithValidation(until, 'Until');
const earliestPublicationDate = dayjs(this.firstPublishedDataset?.server_date_published.toISO());
if (earliestPublicationDate.isAfter(untilDate)) { if (earliestPublicationDate.isAfter(untilDate)) {
throw new OaiModelException( throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR, StatusCodes.INTERNAL_SERVER_ERROR,
`earliestDatestamp is greater than given until date. 'earliestDatestamp is greater than given until date. The given values results in an empty list.',
The given values results in an empty list.`,
OaiErrorCodes.NORECORDSMATCH, OaiErrorCodes.NORECORDSMATCH,
); );
} else {
finder.andWhere('server_date_published', '<=', untilDate.format('YYYY-MM-DD HH:mm:ss'));
}
} }
let reldocIdsDocs = await finder.select('publish_id').orderBy('publish_id'); finder.andWhere('server_date_published', '<=', untilDate.format('YYYY-MM-DD HH:mm:ss'));
numWrapper.reldocIds = reldocIdsDocs.map((dat) => dat.publish_id); }
numWrapper.totalIds = numWrapper.reldocIds.length; //212
private parseDateWithValidation(dateStr: string, label: string) {
let date = dayjs(dateStr);
if (!date.isValid()) {
throw new OaiModelException(
StatusCodes.INTERNAL_SERVER_ERROR,
`${label} date parameter is not valid.`,
OaiErrorCodes.BADARGUMENT,
);
}
date = dayjs.tz(dateStr, 'Europe/Vienna');
return date.hour() === 0 ? (label === 'From' ? date.startOf('day') : date.endOf('day')) : date;
} }
private setParamResumption(res: string, cursor: number, totalIds: number) { private setParamResumption(res: string, cursor: number, totalIds: number) {
@ -641,4 +698,30 @@ export default class OaiController {
this.xsltParameter['oai_error_code'] = 'badVerb'; this.xsltParameter['oai_error_code'] = 'badVerb';
this.xsltParameter['oai_error_message'] = 'The verb provided in the request is illegal.'; this.xsltParameter['oai_error_message'] = 'The verb provided in the request is illegal.';
} }
/**
* Helper method to build a browser fingerprint by combining:
* - User-Agent header,
* - the IP address,
* - Accept-Language header,
* - current timestamp rounded to the hour.
*
* Every new hour, this will return a different fingerprint.
*/
private getBrowserFingerprint(request: Request): string {
const userAgent = request.header('user-agent') || 'unknown';
// Check for X-Forwarded-For header to use the client IP from the proxy if available.
const xForwardedFor = request.header('x-forwarded-for');
let ip = request.ip();
// console.log(ip);
if (xForwardedFor) {
// X-Forwarded-For may contain a comma-separated list of IPs; the first one is the client IP.
ip = xForwardedFor.split(',')[0].trim();
// console.log('xforwardedfor ip' + ip);
}
const locale = request.header('accept-language') || 'default';
// Round the current time to the start of the hour.
const timestampHour = dayjs().startOf('hour').format('YYYY-MM-DDTHH');
return `${userAgent}-${ip}-${locale}-${timestampHour}`;
}
} }

View file

@ -8,6 +8,7 @@ import Description from '#models/description';
import Language from '#models/language'; import Language from '#models/language';
import Coverage from '#models/coverage'; import Coverage from '#models/coverage';
import Collection from '#models/collection'; import Collection from '#models/collection';
import CollectionRole from '#models/collection_role';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Person from '#models/person'; import Person from '#models/person';
import db from '@adonisjs/lucid/services/db'; import db from '@adonisjs/lucid/services/db';
@ -501,7 +502,7 @@ export default class DatasetController {
} }
// save collection // save collection
const collection: Collection | null = await Collection.query().where('id', 21).first(); const collection: Collection | null = await Collection.query().where('id', 594).first();
collection && (await dataset.useTransaction(trx).related('collections').attach([collection.id])); collection && (await dataset.useTransaction(trx).related('collections').attach([collection.id]));
// save coverage // save coverage
@ -1209,7 +1210,7 @@ export default class DatasetController {
throw error; throw error;
} else if (error instanceof Exception) { } else if (error instanceof Exception) {
// General exception handling // General exception handling
session.flash({ error: error.message}); session.flash({ error: error.message });
return response.redirect().back(); return response.redirect().back();
} else { } else {
session.flash({ error: 'An error occurred while deleting the dataset.' }); session.flash({ error: 'An error occurred while deleting the dataset.' });
@ -1217,4 +1218,34 @@ export default class DatasetController {
} }
} }
} }
public async categorize({ inertia, request, response }: HttpContext) {
const id = request.param('id');
// Preload dataset and its "collections" relation
const dataset = await Dataset.query().where('id', id).preload('collections').firstOrFail();
const validStates = ['inprogress', 'rejected_editor'];
if (!validStates.includes(dataset.server_state)) {
// session.flash('errors', 'Invalid server state!');
return response
.flash(
'warning',
`Invalid server state. Dataset with id ${id} cannot be edited. Datset has server state ${dataset.server_state}.`,
)
.redirect()
.toRoute('dataset.list');
}
const collectionRoles = await CollectionRole.query()
.preload('collections', (coll: Collection) => {
// preloa only top level collection with noparent_id
coll.whereNull('parent_id').orderBy('number', 'asc');
})
.exec();
return inertia.render('Submitter/Dataset/Category', {
collectionRoles: collectionRoles,
dataset: dataset,
relatedCollections: dataset.collections,
});
}
} }

View file

@ -4,6 +4,7 @@ export default class ResumptionToken {
private _resumptionId = ''; private _resumptionId = '';
private _startPosition = 0; private _startPosition = 0;
private _totalIds = 0; private _totalIds = 0;
private _queryParams: Record<string, any> = {};
get key(): string { get key(): string {
return this.metadataPrefix + this.startPosition + this.totalIds; return this.metadataPrefix + this.startPosition + this.totalIds;
@ -48,4 +49,12 @@ export default class ResumptionToken {
set totalIds(totalIds: number) { set totalIds(totalIds: number) {
this._totalIds = totalIds; this._totalIds = totalIds;
} }
get queryParams(): Record<string, any> {
return this._queryParams;
}
set queryParams(params: Record<string, any>) {
this._queryParams = params;
}
} }

View file

@ -6,6 +6,6 @@ export default abstract class TokenWorkerContract {
abstract connect(): void; abstract connect(): void;
abstract close(): void; abstract close(): void;
abstract get(key: string): Promise<ResumptionToken | null>; abstract get(key: string): Promise<ResumptionToken | null>;
abstract set(token: ResumptionToken): Promise<string>; abstract set(token: ResumptionToken, browserFingerprint: string): Promise<string>;
} }

View file

@ -40,14 +40,64 @@ export default class TokenWorkerService implements TokenWorkerContract {
return result !== undefined && result !== null; return result !== undefined && result !== null;
} }
public async set(token: ResumptionToken): Promise<string> { /**
const uniqueName = await this.generateUniqueName(); * Simplified set method that stores the token using a browser fingerprint key.
* If the token for that fingerprint already exists and its documentIds match the new token,
* then the fingerprint key is simply returned.
*/
public async set(token: ResumptionToken, browserFingerprint: string): Promise<string> {
// Generate a 15-digit unique number string based on the fingerprint
const uniqueNumberKey = this.createUniqueNumberFromFingerprint(browserFingerprint, token.documentIds, token.totalIds);
// Optionally, you could prefix it if desired, e.g. 'rs_' + uniqueNumberKey
const fingerprintKey = uniqueNumberKey;
// const fingerprintKey = `rs_fp_${browserFingerprint}`;
const existingTokenString = await this.cache.get(fingerprintKey);
if (existingTokenString) {
const existingToken = this.parseToken(existingTokenString);
if (this.arraysAreEqual(existingToken.documentIds, token.documentIds)) {
return fingerprintKey;
}
}
const serialToken = JSON.stringify(token); const serialToken = JSON.stringify(token);
await this.cache.setEx(uniqueName, this.ttl, serialToken); await this.cache.setEx(fingerprintKey, this.ttl, serialToken);
return uniqueName; return fingerprintKey;
} }
// Updated helper method to generate a unique key based on fingerprint and documentIds
private createUniqueNumberFromFingerprint(browserFingerprint: string, documentIds: number[], totalIds: number): string {
// Combine the fingerprint, document IDs and totalIds to produce the input string
const combined = browserFingerprint + ':' + documentIds.join('-') + ':' + totalIds;
// Simple hash algorithm
let hash = 0;
for (let i = 0; i < combined.length; i++) {
hash = (hash << 5) - hash + combined.charCodeAt(i);
hash |= 0; // Convert to 32-bit integer
}
// Ensure positive number and limit it to at most 15 digits
const positiveHash = Math.abs(hash) % 1000000000000000;
// Pad with trailing zeros to ensure a 15-digit string
return positiveHash.toString().padEnd(15, '0');
}
// Add a helper function to compare two arrays of numbers with identical order
private arraysAreEqual(arr1: number[], arr2: number[]): boolean {
if (arr1.length !== arr2.length) {
return false;
}
return arr1.every((num, index) => num === arr2[index]);
}
// public async set(token: ResumptionToken): Promise<string> {
// const uniqueName = await this.generateUniqueName();
// const serialToken = JSON.stringify(token);
// await this.cache.setEx(uniqueName, this.ttl, serialToken);
// return uniqueName;
// }
private async generateUniqueName(): Promise<string> { private async generateUniqueName(): Promise<string> {
let fc = 0; let fc = 0;
const uniqueId = dayjs().unix().toString(); const uniqueId = dayjs().unix().toString();

View file

@ -209,6 +209,15 @@ export default class Dataset extends DatasetExtension {
return mainTitle ? mainTitle.value : null; return mainTitle ? mainTitle.value : null;
} }
@computed({
serializeAs: 'doi_identifier',
})
public get doiIdentifier() {
// return `${this.firstName} ${this.lastName}`;
const identifier: DatasetIdentifier = this.identifier;
return identifier ? identifier.value : null;
}
@manyToMany(() => Person, { @manyToMany(() => Person, {
pivotForeignKey: 'document_id', pivotForeignKey: 'document_id',
pivotRelatedForeignKey: 'person_id', pivotRelatedForeignKey: 'person_id',

View file

@ -51,7 +51,7 @@ export default class Person extends BaseModel {
serializeAs: 'name', serializeAs: 'name',
}) })
public get fullName() { public get fullName() {
return `${this.firstName} ${this.lastName}`; return [this.firstName, this.lastName].filter(Boolean).join(' ');
} }
// @computed() // @computed()
@ -64,10 +64,13 @@ export default class Person extends BaseModel {
// return '2023-03-21 08:45:00'; // return '2023-03-21 08:45:00';
// } // }
@computed()
@computed({
serializeAs: 'dataset_count',
})
public get datasetCount() { public get datasetCount() {
const stock = this.$extras.datasets_count; //my pivot column name was "stock" const stock = this.$extras.datasets_count; //my pivot column name was "stock"
return stock; return Number(stock);
} }
@computed() @computed()

View file

@ -88,7 +88,7 @@ export default class ValidateChecksum extends BaseCommand {
); );
// Construct the file path // Construct the file path
const filePath = '/storage/app/public/' + file.pathName; const filePath = '/storage/app/data/' + file.pathName;
try { try {
// Calculate the MD5 checksum of the file // Calculate the MD5 checksum of the file

View file

@ -80,7 +80,8 @@ export const http = defineConfig({
| headers. | headers.
| |
*/ */
trustProxy: proxyAddr.compile('loopback'), // trustProxy: proxyAddr.compile('loopback'),
trustProxy: proxyAddr.compile(['127.0.0.1', '::1/128']),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -18,6 +18,7 @@ export default class Accounts extends BaseSchema {
table.text("two_factor_recovery_codes").nullable(); table.text("two_factor_recovery_codes").nullable();
table.smallint('state').nullable(); table.smallint('state').nullable();
table.bigint('last_counter').nullable(); table.bigint('last_counter').nullable();
table.string('avatar').nullable();
}); });
} }
@ -43,6 +44,7 @@ export default class Accounts extends BaseSchema {
// two_factor_recovery_codes text COLLATE pg_catalog."default", // two_factor_recovery_codes text COLLATE pg_catalog."default",
// state smallint, // state smallint,
// last_counter bigint, // last_counter bigint,
// avatar character varying(255),
// ) // )
// ALTER TABLE gba.accounts // ALTER TABLE gba.accounts
@ -85,3 +87,6 @@ export default class Accounts extends BaseSchema {
// GRANT ALL ON SEQUENCE gba.totp_secrets_id_seq TO tethys_admin; // GRANT ALL ON SEQUENCE gba.totp_secrets_id_seq TO tethys_admin;
// ALTER TABLE gba.totp_secrets ALTER COLUMN id SET DEFAULT nextval('gba.totp_secrets_id_seq'); // ALTER TABLE gba.totp_secrets ALTER COLUMN id SET DEFAULT nextval('gba.totp_secrets_id_seq');
// ALTER TABLE "accounts" ADD COLUMN "avatar" VARCHAR(255) NULL

View file

@ -54,3 +54,8 @@ export default class Collections extends BaseSchema {
// ON UPDATE CASCADE // ON UPDATE CASCADE
// ON DELETE CASCADE // ON DELETE CASCADE
// ) // )
// change to normal intzeger:
// ALTER TABLE collections ALTER COLUMN id DROP DEFAULT;
// DROP SEQUENCE IF EXISTS collections_id_seq;

643
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,161 +1,142 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ComputedRef } from 'vue'; import { computed } from 'vue';
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
// import { Link } from '@inertiajs/inertia-vue3';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { mdiMinus, mdiPlus } from '@mdi/js'; import { mdiMinus, mdiPlus } from '@mdi/js';
import { getButtonColor } from '@/colors'; import { getButtonColor } from '@/colors';
import BaseIcon from '@/Components/BaseIcon.vue'; import BaseIcon from '@/Components/BaseIcon.vue';
// import AsideMenuList from '@/Components/AsideMenuList.vue';
import { stardust } from '@eidellev/adonis-stardust/client'; import { stardust } from '@eidellev/adonis-stardust/client';
import type { User } from '@/Dataset'; import type { User } from '@/Dataset';
import { MenuItem } from '@headlessui/vue';
const props = defineProps({ interface MenuItem {
item: { href?: string;
type: Object, route?: string;
required: true, icon?: string;
}, label: string;
parentItem: { target?: string;
type: Object, color?: string;
required: false, children?: MenuItem[];
}, isOpen?: boolean;
// isDropdownList: Boolean, roles?: string[];
}); }
const user: ComputedRef<User> = computed(() => { const props = defineProps<{
return usePage().props.authUser as User; item: MenuItem;
parentItem?: MenuItem;
// isDropdownList?: boolean;
}>();
const emit = defineEmits<{
(e: 'menu-click', event: Event, item: MenuItem): void;
}>();
// Retrieve authenticated user from page props
const user = computed<User>(() => usePage().props.authUser as User);
// Check if the menu item has children
const hasChildren = computed(() => {
return Array.isArray(props.item?.children) && props.item.children.length > 0;
}); });
const itemRoute = computed(() => (props.item && props.item.route ? stardust.route(props.item.route) : '')); const itemRoute = computed(() => (props.item && props.item.route ? stardust.route(props.item.route) : ''));
// const isCurrentRoute = computed(() => (props.item && props.item.route ? stardust.isCurrent(props.item.route): false)); // const isCurrentRoute = computed(() => (props.item && props.item.route ? stardust.isCurrent(props.item.route): false));
// const itemHref = computed(() => (props.item && props.item.href ? props.item.href : ''));
const emit = defineEmits(['menu-click']); // Determine which element to render based on 'href' or 'route'
const isComponent = computed(() => {
if (props.item.href) {
return 'a';
}
if (props.item.route) {
return Link;
}
return 'div';
});
// Check if any child route is active
const isChildActive = computed(() => {
if (props.item.children && props.item.children.length > 0) {
return props.item.children.some(child => child.route && stardust.isCurrent(child.route));
}
return false;
});
// Automatically use prop item.isOpen if set from the parent,
// or if one of its children is active then force open state.
const isOpen = computed(() => {
return props.item.isOpen || isChildActive.value;
});
const styleService = StyleService(); const styleService = StyleService();
const hasColor = computed(() => props.item && props.item.color); const hasColor = computed(() => props.item && props.item.color);
// const isDropdownOpen = ref(false);
// const isChildSelected = computed(() => {
// if (props.item.children && props.item.children.length > 0) { // const children = computed(() => {
// return children.value.some(childItem => stardust.isCurrent(childItem.route)); // return props.item.children || [];
// }
// return false;
// }); // });
const hasChildren = computed(() => {
// props.item.children?.length > 0
if (props.item.children && props.item.children.length > 0) {
return true;
}
return false;
});
const children = computed(() => {
return props.item.children || [];
});
const componentClass = computed(() => [ const componentClass = computed(() => [
hasChildren ? 'py-3 px-6 text-sm font-semibold' : 'py-3 px-6', hasChildren ? 'py-3 px-6 text-sm font-semibold' : 'py-3 px-6',
hasColor.value ? getButtonColor(props.item.color, false, true) : styleService.asideMenuItemStyle, hasColor.value ? getButtonColor(props.item.color, false, true) : styleService.asideMenuItemStyle,
]); ]);
const menuClick = (event: Event) => {
// const toggleDropdown = () => {
// // emit('menu-click', event, props.item);
// // console.log(props.item);
// if (hasChildren.value) {
// isDropdownOpen.value = !isDropdownOpen.value;
// }
// // if (props.parentItem?.hasDropdown.value) {
// // props.parentItem.isDropdownActive.value = true;
// // }
// };
const menuClick = (event) => {
emit('menu-click', event, props.item); emit('menu-click', event, props.item);
if (hasChildren.value) { if (hasChildren.value) {
// if (isChildSelected.value == false) { // Toggle open state if the menu has children
// isDropdownOpen.value = !isDropdownOpen.value;
props.item.isOpen = !props.item.isOpen; props.item.isOpen = !props.item.isOpen;
// }
} }
}; };
// const handleChildSelected = () => { const activeStyle = computed(() => {
// isChildSelected.value = true;
// };
const activeInactiveStyle = computed(() => {
if (props.item.route && stardust.isCurrent(props.item.route)) { if (props.item.route && stardust.isCurrent(props.item.route)) {
// console.log(props.item.route); // console.log(props.item.route);
return styleService.asideMenuItemActiveStyle; return 'text-sky-600 font-bold';
} else { } else {
return null; return null;
} }
}); });
const is = computed(() => {
if (props.item.href) {
return 'a';
}
if (props.item.route) {
return Link;
}
return 'div';
});
const hasRoles = computed(() => { const hasRoles = computed(() => {
if (props.item.roles) { if (props.item.roles) {
return user.value.roles.some(role => props.item.roles.includes(role.name)); return user.value.roles.some(role => props.item.roles?.includes(role.name));
// return test; // return test;
} }
return true return true
}); });
// props.routeName && stardust.isCurrent(props.routeName) ? props.activeColor : null
</script> </script>
<!-- :target="props.item.target ?? null" --> <!-- :target="props.item.target ?? null" -->
<template> <template>
<li v-if="hasRoles"> <li v-if="hasRoles">
<!-- <component :is="itemHref ? 'div' : Link" :href="itemHref ? itemHref : itemRoute" --> <component :is="isComponent" :href="props.item.href ? props.item.href : itemRoute"
<component :is="is" :href="itemRoute ? stardust.route(props.item.route) : props.item.href" class="flex cursor-pointer dark:text-slate-300 dark:hover:text-white menu-item-wrapper"
class="flex cursor-pointer dark:text-slate-300 dark:hover:text-white menu-item-wrapper" :class="componentClass" :class="componentClass" @click="menuClick" :target="props.item.target || null">
@click="menuClick" v-bind:target="props.item.target ?? null"> <BaseIcon v-if="props.item.icon" :path="props.item.icon" class="flex-none menu-item-icon"
<BaseIcon v-if="item.icon" :path="item.icon" class="flex-none menu-item-icon" :class="activeInactiveStyle" :class="activeStyle" w="w-16" :size="18" />
w="w-16" :size="18" />
<div class="menu-item-label"> <div class="menu-item-label">
<span class="grow text-ellipsis line-clamp-1" :class="activeInactiveStyle"> <span class="grow text-ellipsis line-clamp-1" :class="[activeStyle]">
{{ item.label }} {{ props.item.label }}
</span> </span>
</div> </div>
<!-- plus icon for expanding sub menu --> <!-- Display plus or minus icon if there are child items -->
<BaseIcon v-if="hasChildren" :path="props.item.isOpen ? mdiMinus : mdiPlus" class="flex-none" <BaseIcon v-if="hasChildren" :path="isOpen ? mdiMinus : mdiPlus" class="flex-none"
:class="[activeInactiveStyle]" w="w-12" /> :class="[activeStyle]" w="w-12" />
</component> </component>
<!-- Render dropdown -->
<div class="menu-item-dropdown" <div class="menu-item-dropdown"
:class="[styleService.asideMenuDropdownStyle, props.item.isOpen ? 'block dark:bg-slate-800/50' : 'hidden']" :class="[styleService.asideMenuDropdownStyle, isOpen ? 'block dark:bg-slate-800/50' : 'hidden']"
v-if="hasChildren"> v-if="props.item.children && props.item.children.length > 0">
<ul> <ul>
<!-- <li v-for="( child, index ) in children " :key="index">
<AsideMenuItem :item="child" :key="index"> </AsideMenuItem> <AsideMenuItem v-for="(childItem, index) in (props.item.children as any[])" :key="index" :item="childItem"
</li> --> @menu-click="$emit('menu-click', $event, childItem)" />
<AsideMenuItem v-for="(childItem, index) in children" :key="index" :item="childItem" />
</ul> </ul>
</div> </div>
<!-- <AsideMenuList v-if="hasChildren" :items="item.children"
:class="[styleService.asideMenuDropdownStyle, isDropdownOpen ? 'block dark:bg-slate-800/50' : 'hidden']" /> -->
</li> </li>
</template> </template>
@ -171,11 +152,6 @@ const hasRoles = computed(() => {
/* margin-right: 10px; */ /* margin-right: 10px; */
} }
/* .menu-item-label {
font-size: 1.2rem;
font-weight: bold;
} */
.menu-item-dropdown { .menu-item-dropdown {
/* margin-left: 10px; */ /* margin-left: 10px; */
padding-left: 0.75rem; padding-left: 0.75rem;

View file

@ -1,6 +1,6 @@
<script setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { mdiTrendingDown, mdiTrendingUp, mdiTrendingNeutral } from '@mdi/js'; // import { mdiTrendingDown, mdiTrendingUp, mdiTrendingNeutral } from '@mdi/js';
import CardBox from '@/Components/CardBox.vue'; import CardBox from '@/Components/CardBox.vue';
import BaseLevel from '@/Components/BaseLevel.vue'; import BaseLevel from '@/Components/BaseLevel.vue';
import PillTag from '@/Components/PillTag.vue'; import PillTag from '@/Components/PillTag.vue';
@ -27,6 +27,10 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
count: {
type: Number,
default: 0,
},
text: { text: {
type: String, type: String,
default: null, default: null,
@ -42,11 +46,11 @@ const pillType = computed(() => {
return props.type; return props.type;
} }
if (props.progress) { if (props.count) {
if (props.progress >= 60) { if (props.count >= 20) {
return 'success'; return 'success';
} }
if (props.progress >= 40) { if (props.count >= 5) {
return 'warning'; return 'warning';
} }
@ -56,17 +60,17 @@ const pillType = computed(() => {
return 'info'; return 'info';
}); });
const pillIcon = computed(() => { // const pillIcon = computed(() => {
return { // return {
success: mdiTrendingUp, // success: mdiTrendingUp,
warning: mdiTrendingNeutral, // warning: mdiTrendingNeutral,
danger: mdiTrendingDown, // danger: mdiTrendingDown,
info: mdiTrendingNeutral, // info: mdiTrendingNeutral,
}[pillType.value]; // }[pillType.value];
}); // });
const pillText = computed(() => props.text ?? `${props.progress}%`); // const pillText = computed(() => props.text ?? `${props.progress}%`);
</script> // </script>
<template> <template>
<CardBox class="mb-6 last:mb-0" hoverable> <CardBox class="mb-6 last:mb-0" hoverable>
@ -83,7 +87,17 @@ const pillText = computed(() => props.text ?? `${props.progress}%`);
</p> </p>
</div> </div>
</BaseLevel> </BaseLevel>
<PillTag :type="pillType" :text="pillText" small :icon="pillIcon" /> <!-- <PillTag :type="pillType" :text="text" small :icon="pillIcon" /> -->
<div class="text-center md:text-right space-y-2">
<p class="text-sm text-gray-500">
Count
</p>
<div>
<PillTag :type="pillType" :text="String(count)" small />
</div>
</div>
</BaseLevel> </BaseLevel>
</CardBox> </CardBox>
</template> </template>

View file

@ -0,0 +1,107 @@
<script lang="ts" setup>
import { computed, PropType } from 'vue';
import { mdiChartTimelineVariant, mdiFileDocumentOutline, mdiFileOutline, mdiDatabase } from '@mdi/js';
import CardBox from '@/Components/CardBox.vue';
import PillTag from '@/Components/PillTag.vue';
import IconRounded from '@/Components/IconRounded.vue';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
// Extend dayjs to support relative times
dayjs.extend(relativeTime);
interface Dataset {
account_id: number;
created_at: string;
creating_corporation: string;
editor_id: number;
embargo_date: string | null;
id: number;
language: string;
main_abstract: string | null;
main_title: string | null;
preferred_reviewer: string | null;
preferred_reviewer_email: string | null;
project_id: number | null;
publish_id: number;
publisher_name: string;
reject_editor_note: string | null;
reject_reviewer_note: string | null;
remaining_time: number;
reviewer_id: number;
server_date_modified: string;
server_date_published: string;
server_state: string;
type: string;
doi_identifier: string;
}
const props = defineProps({
dataset: {
type: Object as PropType<Dataset>,
required: true
}
});
const icon = computed(() => {
switch (props.dataset.type) {
case 'analysisdata':
return { icon: mdiChartTimelineVariant, type: 'success' };
case 'measurementdata':
return { icon: mdiFileDocumentOutline, type: 'warning' };
case 'monitoring':
return { icon: mdiFileOutline, type: 'info' };
case 'remotesensing':
return { icon: mdiDatabase, type: 'primary' };
case 'gis':
return { icon: mdiDatabase, type: 'info' };
case 'models':
return { icon: mdiChartTimelineVariant, type: 'success' };
case 'mixedtype':
return { icon: mdiFileDocumentOutline, type: 'warning' };
case 'vocabulary':
return { icon: mdiFileOutline, type: 'info' };
default:
return { icon: mdiDatabase, type: 'secondary' };
}
});
const displayTitle = computed(() => props.dataset.main_title || 'Untitled Dataset');
const doiLink = computed(() => {
return `https://doi.tethys.at/10.24341/tethys.${props.dataset.publish_id}`;
});
const relativeDate = computed(() => {
const publishedDate = dayjs(props.dataset.server_date_published);
if (publishedDate.isValid()) {
return publishedDate.fromNow();
}
return props.dataset.server_date_published;
});
// const displayBusiness = computed(() => props.dataset.publisher_name);
</script>
<template>
<CardBox class="mb-6 last:mb-0" hoverable>
<div class="flex items-start justify-between">
<IconRounded :icon="icon.icon" :type="icon.type" class="mr-6" />
<div class="flex-grow space-y-1 text-left" style="width: 70%;">
<h4 class="text-lg truncate" >
{{ displayTitle }}
</h4>
<p class="text-gray-500 dark:text-slate-400">
<b>
<a :href="doiLink" target="_blank">View Publication</a>
</b>
{{ relativeDate }}
</p>
</div>
<div class="flex flex-col items-end gap-2">
<p class="text-sm text-gray-500">{{ props.dataset.type }}</p>
<PillTag :type="icon.type" :text="props.dataset.type" small class="inline-flex" />
</div>
</div>
</CardBox>
</template>

View file

@ -1,14 +1,16 @@
<script setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref, Ref } from 'vue';
import { MainService } from '@/Stores/main'; import { MainService } from '@/Stores/main';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { mdiEye, mdiTrashCan } from '@mdi/js'; import { mdiEye } from '@mdi/js';
import CardBoxModal from '@/Components/CardBoxModal.vue'; import CardBoxModal from '@/Components/CardBoxModal.vue';
import TableCheckboxCell from '@/Components/TableCheckboxCell.vue'; import TableCheckboxCell from '@/Components/TableCheckboxCell.vue';
import BaseLevel from '@/Components/BaseLevel.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 UserAvatar from '@/Components/UserAvatar.vue';
import dayjs from 'dayjs';
import { User } from '@/Stores/main';
defineProps({ defineProps({
checkable: Boolean, checkable: Boolean,
@ -19,36 +21,32 @@ const mainService = MainService();
const items = computed(() => mainService.clients); const items = computed(() => mainService.clients);
const isModalActive = ref(false); const isModalActive = ref(false);
const isModalDangerActive = ref(false); // const isModalDangerActive = ref(false);
const perPage = ref(5); const perPage = ref(5);
const currentPage = ref(0); const currentPage = ref(0);
const checkedRows = ref([]); const checkedRows = ref([]);
const currentClient: Ref<User | null> = ref(null);
const itemsPaginated = computed(() => items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1))); const itemsPaginated = computed(() => items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1)));
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 pagesList = computed(() => { const pagesList = computed(() => {
const pagesList = []; const pagesList = [];
for (let i = 0; i < numPages.value; i++) { for (let i = 0; i < numPages.value; i++) {
pagesList.push(i); pagesList.push(i);
} }
return pagesList; return pagesList;
}); });
const remove = (arr, cb) => { const remove = (arr, cb) => {
const newArr = []; const newArr = [];
arr.forEach((item) => { arr.forEach((item) => {
if (!cb(item)) { if (!cb(item)) {
newArr.push(item); newArr.push(item);
} }
}); });
return newArr; return newArr;
}; };
@ -59,26 +57,31 @@ const checked = (isChecked, client) => {
checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id); checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id);
} }
}; };
const showModal = (client: User) => {
currentClient.value = client;
isModalActive.value = true;
};
</script> </script>
<template> <template>
<CardBoxModal v-model="isModalActive" title="Sample modal"> <CardBoxModal v-model="isModalActive" :title="currentClient ? currentClient.login : ''">
<div v-if="currentClient">
<p>Login: {{ currentClient.login }}</p>
<p>Email: {{ currentClient.email }}</p>
<p>Created: {{ currentClient?.created_at ? dayjs(currentClient.created_at).format('MMM D, YYYY h:mm A') : 'N/A' }}
</p>
</div>
</CardBoxModal>
<!-- <CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p> <p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
<p>This is sample modal</p> <p>This is sample modal</p>
</CardBoxModal> </CardBoxModal> -->
<CardBoxModal v-model="isModalDangerActive" large-title="Please confirm" button="danger" has-cancel>
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
<p>This is sample modal</p>
</CardBoxModal>
<div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800"> <div v-if="checkedRows.length" class="p-3 bg-gray-100/50 dark:bg-slate-800">
<span <span v-for="checkedRow in checkedRows" :key="checkedRow.id"
v-for="checkedRow in checkedRows" class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700">
:key="checkedRow.id" {{ checkedRow.login }}
class="inline-block px-2 py-1 rounded-sm mr-2 text-sm bg-gray-100 dark:bg-slate-700"
>
{{ checkedRow.name }}
</span> </span>
</div> </div>
@ -87,10 +90,8 @@ const checked = (isChecked, client) => {
<tr> <tr>
<th v-if="checkable" /> <th v-if="checkable" />
<th /> <th />
<th>Name</th> <th>Login</th>
<th>Email</th> <th>Email</th>
<th>City</th>
<th>Progress</th>
<th>Created</th> <th>Created</th>
<th /> <th />
</tr> </tr>
@ -99,29 +100,33 @@ const checked = (isChecked, client) => {
<tr v-for="client in itemsPaginated" :key="client.id"> <tr v-for="client in itemsPaginated" :key="client.id">
<TableCheckboxCell v-if="checkable" @checked="checked($event, client)" /> <TableCheckboxCell v-if="checkable" @checked="checked($event, client)" />
<td class="border-b-0 lg:w-6 before:hidden"> <td class="border-b-0 lg:w-6 before:hidden">
<UserAvatar :username="client.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" /> <!-- <UserAvatar :username="client.login" :avatar="client.avatar" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" /> -->
<div v-if="client.avatar">
<UserAvatar :default-url="client.avatar ? '/public' + client.avatar : ''"
:username="client.first_name + ' ' + client.last_name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
</div>
<div v-else>
<UserAvatar :username="client.first_name + ' ' + client.last_name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
</div>
</td> </td>
<td data-label="Name"> <td data-label="Login">
{{ client.name }} {{ client.login }}
</td> </td>
<td data-label="Email"> <td data-label="Email">
{{ client.email }} {{ client.email }}
</td> </td>
<td data-label="City"> <td data-label="Created">
{{ client.city }} <small class="text-gray-500 dark:text-slate-400"
</td> :title="client.created_at ? dayjs(client.created_at).format('MMM D, YYYY h:mm A') : 'N/A'">
<td data-label="Progress" class="lg:w-32"> {{ client.created_at ? dayjs(client.created_at).format('MMM D, YYYY h:mm A') : 'N/A' }}
<progress class="flex w-2/5 self-center lg:w-full" max="100" v-bind:value="client.progress"> </small>
{{ client.progress }}
</progress>
</td>
<td data-label="Created" class="lg:w-1 whitespace-nowrap">
<small class="text-gray-500 dark:text-slate-400" :title="client.created">{{ client.created }}</small>
</td> </td>
<td class="before:hidden lg:w-1 whitespace-nowrap"> <td class="before:hidden lg:w-1 whitespace-nowrap">
<BaseButtons type="justify-start lg:justify-end" no-wrap> <BaseButtons type="justify-start lg:justify-end" no-wrap>
<BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" /> <BaseButton color="info" :icon="mdiEye" small @click="showModal(client)" />
<BaseButton color="danger" :icon="mdiTrashCan" small @click="isModalDangerActive = true" /> <!-- <BaseButton color="danger" :icon="mdiTrashCan" small @click="isModalDangerActive = true" /> -->
</BaseButtons> </BaseButtons>
</td> </td>
</tr> </tr>
@ -130,15 +135,8 @@ const checked = (isChecked, client) => {
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800"> <div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
<BaseLevel> <BaseLevel>
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton v-for="page in pagesList" :key="page" :active="page === currentPage" :label="page + 1" small
v-for="page in pagesList" :outline="styleService.darkMode" @click="currentPage = page" />
:key="page"
:active="page === currentPage"
:label="page + 1"
small
:outline="styleService.darkMode"
@click="currentPage = page"
/>
</BaseButtons> </BaseButtons>
<small>Page {{ currentPageHuman }} of {{ numPages }}</small> <small>Page {{ currentPageHuman }} of {{ numPages }}</small>
</BaseLevel> </BaseLevel>

View file

@ -1,4 +1,4 @@
<script setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
@ -22,55 +22,55 @@ const avatar = computed(() => {
const username = computed(() => props.username); const username = computed(() => props.username);
const darkenColor = (color) => { // const darkenColor = (color: string) => {
const r = parseInt(color.slice(0, 2), 16); // const r = parseInt(color.slice(0, 2), 16);
const g = parseInt(color.slice(2, 4), 16); // const g = parseInt(color.slice(2, 4), 16);
const b = parseInt(color.slice(4, 6), 16); // const b = parseInt(color.slice(4, 6), 16);
const darkerR = Math.round(r * 0.6); // const darkerR = Math.round(r * 0.6);
const darkerG = Math.round(g * 0.6); // const darkerG = Math.round(g * 0.6);
const darkerB = Math.round(b * 0.6); // const darkerB = Math.round(b * 0.6);
const darkerColor = ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16); // const darkerColor = ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16);
return darkerColor.padStart(6, '0'); // return darkerColor.padStart(6, '0');
}; // };
const getColorFromName = (name) => { // const getColorFromName = (name: string): string => {
let hash = 0; // let hash = 0;
for (let i = 0; i < name.length; i++) { // for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash); // hash = name.charCodeAt(i) + ((hash << 5) - hash);
} // }
let color = '#'; // let color = '#';
for (let i = 0; i < 3; i++) { // for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff; // const value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2); // color += ('00' + value.toString(16)).substr(-2);
} // }
return color.replace('#', ''); // return color.replace('#', '');
}; // };
const lightenColor = (hexColor, percent) => { // const lightenColor = (hexColor: string, percent: number): string => {
let r = parseInt(hexColor.substring(0, 2), 16); // let r = parseInt(hexColor.substring(0, 2), 16);
let g = parseInt(hexColor.substring(2, 4), 16); // let g = parseInt(hexColor.substring(2, 4), 16);
let b = parseInt(hexColor.substring(4, 6), 16); // let b = parseInt(hexColor.substring(4, 6), 16);
r = Math.floor(r * (100 + percent) / 100); // r = Math.floor(r * (100 + percent) / 100);
g = Math.floor(g * (100 + percent) / 100); // g = Math.floor(g * (100 + percent) / 100);
b = Math.floor(b * (100 + percent) / 100); // b = Math.floor(b * (100 + percent) / 100);
r = (r < 255) ? r : 255; // r = (r < 255) ? r : 255;
g = (g < 255) ? g : 255; // g = (g < 255) ? g : 255;
b = (b < 255) ? b : 255; // b = (b < 255) ? b : 255;
const lighterHex = ((r << 16) | (g << 8) | b).toString(16); // const lighterHex = ((r << 16) | (g << 8) | b).toString(16);
return lighterHex.padStart(6, '0'); // return lighterHex.padStart(6, '0');
}; // };
const generateAvatarUrl = (name) => { const generateAvatarUrl = (name: string): string => {
const originalColor = getColorFromName(name); // const originalColor = getColorFromName(name);
const backgroundColor = lightenColor(originalColor, 60); // const backgroundColor = lightenColor(originalColor, 60);
const textColor = darkenColor(originalColor); // const textColor = darkenColor(originalColor);
const avatarUrl = `/api/avatar?name=${name}&size=50`; const avatarUrl = `/api/avatar?name=${name}&size=50`;
return avatarUrl; return avatarUrl;

View file

@ -2,7 +2,6 @@
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { MainService } from '@/Stores/main'; import { MainService } from '@/Stores/main';
// import { Inertia } from '@inertiajs/inertia';
import { import {
mdiAccountMultiple, mdiAccountMultiple,
mdiDatabaseOutline, mdiDatabaseOutline,
@ -13,21 +12,18 @@ import {
mdiGithub, mdiGithub,
mdiChartPie, mdiChartPie,
} from '@mdi/js'; } from '@mdi/js';
// import { containerMaxW } from '@/config.js'; // "xl:max-w-6xl xl:mx-auto"
// import * as chartConfig from '@/Components/Charts/chart.config.js';
import LineChart from '@/Components/Charts/LineChart.vue'; import LineChart from '@/Components/Charts/LineChart.vue';
import UserCard from '@/Components/unused/UserCard.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import CardBoxWidget from '@/Components/CardBoxWidget.vue'; import CardBoxWidget from '@/Components/CardBoxWidget.vue';
import CardBox from '@/Components/CardBox.vue'; import CardBox from '@/Components/CardBox.vue';
import TableSampleClients from '@/Components/TableSampleClients.vue'; import TableSampleClients from '@/Components/TableSampleClients.vue';
import NotificationBar from '@/Components/NotificationBar.vue'; // import NotificationBar from '@/Components/NotificationBar.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import CardBoxTransaction from '@/Components/CardBoxTransaction.vue';
import CardBoxClient from '@/Components/CardBoxClient.vue'; import CardBoxClient from '@/Components/CardBoxClient.vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import SectionBannerStarOnGitHub from '@/Components/SectionBannerStarOnGitea.vue'; import SectionBannerStarOnGitHub from '@/Components/SectionBannerStarOnGitea.vue';
import CardBoxDataset from '@/Components/CardBoxDataset.vue';
const mainService = MainService() const mainService = MainService()
// const chartData = ref(); // const chartData = ref();
@ -37,36 +33,32 @@ const fillChartData = async () => {
// chartData.value = mainService.graphData; // chartData.value = mainService.graphData;
}; };
const chartData = computed(() => mainService.graphData); const chartData = computed(() => mainService.graphData);
onMounted(async () => { // onMounted(async () => {
await mainService.fetchChartData("2022"); // await mainService.fetchChartData("2022");
}); // });
;
/* Fetch sample data */
mainService.fetch('clients');
mainService.fetch('history');
mainService.fetchApi('authors'); // mainService.fetch('clients');
mainService.fetchApi('datasets'); // mainService.fetch('history');
// mainService.fetchApi('authors');
// mainService.fetchApi('datasets');
// const clientBarItems = computed(() => mainService.clients.slice(0, 4)); // const clientBarItems = computed(() => mainService.clients.slice(0, 4));
const transactionBarItems = computed(() => mainService.history); // const transactionBarItems = computed(() => mainService.history);
const authorBarItems = computed(() => mainService.authors.slice(0, 4)); const authorBarItems = computed(() => mainService.authors.slice(0, 5));
const authors = computed(() => mainService.authors); const authors = computed(() => mainService.authors);
const datasets = computed(() => mainService.datasets); const datasets = computed(() => mainService.datasets);
// const props = defineProps({ const datasetBarItems = computed(() => mainService.datasets.slice(0, 5));
// user: { // let test = datasets.value;
// type: Object, // console.log(test);
// default: () => ({}),
// }
// });
</script> </script>
<template> <template>
<LayoutAuthenticated :showAsideMenu="false"> <LayoutAuthenticated :showAsideMenu="false">
<Head title="Dashboard" /> <Head title="Dashboard" />
<!-- <section class="p-6" v-bind:class="containerMaxW"> -->
<SectionMain> <SectionMain>
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Overview" main> <SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Overview" main>
<BaseButton <BaseButton
@ -97,16 +89,13 @@ const datasets = computed(() => mainService.datasets);
:number="datasets.length" :number="datasets.length"
label="Publications" label="Publications"
/> />
<!-- <CardBoxWidget trend="193" trend-type="info" color="text-blue-500" :icon="mdiCartOutline" :number="datasets.length"
prefix="$" label="Publications" /> -->
<CardBoxWidget <CardBoxWidget
trend="Overflow" trend="+25%"
trend-type="alert" trend-type="up"
color="text-red-500" color="text-purple-500"
:icon="mdiChartTimelineVariant" :icon="mdiChartTimelineVariant"
:number="256" :number="52"
suffix="%" label="Citations"
label="Performance"
/> />
</div> </div>
@ -118,25 +107,19 @@ const datasets = computed(() => mainService.datasets);
:name="client.name" :name="client.name"
:email="client.email" :email="client.email"
:date="client.created_at" :date="client.created_at"
:text="client.datasetCount" :text="client.identifier_orcid"
:count="client.dataset_count"
/> />
</div> </div>
<div class="flex flex-col justify-between"> <div class="flex flex-col justify-between">
<CardBoxTransaction <CardBoxDataset
v-for="(transaction, index) in transactionBarItems" v-for="(dataset, index) in datasetBarItems"
:key="index" :key="index"
:amount="transaction.amount" :dataset="dataset"
:date="transaction.date"
:business="transaction.business"
:type="transaction.type"
:name="transaction.name"
:account="transaction.account"
/> />
</div> </div>
</div> </div>
<UserCard />
<SectionBannerStarOnGitHub /> <SectionBannerStarOnGitHub />
<SectionTitleLineWithButton :icon="mdiChartPie" title="Trends overview: Publications per month" /> <SectionTitleLineWithButton :icon="mdiChartPie" title="Trends overview: Publications per month" />
@ -146,33 +129,13 @@ const datasets = computed(() => mainService.datasets);
</div> </div>
</CardBox> </CardBox>
<SectionTitleLineWithButton :icon="mdiAccountMultiple" title="Submitters (to do)" /> <SectionTitleLineWithButton :icon="mdiAccountMultiple" title="Submitters" />
<NotificationBar color="info" :icon="mdiMonitorCellphone"> <b>Responsive table.</b> Collapses on mobile </NotificationBar> <!-- <NotificationBar color="info" :icon="mdiMonitorCellphone"> <b>Responsive table.</b> Collapses on mobile </NotificationBar> -->
<CardBox :icon="mdiMonitorCellphone" title="Responsive table" has-table> <CardBox :icon="mdiMonitorCellphone" title="Responsive table" has-table>
<TableSampleClients /> <TableSampleClients />
</CardBox> </CardBox>
<!-- <CardBox>
<p class="mb-3 text-gray-500 dark:text-gray-400">
Discover the power of Tethys, the cutting-edge web backend solution that revolutionizes the way you handle research
data. At the heart of Tethys lies our meticulously developed research data repository, which leverages state-of-the-art
CI/CD techniques to deliver a seamless and efficient experience.
</p>
<p class="mb-3 text-gray-500 dark:text-gray-400">
CI/CD, or Continuous Integration and Continuous Deployment, is a modern software development approach that ensures your
code undergoes automated testing, continuous integration, and frequent deployment. By embracing CI/CD techniques, we
ensure that every code change in our research data repository is thoroughly validated, enhancing reliability and
accelerating development cycles.
</p>
<p class="mb-3 text-gray-500 dark:text-gray-400">
With Tethys, you can say goodbye to the complexities of manual deployments and embrace a streamlined process that
eliminates errors and minimizes downtime. Our CI/CD pipeline automatically verifies each code commit, runs comprehensive
tests, and deploys the repository seamlessly, ensuring that your research data is always up-to-date and accessible.
</p>
</CardBox> -->
</SectionMain> </SectionMain>
<!-- </section> -->
</LayoutAuthenticated> </LayoutAuthenticated>
</template> </template>

View file

@ -26,17 +26,6 @@ const errors: Ref<any> = computed(() => {
return usePage().props.errors; return usePage().props.errors;
}); });
// const form = useForm({
// preferred_reviewer: '',
// preferred_reviewer_email: '',
// preferation: 'yes_preferation',
// // preferation: '',
// // isPreferationRequired: false,
// });
// const isPreferationRequired = computed(() => form.preferation === 'yes_preferation');
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
// Notification.showInfo(`doi implementation is in developement. Create DOI for dataset ${props.dataset.publish_id} later on`); // Notification.showInfo(`doi implementation is in developement. Create DOI for dataset ${props.dataset.publish_id} later on`);

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { ref, Ref } from 'vue'; import { ref, Ref } from 'vue';
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiChartTimelineVariant, mdiGithub } from '@mdi/js';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
import BaseButton from '@/Components/BaseButton.vue'; import BaseButton from '@/Components/BaseButton.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue'; import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import { MapOptions } from '@/Components/Map/MapOptions'; import { MapOptions } from '@/Components/Map/MapOptions';
import { stardust } from '@eidellev/adonis-stardust/client'; // import { stardust } from '@eidellev/adonis-stardust/client';
import SearchMap from '@/Components/Map/SearchMap.vue'; import SearchMap from '@/Components/Map/SearchMap.vue';
import { OpensearchDocument } from '@/Dataset'; import { OpensearchDocument } from '@/Dataset';
@ -48,14 +48,15 @@ const mapOptions: MapOptions = {
<template> <template>
<LayoutAuthenticated :showAsideMenu="false"> <LayoutAuthenticated :showAsideMenu="false">
<Head title="Map" /> <Head title="Map" />
<!-- <section class="p-6" v-bind:class="containerMaxW"> --> <!-- <section class="p-6" v-bind:class="containerMaxW"> -->
<SectionMain> <SectionMain>
<SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main> <SectionTitleLineWithButton v-bind:icon="mdiChartTimelineVariant" title="Tethys Map" main>
<!-- <BaseButton href="https://gitea.geologie.ac.at/geolba/tethys" target="_blank" :icon="mdiGithub" <BaseButton href="https://gitea.geosphere.at/geolba/tethys" target="_blank" :icon="mdiGithub"
label="Star on Gitea" color="contrast" rounded-full small /> --> label="Star on GeoSPhere Forgejo" color="contrast" rounded-full small />
<BaseButton :route-name="stardust.route('app.login.show')" label="Login" color="white" rounded-full small /> <!-- <BaseButton :route-name="stardust.route('app.login.show')" label="Login" color="white" rounded-full small /> -->
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<!-- <SectionBannerStarOnGitea /> --> <!-- <SectionBannerStarOnGitea /> -->
@ -80,19 +81,20 @@ const mapOptions: MapOptions = {
<div v-for="author in dataset.author" :key="author" class="mb-1">{{ author }}</div> <div v-for="author in dataset.author" :key="author" class="mb-1">{{ author }}</div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2"> <span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
{{ dataset.year }} {{ dataset.year }}
</span> </span>
<span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2"> <span
class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">
{{ dataset.language }} {{ dataset.language }}
</span> </span>
</div> </div>
<p> <p>
<span class="label"><i class="fas fa-file"></i> {{ dataset.doctype }}</span> <span class="label"><i class="fas fa-file"></i> {{ dataset.doctype }}</span>
<!-- <span>Licence: {{ document.licence }}</span> --> <!-- <span>Licence: {{ document.licence }}</span> -->
<span v-if="openAccessLicences.includes(dataset.licence)" class="label titlecase" <span v-if="openAccessLicences.includes(dataset.licence)" class="label titlecase"><i
><i class="fas fa-lock-open"></i> Open Access</span class="fas fa-lock-open"></i> Open Access</span>
>
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,91 +1,343 @@
<template> <template>
<div class="flex flex-col h-screen p-4 bg-gray-100"> <LayoutAuthenticated>
<header class="flex justify-between items-center mb-4">
<h1 class="text-xl font-bold">SKOS Browser</h1> <Head title="Profile"></Head>
<div class="flex space-x-2"> <SectionMain>
<button @click="updateApp" title="Update the application"> <SectionTitleLineWithButton :icon="mdiLibraryShelves" title="Library Classification" main>
<!-- <img src="/Resources/Images/refresh.png" alt="Update" class="w-4 h-4" /> --> <div class="bg-lime-100 shadow rounded-lg p-6 mb-6 flex items-center justify-between">
</button> <div>
<button @click="showInfo" title="Info"> <label for="role-select" class="block text-lg font-medium text-gray-700 mb-1">
<!-- <img src="/Resources/Images/info.png" alt="Info" class="w-4 h-4" /> --> Select Classification Role <span class="text-red-500">*</span>
</button> </label>
</div> <select id="role-select" v-model="selectedCollectionRole"
</header> class="w-full border border-gray-300 rounded-md p-2 text-gray-700 focus:ring-2 focus:ring-indigo-500"
required>
<!-- <option value="" disabled selected>Please select a role</option> -->
<option v-for="collRole in collectionRoles" :key="collRole.id" :value="collRole">
{{ collRole.name }}
</option>
</select>
</div>
<div class="ml-4 hidden md:block">
<span class="text-sm text-gray-600 italic">* required</span>
</div>
</div>
</SectionTitleLineWithButton>
<!-- Available TopLevel Collections -->
<CardBox class="mb-4 rounded-lg p-4">
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Available Toplevel-Collections
<span v-if="selectedCollectionRole && !selectedToplevelCollection"
class="text-sm text-red-500 italic">(click to
select)</span>
</h2>
<ul class="flex flex-wrap gap-2">
<li v-for="col in collections" :key="col.id" :class="{
'cursor-pointer p-2 border border-gray-200 rounded hover:bg-sky-50 text-sky-700 text-sm': true,
'bg-sky-100 border-sky-500': selectedToplevelCollection && selectedToplevelCollection.id === col.id
}" @click="onToplevelCollectionSelected(col)">
{{ `${col.name} (${col.number})` }}
</li>
<li v-if="collections.length === 0" class="text-gray-800 dark:text-slate-400">
No collections available.
</li>
</ul>
</CardBox>
<!-- Collections Listing -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<!-- Broader Collection (Parent) -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Broader Collection</h2>
<ul class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<li v-for="parent in broaderCollections" :key="parent.id"
class="cursor-pointer p-2 border border-gray-200 rounded bg-green-50 text-green-700 text-sm hover:bg-green-100 hover:underline"
@click="onCollectionSelected(parent)" title="Click to select this collection">
{{ `${parent.name} (${parent.number})` }}
</li>
<li v-if="broaderCollections.length === 0" class="text-gray-500 text-sm">
No broader collections available.
</li>
</ul>
</CardBox>
<!-- Selected Collection Details -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h3 class="text-xl font-bold text-gray-800 dark:text-slate-400 mb-2">Selected Collection</h3>
<p
class="cursor-pointer p-2 border border-gray-200 rounded bg-green-50 text-green-700 text-sm hover:bg-green-100">
{{ `${selectedCollection.name} (${selectedCollection.number})` }}
</p>
</CardBox>
<!-- Narrower Collections (Children) -->
<CardBox v-if="selectedCollection" class="rounded-lg p-4" has-form-data>
<h2 class="text-lg font-bold text-gray-800 dark:text-slate-400 mb-2">Narrower Collections</h2>
<!-- <ul class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<li v-for="child in narrowerCollections" :key="child.id"
class="cursor-pointer p-2 border border-gray-200 rounded bg-green-50 text-green-700 text-sm hover:bg-green-100 hover:underline"
@click="onCollectionSelected(child)">
{{ `${child.name} (${child.number})` }}
</li>
<li v-if="narrowerCollections.length === 0" class="text-gray-500 text-sm">
No sub-collections available.
</li>
</ul> -->
<draggable v-if="narrowerCollections.length > 0" v-model="narrowerCollections"
:group="{ name: 'collections' }" tag="ul" class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<template #item="{ element: child }">
<li :key="child.id"
class="cursor-pointer p-2 border border-gray-200 rounded bg-green-50 text-green-700 text-sm hover:bg-green-100 hover:underline"
@click="onCollectionSelected(child)">
{{ `${child.name} (${child.number})` }}
</li>
</template>
</draggable>
<ul v-else class="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
<li class="text-gray-500 text-sm">
No sub-collections available.
</li>
</ul>
</CardBox>
<div class="bg-white shadow-md rounded-lg p-4 mb-6">
<h2 class="text-lg font-semibold">GBA-Thesaurus</h2>
<label class="block text-sm font-medium">Aktueller Endpoint:</label>
<!-- <TreeView :items="endpoints" @select="onEndpointSelected" /> -->
</div> </div>
<div class="bg-white shadow-md rounded-lg p-4"> <div class="mb-4 rounded-lg">
<h2 class="text-lg font-semibold">Konzept-Suche</h2> <div v-if="selectedCollection" class="bg-gray-100 shadow rounded-lg p-6 mb-6">
<!-- <Autocomplete v-model="selectedConcept" :items="concepts" placeholder="Search for a concept" @change="onConceptSelected" /> --> <p class="mb-4 text-gray-700">Please drag your collections here to classify your previously created
<div class="mt-4"> dataset
<h3 class="text-md font-medium">Ausgewähltes Konzept</h3> according to library classification standards.</p>
<p>{{ selectedConcept.title }}</p> <draggable v-model="dropCollections" :group="{ name: 'collections' }"
<a :href="selectedConcept.uri" target="_blank" class="text-blue-500">URI</a> class="min-h-36 border-dashed border-2 border-gray-400 p-4 text-sm flex flex-wrap gap-2 max-h-60 overflow-y-auto"
<textarea tag="ul">
v-model="selectedConcept.description" <template #item="{ element }">
class="mt-2 w-full h-24 border rounded" <div :key="element.id"
placeholder="Description" class="p-2 m-1 bg-sky-200 text-sky-800 rounded flex items-center gap-2 h-7">
></textarea> <span>{{ element.name }} ({{ element.number }})</span>
<button
@click="dropCollections = dropCollections.filter(item => item.id !== element.id)"
class="hover:text-sky-600 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
</div> </div>
<div class="mt-4"> </template>
<h3 class="text-md font-medium">Untergeordnete Konzepte</h3> </draggable>
<!-- <LinkLabelList :items="narrowerConcepts" /> -->
</div>
<div class="mt-4">
<h3 class="text-md font-medium">Übergeordnete Konzepte</h3>
<!-- <LinkLabelList :items="broaderConcepts" /> -->
</div>
<div class="mt-4">
<h3 class="text-md font-medium">Verwandte Konzepte</h3>
<!-- <LinkLabelList :items="relatedConcepts" /> -->
</div> </div>
</div> </div>
<div class="p-6 border-t border-gray-100 dark:border-slate-800">
<BaseButtons>
<BaseButton @click.stop="syncDatasetCollections" label="Save" color="info" small
:disabled="isSaveDisabled" :style="{ opacity: isSaveDisabled ? 0.5 : 1 }">
</BaseButton>
</BaseButtons>
</div> </div>
</SectionMain>
</LayoutAuthenticated>
</template> </template>
<script> <script lang="ts" setup>
// import TreeView from './TreeView.vue'; // Assuming you have a TreeView component import { ref, Ref, watch, computed } from 'vue';
// import Autocomplete from './Autocomplete.vue'; // Assuming you have an Autocomplete component import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
// import LinkLabelList from './LinkLabelList.vue'; // Assuming you have a LinkLabelList component import SectionMain from '@/Components/SectionMain.vue';
import SectionTitleLineWithButton from '@/Components/SectionTitleLineWithButton.vue';
import axios from 'axios';
import { mdiLibraryShelves } from '@mdi/js';
import draggable from 'vuedraggable';
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue';
import CardBox from '@/Components/CardBox.vue';
export default { interface CollectionRole {
components: { id: number;
// TreeView, name: string;
// Autocomplete, collections?: any[];
// LinkLabelList, }
interface Collection {
id: number;
name: string;
number: string;
parent_id?: number | null;
}
const props = defineProps({
collectionRoles: {
type: Array,
required: true,
default: () => []
}, },
data() { dataset: {
return { type: Object,
endpoints: [], // This should be populated with your data default: () => ({}),
concepts: [], // This should be populated with your data
selectedConcept: {},
narrowerConcepts: [], // Populate with data
broaderConcepts: [], // Populate with data
relatedConcepts: [], // Populate with data
};
}, },
methods: { relatedCollections: Array<Collection>
updateApp() { });
// Handle app update logic
const collectionRoles: Ref<CollectionRole[]> = ref(props.collectionRoles as CollectionRole[]);
const collections: Ref<Collection[]> = ref<Collection[]>([]);
const selectedCollectionRole = ref<CollectionRole | null>(null);
const selectedToplevelCollection = ref<Collection | null>(null);
const selectedCollection = ref<Collection | null>(null);
const narrowerCollections = ref<Collection[]>([]);
const broaderCollections = ref<Collection[]>([]);
// const onCollectionRoleSelected = (event: Event) => {
// const target = event.target as HTMLSelectElement;
// const roleId = Number(target.value);
// selectedCollectionRole.value =
// collectionRoles.value.find((role: CollectionRole) => role.id === roleId) || null;
// // Clear any previously selected collection or related data
// selectedCollection.value = null;
// narrowerCollections.value = [];
// broaderCollections.value = [];
// // fetchTopLevelCollections(roleId);
// collections.value = selectedCollectionRole.value?.collections || []
// };
// New reactive array to hold dropped collections for the dataset
const dropCollections: Ref<Collection[]> = ref([]);
// If there are related collections passed in, fill dropCollections with these.
if (props.relatedCollections && props.relatedCollections.length > 0) {
dropCollections.value = props.relatedCollections;
}
// Add a computed property for the disabled state based on dropCollections length
const isSaveDisabled = computed(() => dropCollections.value.length === 0);
// If the collectionRoles prop might load asynchronously (or change), you can watch for it:
watch(
() => props.collectionRoles as CollectionRole[],
(newCollectionRoles: CollectionRole[]) => {
collectionRoles.value = newCollectionRoles;
// Preselect the role with name "ccs" if it exists
const found: CollectionRole | undefined = collectionRoles.value.find(
role => role.name.toLowerCase() === 'ccs'
);
if (found?.name === 'ccs') {
selectedCollectionRole.value = found;
}
}, },
showInfo() { { immediate: true }
// Handle showing information );
}, // Watch for changes in selectedCollectionRole and update related collections state
onEndpointSelected(endpoint) { watch(
// Handle endpoint selection () => selectedCollectionRole.value as CollectionRole,
}, (newSelectedCollectionRole: CollectionRole | null) => {
onConceptSelected(concept) { if (newSelectedCollectionRole != null) {
this.selectedConcept = concept; collections.value = newSelectedCollectionRole.collections || []
// Handle concept selection logic, e.g., fetching related concepts } else {
selectedToplevelCollection.value = null;
selectedCollection.value = null;
collections.value = []
}
// Reset dependent variables when the role changes
selectedCollection.value = null
narrowerCollections.value = []
broaderCollections.value = []
}, },
{ immediate: true }
)
// Watch for changes in dropCollections
watch(
() => dropCollections.value,
() => {
if (selectedCollection.value) {
fetchCollections(selectedCollection.value.id);
}
}, },
{ deep: true }
);
const onToplevelCollectionSelected = (collection: Collection) => {
selectedToplevelCollection.value = collection;
selectedCollection.value = collection;
// call the API endpoint to get both.
fetchCollections(collection.id)
}; };
const onCollectionSelected = (collection: Collection) => {
selectedCollection.value = collection;
// call the API endpoint to get both.
fetchCollections(collection.id)
};
// New function to load both narrower and broader concepts using the real API route.
const fetchCollections = async (collectionId: number) => {
try {
const response = await axios.get(`/api/collections/${collectionId}`);
const data = response.data;
// Set narrower concepts with filtered collections
narrowerCollections.value = data.narrowerCollections.filter(
collection => !dropCollections.value.some(dc => dc.id === collection.id)
);
// For broader concepts, if present, wrap it in an array (or change your template accordingly)
broaderCollections.value = data.broaderCollection;
} catch (error) {
console.error('Error in fetchConcepts:', error);
}
};
const syncDatasetCollections = async () => {
try {
// Extract the ids from the dropCollections list
const collectionIds = dropCollections.value.map(item => item.id);
await axios.post('/api/dataset/collections/sync', { collections: collectionIds });
// Optionally show a success message or refresh dataset info
} catch (error) {
console.error('Error syncing dataset collections:', error);
}
};
</script> </script>
<style scoped> <style scoped>
/* Add your styles here */ .btn-primary {
background-color: #4f46e5;
color: white;
border-radius: 0.25rem;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-primary:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #4f46e5;
}
.btn-secondary {
background-color: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
}
.btn-secondary:hover {
background-color: #f9fafb;
}
.btn-secondary:focus {
outline: none;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6366f1;
}
</style> </style>

View file

@ -2,7 +2,7 @@
// import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3'; // import { Head, Link, useForm, usePage } from '@inertiajs/inertia-vue3';
import { Head, usePage } from '@inertiajs/vue3'; import { Head, usePage } from '@inertiajs/vue3';
import { ComputedRef } from 'vue'; import { ComputedRef } from 'vue';
import { mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline, mdiLockOpen } from '@mdi/js'; import { mdiSquareEditOutline, mdiTrashCan, mdiAlertBoxOutline, mdiLockOpen, mdiLibraryShelves } from '@mdi/js';
import { computed } from 'vue'; import { computed } from 'vue';
import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue'; import LayoutAuthenticated from '@/Layouts/LayoutAuthenticated.vue';
import SectionMain from '@/Components/SectionMain.vue'; import SectionMain from '@/Components/SectionMain.vue';
@ -140,6 +140,9 @@ const formatServerState = (state: string) => {
:icon="mdiLockOpen" :label="'Release'" small /> :icon="mdiLockOpen" :label="'Release'" small />
<BaseButton v-if="can.edit" :route-name="stardust.route('dataset.edit', [dataset.id])" <BaseButton v-if="can.edit" :route-name="stardust.route('dataset.edit', [dataset.id])"
color="info" :icon="mdiSquareEditOutline" :label="'Edit'" small /> color="info" :icon="mdiSquareEditOutline" :label="'Edit'" small />
<BaseButton v-if="can.edit"
:route-name="stardust.route('dataset.categorize', [dataset.id])" color="info"
:icon="mdiLibraryShelves" :label="'Library'" small />
<BaseButton v-if="can.delete" color="danger" <BaseButton v-if="can.delete" color="danger"
:route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan" :route-name="stardust.route('dataset.delete', [dataset.id])" :icon="mdiTrashCan"
small /> small />

View file

@ -2,6 +2,56 @@ import { defineStore } from 'pinia';
import axios from 'axios'; import axios from 'axios';
import { Dataset } from '@/Dataset'; import { Dataset } from '@/Dataset';
import menu from '@/menu'; import menu from '@/menu';
// import type Person from '#models/person';
export interface User {
id: number;
login: string;
firstName: string;
lastName: string;
email: string;
password: string;
created_at: DateTime;
updatedAt: DateTime;
lastLoginAt: DateTime;
isActive: boolean;
isVerified: boolean;
roles: string[];
permissions: string[];
settings: Record<string, any>;
profile: {
avatar: string;
bio: string;
location: string;
website: string;
social: {
twitter: string;
facebook: string;
linkedin: string;
github: string;
}
};
metadata: Record<string, any>;
verifyPassword: (plainPassword: string) => Promise<boolean>;
}
interface DateTime {
get: (unit: keyof DateTime) => number;
getPossibleOffsets: () => DateTime[];
toRelativeCalendar: (options?: ToRelativeCalendarOptions) => string | null;
toFormat: (format: string) => string;
toISO: () => string;
toJSON: () => string;
toString: () => string;
toLocaleString: (options?: Intl.DateTimeFormatOptions) => string;
toUTC: () => DateTime;
toLocal: () => DateTime;
valueOf: () => number;
toMillis: () => number;
toSeconds: () => number;
toUnixInteger: () => number;
}
export interface Person { export interface Person {
id: number; id: number;
@ -9,10 +59,12 @@ export interface Person {
email: string; email: string;
name_type: string; name_type: string;
identifier_orcid: string; identifier_orcid: string;
datasetCount: string; dataset_count: number;
created_at: string; created_at: string;
} }
interface TransactionItem { interface TransactionItem {
amount: number; amount: number;
account: string; account: string;
@ -61,7 +113,7 @@ export const MainService = defineStore('main', {
isFieldFocusRegistered: false, isFieldFocusRegistered: false,
/* Sample data for starting dashboard(commonly used) */ /* Sample data for starting dashboard(commonly used) */
clients: [], clients: [] as Array<User>,
history: [] as Array<TransactionItem>, history: [] as Array<TransactionItem>,
// api based data // api based data
@ -184,7 +236,7 @@ export const MainService = defineStore('main', {
this.totpState = state; this.totpState = state;
}, },
async fetchChartData(year: string) { fetchChartData(year: string) {
// sampleDataKey= authors or datasets // sampleDataKey= authors or datasets
axios axios
.get(`/api/statistic/${year}`) .get(`/api/statistic/${year}`)

View file

@ -8,6 +8,7 @@ import { createPinia } from 'pinia';
import { StyleService } from '@/Stores/style.service'; import { StyleService } from '@/Stores/style.service';
import { LayoutService } from '@/Stores/layout'; import { LayoutService } from '@/Stores/layout';
import { LocaleStore } from '@/Stores/locale'; import { LocaleStore } from '@/Stores/locale';
import { MainService } from './Stores/main';
import { darkModeKey, styleKey } from '@/config'; import { darkModeKey, styleKey } from '@/config';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { resolvePageComponent } from '@adonisjs/inertia/helpers'; import { resolvePageComponent } from '@adonisjs/inertia/helpers';
@ -80,7 +81,7 @@ const layoutService = LayoutService(pinia);
const localeService = LocaleStore(pinia); const localeService = LocaleStore(pinia);
localeService.initializeLocale(); localeService.initializeLocale();
// const mainService = MainService(pinia); const mainService = MainService(pinia);
// mainService.setUser(user); // mainService.setUser(user);
/* App style */ /* App style */
@ -90,6 +91,12 @@ styleService.setStyle(localStorage[styleKey] ?? 'basic');
if ((!localStorage[darkModeKey] && window.matchMedia('(prefers-color-scheme: dark)').matches) || localStorage[darkModeKey] === '1') { if ((!localStorage[darkModeKey] && window.matchMedia('(prefers-color-scheme: dark)').matches) || localStorage[darkModeKey] === '1') {
styleService.setDarkMode(true); styleService.setDarkMode(true);
} }
// mainService.fetch('clients');
// mainService.fetch('history');
mainService.fetchApi('clients');
mainService.fetchApi('authors');
mainService.fetchApi('datasets');
mainService.fetchChartData("2022");
/* Collapse mobile aside menu on route change */ /* Collapse mobile aside menu on route change */
Inertia.on('navigate', () => { Inertia.on('navigate', () => {

View file

@ -12,6 +12,7 @@ import {
mdiShieldCrownOutline, mdiShieldCrownOutline,
mdiLicense, mdiLicense,
mdiFileDocument, mdiFileDocument,
mdiLibraryShelves
} from '@mdi/js'; } from '@mdi/js';
export default [ export default [
@ -111,6 +112,11 @@ export default [
icon: mdiPublish, icon: mdiPublish,
label: 'Create Dataset', label: 'Create Dataset',
}, },
// {
// route: 'dataset.categorize',
// icon: mdiLibraryShelves,
// label: 'Library Classification',
// },
], ],
}, },
{ {

View file

@ -314,9 +314,11 @@ router
.as('dataset.deleteUpdate') .as('dataset.deleteUpdate')
.use([middleware.auth(), middleware.can(['dataset-delete'])]); .use([middleware.auth(), middleware.can(['dataset-delete'])]);
router.get('/person', [PersonController, 'index']).as('person.index').use([middleware.auth()]); router.get('/person', [PersonController, 'index']).as('person.index').use([middleware.auth()]);
router.get('/dataset/categorize', ({ inertia }: HttpContext) => { router
return inertia.render('Submitter/Dataset/Category'); .get('/dataset/:id/categorize', [DatasetController, 'categorize'])
}); .as('dataset.categorize')
.where('id', router.matchers.number())
.use([middleware.auth(), middleware.can(['dataset-edit'])]);
}) })
.prefix('submitter'); .prefix('submitter');

View file

@ -6,10 +6,12 @@ import HomeController from '#controllers/Http/Api/HomeController';
import FileController from '#controllers/Http/Api/FileController'; import FileController from '#controllers/Http/Api/FileController';
import AvatarController from '#controllers/Http/Api/AvatarController'; import AvatarController from '#controllers/Http/Api/AvatarController';
import UserController from '#controllers/Http/Api/UserController'; import UserController from '#controllers/Http/Api/UserController';
import CollectionsController from '#controllers/Http/Api/collections_controller';
import { middleware } from '../kernel.js'; import { middleware } from '../kernel.js';
// API // API
router router
.group(() => { .group(() => {
router.get('clients', [UserController, 'getSubmitters']).as('client.index');
router.get('authors', [AuthorsController, 'index']).as('author.index'); router.get('authors', [AuthorsController, 'index']).as('author.index');
router.get('datasets', [DatasetController, 'index']).as('dataset.index'); router.get('datasets', [DatasetController, 'index']).as('dataset.index');
router.get('persons', [AuthorsController, 'persons']).as('author.persons'); router.get('persons', [AuthorsController, 'persons']).as('author.persons');
@ -32,6 +34,8 @@ router
.post('/twofactor_backupcodes/settings/create', [UserController, 'createCodes']) .post('/twofactor_backupcodes/settings/create', [UserController, 'createCodes'])
.as('apps.twofactor_backupcodes.create') .as('apps.twofactor_backupcodes.create')
.use(middleware.auth()); .use(middleware.auth());
router.get('collections/:id', [CollectionsController, 'show']).as('collection.show')
}) })
// .namespace('App/Controllers/Http/Api') // .namespace('App/Controllers/Http/Api')
.prefix('api'); .prefix('api');

View file

@ -19,12 +19,63 @@ async function checkDoiExists(doi: string): Promise<boolean> {
} }
// Function to check if ISBN exists using the Open Library API // Function to check if ISBN exists using the Open Library API
// async function checkIsbnExists(isbn: string): Promise<boolean> {
// try {
// const response = await axios.get(`https://isbnsearch.org/isbn/${isbn}`);
// return response.status === 200 && response.data.includes('ISBN'); // Check if response contains ISBN information
// } catch (error) {
// return false; // If request fails, ISBN does not exist
// }
// }
async function checkIsbnExists(isbn: string): Promise<boolean> { async function checkIsbnExists(isbn: string): Promise<boolean> {
// Try Open Library first
try { try {
const response = await axios.get(`https://isbnsearch.org/isbn/${isbn}`); const response = await axios.get(`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`);
return response.status === 200 && response.data.includes('ISBN'); // Check if response contains ISBN information const data = response.data;
if (Object.keys(data).length > 0) {
return true;
}
} catch (error) { } catch (error) {
return false; // If request fails, ISBN does not exist // If an error occurs, continue to the next API
}
// Fallback to Google Books API
try {
const response = await axios.get(`https://www.googleapis.com/books/v1/volumes?q=isbn:${isbn}`);
const data = response.data;
if (data.totalItems > 0) {
return true;
}
} catch (error) {
// If an error occurs, continue to the next API
}
// Lastly use the Koha library by scraping HTML
try {
const response = await axios.get(`https://bibliothek.geosphere.at/cgi-bin/koha/opac-search.pl?idx=nb&q=${isbn}`);
const html = response.data;
// Check if zero results are explicitly indicated (German or English)
if (html.includes('Keine Treffer gefunden!') || html.includes('Your search returned 0 results')) {
return false;
}
// Try to extract the count from German message
let match = html.match(/Ihre Suche erzielte\s*(\d+)\s*Treffer/);
// If not found, try the English equivalent
if (!match) {
match = html.match(/Your search returned\s*(\d+)\s*results/);
}
if (match && match[1]) {
const count = parseInt(match[1], 10);
return count > 0;
}
// Fallback: if no match is found, return false
return false;
} catch (error) {
return false;
} }
} }
@ -42,10 +93,10 @@ async function validateReference(value: unknown, options: Options, field: FieldC
try { try {
const exists = await checkDoiExists(value); const exists = await checkDoiExists(value);
if (!exists) { if (!exists) {
field.report('The {{ field }} must be an existing DOI', 'validateReference', field); field.report('The {{ field }} must be an existing URL', 'validateReference', field);
} }
} catch (error) { } catch (error) {
field.report('Error checking DOI existence: ' + error.message, 'validateReference', field); field.report('Error checking URL existence: ' + error.message, 'validateReference', field);
} }
} }
} }