Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion _contract/health-sample.json
Original file line number Diff line number Diff line change
@@ -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}
{"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}
33 changes: 32 additions & 1 deletion app/src/main/java/com/pinakes/app/data/local/SearchText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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("_", "\\_")
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------
Expand Down Expand Up @@ -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")) ->
Expand All @@ -595,6 +604,8 @@ enum class CirculationRequestKind {
Reservation,
LoanPending,
LoanReadyForPickup,
/** Approved and starting on a later open day (`prenotato`, with `start_date`). */
LoanScheduled,
Unknown,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,8 @@ class CatalogRepository(
limit: Int = 20,
): List<BookSummary> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ class StaffContentRepository(

suspend fun copy(id: Int): ApiResult<StaffCopy> = session.staffCall { api.copy(id) }

suspend fun updateCopy(id: Int, status: String?, notes: String?): ApiResult<StaffCopy> =
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<StaffCopy> =
session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim(), consultationOnly = consultationOnly)) }

suspend fun deleteCopy(id: Int): ApiResult<DeleteResult> = session.staffCall { api.deleteCopy(id) }

Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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),
)
}

Expand Down Expand Up @@ -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"
}
}
65 changes: 65 additions & 0 deletions app/src/main/java/com/pinakes/app/ui/common/CardBarcode.kt
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading