From 4d6dd0a35a3887f5e39f1cabb03a4a235476ecf2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 10:29:37 +0200 Subject: [PATCH 1/4] feat(network): cache HTTP responses so ETags become 304s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server already tags its cacheable GETs — the whole periodicals surface among them — with an ETag and `Cache-Control: private, max-age=0, must-revalidate`, but the client never sent `If-None-Match` back: no okhttp3.Cache was configured, so OkHttp had no stored validator to attach and every navigation re-downloaded a body the device already had. I give the OkHttp client a ~10 MB disk cache in the app cache dir. Revalidation is now transparent and, since `max-age=0` keeps every request going to the server, this trades bandwidth for nothing — a 304 replays the stored body instead of serving stale data. Cache entries are keyed by URL alone, so I evict the whole cache on logout and on instance switch, next to the Room purge that already happens there. The eviction runs on the IO dispatcher and swallows its own failures: an unpurgeable cache is a bandwidth problem and must not turn signing out into an error the user has to fight. --- .../pinakes/app/data/network/NetworkModule.kt | 51 ++++++++++++++++++- .../app/data/repository/AuthRepository.kt | 6 +++ .../main/java/com/pinakes/app/di/AppModule.kt | 6 ++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 5df18e3..0e0ea7f 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -3,11 +3,15 @@ package com.pinakes.app.data.network import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.pinakes.app.BuildConfig import com.pinakes.app.data.store.SessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import okhttp3.Cache import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit +import java.io.File import java.util.concurrent.TimeUnit /** @@ -16,8 +20,21 @@ import java.util.concurrent.TimeUnit * The base URL is per-instance and only known after onboarding, so the Retrofit instance is * (re)created whenever the instance URL changes. The bearer token is read live from * [SessionStore] by the [AuthInterceptor], so the same client survives login/logout. + * + * [cacheDir] (the app's cache directory) enables a disk HTTP cache. The server tags its + * cacheable GETs — the whole periodicals surface, for one — with an ETag and + * `Cache-Control: private, max-age=0, must-revalidate`, but without a cache OkHttp has no + * stored validator to send, so `If-None-Match` never goes out and every request pays for a + * full body. With the cache wired in, revalidation becomes transparent: OkHttp attaches the + * stored ETag and a 304 replays the cached body instead of downloading it again. + * `max-age=0` means nothing is ever served without asking the server first, so this saves + * bandwidth without ever serving stale data. + * + * The cache is keyed by URL only. Two accounts, or two instances that share a path, would + * otherwise collide — so it is purged on logout and on instance switch (see + * [clearHttpCache]) and no response body outlives the session that fetched it. */ -class NetworkModule(private val session: SessionStore) { +class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { val json: Json = Json { ignoreUnknownKeys = true @@ -26,6 +43,15 @@ class NetworkModule(private val session: SessionStore) { coerceInputValues = true } + /** + * Disk cache for conditional GETs, or null when no cache directory was supplied (unit + * tests build the module without an Android context). Constructing a [Cache] only records + * the directory and the size budget — the on-disk journal is opened lazily on first use — + * so this is safe to do off the IO dispatcher. + */ + private val httpCache: Cache? = + cacheDir?.let { Cache(File(it, HTTP_CACHE_DIR), HTTP_CACHE_MAX_BYTES) } + private val okHttpClient: OkHttpClient by lazy { val builder = OkHttpClient.Builder() // Also honour the transient onboarding opt-in: during discovery the instance isn't @@ -36,6 +62,7 @@ class NetworkModule(private val session: SessionStore) { .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) + httpCache?.let { builder.cache(it) } // Complete incomplete server certificate chains via AIA, like a browser does. Self-hosted // instances behind QNAP/Synology proxies often serve a chain missing its intermediate, which // the default Android TLS stack rejects ("Trust anchor for certification path not found") @@ -133,7 +160,29 @@ class NetworkModule(private val session: SessionStore) { cachedPeriodicalsApi = null } + /** + * Evict every stored response. Called on logout and on instance switch: cache entries are + * keyed by URL alone, so a body fetched for one account or one library must never be + * revalidated — let alone replayed — under the next one. + * + * Runs on the IO dispatcher (evicting walks the on-disk journal) and swallows failures: + * a cache that cannot be purged is a bandwidth problem, and must not be allowed to turn + * signing out into an error the user has to fight. + */ + suspend fun clearHttpCache() { + val cache = httpCache ?: return + withContext(Dispatchers.IO) { + runCatching { cache.evictAll() } + } + } + companion object { + /** Subdirectory of the app cache dir holding the OkHttp response cache. */ + private const val HTTP_CACHE_DIR = "http" + + /** ~10 MB: the cached surface is JSON, and OkHttp prunes the directory to fit. */ + private const val HTTP_CACHE_MAX_BYTES = 10L * 1024 * 1024 + /** * Derive the API base URL from a user-entered instance URL. * - when no scheme is given, prepends `https://` — or `http://` when the user has diff --git a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt index 5450da3..71c58f7 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt @@ -173,6 +173,9 @@ class AuthRepository( val api = network.api() val result = apiCall { api.logout() } session.clearToken() + // Drop the cached HTTP responses of the session that just ended: they were fetched + // with a bearer token that no longer exists, and the cache is keyed by URL only. + network.clearHttpCache() return result } @@ -184,6 +187,9 @@ class AuthRepository( // instance and must never surface under the next library's name. catalog.clearCache() network.invalidate() + // Same reasoning as the Room purge: a URL-keyed HTTP cache entry from the old + // instance could otherwise be revalidated against the next library's server. + network.clearHttpCache() } private fun deviceName(): String { diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index bdc9d6f..6b76d30 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -41,8 +41,12 @@ object AppModule { @Provides @Singleton fun features(@ApplicationContext context: Context): FeatureStore = FeatureStore(context) + // The app cache dir backs the OkHttp response cache, so the server's ETags actually turn + // into conditional requests instead of full re-downloads. Android reclaims this directory + // under storage pressure, which is exactly the right lifetime for it. @Provides @Singleton - fun network(session: SessionStore): NetworkModule = NetworkModule(session) + fun network(@ApplicationContext context: Context, session: SessionStore): NetworkModule = + NetworkModule(session, context.cacheDir) @Provides @Singleton fun database(@ApplicationContext context: Context): AppDatabase = AppDatabase.get(context) From 6306300a48f8698c6863cd1d610a26bc1fa314be Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 10:30:40 +0200 Subject: [PATCH 2/4] feat(periodicals): show the supplements note on an issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue detail endpoint returns a nullable `supplements` string — the free-text note on inserts bound with a fascicolo — and the app was dropping it on decode. I add it to the DTO and render it in the issue header, reusing the masthead detail's InfoRow so the label/value pair reads identically on both screens (InfoRow goes from private to internal for that). The row appears only for a non-null, non-blank value, like every other optional field in this section. --- .../java/com/pinakes/app/data/model/PeriodicalsModels.kt | 2 ++ .../pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt | 6 ++++++ .../app/ui/screens/periodicals/PeriodicalDetailScreen.kt | 3 ++- i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 7 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt index 6c6a1dd..0f529de 100644 --- a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt @@ -115,6 +115,8 @@ data class PeriodicalIssueDetail( val pages: Int? = null, val status: String = "", @SerialName("cover_url") val coverUrl: String? = null, + /** Free-text note on inserts bound with this issue ("Supplemento letterario", …). */ + val supplements: String? = null, /** Only present when the digitised PDF is public; null → hide the "Open PDF" action. */ @SerialName("pdf_url") val pdfUrl: String? = null, val masthead: IssueMasthead? = null, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt index 5f39bb7..2790ea1 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt @@ -163,6 +163,12 @@ private fun IssueHeader(issue: PeriodicalIssueDetail) { status = issueStatusBadge(issue.status), label = stringResource(issueStatusLabelRes(issue.status)), ) + // Free text and often absent — shown only when the server actually has something + // to say, using the same label/value row as the masthead detail. + issue.supplements?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(Spacing.md)) + InfoRow(stringResource(R.string.periodicals_label_supplements), it) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt index dca022b..2d41105 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt @@ -174,8 +174,9 @@ private fun coverageLabel(start: Int?, end: Int?): String? = when { else -> null } +/** Label/value detail row, shared with the issue detail header so both read identically. */ @Composable -private fun InfoRow(label: String, value: String) { +internal fun InfoRow(label: String, value: String) { Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xxs)) { Text( label, diff --git a/i18n/de.json b/i18n/de.json index e3398b5..f5eca00 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -453,6 +453,7 @@ "periodicals_label_place": "Erscheinen", "periodicals_label_years": "Zeitraum", "periodicals_label_holdings": "Bestand", + "periodicals_label_supplements": "Beilagen", "periodicals_year_since": "Seit %1$d", "periodicals_years_section": "Jahrgänge", "periodicals_year_volume": "Bd. %1$s", diff --git a/i18n/en.json b/i18n/en.json index 1254535..3072a52 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -453,6 +453,7 @@ "periodicals_label_place": "Publication", "periodicals_label_years": "Coverage", "periodicals_label_holdings": "Holdings", + "periodicals_label_supplements": "Supplements", "periodicals_year_since": "Since %1$d", "periodicals_years_section": "Years", "periodicals_year_volume": "Vol. %1$s", diff --git a/i18n/fr.json b/i18n/fr.json index 5163bae..3eb0a7c 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -453,6 +453,7 @@ "periodicals_label_place": "Publication", "periodicals_label_years": "Période", "periodicals_label_holdings": "État de collection", + "periodicals_label_supplements": "Suppléments", "periodicals_year_since": "Depuis %1$d", "periodicals_years_section": "Années", "periodicals_year_volume": "Vol. %1$s", diff --git a/i18n/it.json b/i18n/it.json index 9c6e0ea..5248a61 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -453,6 +453,7 @@ "periodicals_label_place": "Pubblicazione", "periodicals_label_years": "Periodo", "periodicals_label_holdings": "Consistenza", + "periodicals_label_supplements": "Supplementi", "periodicals_year_since": "Dal %1$d", "periodicals_years_section": "Annate", "periodicals_year_volume": "Vol. %1$s", From dcf542d0ad91deb767f47a2742e68f7e23985c75 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 10:33:18 +0200 Subject: [PATCH 3/4] feat(periodicals): flag a year whose issue list the server capped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET periodicals/years/{id}/issues` caps its result at 400 fascicoli and now reports the cut in `meta.truncated`. Without that flag the app silently presented a partial year as the whole thing. I add the field to the shared Meta DTO as a nullable with a null default, so an instance that predates it keeps decoding: absent must read as "complete", never as "truncated". The decision lives in a pure `isTruncatedList(meta)` so it is testable without a ViewModel, and I deliberately do not infer truncation from the item count — that would cry wolf on a year sitting exactly on the cap and would be wrong outright the day the cap moves. When the flag is set the issue list shows an informational banner above the rows. It is not an error state: the issues below are real and browsable, so nothing is blocked and there is nothing to retry. --- .../java/com/pinakes/app/data/model/Models.kt | 4 ++ .../ui/screens/periodicals/IssueListScreen.kt | 48 +++++++++++++++++++ .../screens/periodicals/IssueListViewModel.kt | 8 +++- .../ui/screens/periodicals/PeriodicalsUi.kt | 17 +++++-- .../periodicals/PeriodicalsUiStateTest.kt | 19 ++++++++ i18n/de.json | 2 + i18n/en.json | 2 + i18n/fr.json | 2 + i18n/it.json | 2 + 9 files changed, 100 insertions(+), 4 deletions(-) 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 f4954c3..54a47a3 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 @@ -17,6 +17,10 @@ data class Meta( @SerialName("total_count") val totalCount: Int? = null, val https: Boolean? = null, val warning: String? = null, // "insecure_transport" on /health + // Set by endpoints that cap their result set (periodicals year issues: 400 max) to say + // the list was cut short. Nullable with a null default on purpose: servers older than + // the field simply omit it, and absent must read as "not truncated", never as "true". + val truncated: Boolean? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt index b7b47de..2293b41 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Newspaper import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -82,6 +84,11 @@ fun IssueListScreen( contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { + // Informational, never blocking: the issues below are real and + // browsable, there are simply more of them on the server. + if (state.truncated) { + item { TruncatedNotice(shown = content.data.size) } + } items(content.data, key = { it.id }) { issue -> IssueRow(issue = issue, onClick = { onOpenIssue(issue.id) }) } @@ -92,6 +99,47 @@ fun IssueListScreen( } } +/** + * Partial-list notice for a year the server capped. Styled as an informational banner + * (secondary container, like the book detail's status banners) rather than an error: nothing + * failed and there is nothing to retry. + */ +@Composable +private fun TruncatedNotice(shown: Int) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.periodicals_issues_truncated_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(Modifier.height(Spacing.xs)) + Text( + stringResource(R.string.periodicals_issues_truncated_subtitle, shown), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + /** "No. 12 · Title" heading, or just the localized fallback when both are absent. */ @Composable internal fun issueHeading(number: String?, title: String?): String { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt index 94b7099..488b096 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch data class IssueListUiState( val content: UiState> = UiState.Loading, val refreshing: Boolean = false, + /** The server capped this year's issues and said so via `meta.truncated`. */ + val truncated: Boolean = false, ) @HiltViewModel @@ -46,7 +48,11 @@ class IssueListViewModel @Inject constructor( viewModelScope.launch { when (val res = repo.yearIssues(yearId)) { is ApiResult.Success -> _state.update { - it.copy(content = UiState.Success(res.data), refreshing = false) + it.copy( + content = UiState.Success(res.data), + refreshing = false, + truncated = isTruncatedList(res.meta), + ) } is ApiResult.Failure -> _state.update { it.copy( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt index 0d73181..1fafba6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt @@ -2,12 +2,13 @@ package com.pinakes.app.ui.screens.periodicals import androidx.annotation.StringRes import com.pinakes.app.R +import com.pinakes.app.data.model.Meta import com.pinakes.app.ui.components.AvailabilityStatus /** - * Pure helpers for the Periodicals screens: enum → localized label lookups and the - * issue-status → badge mapping. Kept free of Compose so they are unit-testable - * (see PeriodicalsUiStateTest). + * Pure helpers for the Periodicals screens: enum → localized label lookups, the + * issue-status → badge mapping and the truncated-list decision. Kept free of Compose so + * they are unit-testable (see PeriodicalsUiStateTest). */ /** The masthead types the server may emit, in filter-chip order. */ @@ -61,3 +62,13 @@ fun issueStatusBadge(status: String): AvailabilityStatus = when (status) { "danneggiato", "in_restauro" -> AvailabilityStatus.DueSoon else -> AvailabilityStatus.Returned } + +/** + * True when the server says it cut the list short (`meta.truncated`), which is the only + * signal for the 400-issue cap on a year's fascicoli. + * + * Servers that predate the field omit it, so `null` MUST read as "complete": inferring + * truncation from the item count instead would cry wolf on any year that happens to sit + * exactly on the cap, and would be flatly wrong the day the cap changes. + */ +internal fun isTruncatedList(meta: Meta?): Boolean = meta?.truncated == true diff --git a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt index 5d57731..9ea9ab1 100644 --- a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt +++ b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.data.model.Meta import com.pinakes.app.data.model.PeriodicalIssueDetail import com.pinakes.app.data.model.PeriodicalSummary import com.pinakes.app.ui.components.AvailabilityStatus @@ -112,4 +113,22 @@ class PeriodicalsUiStateTest { assertFalse(PeriodicalIssueDetail(id = 1, pdfUrl = "").canOpenPdf) assertFalse(PeriodicalIssueDetail(id = 1, pdfUrl = " ").canOpenPdf) } + + // ---- Truncated issue list ---- + + @Test fun truncatedMetaRaisesTheBanner() { + assertTrue(isTruncatedList(Meta(truncated = true))) + } + + @Test fun explicitlyUntruncatedMetaDoesNotRaiseTheBanner() { + assertFalse(isTruncatedList(Meta(truncated = false))) + } + + @Test fun aServerThatOmitsTheFieldIsTreatedAsComplete() { + // Older servers have no `truncated` key at all: absent must never read as true, + // or every year would claim to be partial against an un-upgraded instance. + assertFalse(isTruncatedList(Meta(truncated = null))) + assertFalse(isTruncatedList(Meta(nextCursor = "c1"))) + assertFalse(isTruncatedList(null)) + } } diff --git a/i18n/de.json b/i18n/de.json index f5eca00..349ed82 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -464,6 +464,8 @@ "periodicals_issues_error": "Die Hefte konnten nicht geladen werden.", "periodicals_issues_empty_title": "Keine Hefte", "periodicals_issues_empty_subtitle": "Für diesen Jahrgang sind noch keine Hefte erfasst.", + "periodicals_issues_truncated_title": "Unvollständige Liste", + "periodicals_issues_truncated_subtitle": "Es werden die ersten %1$d Hefte dieses Jahrgangs angezeigt.", "periodicals_issue_fallback": "Heft", "periodicals_issue_number": "Nr. %1$s", "periodicals_issue_pages": "%1$d Seiten", diff --git a/i18n/en.json b/i18n/en.json index 3072a52..7c2a558 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -464,6 +464,8 @@ "periodicals_issues_error": "Couldn't load the issues.", "periodicals_issues_empty_title": "No issues", "periodicals_issues_empty_subtitle": "This year has no catalogued issues yet.", + "periodicals_issues_truncated_title": "Partial list", + "periodicals_issues_truncated_subtitle": "Showing the first %1$d issues of this year.", "periodicals_issue_fallback": "Issue", "periodicals_issue_number": "No. %1$s", "periodicals_issue_pages": "%1$d pages", diff --git a/i18n/fr.json b/i18n/fr.json index 3eb0a7c..96562f4 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -464,6 +464,8 @@ "periodicals_issues_error": "Impossible de charger les fascicules.", "periodicals_issues_empty_title": "Aucun fascicule", "periodicals_issues_empty_subtitle": "Cette année n'a pas encore de fascicules catalogués.", + "periodicals_issues_truncated_title": "Liste partielle", + "periodicals_issues_truncated_subtitle": "Affichage des %1$d premiers fascicules de cette année.", "periodicals_issue_fallback": "Fascicule", "periodicals_issue_number": "N° %1$s", "periodicals_issue_pages": "%1$d pages", diff --git a/i18n/it.json b/i18n/it.json index 5248a61..af55597 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -464,6 +464,8 @@ "periodicals_issues_error": "Impossibile caricare i fascicoli.", "periodicals_issues_empty_title": "Nessun fascicolo", "periodicals_issues_empty_subtitle": "Questa annata non ha ancora fascicoli catalogati.", + "periodicals_issues_truncated_title": "Elenco parziale", + "periodicals_issues_truncated_subtitle": "Mostrati i primi %1$d fascicoli di questa annata.", "periodicals_issue_fallback": "Fascicolo", "periodicals_issue_number": "N. %1$s", "periodicals_issue_pages": "%1$d pagine", From 6fb31871a3f0671307310d4a0ca3bf5ad34a9c9c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 9 Sep 2026 10:33:56 +0200 Subject: [PATCH 4/4] fix(periodicals): tell a missing record apart from a disabled section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriodicalsViewModel already degraded gracefully on a 404 — re-probe health, and if the plugin is really off show a terminal "section unavailable" state instead of a retryable error. The three detail screens did not: a masthead, a year or an issue that 404'd produced a generic failure with a retry button that could only 404 again. I extend the same pattern to them, but a 404 on a detail endpoint is ambiguous in a way the list's never was: the one record may have been deleted while the section is perfectly alive. So health stays the oracle — I probe it only on a 404, and only a 404 from health too means the section is gone. Anything else stays "no longer exists", which sends the user back to browsing rather than telling them the whole archive vanished. The decision is a pure function over the failure plus the probe answer, unit-tested for both 404 branches and for the guarantee that a non-404 can never degrade to "gone" even if a stale probe says so. The gone/not-found states drop the server's bare "Not found." message on purpose: resolvedMessage() prefers a non-blank message, so keeping it would have shown that instead of the wording that distinguishes the two cases. --- .../screens/periodicals/IssueDetailScreen.kt | 9 +- .../periodicals/IssueDetailViewModel.kt | 26 ++++- .../ui/screens/periodicals/IssueListScreen.kt | 8 +- .../screens/periodicals/IssueListViewModel.kt | 26 ++++- .../periodicals/PeriodicalDetailScreen.kt | 9 +- .../periodicals/PeriodicalDetailViewModel.kt | 27 +++++- .../ui/screens/periodicals/PeriodicalsUi.kt | 65 ++++++++++++- .../periodicals/PeriodicalsUiStateTest.kt | 94 +++++++++++++++++++ i18n/de.json | 3 + i18n/en.json | 3 + i18n/fr.json | 3 + i18n/it.json | 3 + 12 files changed, 256 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt index 2790ea1..a47d243 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt @@ -37,6 +37,7 @@ import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.PinakesTopBar @@ -69,7 +70,13 @@ fun IssueDetailScreen(onNavigateUp: () -> Unit) { ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issue_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> + // Plugin deactivated server-side: a terminal state, not a retryable + // error — retrying can only 404 again (see periodicalsFailureKind). + if (state.pluginGone) EmptyState( + title = stringResource(R.string.periodicals_gone_title), + subtitle = stringResource(R.string.periodicals_gone_subtitle), + ) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) is UiState.Success -> { val issue = content.data LazyColumn( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt index 1d697b6..4c7d49d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch data class IssueDetailUiState( val content: UiState = UiState.Loading, val refreshing: Boolean = false, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, ) @HiltViewModel @@ -45,12 +47,26 @@ class IssueDetailViewModel @Inject constructor( is ApiResult.Success -> _state.update { it.copy(content = UiState.Success(res.data), refreshing = false) } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(res.message, res.code, R.string.periodicals_issue_error), - refreshing = false, + is ApiResult.Failure -> { + // A 404 here is either a deleted fascicolo or a deactivated plugin: + // health decides which (see periodicalsFailureKind). + val kind = periodicalsFailureKind( + res, + goneConfirmed = isNotFoundFailure(res) && repo.confirmGone(), ) + _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else periodicalsErrorState( + failure = res, + kind = kind, + genericRes = R.string.periodicals_issue_error, + notFoundRes = R.string.periodicals_issue_not_found, + ), + refreshing = false, + pluginGone = kind == PeriodicalsFailure.Gone, + ) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt index 2293b41..c0f43e6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt @@ -70,7 +70,13 @@ fun IssueListScreen( ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issues_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> + // Plugin deactivated server-side: a terminal state, not a retryable + // error — retrying can only 404 again (see periodicalsFailureKind). + if (state.pluginGone) EmptyState( + title = stringResource(R.string.periodicals_gone_title), + subtitle = stringResource(R.string.periodicals_gone_subtitle), + ) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) is UiState.Success -> if (content.data.isEmpty()) { EmptyState( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt index 488b096..f675826 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch data class IssueListUiState( val content: UiState> = UiState.Loading, val refreshing: Boolean = false, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, /** The server capped this year's issues and said so via `meta.truncated`. */ val truncated: Boolean = false, ) @@ -54,12 +56,26 @@ class IssueListViewModel @Inject constructor( truncated = isTruncatedList(res.meta), ) } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(res.message, res.code, R.string.periodicals_issues_error), - refreshing = false, + is ApiResult.Failure -> { + // A 404 is either a year that no longer exists or a deactivated plugin: + // health decides which (see periodicalsFailureKind). + val kind = periodicalsFailureKind( + res, + goneConfirmed = isNotFoundFailure(res) && repo.confirmGone(), ) + _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else periodicalsErrorState( + failure = res, + kind = kind, + genericRes = R.string.periodicals_issues_error, + notFoundRes = R.string.periodicals_issues_not_found, + ), + refreshing = false, + pluginGone = kind == PeriodicalsFailure.Gone, + ) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt index 2d41105..980731f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt @@ -37,6 +37,7 @@ import com.pinakes.app.data.model.PeriodicalDetail import com.pinakes.app.data.model.PeriodicalYear import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.PinakesTopBar @@ -65,7 +66,13 @@ fun PeriodicalDetailScreen( ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_detail_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> + // Plugin deactivated server-side: a terminal state, not a retryable + // error — retrying can only 404 again (see periodicalsFailureKind). + if (state.pluginGone) EmptyState( + title = stringResource(R.string.periodicals_gone_title), + subtitle = stringResource(R.string.periodicals_gone_subtitle), + ) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) is UiState.Success -> { val detail = content.data LazyColumn( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt index 42bf2f7..0294b9e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch data class PeriodicalDetailUiState( val content: UiState = UiState.Loading, val refreshing: Boolean = false, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, ) @HiltViewModel @@ -46,12 +48,27 @@ class PeriodicalDetailViewModel @Inject constructor( is ApiResult.Success -> _state.update { it.copy(content = UiState.Success(res.data), refreshing = false) } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(res.message, res.code, R.string.periodicals_detail_error), - refreshing = false, + is ApiResult.Failure -> { + // Spend a health probe only on a 404, and only to tell "this masthead is + // gone" apart from "the whole section is gone" (which also flips the + // feature flag, hiding every entry point). + val kind = periodicalsFailureKind( + res, + goneConfirmed = isNotFoundFailure(res) && repo.confirmGone(), ) + _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else periodicalsErrorState( + failure = res, + kind = kind, + genericRes = R.string.periodicals_detail_error, + notFoundRes = R.string.periodicals_detail_not_found, + ), + refreshing = false, + pluginGone = kind == PeriodicalsFailure.Gone, + ) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt index 1fafba6..19d03a6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt @@ -3,12 +3,16 @@ package com.pinakes.app.ui.screens.periodicals import androidx.annotation.StringRes import com.pinakes.app.R import com.pinakes.app.data.model.Meta +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.components.AvailabilityStatus /** * Pure helpers for the Periodicals screens: enum → localized label lookups, the - * issue-status → badge mapping and the truncated-list decision. Kept free of Compose so - * they are unit-testable (see PeriodicalsUiStateTest). + * issue-status → badge mapping, the truncated-list decision and the failure classification + * the detail screens share. Kept free of Compose so they are unit-testable + * (see PeriodicalsUiStateTest). */ /** The masthead types the server may emit, in filter-chip order. */ @@ -72,3 +76,60 @@ fun issueStatusBadge(status: String): AvailabilityStatus = when (status) { * exactly on the cap, and would be flatly wrong the day the cap changes. */ internal fun isTruncatedList(meta: Meta?): Boolean = meta?.truncated == true + +/** How a Periodicals detail failure should be presented. */ +internal enum class PeriodicalsFailure { + /** The plugin itself is off: a terminal, non-retryable state. */ + Gone, + + /** The section is alive, this one masthead/year/issue is not. */ + NotFound, + + /** Anything else — network, 5xx, auth — worth a retry. */ + Error, +} + +/** True for a 404, the only failure worth spending a health probe on. */ +internal fun isNotFoundFailure(failure: ApiResult.Failure): Boolean = + failure.httpStatus == 404 || failure.code == ErrorCodes.NOT_FOUND + +/** + * Classify a detail failure. A 404 is ambiguous — the single resource may have been deleted, + * or the whole plugin may have been switched off server-side — and the two deserve opposite + * treatments: "this issue no longer exists" (the section still works, go back and browse) vs + * "the periodicals section is gone" (nothing here will ever load again). + * + * The health endpoint is the oracle, exactly as it is for the availability probe: + * [goneConfirmed] is `PeriodicalsRepository.confirmGone()`, which re-probes health and only + * answers true when health 404s too. Callers must evaluate it ONLY for a 404 — probing on + * every timeout would turn a network blip into an extra doomed request. + */ +internal fun periodicalsFailureKind( + failure: ApiResult.Failure, + goneConfirmed: Boolean, +): PeriodicalsFailure = when { + !isNotFoundFailure(failure) -> PeriodicalsFailure.Error + goneConfirmed -> PeriodicalsFailure.Gone + else -> PeriodicalsFailure.NotFound +} + +/** + * Build the [UiState.Error] for a classified detail failure. + * + * Gone and NotFound deliberately drop the server's message: `resolvedMessage()` prefers a + * non-blank message over the localized fallback, so keeping the bare "Not found." would show + * that instead of wording that tells the user which of the two situations they are in. + */ +internal fun periodicalsErrorState( + failure: ApiResult.Failure, + kind: PeriodicalsFailure, + @StringRes genericRes: Int, + @StringRes notFoundRes: Int, +): UiState.Error = when (kind) { + PeriodicalsFailure.Gone -> + UiState.Error("", failure.code, R.string.periodicals_gone_subtitle) + PeriodicalsFailure.NotFound -> + UiState.Error("", failure.code, notFoundRes) + PeriodicalsFailure.Error -> + UiState.Error(failure.message, failure.code, genericRes) +} diff --git a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt index 9ea9ab1..ff3865c 100644 --- a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt +++ b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt @@ -1,8 +1,12 @@ package com.pinakes.app.ui.screens.periodicals +import com.pinakes.app.R import com.pinakes.app.data.model.Meta import com.pinakes.app.data.model.PeriodicalIssueDetail import com.pinakes.app.data.model.PeriodicalSummary +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.components.AvailabilityStatus import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -131,4 +135,94 @@ class PeriodicalsUiStateTest { assertFalse(isTruncatedList(Meta(nextCursor = "c1"))) assertFalse(isTruncatedList(null)) } + + // ---- 404 classification: missing resource vs deactivated plugin ---- + + private fun failure(status: Int, code: String = ErrorCodes.NOT_FOUND, message: String = "Not found.") = + ApiResult.Failure(code = code, message = message, httpStatus = status) + + @Test fun a404IsRecognisedByStatusOrByCode() { + assertTrue(isNotFoundFailure(failure(404))) + // The envelope can carry the code with no HTTP status attached (apiCall maps a + // body-level error with httpStatus = 0). + assertTrue(isNotFoundFailure(failure(0, code = ErrorCodes.NOT_FOUND))) + } + + @Test fun otherFailuresAreNotWorthAHealthProbe() { + assertFalse(isNotFoundFailure(failure(500, code = ErrorCodes.SERVER_ERROR))) + assertFalse(isNotFoundFailure(failure(0, code = ErrorCodes.NETWORK))) + assertFalse(isNotFoundFailure(failure(403, code = ErrorCodes.FORBIDDEN))) + } + + @Test fun a404WithHealthAlsoGoneMeansThePluginIsOff() { + assertEquals( + PeriodicalsFailure.Gone, + periodicalsFailureKind(failure(404), goneConfirmed = true), + ) + } + + @Test fun a404WithHealthStillUpMeansOnlyThisResourceIsMissing() { + assertEquals( + PeriodicalsFailure.NotFound, + periodicalsFailureKind(failure(404), goneConfirmed = false), + ) + } + + @Test fun aNon404NeverDegradesToGoneEvenIfTheProbeSaysSo() { + // Guards the call site's short-circuit: confirmGone() must not be consulted for a + // network blip, and even a stale true must not hide a retryable error. + assertEquals( + PeriodicalsFailure.Error, + periodicalsFailureKind(failure(0, code = ErrorCodes.NETWORK), goneConfirmed = true), + ) + assertEquals( + PeriodicalsFailure.Error, + periodicalsFailureKind(failure(500, code = ErrorCodes.SERVER_ERROR), goneConfirmed = false), + ) + } + + // ---- Error state built from the classification ---- + + private fun errorState(kind: PeriodicalsFailure) = periodicalsErrorState( + failure = failure(404), + kind = kind, + genericRes = R.string.periodicals_issue_error, + notFoundRes = R.string.periodicals_issue_not_found, + ) + + @Test fun notFoundDropsTheServerMessageSoTheLocalizedWordingWins() { + val state = errorState(PeriodicalsFailure.NotFound) + + // resolvedMessage() prefers a non-blank message: leaving the server's bare + // "Not found." would show that instead of "This issue no longer exists." + assertEquals("", state.message) + assertEquals(R.string.periodicals_issue_not_found, state.messageRes) + } + + @Test fun goneUsesTheSectionWideWording() { + val state = errorState(PeriodicalsFailure.Gone) + + assertEquals("", state.message) + assertEquals(R.string.periodicals_gone_subtitle, state.messageRes) + } + + @Test fun aGenericFailureKeepsTheServerMessageAndTheScreenFallback() { + val state = periodicalsErrorState( + failure = ApiResult.Failure(ErrorCodes.SERVER_ERROR, "Upstream exploded", 500), + kind = PeriodicalsFailure.Error, + genericRes = R.string.periodicals_issue_error, + notFoundRes = R.string.periodicals_issue_not_found, + ) + + assertEquals("Upstream exploded", state.message) + assertEquals(R.string.periodicals_issue_error, state.messageRes) + } + + @Test fun everyClassifiedFailureCodeSurvivesIntoTheState() { + // The code is what auth-expiry checks and telemetry key off: it must never be lost. + PeriodicalsFailure.entries.forEach { kind -> + val state: UiState.Error = errorState(kind) + assertEquals(ErrorCodes.NOT_FOUND, state.code) + } + } } diff --git a/i18n/de.json b/i18n/de.json index 349ed82..ca1a860 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -449,6 +449,7 @@ "periodicals_issues_count": "%1$d Hefte", "periodicals_detail_loading": "Zeitschrift wird geladen…", "periodicals_detail_error": "Diese Zeitschrift konnte nicht geladen werden.", + "periodicals_detail_not_found": "Diese Zeitschrift existiert nicht mehr.", "periodicals_label_publisher": "Verlag", "periodicals_label_place": "Erscheinen", "periodicals_label_years": "Zeitraum", @@ -462,6 +463,7 @@ "periodicals_issues_title": "Jahrgang %1$d", "periodicals_issues_loading": "Hefte werden geladen…", "periodicals_issues_error": "Die Hefte konnten nicht geladen werden.", + "periodicals_issues_not_found": "Dieser Jahrgang existiert nicht mehr.", "periodicals_issues_empty_title": "Keine Hefte", "periodicals_issues_empty_subtitle": "Für diesen Jahrgang sind noch keine Hefte erfasst.", "periodicals_issues_truncated_title": "Unvollständige Liste", @@ -471,6 +473,7 @@ "periodicals_issue_pages": "%1$d Seiten", "periodicals_issue_loading": "Heft wird geladen…", "periodicals_issue_error": "Dieses Heft konnte nicht geladen werden.", + "periodicals_issue_not_found": "Dieses Heft existiert nicht mehr.", "periodicals_articles_section": "Inhalt", "periodicals_article_pages": "S. %1$d–%2$d", "periodicals_article_page": "S. %1$d", diff --git a/i18n/en.json b/i18n/en.json index 7c2a558..8253623 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -449,6 +449,7 @@ "periodicals_issues_count": "%1$d issues", "periodicals_detail_loading": "Loading periodical…", "periodicals_detail_error": "Couldn't load this periodical.", + "periodicals_detail_not_found": "This periodical no longer exists.", "periodicals_label_publisher": "Publisher", "periodicals_label_place": "Publication", "periodicals_label_years": "Coverage", @@ -462,6 +463,7 @@ "periodicals_issues_title": "Year %1$d", "periodicals_issues_loading": "Loading issues…", "periodicals_issues_error": "Couldn't load the issues.", + "periodicals_issues_not_found": "This year no longer exists.", "periodicals_issues_empty_title": "No issues", "periodicals_issues_empty_subtitle": "This year has no catalogued issues yet.", "periodicals_issues_truncated_title": "Partial list", @@ -471,6 +473,7 @@ "periodicals_issue_pages": "%1$d pages", "periodicals_issue_loading": "Loading issue…", "periodicals_issue_error": "Couldn't load this issue.", + "periodicals_issue_not_found": "This issue no longer exists.", "periodicals_articles_section": "Contents", "periodicals_article_pages": "pp. %1$d–%2$d", "periodicals_article_page": "p. %1$d", diff --git a/i18n/fr.json b/i18n/fr.json index 96562f4..a5169dd 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -449,6 +449,7 @@ "periodicals_issues_count": "%1$d fascicules", "periodicals_detail_loading": "Chargement du périodique…", "periodicals_detail_error": "Impossible de charger ce périodique.", + "periodicals_detail_not_found": "Ce périodique n'existe plus.", "periodicals_label_publisher": "Éditeur", "periodicals_label_place": "Publication", "periodicals_label_years": "Période", @@ -462,6 +463,7 @@ "periodicals_issues_title": "Année %1$d", "periodicals_issues_loading": "Chargement des fascicules…", "periodicals_issues_error": "Impossible de charger les fascicules.", + "periodicals_issues_not_found": "Cette année n'existe plus.", "periodicals_issues_empty_title": "Aucun fascicule", "periodicals_issues_empty_subtitle": "Cette année n'a pas encore de fascicules catalogués.", "periodicals_issues_truncated_title": "Liste partielle", @@ -471,6 +473,7 @@ "periodicals_issue_pages": "%1$d pages", "periodicals_issue_loading": "Chargement du fascicule…", "periodicals_issue_error": "Impossible de charger ce fascicule.", + "periodicals_issue_not_found": "Ce fascicule n'existe plus.", "periodicals_articles_section": "Sommaire", "periodicals_article_pages": "pp. %1$d–%2$d", "periodicals_article_page": "p. %1$d", diff --git a/i18n/it.json b/i18n/it.json index af55597..38ead9f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -449,6 +449,7 @@ "periodicals_issues_count": "%1$d fascicoli", "periodicals_detail_loading": "Caricamento della testata…", "periodicals_detail_error": "Impossibile caricare questa testata.", + "periodicals_detail_not_found": "Questa testata non esiste più.", "periodicals_label_publisher": "Editore", "periodicals_label_place": "Pubblicazione", "periodicals_label_years": "Periodo", @@ -462,6 +463,7 @@ "periodicals_issues_title": "Annata %1$d", "periodicals_issues_loading": "Caricamento dei fascicoli…", "periodicals_issues_error": "Impossibile caricare i fascicoli.", + "periodicals_issues_not_found": "Questa annata non esiste più.", "periodicals_issues_empty_title": "Nessun fascicolo", "periodicals_issues_empty_subtitle": "Questa annata non ha ancora fascicoli catalogati.", "periodicals_issues_truncated_title": "Elenco parziale", @@ -471,6 +473,7 @@ "periodicals_issue_pages": "%1$d pagine", "periodicals_issue_loading": "Caricamento del fascicolo…", "periodicals_issue_error": "Impossibile caricare questo fascicolo.", + "periodicals_issue_not_found": "Questo fascicolo non esiste più.", "periodicals_articles_section": "Spoglio", "periodicals_article_pages": "pp. %1$d–%2$d", "periodicals_article_page": "p. %1$d",