diff --git a/README.md b/README.md index 1e3d41c..c3e800c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,22 @@ server advertises their APIs. The five bottom navigation destinations are retain > These are **debug-signed** builds for direct sideloading, not Play Store releases. Each release bumps `versionCode`, so a newer APK installs over the previous one in place. +## What's New in v1.13.0 + +Pinakes Android 1.13.0 follows Pinakes 0.9.2 (Mobile API 1.11.0). In the staff area the visitor log checks in a member without the card: *Find member* opens the same member search as the loan desk, and the visit is recorded exactly as with the card, only once even after a retry. The button appears only when the server announces `staff_attendance_member_pick` in `/health`; on older servers the app behaves as 1.12.0. + +## What's New + +The complete history is on the [Releases page](https://github.com/fabiodalez-dev/Pinakes-Android/releases). + +### v1.12.0 + +Follows Pinakes 0.9.0 and 0.9.1 (Mobile API 1.10.0). Reader and staff loans show the usable return day the server computes from the branch closures calendar, and lateness follows the server in the library timezone, so a loan due on a closed day is no longer shown as late. Library settings → Loans gains the closures calendar section with the same-day handover switch. + +### v1.11.0 + +Follows Pinakes 0.9.0: everything the 0.9.0 admin adds can be done from the staff area, including library settings, patron types and loan rules, the closures calendar, stocktake, patron ledger, MARC import and export, and the visitor log. The reader side follows the new circulation rules; on older servers every new field is optional and the new screens stay hidden. + ## What it is A single self-contained app any Pinakes library can hand to its members. On first @@ -271,7 +287,7 @@ _contract/ OpenAPI snapshot + API spec the app is built against All core screens are implemented and verified end-to-end on an emulator against a live instance (onboarding, login, catalog, detail, loans, reservations, audio, PDF, themes, languages). UnifiedPush registration is wired in the data layer; the -distributor receiver is a follow-up. See `STATUS.md` for the full breakdown. +distributor receiver is a follow-up. See `STATUS.md` for the full breakdown, and [the request-by-request Uwe verification](docs/UWE-PARITY.md) for the reader-side parity with the website. ## License @@ -286,22 +302,3 @@ and published online resources follow the server data. Public PDFs use the URL supplied by the server; the website action opens the article’s full page. Older servers retain the existing periodicals browser. See [the article integration notes](docs/emeroteca-standalone-articles.md). -## Android 1.6 / current server parity - -The updated client reads Mobile API 1.5.0, Desiderata 1.2.0, Emeroteca 1.13.0 and -Archives 1.5.1. Update the server/plugins before expecting the new optional -collection screens. Older instances remain usable; unsupported article facets -show an upgrade message rather than silently returning unfiltered results. - -Desiderata is the library's wanted collection, separate from a member's wishlist. -Donation contact details come from the verified account. Proposals require -consent and survive transport retries/process death without creating inventory. -Archives supports paged hierarchy, text/level/year search, authorities, all -public documents and export links. Article/book citations are formatted by the -server, with five styles and text/HTML clipboard copies. Every digital book -attachment is shown; audio switches one native player at a time. - -Administrative cataloguing and uploads open the protected PHP website, including -the existing article form. No native administrative CRUD API is invented. - -See [the request-by-request Uwe verification](docs/UWE-PARITY.md). diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt index d488aa3..a350209 100644 --- a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -4,17 +4,18 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase /** - * Local cache database. Everything in it can be rebuilt from the server, so schema changes use - * destructive migration: v1 (40-row snapshot) → v2 (whole catalogue + loans + wishlist + sync - * bookkeeping) → v3 (folded search key) simply starts empty and the next sync refills it - * (the sync watermark lives in the same database, so it restarts with a full pass). Excluded from backups - * (`backup_rules.xml`, `data_extraction_rules.xml`): it belongs to one instance and one user. + * Local cache database. V4 adds the usable return day, the server lateness and the pickup deadline + * of a loan while keeping data cached by v3. + * Older schemas can be rebuilt from the server; clearing the sync watermark triggers a full sync. + * The database is excluded from backups because it belongs to one instance and one user. */ @Database( entities = [CachedBook::class, CachedLoan::class, CachedWishlistItem::class, SyncMeta::class], - version = 3, + version = 4, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -25,6 +26,15 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val FILE_NAME = "pinakes-cache.db" + /** v4 keeps the v3 rows and adds the server calendar facts of a loan, null until the next refresh. */ + internal val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE cached_loans ADD COLUMN effectiveDueAt TEXT") + db.execSQL("ALTER TABLE cached_loans ADD COLUMN isOverdue INTEGER") + db.execSQL("ALTER TABLE cached_loans ADD COLUMN pickupDeadline TEXT") + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -34,7 +44,9 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, FILE_NAME, - ).fallbackToDestructiveMigration().build().also { INSTANCE = it } + ).addMigrations(MIGRATION_3_4) + .fallbackToDestructiveMigration() + .build().also { INSTANCE = it } } } } diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt index 245947f..1d51d84 100644 --- a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt @@ -25,7 +25,11 @@ data class CachedLoan( val requestedAt: String? = null, val loanedAt: String? = null, val dueAt: String? = null, + // Server calendar facts (Mobile API 1.10.0): offline rows must not fall back to the stored due date. + val effectiveDueAt: String? = null, + val isOverdue: Boolean? = null, val dueAttention: Boolean = false, + val pickupDeadline: String? = null, val returnedAt: String? = null, val renewals: Int? = null, val cancellable: Boolean = false, @@ -75,13 +79,15 @@ data class SyncMeta( fun LoanItem.toCached(bucket: String, position: Int): CachedLoan = CachedLoan( bucket = bucket, id = id, position = position, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, - dueAttention = dueAttention, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, + effectiveDueAt = effectiveDueAt, isOverdue = isOverdue, + dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, + renewals = renewals, cancellable = cancellable, ) fun CachedLoan.toLoanItem(): LoanItem = LoanItem( id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, - requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, dueAttention = dueAttention, - returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, + requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, effectiveDueAt = effectiveDueAt, + isOverdue = isOverdue, dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, ) fun ReservationItem.toCached(position: Int): CachedLoan = CachedLoan( 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 8ad599d..1755a37 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 @@ -534,6 +534,9 @@ data class LoanItem( @SerialName("is_overdue") val isOverdue: Boolean? = null, // Computed in the library timezone; do not re-derive from device LocalDate. @SerialName("due_attention") val dueAttention: Boolean = false, + // Mobile API 1.12.0: last day to collect a da_ritirare loan, already moved past the + // copy branch closures by the server. Null for other states and on older servers. + @SerialName("pickup_deadline") val pickupDeadline: String? = null, @SerialName("returned_at") val returnedAt: String? = null, val renewals: Int? = null, // Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it. diff --git a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt index 8e79dc2..51b373e 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt @@ -103,7 +103,7 @@ object StatusMapping { fun loanNeedsAttention(loan: LoanItem): Boolean = loan.dueAttention || loanGroup(loan) == LoanGroup.Overdue - enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed } + enum class LoanDateKind { Overdue, Returned, Requested, Pickup, Due, Borrowed } data class LoanDate(val kind: LoanDateKind, val value: String? = null) @@ -119,6 +119,10 @@ object StatusMapping { return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) } } if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt) + // A ready loan is not in hand yet: the date that matters is the last pickup day. + if (loan.status == "da_ritirare" && !loan.pickupDeadline.isNullOrBlank()) { + return LoanDate(LoanDateKind.Pickup, loan.pickupDeadline) + } if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt) if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt) return null diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 1ae40f6..c14ad40 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -263,6 +263,8 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( stringResource(R.string.library_returned_on, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Requested -> stringResource(R.string.library_requested_on, DateFormat.date(requireNotNull(date.value))) + StatusMapping.LoanDateKind.Pickup -> + stringResource(R.string.library_pickup_by, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Due -> stringResource(R.string.library_due_label, DateFormat.date(requireNotNull(date.value))) StatusMapping.LoanDateKind.Borrowed -> diff --git a/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt b/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt new file mode 100644 index 0000000..ca1b3aa --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CacheMigrationTest.kt @@ -0,0 +1,83 @@ +package com.pinakes.app + +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.local.AppDatabase +import com.pinakes.app.data.local.CachedLoan +import com.pinakes.app.data.local.toLoansData +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Upgrading the offline cache keeps what v3 stored. The v3 schema below is the one Room generated + * for version 3 and never changes; Room validates the migrated tables against the current + * entities, so a column added to an entity without a migration fails here instead of on a phone. + * No destructive fallback in this builder: a missing migration is an error, not an empty cache. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class CacheMigrationTest { + private val context get() = ApplicationProvider.getApplicationContext() + private lateinit var file: File + + @Before fun setup() { + file = context.getDatabasePath("migration-test.db") + file.parentFile?.mkdirs() + file.delete() + } + + @After fun teardown() { + file.delete() + } + + private fun createV3() { + val raw = SQLiteDatabase.openOrCreateDatabase(file, null) + V3_SCHEMA.forEach(raw::execSQL) + raw.execSQL("INSERT INTO cached_loans (bucket,id,position,bookId,title,status,dueAt,dueAttention,cancellable) VALUES ('active',7,0,70,'Kept','da_ritirare','2030-12-30',0,1)") + raw.execSQL("INSERT INTO sync_meta (`key`,`value`) VALUES ('watermark','w3')") + raw.version = 3 + raw.close() + } + + @Test fun v3RowsSurviveAndTheNewCalendarFieldsStartEmpty() = runBlocking { + createV3() + val db = Room.databaseBuilder(context, AppDatabase::class.java, file.absolutePath) + .addMigrations(AppDatabase.MIGRATION_3_4) + .allowMainThreadQueries() + .build() + try { + val loan = db.libraryDao().loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals("Kept", loan.title) + assertEquals("2030-12-30", loan.dueAt) + assertNull(loan.effectiveDueAt) + assertNull(loan.isOverdue) + assertNull(loan.pickupDeadline) + assertEquals("w3", db.syncMetaDao().get("watermark")) + assertEquals(4, db.openHelper.readableDatabase.version) + } finally { + db.close() + } + } + + private companion object { + val V3_SCHEMA = listOf( + "CREATE TABLE IF NOT EXISTS `cached_books` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `publisher` TEXT, `genre` TEXT, `year` INTEGER, `language` TEXT, `mediaType` TEXT, `isbn13` TEXT, `isbn10` TEXT, `ean` TEXT, `pages` INTEGER, `description` TEXT, `locationLabel` TEXT, `coverUrl` TEXT, `copiesTotal` INTEGER NOT NULL, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, `isWanted` INTEGER NOT NULL, `updatedAt` TEXT NOT NULL, `searchKey` TEXT NOT NULL, PRIMARY KEY(`id`))", + "CREATE INDEX IF NOT EXISTS `index_cached_books_title` ON `cached_books` (`title`)", + "CREATE INDEX IF NOT EXISTS `index_cached_books_updatedAt` ON `cached_books` (`updatedAt`)", + "CREATE TABLE IF NOT EXISTS `cached_loans` (`bucket` TEXT NOT NULL, `id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `bookId` INTEGER NOT NULL, `title` TEXT NOT NULL, `coverUrl` TEXT, `status` TEXT NOT NULL, `statusLabel` TEXT, `requestedAt` TEXT, `loanedAt` TEXT, `dueAt` TEXT, `dueAttention` INTEGER NOT NULL, `returnedAt` TEXT, `renewals` INTEGER, `cancellable` INTEGER NOT NULL, `queuePosition` INTEGER, `requestedFrom` TEXT, `requestedTo` TEXT, `reservedAt` TEXT, `expiresAt` TEXT, PRIMARY KEY(`bucket`, `id`))", + "CREATE TABLE IF NOT EXISTS `cached_wishlist` (`bookId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `year` INTEGER, `coverUrl` TEXT, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, PRIMARY KEY(`bookId`))", + "CREATE TABLE IF NOT EXISTS `sync_meta` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'v3')", + ) + } +} diff --git a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt index c1d1418..97cf060 100644 --- a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt +++ b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt @@ -45,6 +45,31 @@ class CalendarLoanAlignmentTest { assertEquals(R.string.loan_status_on_loan, StatusMapping.loan(loan).second.resId) } + @Test fun readyLoanShowsTheLastPickupDayTheServerMovedPastAClosure() { + val loan = json.decodeFromString("""{ + "id":8,"status":"da_ritirare","due_at":"2030-12-30","effective_due_at":"2030-12-30", + "pickup_deadline":"2030-12-27","is_overdue":false + }""") + assertEquals("2030-12-27", loan.pickupDeadline) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Pickup, "2030-12-27"), StatusMapping.loanDate(loan)) + } + + @Test fun readyLoanFromAnOlderServerFallsBackToTheReturnDay() { + val loan = json.decodeFromString("""{"status":"da_ritirare","due_at":"2030-12-30"}""") + assertNull(loan.pickupDeadline) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + + @Test fun blankPickupDayFallsBackToTheReturnDay() { + val loan = LoanItem(status = "da_ritirare", dueAt = "2030-12-30", pickupDeadline = " ", isOverdue = false) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + + @Test fun pickupDayIsIgnoredOnceTheLoanIsCollected() { + val loan = LoanItem(status = "in_corso", dueAt = "2030-12-30", pickupDeadline = "2030-12-27", isOverdue = false) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan)) + } + @Test fun olderReaderPayloadKeepsItsPreviousStatusAndDate() { val loan = json.decodeFromString("""{"status":"in_ritardo","due_at":"2030-12-25"}""") assertNull(loan.isOverdue) diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt index ff3e4ea..e1fc3a0 100644 --- a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -15,6 +15,7 @@ import com.pinakes.app.data.model.LoanItem import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.data.model.WishlistItem +import com.pinakes.app.ui.common.StatusMapping import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -26,7 +27,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Offline copies of the personal lists and the sync bookkeeping (Room v2). */ +/** Offline copies of the personal lists and the sync bookkeeping. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class LibraryDaoTest { @@ -47,8 +48,9 @@ class LibraryDaoTest { private val loans = LoansData( pending = listOf(LoanItem(id = 3, bookId = 30, title = "Pending", status = "pendente", cancellable = true)), active = listOf( - LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01"), - LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", dueAttention = true), + LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01", effectiveDueAt = "2026-11-03", isOverdue = false), + LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", isOverdue = true, dueAttention = true), + LoanItem(id = 5, bookId = 50, title = "Ready", status = "da_ritirare", dueAt = "2026-11-20", pickupDeadline = "2026-11-06"), ), history = listOf(LoanItem(id = 4, bookId = 40, title = "Old", status = "restituito", returnedAt = "2026-01-01")), ) @@ -59,6 +61,30 @@ class LibraryDaoTest { assertEquals(loans, back) } + @Test fun readyLoanKeepsItsPickupDateOffline() = runBlocking { + val ready = LoanItem( + id = 5, + status = "da_ritirare", + dueAt = "2030-12-30", + pickupDeadline = "2030-12-27", + ) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData(active = listOf(ready)).toCached()) + val cached = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals( + StatusMapping.LoanDate(StatusMapping.LoanDateKind.Pickup, "2030-12-27"), + StatusMapping.loanDate(cached), + ) + } + + @Test fun offlineLoanKeepsTheServerCalendar() = runBlocking { + // Due on a day the branch later closed: the server moved it and cleared the stale late state. + val moved = LoanItem(id = 6, status = "in_ritardo", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = false) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData(active = listOf(moved)).toCached()) + val cached = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single() + assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(cached)) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-27"), StatusMapping.loanDate(cached)) + } + @Test fun replacingLoansKeepsReservations() = runBlocking { dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R", queuePosition = 2).toCached(0))) dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) @@ -108,7 +134,7 @@ class LibraryDaoTest { dao.deleteLoan(9, listOf(CachedLoan.RESERVATION)) val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() assertTrue(back.pending.isEmpty()) - assertEquals(2, back.active.size) + assertEquals(loans.active, back.active) // the active bucket is untouched assertEquals(1, back.history.size) // other buckets untouched assertTrue(dao.loans(listOf(CachedLoan.RESERVATION)).isEmpty()) diff --git a/i18n/de.json b/i18n/de.json index 1cb396d..ada0297 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Abholbereit & geplant", "library_section_pending": "Ausstehende Anfragen", "library_due_label": "Fällig am %1$s", + "library_pickup_by": "Abholen bis %1$s", "library_overdue_label": "Überfällig", "library_overdue_since": "Überfällig, war fällig am %1$s", "book_personal_on_loan_title": "Du hast es ausgeliehen", diff --git a/i18n/en.json b/i18n/en.json index 734cf9e..11bec0b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Ready & scheduled", "library_section_pending": "Pending requests", "library_due_label": "Due %1$s", + "library_pickup_by": "Pick up by %1$s", "library_overdue_label": "Overdue", "library_overdue_since": "Overdue, was due %1$s", "book_personal_on_loan_title": "You have this on loan", diff --git a/i18n/fr.json b/i18n/fr.json index 2c8fa74..a489c7e 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -305,6 +305,7 @@ "library_section_upcoming": "À retirer et programmés", "library_section_pending": "Demandes en attente", "library_due_label": "À rendre le %1$s", + "library_pickup_by": "À retirer au plus tard le %1$s", "library_overdue_label": "En retard", "library_overdue_since": "En retard, devait être rendu le %1$s", "book_personal_on_loan_title": "Vous l'avez emprunté", @@ -901,7 +902,7 @@ "staff_loan_recalls": "Relances", "staff_loan_processed_by": "Traité par", "staff_loan_days_overdue": "Jours de retard : %1$d", - "staff_loan_pickup_by": "À retirer avant le %1$s", + "staff_loan_pickup_by": "À retirer au plus tard le %1$s", "staff_loan_starts_on": "Commence le %1$s", "staff_penalty": "Pénalité", "staff_penalty_invalid": "Un montant comme 12,50 (exemplaires perdus ou abîmés seulement)", diff --git a/i18n/it.json b/i18n/it.json index 0231213..b8a60d6 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -305,6 +305,7 @@ "library_section_upcoming": "Da ritirare e programmati", "library_section_pending": "Richieste in attesa", "library_due_label": "Scade il %1$s", + "library_pickup_by": "Da ritirare entro il %1$s", "library_overdue_label": "In ritardo", "library_overdue_since": "In ritardo, scaduto il %1$s", "book_personal_on_loan_title": "Ce l'hai in prestito",