diff --git a/STATUS.md b/STATUS.md index 5fce723..20d48e7 100644 --- a/STATUS.md +++ b/STATUS.md @@ -10,6 +10,12 @@ Pairs with Pinakes 0.9.0 (Mobile API 1.9.0 for `closed_dates`, `loanable`, `rese - **Refusal codes**: `not_loanable`, `reservations_disabled`, `reservation_limit_reached`, `media_limit_reached`, `max_loans_reached` and `not_eligible` with `meta.reason` `debt_block` / `card_expired` / `user_suspended` have their own wording for readers and staff; unknown codes keep the server message. Staff borrower pages and the new-loan form name the eligibility reason in the app's words. - **Balance and movements** (Profile, only with `features.fines`): balance, the "loans blocked" banner with the library's threshold, fines still accruing on overdue loans, and the ledger. A 404 `feature_disabled` closes the page without an error. - Additive, shown when the server sends them: `patron_type` on the profile, `consultation_only` on a staff copy. +- **Consultation-only copies on their own switch**: Pinakes 0.9.0 moved them off the rules matrix (`/health.features.consultation_only`). The book page now asks for the reader's rules when either `circulation_rules` or `consultation_only` is on, so a consultation-only title shows the disabled button with its reason even with patron-type rules off. A server without the key keeps tying it to `circulation_rules`. `automatic_fines` and `debt_block` need no client change: the server already leaves the accruing fines out and sends `debt_block_threshold_cents: null` and `debt_blocked: false` when they are off. +- **A loan approved on a closed day**: with the library closed today, an auto-approved request comes back `prenotato` with `start_date` (the next open day). The confirmation now says from which day the book can be picked up, instead of "ready for pickup". +- **Quoted searches offline** (Pinakes #501): online, quotes ask the server for the exact spelling. The offline catalogue folds accents and cannot tell spellings apart, so it now drops the quotes and searches the words, as the server reads them (straight and typographic quotes; an inch mark after a digit stays), in the text query and in the author and publisher filters. Before, it looked for the quote characters and found nothing. +- **Staff reservations beyond the patron type's limits**: with `circulation_rules` on, the staff new-reservation form has the website's switch "Ignore the reservation limits of the patron's type", sent as `override_limits` on `POST /staff/reservations`. Without the switch, or with the rules off, the key is left out. +- **Consultation-only copies from the copy editor**: with `features.consultation_only` on, the staff copy editor has the switch the book page has; `consultation_only` is sent on `PUT /staff/copies/{id}` only when it changed. The server refuses it for a copy on loan, awaiting pickup or requested, as the website does. Needs the Mobile API change in Pinakes #516, which also sends `patron_type` on `GET /me` for the profile row. +- **Library card barcode**: the profile draws the card code (`codice_tessera`) as Code128, the printed card's default (Pinakes 0.9.0 library cards), so the desk and the visitor-log kiosk can scan the phone. Only printable ASCII is encoded, as on the printed card. - No change to the local cache database (Room version 3). ## 1.10.0 (versionCode 24): loan requests bounded by a scheduled loan diff --git a/_contract/health-sample.json b/_contract/health-sample.json index 962e2ed..8911230 100644 --- a/_contract/health-sample.json +++ b/_contract/health-sample.json @@ -1 +1 @@ -{"data":{"name":"Pinakes","logo":null,"version":"0.9.0","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BE1SJEHx27wB0gDE4cx6BRrW6HRbSpDuJoGXpMaRMRsZ-7sKEQpOOk1XEy0TBIdiP-ZPF54Hmw5wNsoJI22xWdA"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file +{"data":{"name":"Pinakes","logo":null,"version":"0.9.0","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true,"reviews":true,"archives":true,"desiderata":true,"staff_catalog":true,"catalog_sync":true,"calendar_feed":true,"automatic_fines":false,"debt_block":false,"consultation_only":true,"fines":false,"closures_calendar":true,"circulation_rules":true,"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true,"staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true,"staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true},"catalogue_mode":false,"loan_approval_required":true,"app_access_enabled":true,"registration_enabled":true,"registration":{"require_cognome":true,"require_telefono":true,"require_indirizzo":true,"show_sesso":true,"show_cod_fiscale":true,"custom_fields":[]},"private_mode":false,"vapid_public_key":"BE1SJEHx27wB0gDE4cx6BRrW6HRbSpDuJoGXpMaRMRsZ-7sKEQpOOk1XEy0TBIdiP-ZPF54Hmw5wNsoJI22xWdA"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file diff --git a/app/src/main/java/com/pinakes/app/data/local/SearchText.kt b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt index 1151036..71f2e8b 100644 --- a/app/src/main/java/com/pinakes/app/data/local/SearchText.kt +++ b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt @@ -14,6 +14,8 @@ object SearchText { private val COMBINING = Regex("\\p{M}+") private val ID_SEPARATORS = Regex("[\\s-]") private val IDENTIFIER_LIKE = Regex("^[0-9Xx\\s-]+$") + private const val QUOTES = "\"\u201C\u201D\u201E\u00AB\u00BB" + private val SPACES = Regex("\\s+") /** Lower-case, accent-free form of [text] ("Città" → "citta", "Ærø" keeps æ/ø, ß stays). */ fun fold(text: String?): String = @@ -39,13 +41,42 @@ object SearchText { * so "978-88-04" finds 9788804…. */ fun queryPattern(query: String?): String { - val trimmed = query?.trim().orEmpty() + val trimmed = withoutSearchQuotes(query?.trim().orEmpty()) val compact = if (IDENTIFIER_LIKE.matches(trimmed) && trimmed.count { it.isDigit() } >= 4) { trimmed.replace(ID_SEPARATORS, "") } else trimmed return escapeLike(fold(compact)) } + /** + * [query] without the quotes that mark an exact spelling online (Pinakes #501): straight and + * typographic quotes, as the server reads them. The offline key folds accents, so it cannot + * tell "Häffner" from "Haffner"; without this the quotes themselves were searched for and a + * quoted query found nothing offline. An inch mark (a straight or closing quote right after + * a digit, outside a quoted part, as in `12"`) stays, as it does on the server. + */ + fun withoutSearchQuotes(query: String): String { + if (query.none { it in QUOTES }) return query + val out = StringBuilder(query.length) + var open = false + var prev = ' ' + for (c in query) { + if (c in QUOTES) { + if (!open && (c == '"' || c == '\u201D') && prev.isDigit()) out.append(c) + else { open = !open; out.append(' ') } + } else out.append(c) + prev = c + } + return out.toString().replace(SPACES, " ").trim() + } + + /** + * The LIKE pattern of the author or publisher filter. Those are quoted for an exact spelling + * too (#501); offline the quotes go, as in [queryPattern]. Case and accents are left to the + * DAO, as before. + */ + fun filterPattern(value: String?): String = escapeLike(withoutSearchQuotes(value?.trim().orEmpty())) + /** Escape the LIKE metacharacters for `ESCAPE '\'`. */ fun escapeLike(value: String): String = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 014acad..6693bc4 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -122,6 +122,10 @@ data class HealthFeatures( val fines: Boolean = false, @SerialName("closures_calendar") val closuresCalendar: Boolean = false, @SerialName("circulation_rules") val circulationRules: Boolean = false, + // Pinakes 0.9.0 splits consultation-only copies off the rules matrix: with this switch on + // and `circulation_rules` off, `loanable` can still be false. A server that predates the + // split omits the key and ties consultation to `circulation_rules`, as before. + @SerialName("consultation_only") val consultationOnly: Boolean = false, ) // ---------- Auth ---------- @@ -578,11 +582,16 @@ data class CirculationRequestResult( @SerialName("book_id") val bookId: Int = 0, @SerialName("loan_id") val loanId: Int? = null, @SerialName("auto_approved") val autoApproved: Boolean? = null, - val status: String? = null, // pendente | da_ritirare + val status: String? = null, // pendente | da_ritirare | prenotato (Pinakes 0.9.0) + // Start day of an approved loan that begins on a later open day (`prenotato`): the library + // is closed today. Null otherwise, and on servers before Pinakes 0.9.0. + @SerialName("start_date") val startDate: String? = null, ) { val kind: CirculationRequestKind get() = when { type == "reservation" -> CirculationRequestKind.Reservation + // Approved, but it starts on the next open day: not waiting at the desk yet. + type == "loan" && status == "prenotato" -> CirculationRequestKind.LoanScheduled type == "loan" && (autoApproved == true || status == "da_ritirare") -> CirculationRequestKind.LoanReadyForPickup type == "loan" && (autoApproved == false || status in setOf("pendente", "in_attesa")) -> @@ -595,6 +604,8 @@ enum class CirculationRequestKind { Reservation, LoanPending, LoanReadyForPickup, + /** Approved and starting on a later open day (`prenotato`, with `start_date`). */ + LoanScheduled, Unknown, } diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt index 3c89c55..7a5f7d4 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt @@ -218,4 +218,7 @@ data class ReservationWrite( @SerialName("expires_at") val expiresAt: String? = null, @SerialName("requested_from") val requestedFrom: String? = null, @SerialName("requested_to") val requestedTo: String? = null, + // Pinakes 0.9.0: staff may ignore the patron type's reservation limits, as the website's + // "new reservation" form allows. Null is omitted, so an older server never sees the key. + @SerialName("override_limits") val overrideLimits: Boolean? = null, ) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt index 2bf91e1..22db266 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt @@ -9,7 +9,13 @@ import kotlinx.serialization.json.JsonObject // duplicate merges, labels, CSV/LibraryThing imports, covers and bulk enrichment. @Serializable -data class CopyUpdate(val status: String? = null, val notes: String? = null) +data class CopyUpdate( + val status: String? = null, + val notes: String? = null, + // Pinakes 0.9.0: keep the copy for in-house consultation, or make it lendable again. Null is + // omitted, so an older server, which would ignore it, never sees the key. + @SerialName("consultation_only") val consultationOnly: Boolean? = null, +) // ---------- Series ---------- diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index 23b978c..c345095 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -199,8 +199,8 @@ class CatalogRepository( limit: Int = 20, ): List = catalogDao.search( q = SearchText.queryPattern(filters.query), - author = SearchText.escapeLike(filters.author?.trim().orEmpty()), - publisher = SearchText.escapeLike(filters.publisher?.trim().orEmpty()), + author = SearchText.filterPattern(filters.author), + publisher = SearchText.filterPattern(filters.publisher), language = filters.language?.trim().orEmpty(), genre = genreName?.trim().orEmpty(), availableOnly = filters.availableOnly == true, diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt index 61afb8b..ca55b93 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt @@ -59,8 +59,8 @@ class StaffContentRepository( suspend fun copy(id: Int): ApiResult = session.staffCall { api.copy(id) } - suspend fun updateCopy(id: Int, status: String?, notes: String?): ApiResult = - session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim())) } + suspend fun updateCopy(id: Int, status: String?, notes: String?, consultationOnly: Boolean? = null): ApiResult = + session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim(), consultationOnly = consultationOnly)) } suspend fun deleteCopy(id: Int): ApiResult = session.staffCall { api.deleteCopy(id) } diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index ddef1c1..d8bdf7b 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -83,6 +83,8 @@ data class InstanceFeatures( */ val closuresCalendarAvailable: Boolean = false, val circulationRulesAvailable: Boolean = false, + /** `features.consultation_only`: consultation-only copies apply on their own switch. */ + val consultationOnlyAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -95,6 +97,13 @@ data class InstanceFeatures( /** Book reviews (read + write) surfaced on book detail and the "My reviews" page. */ val showReviews: Boolean get() = reviews + /** + * Whether the book page asks the server up front if the reader may borrow or join the + * waitlist: patron-type rules close the waitlist, and consultation-only copies close the + * loan, each on its own switch since Pinakes 0.9.0. + */ + val viewerRulesAvailable: Boolean get() = circulationRulesAvailable || consultationOnlyAvailable + /** The balance and movements page (fines ledger on, loans offered). */ val showMovements: Boolean get() = finesAvailable && loans && !catalogueMode @@ -147,6 +156,7 @@ data class InstanceFeatures( finesAvailable = f.fines && app && circulation, closuresCalendarAvailable = f.closuresCalendar && app && circulation, circulationRulesAvailable = f.circulationRules && app && circulation, + consultationOnlyAvailable = f.consultationOnly && app && circulation, ) } } @@ -215,6 +225,7 @@ class FeatureStore(context: Context) { .putBoolean(KEY_FINES, value.finesAvailable) .putBoolean(KEY_CLOSURES_CALENDAR, value.closuresCalendarAvailable) .putBoolean(KEY_CIRCULATION_RULES, value.circulationRulesAvailable) + .putBoolean(KEY_CONSULTATION_ONLY, value.consultationOnlyAvailable) .apply() _features.value = value } @@ -277,6 +288,7 @@ class FeatureStore(context: Context) { finesAvailable = prefs.getBoolean(KEY_FINES, false), closuresCalendarAvailable = prefs.getBoolean(KEY_CLOSURES_CALENDAR, false), circulationRulesAvailable = prefs.getBoolean(KEY_CIRCULATION_RULES, false), + consultationOnlyAvailable = prefs.getBoolean(KEY_CONSULTATION_ONLY, false), ) } @@ -315,5 +327,6 @@ class FeatureStore(context: Context) { private const val KEY_FINES = "f_fines" private const val KEY_CLOSURES_CALENDAR = "f_closures_calendar" private const val KEY_CIRCULATION_RULES = "f_circulation_rules" + private const val KEY_CONSULTATION_ONLY = "f_consultation_only" } } diff --git a/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt b/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt new file mode 100644 index 0000000..df65b2d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt @@ -0,0 +1,65 @@ +package com.pinakes.app.ui.common + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.zxing.oned.Code128Writer + +/** + * The library card code as a Code128 barcode, the default of the printed card (Pinakes 0.9.0, + * Settings → Cards) and what the desk and the visitor-log kiosk scan. Pure, unit-tested. + */ +object CardBarcode { + /** Quiet zone each side, in modules, so a scanner finds the start of the code. */ + const val QUIET_ZONE = 10 + + /** + * The bars of [code] (true = black module), or null when Code128 cannot hold it: as on the + * printed card, only printable ASCII is encoded, and the text line still shows the code. + */ + fun modules(code: String?): BooleanArray? { + val value = code?.trim().orEmpty() + if (value.isEmpty() || value.length > 80 || value.any { it.code !in 0x20..0x7E }) return null + return runCatching { Code128Writer().encode(value) }.getOrNull()?.takeIf { it.isNotEmpty() } + } +} + +/** + * Draws [code] as black bars on white (whatever the theme: scanners need the contrast), or + * nothing when it cannot be encoded. + */ +@Composable +fun CardBarcodeImage(code: String, description: String, modifier: Modifier = Modifier) { + val bars = remember(code) { CardBarcode.modules(code) } ?: return + Canvas( + modifier + .fillMaxWidth() + .height(72.dp) + .background(Color.White) + .padding(vertical = 8.dp) + .semantics { contentDescription = description }, + ) { + val total = bars.size + 2 * CardBarcode.QUIET_ZONE + val module = size.width / total + bars.forEachIndexed { i, black -> + if (black) { + drawRect( + color = Color.Black, + topLeft = Offset((i + CardBarcode.QUIET_ZONE) * module, 0f), + size = Size(module, size.height), + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 76c2c0d..6e5d941 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -141,6 +141,10 @@ fun BookDetailScreen( ) CirculationRequestKind.LoanReadyForPickup -> stringResource(R.string.snackbar_loan_ready_for_pickup) + CirculationRequestKind.LoanScheduled -> stringResource( + R.string.snackbar_loan_scheduled, + formatDisplayDate(confirmation.startDate ?: confirmation.desiredDate), + ) CirculationRequestKind.LoanPending -> stringResource( R.string.snackbar_request_for_date, formatDisplayDate(confirmation.desiredDate), diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index 52e022c..9a40468 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -63,6 +63,8 @@ data class BookDetailUiState( data class RequestConfirmation( val desiredDate: String, val kind: CirculationRequestKind, + /** The server's start day of a scheduled loan ([CirculationRequestKind.LoanScheduled]). */ + val startDate: String? = null, ) @HiltViewModel @@ -116,12 +118,13 @@ class BookDetailViewModel @Inject constructor( } /** - * With the server's circulation rules on, learn up front whether the title is consultation - * only or the waitlist is closed for this reader, so the button says so before a tap. Without - * the flag (an older server) nothing is fetched and the page behaves as it always did. + * With the server's circulation rules or consultation-only copies on, learn up front whether + * the title is consultation only or the waitlist is closed for this reader, so the button says + * so before a tap. Without either flag (an older server) nothing is fetched and the page + * behaves as it always did. */ private fun loadRules() { - if (!featureStore.features.value.circulationRulesAvailable) return + if (!featureStore.features.value.viewerRulesAvailable) return viewModelScope.launch { (catalog.bookAvailability(bookId) as? ApiResult.Success)?.data?.let { cal -> _state.update { it.copy(rules = ViewerRules.of(cal)) } @@ -217,7 +220,7 @@ class BookDetailViewModel @Inject constructor( reserveBusy = false, snackbar = null, snackbarRes = null, - requestConfirmation = RequestConfirmation(desiredDate, res.data.kind), + requestConfirmation = RequestConfirmation(desiredDate, res.data.kind, res.data.startDate), showLoanSheet = false, availability = null, availabilityFallback = false, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index ae8faab..428c0f5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -310,6 +310,14 @@ private fun ProfileContent( cardNumber?.let { ReadOnlyRow(stringResource(R.string.profile_card_number), it) } cardExpiry?.let { ReadOnlyRow(stringResource(R.string.profile_card_expires), DateFormat.date(it)) } patronType?.let { ReadOnlyRow(stringResource(R.string.profile_patron_type), it) } + // The card's code as the printed card carries it, for the desk or kiosk scanner. + cardNumber?.let { + com.pinakes.app.ui.common.CardBarcodeImage( + code = it, + description = stringResource(R.string.profile_card_barcode, it), + modifier = Modifier.padding(top = Spacing.sm), + ) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index c058bb6..1882e36 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -33,6 +33,7 @@ import com.pinakes.app.R import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.LoanCreateRequest import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.StaffCirculationRepository import com.pinakes.app.data.repository.StaffRepository @@ -94,6 +95,10 @@ data class LoanCreateUiState( val forbidden: Boolean = false, /** Loan id after a successful create (the screen opens it); -1 for a reservation. */ val createdLoanId: Int? = null, + /** Patron-type rules are on (`features.circulation_rules`): their limits can be overridden. */ + val limitsOverridable: Boolean = false, + /** Ignore the patron type's reservation limits (a new reservation only). */ + val overrideLimits: Boolean = false, ) { val today: String get() = LocalDate.now().toString() @@ -111,6 +116,15 @@ data class LoanCreateUiState( */ val closedDateChosen: String? get() = listOfNotNull(start ?: today.takeIf { !reservation }, end).firstOrNull { it in closedDates } + /** + * The reservation sent to the server. The override is sent only when staff ticked it and the + * rules it overrides are on, the same condition under which the website shows the box. + */ + fun reservationWrite(userId: Int, bookId: Int): ReservationWrite = ReservationWrite( + userId = userId, bookId = bookId, requestedFrom = start, requestedTo = end, + overrideLimits = true.takeIf { overrideLimits && limitsOverridable }, + ) + /** A new availability request starts: it supersedes any one still in flight. */ fun startCalendarRequest(): LoanCreateUiState = copy(calendarLoading = true, calendarRequest = calendarRequest + 1) @@ -133,12 +147,14 @@ data class LoanCreateUiState( class StaffLoanCreateViewModel @Inject constructor( private val circulation: StaffCirculationRepository, private val staff: StaffRepository, + featureStore: FeatureStore, savedState: SavedStateHandle, ) : ViewModel() { private val _state = MutableStateFlow( LoanCreateUiState( reservation = savedState.get(Routes.ARG_RESERVATION_MODE) ?: false, copyCode = savedState.get(Routes.ARG_COPY_CODE).orEmpty(), + limitsOverridable = featureStore.features.value.circulationRulesAvailable, ), ) val state: StateFlow = _state.asStateFlow() @@ -213,6 +229,7 @@ class StaffLoanCreateViewModel @Inject constructor( fun setEnd(value: String?) = _state.update { it.copy(end = value) } fun setHandOver(value: Boolean) = _state.update { it.copy(handOverNow = value && it.canHandOver) } fun setNotes(value: String) = _state.update { it.copy(notes = value) } + fun setOverrideLimits(value: Boolean) = _state.update { it.copy(overrideLimits = value) } fun submit() { val s = _state.value @@ -231,7 +248,7 @@ class StaffLoanCreateViewModel @Inject constructor( _state.update { it.copy(saving = true, error = null) } viewModelScope.launch { if (s.reservation) { - val body = ReservationWrite(userId = user.id, bookId = book.id, requestedFrom = s.start, requestedTo = s.end) + val body = s.reservationWrite(user.id, book.id) val res = withIdempotency(body) { key -> circulation.createReservation(body, key) } _state.update { when (res) { @@ -341,6 +358,12 @@ fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> modifier = Modifier.testTag("loan-calendar"), ) } + if (state.reservation && state.limitsOverridable) { + SwitchRow( + stringResource(R.string.staff_reservation_override_limits), state.overrideLimits, vm::setOverrideLimits, + subtitle = stringResource(R.string.staff_reservation_override_limits_hint), + ) + } if (!state.reservation) { SwitchRow( stringResource(R.string.staff_hand_over_now), state.handOverNow && state.canHandOver, vm::setHandOver, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt index 09d1220..5a8c9e2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt @@ -58,6 +58,7 @@ import com.pinakes.app.data.repository.StaffFilesRepository import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.staff.CopyCodeCodec import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.components.ConfirmDialog @@ -70,6 +71,7 @@ import com.pinakes.app.ui.navigation.Routes import com.pinakes.app.ui.screens.staff.ExportFileDialog import com.pinakes.app.ui.screens.staff.LookupField import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.SwitchRow import com.pinakes.app.ui.screens.staff.resolve import com.pinakes.app.ui.screens.staff.toStaffMessage import com.pinakes.app.ui.theme.Spacing @@ -83,7 +85,23 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** The copy being edited (sheet) or created (id 0). */ -data class CopyDraft(val id: Int = 0, val code: String = "", val status: String = "", val notes: String = "", val originalStatus: String = "") +data class CopyDraft( + val id: Int = 0, + val code: String = "", + val status: String = "", + val notes: String = "", + val originalStatus: String = "", + /** Kept for in-house consultation (Pinakes 0.9.0 `consultation_only`), as edited and as loaded. */ + val consultationOnly: Boolean = false, + val originalConsultationOnly: Boolean = false, +) { + /** + * The consultation flag to send: only for an existing copy, only when it changed, and only + * when the server applies consultation-only copies ([editable]); otherwise null (left out). + */ + fun consultationChange(editable: Boolean): Boolean? = + consultationOnly.takeIf { editable && id > 0 && it != originalConsultationOnly } +} data class CopiesUiState( val copies: List = emptyList(), @@ -97,6 +115,8 @@ data class CopiesUiState( val forbidden: Boolean = false, /** The reloaded record after a copy change, for the editor's counters. */ val refreshed: StaffBook? = null, + /** The server applies consultation-only copies (`features.consultation_only`): the switch shows. */ + val consultationEditable: Boolean = false, ) /** @@ -109,10 +129,11 @@ class StaffCopiesViewModel @Inject constructor( private val staff: StaffRepository, private val content: StaffContentRepository, private val files: StaffFilesRepository, + featureStore: FeatureStore, savedState: SavedStateHandle, ) : ViewModel() { private val bookId: Int = savedState.get(Routes.ARG_BOOK_ID) ?: 0 - private val _state = MutableStateFlow(CopiesUiState()) + private val _state = MutableStateFlow(CopiesUiState(consultationEditable = featureStore.features.value.consultationOnlyAvailable)) val state: StateFlow = _state.asStateFlow() /** The editor already loaded the record: start from its copies. */ @@ -133,7 +154,13 @@ class StaffCopiesViewModel @Inject constructor( fun startAdd() = _state.update { it.copy(editing = CopyDraft(status = "disponibile"), formError = null) } fun startEdit(copy: StaffCopy) = _state.update { - it.copy(editing = CopyDraft(copy.id, copy.inventoryNumber.orEmpty(), copy.status.orEmpty(), copy.notes.orEmpty(), copy.status.orEmpty()), formError = null) + it.copy( + editing = CopyDraft( + copy.id, copy.inventoryNumber.orEmpty(), copy.status.orEmpty(), copy.notes.orEmpty(), copy.status.orEmpty(), + consultationOnly = copy.consultationOnly, originalConsultationOnly = copy.consultationOnly, + ), + formError = null, + ) } fun edit(transform: (CopyDraft) -> CopyDraft) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null) } @@ -152,7 +179,8 @@ class StaffCopiesViewModel @Inject constructor( } else { // Send the status only when it changed: an unchanged circulation state is not a request. val status = draft.status.takeIf { it != draft.originalStatus } - when (val res = content.updateCopy(draft.id, status, draft.notes)) { + val consultation = draft.consultationChange(_state.value.consultationEditable) + when (val res = content.updateCopy(draft.id, status, draft.notes, consultation)) { is ApiResult.Success -> null is ApiResult.Failure -> res } @@ -348,6 +376,13 @@ private fun CopyEditorSheet(draft: CopyDraft, state: CopiesUiState, lookups: Sta value = draft.notes, onValueChange = { v -> vm.edit { it.copy(notes = v) } }, label = stringResource(R.string.staff_field_notes), singleLine = false, maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("copy-notes"), ) + if (draft.id > 0 && state.consultationEditable) { + SwitchRow( + stringResource(R.string.staff_copy_consultation_switch), draft.consultationOnly, + { v -> vm.edit { it.copy(consultationOnly = v) } }, + subtitle = stringResource(R.string.staff_copy_consultation_switch_hint), + ) + } PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, modifier = Modifier.fillMaxWidth().testTag("copy-save")) if (draft.id > 0) { if (labels) { diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt index 8177a47..8f8539a 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -49,7 +49,7 @@ class CatalogDaoTest { genre: String = "", availableOnly: Boolean = false, sort: String = "title_asc", limit: Int = 50, offset: Int = 0, ) = dao.search( - SearchText.queryPattern(q), SearchText.escapeLike(author), SearchText.escapeLike(publisher), + SearchText.queryPattern(q), SearchText.filterPattern(author), SearchText.filterPattern(publisher), language, genre, availableOnly, sort, limit, offset, ).map { it.id } @@ -212,4 +212,18 @@ class CatalogDaoTest { dao.upsertAll(listOf(book(1, author = "A_B"), book(2, author = "AxB"))) assertEquals(listOf(1), search(author = "A_B")) } + + @Test + fun quotedQueriesAndFiltersFindTheirWordsOffline() = runBlocking { + // Pinakes #501: quotes ask the server for the exact spelling; offline they are dropped. + dao.upsertAll( + listOf( + book(1, title = "Il barone rampante", author = "Italo Calvino", publisher = "Einaudi"), + book(2, title = "Altro", author = "Thomas Mann", publisher = "Fischer"), + ), + ) + assertEquals(listOf(1), search(q = "\"barone rampante\"")) + assertEquals(listOf(1), search(author = "\"Italo Calvino\"")) + assertEquals(listOf(1), search(publisher = "\u201CEinaudi\u201D")) + } } diff --git a/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt index 4299c42..f5b934b 100644 --- a/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt +++ b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt @@ -33,6 +33,14 @@ class LatestReleaseContractTest { assertEquals(CirculationRequestKind.LoanPending, pending.kind) assertEquals(CirculationRequestKind.LoanReadyForPickup, ready.kind) assertEquals(CirculationRequestKind.Unknown, CirculationRequestResult(type = "future").kind) + // Pinakes 0.9.0: approved with the library closed today, the loan starts on the next open + // day; auto_approved is true but the book is not waiting at the desk yet. + val scheduled = json.decodeFromString>( + """{"data":{"type":"loan","book_id":7,"loan_id":9,"auto_approved":true,"status":"prenotato","start_date":"2026-11-02"},"meta":{},"error":null}""", + ).data + assertEquals(CirculationRequestKind.LoanScheduled, scheduled?.kind) + assertEquals("2026-11-02", scheduled?.startDate) + assertEquals(null, ready.startDate) assertEquals( CirculationRequestKind.Unknown, CirculationRequestResult(type = "loan", status = "future_status").kind, diff --git a/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt b/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt new file mode 100644 index 0000000..5884408 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/CardBarcodeTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.server090 + +import com.google.zxing.BarcodeFormat +import com.google.zxing.DecodeHintType +import com.google.zxing.common.BitArray +import com.google.zxing.oned.Code128Reader +import com.pinakes.app.ui.common.CardBarcode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** The library card code drawn as the printed card's Code128 (Pinakes 0.9.0) reads back as the code. */ +class CardBarcodeTest { + private fun decode(modules: BooleanArray): String { + val row = BitArray(modules.size + 2 * CardBarcode.QUIET_ZONE) + modules.forEachIndexed { i, black -> if (black) row.set(i + CardBarcode.QUIET_ZONE) } + val hints = mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.CODE_128)) + return Code128Reader().decodeRow(0, row, hints).text + } + + @Test fun aCardCodeReadsBackAsItself() { + for (code in listOf("TES000123", "000123", "LIB-2026/07", "a b")) { + val bars = CardBarcode.modules(code) + assertNotNull(bars) + assertEquals(code, decode(bars!!)) + } + } + + @Test fun surroundingSpacesAreNotEncoded() { + assertEquals("TES1", decode(CardBarcode.modules(" TES1 ")!!)) + } + + @Test fun whatCode128CannotHoldIsNotDrawn() { + // As on the printed card: only printable ASCII; the text line still shows the code. + assertNull(CardBarcode.modules("Tessera-è")) + assertNull(CardBarcode.modules("TES\u0007")) + assertNull(CardBarcode.modules("")) + assertNull(CardBarcode.modules(null)) + assertNull(CardBarcode.modules("X".repeat(81))) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt index de62098..80f869b 100644 --- a/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt +++ b/app/src/test/java/com/pinakes/app/server090/FeatureFlags090Test.kt @@ -59,6 +59,28 @@ class FeatureFlags090Test { assertFalse(f.showMovements) } + @Test fun consultationOnlyIsReadOnItsOwnSwitch() { + // Pinakes 0.9.0: consultation-only copies have their own switch, so a title can be + // consultation only with the patron-type rules off. + val f = InstanceFeatures.from(health(""""circulation_rules":false,"consultation_only":true""")) + assertFalse(f.circulationRulesAvailable) + assertTrue(f.consultationOnlyAvailable) + assertTrue(f.viewerRulesAvailable) + } + + @Test fun theBookPageAsksForTheReaderRulesWithEitherSwitch() { + assertTrue(InstanceFeatures.from(health(""""circulation_rules":true""")).viewerRulesAvailable) + assertFalse(InstanceFeatures.from(health(""""circulation_rules":false,"consultation_only":false""")).viewerRulesAvailable) + // A server from before the split omits consultation_only: circulation_rules alone decides. + assertFalse(InstanceFeatures.from(health(""""wishlist":true""")).viewerRulesAvailable) + } + + @Test fun consultationOnlyFollowsCatalogueModeAndAppAccess() { + assertFalse(InstanceFeatures.from(health(""""consultation_only":true""", catalogueMode = true)).consultationOnlyAvailable) + val noApp = json.decodeFromString("""{"app_access_enabled":false,"features":{"loans":true,"consultation_only":true}}""") + assertFalse(InstanceFeatures.from(noApp).viewerRulesAvailable) + } + @Test fun theMovementsPageNeedsLoansToo() { val f = InstanceFeatures(finesAvailable = true, loans = false) assertFalse(f.showMovements) diff --git a/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt new file mode 100644 index 0000000..7218764 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffCopyConsultationTest.kt @@ -0,0 +1,50 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.CopyUpdate +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.ui.screens.staff.content.CopyDraft +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Consultation-only copies from the copy editor (Pinakes 0.9.0): `consultation_only` on + * `PUT /staff/copies/{id}`, sent only when it changed and the server applies the module. + */ +class StaffCopyConsultationTest { + // The network client's settings: null fields are left out of the body. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private val loaded = CopyDraft(id = 5, code = "INV1", status = "disponibile", originalStatus = "disponibile") + + @Test fun aChangedFlagIsSentWhenTheModuleIsOn() { + assertEquals(true, loaded.copy(consultationOnly = true).consultationChange(editable = true)) + val wasConsultation = loaded.copy(consultationOnly = false, originalConsultationOnly = true) + assertEquals(false, wasConsultation.consultationChange(editable = true)) + } + + @Test fun anUnchangedFlagANewCopyOrTheModuleOffSendsNothing() { + assertNull(loaded.consultationChange(editable = true)) + assertNull(loaded.copy(consultationOnly = true, originalConsultationOnly = true).consultationChange(editable = true)) + assertNull(loaded.copy(consultationOnly = true).consultationChange(editable = false)) + assertNull(CopyDraft(id = 0, consultationOnly = true).consultationChange(editable = true)) + } + + @Test fun theKeyIsInTheBodyOnlyWhenSet() { + assertFalse(json.encodeToString(CopyUpdate(notes = "n")).contains("consultation_only")) + assertTrue(json.encodeToString(CopyUpdate(notes = "n", consultationOnly = true)).contains("\"consultation_only\":true")) + assertTrue(json.encodeToString(CopyUpdate(consultationOnly = false)).contains("\"consultation_only\":false")) + } + + @Test fun theServerFlagIsRead() { + val copy = json.decodeFromString("""{"id":5,"inventory_number":"INV1","status":"disponibile","consultation_only":true}""") + assertTrue(copy.consultationOnly) + // An older server omits it: lendable. + assertFalse(json.decodeFromString("""{"id":6,"status":"disponibile"}""").consultationOnly) + } +} diff --git a/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt b/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt new file mode 100644 index 0000000..94b3e96 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/server090/StaffReservationOverrideTest.kt @@ -0,0 +1,41 @@ +package com.pinakes.app.server090 + +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.ui.screens.staff.circulation.LoanCreateUiState +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A staff reservation may ignore the patron type's reservation limits (Pinakes 0.9.0, + * `override_limits` on POST /staff/reservations), as the website's "new reservation" form allows. + */ +class StaffReservationOverrideTest { + // The network client's settings: null fields are left out of the body. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; encodeDefaults = false } + + private val form = LoanCreateUiState(reservation = true, start = "2030-03-04", end = "2030-03-20") + + @Test fun theOverrideIsSentOnlyWhenTickedAndTheRulesAreOn() { + assertEquals(true, form.copy(limitsOverridable = true, overrideLimits = true).reservationWrite(1, 2).overrideLimits) + assertNull(form.copy(limitsOverridable = true, overrideLimits = false).reservationWrite(1, 2).overrideLimits) + // Rules off on the server: there are no limits to override, as on the website. + assertNull(form.copy(limitsOverridable = false, overrideLimits = true).reservationWrite(1, 2).overrideLimits) + } + + @Test fun theBodyKeepsTheDatesAndIdsAsBefore() { + val body = form.reservationWrite(7, 9) + assertEquals(ReservationWrite(userId = 7, bookId = 9, requestedFrom = "2030-03-04", requestedTo = "2030-03-20"), body) + } + + @Test fun withoutTheOverrideTheKeyIsNotSent() { + val plain = json.encodeToString(form.reservationWrite(7, 9)) + assertFalse(plain.contains("override_limits")) + val forced = json.encodeToString(form.copy(limitsOverridable = true, overrideLimits = true).reservationWrite(7, 9)) + assertTrue(forced.contains("\"override_limits\":true")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt index 8f892a5..d80cad7 100644 --- a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt @@ -34,4 +34,23 @@ class SearchTextTest { assertEquals("1-2", SearchText.queryPattern("1-2")) assertEquals("harry potter 7", SearchText.queryPattern("Harry Potter 7")) } + + @Test fun quotedQueriesSearchTheWordsOffline() { + // Pinakes #501: quotes ask the server for the exact spelling. Offline the key folds + // accents, so the quotes go and the words are searched as they would be unquoted. + assertEquals("rolf haffner", SearchText.queryPattern("\"Rolf Häffner\"")) + assertEquals("haffner roma", SearchText.queryPattern("\u201EHäffner\u201C Roma")) + assertEquals("haffner roma", SearchText.queryPattern("\u201CHäffner\u201D Roma")) + assertEquals("haffner", SearchText.queryPattern("\u00ABHäffner\u00BB")) + // An unclosed quote is dropped too. + assertEquals("haffner", SearchText.queryPattern("\"Häffner")) + assertEquals("plain words", SearchText.withoutSearchQuotes("plain words")) + } + + @Test fun anInchMarkAfterADigitStays() { + assertEquals("disco 12\"", SearchText.queryPattern("Disco 12\"")) + assertEquals("schermo 15\u201D", SearchText.queryPattern("Schermo 15\u201D")) + // Inside a quoted part the quote after a digit closes it, as on the server. + assertEquals("catch 22", SearchText.queryPattern("\"Catch 22\"")) + } } diff --git a/i18n/de.json b/i18n/de.json index 8b76b1a..6208477 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -207,6 +207,7 @@ "profile_date_none": "Nicht festgelegt", "profile_card_number": "Ausweisnummer", "profile_card_expires": "Ausweis läuft ab", + "profile_card_barcode": "Barcode des Ausweises %1$s", "profile_change_password_title": "Passwort ändern", "profile_current_password": "Aktuelles Passwort", "profile_new_password": "Neues Passwort", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Ausleihe angefragt für %1$s", "snackbar_reservation_for_date": "Reservierung für den %1$s eingetragen", "snackbar_loan_ready_for_pickup": "Ausleihe genehmigt; zur Abholung bereit.", + "snackbar_loan_scheduled": "Ausleihe genehmigt: Sie können das Buch ab dem %1$s abholen.", "book_section_audiobook": "Hörbuch", "audio_play": "Abspielen", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Nächstes Exemplar scannen", "staff_loan_new_with_copy": "Neue Ausleihe mit diesem Exemplar", "staff_loan_open": "Ausleihe öffnen", + "staff_reservation_override_limits": "Vormerkungsgrenzen der Leserkategorie ignorieren", + "staff_reservation_override_limits_hint": "Wie im Formular der Website: Die Vormerkung wird auch über die Grenzen der Leserkategorie hinaus angelegt.", "staff_hand_over_now": "Jetzt aushändigen", "staff_hand_over_now_hint": "Der Nutzer nimmt das Exemplar an der Theke mit: die Ausleihe läuft sofort.", "staff_hand_over_future": "Eine später beginnende Ausleihe wird geplant und an diesem Tag ausgehändigt.", @@ -1475,6 +1479,8 @@ "staff_err_card_expired": "Der Bibliotheksausweis dieser Person ist abgelaufen.", "staff_err_user_suspended": "Diese Person ist gesperrt.", "staff_copy_consultation_only": "Nur zur Einsicht", + "staff_copy_consultation_switch": "Nur zur Einsicht vor Ort", + "staff_copy_consultation_switch_hint": "Das Exemplar wird nie ausgeliehen. Wie auf der Buchseite: nicht möglich, solange das Exemplar ausgeliehen, zur Abholung bereit oder angefragt ist.", "profile_patron_type": "Leserkategorie", "profile_action_movements": "Kontostand und Bewegungen", "title_movements": "Kontostand und Bewegungen", diff --git a/i18n/en.json b/i18n/en.json index b546822..58676a0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -207,6 +207,7 @@ "profile_date_none": "Not set", "profile_card_number": "Card number", "profile_card_expires": "Card expires", + "profile_card_barcode": "Library card barcode %1$s", "profile_change_password_title": "Change password", "profile_current_password": "Current password", "profile_new_password": "New password", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Loan requested for %1$s", "snackbar_reservation_for_date": "Reservation added for %1$s", "snackbar_loan_ready_for_pickup": "Loan approved; ready for pickup.", + "snackbar_loan_scheduled": "Loan approved: you can pick up the book from %1$s.", "book_section_audiobook": "Audiobook", "audio_play": "Play", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Scan the next copy", "staff_loan_new_with_copy": "New loan with this copy", "staff_loan_open": "Open the loan", + "staff_reservation_override_limits": "Ignore the reservation limits of the patron's type", + "staff_reservation_override_limits_hint": "As in the website form: the reservation is created even beyond the limits of the patron's type.", "staff_hand_over_now": "Hand over now", "staff_hand_over_now_hint": "The borrower takes the copy at the desk: the loan starts running at once.", "staff_hand_over_future": "A loan starting later is scheduled and handed over on that day.", @@ -1475,6 +1479,8 @@ "staff_err_card_expired": "This user's library card has expired.", "staff_err_user_suspended": "This user is suspended.", "staff_copy_consultation_only": "Consultation only", + "staff_copy_consultation_switch": "Consultation only, in the library", + "staff_copy_consultation_switch_hint": "The copy is never lent. As on the book page: it cannot be set while the copy is on loan, awaiting pickup or requested.", "profile_patron_type": "Patron type", "profile_action_movements": "Balance and movements", "title_movements": "Balance and movements", diff --git a/i18n/fr.json b/i18n/fr.json index 2f10545..96e66dc 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -207,6 +207,7 @@ "profile_date_none": "Non renseignée", "profile_card_number": "Numéro de carte", "profile_card_expires": "Expiration de la carte", + "profile_card_barcode": "Code-barres de la carte %1$s", "profile_change_password_title": "Changer le mot de passe", "profile_current_password": "Mot de passe actuel", "profile_new_password": "Nouveau mot de passe", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Prêt demandé pour le %1$s", "snackbar_reservation_for_date": "Réservation enregistrée pour le %1$s", "snackbar_loan_ready_for_pickup": "Prêt approuvé; prêt à être retiré.", + "snackbar_loan_scheduled": "Prêt approuvé : vous pourrez retirer le livre à partir du %1$s.", "book_section_audiobook": "Livre audio", "audio_play": "Lecture", "audio_pause": "Pause", @@ -993,6 +995,8 @@ "staff_scan_next": "Scanner l’exemplaire suivant", "staff_loan_new_with_copy": "Nouveau prêt avec cet exemplaire", "staff_loan_open": "Ouvrir le prêt", + "staff_reservation_override_limits": "Ignorer les limites de réservation de la catégorie du lecteur", + "staff_reservation_override_limits_hint": "Comme dans le formulaire du site : la réservation est créée même au-delà des limites de la catégorie du lecteur.", "staff_hand_over_now": "Remettre maintenant", "staff_hand_over_now_hint": "Le lecteur emporte l’exemplaire au guichet : le prêt démarre aussitôt.", "staff_hand_over_future": "Un prêt qui commence plus tard est programmé et remis ce jour-là.", @@ -1475,6 +1479,8 @@ "staff_err_card_expired": "La carte de bibliothèque de cet utilisateur a expiré.", "staff_err_user_suspended": "Cet utilisateur est suspendu.", "staff_copy_consultation_only": "Consultation sur place", + "staff_copy_consultation_switch": "Consultation sur place uniquement", + "staff_copy_consultation_switch_hint": "L'exemplaire n'est jamais prêté. Comme sur la fiche du livre : impossible tant que l'exemplaire est prêté, en attente de retrait ou demandé.", "profile_patron_type": "Catégorie de lecteur", "profile_action_movements": "Solde et mouvements", "title_movements": "Solde et mouvements", diff --git a/i18n/it.json b/i18n/it.json index 166cac7..1f6b1d5 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -207,6 +207,7 @@ "profile_date_none": "Non impostata", "profile_card_number": "Numero tessera", "profile_card_expires": "Scadenza tessera", + "profile_card_barcode": "Codice a barre della tessera %1$s", "profile_change_password_title": "Cambia password", "profile_current_password": "Password attuale", "profile_new_password": "Nuova password", @@ -269,6 +270,7 @@ "snackbar_request_for_date": "Prestito richiesto per il %1$s", "snackbar_reservation_for_date": "Prenotazione inserita per il %1$s", "snackbar_loan_ready_for_pickup": "Prestito approvato; pronto per il ritiro.", + "snackbar_loan_scheduled": "Prestito approvato: potrai ritirare il libro dal %1$s.", "book_section_audiobook": "Audiolibro", "audio_play": "Riproduci", "audio_pause": "Pausa", @@ -993,6 +995,8 @@ "staff_scan_next": "Scansiona la copia successiva", "staff_loan_new_with_copy": "Nuovo prestito con questa copia", "staff_loan_open": "Apri il prestito", + "staff_reservation_override_limits": "Ignora i limiti di prenotazione della tipologia del lettore", + "staff_reservation_override_limits_hint": "Come nel modulo del sito: la prenotazione viene creata anche oltre i limiti della tipologia.", "staff_hand_over_now": "Consegna subito", "staff_hand_over_now_hint": "L’utente prende la copia al banco: il prestito parte subito.", "staff_hand_over_future": "Un prestito che inizia più avanti viene programmato e consegnato quel giorno.", @@ -1475,6 +1479,8 @@ "staff_err_card_expired": "La tessera dell'utente è scaduta.", "staff_err_user_suspended": "L'utente è sospeso.", "staff_copy_consultation_only": "Solo consultazione", + "staff_copy_consultation_switch": "Solo consultazione in sede", + "staff_copy_consultation_switch_hint": "La copia non viene mai prestata. Come nella scheda del libro: non si può impostare se la copia è in prestito, in ritiro o richiesta.", "profile_patron_type": "Tipologia lettore", "profile_action_movements": "Saldo e movimenti", "title_movements": "Saldo e movimenti",