diff --git a/config.example.yaml b/config.example.yaml index dc1e11a..eda6db1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,19 +16,93 @@ login: # url: 'https://url-to-validate-login-scans/validate' # URL to validate CampusID login scans # api_key: 'replace_me' +global_return_directives: + - binId: fatal + color: red + priority: 100 + label: + de: "Rote Regal" + en: "red shelf" + message: + de: "Bitte wenden Sie sich an einem Bibliothekmitarbeiter." + en: "Please go to the main desk" + sort_order: 1 + # when defines when a book is assigned to the bin + # any if only one condition must be true + # all if all conditions must be true + # and the rules can be: + # - in to check if the field value is in the specified list + # - not_in to check if the field value is not in the specified list + # - equals to check if the field value equals the specified value + # - to check if the field exists + when: + any: + - field: process_type + in: ["LOST_LOAN", "LOST_AND_PAID", "CLAIMED_RETURNED_LOAN", "MISSING"] + all: + - field: library_code + in: ["TWIST", "OTHER"] + - field: process_type + in: ["HOLD_SHELF"] + - binId: warning + color: yellow + label: + de: "Gelbe Regal" + en: "Yellow shelf" + message: + de: "Bitte legen Sie das Medium in das blaue Regal." + en: "Please place this item in the blue shelf." + sort_order: 1 + when: + any: + - field: process_type + in: ["HOLD_SHELF", "LOST_AND_PAID", "CLAIMED_RETURNED_LOAN", "MISSING"] + - field: destination_library_code + exists: true + - field: has_request + equal: true + - field: process_type + not_in: ["LOST_LOAN", "MISSING"] + - binId: main + color: blue + label: + de: "Blaue Regal" + en: "Blue shelf" + message: + de: "Bitte legen Sie das Medium in das blaue Regal." + en: "Please place this item in the blue shelf." + sort_order: 1 + when: + always: true + checkout: # Optional image for the checkout list empty state; supports /absolute/path or https:// URL #no_media_found_image: '/branding/place_book.png' profiles: # The profile id is used via the checkout_profile_id URL param on checkout pages + # Each return directive must at least define one return directive with binId: main, + # and to link the bin just add the bin if to the list of return_directives + # Untouched configuration of the bin will default to the logic defined in global return directives - id: terminal1 type: alma library: MAIN_LIBRARY circulation_desk: SELF-CHECKOUT-CIRC1 + return_directives: + - binId: main + - binId: warning + - binId: fatal + label: + de: "Blaue Regal" + en: "Blue shelf" + message: + de: "Bitte legen Sie das Medium in das blaue Regal." + en: "Please place this item in the blue shelf." - id: terminal2 type: alma library: MAIN_LIBRARY circulation_desk: SELF-CHECKOUT-CIRC2 + return_directives: + - binId: main # Security gate display configuration gate: diff --git a/src/lib/lms/lms.ts b/src/lib/lms/lms.ts index 3c42744..57c71e2 100644 --- a/src/lib/lms/lms.ts +++ b/src/lib/lms/lms.ts @@ -21,8 +21,17 @@ export const MediaItemSchema = v.object({ publisher: v.optional(v.string()), cover: v.optional(v.string()), status: v.optional(v.string()), + process_type: v.optional(v.string()), library: v.optional(v.string()), + library_code: v.optional(v.string()), + destination_library_code: v.optional(v.string()), location: v.optional(v.string()), + location_code: v.optional(v.string()), + destination_location_code: v.optional(v.string()), + circulation_desk: v.optional(v.string()), + circulation_desk_code: v.optional(v.string()), + has_request: v.optional(v.boolean()), + request_type: v.optional(v.string()), shelfmark: v.optional(v.string()), loanDate: v.optional(v.string()), dueDate: v.optional(v.string()), diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 4028204..023ca49 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -52,11 +52,41 @@ export interface LoginValidationConfig { campus_id?: LoginValidationCampusIdConfig; } +export interface ShelfCheck { + check: string, + value: string +} + +export interface ReturnRule { + field: string; + in?: string[]; + not_in?: string[]; + equals?: string | boolean | number; + exists?: boolean; +} + +export interface ReturnCondition { + any?: ReturnRule[]; + all?: ReturnRule[]; + always?: boolean; +} + +export interface ReturnDirective { + binId: string; + priority: number; + message: Record; + label: Record; + color: string; + sort_order: number; + when?: ReturnCondition +} + export interface CheckoutProfileConfig { id: string; type?: string; library: string; circulation_desk: string; + return_directives: ReturnDirective[] } export interface CheckoutConfig { @@ -96,6 +126,7 @@ export interface LMSConfig { checkout?: CheckoutConfig; theme?: ThemeConfig; middleware_instances: MiddlewareInstanceConfig[]; + global_return_directives?: ReturnDirective[]; } const DEFAULT_LOGIN_MODE: LoginMode = 'username_password'; @@ -130,6 +161,26 @@ const DEFAULT_THEME_CONFIG: ThemeConfig = { }, logo: undefined }; +const DEFAULT_RETURN_DIRECTIVE_COLOR_CONFIG: string = "blue"; +const DEFAULT_RETURN_DIRECTIVE_MESSAGE_CONFIG: Record = { + de: "Bitte legen Sie das Medium in das blaue Regal.", + en: "lease place this item in the blue shelf." +} +const DEFAULT_RETURN_DIRECTIVE_LABEL_CONFIG: Record = { + de: "Blaue Regal", + en: "Blue shelf" +} +const DEFAULT_RETURN_DIRECTIVES_CONFIG: ReturnDirective[] = [ + { + binId: "main", + priority: 0, + message: DEFAULT_RETURN_DIRECTIVE_MESSAGE_CONFIG, + label: DEFAULT_RETURN_DIRECTIVE_LABEL_CONFIG, + color: DEFAULT_RETURN_DIRECTIVE_COLOR_CONFIG, + sort_order: 2, + when: {always: true} + } +] function normalizeCssColor(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; @@ -220,6 +271,44 @@ function parseTaggingConfig(data: LMSConfig): TaggingConfig { }; } +function parseGlobalReturnDirectiveConfig(data: LMSConfig): ReturnDirective[] { + if (data.global_return_directives === undefined) return DEFAULT_RETURN_DIRECTIVES_CONFIG; + + return data.global_return_directives.map((returnDirective) => { + return { + binId: returnDirective.binId, + priority: returnDirective.priority, + label: returnDirective.label, + message: returnDirective.message, + color: returnDirective.color, + when: returnDirective.when, + sort_order: returnDirective.sort_order + } + }) +} + +function parseSpecialReturnDirectiveConfig(global: ReturnDirective[], override: Record): ReturnDirective { + const globalReturnDirectiveOfBin = global.find((returnDirective) => returnDirective.binId === override.binId); + if (globalReturnDirectiveOfBin === undefined) { + throw new Error(`Global return directive not found for binId ${override.binId}`); + } + const priority = override.priority === undefined ? globalReturnDirectiveOfBin.priority: override.priority; + const label = override.label === undefined ? globalReturnDirectiveOfBin.label : override.label; + const message = override.message === undefined ? globalReturnDirectiveOfBin.message : override.message; + const color = override.color === undefined ? globalReturnDirectiveOfBin.color : override.color; + const sortOrder = override.sort_order === undefined ? globalReturnDirectiveOfBin.sort_order : override.sort_order; + const when = override.when === undefined ? globalReturnDirectiveOfBin.when : override.when; + return { + binId: override.binId, + priority: priority, + label: label, + message: message, + color: color, + when: when, + sort_order: sortOrder + } +} + function parseCheckoutProfiles(data: LMSConfig): CheckoutProfileConfig[] { if (data.checkout?.profiles === undefined) return DEFAULT_CHECKOUT_CONFIG.profiles ?? []; @@ -228,6 +317,7 @@ function parseCheckoutProfiles(data: LMSConfig): CheckoutProfileConfig[] { } const lmsType = typeof data.lms?.type === 'string' ? data.lms.type : ''; + const globalReturnDirectives = parseGlobalReturnDirectiveConfig(data); return data.checkout.profiles.map((profile, index) => { if (!profile || typeof profile !== 'object') { @@ -236,9 +326,16 @@ function parseCheckoutProfiles(data: LMSConfig): CheckoutProfileConfig[] { const id = typeof profile.id === 'string' ? profile.id.trim() : ''; const library = typeof profile.library === 'string' ? profile.library.trim() : ''; - const circulationDesk = - typeof profile.circulation_desk === 'string' ? profile.circulation_desk.trim() : ''; + const circulationDesk = typeof profile.circulation_desk === 'string' ? profile.circulation_desk.trim() : ''; const type = typeof profile.type === 'string' ? profile.type.trim() : lmsType; + var returnDirectives; + if (profile.return_directives === undefined) { + returnDirectives = globalReturnDirectives; + } else { + returnDirectives = profile.return_directives.map((returnDirective) => { + return parseSpecialReturnDirectiveConfig(globalReturnDirectives, returnDirective) + }); + } if (!id || !library || !circulationDesk) { throw new Error( @@ -255,7 +352,8 @@ function parseCheckoutProfiles(data: LMSConfig): CheckoutProfileConfig[] { id, type, library, - circulation_desk: circulationDesk + circulation_desk: circulationDesk, + return_directives: returnDirectives }; }); } diff --git a/src/lib/server/lms/alma.ts b/src/lib/server/lms/alma.ts index 908616c..92de6b6 100644 --- a/src/lib/server/lms/alma.ts +++ b/src/lib/server/lms/alma.ts @@ -10,14 +10,39 @@ import type { } from '../../lms/lms'; import * as v from 'valibot'; import { logger } from '$lib/server/logger'; +import { getLocale } from '$lib/paraglide/runtime'; const DEFAULT_API_URL = 'https://api-eu.hosted.exlibrisgroup.com/almaws/v1/'; +type ReturnRule = { + field: string; + in?: string[]; + equals?: string | boolean | number; + exists?: boolean; +} + +type ReturnCondition = { + any?: ReturnRule[]; + all?: ReturnRule[]; + always?: boolean; +} + +type ReturnDirective = { + binId: string; + priority: number; + message: Record; + label: Record; + color: string; + sort_order: number; + when?: ReturnCondition +} + type CheckoutProfile = { id: string; library: string; circulation_desk: string; type?: string; + return_directives?: ReturnDirective[]; }; interface AlmaLmsOptions { @@ -202,39 +227,71 @@ export class AlmaLMS implements LibraryManagementSystem { private params: URLSearchParams; private itemCache = new Map(); private checkoutProfiles = new Map(); + private returnDirectives = new Map>(); + + private matchesRule(rule: ReturnRule, item: MediaItem): boolean { + const value = item[rule.field as keyof MediaItem]; + if (rule.exists !== undefined) { + return rule.exists + ? value !== undefined && value !== null + : value === undefined || value === null; + } + if (rule.in !== undefined) { + return value !== undefined && + rule.in.includes(String(value)); + } + if (rule.not_in !== undefined) { + return value === undefined || + !rule.in.includes(String(value)); + } + if (rule.equals !== undefined) { + return value === rule.equals; + } + return false; + } + + private matches(condition: ReturnCondition | undefined, item: MediaItem): boolean { + if (!condition) { return false; } + if (condition.always === true) { return true; } + if (condition.any) { return condition.any.some(child => this.matchesRule(child, item)); } + if (condition.all) { return condition.all.every(child => this.matchesRule(child, item)); } + return false; + } - // TODO this is still mocked private buildReturnDirective(item: MediaItem): LmsReturnDirective | undefined { - const location = item.location?.toLowerCase() ?? ''; - const library = item.library?.toLowerCase() ?? ''; - - if (location.includes('children') || library.includes('children')) { - return { - binId: 'blue', - label: 'Blue shelf', - color: 'blue', - message: 'Place this item in the blue sorting shelf', - sortOrder: 2 - }; - } - - if (location.includes('fiction') || location.includes('fantasy')) { - return { - binId: 'red', - label: 'Red shelf', - color: 'red', - message: 'Place this item in the red sorting shelf', - sortOrder: 1 - }; - } - - return { - binId: 'yellow', - label: 'Yellow shelf', - color: 'yellow', - message: 'Place this item in the yellow sorting shelf', - sortOrder: 3 - }; + const library = item.library_code?.toLowerCase() ?? ''; + if (library === undefined || library === "") { + return { + binId: "", + label: "", + message: "", + color: "", + sortOrder: 1 + }; + } + const location = item.location_code?.toLowerCase() ?? ''; + // const circulation_desk = "DEFAULT_CIRC_DESK".toLowerCase(); + // const circulation_desk = item.location?.toLowerCase() ?? ''; + const circulation_desk = item.circulation_desk_code?.toLowerCase() ?? "DEFAULT_CIRC_DESK".toLowerCase(); + const key = `${library.trim()}:${circulation_desk.trim()}`.toLowerCase(); + const returnDirectives = this.returnDirectives.get(key); + if (returnDirectives == undefined) { + throw new Error(`Return directives not defined for the library ${library} and ${circulation_desk}`); + } + logger.debug({ returnDirectives }, `Return directives of ${key}`); + for (const directive of returnDirectives.sort((a, b) => b.priority - a.priority)) { + if (this.matches(directive.when, item)) { + logger.debug({ directive }, `Matched directive of ${key}`); + const locale = getLocale(); + return { + binId: directive.binId, + label: directive.label[locale] ?? directive.label.en, + message: directive.message[locale] ?? directive.message.en, + color: directive.color, + sortOrder: directive.sort_order + }; + } + } } private getCachedItem(barcode: string) { @@ -406,7 +463,9 @@ export class AlmaLMS implements LibraryManagementSystem { date: itemData.bib_data.date_of_publication, publisher: itemData.bib_data.publisher_const, library: itemData.item_data.library.desc, + library_code: itemData.item_data.library.value, location: itemData.item_data.location.desc, + location_code: itemData.item_data.location.value, shelfmark: itemData.item_data.alternative_call_number, status: itemData.item_data.base_status.desc + ': ' + (itemData.item_data.process_type?.desc ?? '-'), @@ -502,6 +561,17 @@ export class AlmaLMS implements LibraryManagementSystem { this.apiUrl = apiUrl; this.apiKey = apiKey; + this.returnDirectives = new Map(); + for (const profile of checkoutProfiles) { + const key = `${profile.library.trim()}:${profile.circulation_desk.trim()}`.toLowerCase(); + logger.trace({ key }, "Map key to return directives"); + const returnDirectives = profile.return_directives; + logger.trace({ returnDirectives }, `Return Directives of key ${key}`); + if (returnDirectives === undefined) { + throw new Error(`Return directives not configured for library ${profile.library} and circulation_desk ${profile.circulation_desk}`); + } + this.returnDirectives.set(key, returnDirectives); + } this.checkoutProfiles = new Map( checkoutProfiles .filter((profile) => (profile.type ?? 'alma').toLowerCase() === 'alma') @@ -815,6 +885,7 @@ export class AlmaLMS implements LibraryManagementSystem { date: parsedItemData.output.bib_data.date_of_publication, publisher: parsedItemData.output.bib_data.publisher_const, library: parsedItemData.output.item_data.library.desc, + library_code: parsedItemData.output.item_data.library.value, location: parsedItemData.output.item_data.location.desc, shelfmark: parsedItemData.output.item_data.alternative_call_number, status: diff --git a/src/lib/server/lms/resolve.ts b/src/lib/server/lms/resolve.ts index 35126f3..8638bf1 100644 --- a/src/lib/server/lms/resolve.ts +++ b/src/lib/server/lms/resolve.ts @@ -8,15 +8,16 @@ let cached: LibraryManagementSystem | null = null; export function getLms(): LibraryManagementSystem { if (cached) return cached; - const { lms: lmsConfig, checkout } = getConfig(); + // const { lms: lmsConfig, checkout } = getConfig(); + const config = getConfig(); - if (lmsConfig.type === 'alma') { - if (!lmsConfig.api_key) { + if (config.lms.type === 'alma') { + if (!config.lms.api_key) { throw new Error('Missing Alma API key in configuration (lms.api_key)'); } cached = new AlmaLMS({ - apiKey: lmsConfig.api_key, - checkoutProfiles: checkout?.profiles + apiKey: config.lms.api_key, + checkoutProfiles: config.checkout?.profiles }); return cached; }