Skip to content
Merged
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
148 changes: 148 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.pinakes.app.data.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Models for the Periodicals ("Emeroteca") plugin's mobile surface (`/api/v1/periodicals/…`).
*
* Unlike the Book Club plugin, this surface uses the CORE `{data, meta, error}` envelope
* ([Envelope]) — so every call goes through the shared `apiCall`, and list pagination reads
* `meta.next_cursor` exactly like the catalog search.
*
* The section is read-only (consultation of the periodicals archive): no write endpoints.
* All fields mirror the server's English snake_case JSON; anything the server may omit or
* null is declared nullable with a default so a lean payload can never fail decoding.
*/

/** GET periodicals/health → `{data:{status:"ok"}}` when the plugin is on (404 when off). */
@Serializable
data class PeriodicalsHealth(
val status: String = "",
)

// ---------- Mastheads list ----------
// GET periodicals?q=&type=&cursor=&limit= → { data: [PeriodicalSummary], meta: {next_cursor,…} }
@Serializable
data class PeriodicalSummary(
val id: Int = 0,
val title: String = "",
val subtitle: String? = null,
val issn: String? = null,
/** rivista | giornale | magazine | bollettino | fanzine */
val type: String = "",
/** quotidiano | settimanale | quindicinale | mensile | bimestrale | trimestrale | semestrale | annuale | irregolare */
val frequency: String? = null,
val publisher: PeriodicalPublisher? = null,
@SerialName("logo_url") val logoUrl: String? = null,
@SerialName("year_start") val yearStart: Int? = null,
@SerialName("year_end") val yearEnd: Int? = null,
@SerialName("collection_status") val collectionStatus: String? = null,
@SerialName("years_count") val yearsCount: Int = 0,
@SerialName("issues_count") val issuesCount: Int = 0,
)

@Serializable
data class PeriodicalPublisher(
val id: Int = 0,
val name: String = "",
)

// ---------- Masthead detail ----------
// GET periodicals/{id} → summary fields + description/place/language/holdings + years
@Serializable
data class PeriodicalDetail(
val id: Int = 0,
val title: String = "",
val subtitle: String? = null,
val issn: String? = null,
val type: String = "",
val frequency: String? = null,
val publisher: PeriodicalPublisher? = null,
@SerialName("logo_url") val logoUrl: String? = null,
@SerialName("year_start") val yearStart: Int? = null,
@SerialName("year_end") val yearEnd: Int? = null,
@SerialName("collection_status") val collectionStatus: String? = null,
@SerialName("years_count") val yearsCount: Int = 0,
@SerialName("issues_count") val issuesCount: Int = 0,
val description: String? = null,
val place: String? = null,
val language: String? = null,
/** Free-text consistency note ("1946-1998, lacune 1953-1955", …). */
val holdings: String? = null,
val years: List<PeriodicalYear> = emptyList(),
)

@Serializable
data class PeriodicalYear(
val id: Int = 0,
val year: Int = 0,
val volume: String? = null,
/** True when the annata is bound into a single physical volume. */
val bound: Boolean = false,
@SerialName("cover_url") val coverUrl: String? = null,
@SerialName("issues_count") val issuesCount: Int = 0,
@SerialName("owned_count") val ownedCount: Int = 0,
)

// ---------- Issues of a year ----------
// GET periodicals/years/{id}/issues → { data: [PeriodicalIssue] }
@Serializable
data class PeriodicalIssue(
val id: Int = 0,
val number: String? = null,
val sequence: String? = null,
val title: String? = null,
@SerialName("cover_date") val coverDate: String? = null,
@SerialName("publication_date") val publicationDate: String? = null,
val pages: Int? = null,
/** posseduto | mancante | danneggiato | in_restauro | smarrito | atteso */
val status: String = "",
@SerialName("cover_url") val coverUrl: String? = null,
@SerialName("has_public_pdf") val hasPublicPdf: Boolean = false,
)

// ---------- Issue detail ----------
// GET periodicals/issues/{id} → issue + pdf_url (only when public) + context + spoglio
@Serializable
data class PeriodicalIssueDetail(
val id: Int = 0,
val number: String? = null,
val sequence: String? = null,
val title: String? = null,
@SerialName("cover_date") val coverDate: String? = null,
@SerialName("publication_date") val publicationDate: String? = null,
val pages: Int? = null,
val status: String = "",
@SerialName("cover_url") val coverUrl: 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,
val year: IssueYear? = null,
val articles: List<IssueArticle> = emptyList(),
) {
/** The PDF action is offered ONLY for a non-blank public URL (server-authoritative). */
val canOpenPdf: Boolean get() = !pdfUrl.isNullOrBlank()
}

@Serializable
data class IssueMasthead(
val id: Int = 0,
val title: String = "",
)

@Serializable
data class IssueYear(
val id: Int = 0,
val year: Int = 0,
val volume: String? = null,
)

@Serializable
data class IssueArticle(
val title: String = "",
val authors: String? = null,
@SerialName("page_start") val pageStart: Int? = null,
@SerialName("page_end") val pageEnd: Int? = null,
val type: String? = null,
)
15 changes: 15 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class NetworkModule(private val session: SessionStore) {
@Volatile
private var cachedBookClubApi: BookClubApi? = null

@Volatile
private var cachedPeriodicalsApi: PeriodicalsApi? = null

/** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */
@Synchronized
private fun retrofit(baseUrl: String?): Retrofit {
Expand All @@ -85,6 +88,7 @@ class NetworkModule(private val session: SessionStore) {
cachedRetrofit = retrofit
cachedApi = null
cachedBookClubApi = null
cachedPeriodicalsApi = null
return retrofit
}

Expand All @@ -109,13 +113,24 @@ class NetworkModule(private val session: SessionStore) {
return cachedBookClubApi ?: retrofit.create(BookClubApi::class.java).also { cachedBookClubApi = it }
}

/**
* Returns the [PeriodicalsApi] bound to the same instance base URL as [api]. The
* Periodicals plugin lives under `/api/v1/periodicals/…` and reuses the same bearer token.
*/
@Synchronized
fun periodicalsApi(baseUrl: String? = null): PeriodicalsApi {
val retrofit = retrofit(baseUrl)
return cachedPeriodicalsApi ?: retrofit.create(PeriodicalsApi::class.java).also { cachedPeriodicalsApi = it }
}

/** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */
@Synchronized
fun invalidate() {
cachedBaseUrl = null
cachedRetrofit = null
cachedApi = null
cachedBookClubApi = null
cachedPeriodicalsApi = null
}

companion object {
Expand Down
48 changes: 48 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.pinakes.app.data.network

import com.pinakes.app.data.model.Envelope
import com.pinakes.app.data.model.PeriodicalDetail
import com.pinakes.app.data.model.PeriodicalIssue
import com.pinakes.app.data.model.PeriodicalIssueDetail
import com.pinakes.app.data.model.PeriodicalSummary
import com.pinakes.app.data.model.PeriodicalsHealth
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

/**
* Periodicals ("Emeroteca") plugin endpoints, exposed under `/api/v1/periodicals/…`.
* Base URL is the same instance origin + `/api/v1/` used by [PinakesApi], and every call
* carries the SAME bearer token (injected by [AuthInterceptor]) — the app authenticates
* once via the core Mobile API.
*
* Unlike [BookClubApi], these responses use the CORE `{data, meta, error}` envelope, so they
* are wrapped with the shared `apiCall`. The whole surface is read-only.
*/
interface PeriodicalsApi {

/** Discovery — 2xx means the section is available; 404 means the plugin is off. */
@GET("periodicals/health")
suspend fun health(): Envelope<PeriodicalsHealth>

/** Mastheads list; cursor-paginated like the catalog search (`meta.next_cursor`). */
@GET("periodicals")
suspend fun periodicals(
@Query("q") q: String? = null,
@Query("type") type: String? = null,
@Query("cursor") cursor: String? = null,
@Query("limit") limit: Int? = null,
): Envelope<List<PeriodicalSummary>>

/** Masthead detail + its years (annate). */
@GET("periodicals/{id}")
suspend fun periodical(@Path("id") id: Int): Envelope<PeriodicalDetail>

/** Issues (fascicoli) of a year. */
@GET("periodicals/years/{id}/issues")
suspend fun yearIssues(@Path("id") yearId: Int): Envelope<List<PeriodicalIssue>>

/** Issue detail + spoglio articles + public PDF url when available. */
@GET("periodicals/issues/{id}")
suspend fun issue(@Path("id") id: Int): Envelope<PeriodicalIssueDetail>
}
25 changes: 14 additions & 11 deletions app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class AuthRepository(
private val session: SessionStore,
private val features: FeatureStore,
private val bookClub: BookClubRepository,
private val periodicals: PeriodicalsRepository,
private val catalog: CatalogRepository,
) {

Expand Down Expand Up @@ -83,10 +84,11 @@ class AuthRepository(
suspend fun refreshHealth() {
val instance = session.instanceUrl ?: return
coroutineScope {
// The core /health and the Book Club plugin probe are independent requests to
// the same instance — run them concurrently so the refresh costs max(RTT), not
// the sum (this path gates the post-login spinner).
val probe = async { bookClub.probeAvailability() }
// The core /health and the plugin probes are independent requests to the same
// instance — run them concurrently so the refresh costs max(RTT), not the sum
// (this path gates the post-login spinner).
val bookClubProbe = async { bookClub.probeAvailability() }
val periodicalsProbe = async { periodicals.probeAvailability() }
var appAccessEnabled: Boolean? = null
when (val res = apiCall { network.api().health() }) {
is ApiResult.Success -> {
Expand All @@ -95,12 +97,13 @@ class AuthRepository(
}
is ApiResult.Failure -> { /* keep last-known flags; never lock the user out */ }
}
// The plugin's health endpoint is public and answers 2xx even when the instance
// has mobile app access switched off — gate the section on the core flag so it
// hides instead of rendering entries whose calls can only 403.
val probed = probe.await()
val available = if (appAccessEnabled == false) false else probed
bookClub.applyAvailability(available, probedInstanceUrl = instance)
// A plugin's health endpoint may answer 2xx even when the instance has mobile
// app access switched off — gate each section on the core flag so it hides
// instead of rendering entries whose calls can only 403.
val bookClubAvailable = bookClubProbe.await().let { if (appAccessEnabled == false) false else it }
bookClub.applyAvailability(bookClubAvailable, probedInstanceUrl = instance)
val periodicalsAvailable = periodicalsProbe.await().let { if (appAccessEnabled == false) false else it }
periodicals.applyAvailability(periodicalsAvailable, probedInstanceUrl = instance)
}
}

Expand Down Expand Up @@ -176,7 +179,7 @@ class AuthRepository(
/** Forget the instance entirely (back to onboarding). */
suspend fun forgetInstance() {
session.clearAll()
features.clear() // resets the Book Club availability flag too
features.clear() // resets the Book Club + Periodicals availability flags too
// Purge the offline catalog cache (Room + in-memory ETags): it belongs to the old
// instance and must never surface under the next library's name.
catalog.clearCache()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.pinakes.app.data.repository

import com.pinakes.app.data.model.PeriodicalDetail
import com.pinakes.app.data.model.PeriodicalIssue
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.data.network.NetworkModule
import com.pinakes.app.data.network.apiCall
import com.pinakes.app.data.store.FeatureStore
import com.pinakes.app.data.store.SessionStore

/** One cursor page of the mastheads list. */
data class PeriodicalsPage(
val items: List<PeriodicalSummary> = emptyList(),
val nextCursor: String? = null,
)

/**
* Periodicals ("Emeroteca") plugin surface (`/api/v1/periodicals/…`): availability discovery
* plus the read-only browse chain — mastheads list, masthead detail (with years), a year's
* issues and the issue detail.
*
* Availability is probed alongside every `/health` refresh and stored as an
* [com.pinakes.app.data.store.InstanceFeatures] flag, so the UI only shows the section when
* the plugin is active for this instance — the same lifecycle as [BookClubRepository].
*/
class PeriodicalsRepository(
private val network: NetworkModule,
private val features: FeatureStore,
private val session: SessionStore,
) {

/**
* Probe `GET /periodicals/health`.
* Returns true on 2xx (plugin on), false on an explicit 404 (plugin off), and null on
* any other failure — the caller keeps the last-known flag rather than hiding a working
* section on a network blip.
*/
suspend fun probeAvailability(): Boolean? =
when (val res = apiCall { network.periodicalsApi().health() }) {
is ApiResult.Success -> true
is ApiResult.Failure ->
if (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) false else null
}

/**
* Apply a probe result, guarded against instance switches: a late response from the
* previous instance (the user tapped "change library" mid-flight) must not resurrect
* or clobber the flag of the instance now configured. Null keeps the last-known value.
*/
fun applyAvailability(available: Boolean?, probedInstanceUrl: String?): Boolean {
if (available == null) return false
if (probedInstanceUrl == null || session.instanceUrl != probedInstanceUrl) return false
features.setPeriodicalsAvailable(available)
return true
}

/** One page of mastheads, optionally filtered by free text and/or type. */
suspend fun periodicals(
query: String? = null,
type: String? = null,
cursor: String? = null,
limit: Int? = null,
): ApiResult<PeriodicalsPage> =
when (val res = apiCall { network.periodicalsApi().periodicals(query, type, cursor, limit) }) {
is ApiResult.Success ->
ApiResult.Success(PeriodicalsPage(res.data, res.meta?.nextCursor), res.meta)
is ApiResult.Failure -> res
}

suspend fun periodical(id: Int): ApiResult<PeriodicalDetail> =
apiCall { network.periodicalsApi().periodical(id) }

suspend fun yearIssues(yearId: Int): ApiResult<List<PeriodicalIssue>> =
apiCall { network.periodicalsApi().yearIssues(yearId) }

suspend fun issue(id: Int): ApiResult<PeriodicalIssueDetail> =
apiCall { network.periodicalsApi().issue(id) }

/**
* A periodicals endpoint answered 404: re-probe the plugin health and, when the plugin
* is confirmed gone, flip the feature flag so every entry point hides immediately.
* Returns true when the plugin is really unavailable (vs a single missing resource).
*/
suspend fun confirmGone(): Boolean {
val instance = session.instanceUrl
val available = probeAvailability()
// Only treat the plugin as gone when the probe actually applied to the still-current
// instance — a stale 404 from a since-switched instance must not drive pluginGone.
val applied = applyAvailability(available, instance)
return applied && available == false
}
}
Loading
Loading